From fdf7a0fc50ca38f04776881be9d01d117e906f0a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 10:58:54 -0700 Subject: [PATCH 01/62] fix(reorg): honor Litecoin testnet depth and halt status --- src/api.js | 23 ++++++++++- src/client/sync.js | 9 +++-- src/config.js | 11 ++++++ .../19_small_branches.test.js | 30 +++++++++++++- test/unit/replica_freshness.test.js | 39 +++++++++++++++++++ 5 files changed, 106 insertions(+), 6 deletions(-) diff --git a/src/api.js b/src/api.js index 61dc862e..4176c673 100644 --- a/src/api.js +++ b/src/api.js @@ -181,6 +181,26 @@ function applyReplicaFreshness(row, pollerStatus){ return row; } +async function applyProtocolHaltFreshness(row, db, dbType){ + if(!db || typeof db.getActiveHalt !== 'function'){ + row.replica_halted = null; + return row; + } + try { + row.replica_halted = !!(await db.getActiveHalt(dbType)); + } catch(e){ + row.replica_halted = null; + row.replica_stale = true; + row.lag_blocks = null; + return row; + } + if(row.replica_halted){ + row.replica_stale = true; + row.lag_blocks = null; + } + return row; +} + // One /health databases[] row for a chain, and the verdict it implies. // // Module-scope and exported for the same reason buildStatusRow is: this is the @@ -266,6 +286,7 @@ async function buildStatusRow(syncService, db, dbType, chain, network){ ? pollerStatus.poll_error_count : 0 }; applyReplicaFreshness(row, pollerStatus); + await applyProtocolHaltFreshness(row, db, dbType); if(dbType === 'decoder'){ row.block_hash = hashRow ? hashRow.block_hash : null; } else { @@ -1254,5 +1275,5 @@ if(require.main === module){ startApi(); } -module.exports = { trustProxyHops, snapshotKey, createRateLimiters, applyReplicaFreshness, +module.exports = { trustProxyHops, snapshotKey, createRateLimiters, applyReplicaFreshness, applyProtocolHaltFreshness, buildHealthEntry, healthEntryDegraded, buildStatusRow, startApi }; diff --git a/src/client/sync.js b/src/client/sync.js index e4a6f30a..d3de6974 100644 --- a/src/client/sync.js +++ b/src/client/sync.js @@ -96,6 +96,9 @@ class ClientSync { this.hashVerifier = hashVerifier; this.config = config; this.util = util; + this.maxRollbackDepth = envConfig.resolveMaxRollbackDepth( + this.chain, this.network, this.config['MAX_ROLLBACK_DEPTH'], + this.config['MAX_ROLLBACK_DEPTH_EXPLICIT']); // Independent block-hash recomputation (true byzantine / replication- // integrity detection). Verifies the replicated raw rows actually hash to // the committed hash, rather than trusting verbatim-replicated hashes. @@ -205,7 +208,7 @@ class ClientSync { // misconfigured small depth can't quietly strand the replica. (depth 0 = full- // history replica, not truncated, so it is exempt.) if(this._truncatedDepth >= 1){ - let maxRollback = Number(this.config['MAX_ROLLBACK_DEPTH']); + let maxRollback = Number(this.maxRollbackDepth); if(!Number.isFinite(maxRollback) || maxRollback < 1) maxRollback = 100; if(this._truncatedDepth <= maxRollback){ let clamped = maxRollback + 1; @@ -3891,7 +3894,7 @@ class ClientSync { if(this.lastAppliedBlock !== null){ let depth = this.lastAppliedBlock - event.block_index + 1; - if(depth > this.config['MAX_ROLLBACK_DEPTH']){ + if(depth > this.maxRollbackDepth){ // A reorg too deep to roll back safely must FAIL CLOSED, not fail open. // Returning bare here would leave lastAppliedBlock pointing at the now- // orphaned tip: every canonical block the source re-streams from @@ -3904,7 +3907,7 @@ class ClientSync { // a durable halt via the same contract used for consensus divergence and let // the operator investigate/clear, rather than advancing onto the fork. await this.haltOnDivergence(event.block_index, - [{ field: 'rollback_depth', depth, max: this.config['MAX_ROLLBACK_DEPTH'] }], + [{ field: 'rollback_depth', depth, max: this.maxRollbackDepth }], this.sources.slice(0, 1), 'max-rollback-depth-exceeded'); return; // halted: no rollback, lastAppliedBlock left as-is, no further applies } diff --git a/src/config.js b/src/config.js index 858146b2..bb970bba 100644 --- a/src/config.js +++ b/src/config.js @@ -69,6 +69,15 @@ function bootstrapDepthKey(chain, network){ return String(ticker).toUpperCase() + ':' + String(network).toUpperCase(); } +function resolveMaxRollbackDepth(chain, network, configuredDepth, explicitOverride){ + const configured = parseIntMin1(configuredDepth, 100) + if(explicitOverride === true) return configured + if(explicitOverride !== false) return configured + return coinTicker(String(chain)) === 'LTC' && String(network).toLowerCase() === 'testnet' + ? 5000 + : configured +} + // Canonical key for a SYNC_BOOTSTRAP_DEPTH__ env name, or null when // the suffix has no CHAIN_NETWORK shape at all (e.g. SYNC_BOOTSTRAP_DEPTH_BADKEY). // An unrecognized chain still yields a key: it is a real key that matches no chain, @@ -164,6 +173,7 @@ module.exports = { bootstrapDepthEnvKey, unmatchedBootstrapDepthKeys, assertBootstrapDepthChains, + resolveMaxRollbackDepth, getConfig: function(){ let config = {}; @@ -326,6 +336,7 @@ module.exports = { // Security: Maximum rollback depth from a single source (blocks) config['MAX_ROLLBACK_DEPTH'] = parseIntMin1(process.env.MAX_ROLLBACK_DEPTH, 100); + config['MAX_ROLLBACK_DEPTH_EXPLICIT'] = process.env.MAX_ROLLBACK_DEPTH !== undefined; // Security: Reject blocks on cross-source verification timeout (instead of applying from primary) config['HASH_CONFIRM_STRICT'] = (process.env.HASH_CONFIRM_STRICT || '').toLowerCase() === 'true'; diff --git a/test/unit/client_sync_io.test/19_small_branches.test.js b/test/unit/client_sync_io.test/19_small_branches.test.js index 4735e827..2f07b803 100644 --- a/test/unit/client_sync_io.test/19_small_branches.test.js +++ b/test/unit/client_sync_io.test/19_small_branches.test.js @@ -29,7 +29,7 @@ function createMockApplier(){ applyIncrementalSnapshot: sinon.stub().resolves() }; } function createMockRollback(){ return { rollback: sinon.stub().resolves() }; } -function makeSync(configOverrides, dbOverrides){ +function makeSync(configOverrides, dbOverrides, identity){ let db = createMockDb(dbOverrides), applier = createMockApplier(), rb = createMockRollback(); let hv = new HashVerifier(), util = new Utility(); let config = Object.assign({ @@ -37,7 +37,8 @@ function makeSync(configOverrides, dbOverrides){ HASH_CONFIRM_TIMEOUT: 5000, SNAPSHOT_MAX_CONTENT: 200 * 1024 * 1024, WS_MAX_PAYLOAD: 50 * 1024 * 1024, MAX_ROLLBACK_DEPTH: 10, GAP_LOG_INTERVAL_MS: 30000 }, configOverrides || {}); - let sync = new ClientSync('bitcoin', 'mainnet', withDbMixins(db), applier, rb, hv, config, util); + identity = identity || { chain: 'bitcoin', network: 'mainnet' }; + let sync = new ClientSync(identity.chain, identity.network, withDbMixins(db), applier, rb, hv, config, util); return { sync, db, applier, rb, hv, util, config }; } @@ -200,6 +201,31 @@ describe('ClientSync: small branches', function(){ assert.strictEqual(sync.getHaltInfo().reason, 'max-rollback-depth-exceeded'); assert.strictEqual(db.recordHalt.firstCall.args[0], 'decoder'); }); +}); + +describe('ClientSync: litecoin testnet reorg depth', function(){ + beforeEach(setupSmallBranchTest); + afterEach(function(){ sinon.restore(); }); + + it('rolls back a 134-block litecoin testnet reorg under the network default', async function(){ + let rb; + ({ sync, rb } = makeSync( + { MAX_ROLLBACK_DEPTH: 100, MAX_ROLLBACK_DEPTH_EXPLICIT: false }, + { dbType: 'decoder' }, + { chain: 'litecoin', network: 'testnet' })); + sync.lastAppliedBlock = 1000; + + await sync.handleReorg({ type: 'reorg', block_index: 867 }); + + assert.strictEqual(sync.maxRollbackDepth, 5000); + assert.strictEqual(rb.rollback.calledOnceWith(867), true); + assert.strictEqual(sync.isHalted(), false); + }); +}); + +describe('ClientSync: small branches', function(){ + beforeEach(setupSmallBranchTest); + afterEach(function(){ sinon.restore(); }); // Stress-sweep 2026-07-08: a reorg ABOVE the tip must be a no-op, never a cursor // advance. depth = tip - block_index + 1 goes <= 0 above the tip, so it never trips diff --git a/test/unit/replica_freshness.test.js b/test/unit/replica_freshness.test.js index 04fa618b..ef4c4db8 100644 --- a/test/unit/replica_freshness.test.js +++ b/test/unit/replica_freshness.test.js @@ -93,6 +93,19 @@ describe('/status replication freshness', function(){ }); }); +describe('per-chain rollback depth', function(){ + it('raises only litecoin testnet to the public-testnet rollback window', function(){ + assert.strictEqual(config.resolveMaxRollbackDepth('litecoin', 'testnet', 100, false), 5000); + assert.strictEqual(config.resolveMaxRollbackDepth('LTC', 'mainnet', 100, false), 100); + assert.strictEqual(config.resolveMaxRollbackDepth('LTC', 'regtest', 100, false), 100); + assert.strictEqual(config.resolveMaxRollbackDepth('bitcoin', 'testnet', 100, false), 100); + }); + + it('preserves an explicit operator override', function(){ + assert.strictEqual(config.resolveMaxRollbackDepth('litecoin', 'testnet', 250, true), 250); + }); +}); + describe('/status replication freshness', function(){ // A follower's own lag_blocks is computed against a height its SOURCE published. @@ -128,3 +141,29 @@ describe('/status replication freshness', function(){ }); }); }); + +describe('/status server row carries a protocol-client halt', function(){ + afterEach(function(){ sinon.restore(); }); + + it('marks a durable halt stale even when native SQL replication is fresh', async function(){ + let prior = process.env.SYNC_MODE; + process.env.SYNC_MODE = 'server'; + let { buildStatusRow } = proxyquire('../../src/api', {}); + if(prior === undefined) delete process.env.SYNC_MODE; else process.env.SYNC_MODE = prior; + let db = mockDb(); + db.getActiveHalt = sinon.stub().resolves({ block_index: 99, reason: 'rollback-depth-exceeded' }); + let service = { + getBroadcaster: () => ({ + getStatus: () => ({ block_height: 100, source_block_height: 100, + replica_stale: false, replica_seconds_behind: 0 }), + getSubscribers: () => [] + }), + getSnapshotBuilder: () => null + }; + + let row = await buildStatusRow(service, db, 'decoder', 'litecoin', 'testnet'); + assert.strictEqual(row.replica_halted, true); + assert.strictEqual(row.replica_stale, true); + assert.strictEqual(row.lag_blocks, null); + }); +}); From 7d0ea1c275b1f62c909593bb33cd863b507b3a3b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 08:47:22 -0700 Subject: [PATCH 02/62] test(e2e): TRUST_PROXY forwarded headers have a regression test and the decoder lifecycle harness uses the shared server helper --- .../helpers/decoder_lifecycle_harness.js | 173 ++---------------- test/e2e/helpers/serverProcess.js | 30 +-- test/e2e/server_process_trust_proxy.test.js | 79 ++++++++ 3 files changed, 110 insertions(+), 172 deletions(-) create mode 100644 test/e2e/server_process_trust_proxy.test.js diff --git a/test/e2e/decoder_lifecycle.test/helpers/decoder_lifecycle_harness.js b/test/e2e/decoder_lifecycle.test/helpers/decoder_lifecycle_harness.js index ac2b02ea..2686b90a 100644 --- a/test/e2e/decoder_lifecycle.test/helpers/decoder_lifecycle_harness.js +++ b/test/e2e/decoder_lifecycle.test/helpers/decoder_lifecycle_harness.js @@ -38,31 +38,18 @@ 'use strict'; -const http = require('http'); -const express = require('express'); -const cors = require('cors'); -const { parseCorsOrigin } = require('../../../../src/http/cors_origin'); -const WebSocket = require('ws'); const sinon = require('sinon'); -const Database = require('../../../../src/db'); -const ServerPoller = require('../../../../src/server/poller'); -const BlockBroadcaster = require('../../../../src/server/block_broadcaster'); -const SnapshotBuilder = require('../../../../src/server/snapshot_builder'); -const ClientSync = require('../../../../src/client/sync'); -const ClientApplier = require('../../../../src/client/applier'); -const ClientRollback = require('../../../../src/client/rollback'); -const HashVerifier = require('../../../../src/client/hash_verifier'); -const Utility = require('../../../../src/util'); -// Proxy-trust and rate-limiter wiring comes from the real api.js rather than a -// parallel copy: how req.ip resolves and which limiter guards which route are -// production decisions, and a harness that re-declares them cannot notice when -// production drifts. api.js guards its env check and listen() behind -// require.main === module, so requiring it here opens no port. -const { trustProxyHops, createRateLimiters } = require('../../../../src/api'); +const Database = require('../../../../src/db'); +const ClientSync = require('../../../../src/client/sync'); +const ClientApplier = require('../../../../src/client/applier'); +const ClientRollback = require('../../../../src/client/rollback'); +const HashVerifier = require('../../../../src/client/hash_verifier'); +const Utility = require('../../../../src/util'); const decoderFixtures = require('../../helpers/decoderFixtures'); const { getMariadb } = require('../../helpers/mariadbLoader'); +const ServerProcess = require('../../helpers/serverProcess'); const SOURCE_HOST = process.env.E2E_DB_HOST || '127.0.0.1'; const SOURCE_PORT = parseInt(process.env.E2E_DB_PORT) || 23306; @@ -101,128 +88,12 @@ const SERVER_CONFIG = { TRANSPARENCY_RATE_LIMIT: 100000 }; -function mountStatusRoute(app, sourceDb){ - let validateDbType = (dt) => (dt === 'indexer' || dt === 'decoder') ? dt : null; - app.get('/status/:dbType/:chain/:network', async (req, res) => { - let dbType = validateDbType(req.params.dbType); - if(!dbType) return res.status(400).json({ error: 'Invalid dbType' }); - if(req.params.chain !== CHAIN || req.params.network !== NETWORK) - return res.status(404).json({ error: 'Chain/network not found' }); - try { - let last = await sourceDb.getLastBlock(); - let row = last !== null ? await sourceDb.getBlockHashRow(last) : null; - let body = { - chain: req.params.chain, - network: req.params.network, - dbType: dbType, - block_height: row ? Number(row.block_index) : null, - block_time: row ? Number(row.block_time) : null - }; - if(dbType === 'decoder'){ - body.block_hash = row ? row.block_hash : null; - } else { - body.ledger_hash = row ? row.ledger_hash : null; - body.actions_hash = row ? row.actions_hash : null; - body.contract_hash = row ? row.contract_hash : null; - } - res.json(body); - } catch(e){ - res.status(500).json({ error: e.message }); - } - }); -} - -function mountSchemaRoute(app, sourceDb){ - app.get('/schema/:dbType/:chain/:network', async (req, res) => { - try { - let tables = await sourceDb.doQuery( - "SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE' ORDER BY table_name", - [sourceDb.dbName] - ); - let schema = {}; - for(let row of tables){ - let tn = row.table_name || row.TABLE_NAME; - let ddl = await sourceDb.doQuery("SHOW CREATE TABLE `" + tn + "`"); - if(ddl.length > 0) schema[tn] = ddl[0]['Create Table']; - } - res.json({ chain: req.params.chain, network: req.params.network, dbType: req.params.dbType, tables: schema }); - } catch(e){ - res.status(500).json({ error: e.message }); - } - }); -} - -function mountSnapshotRoutes(app, sourceDb, snapshotBuilder, limiters){ - app.get('/snapshot/:dbType/:chain/:network', limiters.fullSnapshotLimiter, async (req, res) => { - try { - await snapshotBuilder.streamFullSnapshot(sourceDb, res); - } catch(e){ - if(!res.headersSent) res.status(500).json({ error: e.message }); - } - }); - - app.get('/snapshot/:dbType/:chain/:network/since/:blockHeight', limiters.incrSnapshotLimiter, async (req, res) => { - let since = parseInt(req.params.blockHeight); - if(isNaN(since) || since < 0) return res.status(400).json({ error: 'Invalid blockHeight' }); - try { - await snapshotBuilder.streamIncrementalSnapshot(sourceDb, since, res); - } catch(e){ - if(!res.headersSent) res.status(500).json({ error: e.message }); - } - }); -} - -function mountTransparencyRoute(app, limiters){ - // Transparency is indexer-only; decoder requests must return 400. - app.get('/transparency/:dbType/:chain/:network/roots', limiters.transparencyLimiter, (req, res) => { - if(req.params.dbType !== 'indexer') - return res.status(400).json({ error: 'Transparency log is indexer-only' }); - res.json({ entries: [] }); - }); -} - -// Build the mini HTTP+WS server that mirrors src/api.js for decoder -// surface. Reuses real BlockBroadcaster + SnapshotBuilder + ServerPoller -// so the test exercises actual Phase 3 code paths. -function buildServer(sourceDb, broadcaster, snapshotBuilder, cfg){ - let app = express(); - // Must precede the limiters, which read req.ip: same ordering requirement - // startApi() has. - app.set('trust proxy', trustProxyHops(cfg['TRUST_PROXY'])); - app.use(cors({ origin: parseCorsOrigin(process.env.CORS_ORIGIN), methods: ['GET'] })); - - // The limiter instances startApi() mounts, on the routes it guards. - let limiters = createRateLimiters(cfg); - app.use(limiters.backstopLimiter); - mountStatusRoute(app, sourceDb); - mountSchemaRoute(app, sourceDb); - mountSnapshotRoutes(app, sourceDb, snapshotBuilder, limiters); - mountTransparencyRoute(app, limiters); - - let server = http.createServer(app); - let wss = new WebSocket.Server({ noServer: true }); - server.on('upgrade', (request, socket, head) => { - let m = request.url.match(/^\/subscribe\/([^\/]+)\/([^\/]+)\/([^\/\?]+)/); - if(!m){ socket.destroy(); return; } - let [, dbType, chain, network] = m; - if(dbType !== 'indexer' && dbType !== 'decoder'){ socket.destroy(); return; } - wss.handleUpgrade(request, socket, head, (ws) => { - broadcaster.addSubscription(ws, request, chain, network, 'full', dbType); - }); - }); - return server; -} - class DecoderLifecycle { constructor(assignState){ this.assignState = assignState; this.sourceDb = null; this.replicaDb = null; - this.broadcaster = null; - this.snapshotBuilder = null; - this.poller = null; - this.server = null; - this.pollInterval = null; + this.serverProcess = null; this.client = null; } @@ -272,18 +143,14 @@ class DecoderLifecycle { async teardown(){ sinon.restore(); if(this.client) this.client.stop(); - if(this.pollInterval) clearInterval(this.pollInterval); - if(this.poller) this.poller.stop(); - if(this.server) await new Promise(r => this.server.close(r)); + if(this.serverProcess) await this.serverProcess.stop(); if(this.sourceDb) await this.sourceDb.close(); if(this.replicaDb) await this.replicaDb.close(); } async reset(){ if(this.client) { this.client.stop(); this.client = null; } - if(this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; } - if(this.poller) { this.poller.stop(); this.poller = null; } - if(this.server) { await new Promise(r => this.server.close(r)); this.server = null; } + if(this.serverProcess) { await this.serverProcess.stop(); this.serverProcess = null; } await decoderFixtures.truncateAll(this.sourceDb); await decoderFixtures.truncateAll(this.replicaDb); this.publish(); @@ -297,23 +164,9 @@ class DecoderLifecycle { } async startServer(overrides){ - // One config object for the broadcaster, the poller and the app, so a - // test that flips TRUST_PROXY moves both budget surfaces at once, the - // way a deployment does. - let cfg = Object.assign({}, SERVER_CONFIG, overrides); - - this.broadcaster = new BlockBroadcaster(cfg); - this.snapshotBuilder = new SnapshotBuilder(util); - this.poller = new ServerPoller(CHAIN, NETWORK, this.sourceDb, this.broadcaster, null, cfg, util); - this.server = buildServer(this.sourceDb, this.broadcaster, this.snapshotBuilder, cfg); - await new Promise(r => this.server.listen(SERVER_PORT, r)); - - // Drive the poller manually; match the indexer e2e harness pattern. - this.poller.lastPolledBlock = await this.sourceDb.getLastBlock(); - await this.poller.updateStatus(); - this.pollInterval = setInterval(async () => { - try { await this.poller.poll(); } catch(e){} - }, 200); + this.serverProcess = new ServerProcess(this.sourceDb, SERVER_PORT, CHAIN, NETWORK); + Object.assign(this.serverProcess.config, SERVER_CONFIG, overrides); + await this.serverProcess.start(); } makeClient(){ diff --git a/test/e2e/helpers/serverProcess.js b/test/e2e/helpers/serverProcess.js index aae2a268..37cd034e 100644 --- a/test/e2e/helpers/serverProcess.js +++ b/test/e2e/helpers/serverProcess.js @@ -31,6 +31,7 @@ class ServerProcess { this.port = port; this.chain = chain || 'bitcoin'; this.network = network || 'mainnet'; + this.dbType = sourceDb.dbType || 'indexer'; this.config = { SYNC_MODE: 'server', @@ -54,6 +55,7 @@ class ServerProcess { TRANSPARENCY_RATE_LIMIT: 100000 }; + this.app = null; this.server = null; this.wss = null; this.broadcaster = null; @@ -74,7 +76,7 @@ class ServerProcess { async start() { this.broadcaster = new BlockBroadcaster(this.config); - this.log = new TransparencyLog(this.sourceDb); + this.log = this.dbType === 'indexer' ? new TransparencyLog(this.sourceDb) : null; this.snapshotBuilder = new SnapshotBuilder(testDb.util); this.poller = new ServerPoller( this.chain, this.network, this.sourceDb, @@ -82,6 +84,7 @@ class ServerProcess { ); let app = express(); + this.app = app; // Must precede the limiters below (they read req.ip): same ordering // requirement as api.js's startApi(). Deriving from trustProxyHops // rather than a re-declared literal is the whole point of this seam: @@ -101,8 +104,8 @@ class ServerProcess { // backs the e2e suite, so its surface needs to match the real API // after the Phase 3 path migration; otherwise the suite would // either 404 on status checks or pass-by-accident on snapshots. - // :dbType is one of 'indexer' or 'decoder'; this helper only seeds - // indexer-shaped data, so 'decoder' requests respond as a stub. + // :dbType is one of 'indexer' or 'decoder'. The source DB type selects + // the matching hash shape for status and polling. let validateDbType = (dt) => (dt === 'indexer' || dt === 'decoder') ? dt : null; @@ -113,15 +116,18 @@ class ServerProcess { let hashRow = lastBlock !== null ? await this.sourceDb.getBlockHashRow(lastBlock) : null; let result = {}; result[this.chain] = {}; - result[this.chain][this.network] = { - indexer: { - block_height: hashRow ? Number(hashRow.block_index) : null, - block_time: hashRow ? Number(hashRow.block_time) : null, - ledger_hash: hashRow ? hashRow.ledger_hash : null, - actions_hash: hashRow ? hashRow.actions_hash : null, - contract_hash:hashRow ? hashRow.contract_hash : null - } + let status = { + block_height: hashRow ? Number(hashRow.block_index) : null, + block_time: hashRow ? Number(hashRow.block_time) : null }; + if (this.dbType === 'decoder') { + status.block_hash = hashRow ? hashRow.block_hash : null; + } else { + status.ledger_hash = hashRow ? hashRow.ledger_hash : null; + status.actions_hash = hashRow ? hashRow.actions_hash : null; + status.contract_hash = hashRow ? hashRow.contract_hash : null; + } + result[this.chain][this.network] = { [this.dbType]: status }; result.last_updated = new Date().toISOString(); res.json(result); } catch (e) { @@ -146,7 +152,7 @@ class ServerProcess { last_updated: new Date().toISOString() }; if (dbType === 'decoder') { - body.block_hash = hashRow ? hashRow.ledger_hash : null; // stub: helper seeds indexer rows + body.block_hash = hashRow ? hashRow.block_hash : null; } else { body.ledger_hash = hashRow ? hashRow.ledger_hash : null; body.actions_hash = hashRow ? hashRow.actions_hash : null; diff --git a/test/e2e/server_process_trust_proxy.test.js b/test/e2e/server_process_trust_proxy.test.js new file mode 100644 index 00000000..53807a3f --- /dev/null +++ b/test/e2e/server_process_trust_proxy.test.js @@ -0,0 +1,79 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert'); +const sinon = require('sinon'); +const ServerProcess = require('./helpers/serverProcess'); + +const SERVER_PORT = 29951; +const FORWARDED_IP = '198.51.100.7'; + +describe('E2E: ServerProcess proxy trust', function() { + + let server; + const sourceDb = { + dbType: 'indexer', + getLastBlock: async () => null, + getReplicaStatus: async () => ({ isReplica: false }) + }; + + before(function() { + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + }); + + after(async function() { + sinon.restore(); + if (server) await server.stop(); + }); + + afterEach(async function() { + if (server) { + await server.stop(); + server = null; + } + }); + + async function requestContext(trustProxy) { + server = new ServerProcess(sourceDb, SERVER_PORT); + if (trustProxy === undefined) delete server.config.TRUST_PROXY; + else server.config.TRUST_PROXY = trustProxy; + await server.start(); + + server.app.get('/request-context', (req, res) => { + res.json({ ip: req.ip, protocol: req.protocol }); + }); + + let response = await fetch(server.getUrl() + '/request-context', { + headers: { + 'x-forwarded-for': FORWARDED_IP, + 'x-forwarded-proto': 'https' + } + }); + assert.strictEqual(response.status, 200); + return response.json(); + } + + it('honours forwarded client and protocol when TRUST_PROXY is true', async function() { + this.timeout(30000); + let context = await requestContext(true); + + assert.strictEqual(context.ip, FORWARDED_IP); + assert.strictEqual(context.protocol, 'https'); + }); + + it('ignores forwarded client and protocol when TRUST_PROXY is unset', async function() { + this.timeout(30000); + let context = await requestContext(undefined); + + assert.notStrictEqual(context.ip, FORWARDED_IP); + assert.strictEqual(context.protocol, 'http'); + }); +}); From cefb9cb32f0ed7772d83df9d9eaa44a0420cf356 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 09:49:29 -0700 Subject: [PATCH 03/62] ci: sibling checkouts follow the pull request's base branch, keeping push, release and hotfix refs unchanged --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 318b0d06..b44f9748 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: uses: XChain-Platform/.github/.github/workflows/ci-reusable.yml@6f4d39ae85787fc31e90a31588d87610a2c33103 # pin: XChain-Platform/.github @ master 2026-08-14; bump deliberately with: # A hotfix or release PR compares against siblings at the same branch, not develop. - siblings-ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || '' }} + siblings-ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || github.base_ref || '' }} # Override the Node version for a repo if ever needed: # node-version: "20" @@ -78,7 +78,7 @@ jobs: - name: Check out declared sibling repositories uses: XChain-Platform/.github/actions/checkout-siblings@master # one definition for every call site; see the action for why this tracks master with: - ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || '' }} + ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || github.base_ref || '' }} - name: Use Node.js 22 uses: actions/setup-node@v4 @@ -154,7 +154,7 @@ jobs: uses: actions/checkout@v4 with: repository: XChain-Platform/xchain-hub - ref: ${{ github.ref == 'refs/heads/master' && 'master' || 'develop' }} + ref: ${{ github.base_ref || (github.ref == 'refs/heads/master' && 'master' || 'develop') }} ssh-key: ${{ secrets.XCHAIN_HUB_DEPLOY_KEY }} path: xchain-hub @@ -214,7 +214,7 @@ jobs: - name: Check out declared sibling repositories uses: XChain-Platform/.github/actions/checkout-siblings@master # one definition for every call site; see the action for why this tracks master with: - ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || '' }} + ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || github.base_ref || '' }} - name: Use Node.js 22 uses: actions/setup-node@v4 From 10c66f9ba2531d11366b40419325ce74ead38116 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 10:27:41 -0700 Subject: [PATCH 04/62] test(sync): split trust-proxy e2e callback under the function length limit --- test/e2e/server_process_trust_proxy.test.js | 112 +++++++++++--------- 1 file changed, 60 insertions(+), 52 deletions(-) diff --git a/test/e2e/server_process_trust_proxy.test.js b/test/e2e/server_process_trust_proxy.test.js index 53807a3f..8740f670 100644 --- a/test/e2e/server_process_trust_proxy.test.js +++ b/test/e2e/server_process_trust_proxy.test.js @@ -15,65 +15,73 @@ const ServerProcess = require('./helpers/serverProcess'); const SERVER_PORT = 29951; const FORWARDED_IP = '198.51.100.7'; -describe('E2E: ServerProcess proxy trust', function() { - - let server; - const sourceDb = { - dbType: 'indexer', - getLastBlock: async () => null, - getReplicaStatus: async () => ({ isReplica: false }) - }; - - before(function() { - sinon.stub(console, 'log'); - sinon.stub(console, 'error'); - }); +let server; +const sourceDb = { + dbType: 'indexer', + getLastBlock: async () => null, + getReplicaStatus: async () => ({ isReplica: false }) +}; + +function stubConsole() { + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); +} + +async function restoreConsoleAndStopServer() { + sinon.restore(); + if (server) await server.stop(); +} + +async function stopServer() { + if (server) { + await server.stop(); + server = null; + } +} + +async function requestContext(trustProxy) { + server = new ServerProcess(sourceDb, SERVER_PORT); + if (trustProxy === undefined) delete server.config.TRUST_PROXY; + else server.config.TRUST_PROXY = trustProxy; + await server.start(); - after(async function() { - sinon.restore(); - if (server) await server.stop(); + server.app.get('/request-context', (req, res) => { + res.json({ ip: req.ip, protocol: req.protocol }); }); - afterEach(async function() { - if (server) { - await server.stop(); - server = null; + let response = await fetch(server.getUrl() + '/request-context', { + headers: { + 'x-forwarded-for': FORWARDED_IP, + 'x-forwarded-proto': 'https' } }); + assert.strictEqual(response.status, 200); + return response.json(); +} - async function requestContext(trustProxy) { - server = new ServerProcess(sourceDb, SERVER_PORT); - if (trustProxy === undefined) delete server.config.TRUST_PROXY; - else server.config.TRUST_PROXY = trustProxy; - await server.start(); - - server.app.get('/request-context', (req, res) => { - res.json({ ip: req.ip, protocol: req.protocol }); - }); - - let response = await fetch(server.getUrl() + '/request-context', { - headers: { - 'x-forwarded-for': FORWARDED_IP, - 'x-forwarded-proto': 'https' - } - }); - assert.strictEqual(response.status, 200); - return response.json(); - } +async function honourForwardedClientAndProtocol() { + this.timeout(30000); + let context = await requestContext(true); - it('honours forwarded client and protocol when TRUST_PROXY is true', async function() { - this.timeout(30000); - let context = await requestContext(true); + assert.strictEqual(context.ip, FORWARDED_IP); + assert.strictEqual(context.protocol, 'https'); +} - assert.strictEqual(context.ip, FORWARDED_IP); - assert.strictEqual(context.protocol, 'https'); - }); +async function ignoreForwardedClientAndProtocol() { + this.timeout(30000); + let context = await requestContext(undefined); - it('ignores forwarded client and protocol when TRUST_PROXY is unset', async function() { - this.timeout(30000); - let context = await requestContext(undefined); + assert.notStrictEqual(context.ip, FORWARDED_IP); + assert.strictEqual(context.protocol, 'http'); +} - assert.notStrictEqual(context.ip, FORWARDED_IP); - assert.strictEqual(context.protocol, 'http'); - }); -}); +function registerProxyTrustTests() { + before(stubConsole); + after(restoreConsoleAndStopServer); + afterEach(stopServer); + + it('honours forwarded client and protocol when TRUST_PROXY is true', honourForwardedClientAndProtocol); + it('ignores forwarded client and protocol when TRUST_PROXY is unset', ignoreForwardedClientAndProtocol); +} + +describe('E2E: ServerProcess proxy trust', registerProxyTrustTests); From c58a79744daa9f455a90eceb4bc264966cad3f9d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 11:29:42 -0700 Subject: [PATCH 05/62] test: split observability suite registration --- bin/pins/suite-title-splits.json | 7 + test/unit/observability.test.js | 784 +----------------- .../observability.test/01_metrics.test.js | 182 ++++ .../observability.test/02_log_shipper.test.js | 365 ++++++++ .../unit/observability.test/03_routes.test.js | 186 +++++ .../04_flush_and_health.test.js | 174 ++++ 6 files changed, 936 insertions(+), 762 deletions(-) create mode 100644 test/unit/observability.test/01_metrics.test.js create mode 100644 test/unit/observability.test/02_log_shipper.test.js create mode 100644 test/unit/observability.test/03_routes.test.js create mode 100644 test/unit/observability.test/04_flush_and_health.test.js diff --git a/bin/pins/suite-title-splits.json b/bin/pins/suite-title-splits.json index 94933440..371f71b2 100644 --- a/bin/pins/suite-title-splits.json +++ b/bin/pins/suite-title-splits.json @@ -169,6 +169,13 @@ "test/unit/hub_client.test/09_parse_endpoints.test.js", "test/unit/hub_client.test/10_credential_tier.test.js" ], + "test/unit/observability.test.js": [ + "test/unit/observability.test.js", + "test/unit/observability.test/01_metrics.test.js", + "test/unit/observability.test/02_log_shipper.test.js", + "test/unit/observability.test/03_routes.test.js", + "test/unit/observability.test/04_flush_and_health.test.js" + ], "test/unit/server_poller.test.js": [ "test/unit/server_poller.test.js", "test/unit/server_poller.test/01_resume_cursor_restart_resume_regression.test.js", diff --git a/test/unit/observability.test.js b/test/unit/observability.test.js index accfde0c..b08350dd 100644 --- a/test/unit/observability.test.js +++ b/test/unit/observability.test.js @@ -39,6 +39,13 @@ const { installObservability, readObservabilityEnv, routeLabel } = require('../../src/observability/index.js'); +const { registerMetricsTests } = require('./observability.test/01_metrics.test.js'); +const { + registerLogShipperTests, registerTextFormatTests, registerMessageRedactionTests +} = require('./observability.test/02_log_shipper.test.js'); +const { registerRouteTests } = require('./observability.test/03_routes.test.js'); +const { registerFlushAndHealthTests } = require('./observability.test/04_flush_and_health.test.js'); + // A console-shaped sink so tests never write to the mocha output. function fakeConsole() { const lines = { log: [], warn: [], error: [] }; @@ -61,364 +68,20 @@ async function listen(app) { }; } -describe('observability/metrics: exposition format', function () { - - it('renders a counter with HELP, TYPE and labelled samples', function () { - const reg = new Registry(); - const c = reg.counter({ name: 'test_requests_total', help: 'Requests', labelNames: ['route'] }); - c.inc({ route: '/a' }, 2); - c.inc({ route: '/a' }); - c.inc({ route: '/b' }); - - const out = reg.render(); - expect(out).to.include('# HELP test_requests_total Requests'); - expect(out).to.include('# TYPE test_requests_total counter'); - expect(out).to.include('test_requests_total{route="/a"} 3'); - expect(out).to.include('test_requests_total{route="/b"} 1'); - expect(out.endsWith('\n')).to.equal(true); - }); - - it('rejects invalid metric and label names at declaration', function () { - const reg = new Registry(); - expect(() => reg.counter({ name: '9bad', help: 'x' })).to.throw(/invalid metric name/); - expect(() => reg.counter({ name: 'ok_total', help: 'x', labelNames: ['bad-label'] })).to.throw(/invalid label name/); - expect(() => reg.counter({ name: 'ok2_total', help: 'x', labelNames: ['__name__'] })).to.throw(/reserved/); - }); - - it('hands back the same metric when an identical declaration repeats', function () { - // Modules register their counters wherever they are required, and the - // registry is now process-wide, so an identical re-declaration is a - // normal event rather than a conflict. - const reg = new Registry(); - const first = reg.gauge({ name: 'dup_gauge', help: 'x' }); - expect(reg.gauge({ name: 'dup_gauge', help: 'y' })).to.equal(first); - }); - - it('still refuses a duplicate name declared with a different shape', function () { - const reg = new Registry(); - reg.gauge({ name: 'shape_clash', help: 'x' }); - expect(() => reg.counter({ name: 'shape_clash', help: 'x' })).to.throw(/different shape/); - reg.gauge({ name: 'label_clash', help: 'x', labelNames: ['a'] }); - expect(() => reg.gauge({ name: 'label_clash', help: 'x', labelNames: ['b'] })).to.throw(/different shape/); - }); - - it('rejects a negative counter increment and an unknown label', function () { - const reg = new Registry(); - const c = reg.counter({ name: 'neg_total', help: 'x', labelNames: ['a'] }); - expect(() => c.inc({ a: '1' }, -1)).to.throw(/non-negative/); - expect(() => c.inc({ b: '1' }, 1)).to.throw(/unknown label/); - }); - - it('escapes backslash, quote and newline in label values and help', function () { - const reg = new Registry(); - const c = reg.counter({ name: 'esc_total', help: 'line1\nline2 \\ end', labelNames: ['v'] }); - c.inc({ v: 'a"b\\c\nd' }); - const out = reg.render(); - expect(out).to.include('# HELP esc_total line1\\nline2 \\\\ end'); - expect(out).to.include('esc_total{v="a\\"b\\\\c\\nd"} 1'); - // No raw newline may appear inside a sample line. - for (const line of out.trim().split('\n')) expect(line).to.not.equal(''); - }); - - it('treats label order as declared order, not caller order', function () { - const reg = new Registry(); - const c = reg.counter({ name: 'order_total', help: 'x', labelNames: ['a', 'b'] }); - c.inc({ a: '1', b: '2' }); - c.inc({ b: '2', a: '1' }); - expect(c.get({ a: '1', b: '2' })).to.equal(2); - expect(reg.render().split('\n').filter((l) => l.startsWith('order_total{')).length).to.equal(1); - }); - - it('emits cumulative histogram buckets with +Inf equal to _count', function () { - const reg = new Registry(); - const h = reg.histogram({ name: 'lat_seconds', help: 'x', labelNames: ['route'], buckets: [0.1, 0.5, 1] }); - h.observe({ route: '/a' }, 0.05); - h.observe({ route: '/a' }, 0.3); - h.observe({ route: '/a' }, 2); +const testContext = { + expect, express, http, + Registry, Counter, Gauge, Histogram, collectDefaultMetrics, + createLogShipper, readLogEnv, redactFields, scrubMessage, REDACTED, + installObservability, readObservabilityEnv, routeLabel, + fakeConsole, listen +}; - const out = reg.render(); - expect(out).to.include('# TYPE lat_seconds histogram'); - expect(out).to.include('lat_seconds_bucket{route="/a",le="0.1"} 1'); - expect(out).to.include('lat_seconds_bucket{route="/a",le="0.5"} 2'); - expect(out).to.include('lat_seconds_bucket{route="/a",le="1"} 2'); - expect(out).to.include('lat_seconds_bucket{route="/a",le="+Inf"} 3'); - expect(out).to.include('lat_seconds_count{route="/a"} 3'); - expect(out).to.include('lat_seconds_sum{route="/a"} 2.35'); - }); - - it('ignores a non-finite histogram observation instead of poisoning the sum', function () { - const reg = new Registry(); - const h = reg.histogram({ name: 'nan_seconds', help: 'x', buckets: [1] }); - h.observe({}, Number.NaN); - h.observe({}, Infinity); - h.observe({}, 0.5); - expect(h.get({}).count).to.equal(1); - expect(h.get({}).sum).to.equal(0.5); - }); - - it('reserves le for histogram buckets', function () { - const reg = new Registry(); - expect(() => reg.histogram({ name: 'le_seconds', help: 'x', labelNames: ['le'] })).to.throw(/reserved/); - }); - - it('caps series per metric and counts the drops instead of growing', function () { - const reg = new Registry({ maxSeries: 3 }); - const c = reg.counter({ name: 'card_total', help: 'x', labelNames: ['id'] }); - for (let i = 0; i < 10; i++) c.inc({ id: `id-${i}` }); - expect(c.series.size).to.equal(3); - expect(reg.get('xchain_metrics_series_dropped_total').get({ metric: 'card_total' })).to.equal(7); - expect(reg.render()).to.include('xchain_metrics_series_dropped_total{metric="card_total"} 7'); - }); - - it('gauge set/inc/dec track a value and reject a non-finite set', function () { - const reg = new Registry(); - const g = reg.gauge({ name: 'depth', help: 'x' }); - g.set({}, 5); - g.inc({}, 2); - g.dec({}, 3); - expect(g.get({})).to.equal(4); - expect(() => g.set({}, Number.NaN)).to.throw(/finite/); - }); - - it('setMonotonic never lets a collector-driven counter go backwards', function () { - const reg = new Registry(); - const c = reg.counter({ name: 'cpu_total', help: 'x' }); - c.setMonotonic({}, 10); - c.setMonotonic({}, 4); - expect(c.get({})).to.equal(10); - }); - - it('survives a throwing collector and still renders the rest', function () { - const reg = new Registry(); - reg.counter({ name: 'ok_total', help: 'x' }).inc({}); - reg.addCollector(() => { throw new Error('boom'); }); - expect(reg.render()).to.include('ok_total 1'); - }); - - it('collectDefaultMetrics exposes process and service identity', function () { - const reg = new Registry(); - collectDefaultMetrics(reg, { service: 'xchain-sync', version: '1.2.3', coin: 'BTC', network: 'regtest' }); - const out = reg.render(); - expect(out).to.include('xchain_service_info{service="xchain-sync",version="1.2.3",coin="BTC",network="regtest"'); - expect(out).to.match(/process_resident_memory_bytes \d+/); - expect(out).to.match(/process_cpu_user_seconds_total [\d.]+/); - expect(out).to.include('# TYPE process_cpu_user_seconds_total counter'); - expect(out).to.match(/nodejs_heap_size_used_bytes \d+/); - }); - - it('exports the Prometheus content type', function () { - expect(new Registry().contentType()).to.equal('text/plain; version=0.0.4; charset=utf-8'); - }); - - it('metric classes are usable standalone', function () { - expect(new Counter({ name: 'a_total', help: 'x' }).inc({}, 2)).to.equal(2); - const g = new Gauge({ name: 'b', help: 'x' }); g.set({}, 1); expect(g.get({})).to.equal(1); - const h = new Histogram({ name: 'c', help: 'x' }); h.observe({}, 1); expect(h.get({}).count).to.equal(1); - }); +describe('observability/metrics: exposition format', function () { + registerMetricsTests(testContext); }); describe('observability/logShipper', function () { - - it('is inert by default: text output, no buffering, no shipping', function () { - const sink = fakeConsole(); - const log = createLogShipper({ service: 'svc', env: {}, console: sink }); - log.info('hello world'); - expect(log.config.shipEnabled).to.equal(false); - expect(log.buffer.length).to.equal(0); - expect(log.timer).to.equal(null); - expect(sink.lines.log).to.have.lengthOf(1); - expect(sink.lines.log[0]).to.match(/^\S+Z info \[svc\] hello world$/); - }); - - it('emits NDJSON with the envelope keys when LOG_FORMAT=json', function () { - const sink = fakeConsole(); - const log = createLogShipper({ service: 'xchain-sync', version: '9.9.9', env: { LOG_FORMAT: 'json' }, console: sink }); - log.warn('block stalled', { height: 42 }); - const rec = JSON.parse(sink.lines.warn[0]); - expect(rec.level).to.equal('warn'); - expect(rec.service).to.equal('xchain-sync'); - expect(rec.msg).to.equal('block stalled'); - expect(rec.height).to.equal(42); - expect(rec.version).to.equal('9.9.9'); - expect(new Date(rec.ts).toISOString()).to.equal(rec.ts); - }); - - it('honours LOG_LEVEL and drops quieter levels', function () { - const sink = fakeConsole(); - const log = createLogShipper({ service: 'svc', env: { LOG_LEVEL: 'warn' }, console: sink }); - expect(log.info('quiet')).to.equal(null); - expect(log.error('loud')).to.not.equal(null); - expect(sink.lines.log.length).to.equal(0); - }); - - it('redacts credential-shaped field keys and inline key=value pairs', function () { - const sink = fakeConsole(); - const log = createLogShipper({ service: 'svc', env: { LOG_FORMAT: 'json' }, console: sink }); - const rec = log.info('connect password=hunter2 then api_key: abc123', { - db: { user: 'app', password: 'hunter2' }, - HUB_API_KEY: 'zzz', - height: 7 - }); - expect(rec.db.password).to.equal(REDACTED); - expect(rec.db.user).to.equal('app'); - expect(rec.HUB_API_KEY).to.equal(REDACTED); - expect(rec.height).to.equal(7); - expect(rec.msg).to.not.include('hunter2'); - expect(rec.msg).to.not.include('abc123'); - expect(JSON.stringify(rec)).to.not.include('hunter2'); - }); - - it('never lets a caller field forge the record envelope', function () { - const log = createLogShipper({ service: 'real-svc', env: {}, console: fakeConsole() }); - const rec = log.info('m', { service: 'spoofed', level: 'debug', msg: 'spoofed' }); - expect(rec.service).to.equal('real-svc'); - expect(rec.level).to.equal('info'); - expect(rec.msg).to.equal('m'); - }); - - it('handles cyclic and deep field graphs without throwing', function () { - const a = { name: 'a' }; - a.self = a; - expect(redactFields(a).self).to.equal('[circular]'); - expect(redactFields({ a: { b: { c: { d: { e: 1 } } } } }).a.b.c.d).to.equal('[truncated]'); - expect(scrubMessage('token=abc')).to.equal(`token=${REDACTED}`); - }); - - it('serializes an Error field with a scrubbed message', function () { - const out = redactFields({ err: new Error('login failed for password=hunter2') }); - expect(out.err.message).to.include(REDACTED); - expect(out.err.message).to.not.include('hunter2'); - }); - - it('requires BOTH the flag and a valid URL before shipping', function () { - expect(readLogEnv({ LOG_SHIP_ENABLED: '1' }).shipEnabled).to.equal(false); - expect(readLogEnv({ LOG_SHIP_URL: 'https://c/logs' }).shipEnabled).to.equal(false); - expect(readLogEnv({ LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'ftp://c/logs' }).shipEnabled).to.equal(false); - expect(readLogEnv({ LOG_SHIP_ENABLED: 'true', LOG_SHIP_URL: 'https://c/logs' }).shipEnabled).to.equal(true); - }); - - it('batches NDJSON to the transport once the batch size is reached', async function () { - const bodies = []; - const log = createLogShipper({ - service: 'svc', - env: { LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'https://collector.invalid/logs', LOG_SHIP_BATCH_SIZE: '2' }, - console: fakeConsole(), - transport: (body) => { bodies.push(body); return Promise.resolve(); } - }); - log.info('one'); - log.info('two'); - await new Promise((r) => setImmediate(r)); - await log.stop(); - - expect(bodies.length).to.equal(1); - const lines = bodies[0].trim().split('\n').map((l) => JSON.parse(l)); - expect(lines.map((l) => l.msg)).to.deep.equal(['one', 'two']); - expect(log.stats.shipped).to.equal(2); - }); - - it('drops the oldest lines when the buffer is full and counts the loss', function () { - const log = createLogShipper({ - service: 'svc', - env: { - LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'https://collector.invalid/logs', - LOG_SHIP_BATCH_SIZE: '1000', LOG_SHIP_MAX_BUFFER: '3' - }, - console: fakeConsole(), - transport: () => new Promise(() => {}) // never settles: buffer fills - }); - for (let i = 0; i < 6; i++) log.info(`line-${i}`); - expect(log.buffer.length).to.equal(3); - expect(log.stats.dropped).to.equal(3); - expect(log.buffer.map((r) => r.msg)).to.deep.equal(['line-3', 'line-4', 'line-5']); - }); - - it('survives a failing collector, re-queues the batch and rate-limits the stderr note', async function () { - const sink = fakeConsole(); - const log = createLogShipper({ - service: 'svc', - env: { LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'https://collector.invalid/logs', LOG_SHIP_BATCH_SIZE: '1' }, - console: sink, - transport: () => Promise.reject(new Error('ECONNREFUSED')) - }); - log.info('a'); - await log.flush(); - log.info('b'); - await log.flush(); - - expect(log.stats.failures).to.be.greaterThan(0); - expect(log.stats.shipped).to.equal(0); - expect(log.buffer.length).to.be.greaterThan(0); - // One note per minute, so the second failure adds no line. - expect(sink.lines.error.filter((l) => l.includes('[log-ship]')).length).to.equal(1); - await log.stop(); - }); - - it('exposes shipper counters on a registry when one is supplied', function () { - const reg = new Registry(); - const log = createLogShipper({ service: 'svc', env: {}, console: fakeConsole(), registry: reg }); - log.info('x'); - log.error('y'); - const out = reg.render(); - expect(out).to.include('log_lines_emitted_total{level="info"} 1'); - expect(out).to.include('log_lines_emitted_total{level="error"} 1'); - expect(out).to.include('log_ship_buffer_lines 0'); - }); - - it('stop() clears the flush timer so the process can exit', async function () { - const log = createLogShipper({ - service: 'svc', - env: { LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'https://collector.invalid/logs' }, - console: fakeConsole(), - transport: () => Promise.resolve() - }); - expect(log.timer).to.not.equal(null); - await log.stop(); - expect(log.timer).to.equal(null); - }); - - // Exercises the real _post/fetch path. Every other test here injects a - // transport, which is why the unreleased response body below went unseen. - it('releases the response body so a stalled collector cannot pin the socket', async function () { - this.timeout(5000); - let closed = false; - const sockets = new Set(); - const server = http.createServer((req, res) => { - req.resume(); - // Answer with headers and a first chunk, then never end the body. - req.on('end', () => { res.writeHead(200); res.write('ack'); }); - res.socket.on('close', () => { closed = true; }); - }); - server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const { port } = server.address(); - - const log = createLogShipper({ - service: 'svc', - env: { - LOG_SHIP_ENABLED: '1', - LOG_SHIP_URL: `http://127.0.0.1:${port}/logs`, - LOG_SHIP_BATCH_SIZE: '1', - LOG_SHIP_TIMEOUT_MS: '400' - }, - console: fakeConsole() - }); - - try { - log.info('one'); - await log.flush(); - // fetch() resolves on headers and the abort timer is cleared with it, - // so an unreleased body leaves nothing that will ever close this - // socket. Measured: released in under 2ms, unreleased still open at 3s. - for (let i = 0; i < 100 && !closed; i++) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - expect(closed).to.equal(true); - } finally { - await log.stop(); - for (const s of sockets) s.destroy(); - await new Promise((resolve) => server.close(resolve)); - } - }); + registerLogShipperTests(testContext); }); describe('observability/installObservability', function () { @@ -427,159 +90,7 @@ describe('observability/installObservability', function () { // service), so a suite that installs many times has to drop them between // cases or it reads the previous case's service label and HTTP series. afterEach(function () { require('../../src/observability/index.js')._resetObservability(); }); - - it('reads a default-off config from an empty env', function () { - const cfg = readObservabilityEnv({}); - expect(cfg.metricsEnabled).to.equal(false); - expect(cfg.httpMetrics).to.equal(false); - expect(cfg.metricsPath).to.equal('/metrics'); - expect(cfg.log.shipEnabled).to.equal(false); - }); - - it('normalizes a METRICS_PATH given without a leading slash', function () { - expect(readObservabilityEnv({ METRICS_ENABLED: '1', METRICS_PATH: 'internal/metrics' }).metricsPath) - .to.equal('/internal/metrics'); - }); - - it('registers NO route when the flag is unset, but still hands back a registry', async function () { - const app = express(); - app.get('/health', (req, res) => res.json({ ok: true })); - const obs = installObservability(app, { service: 'xchain-sync', env: {}, console: fakeConsole() }); - expect(obs.enabled).to.equal(false); - // The registry is deliberately NOT gated: a counter a consensus module - // registers has to exist on the default fleet, or it can never record. - // Only the endpoint is an operator decision. - expect(obs.registry).to.not.equal(null); - expect(typeof obs.registry.counter).to.equal('function'); - - const srv = await listen(app); - try { - const res = await fetch(srv.url('/metrics')); - expect(res.status).to.equal(404); - } finally { - await obs.shutdown(); - await srv.close(); - } - }); - - it('serves the exposition text and instruments requests when enabled', async function () { - const app = express(); - app.get('/health', (req, res) => res.json({ ok: true })); - const obs = installObservability(app, { - service: 'xchain-sync', version: '1.0.0', coin: 'BTC', network: 'regtest', - env: { METRICS_ENABLED: '1' }, console: fakeConsole() - }); - expect(obs.enabled).to.equal(true); - - const srv = await listen(app); - try { - await fetch(srv.url('/health')); - await fetch(srv.url('/health')); - await fetch(srv.url('/nope')); - - const res = await fetch(srv.url('/metrics')); - expect(res.status).to.equal(200); - expect(res.headers.get('content-type')).to.include('version=0.0.4'); - expect(res.headers.get('cache-control')).to.equal('no-store'); - - const body = await res.text(); - expect(body).to.include('http_requests_total{method="GET",route="/health",status="200"} 2'); - expect(body).to.include('http_request_duration_seconds_count{method="GET",route="/health"} 2'); - expect(body).to.include('http_requests_in_flight 0'); - expect(body).to.include('xchain_service_info{service="xchain-sync",version="1.0.0",coin="BTC",network="regtest"'); - // The scrape itself is never counted. - expect(body).to.not.include('route="/metrics"'); - } finally { - await obs.shutdown(); - await srv.close(); - } - }); - - it('buckets an unmatched path by first segment so URLs cannot explode cardinality', async function () { - const app = express(); - const obs = installObservability(app, { service: 'svc', env: { METRICS_ENABLED: '1' }, console: fakeConsole() }); - const srv = await listen(app); - try { - await fetch(srv.url('/block/000000001')); - await fetch(srv.url('/block/000000002')); - const body = await (await fetch(srv.url('/metrics'))).text(); - expect(body).to.include('http_requests_total{method="GET",route="/block",status="404"} 2'); - } finally { - await obs.shutdown(); - await srv.close(); - } - }); - - it('uses the express route pattern, not the concrete path, as the route label', function () { - expect(routeLabel({ route: { path: '/snapshot/:table' }, baseUrl: '/hub-db' })).to.equal('/hub-db/snapshot/:table'); - expect(routeLabel({ originalUrl: '/telemetry/summary?x=1' })).to.equal('/telemetry'); - expect(routeLabel({ url: '/' })).to.equal('/'); - }); - - it('gates the endpoint behind METRICS_TOKEN when one is configured', async function () { - const app = express(); - const obs = installObservability(app, { - service: 'svc', env: { METRICS_ENABLED: '1', METRICS_TOKEN: 'sekret-scrape' }, console: fakeConsole() - }); - const srv = await listen(app); - try { - expect((await fetch(srv.url('/metrics'))).status).to.equal(401); - expect((await fetch(srv.url('/metrics'), { headers: { Authorization: 'Bearer wrong' } })).status).to.equal(401); - const ok = await fetch(srv.url('/metrics'), { headers: { Authorization: 'Bearer sekret-scrape' } }); - expect(ok.status).to.equal(200); - expect(await ok.text()).to.include('# TYPE'); - } finally { - await obs.shutdown(); - await srv.close(); - } - }); - - it('honours a custom METRICS_PATH and can skip HTTP instrumentation', async function () { - const app = express(); - app.get('/health', (req, res) => res.json({ ok: true })); - const obs = installObservability(app, { - service: 'svc', env: { METRICS_ENABLED: '1', METRICS_PATH: '/internal/metrics', METRICS_HTTP: '0' }, - console: fakeConsole() - }); - const srv = await listen(app); - try { - await fetch(srv.url('/health')); - expect((await fetch(srv.url('/metrics'))).status).to.equal(404); - const body = await (await fetch(srv.url('/internal/metrics'))).text(); - expect(body).to.include('xchain_service_info'); - expect(body).to.not.include('http_requests_total{'); - } finally { - await obs.shutdown(); - await srv.close(); - } - }); - - it('instruments routes registered BEFORE the install call (layer is hoisted)', async function () { - // The six services wire this at different points in their api.js; Express - // dispatches in registration order, so without the hoist an install that - // lands after the routes would export zero HTTP metrics. - const app = express(); - app.get('/early', (req, res) => res.send('ok')); - const obs = installObservability(app, { service: 'svc', env: { METRICS_ENABLED: '1' }, console: fakeConsole() }); - const srv = await listen(app); - try { - await fetch(srv.url('/early')); - const body = await (await fetch(srv.url('/metrics'))).text(); - expect(body).to.include('http_requests_total{method="GET",route="/early",status="200"} 1'); - } finally { - await obs.shutdown(); - await srv.close(); - } - }); - - it('returns a usable logger even with no app to mount on', function () { - const sink = fakeConsole(); - const obs = installObservability(null, { service: 'worker', env: {}, console: sink }); - expect(obs.enabled).to.equal(false); - obs.logger.info('tick'); - expect(sink.lines.log).to.have.lengthOf(1); - expect(sink.lines.log[0]).to.match(/^\S+Z info \[worker\] tick$/); - }); + registerRouteTests(testContext); }); // The fleet runs text mode, so text mode is where the structured record has to @@ -587,120 +98,11 @@ describe('observability/installObservability', function () { // threw the whole record away: LOG_LEVEL and LOG_FORMAT changed nothing an // operator could see on any box. describe('observability/logShipper: text-with-fields format', function () { - it('renders ts, lowercase level, service tag, message, then key=value', function () { - const sink = fakeConsole(); - const log = createLogShipper({ service: 'xchain-sync', env: {}, console: sink }); - log.warn('PBFT_DROP', { reason: 'digest_mismatch', phase: 'prepare', round: 42 }); - expect(sink.lines.warn).to.have.lengthOf(1); - expect(sink.lines.warn[0]).to.match( - /^\d{4}-\d{2}-\d{2}T[\d:.]+Z warn \[xchain-sync\] PBFT_DROP reason=digest_mismatch phase=prepare round=42$/ - ); - }); - - it('keeps the level token lowercase so the server-monitor ERROR|FATAL grep does not match it', function () { - const sink = fakeConsole(); - const log = createLogShipper({ service: 'svc', env: {}, console: sink }); - log.error('boom'); - // collect-snapshot.sh counts `grep -cE 'ERROR|FATAL'`. An uppercase - // token would make every console.error line count and trip the crit - // threshold fleet-wide on first deploy. - expect(sink.lines.error[0]).to.not.match(/ERROR|FATAL/); - expect(sink.lines.error[0]).to.include(' error [svc] boom'); - }); - - it('puts the message immediately after the service tag so existing substring greps still match', function () { - const sink = fakeConsole(); - const log = createLogShipper({ service: 'xchain-sync', env: {}, console: sink }); - log.info('Oracle: Round 12 finalized'); - expect(sink.lines.log[0]).to.include('Oracle: Round 12 finalized'); - }); - - it('quotes a value carrying whitespace, = or a quote, and leaves plain tokens bare', function () { - const sink = fakeConsole(); - const log = createLogShipper({ service: 'svc', env: {}, console: sink }); - log.info('m', { plain: 'abc', spaced: 'a b', eq: 'k=v', num: 3, flag: true, nil: null }); - const line = sink.lines.log[0]; - expect(line).to.include('plain=abc'); - expect(line).to.include('spaced="a b"'); - expect(line).to.include('eq="k=v"'); - expect(line).to.include('num=3'); - expect(line).to.include('flag=true'); - expect(line).to.include('nil=null'); - }); - - it('redacts a credential-shaped field and an inline credential in the message', function () { - const sink = fakeConsole(); - const log = createLogShipper({ service: 'svc', env: {}, console: sink }); - log.warn('connect failed password=hunter2', { db_password: 'hunter2', host: 'db1' }); - const line = sink.lines.warn[0]; - expect(line).to.not.include('hunter2'); - expect(line).to.include(REDACTED); - expect(line).to.include('host=db1'); - }); - - it('emits one NDJSON record per line under LOG_FORMAT=json', function () { - const sink = fakeConsole(); - const log = createLogShipper({ service: 'svc', env: { LOG_FORMAT: 'json' }, console: sink }); - log.info('hello', { a: 1 }); - const parsed = JSON.parse(sink.lines.log[0]); - expect(parsed).to.include({ level: 'info', service: 'svc', msg: 'hello', a: 1 }); - expect(parsed.ts).to.be.a('string'); - }); - - it('silences info under LOG_LEVEL=warn while still emitting warn', function () { - const sink = fakeConsole(); - const log = createLogShipper({ service: 'svc', env: { LOG_LEVEL: 'warn' }, console: sink }); - log.info('quiet'); - log.warn('loud'); - expect(sink.lines.log).to.have.lengthOf(0); - expect(sink.lines.warn).to.have.lengthOf(1); - }); + registerTextFormatTests(testContext); }); describe('observability/logShipper: message redaction', function () { - // An env-validation failure prints the variable NAME and its value, and the - // names the services use are all prefixed (HUB_DB_SECRET, INDEXER_DB_PASS, - // db_password). A `\b`-anchored key never matches those, because `_` is a - // word character and `\b` does not fire between two word characters. With - // LOG_SHIP_* configured, an unscrubbed line goes off-box in the clear. - const leaky = [ - ['prefixed env secret', 'Missing required environment variable: HUB_DB_SECRET=hunter2swordfish'], - ['prefixed db pass', 'connect failed db_password=hunter2swordfish'], - ['screaming env pass', 'INDEXER_DB_PASS=hunter2swordfish'], - ['api key', 'HUB_API_KEY=hunter2swordfish'], - ['keyed bearer', 'Authorization: Bearer eyJhbGciOi.SECRETPAYLOAD.sig'], - ['bare bearer', 'sending Bearer eyJhbGciOi.SECRETPAYLOAD.sig upstream'], - ['quoted mnemonic', 'mnemonic="correct horse battery staple"'], - ]; - for (const [name, line] of leaky) { - it(`scrubs a ${name}`, function () { - const out = scrubMessage(line); - expect(out).to.not.match(/hunter2swordfish|SECRETPAYLOAD|correct horse/); - expect(out).to.include(REDACTED); - }); - } - - it('redacts the token, not the word Bearer', function () { - // The value group would otherwise capture "Bearer" and stop, leaving the - // token itself in the clear immediately after a [redacted] marker that - // makes the line look handled. - const out = scrubMessage('Authorization: Bearer eyJhbGciOi.SECRETPAYLOAD.sig'); - expect(out).to.not.include('SECRETPAYLOAD'); - }); - - it('leaves real operational lines untouched, hex identifiers included', function () { - // Hub and indexer lines are full of legitimate 64-char hex (txids, block - // hashes, state roots). A hex sweep here would gut the logs this work - // exists to make readable. - const keep = [ - 'Oracle: Round 12 finalized with 4 of 5 votes', - 'StateAnchorPublisher: anchored bundle regtest @ 100 (txid a3f9bc21de)', - 'P2P: Invalid signature from xc1qexampleaddr; dropping message', - 'seed block=5 imported', - 'PBFT_DROP reason=digest_mismatch phase=prepare round=42' - ]; - for (const line of keep) expect(scrubMessage(line)).to.equal(line); - }); + registerMessageRedactionTests(testContext); }); describe('observability/patchConsole', function () { @@ -708,149 +110,7 @@ describe('observability/patchConsole', function () { require('../../src/observability/index.js'); afterEach(function () { _resetObservability(); }); - - it('routes console.* through the shim, mapping log to info', function () { - const handle = patchConsole({ service: 'xchain-sync', env: {} }); - const seen = []; - // Read the shipper's own output by swapping its sink after the patch, - // which is the only place the bound originals are reachable from. - handle.logger.console = { log: (m) => seen.push(m), warn: (m) => seen.push(m), error: (m) => seen.push(m) }; - console.log('plain'); - console.warn('careful'); - console.error('bad'); - unpatchConsole(); - expect(seen[0]).to.match(/ info \[xchain-sync\] plain$/); - expect(seen[1]).to.match(/ warn \[xchain-sync\] careful$/); - expect(seen[2]).to.match(/ error \[xchain-sync\] bad$/); - }); - - it('resolves printf format strings and keeps an Error stack, via util.format', function () { - const handle = patchConsole({ service: 'svc', env: {} }); - const seen = []; - handle.logger.console = { log: (m) => seen.push(m), warn: (m) => seen.push(m), error: (m) => seen.push(m) }; - console.log('round %d of %s', 7, 'oracle'); - console.error('crashed:', new Error('kaboom')); - unpatchConsole(); - expect(seen[0]).to.include('round 7 of oracle'); - expect(seen[1]).to.include('kaboom'); - expect(seen[1]).to.include('Error'); - }); - - it('does not recurse: the sink holds bound originals captured BEFORE the patch', function () { - // `const orig = console` would hand the logger the very object about to - // be replaced, so every line would re-enter the wrapper forever. The - // proof is simply that a line completes and arrives once. - const realLog = console.log; - let depth = 0; - let maxDepth = 0; - console.log = (...a) => { depth += 1; maxDepth = Math.max(maxDepth, depth); depth -= 1; return realLog.apply(console, a); }; - const captured = console.log; - try { - patchConsole({ service: 'svc', env: {} }); - expect(console.log).to.not.equal(captured); - console.log('one line'); - unpatchConsole(); - } finally { - console.log = realLog; - } - expect(maxDepth).to.equal(1); - }); - - it('no-ops under XCHAIN_LOG_PATCH=0 so test bootstraps see stock console', function () { - const before = console.log; - const handle = patchConsole({ service: 'svc', env: { XCHAIN_LOG_PATCH: '0' } }); - expect(handle.patched).to.equal(false); - expect(console.log).to.equal(before); - }); - - it('is idempotent and restores the exact original functions on unpatch', function () { - const before = { log: console.log, warn: console.warn, error: console.error }; - const first = patchConsole({ service: 'svc', env: {} }); - const second = patchConsole({ service: 'other', env: {} }); - expect(second).to.equal(first); - unpatchConsole(); - expect(console.log).to.equal(before.log); - expect(console.warn).to.equal(before.warn); - expect(console.error).to.equal(before.error); - }); - - it('getLogger works before any install and reaches the real shipper after', function () { - // A module that logs while being required must not be able to crash the - // process just because it loaded before the wiring. - const log = getLogger(); - expect(() => log.info('early', { a: 1 })).to.not.throw(); - const handle = patchConsole({ service: 'svc', env: {} }); - const seen = []; - handle.logger.console = { log: (m) => seen.push(m), warn: (m) => seen.push(m), error: (m) => seen.push(m) }; - log.warn('LATE_EVENT', { reason: 'x' }); - unpatchConsole(); - expect(seen[0]).to.match(/ warn \[svc\] LATE_EVENT reason=x$/); - }); - - // A trailing Error argument expands across lines under util.inspect, and only - // the first line carries the prefix. Measured on the live fleet as orphaned - // fragments like " fatal: true," from a pretty-printed mariadb SqlError: - // no operation, no error, no coin, and unparseable by anything keying on the - // prefix. One console call must be one line. - it('renders a multi-line message as ONE line with the breaks escaped', () => { - const sink = fakeConsole(); - const log = createLogShipper({ service: 'xchain-sync', env: {}, console: sink }); - log.error('DB write failed: SqlError: connect ECONNREFUSED\n fatal: true,\n errno: -111'); - expect(sink.lines.error).to.have.lengthOf(1); - expect(sink.lines.error[0]).to.not.match(/\n/); - expect(sink.lines.error[0]).to.include('\\n fatal: true,'); - expect(sink.lines.error[0]).to.match(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z error \[xchain-sync\] DB write failed:/); - }); - - it('escapes a bare carriage return too, so a progress writer cannot split a record', () => { - const sink = fakeConsole(); - const log = createLogShipper({ service: 'xchain-sync', env: {}, console: sink }); - log.warn('rewriting\rline'); - expect(sink.lines.warn).to.have.lengthOf(1); - expect(sink.lines.warn[0]).to.include('rewriting\\nline'); - }); - - // JSON mode needs no escaping of its own: JSON.stringify already emits one - // physical line and keeps the true characters, which is better fidelity for a - // machine reader than a lossy substitution would be. - it('JSON mode keeps the real newlines and still emits one physical line', () => { - const sink = fakeConsole(); - const log = createLogShipper({ service: 'xchain-sync', env: { LOG_FORMAT: 'json' }, console: sink }); - log.error('line one\nline two'); - expect(sink.lines.error).to.have.lengthOf(1); - expect(sink.lines.error[0]).to.not.match(/\n/); - expect(JSON.parse(sink.lines.error[0]).msg).to.equal('line one\nline two'); - }); - - it('does not double-format: a shipper built AFTER the patch writes to the pre-patch sink', function () { - // The shim's default sink is the global console by reference. A shipper - // taking that default once console is patched emits its formatted line - // INTO the wrapper and gets it formatted again, so the line reads - // ` warn [svc] warn [svc] msg`. Caught by driving the real hub - // suite, not by reading the diff. - const seen = []; - const realWarn = console.warn; - console.warn = (m) => seen.push(m); - try { - patchConsole({ service: 'svc', env: {} }); - // A custom transport means this handle does NOT adopt the process - // shipper, so it builds a second one: the path where the global - // console would otherwise be taken as the default sink. - const second = installObservability(null, { service: 'svc', env: {}, logTransport: () => Promise.resolve() }); - second.logger.warn('once only'); - } finally { - unpatchConsole(); - console.warn = realWarn; - } - expect(seen).to.have.lengthOf(1); - expect(seen[0]).to.match(/^\S+Z warn \[svc\] once only$/); - }); - - it('hands out one registry, always constructed, before any install call', function () { - const reg = getRegistry({ service: 'svc' }); - expect(reg).to.equal(getRegistry()); - const c = reg.counter({ name: 'xchain_probe_total', help: 'probe' }); - c.inc({}, 1); - expect(reg.render()).to.include('xchain_probe_total'); + registerFlushAndHealthTests({ + ...testContext, patchConsole, unpatchConsole, getLogger, getRegistry }); }); diff --git a/test/unit/observability.test/01_metrics.test.js b/test/unit/observability.test/01_metrics.test.js new file mode 100644 index 00000000..1f30feba --- /dev/null +++ b/test/unit/observability.test/01_metrics.test.js @@ -0,0 +1,182 @@ +'use strict'; + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +'use strict'; + +function registerMetricsTestsPart1({ expect, Registry }) { + it('renders a counter with HELP, TYPE and labelled samples', function () { + const reg = new Registry(); + const c = reg.counter({ name: 'test_requests_total', help: 'Requests', labelNames: ['route'] }); + c.inc({ route: '/a' }, 2); + c.inc({ route: '/a' }); + c.inc({ route: '/b' }); + + const out = reg.render(); + expect(out).to.include('# HELP test_requests_total Requests'); + expect(out).to.include('# TYPE test_requests_total counter'); + expect(out).to.include('test_requests_total{route="/a"} 3'); + expect(out).to.include('test_requests_total{route="/b"} 1'); + expect(out.endsWith('\n')).to.equal(true); + }); + + it('rejects invalid metric and label names at declaration', function () { + const reg = new Registry(); + expect(() => reg.counter({ name: '9bad', help: 'x' })).to.throw(/invalid metric name/); + expect(() => reg.counter({ name: 'ok_total', help: 'x', labelNames: ['bad-label'] })).to.throw(/invalid label name/); + expect(() => reg.counter({ name: 'ok2_total', help: 'x', labelNames: ['__name__'] })).to.throw(/reserved/); + }); + + it('hands back the same metric when an identical declaration repeats', function () { + // Modules register their counters wherever they are required, and the + // registry is now process-wide, so an identical re-declaration is a + // normal event rather than a conflict. + const reg = new Registry(); + const first = reg.gauge({ name: 'dup_gauge', help: 'x' }); + expect(reg.gauge({ name: 'dup_gauge', help: 'y' })).to.equal(first); + }); + + it('still refuses a duplicate name declared with a different shape', function () { + const reg = new Registry(); + reg.gauge({ name: 'shape_clash', help: 'x' }); + expect(() => reg.counter({ name: 'shape_clash', help: 'x' })).to.throw(/different shape/); + reg.gauge({ name: 'label_clash', help: 'x', labelNames: ['a'] }); + expect(() => reg.gauge({ name: 'label_clash', help: 'x', labelNames: ['b'] })).to.throw(/different shape/); + }); + + it('rejects a negative counter increment and an unknown label', function () { + const reg = new Registry(); + const c = reg.counter({ name: 'neg_total', help: 'x', labelNames: ['a'] }); + expect(() => c.inc({ a: '1' }, -1)).to.throw(/non-negative/); + expect(() => c.inc({ b: '1' }, 1)).to.throw(/unknown label/); + }); + + it('escapes backslash, quote and newline in label values and help', function () { + const reg = new Registry(); + const c = reg.counter({ name: 'esc_total', help: 'line1\nline2 \\ end', labelNames: ['v'] }); + c.inc({ v: 'a"b\\c\nd' }); + const out = reg.render(); + expect(out).to.include('# HELP esc_total line1\\nline2 \\\\ end'); + expect(out).to.include('esc_total{v="a\\"b\\\\c\\nd"} 1'); + // No raw newline may appear inside a sample line. + for (const line of out.trim().split('\n')) expect(line).to.not.equal(''); + }); + +} + +function registerMetricsTestsPart2({ expect, Registry }) { + it('treats label order as declared order, not caller order', function () { + const reg = new Registry(); + const c = reg.counter({ name: 'order_total', help: 'x', labelNames: ['a', 'b'] }); + c.inc({ a: '1', b: '2' }); + c.inc({ b: '2', a: '1' }); + expect(c.get({ a: '1', b: '2' })).to.equal(2); + expect(reg.render().split('\n').filter((l) => l.startsWith('order_total{')).length).to.equal(1); + }); + + it('emits cumulative histogram buckets with +Inf equal to _count', function () { + const reg = new Registry(); + const h = reg.histogram({ name: 'lat_seconds', help: 'x', labelNames: ['route'], buckets: [0.1, 0.5, 1] }); + h.observe({ route: '/a' }, 0.05); + h.observe({ route: '/a' }, 0.3); + h.observe({ route: '/a' }, 2); + + const out = reg.render(); + expect(out).to.include('# TYPE lat_seconds histogram'); + expect(out).to.include('lat_seconds_bucket{route="/a",le="0.1"} 1'); + expect(out).to.include('lat_seconds_bucket{route="/a",le="0.5"} 2'); + expect(out).to.include('lat_seconds_bucket{route="/a",le="1"} 2'); + expect(out).to.include('lat_seconds_bucket{route="/a",le="+Inf"} 3'); + expect(out).to.include('lat_seconds_count{route="/a"} 3'); + expect(out).to.include('lat_seconds_sum{route="/a"} 2.35'); + }); + + it('ignores a non-finite histogram observation instead of poisoning the sum', function () { + const reg = new Registry(); + const h = reg.histogram({ name: 'nan_seconds', help: 'x', buckets: [1] }); + h.observe({}, Number.NaN); + h.observe({}, Infinity); + h.observe({}, 0.5); + expect(h.get({}).count).to.equal(1); + expect(h.get({}).sum).to.equal(0.5); + }); + + it('reserves le for histogram buckets', function () { + const reg = new Registry(); + expect(() => reg.histogram({ name: 'le_seconds', help: 'x', labelNames: ['le'] })).to.throw(/reserved/); + }); + + it('caps series per metric and counts the drops instead of growing', function () { + const reg = new Registry({ maxSeries: 3 }); + const c = reg.counter({ name: 'card_total', help: 'x', labelNames: ['id'] }); + for (let i = 0; i < 10; i++) c.inc({ id: `id-${i}` }); + expect(c.series.size).to.equal(3); + expect(reg.get('xchain_metrics_series_dropped_total').get({ metric: 'card_total' })).to.equal(7); + expect(reg.render()).to.include('xchain_metrics_series_dropped_total{metric="card_total"} 7'); + }); + +} + +function registerMetricsTestsPart3({ expect, Registry, Counter, Gauge, Histogram, collectDefaultMetrics }) { + it('gauge set/inc/dec track a value and reject a non-finite set', function () { + const reg = new Registry(); + const g = reg.gauge({ name: 'depth', help: 'x' }); + g.set({}, 5); + g.inc({}, 2); + g.dec({}, 3); + expect(g.get({})).to.equal(4); + expect(() => g.set({}, Number.NaN)).to.throw(/finite/); + }); + + it('setMonotonic never lets a collector-driven counter go backwards', function () { + const reg = new Registry(); + const c = reg.counter({ name: 'cpu_total', help: 'x' }); + c.setMonotonic({}, 10); + c.setMonotonic({}, 4); + expect(c.get({})).to.equal(10); + }); + + it('survives a throwing collector and still renders the rest', function () { + const reg = new Registry(); + reg.counter({ name: 'ok_total', help: 'x' }).inc({}); + reg.addCollector(() => { throw new Error('boom'); }); + expect(reg.render()).to.include('ok_total 1'); + }); + + it('collectDefaultMetrics exposes process and service identity', function () { + const reg = new Registry(); + collectDefaultMetrics(reg, { service: 'xchain-sync', version: '1.2.3', coin: 'BTC', network: 'regtest' }); + const out = reg.render(); + expect(out).to.include('xchain_service_info{service="xchain-sync",version="1.2.3",coin="BTC",network="regtest"'); + expect(out).to.match(/process_resident_memory_bytes \d+/); + expect(out).to.match(/process_cpu_user_seconds_total [\d.]+/); + expect(out).to.include('# TYPE process_cpu_user_seconds_total counter'); + expect(out).to.match(/nodejs_heap_size_used_bytes \d+/); + }); + + it('exports the Prometheus content type', function () { + expect(new Registry().contentType()).to.equal('text/plain; version=0.0.4; charset=utf-8'); + }); + + it('metric classes are usable standalone', function () { + expect(new Counter({ name: 'a_total', help: 'x' }).inc({}, 2)).to.equal(2); + const g = new Gauge({ name: 'b', help: 'x' }); g.set({}, 1); expect(g.get({})).to.equal(1); + const h = new Histogram({ name: 'c', help: 'x' }); h.observe({}, 1); expect(h.get({}).count).to.equal(1); + }); +} + +function registerMetricsTests(context) { + registerMetricsTestsPart1(context); + registerMetricsTestsPart2(context); + registerMetricsTestsPart3(context); +} + +module.exports = { registerMetricsTests }; diff --git a/test/unit/observability.test/02_log_shipper.test.js b/test/unit/observability.test/02_log_shipper.test.js new file mode 100644 index 00000000..27ba0a83 --- /dev/null +++ b/test/unit/observability.test/02_log_shipper.test.js @@ -0,0 +1,365 @@ +'use strict'; + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +'use strict'; + +function registerLogShipperTestsPart1({ expect, createLogShipper, readLogEnv, redactFields, scrubMessage, REDACTED, fakeConsole }) { + it('is inert by default: text output, no buffering, no shipping', function () { + const sink = fakeConsole(); + const log = createLogShipper({ service: 'svc', env: {}, console: sink }); + log.info('hello world'); + expect(log.config.shipEnabled).to.equal(false); + expect(log.buffer.length).to.equal(0); + expect(log.timer).to.equal(null); + expect(sink.lines.log).to.have.lengthOf(1); + expect(sink.lines.log[0]).to.match(/^\S+Z info \[svc\] hello world$/); + }); + + it('emits NDJSON with the envelope keys when LOG_FORMAT=json', function () { + const sink = fakeConsole(); + const log = createLogShipper({ service: 'xchain-sync', version: '9.9.9', env: { LOG_FORMAT: 'json' }, console: sink }); + log.warn('block stalled', { height: 42 }); + const rec = JSON.parse(sink.lines.warn[0]); + expect(rec.level).to.equal('warn'); + expect(rec.service).to.equal('xchain-sync'); + expect(rec.msg).to.equal('block stalled'); + expect(rec.height).to.equal(42); + expect(rec.version).to.equal('9.9.9'); + expect(new Date(rec.ts).toISOString()).to.equal(rec.ts); + }); + + it('honours LOG_LEVEL and drops quieter levels', function () { + const sink = fakeConsole(); + const log = createLogShipper({ service: 'svc', env: { LOG_LEVEL: 'warn' }, console: sink }); + expect(log.info('quiet')).to.equal(null); + expect(log.error('loud')).to.not.equal(null); + expect(sink.lines.log.length).to.equal(0); + }); + + it('redacts credential-shaped field keys and inline key=value pairs', function () { + const sink = fakeConsole(); + const log = createLogShipper({ service: 'svc', env: { LOG_FORMAT: 'json' }, console: sink }); + const rec = log.info('connect password=hunter2 then api_key: abc123', { + db: { user: 'app', password: 'hunter2' }, + HUB_API_KEY: 'zzz', + height: 7 + }); + expect(rec.db.password).to.equal(REDACTED); + expect(rec.db.user).to.equal('app'); + expect(rec.HUB_API_KEY).to.equal(REDACTED); + expect(rec.height).to.equal(7); + expect(rec.msg).to.not.include('hunter2'); + expect(rec.msg).to.not.include('abc123'); + expect(JSON.stringify(rec)).to.not.include('hunter2'); + }); + +} + +function registerLogShipperTestsPart2({ + expect, createLogShipper, readLogEnv, redactFields, scrubMessage, REDACTED, fakeConsole +}) { + it('never lets a caller field forge the record envelope', function () { + const log = createLogShipper({ service: 'real-svc', env: {}, console: fakeConsole() }); + const rec = log.info('m', { service: 'spoofed', level: 'debug', msg: 'spoofed' }); + expect(rec.service).to.equal('real-svc'); + expect(rec.level).to.equal('info'); + expect(rec.msg).to.equal('m'); + }); + + it('handles cyclic and deep field graphs without throwing', function () { + const a = { name: 'a' }; + a.self = a; + expect(redactFields(a).self).to.equal('[circular]'); + expect(redactFields({ a: { b: { c: { d: { e: 1 } } } } }).a.b.c.d).to.equal('[truncated]'); + expect(scrubMessage('token=abc')).to.equal(`token=${REDACTED}`); + }); + + it('serializes an Error field with a scrubbed message', function () { + const out = redactFields({ err: new Error('login failed for password=hunter2') }); + expect(out.err.message).to.include(REDACTED); + expect(out.err.message).to.not.include('hunter2'); + }); + + it('requires BOTH the flag and a valid URL before shipping', function () { + expect(readLogEnv({ LOG_SHIP_ENABLED: '1' }).shipEnabled).to.equal(false); + expect(readLogEnv({ LOG_SHIP_URL: 'https://c/logs' }).shipEnabled).to.equal(false); + expect(readLogEnv({ LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'ftp://c/logs' }).shipEnabled).to.equal(false); + expect(readLogEnv({ LOG_SHIP_ENABLED: 'true', LOG_SHIP_URL: 'https://c/logs' }).shipEnabled).to.equal(true); + }); + + it('batches NDJSON to the transport once the batch size is reached', async function () { + const bodies = []; + const log = createLogShipper({ + service: 'svc', + env: { LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'https://collector.invalid/logs', LOG_SHIP_BATCH_SIZE: '2' }, + console: fakeConsole(), + transport: (body) => { bodies.push(body); return Promise.resolve(); } + }); + log.info('one'); + log.info('two'); + await new Promise((r) => setImmediate(r)); + await log.stop(); + + expect(bodies.length).to.equal(1); + const lines = bodies[0].trim().split('\n').map((l) => JSON.parse(l)); + expect(lines.map((l) => l.msg)).to.deep.equal(['one', 'two']); + expect(log.stats.shipped).to.equal(2); + }); + +} + +function registerLogShipperTestsPart3({ expect, Registry, createLogShipper, fakeConsole }) { + it('drops the oldest lines when the buffer is full and counts the loss', function () { + const log = createLogShipper({ + service: 'svc', + env: { + LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'https://collector.invalid/logs', + LOG_SHIP_BATCH_SIZE: '1000', LOG_SHIP_MAX_BUFFER: '3' + }, + console: fakeConsole(), + transport: () => new Promise(() => {}) // never settles: buffer fills + }); + for (let i = 0; i < 6; i++) log.info(`line-${i}`); + expect(log.buffer.length).to.equal(3); + expect(log.stats.dropped).to.equal(3); + expect(log.buffer.map((r) => r.msg)).to.deep.equal(['line-3', 'line-4', 'line-5']); + }); + + it('survives a failing collector, re-queues the batch and rate-limits the stderr note', async function () { + const sink = fakeConsole(); + const log = createLogShipper({ + service: 'svc', + env: { LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'https://collector.invalid/logs', LOG_SHIP_BATCH_SIZE: '1' }, + console: sink, + transport: () => Promise.reject(new Error('ECONNREFUSED')) + }); + log.info('a'); + await log.flush(); + log.info('b'); + await log.flush(); + + expect(log.stats.failures).to.be.greaterThan(0); + expect(log.stats.shipped).to.equal(0); + expect(log.buffer.length).to.be.greaterThan(0); + // One note per minute, so the second failure adds no line. + expect(sink.lines.error.filter((l) => l.includes('[log-ship]')).length).to.equal(1); + await log.stop(); + }); + + it('exposes shipper counters on a registry when one is supplied', function () { + const reg = new Registry(); + const log = createLogShipper({ service: 'svc', env: {}, console: fakeConsole(), registry: reg }); + log.info('x'); + log.error('y'); + const out = reg.render(); + expect(out).to.include('log_lines_emitted_total{level="info"} 1'); + expect(out).to.include('log_lines_emitted_total{level="error"} 1'); + expect(out).to.include('log_ship_buffer_lines 0'); + }); + +} + +function registerLogShipperTestsPart4({ expect, http, createLogShipper, fakeConsole }) { + it('stop() clears the flush timer so the process can exit', async function () { + const log = createLogShipper({ + service: 'svc', + env: { LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'https://collector.invalid/logs' }, + console: fakeConsole(), + transport: () => Promise.resolve() + }); + expect(log.timer).to.not.equal(null); + await log.stop(); + expect(log.timer).to.equal(null); + }); + + // Exercises the real _post/fetch path. Every other test here injects a + // transport, which is why the unreleased response body below went unseen. + it('releases the response body so a stalled collector cannot pin the socket', async function () { + this.timeout(5000); + let closed = false; + const sockets = new Set(); + const server = http.createServer((req, res) => { + req.resume(); + // Answer with headers and a first chunk, then never end the body. + req.on('end', () => { res.writeHead(200); res.write('ack'); }); + res.socket.on('close', () => { closed = true; }); + }); + server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + + const log = createLogShipper({ + service: 'svc', + env: { + LOG_SHIP_ENABLED: '1', + LOG_SHIP_URL: `http://127.0.0.1:${port}/logs`, + LOG_SHIP_BATCH_SIZE: '1', + LOG_SHIP_TIMEOUT_MS: '400' + }, + console: fakeConsole() + }); + + try { + log.info('one'); + await log.flush(); + // fetch() resolves on headers and the abort timer is cleared with it, + // so an unreleased body leaves nothing that will ever close this + // socket. Measured: released in under 2ms, unreleased still open at 3s. + for (let i = 0; i < 100 && !closed; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(closed).to.equal(true); + } finally { + await log.stop(); + for (const s of sockets) s.destroy(); + await new Promise((resolve) => server.close(resolve)); + } + }); +} + +function registerLogShipperTests(context) { + registerLogShipperTestsPart1(context); + registerLogShipperTestsPart2(context); + registerLogShipperTestsPart3(context); + registerLogShipperTestsPart4(context); +} + +function registerTextFormatTestsPart1({ expect, createLogShipper, REDACTED, fakeConsole }) { + it('renders ts, lowercase level, service tag, message, then key=value', function () { + const sink = fakeConsole(); + const log = createLogShipper({ service: 'xchain-sync', env: {}, console: sink }); + log.warn('PBFT_DROP', { reason: 'digest_mismatch', phase: 'prepare', round: 42 }); + expect(sink.lines.warn).to.have.lengthOf(1); + expect(sink.lines.warn[0]).to.match( + /^\d{4}-\d{2}-\d{2}T[\d:.]+Z warn \[xchain-sync\] PBFT_DROP reason=digest_mismatch phase=prepare round=42$/ + ); + }); + + it('keeps the level token lowercase so the server-monitor ERROR|FATAL grep does not match it', function () { + const sink = fakeConsole(); + const log = createLogShipper({ service: 'svc', env: {}, console: sink }); + log.error('boom'); + // collect-snapshot.sh counts `grep -cE 'ERROR|FATAL'`. An uppercase + // token would make every console.error line count and trip the crit + // threshold fleet-wide on first deploy. + expect(sink.lines.error[0]).to.not.match(/ERROR|FATAL/); + expect(sink.lines.error[0]).to.include(' error [svc] boom'); + }); + + it('puts the message immediately after the service tag so existing substring greps still match', function () { + const sink = fakeConsole(); + const log = createLogShipper({ service: 'xchain-sync', env: {}, console: sink }); + log.info('Oracle: Round 12 finalized'); + expect(sink.lines.log[0]).to.include('Oracle: Round 12 finalized'); + }); + + it('quotes a value carrying whitespace, = or a quote, and leaves plain tokens bare', function () { + const sink = fakeConsole(); + const log = createLogShipper({ service: 'svc', env: {}, console: sink }); + log.info('m', { plain: 'abc', spaced: 'a b', eq: 'k=v', num: 3, flag: true, nil: null }); + const line = sink.lines.log[0]; + expect(line).to.include('plain=abc'); + expect(line).to.include('spaced="a b"'); + expect(line).to.include('eq="k=v"'); + expect(line).to.include('num=3'); + expect(line).to.include('flag=true'); + expect(line).to.include('nil=null'); + }); + +} + +function registerTextFormatTestsPart2({ expect, createLogShipper, REDACTED, fakeConsole }) { + it('redacts a credential-shaped field and an inline credential in the message', function () { + const sink = fakeConsole(); + const log = createLogShipper({ service: 'svc', env: {}, console: sink }); + log.warn('connect failed password=hunter2', { db_password: 'hunter2', host: 'db1' }); + const line = sink.lines.warn[0]; + expect(line).to.not.include('hunter2'); + expect(line).to.include(REDACTED); + expect(line).to.include('host=db1'); + }); + + it('emits one NDJSON record per line under LOG_FORMAT=json', function () { + const sink = fakeConsole(); + const log = createLogShipper({ service: 'svc', env: { LOG_FORMAT: 'json' }, console: sink }); + log.info('hello', { a: 1 }); + const parsed = JSON.parse(sink.lines.log[0]); + expect(parsed).to.include({ level: 'info', service: 'svc', msg: 'hello', a: 1 }); + expect(parsed.ts).to.be.a('string'); + }); + + it('silences info under LOG_LEVEL=warn while still emitting warn', function () { + const sink = fakeConsole(); + const log = createLogShipper({ service: 'svc', env: { LOG_LEVEL: 'warn' }, console: sink }); + log.info('quiet'); + log.warn('loud'); + expect(sink.lines.log).to.have.lengthOf(0); + expect(sink.lines.warn).to.have.lengthOf(1); + }); +} + +function registerTextFormatTests(context) { + registerTextFormatTestsPart1(context); + registerTextFormatTestsPart2(context); +} + +function registerMessageRedactionTests({ expect, scrubMessage, REDACTED }) { + // An env-validation failure prints the variable NAME and its value, and the + // names the services use are all prefixed (HUB_DB_SECRET, INDEXER_DB_PASS, + // db_password). A `\b`-anchored key never matches those, because `_` is a + // word character and `\b` does not fire between two word characters. With + // LOG_SHIP_* configured, an unscrubbed line goes off-box in the clear. + const leaky = [ + ['prefixed env secret', 'Missing required environment variable: HUB_DB_SECRET=hunter2swordfish'], + ['prefixed db pass', 'connect failed db_password=hunter2swordfish'], + ['screaming env pass', 'INDEXER_DB_PASS=hunter2swordfish'], + ['api key', 'HUB_API_KEY=hunter2swordfish'], + ['keyed bearer', 'Authorization: Bearer eyJhbGciOi.SECRETPAYLOAD.sig'], + ['bare bearer', 'sending Bearer eyJhbGciOi.SECRETPAYLOAD.sig upstream'], + ['quoted mnemonic', 'mnemonic="correct horse battery staple"'], + ]; + for (const [name, line] of leaky) { + it(`scrubs a ${name}`, function () { + const out = scrubMessage(line); + expect(out).to.not.match(/hunter2swordfish|SECRETPAYLOAD|correct horse/); + expect(out).to.include(REDACTED); + }); + } + + it('redacts the token, not the word Bearer', function () { + // The value group would otherwise capture "Bearer" and stop, leaving the + // token itself in the clear immediately after a [redacted] marker that + // makes the line look handled. + const out = scrubMessage('Authorization: Bearer eyJhbGciOi.SECRETPAYLOAD.sig'); + expect(out).to.not.include('SECRETPAYLOAD'); + }); + + it('leaves real operational lines untouched, hex identifiers included', function () { + // Hub and indexer lines are full of legitimate 64-char hex (txids, block + // hashes, state roots). A hex sweep here would gut the logs this work + // exists to make readable. + const keep = [ + 'Oracle: Round 12 finalized with 4 of 5 votes', + 'StateAnchorPublisher: anchored bundle regtest @ 100 (txid a3f9bc21de)', + 'P2P: Invalid signature from xc1qexampleaddr; dropping message', + 'seed block=5 imported', + 'PBFT_DROP reason=digest_mismatch phase=prepare round=42' + ]; + for (const line of keep) expect(scrubMessage(line)).to.equal(line); + }); +} + +module.exports = { + registerLogShipperTests, + registerTextFormatTests, + registerMessageRedactionTests +}; diff --git a/test/unit/observability.test/03_routes.test.js b/test/unit/observability.test/03_routes.test.js new file mode 100644 index 00000000..e9e867ef --- /dev/null +++ b/test/unit/observability.test/03_routes.test.js @@ -0,0 +1,186 @@ +'use strict'; + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +'use strict'; + +function registerRouteTestsPart1({ expect, express, installObservability, readObservabilityEnv, fakeConsole, listen }) { + it('reads a default-off config from an empty env', function () { + const cfg = readObservabilityEnv({}); + expect(cfg.metricsEnabled).to.equal(false); + expect(cfg.httpMetrics).to.equal(false); + expect(cfg.metricsPath).to.equal('/metrics'); + expect(cfg.log.shipEnabled).to.equal(false); + }); + + it('normalizes a METRICS_PATH given without a leading slash', function () { + expect(readObservabilityEnv({ METRICS_ENABLED: '1', METRICS_PATH: 'internal/metrics' }).metricsPath) + .to.equal('/internal/metrics'); + }); + + it('registers NO route when the flag is unset, but still hands back a registry', async function () { + const app = express(); + app.get('/health', (req, res) => res.json({ ok: true })); + const obs = installObservability(app, { service: 'xchain-sync', env: {}, console: fakeConsole() }); + expect(obs.enabled).to.equal(false); + // The registry is deliberately NOT gated: a counter a consensus module + // registers has to exist on the default fleet, or it can never record. + // Only the endpoint is an operator decision. + expect(obs.registry).to.not.equal(null); + expect(typeof obs.registry.counter).to.equal('function'); + + const srv = await listen(app); + try { + const res = await fetch(srv.url('/metrics')); + expect(res.status).to.equal(404); + } finally { + await obs.shutdown(); + await srv.close(); + } + }); + +} + +function registerRouteTestsPart2({ expect, express, installObservability, routeLabel, fakeConsole, listen }) { + it('serves the exposition text and instruments requests when enabled', async function () { + const app = express(); + app.get('/health', (req, res) => res.json({ ok: true })); + const obs = installObservability(app, { + service: 'xchain-sync', version: '1.0.0', coin: 'BTC', network: 'regtest', + env: { METRICS_ENABLED: '1' }, console: fakeConsole() + }); + expect(obs.enabled).to.equal(true); + + const srv = await listen(app); + try { + await fetch(srv.url('/health')); + await fetch(srv.url('/health')); + await fetch(srv.url('/nope')); + + const res = await fetch(srv.url('/metrics')); + expect(res.status).to.equal(200); + expect(res.headers.get('content-type')).to.include('version=0.0.4'); + expect(res.headers.get('cache-control')).to.equal('no-store'); + + const body = await res.text(); + expect(body).to.include('http_requests_total{method="GET",route="/health",status="200"} 2'); + expect(body).to.include('http_request_duration_seconds_count{method="GET",route="/health"} 2'); + expect(body).to.include('http_requests_in_flight 0'); + expect(body).to.include('xchain_service_info{service="xchain-sync",version="1.0.0",coin="BTC",network="regtest"'); + // The scrape itself is never counted. + expect(body).to.not.include('route="/metrics"'); + } finally { + await obs.shutdown(); + await srv.close(); + } + }); + + it('buckets an unmatched path by first segment so URLs cannot explode cardinality', async function () { + const app = express(); + const obs = installObservability(app, { service: 'svc', env: { METRICS_ENABLED: '1' }, console: fakeConsole() }); + const srv = await listen(app); + try { + await fetch(srv.url('/block/000000001')); + await fetch(srv.url('/block/000000002')); + const body = await (await fetch(srv.url('/metrics'))).text(); + expect(body).to.include('http_requests_total{method="GET",route="/block",status="404"} 2'); + } finally { + await obs.shutdown(); + await srv.close(); + } + }); + + it('uses the express route pattern, not the concrete path, as the route label', function () { + expect(routeLabel({ route: { path: '/snapshot/:table' }, baseUrl: '/hub-db' })).to.equal('/hub-db/snapshot/:table'); + expect(routeLabel({ originalUrl: '/telemetry/summary?x=1' })).to.equal('/telemetry'); + expect(routeLabel({ url: '/' })).to.equal('/'); + }); + +} + +function registerRouteTestsPart3({ expect, express, installObservability, fakeConsole, listen }) { + it('gates the endpoint behind METRICS_TOKEN when one is configured', async function () { + const app = express(); + const obs = installObservability(app, { + service: 'svc', env: { METRICS_ENABLED: '1', METRICS_TOKEN: 'sekret-scrape' }, console: fakeConsole() + }); + const srv = await listen(app); + try { + expect((await fetch(srv.url('/metrics'))).status).to.equal(401); + expect((await fetch(srv.url('/metrics'), { headers: { Authorization: 'Bearer wrong' } })).status).to.equal(401); + const ok = await fetch(srv.url('/metrics'), { headers: { Authorization: 'Bearer sekret-scrape' } }); + expect(ok.status).to.equal(200); + expect(await ok.text()).to.include('# TYPE'); + } finally { + await obs.shutdown(); + await srv.close(); + } + }); + + it('honours a custom METRICS_PATH and can skip HTTP instrumentation', async function () { + const app = express(); + app.get('/health', (req, res) => res.json({ ok: true })); + const obs = installObservability(app, { + service: 'svc', env: { METRICS_ENABLED: '1', METRICS_PATH: '/internal/metrics', METRICS_HTTP: '0' }, + console: fakeConsole() + }); + const srv = await listen(app); + try { + await fetch(srv.url('/health')); + expect((await fetch(srv.url('/metrics'))).status).to.equal(404); + const body = await (await fetch(srv.url('/internal/metrics'))).text(); + expect(body).to.include('xchain_service_info'); + expect(body).to.not.include('http_requests_total{'); + } finally { + await obs.shutdown(); + await srv.close(); + } + }); + +} + +function registerRouteTestsPart4({ expect, express, installObservability, fakeConsole, listen }) { + it('instruments routes registered BEFORE the install call (layer is hoisted)', async function () { + // The six services wire this at different points in their api.js; Express + // dispatches in registration order, so without the hoist an install that + // lands after the routes would export zero HTTP metrics. + const app = express(); + app.get('/early', (req, res) => res.send('ok')); + const obs = installObservability(app, { service: 'svc', env: { METRICS_ENABLED: '1' }, console: fakeConsole() }); + const srv = await listen(app); + try { + await fetch(srv.url('/early')); + const body = await (await fetch(srv.url('/metrics'))).text(); + expect(body).to.include('http_requests_total{method="GET",route="/early",status="200"} 1'); + } finally { + await obs.shutdown(); + await srv.close(); + } + }); + + it('returns a usable logger even with no app to mount on', function () { + const sink = fakeConsole(); + const obs = installObservability(null, { service: 'worker', env: {}, console: sink }); + expect(obs.enabled).to.equal(false); + obs.logger.info('tick'); + expect(sink.lines.log).to.have.lengthOf(1); + expect(sink.lines.log[0]).to.match(/^\S+Z info \[worker\] tick$/); + }); +} + +function registerRouteTests(context) { + registerRouteTestsPart1(context); + registerRouteTestsPart2(context); + registerRouteTestsPart3(context); + registerRouteTestsPart4(context); +} + +module.exports = { registerRouteTests }; diff --git a/test/unit/observability.test/04_flush_and_health.test.js b/test/unit/observability.test/04_flush_and_health.test.js new file mode 100644 index 00000000..009c5242 --- /dev/null +++ b/test/unit/observability.test/04_flush_and_health.test.js @@ -0,0 +1,174 @@ +'use strict'; + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +'use strict'; + +function registerFlushAndHealthTestsPart1({ expect, patchConsole, unpatchConsole }) { + it('routes console.* through the shim, mapping log to info', function () { + const handle = patchConsole({ service: 'xchain-sync', env: {} }); + const seen = []; + // Read the shipper's own output by swapping its sink after the patch, + // which is the only place the bound originals are reachable from. + handle.logger.console = { log: (m) => seen.push(m), warn: (m) => seen.push(m), error: (m) => seen.push(m) }; + console.log('plain'); + console.warn('careful'); + console.error('bad'); + unpatchConsole(); + expect(seen[0]).to.match(/ info \[xchain-sync\] plain$/); + expect(seen[1]).to.match(/ warn \[xchain-sync\] careful$/); + expect(seen[2]).to.match(/ error \[xchain-sync\] bad$/); + }); + + it('resolves printf format strings and keeps an Error stack, via util.format', function () { + const handle = patchConsole({ service: 'svc', env: {} }); + const seen = []; + handle.logger.console = { log: (m) => seen.push(m), warn: (m) => seen.push(m), error: (m) => seen.push(m) }; + console.log('round %d of %s', 7, 'oracle'); + console.error('crashed:', new Error('kaboom')); + unpatchConsole(); + expect(seen[0]).to.include('round 7 of oracle'); + expect(seen[1]).to.include('kaboom'); + expect(seen[1]).to.include('Error'); + }); + + it('does not recurse: the sink holds bound originals captured BEFORE the patch', function () { + // `const orig = console` would hand the logger the very object about to + // be replaced, so every line would re-enter the wrapper forever. The + // proof is simply that a line completes and arrives once. + const realLog = console.log; + let depth = 0; + let maxDepth = 0; + console.log = (...a) => { depth += 1; maxDepth = Math.max(maxDepth, depth); depth -= 1; return realLog.apply(console, a); }; + const captured = console.log; + try { + patchConsole({ service: 'svc', env: {} }); + expect(console.log).to.not.equal(captured); + console.log('one line'); + unpatchConsole(); + } finally { + console.log = realLog; + } + expect(maxDepth).to.equal(1); + }); + +} + +function registerFlushAndHealthTestsPart2({ expect, patchConsole, unpatchConsole, getLogger, createLogShipper, fakeConsole }) { + it('no-ops under XCHAIN_LOG_PATCH=0 so test bootstraps see stock console', function () { + const before = console.log; + const handle = patchConsole({ service: 'svc', env: { XCHAIN_LOG_PATCH: '0' } }); + expect(handle.patched).to.equal(false); + expect(console.log).to.equal(before); + }); + + it('is idempotent and restores the exact original functions on unpatch', function () { + const before = { log: console.log, warn: console.warn, error: console.error }; + const first = patchConsole({ service: 'svc', env: {} }); + const second = patchConsole({ service: 'other', env: {} }); + expect(second).to.equal(first); + unpatchConsole(); + expect(console.log).to.equal(before.log); + expect(console.warn).to.equal(before.warn); + expect(console.error).to.equal(before.error); + }); + + it('getLogger works before any install and reaches the real shipper after', function () { + // A module that logs while being required must not be able to crash the + // process just because it loaded before the wiring. + const log = getLogger(); + expect(() => log.info('early', { a: 1 })).to.not.throw(); + const handle = patchConsole({ service: 'svc', env: {} }); + const seen = []; + handle.logger.console = { log: (m) => seen.push(m), warn: (m) => seen.push(m), error: (m) => seen.push(m) }; + log.warn('LATE_EVENT', { reason: 'x' }); + unpatchConsole(); + expect(seen[0]).to.match(/ warn \[svc\] LATE_EVENT reason=x$/); + }); + + // A trailing Error argument expands across lines under util.inspect, and only + // the first line carries the prefix. Measured on the live fleet as orphaned + // fragments like " fatal: true," from a pretty-printed mariadb SqlError: + // no operation, no error, no coin, and unparseable by anything keying on the + // prefix. One console call must be one line. + it('renders a multi-line message as ONE line with the breaks escaped', () => { + const sink = fakeConsole(); + const log = createLogShipper({ service: 'xchain-sync', env: {}, console: sink }); + log.error('DB write failed: SqlError: connect ECONNREFUSED\n fatal: true,\n errno: -111'); + expect(sink.lines.error).to.have.lengthOf(1); + expect(sink.lines.error[0]).to.not.match(/\n/); + expect(sink.lines.error[0]).to.include('\\n fatal: true,'); + expect(sink.lines.error[0]).to.match(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z error \[xchain-sync\] DB write failed:/); + }); + +} + +function registerFlushAndHealthTestsPart3({ expect, patchConsole, unpatchConsole, getRegistry, createLogShipper, installObservability, fakeConsole }) { + it('escapes a bare carriage return too, so a progress writer cannot split a record', () => { + const sink = fakeConsole(); + const log = createLogShipper({ service: 'xchain-sync', env: {}, console: sink }); + log.warn('rewriting\rline'); + expect(sink.lines.warn).to.have.lengthOf(1); + expect(sink.lines.warn[0]).to.include('rewriting\\nline'); + }); + + // JSON mode needs no escaping of its own: JSON.stringify already emits one + // physical line and keeps the true characters, which is better fidelity for a + // machine reader than a lossy substitution would be. + it('JSON mode keeps the real newlines and still emits one physical line', () => { + const sink = fakeConsole(); + const log = createLogShipper({ service: 'xchain-sync', env: { LOG_FORMAT: 'json' }, console: sink }); + log.error('line one\nline two'); + expect(sink.lines.error).to.have.lengthOf(1); + expect(sink.lines.error[0]).to.not.match(/\n/); + expect(JSON.parse(sink.lines.error[0]).msg).to.equal('line one\nline two'); + }); + + it('does not double-format: a shipper built AFTER the patch writes to the pre-patch sink', function () { + // The shim's default sink is the global console by reference. A shipper + // taking that default once console is patched emits its formatted line + // INTO the wrapper and gets it formatted again, so the line reads + // ` warn [svc] warn [svc] msg`. Caught by driving the real hub + // suite, not by reading the diff. + const seen = []; + const realWarn = console.warn; + console.warn = (m) => seen.push(m); + try { + patchConsole({ service: 'svc', env: {} }); + // A custom transport means this handle does NOT adopt the process + // shipper, so it builds a second one: the path where the global + // console would otherwise be taken as the default sink. + const second = installObservability(null, { service: 'svc', env: {}, logTransport: () => Promise.resolve() }); + second.logger.warn('once only'); + } finally { + unpatchConsole(); + console.warn = realWarn; + } + expect(seen).to.have.lengthOf(1); + expect(seen[0]).to.match(/^\S+Z warn \[svc\] once only$/); + }); + + it('hands out one registry, always constructed, before any install call', function () { + const reg = getRegistry({ service: 'svc' }); + expect(reg).to.equal(getRegistry()); + const c = reg.counter({ name: 'xchain_probe_total', help: 'probe' }); + c.inc({}, 1); + expect(reg.render()).to.include('xchain_probe_total'); + }); +} + +function registerFlushAndHealthTests(context) { + registerFlushAndHealthTestsPart1(context); + registerFlushAndHealthTestsPart2(context); + registerFlushAndHealthTestsPart3(context); +} + +module.exports = { registerFlushAndHealthTests }; From baedc40a6c05cd7a8307160bf224e86c514f7e1d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 11:27:07 -0700 Subject: [PATCH 06/62] fix(sync): scope missing-table warnings to source schema Capture the validated source table set during schema reconciliation and report only replicated tables that exist upstream but not locally. Keep unknown source schemas fail-visible and preserve server-side build coverage checks. --- src/api.js | 20 ++++++++-------- src/client/sync.js | 22 ++++++++++++++---- src/schema/replicated_tables.js | 18 ++++++++------- test/unit/client_sync_io.test.js | 15 ++++++++++++ test/unit/missing_tables.test.js | 39 +++++++++++++++++++++++++++----- 5 files changed, 84 insertions(+), 30 deletions(-) diff --git a/src/api.js b/src/api.js index 4176c673..b978abc7 100644 --- a/src/api.js +++ b/src/api.js @@ -247,6 +247,13 @@ function healthEntryDegraded(entry){ return entry.circuit === 'open' || entry.poll_error_count > 0 || entry.halted === true; } +function clientMissingTables(syncService, chain, network, dbType){ + let sync = (typeof syncService.getClientSync === 'function') + ? syncService.getClientSync(chain, network, dbType) : null; + return (sync && typeof sync.getMissingTables === 'function') + ? sync.getMissingTables() : null; +} + // Build the status row for one (db, dbType, chain, network) tuple. // // Module-scope (not a closure inside startApi) and exported so the row shape is @@ -482,17 +489,8 @@ async function buildStatusRow(syncService, db, dbType, chain, network){ // indexer split); omit rather than fail the whole status. } } - // Replica-completeness gap, made monitorable. - // - // Every apply path tolerates errno 1146 so a replica whose schema lags the - // source does not wedge; the consequence is that entire tables can fail to - // arrive while this row still reports halted:false and lag_blocks:0, and - // table_counts cannot show it because a missing table is simply absent from - // the object (indistinguishable from a table nobody counted). Publish the - // names instead, so a monitor can alert on a non-empty array rather than on - // repeated ER_NO_SUCH_TABLE stack traces under a green status. null means the - // table listing itself failed: unknown, NOT "nothing missing". - row.missing_tables = missingReplicatedTables(present, dbType); + // ClientSync owns the source-scoped verdict; null means either schema is unknown. + row.missing_tables = clientMissingTables(syncService, chain, network, dbType); return row; } diff --git a/src/client/sync.js b/src/client/sync.js index d3de6974..d7e1908e 100644 --- a/src/client/sync.js +++ b/src/client/sync.js @@ -343,6 +343,11 @@ class ClientSync { this._replicaGapAlertSweeps = this.numericSetting('REPLICA_GAP_ALERT_SWEEPS', 2, 1); this._replicaGapAlertRepeatMs = this.numericSetting('REPLICA_GAP_ALERT_REPEAT_MS', 21600000, 0); + // Validated table names returned by the primary source's /schema endpoint. + // null means no source schema has been observed, so a missing-table verdict + // would be unknown rather than empty. + this._sourceTables = null; + // Throttled gap logging. On an inherently fast chain (e.g. Dogecoin // testnet, which mints blocks at ~10/sec and is tens of millions of // blocks high) the replica perpetually trails the live tip, so every @@ -448,12 +453,14 @@ class ClientSync { async warnMissingTables(){ try { let present = await this.db.listExistingTables(); - let missing = replicatedTables.missingReplicatedTables(present, this.dbType); + let missing = replicatedTables.missingReplicatedTables( + present, this.dbType, this._sourceTables + ); this._missingTables = missing; if(missing && missing.length){ getLogger().warn('MISSING_REPLICATED_TABLES: ' + this.chain + '/' + this.network + '/' + this.dbType + ' replica schema is missing ' + missing.length + - ' table(s) that this build replicates per block: ' + missing.join(', ') + + ' source table(s) that this build replicates per block: ' + missing.join(', ') + '. Rows for these tables are SKIPPED (errno 1146 is tolerated so a schema gap ' + 'cannot wedge the replica), so replication is partial while /status still ' + 'reports halted:false. Migrate this replica to the source schema; the same ' + @@ -466,8 +473,8 @@ class ClientSync { } } - // Per-block replicated tables absent from this replica's schema, or null when - // the check has not run / could not read the table listing. + // Source-side per-block replicated tables absent from this replica's schema, + // or null when the check has not run or either table listing is unknown. getMissingTables(){ return this._missingTables === undefined ? null : this._missingTables; } // Multi-source Byzantine quorum helpers. @@ -780,15 +787,19 @@ class ClientSync { if(!schema || !schema.tables) return; // Validate every table name + DDL up front, then collect the apply set. + // Keep the independently validated name set for missing-table checks: a + // source on an older release legitimately omits tables known to this build. + let sourceTables = new Set(); let pending = []; for(let tableName in schema.tables){ let createSql = schema.tables[tableName]; - if(!createSql) continue; let idCheck = validation.validateIdentifier(tableName); if(!idCheck.valid){ getLogger().error('Rejected table name from schema: ' + tableName + ' (' + idCheck.reason + ')'); continue; } + sourceTables.add(tableName); + if(!createSql) continue; let ddlCheck = validation.validateDdl(createSql); if(!ddlCheck.valid){ getLogger().error('Rejected DDL for table ' + tableName + ': ' + ddlCheck.reason); @@ -796,6 +807,7 @@ class ClientSync { } pending.push({ tableName, createSql }); } + this._sourceTables = sourceTables; // Multi-pass fixpoint. A CREATE can fail because a table it FK-references // has not been created yet; retrying the not-yet-applied tables until a diff --git a/src/schema/replicated_tables.js b/src/schema/replicated_tables.js index c389cc75..fe4679d1 100644 --- a/src/schema/replicated_tables.js +++ b/src/schema/replicated_tables.js @@ -232,15 +232,17 @@ function contentParityExclusions(dbType){ // names the gap: any table in the per-block replicated set that this schema // lacks is a table replication will skip without ever failing. // -// Deliberately a superset signal. A table absent on BOTH the source and this -// replica (source older than this build) is reported too, because from here the -// two cases are indistinguishable and reporting the harmless one costs an -// operator one migration check, while missing the real one costs silent data -// loss. Returns null when the table listing itself is unavailable: "unknown" -// must not read as "nothing missing". -function missingReplicatedTables(present, dbType){ +// Client callers pass the validated source table set so a mixed-version source +// does not make build-newer tables look like replica gaps. Server callers omit +// it and continue checking their own schema against this build's topology. +// Returns null when either required listing is unavailable: "unknown" must not +// read as "nothing missing". +function missingReplicatedTables(present, dbType, sourcePresent){ if(!present || typeof present.has !== 'function') return null; - return getReplicatedTables(dbType).filter(t => !present.has(t)).sort(); + if(sourcePresent === null || (sourcePresent !== undefined && typeof sourcePresent.has !== 'function')) return null; + return getReplicatedTables(dbType) + .filter(t => (sourcePresent === undefined || sourcePresent.has(t)) && !present.has(t)) + .sort(); } // The cursor column for id-ordered paging of an append-only lookup table diff --git a/test/unit/client_sync_io.test.js b/test/unit/client_sync_io.test.js index 401a9c21..84793fd0 100644 --- a/test/unit/client_sync_io.test.js +++ b/test/unit/client_sync_io.test.js @@ -168,6 +168,20 @@ describe('ClientSync: fetchAndApplySchema', function(){ assert.ok(errorCalls.some(m => m && m.indexOf('Rejected DDL') !== -1), 'should log rejected DDL'); }); + + it('records validated source table names for replica-gap checks', async function(){ + sinon.stub(axios, 'get').resolves({ data: { + tables: { + goodtable: 'CREATE TABLE `goodtable` (id int)', + emptytable: '', + 'bad-name': 'CREATE TABLE `bad-name` (id int)' + } + }}); + db.doQuery.resolves([]); + await sync.fetchAndApplySchema('http://src1:3006'); + + assert.deepStrictEqual(sync._sourceTables, new Set(['goodtable', 'emptytable'])); + }); }); describe('ClientSync: fetchAndApplySchema', function(){ @@ -207,6 +221,7 @@ describe('ClientSync: fetchAndApplySchema', function(){ let errorCalls = console.error.getCalls().map(c => c.args[0]); assert.ok(errorCalls.some(m => m && m.indexOf('Failed to fetch schema') !== -1), 'outer catch must log "Failed to fetch schema"'); + assert.strictEqual(sync._sourceTables, null, 'failed fetch must leave source schema unknown'); }); }); diff --git a/test/unit/missing_tables.test.js b/test/unit/missing_tables.test.js index 160b3fc0..bbb723d0 100644 --- a/test/unit/missing_tables.test.js +++ b/test/unit/missing_tables.test.js @@ -31,6 +31,7 @@ const { getReplicatedTables, missingReplicatedTables } = require('../../src/sche // whose source was already writing them. const BET_TABLES = ['bets', 'bet_statuses', 'bet_resolves', 'bet_feeds', 'bet_feed_statuses', 'bet_cancels']; +const BRIDGE_TABLES = ['bridge_settlements', 'xbridges']; // A schema listing that holds every replicated table except `absent`. function presentExcept(dbType, absent){ @@ -74,6 +75,16 @@ describe('missingReplicatedTables', function(){ assert.strictEqual(missingReplicatedTables(undefined, 'indexer'), null); assert.strictEqual(missingReplicatedTables(['bets'], 'indexer'), null); }); + + it('checks only tables present in an explicitly supplied source schema', function(){ + let source = presentExcept('indexer', BRIDGE_TABLES); + let local = new Set(source); + assert.deepStrictEqual(missingReplicatedTables(local, 'indexer', source), []); + + source.add('bridge_settlements'); + assert.deepStrictEqual(missingReplicatedTables(local, 'indexer', source), ['bridge_settlements']); + assert.strictEqual(missingReplicatedTables(local, 'indexer', null), null); + }); }); function makeSync(dbOverrides, dbType){ @@ -101,6 +112,7 @@ function makeSync(dbOverrides, dbType){ }; let sync = new ClientSync('bitcoin', 'mainnet', db, applier, rb, new HashVerifier(), config, new Utility()); + sync._sourceTables = new Set(getReplicatedTables(dbType || 'indexer')); return { sync, db }; } @@ -146,6 +158,20 @@ describe('ClientSync.warnMissingTables', function(){ await sync.warnMissingTables(); assert.deepStrictEqual(sync.getMissingTables(), ['bets']); }); + + it('stays silent for replicated tables absent from both source and replica', async function(){ + let sourceTables = presentExcept('indexer', BRIDGE_TABLES); + let { sync } = makeSync({ + listExistingTables: sinon.stub().resolves(new Set(sourceTables)) + }); + sync._sourceTables = sourceTables; + + await sync.warnMissingTables(); + + let calls = warnStub.getCalls().filter(c => String(c.args[0]).indexOf('MISSING_REPLICATED_TABLES') === 0); + assert.strictEqual(calls.length, 0); + assert.deepStrictEqual(sync.getMissingTables(), []); + }); }); describe('ClientSync.warnMissingTables', function(){ @@ -220,8 +246,9 @@ function mockDb(present, dbType){ // A client-mode SyncService that reports the exact shape the live incident // showed: caught up, not halted, nothing wrong. -function mockClientSyncService(){ +function mockClientSyncService(missingTables){ return { + getClientSync: () => ({ getMissingTables: () => missingTables }), getClientSyncState: () => ({ lastKnownServerBlock: 100, sourceHeightStale: false, halted: false, haltInfo: null, truncated: false, bootstrapBase: null, sourceQuorum: 1, sourcesConfigured: 1, @@ -236,7 +263,8 @@ describe('/status missing_tables', function(){ it('names the missing tables on a replica that otherwise looks perfectly healthy', async function(){ let { buildStatusRow } = loadApi('client'); - let row = await buildStatusRow(mockClientSyncService(), + let expected = BET_TABLES.filter(t => getReplicatedTables('indexer').includes(t)).sort(); + let row = await buildStatusRow(mockClientSyncService(expected), mockDb(presentExcept('indexer', BET_TABLES)), 'indexer', 'bitcoin', 'mainnet'); @@ -246,13 +274,12 @@ describe('/status missing_tables', function(){ // table_counts cannot show the gap: an absent table is simply not a key. for(let t of BET_TABLES) assert.ok(!(t in row.table_counts)); // missing_tables can, and does. - let expected = BET_TABLES.filter(t => getReplicatedTables('indexer').includes(t)).sort(); assert.deepStrictEqual(row.missing_tables, expected); }); it('is an empty array on a complete replica', async function(){ let { buildStatusRow } = loadApi('client'); - let row = await buildStatusRow(mockClientSyncService(), + let row = await buildStatusRow(mockClientSyncService([]), mockDb(new Set(getReplicatedTables('indexer'))), 'indexer', 'bitcoin', 'mainnet'); assert.deepStrictEqual(row.missing_tables, []); @@ -260,7 +287,7 @@ describe('/status missing_tables', function(){ it('is null, not [], when the table listing fails', async function(){ let { buildStatusRow } = loadApi('client'); - let row = await buildStatusRow(mockClientSyncService(), + let row = await buildStatusRow(mockClientSyncService(null), mockDb(new Error('information_schema unavailable')), 'indexer', 'bitcoin', 'mainnet'); assert.strictEqual(row.missing_tables, null); @@ -273,7 +300,7 @@ describe('/status missing_tables', function(){ it('scopes to the decoder replicated set on a decoder replica', async function(){ let { buildStatusRow } = loadApi('client'); - let row = await buildStatusRow(mockClientSyncService(), + let row = await buildStatusRow(mockClientSyncService(['pubkeys']), mockDb(presentExcept('decoder', ['pubkeys']), 'decoder'), 'decoder', 'bitcoin', 'mainnet'); assert.deepStrictEqual(row.missing_tables, ['pubkeys']); From f691f7ab563621b344f4551ee3bc74b143fe6802 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 15:52:11 -0700 Subject: [PATCH 07/62] fix(consensus): re-cut the LTC and DOGE mirror-admission heights onto the BTC instant The v0.20.0 family was sized 2026-09-16 20:41Z from last-99-block cadences. LTC testnet then ran at about 82 s per block against the 146.6 s that sizing assumed, pulling its producer boundary to 3.2 h out while BTC's stayed 53.0 h out, and DOGE's drifted 7.5 h early. Two legs of one cross-chain match would have crossed the flag day about two days apart, which is what the same-wall-clock-instant rule exists to prevent. Re-measured 2026-09-17 22:45Z over a trailing window as long as the lead being sized: TBTC 152,891 at 576.7 s per block, TLTC 4,889,190 at 82.5 s, TDOGE 67,904,912 at 27.7 s. BTC is unchanged, still keyed to epoch close 153,216 plus 6 buried; LTC and DOGE are converted onto that same instant, and each consumer is its own producer plus six hours at its own measured cadence rather than a block count carried over from the first sizing. Arms the TRAIN_ACTIVATION 0.20.0 row at testnet 153,116, 106 blocks and about 17 h below the BTC producer. resolveRuleSet reads only the local map, so with no row every block above the boundary keeps resolving under 0.19.0, and a manifest naming 0.20.0 halts a fleet in which no build implements it. Records the cadence-window rule the re-cut used and makes the re-size rule per chain instead of BTC-keyed, so an LTC or DOGE drift past the six-hour ordering margin forces a re-cut the way an overrun BTC height already does. --- bin/pins/identity.json | 4 ++-- src/consensus/gate_registry/shared_rows_2.js | 21 ++++++++++---------- src/consensus/gate_registry/shared_rows_5.js | 14 +++++++++++++ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/bin/pins/identity.json b/bin/pins/identity.json index 1c26ab48..0cd4d957 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -1,5 +1,5 @@ { - "armed_map_fingerprint": "69934f9b966c1c491fb0e737a41730488c729e3c209f1eda44b3ec7f6d2d6193", + "armed_map_fingerprint": "f3d4455007a84ab8000c47f586c0806097449574002f4bcf0f043ef3998a87d1", "armed_map_fingerprint_version": 2, "armedMapRows": { "archive_rollback_author_scope_activation.ARCHIVE_AUTHOR_SCOPE_JOIN_SQL": "c8b6c1753f86fb65689fbc5d72f001e9467d5553a91895a21c9cf48d643e8895", @@ -40,7 +40,7 @@ "swq_source_cap_activation.STAKE_WEIGHT_MAX_KEYS_PER_SOURCE": "a68b412c4282555f15546cf6e1fc42893b7e07f271557ceb021821098dd66c1b", "swq_source_cap_activation.STAKE_WEIGHT_MAX_SOURCES": "40510175845988f13f6162ed8526f0b09f73384467fa855e1e79b44a56562a58", "swq_source_cap_activation.SWQ_SOURCE_CAP_ACTIVATION": "d9105d53199963f94287f25b3d87d1e625b77d56ac8baab6d6ee64029b0fae74", - "train_activation.TRAIN_ACTIVATION": "8f740a4fe0949e295750bdf03de698c1914c5d584f617c179ca93910ab43d26e" + "train_activation.TRAIN_ACTIVATION": "3f842b0a9bec3dfc429b72e9172b941e262d8a1eef56eb2b4c60f63fdd90b8e4" }, "armed_map_rows": 39, "carrier_logic_digest": "f142db83339b2a74577e6e79d49cffc45c45ac642bcd57609b554f98a2f2e068", diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index eda6ba32..c7fd765d 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -204,20 +204,21 @@ addGate('mirror_admission_activation.ADMIT_MAX_FUTURE_BLOCKS', 'constant', { * height on a BTC indexer, so the two legs of one cross-chain match would cross the flag day at * unrelated instants. The 'COIN:network' key shape is established precedent. * - * Mainnet is null under the 2026-08-29 write hold. TESTNET SIZED 2026-09-16 20:41Z; the measured - * tips, the formula, the epoch-close rule and the re-size rule are written once in the canon - * (xchain-documentation/protocol/constants.js), which this row is held value-identical to. The v7 - * HUB_SCHEMA_VERSION roll completes BEFORE any of these heights: the heights map rides frames - * carrying no schema_version, so a v7 indexer above the activation against a v6 hub would see no - * heights at all and defer forever under the fail-closed rule. + * Mainnet is null under the 2026-08-29 write hold. TESTNET SIZED 2026-09-16 20:41Z, LTC and DOGE + * RE-CUT 2026-09-17 22:45Z onto the BTC instant after their cadences drifted off it; the measured + * tips, the formula, the cadence-window rule, the epoch-close rule and the per-chain re-size rule + * are written once in the canon (xchain-documentation/protocol/constants.js), which this row is + * held value-identical to. The v7 HUB_SCHEMA_VERSION roll completes BEFORE any of these heights: + * the heights map rides frames carrying no schema_version, so a v7 indexer above the activation + * against a v6 hub would see no heights at all and defer forever under the fail-closed rule. */ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 153222, // SIZED 2026-09-16 20:41Z: epoch close 153,216 + 6 buried; tip 152,756 + 466 at 498.7 s/blk, about 64.5 h - 'LTC:testnet': 4889331, // the same instant: tip 4,887,745 + 1586 at 146.6 s/blk - 'DOGE:testnet': 67910821, // the same instant: tip 67,901,335 + 9486 at 24.5 s/blk + 'LTC:testnet': 4891504, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 4,889,190 + 2314 at 82.5 s/blk + 'DOGE:testnet': 67911796, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 67,904,912 + 6884 at 27.7 s/blk 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -228,8 +229,8 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 153266, // its producer + 44 blocks, about 6 h: strictly above, never equal - 'LTC:testnet': 4889479, // its producer + 148 blocks, about 6 h - 'DOGE:testnet': 67911703, // its producer + 882 blocks, about 6 h + 'LTC:testnet': 4891766, // its producer + 262 blocks, about 6 h at 82.5 s/blk + 'DOGE:testnet': 67912575, // its producer + 779 blocks, about 6 h at 27.7 s/blk 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration diff --git a/src/consensus/gate_registry/shared_rows_5.js b/src/consensus/gate_registry/shared_rows_5.js index 92f3979b..cbf28976 100644 --- a/src/consensus/gate_registry/shared_rows_5.js +++ b/src/consensus/gate_registry/shared_rows_5.js @@ -97,6 +97,20 @@ addGate('train_activation.TRAIN_ACTIVATION', 'ruleset', { // bridge height below sits above it on the same BTC clock, so a node lacking this rule // set halts before it can grade a bridge action. '0.19.0': { mainnet: 9999999999, testnet: 152787, regtest: 0 }, + // The mirror-admission rule set, armed at the v0.20.0 cut: the producer and consumer + // admission maps and the anchor-attest barrier replace the effective_time binding, so a + // node without them grades an admission-stamped row under the rule it replaced. Mainnet + // holds the house sentinel because the whole family is null on mainnet under the + // 2026-08-29 write hold. Testnet: SIZED 2026-09-17 22:45Z, chain_tip TBTC 152,891 + 225 + // blocks, which is ceil(36 h / 576.7 s per block), about 36.0 h. The cadence is measured + // over a trailing window as long as the lead being sized (53 h here), never the last 99 + // blocks: a 99-block window on a testnet difficulty burst is noise, and it is what pulled + // the LTC leg of this family two days off its BTC counterpart a day after the first cut. + // That lead is the rolling-upgrade window the fleet roll must finish inside (24x the 90 + // minute roll budget), and every testnet mirror-admission height sits above it on the same + // BTC clock (the BTC producer at 153,222 is 106 blocks and about 17.0 h further up), so a + // node lacking this rule set halts before it can grade an admission-stamped row. + '0.20.0': { mainnet: 9999999999, testnet: 153116, regtest: 0 }, }); // xchain_bridge_activation From 1e2c5f95c964b57940471c8ca1a6a24123bc2598 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 15:41:59 -0700 Subject: [PATCH 08/62] test(sync): split balance helper registrations Move balance helper test registrations into synchronous module-scope helpers while preserving suite order and hook scope. Reduce the two over-limit callbacks without adding await points or changing test coverage. --- test/unit/balance_helpers.test.js | 308 ++++++++++++++++-------------- 1 file changed, 166 insertions(+), 142 deletions(-) diff --git a/test/unit/balance_helpers.test.js b/test/unit/balance_helpers.test.js index b1c132bb..2b7172bd 100644 --- a/test/unit/balance_helpers.test.js +++ b/test/unit/balance_helpers.test.js @@ -22,83 +22,180 @@ function fakeDb() { return { queries, argsLog, doQuery: async (sql, args) => { queries.push(sql.replace(/\s+/g, ' ').trim()); argsLog.push(args || []); return []; } }; } +function registerRebuildBalancesTests(getDb) { + it('clears the table before reinserting (DELETE then INSERT, in order)', function () { + const db = getDb(); + assert.strictEqual(db.queries.length, 2); + assert.ok(/^DELETE FROM balances/i.test(db.queries[0])); + assert.ok(/INSERT INTO balances \(address_id, tick_id, amount\)/i.test(db.queries[1])); + }); + + it('aggregates credits minus debits per (address_id, tick_id)', function () { + const db = getDb(); + const sql = db.queries[1]; + assert.ok(/FROM credits/i.test(sql) && /FROM debits/i.test(sql), 'unions credits + debits'); + assert.ok(/UNION ALL/i.test(sql)); + assert.ok(/WHEN t\.type = 'credit' THEN CAST\(t\.amount AS DECIMAL\(65,18\)\) ELSE -CAST\(t\.amount AS DECIMAL\(65,18\)\) END/i.test(sql), 'credit positive, debit negative'); + assert.ok(/GROUP BY address_id, tick_id/i.test(sql)); + }); + + it('sums at DECIMAL(65,18) (bare VARCHAR amounts promote to DOUBLE and corrupt >16-digit amounts)', function () { + const db = getDb(); + const sql = db.queries[1]; + assert.ok(!/THEN t\.amount/i.test(sql), 'no un-cast amount may reach the SUM'); + assert.ok(/CAST\(t\.amount AS DECIMAL\(65,18\)\)/i.test(sql)); + }); + + it('renders amounts in the source indexer\'s minimal-decimal format', function () { + const db = getDb(); + assert.ok(/TRIM\(TRAILING '\.' FROM TRIM\(TRAILING '0' FROM CAST\(/i.test(db.queries[1]), + 'trailing zeros then the bare dot are trimmed so rebuilt bytes match source bytes'); + }); + + it('prunes rows that net to exactly zero (HAVING ... != 0)', function () { + const db = getDb(); + assert.ok(/HAVING SUM\(.*\) != 0/i.test(db.queries[1])); + }); + + it('rejects if the DB layer fails (caller owns error handling)', async function () { + const boom = { doQuery: async () => { throw new Error('1146 no table'); } }; + await assert.rejects(() => rebuildBalances(boom), /1146/); + }); +} + +function registerScopedRebuildTests(getDb) { + it('scopes the DELETE to the touched ids (parameterized IN-lists)', function () { + const db = getDb(); + assert.ok(/^DELETE FROM balances WHERE address_id IN \(\?, \?\) AND tick_id IN \(\?\)$/i.test(db.queries[0])); + assert.deepStrictEqual(db.argsLog[0], [7, 9, 3]); + }); + + it('scopes BOTH union branches so the source scans stay bounded', function () { + const db = getDb(); + const sql = db.queries[1]; + const branchPreds = sql.match(/FROM (credits|debits) WHERE address_id IN \(\?, \?\) AND tick_id IN \(\?\)/gi) || []; + assert.strictEqual(branchPreds.length, 2, 'credits and debits both scoped'); + assert.deepStrictEqual(db.argsLog[1], [7, 9, 3, 7, 9, 3], 'args repeated per branch'); + }); + + it('keeps the full-rebuild aggregation semantics (signs, grouping, zero pruning)', function () { + const db = getDb(); + const sql = db.queries[1]; + assert.ok(/WHEN t\.type = 'credit' THEN CAST\(t\.amount AS DECIMAL\(65,18\)\) ELSE -CAST\(t\.amount AS DECIMAL\(65,18\)\) END/i.test(sql)); + assert.ok(/GROUP BY address_id, tick_id/i.test(sql)); + assert.ok(/HAVING SUM\(.*\) != 0/i.test(sql)); + }); + + it('is a no-op when the scope is empty (nothing was touched)', async function () { + const empty = fakeDb(); + await rebuildBalances(empty, { addressIds: [], tickIds: [] }); + assert.strictEqual(empty.queries.length, 0); + }); + + it('an absent scope still issues the unscoped full rebuild', async function () { + const full = fakeDb(); + await rebuildBalances(full); + assert.ok(/^DELETE FROM balances$/i.test(full.queries[0])); + assert.ok(!/WHERE/i.test(full.queries[0])); + }); +} + +function registerRecomputeUpdateTests(precisionDb) { + it('runs one UPDATE per distinct token precision, keyed by decimals', async function () { + const db = precisionDb([{ decimals: 8 }, { decimals: 0 }]); + await recomputeTokenSupplies(db); + const updates = db.queries.filter(q => /^UPDATE tokens/i.test(q)); + assert.strictEqual(updates.length, 2, 'one UPDATE per distinct precision'); + // Precision is the bind arg (a DECIMAL scale can't be parameterized, but the + // WHERE key can), so tokens are fixed precision-by-precision. + const updateArgs = db.argsLog.filter((_, i) => /^UPDATE tokens/i.test(db.queries[i])); + assert.deepStrictEqual(updateArgs.sort(), [[0], [8]]); + }); + + // Rows are summed EXACTLY (DECIMAL(60,18)) and the TOTAL is rounded once + // to the token's own scale: per-ROW rounding agreed with the indexer only + // while every amount sat on the token's grid, and inflates supply once + // fee amounts go finer than the tick. + it('sums (credits - debits) + escrows at the EXACT scale and rounds ONCE at the token scale', async function () { + const db = precisionDb([{ decimals: 8 }]); + await recomputeTokenSupplies(db); + const sql = db.queries.find(q => /^UPDATE tokens/i.test(q)); + assert.ok(/FROM credits/i.test(sql) && /FROM debits/i.test(sql) && /FROM escrows/i.test(sql), + 'unions credits + debits + escrows (escrows folds into supply, unlike balances)'); + assert.ok(/SELECT tick_id, ?CAST\(amount AS DECIMAL\(60,18\)\) AS amt FROM credits/i.test(sql), 'credit is positive'); + assert.ok(/- ?CAST\(amount AS DECIMAL\(60,18\)\) AS amt FROM debits/i.test(sql), 'debit is negative'); + assert.ok(/CAST\(amount AS DECIMAL\(60,18\)\) AS amt FROM escrows/i.test(sql), 'escrow is positive'); + assert.ok(/CAST\(SUM\(amt\) AS DECIMAL\(60,8\)\)/i.test(sql), + 'the TOTAL is rounded once at the token scale, so it stays byte-identical to the source supply'); + assert.ok(!/CAST\(amount AS DECIMAL\(60,8\)\)/i.test(sql), + 'must NOT round each ROW to the token scale (the per-row rounding overcharge shape)'); + assert.ok(!/DECIMAL\(65,18\)/i.test(sql), 'must NOT use the fixed 65,18 scale (byte-identity needs the token scale)'); + assert.ok(/GROUP BY tick_id/i.test(sql)); + }); +} + +function registerRecomputeRenderingTests(precisionDb) { + it('LEFT JOINs so a token with no surviving ledger rows resolves to supply 0', async function () { + const db = precisionDb([{ decimals: 8 }]); + await recomputeTokenSupplies(db); + const sql = db.queries.find(q => /^UPDATE tokens/i.test(q)); + assert.ok(/LEFT JOIN/i.test(sql), 'LEFT JOIN keeps tokens with no ledger rows'); + assert.ok(/SET tok\.supply = COALESCE\(agg\.supply, '0'\)/i.test(sql), 'no-ledger token -> supply 0'); + }); + + it('renders supply in the source minimal-decimal format for divisible tokens', async function () { + const db = precisionDb([{ decimals: 8 }]); + await recomputeTokenSupplies(db); + const sql = db.queries.find(q => /^UPDATE tokens/i.test(q)); + assert.ok(/TRIM\(TRAILING '\.' FROM TRIM\(TRAILING '0' FROM CAST\(CAST\(SUM\(amt\) AS DECIMAL\(60,8\)\) AS CHAR\)\)\)/i.test(sql), + 'divisible: round the total once, then trim trailing zeros and the bare dot'); + }); + + it('renders an integer (no zero-trim) for a non-divisible token (decimals=0)', async function () { + const db = precisionDb([{ decimals: 0 }]); + await recomputeTokenSupplies(db); + const sql = db.queries.find(q => /^UPDATE tokens/i.test(q)); + // decimals=0: CAST AS CHAR has no '.', so the trailing-zero trim would eat + // integer zeros ('100' -> '1'); it must emit the integer verbatim. + assert.ok(/CAST\(CAST\(SUM\(amt\) AS DECIMAL\(60,0\)\) AS CHAR\) AS supply/i.test(sql)); + assert.ok(!/TRIM\(TRAILING '0'/i.test(sql), 'no zero-trim on an integer supply'); + }); +} + +function registerRecomputeGuardTests(precisionDb) { + it('clamps an out-of-range precision to [0,18] (no DECIMAL-scale injection)', async function () { + const db = precisionDb([{ decimals: 99 }]); + await recomputeTokenSupplies(db); + const sql = db.queries.find(q => /^UPDATE tokens/i.test(q)); + assert.ok(/DECIMAL\(60,18\)/i.test(sql), 'clamped to the max scale'); + assert.ok(!/DECIMAL\(60,99\)/i.test(sql)); + }); + + it('is a no-op when there are no tokens', async function () { + const db = precisionDb([]); + await recomputeTokenSupplies(db); + assert.ok(!db.queries.some(q => /^UPDATE tokens/i.test(q)), 'no UPDATE without any token precisions'); + }); + + it('rejects if the DB layer fails (caller owns error handling)', async function () { + const boom = { doQuery: async () => { throw new Error('1146 no table'); } }; + await assert.rejects(() => recomputeTokenSupplies(boom), /1146/); + }); +} + describe('balance-helpers @money @regression', function () { describe('rebuildBalances()', function () { let db; beforeEach(async function () { db = fakeDb(); await rebuildBalances(db); }); - - it('clears the table before reinserting (DELETE then INSERT, in order)', function () { - assert.strictEqual(db.queries.length, 2); - assert.ok(/^DELETE FROM balances/i.test(db.queries[0])); - assert.ok(/INSERT INTO balances \(address_id, tick_id, amount\)/i.test(db.queries[1])); - }); - - it('aggregates credits minus debits per (address_id, tick_id)', function () { - const sql = db.queries[1]; - assert.ok(/FROM credits/i.test(sql) && /FROM debits/i.test(sql), 'unions credits + debits'); - assert.ok(/UNION ALL/i.test(sql)); - assert.ok(/WHEN t\.type = 'credit' THEN CAST\(t\.amount AS DECIMAL\(65,18\)\) ELSE -CAST\(t\.amount AS DECIMAL\(65,18\)\) END/i.test(sql), 'credit positive, debit negative'); - assert.ok(/GROUP BY address_id, tick_id/i.test(sql)); - }); - - it('sums at DECIMAL(65,18) (bare VARCHAR amounts promote to DOUBLE and corrupt >16-digit amounts)', function () { - const sql = db.queries[1]; - assert.ok(!/THEN t\.amount/i.test(sql), 'no un-cast amount may reach the SUM'); - assert.ok(/CAST\(t\.amount AS DECIMAL\(65,18\)\)/i.test(sql)); - }); - - it('renders amounts in the source indexer\'s minimal-decimal format', function () { - assert.ok(/TRIM\(TRAILING '\.' FROM TRIM\(TRAILING '0' FROM CAST\(/i.test(db.queries[1]), - 'trailing zeros then the bare dot are trimmed so rebuilt bytes match source bytes'); - }); - - it('prunes rows that net to exactly zero (HAVING ... != 0)', function () { - assert.ok(/HAVING SUM\(.*\) != 0/i.test(db.queries[1])); - }); - - it('rejects if the DB layer fails (caller owns error handling)', async function () { - const boom = { doQuery: async () => { throw new Error('1146 no table'); } }; - await assert.rejects(() => rebuildBalances(boom), /1146/); - }); + registerRebuildBalancesTests(() => db); }); describe('rebuildBalances(): scoped', function () { const scope = { addressIds: [7, 9], tickIds: [3] }; let db; beforeEach(async function () { db = fakeDb(); await rebuildBalances(db, scope); }); - - it('scopes the DELETE to the touched ids (parameterized IN-lists)', function () { - assert.ok(/^DELETE FROM balances WHERE address_id IN \(\?, \?\) AND tick_id IN \(\?\)$/i.test(db.queries[0])); - assert.deepStrictEqual(db.argsLog[0], [7, 9, 3]); - }); - - it('scopes BOTH union branches so the source scans stay bounded', function () { - const sql = db.queries[1]; - const branchPreds = sql.match(/FROM (credits|debits) WHERE address_id IN \(\?, \?\) AND tick_id IN \(\?\)/gi) || []; - assert.strictEqual(branchPreds.length, 2, 'credits and debits both scoped'); - assert.deepStrictEqual(db.argsLog[1], [7, 9, 3, 7, 9, 3], 'args repeated per branch'); - }); - - it('keeps the full-rebuild aggregation semantics (signs, grouping, zero pruning)', function () { - const sql = db.queries[1]; - assert.ok(/WHEN t\.type = 'credit' THEN CAST\(t\.amount AS DECIMAL\(65,18\)\) ELSE -CAST\(t\.amount AS DECIMAL\(65,18\)\) END/i.test(sql)); - assert.ok(/GROUP BY address_id, tick_id/i.test(sql)); - assert.ok(/HAVING SUM\(.*\) != 0/i.test(sql)); - }); - - it('is a no-op when the scope is empty (nothing was touched)', async function () { - const empty = fakeDb(); - await rebuildBalances(empty, { addressIds: [], tickIds: [] }); - assert.strictEqual(empty.queries.length, 0); - }); - - it('an absent scope still issues the unscoped full rebuild', async function () { - const full = fakeDb(); - await rebuildBalances(full); - assert.ok(/^DELETE FROM balances$/i.test(full.queries[0])); - assert.ok(!/WHERE/i.test(full.queries[0])); - }); + registerScopedRebuildTests(() => db); }); // Fix #5 (MED): ClientRollback rebuilt balances but not tokens.supply on reorg, @@ -121,82 +218,9 @@ describe('balance-helpers @money @regression', function () { }; } - it('runs one UPDATE per distinct token precision, keyed by decimals', async function () { - const db = precisionDb([{ decimals: 8 }, { decimals: 0 }]); - await recomputeTokenSupplies(db); - const updates = db.queries.filter(q => /^UPDATE tokens/i.test(q)); - assert.strictEqual(updates.length, 2, 'one UPDATE per distinct precision'); - // Precision is the bind arg (a DECIMAL scale can't be parameterized, but the - // WHERE key can), so tokens are fixed precision-by-precision. - const updateArgs = db.argsLog.filter((_, i) => /^UPDATE tokens/i.test(db.queries[i])); - assert.deepStrictEqual(updateArgs.sort(), [[0], [8]]); - }); - - // Rows are summed EXACTLY (DECIMAL(60,18)) and the TOTAL is rounded once - // to the token's own scale: per-ROW rounding agreed with the indexer only - // while every amount sat on the token's grid, and inflates supply once - // fee amounts go finer than the tick. - it('sums (credits - debits) + escrows at the EXACT scale and rounds ONCE at the token scale', async function () { - const db = precisionDb([{ decimals: 8 }]); - await recomputeTokenSupplies(db); - const sql = db.queries.find(q => /^UPDATE tokens/i.test(q)); - assert.ok(/FROM credits/i.test(sql) && /FROM debits/i.test(sql) && /FROM escrows/i.test(sql), - 'unions credits + debits + escrows (escrows folds into supply, unlike balances)'); - assert.ok(/SELECT tick_id, ?CAST\(amount AS DECIMAL\(60,18\)\) AS amt FROM credits/i.test(sql), 'credit is positive'); - assert.ok(/- ?CAST\(amount AS DECIMAL\(60,18\)\) AS amt FROM debits/i.test(sql), 'debit is negative'); - assert.ok(/CAST\(amount AS DECIMAL\(60,18\)\) AS amt FROM escrows/i.test(sql), 'escrow is positive'); - assert.ok(/CAST\(SUM\(amt\) AS DECIMAL\(60,8\)\)/i.test(sql), - 'the TOTAL is rounded once at the token scale, so it stays byte-identical to the source supply'); - assert.ok(!/CAST\(amount AS DECIMAL\(60,8\)\)/i.test(sql), - 'must NOT round each ROW to the token scale (the per-row rounding overcharge shape)'); - assert.ok(!/DECIMAL\(65,18\)/i.test(sql), 'must NOT use the fixed 65,18 scale (byte-identity needs the token scale)'); - assert.ok(/GROUP BY tick_id/i.test(sql)); - }); - - it('LEFT JOINs so a token with no surviving ledger rows resolves to supply 0', async function () { - const db = precisionDb([{ decimals: 8 }]); - await recomputeTokenSupplies(db); - const sql = db.queries.find(q => /^UPDATE tokens/i.test(q)); - assert.ok(/LEFT JOIN/i.test(sql), 'LEFT JOIN keeps tokens with no ledger rows'); - assert.ok(/SET tok\.supply = COALESCE\(agg\.supply, '0'\)/i.test(sql), 'no-ledger token -> supply 0'); - }); - - it('renders supply in the source minimal-decimal format for divisible tokens', async function () { - const db = precisionDb([{ decimals: 8 }]); - await recomputeTokenSupplies(db); - const sql = db.queries.find(q => /^UPDATE tokens/i.test(q)); - assert.ok(/TRIM\(TRAILING '\.' FROM TRIM\(TRAILING '0' FROM CAST\(CAST\(SUM\(amt\) AS DECIMAL\(60,8\)\) AS CHAR\)\)\)/i.test(sql), - 'divisible: round the total once, then trim trailing zeros and the bare dot'); - }); - - it('renders an integer (no zero-trim) for a non-divisible token (decimals=0)', async function () { - const db = precisionDb([{ decimals: 0 }]); - await recomputeTokenSupplies(db); - const sql = db.queries.find(q => /^UPDATE tokens/i.test(q)); - // decimals=0: CAST AS CHAR has no '.', so the trailing-zero trim would eat - // integer zeros ('100' -> '1'); it must emit the integer verbatim. - assert.ok(/CAST\(CAST\(SUM\(amt\) AS DECIMAL\(60,0\)\) AS CHAR\) AS supply/i.test(sql)); - assert.ok(!/TRIM\(TRAILING '0'/i.test(sql), 'no zero-trim on an integer supply'); - }); - - it('clamps an out-of-range precision to [0,18] (no DECIMAL-scale injection)', async function () { - const db = precisionDb([{ decimals: 99 }]); - await recomputeTokenSupplies(db); - const sql = db.queries.find(q => /^UPDATE tokens/i.test(q)); - assert.ok(/DECIMAL\(60,18\)/i.test(sql), 'clamped to the max scale'); - assert.ok(!/DECIMAL\(60,99\)/i.test(sql)); - }); - - it('is a no-op when there are no tokens', async function () { - const db = precisionDb([]); - await recomputeTokenSupplies(db); - assert.ok(!db.queries.some(q => /^UPDATE tokens/i.test(q)), 'no UPDATE without any token precisions'); - }); - - it('rejects if the DB layer fails (caller owns error handling)', async function () { - const boom = { doQuery: async () => { throw new Error('1146 no table'); } }; - await assert.rejects(() => recomputeTokenSupplies(boom), /1146/); - }); + registerRecomputeUpdateTests(precisionDb); + registerRecomputeRenderingTests(precisionDb); + registerRecomputeGuardTests(precisionDb); }); }); From 058215cccbc251da8d54870665e3df527096b6a9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 15:43:05 -0700 Subject: [PATCH 09/62] test(sync): split hash continuity registrations Extract the hash-continuity cases into bounded module-scope registration helpers. Preserve hook count, test order, titles, assertions, and synchronous scheduling. --- test/unit/boundaries/hash_continuity.test.js | 29 ++++++++++++++------ 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/test/unit/boundaries/hash_continuity.test.js b/test/unit/boundaries/hash_continuity.test.js index 57c570db..5b5902dc 100644 --- a/test/unit/boundaries/hash_continuity.test.js +++ b/test/unit/boundaries/hash_continuity.test.js @@ -11,15 +11,10 @@ const assert = require('assert'); const HashVerifier = require('../../../src/client/hash_verifier'); -describe('Boundary: Hash Continuity Check', function(){ - - let verifier; - const hashes = { ledger_hash: 'aaa', actions_hash: 'bbb', contract_hash: 'ccc' }; - - beforeEach(function(){ - verifier = new HashVerifier(); - }); +let verifier; +const hashes = { ledger_hash: 'aaa', actions_hash: 'bbb', contract_hash: 'ccc' }; +function registerBlockIndexContinuityTests(){ describe('block index continuity (exact +1 requirement)', function(){ it('valid: 10 → 11 (sequential)', function(){ let result = verifier.verifyChainContinuity(10, hashes, { block_index: 11 }); @@ -58,7 +53,9 @@ describe('Boundary: Hash Continuity Check', function(){ assert.strictEqual(result.valid, false); }); }); +} +function registerBootstrapTests(){ describe('null prevBlockIndex (bootstrap)', function(){ it('valid: null → 1 (first block)', function(){ let result = verifier.verifyChainContinuity(null, null, { block_index: 1 }); @@ -76,14 +73,18 @@ describe('Boundary: Hash Continuity Check', function(){ assert.strictEqual(result.valid, true); }); }); +} +function registerNullHashTests(){ describe('null prevHashes', function(){ it('valid: prevBlockIndex=5, prevHashes=null → skips check', function(){ let result = verifier.verifyChainContinuity(null, null, { block_index: 6 }); assert.strictEqual(result.valid, true); }); }); +} +function registerHashComparisonTests(){ describe('hash comparison boundaries', function(){ it('match: all three hashes identical', function(){ let result = verifier.compareBlockHashes(1, hashes, { ...hashes }); @@ -128,4 +129,16 @@ describe('Boundary: Hash Continuity Check', function(){ assert.strictEqual(result.mismatches.length, 3); }); }); +} + +describe('Boundary: Hash Continuity Check', function(){ + + beforeEach(function(){ + verifier = new HashVerifier(); + }); + + registerBlockIndexContinuityTests(); + registerBootstrapTests(); + registerNullHashTests(); + registerHashComparisonTests(); }); From edb99a68e9821b4f699e67a6d4e8dfd0854d1e82 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 15:44:11 -0700 Subject: [PATCH 10/62] test(sync): split config boundary registrations Extract the config boundary groups into synchronous module-scope registration helpers. Preserve hook scope, title order, assertions, and zero-yield behavior while lowering the structural finding count. --- test/unit/boundaries/config_parsing.test.js | 79 ++++++++++++++------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/test/unit/boundaries/config_parsing.test.js b/test/unit/boundaries/config_parsing.test.js index 468a7d90..ca071346 100644 --- a/test/unit/boundaries/config_parsing.test.js +++ b/test/unit/boundaries/config_parsing.test.js @@ -11,33 +11,7 @@ const assert = require('assert'); const config = require('../../../src/config'); -describe('Boundary: Config Parsing', function(){ - - const ENV_KEYS = [ - 'SYNC_MODE', 'SYNC_API_PORT', 'HUB_API_HOST', 'HUB_PORT', - 'CORS_ORIGIN', 'BLOCK_POLL_INTERVAL', 'WS_MAX_PER_IP', - 'SNAPSHOT_RATE_FULL', 'SNAPSHOT_RATE_INCR', 'SYNC_SOURCES', - 'VERIFY_HASHES', 'REPLICA_DB_HOST', 'REPLICA_DB_PORT', - 'REPLICA_DB_USER', 'REPLICA_DB_PASS' - ]; - let savedEnv = {}; - - beforeEach(function(){ - for(let key of ENV_KEYS){ - savedEnv[key] = process.env[key]; - delete process.env[key]; - } - }); - - afterEach(function(){ - for(let key of ENV_KEYS){ - if(savedEnv[key] !== undefined) - process.env[key] = savedEnv[key]; - else - delete process.env[key]; - } - }); - +function registerZeroValueTests(){ describe('zero values (falsy-zero)', function(){ it('preserves SYNC_API_PORT=0', function(){ process.env.SYNC_API_PORT = '0'; @@ -59,7 +33,9 @@ describe('Boundary: Config Parsing', function(){ assert.strictEqual(config.getConfig().SNAPSHOT_RATE_INCR, 0); }); }); +} +function registerNegativeValueTests(){ describe('negative values clamped', function(){ it('clamps WS_MAX_PER_IP=-1 to 1', function(){ process.env.WS_MAX_PER_IP = '-1'; @@ -86,7 +62,9 @@ describe('Boundary: Config Parsing', function(){ assert.strictEqual(config.getConfig().SNAPSHOT_RATE_FULL, 0); }); }); +} +function registerInvalidValueTests(){ describe('NaN inputs default gracefully', function(){ it('SYNC_API_PORT=abc defaults to 3006', function(){ process.env.SYNC_API_PORT = 'abc'; @@ -108,7 +86,9 @@ describe('Boundary: Config Parsing', function(){ assert.strictEqual(config.getConfig().BLOCK_POLL_INTERVAL, 3000); }); }); +} +function registerFloatingPointTests(){ describe('float strings truncated', function(){ it('WS_MAX_PER_IP=3.7 becomes 3', function(){ process.env.WS_MAX_PER_IP = '3.7'; @@ -120,7 +100,9 @@ describe('Boundary: Config Parsing', function(){ assert.strictEqual(config.getConfig().SYNC_API_PORT, 3006); }); }); +} +function registerLargeValueTests(){ describe('large values', function(){ it('SNAPSHOT_RATE_FULL=999999 preserved', function(){ process.env.SNAPSHOT_RATE_FULL = '999999'; @@ -132,7 +114,9 @@ describe('Boundary: Config Parsing', function(){ assert.strictEqual(config.getConfig().BLOCK_POLL_INTERVAL, 2147483647); }); }); +} +function registerBooleanTests(){ describe('VERIFY_HASHES case insensitivity', function(){ it('"false" disables', function(){ process.env.VERIFY_HASHES = 'false'; @@ -159,7 +143,9 @@ describe('Boundary: Config Parsing', function(){ assert.strictEqual(config.getConfig().VERIFY_HASHES, true); }); }); +} +function registerSourceListTests(){ describe('SYNC_SOURCES parsing boundaries', function(){ it('empty string yields empty after getConfig', function(){ process.env.SYNC_SOURCES = ''; @@ -181,7 +167,9 @@ describe('Boundary: Config Parsing', function(){ assert.strictEqual(config.getConfig().SYNC_SOURCES, ' http://s1 , http://s2 '); }); }); +} +function registerPortBoundaryTests(){ describe('minimum-one clamping for ports', function(){ it('WS_MAX_PER_IP=0 clamps to 1', function(){ process.env.WS_MAX_PER_IP = '0'; @@ -203,4 +191,41 @@ describe('Boundary: Config Parsing', function(){ assert.strictEqual(config.getConfig().REPLICA_DB_PORT, 1); }); }); +} + +describe('Boundary: Config Parsing', function(){ + + const ENV_KEYS = [ + 'SYNC_MODE', 'SYNC_API_PORT', 'HUB_API_HOST', 'HUB_PORT', + 'CORS_ORIGIN', 'BLOCK_POLL_INTERVAL', 'WS_MAX_PER_IP', + 'SNAPSHOT_RATE_FULL', 'SNAPSHOT_RATE_INCR', 'SYNC_SOURCES', + 'VERIFY_HASHES', 'REPLICA_DB_HOST', 'REPLICA_DB_PORT', + 'REPLICA_DB_USER', 'REPLICA_DB_PASS' + ]; + let savedEnv = {}; + + beforeEach(function(){ + for(let key of ENV_KEYS){ + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(function(){ + for(let key of ENV_KEYS){ + if(savedEnv[key] !== undefined) + process.env[key] = savedEnv[key]; + else + delete process.env[key]; + } + }); + + registerZeroValueTests(); + registerNegativeValueTests(); + registerInvalidValueTests(); + registerFloatingPointTests(); + registerLargeValueTests(); + registerBooleanTests(); + registerSourceListTests(); + registerPortBoundaryTests(); }); From e1b239d13cf5a0e64d9dc8439c9bd6ecc42f3432 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 18:15:20 -0700 Subject: [PATCH 11/62] fix(sync): carry an edited tokens row to the replica in the block the ISSUE landed in A valid ISSUE re-derives the whole tokens row from the tick's issues history, so an edit of an existing tick rewrites owner_id, description, the locks, the callback and list fields and the bridge opt-in in place. Both action_index and last_action_index stay pinned at the first issuance, so the action-scoped stream carried the new issues row and never the tokens row it produced, and the only in-place carry keyed on ticks touched by a credit, debit or escrow row. An ownership transfer moves no balance, so every replica served the pre-transfer owner until some later action on the tick happened to move one (xchain-indexer#39: a TDOGE token kept its old owner for 24 blocks, then flipped when an unrelated DESTROY landed). Add the tokens-edit class beside the supply one, keyed on the ticks carrying a valid issues row in the window. Forward only: the reorg direction needs a replica-side re-derive and is registered separately. The 400-line limit put the two tokens classes and the row accumulator into named parts, and the integration proofs for the tokens table into one directory. The registry note is the vendored half of the indexer's canonical edit. --- src/server/updated_rows.js | 74 ++----- src/server/updated_rows/accumulator.js | 31 +++ src/server/updated_rows/token_rows.js | 127 ++++++++++++ src/table_lifecycle/action_tables.js | 2 +- .../tokens.test/owner_edit_refresh.test.js | 180 ++++++++++++++++++ .../supply_recompute.test.js} | 6 +- test/unit/updated_rows.test.js | 54 +++++- 7 files changed, 402 insertions(+), 72 deletions(-) create mode 100644 src/server/updated_rows/accumulator.js create mode 100644 src/server/updated_rows/token_rows.js create mode 100644 test/integration/tokens.test/owner_edit_refresh.test.js rename test/integration/{token_supply_recompute.test.js => tokens.test/supply_recompute.test.js} (97%) diff --git a/src/server/updated_rows.js b/src/server/updated_rows.js index dbbab58e..9fac63ac 100644 --- a/src/server/updated_rows.js +++ b/src/server/updated_rows.js @@ -69,6 +69,14 @@ * cursor, so the action-scoped stream misses every later supply bump. Found via * the ticks touched by a credit / debit / escrow row in this window, since those * ledger tables are action-scoped and pin the supply change to a block. + * - metadata refresh on a surviving tokens row (the indexer re-derives every + * derived token column from the `issues` history on each valid ISSUE, so an + * EDIT of an existing tick - ownership TRANSFER, description, the locks, the + * callback and list fields, the bridge opt-in - is an in-place UPDATE). Both + * action_index and last_action_index stay pinned at the first issuance, below + * the cursor, so the action-scoped stream misses the edit. Found via the + * ticks carrying a valid `issues` row in this window, since `issues` is + * action-scoped and pins the edit to a block. * * tokens.escrow_action_index rides along here (the tokens class selects `t.*`), so the * source's own authoritative gate value lands on the replica. The follower ALSO @@ -86,18 +94,8 @@ const { POLL_FINALIZE_TABLES, COOLDOWN_STATUS_TABLES, ATTEST_BATCH_HEAD_VERSION, ATTEST_BATCH_CONTINUATION_VERSION, ATTEST_BATCH_COMPLETION_STAMP, BET_STATUS_SPECS } = require('./updated_rows/table_specs.js'); - -// table -> Map(action_index -> row). The Map dedups rows reached by more than -// one class (e.g. a stake both deactivated and slashed in the same window) by -// their UNIQUE action_index, so each table emits each surviving row once. -function add(acc, table, rows){ - if(!rows || rows.length === 0) return; - let m = acc[table] || (acc[table] = new Map()); - for(let r of rows){ - if(r && r.action_index !== undefined && r.action_index !== null) - m.set(String(r.action_index), r); - } -} +const { add } = require('./updated_rows/accumulator.js'); +const { collectTokenSupplyRows, collectTokenEditRows } = require('./updated_rows/token_rows.js'); // Query classes skip missing tables or columns on older source schemas. Other // numeric database errors propagate to the caller. @@ -305,57 +303,6 @@ async function collectAttestBatchHeadRows(db, from, to, conn, acc){ } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; } } -async function collectTokenSupplyRows(db, from, to, conn, acc){ - // 6. tokens.supply refresh on surviving token rows. The indexer materialises - // tokens.supply as an in-place UPDATE (db.createToken on DEPLOY/ISSUE/MINT and - // db.updateTokens after order/swap/dispense settlement and STAKE rebalances). The - // row's action_index stays at the DEPLOY action, and last_action_index is also - // written back to that same DEPLOY index (createToken sets both from the first - // valid issuance), so BOTH columns sit below the catch-up cursor: the - // action-scoped stream keyed on action_index never carries the later supply bump. - // Followers therefore served a stale supply (invisible to /status counts and not - // covered by any hash). Supply changes exactly when a credit / debit / escrow row - // is written for the tick, and those ledger tables ARE action-scoped (they ride the - // per-block / catch-up stream). So the set of ticks whose supply moved in this - // window is exactly the set of tick_ids touched by a credit / debit / escrow row - // whose action falls in [from, to]. We carry the CURRENT full tokens row for those - // ticks (SELECT t.* -> the source `id`, which followers replicate verbatim, so the - // follower's INSERT ... ON DUPLICATE KEY UPDATE lands on the matching PRIMARY KEY - // row and overwrites supply to the source's current value). Idempotent: re-sending - // an already-current row is a no-op. Reorg-safe: on rollback the source - // re-materialises supply (rollback.js -> updateTokens) and the next forward window's - // ledger changes re-emit the refreshed row; in-order block apply means a later - // window's row never lands before an earlier one. tokens.supply stays out of the - // consensus block hashes, but since 2026-07-07 this class HAS a state_hash twin: - // buildStateHashData's token_supply class hashes (tick, supply) for the same - // ledger-touched tick set (flag-day gated per chain via - // TOKEN_SUPPLY_STATE_HASH_ACTIVATION), so once armed, a follower that drops this - // upsert halts at the block instead of serving a stale supply. - try { - // Join each ledger table to `actions` independently and UNION the tick_ids, - // rather than UNION ALL-ing the three full tables into a derived table and - // joining once. The derived-table form forces MariaDB to materialise every - // credits/debits/escrows row before the block-range predicate can apply (it - // cannot push `a.block_index BETWEEN ? AND ?` down into the UNION ALL), an - // O(total ledger size) scan on every block/catch-up window. Per-branch joins - // let the optimiser drive from `actions` (block_index range) into each table - // via its action_index index. UNION (not UNION ALL) preserves the original - // SELECT DISTINCT semantics, so the emitted tick set is byte-identical. - let tokenRows = await db.doQuery( - "SELECT t.* FROM `tokens` t WHERE t.tick_id IN (" + - "SELECT c.tick_id FROM credits c JOIN actions a ON a.action_index = c.action_index " + - "WHERE a.block_index BETWEEN ? AND ? AND c.tick_id IS NOT NULL " + - "UNION " + - "SELECT d.tick_id FROM debits d JOIN actions a ON a.action_index = d.action_index " + - "WHERE a.block_index BETWEEN ? AND ? AND d.tick_id IS NOT NULL " + - "UNION " + - "SELECT e.tick_id FROM escrows e JOIN actions a ON a.action_index = e.action_index " + - "WHERE a.block_index BETWEEN ? AND ? AND e.tick_id IS NOT NULL)", - [from, to, from, to, from, to], conn); - add(acc, 'tokens', tokenRows); - } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; } -} - // Collect the in-place-mutated surviving rows for the block window [fromBlock, toBlock]. // Returns a { tableName: [rows] } map (only non-empty tables). Rows are raw DB rows; // the caller is responsible for wire-encoding binary columns (encodeRow / encodeTables). @@ -379,6 +326,7 @@ async function collectUpdatedRows(db, fromBlock, toBlock, activationDelay, conn) await collectInvalidArchiveRows(db, from, to, conn, acc); await collectAttestBatchHeadRows(db, from, to, conn, acc); await collectTokenSupplyRows(db, from, to, conn, acc); + await collectTokenEditRows(db, from, to, conn, acc); let out = {}; for(let table in acc){ let arr = Array.from(acc[table].values()); diff --git a/src/server/updated_rows/accumulator.js b/src/server/updated_rows/accumulator.js new file mode 100644 index 00000000..288e926c --- /dev/null +++ b/src/server/updated_rows/accumulator.js @@ -0,0 +1,31 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * XChain Sync - Updated rows: the row accumulator + * + ********************************************************************/ + +// table -> Map(action_index -> row). The Map dedups rows reached by more than +// one class (e.g. a stake both deactivated and slashed in the same window) by +// their UNIQUE action_index, so each table emits each surviving row once. +function add(acc, table, rows){ + if(!rows || rows.length === 0) return; + let m = acc[table] || (acc[table] = new Map()); + for(let r of rows){ + if(r && r.action_index !== undefined && r.action_index !== null) + m.set(String(r.action_index), r); + } +} + +module.exports = { add }; diff --git a/src/server/updated_rows/token_rows.js b/src/server/updated_rows/token_rows.js new file mode 100644 index 00000000..2379ce54 --- /dev/null +++ b/src/server/updated_rows/token_rows.js @@ -0,0 +1,127 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * XChain Sync - Updated rows: the two tokens-table classes + * + ********************************************************************/ + +const { add } = require('./accumulator.js'); + +// The two classes that refresh a surviving `tokens` row, split out of the entry because +// they share one shape: the row's action_index is pinned at the tick's first issuance, so +// nothing the tick does later moves it back into the action-scoped stream's window. They +// differ only in what pins the mutation to a block - a ledger row for supply, an `issues` +// row for the metadata. Each adds to `acc` in place, like every other class here. + +async function collectTokenSupplyRows(db, from, to, conn, acc){ + // 6. tokens.supply refresh on surviving token rows. The indexer materialises + // tokens.supply as an in-place UPDATE (db.createToken on DEPLOY/ISSUE/MINT and + // db.updateTokens after order/swap/dispense settlement and STAKE rebalances). The + // row's action_index stays at the DEPLOY action, and last_action_index is also + // written back to that same DEPLOY index (createToken sets both from the first + // valid issuance), so BOTH columns sit below the catch-up cursor: the + // action-scoped stream keyed on action_index never carries the later supply bump. + // Followers therefore served a stale supply (invisible to /status counts and not + // covered by any hash). Supply changes exactly when a credit / debit / escrow row + // is written for the tick, and those ledger tables ARE action-scoped (they ride the + // per-block / catch-up stream). So the set of ticks whose supply moved in this + // window is exactly the set of tick_ids touched by a credit / debit / escrow row + // whose action falls in [from, to]. We carry the CURRENT full tokens row for those + // ticks (SELECT t.* -> the source `id`, which followers replicate verbatim, so the + // follower's INSERT ... ON DUPLICATE KEY UPDATE lands on the matching PRIMARY KEY + // row and overwrites supply to the source's current value). Idempotent: re-sending + // an already-current row is a no-op. Reorg-safe: on rollback the source + // re-materialises supply (rollback.js -> updateTokens) and the next forward window's + // ledger changes re-emit the refreshed row; in-order block apply means a later + // window's row never lands before an earlier one. tokens.supply stays out of the + // consensus block hashes, but since 2026-07-07 this class HAS a state_hash twin: + // buildStateHashData's token_supply class hashes (tick, supply) for the same + // ledger-touched tick set (flag-day gated per chain via + // TOKEN_SUPPLY_STATE_HASH_ACTIVATION), so once armed, a follower that drops this + // upsert halts at the block instead of serving a stale supply. + try { + // Join each ledger table to `actions` independently and UNION the tick_ids, + // rather than UNION ALL-ing the three full tables into a derived table and + // joining once. The derived-table form forces MariaDB to materialise every + // credits/debits/escrows row before the block-range predicate can apply (it + // cannot push `a.block_index BETWEEN ? AND ?` down into the UNION ALL), an + // O(total ledger size) scan on every block/catch-up window. Per-branch joins + // let the optimiser drive from `actions` (block_index range) into each table + // via its action_index index. UNION (not UNION ALL) preserves the original + // SELECT DISTINCT semantics, so the emitted tick set is byte-identical. + let tokenRows = await db.doQuery( + "SELECT t.* FROM `tokens` t WHERE t.tick_id IN (" + + "SELECT c.tick_id FROM credits c JOIN actions a ON a.action_index = c.action_index " + + "WHERE a.block_index BETWEEN ? AND ? AND c.tick_id IS NOT NULL " + + "UNION " + + "SELECT d.tick_id FROM debits d JOIN actions a ON a.action_index = d.action_index " + + "WHERE a.block_index BETWEEN ? AND ? AND d.tick_id IS NOT NULL " + + "UNION " + + "SELECT e.tick_id FROM escrows e JOIN actions a ON a.action_index = e.action_index " + + "WHERE a.block_index BETWEEN ? AND ? AND e.tick_id IS NOT NULL)", + [from, to, from, to, from, to], conn); + add(acc, 'tokens', tokenRows); + } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; } +} + +async function collectTokenEditRows(db, from, to, conn, acc){ + // 7. tokens metadata refresh on surviving token rows, class 6's sibling one column + // family over. Every valid ISSUE re-derives the WHOLE tokens row from the tick's + // `issues` history (issue/settle.js -> createToken, then updateTokens -> + // getTokenInfo's replay), so an ISSUE that EDITS an existing tick is an in-place + // UPDATE of owner_id, description, the seven locks, the callback and list fields, + // the mint window and the bridge opt-in. action_index and last_action_index both + // stay pinned at the FIRST issuance, so - exactly as with supply - the + // action-scoped stream carries the new `issues` row but never the edited `tokens` + // row it produced. + // + // Class 6 hid this for every edit that also moves a balance (its ledger-touched + // tick set catches those), which is why it surfaced as an intermittent bug rather + // than a permanent one: a fee-free edit that writes NO credit / debit / escrow row + // in its own tick left the replica on the pre-edit row until some unrelated later + // action on the tick moved a balance and re-emitted it. An ownership TRANSFER + // (`ISSUE|0|||||||`) is exactly that shape, so explorer read APIs + // served the OLD owner indefinitely while consensus had the new one + // (xchain-indexer#39; ownership-gated client UI reads this field). + // + // Keyed on the tick's valid `issues` rows in the window rather than on the ISSUE + // action alone: `issues` stores every ISSUE, valid or not, and only a valid one + // reaches createToken, so an invalid edit must not re-emit (harmless, but it would + // make the class claim a mutation that never happened). SELECT t.* and the + // add()-by-action_index dedup are class 6's, so a tick reached by both classes in + // one window emits once. + // + // FORWARD ONLY, deliberately. A reorg that orphans an edit-ISSUE leaves no valid + // `issues` row behind for the tick, so nothing re-emits the row and the follower + // keeps the orphaned edit's values while the source re-folds back (rollback.js -> + // updateTokens). That reverse leg needs a replica-side re-derive beside the escrow + // one in ClientRollback and is not built here. + // + // UN-GATED, like classes 5 and 5b: shipping a row is not a hash preimage, and no + // state_hash class covers these columns (the token_supply twin hashes (tick, supply) + // only), so a follower that never receives the edit diverges silently instead of + // halting. That is the gap this closes, and it must be live before any future + // state-hash twin arms or a follower would halt on a row it was never sent. + try { + let tokenRows = await db.doQuery( + "SELECT t.* FROM `tokens` t WHERE t.tick_id IN (" + + "SELECT i.tick_id FROM issues i " + + "JOIN actions a ON a.action_index = i.action_index " + + "JOIN index_statuses s ON s.id = i.status_id AND s.status = 'valid' " + + "WHERE a.block_index BETWEEN ? AND ? AND i.tick_id IS NOT NULL)", + [from, to], conn); + add(acc, 'tokens', tokenRows); + } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; } +} +module.exports = { collectTokenSupplyRows, collectTokenEditRows }; diff --git a/src/table_lifecycle/action_tables.js b/src/table_lifecycle/action_tables.js index 323076e0..f41d2602 100644 --- a/src/table_lifecycle/action_tables.js +++ b/src/table_lifecycle/action_tables.js @@ -126,7 +126,7 @@ const TABLES = [ { table: 'sweeps', owner: 'indexer', replication: 'stream:action', rollback: 'action', replicaRollback: 'mirror', hashed: DERIVED }, { table: 'tokens', owner: 'indexer', replication: 'stream:action', rollback: 'action', replicaRollback: 'mirror', alsoRecomputed: true, hashed: { classes: ['state_hash'], - note: 'The in-place supply mutation on a surviving token row (carried forward by the updated_rows tokens-supply class) is covered by the state_hash token_supply class: (tick, supply) per ledger-touched tick, flag-day gated per chain (TOKEN_SUPPLY_STATE_HASH_ACTIVATION, armed 2026-07-07 at tip + margin). Supply is also recomputed and sanity-checked against credits/debits/escrows each block; new rows are otherwise action-derived. This closes a supply-forward gap that would otherwise exist.' } }, + note: 'The in-place supply mutation on a surviving token row (carried forward by the updated_rows tokens-supply class) is covered by the state_hash token_supply class: (tick, supply) per ledger-touched tick, flag-day gated per chain (TOKEN_SUPPLY_STATE_HASH_ACTIVATION, armed 2026-07-07 at tip + margin). Supply is also recomputed and sanity-checked against credits/debits/escrows each block; new rows are otherwise action-derived. This closes a supply-forward gap that would otherwise exist. The row\'s OTHER derived columns (owner_id, description, the locks, the callback and list fields, the bridge opt-in) are re-derived from the `issues` history by every valid ISSUE, an in-place mutation with the same pinned action_index; the updated_rows tokens-EDIT class carries those forward, keyed on the tick\'s valid issues rows in the window. That class has NO state_hash twin, so a follower that misses one diverges silently rather than halting (xchain-indexer#39).' } }, { table: 'stakes', owner: 'indexer', replication: 'stream:action', rollback: 'action', replicaRollback: 'mirror', hashed: { classes: ['state_hash', 'state_commitment'], note: 'state_hash covers the in-place deactivation_block stamps and capability SLASH amount cuts; the light-client state commitment covers active BTC stake weights.' } }, diff --git a/test/integration/tokens.test/owner_edit_refresh.test.js b/test/integration/tokens.test/owner_edit_refresh.test.js new file mode 100644 index 00000000..802efc49 --- /dev/null +++ b/test/integration/tokens.test/owner_edit_refresh.test.js @@ -0,0 +1,180 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Behavioural proof for the tokens-EDIT updated-rows class, against real MariaDB and +// the real indexer schema. +// +// WHY THIS EXISTS. xchain-indexer#39: after a valid ownership transfer +// (ISSUE|0|||||||) the explorer served the OLD owner indefinitely, +// and flipped to the new one only once some unrelated later action touched the token. +// The source indexer was right the whole time; the replica the explorer reads was not. +// `tokens.action_index` is pinned at the tick's FIRST issuance, so an edit never +// re-enters the action-scoped stream's window, and the only carry that existed keyed +// on the ticks touched by a credit / debit / escrow row - which an ownership transfer +// does not write. That is why it looked intermittent: an edit that also moved a +// balance rode the supply class and looked fine. +// +// The unit tier pins the SQL shape. This pins what MariaDB actually selects over the +// real schema, and carries the row the whole way: source rows -> collectUpdatedRows +// -> ClientApplier.upsertRows -> the replica's own tokens.owner_id. + +const assert = require('assert'); +const sinon = require('sinon'); +const setup = require('../helpers/setup'); +const testDb = require('../helpers/testDb'); + +const { collectUpdatedRows } = require('../../../src/server/updated_rows'); +const ClientApplier = require('../../../src/client/applier'); + +const TICK = 'MGRTEST'; +const OLD = 'nOLDownerAddressForTheIssue39Repro'; +const NEW = 'nNEWownerAddressForTheIssue39Repro'; +const GENESIS_BLOCK = 100; // the tick's first issuance +const EDIT_BLOCK = 200; // the ownership transfer, far above it + +// One ISSUE: its actions row (what pins it to a block), its transactions row and its +// issues row. `transfer_id` null on the genesis issuance, the new owner on the edit. +async function seedIssue(db, ids, opts){ + await db.doQuery( + 'INSERT INTO transactions (tx_index, block_index, tx_hash_id, source_id) VALUES (?, ?, ?, ?)', + [opts.action_index, opts.block_index, opts.action_index, ids.old]); + await db.doQuery( + 'INSERT INTO actions (action_index, block_index, tx_index, tx_vout, action_id, action_format, source_id) ' + + 'VALUES (?, ?, ?, 0, ?, 0, ?)', + [opts.action_index, opts.block_index, opts.action_index, ids.action, ids.old]); + await db.doQuery( + 'INSERT INTO issues (action_index, tick_id, transfer_id, status_id) VALUES (?, ?, ?, ?)', + [opts.action_index, ids.tick, opts.transfer_id, opts.status_id]); +} + +// The interned ids every row above is keyed by, plus the tokens row the genesis +// issuance produced (owner = OLD, action_index pinned at the genesis action). +async function seedTick(db){ + await db.doQuery('INSERT INTO index_tickers (tick) VALUES (?)', [TICK]); + await db.doQuery('INSERT INTO index_addresses (address) VALUES (?), (?)', [OLD, NEW]); + await db.doQuery('INSERT INTO index_actions (action) VALUES (?)', ['ISSUE']); + await db.doQuery('INSERT INTO index_statuses (status) VALUES (?), (?)', ['valid', 'invalid: issued by another address']); + await db.doQuery('INSERT INTO index_transactions (hash) VALUES (?), (?)', ['tx_genesis_issue', 'tx_owner_transfer']); + let ids = { + tick: (await db.doQuery('SELECT id FROM index_tickers WHERE tick=?', [TICK]))[0].id, + old: (await db.doQuery('SELECT id FROM index_addresses WHERE address=?', [OLD]))[0].id, + new: (await db.doQuery('SELECT id FROM index_addresses WHERE address=?', [NEW]))[0].id, + action: (await db.doQuery('SELECT id FROM index_actions WHERE action=?', ['ISSUE']))[0].id, + valid: (await db.doQuery('SELECT id FROM index_statuses WHERE status=?', ['valid']))[0].id, + invalid: (await db.doQuery('SELECT id FROM index_statuses WHERE status=?', ['invalid: issued by another address']))[0].id + }; + await seedIssue(db, ids, { action_index: 1, block_index: GENESIS_BLOCK, transfer_id: null, status_id: ids.valid }); + await db.doQuery( + 'INSERT INTO tokens (tick_id, owner_id, action_index, last_action_index, supply, decimals) VALUES (?, ?, 1, 1, ?, 0)', + [ids.tick, ids.old, '1000']); + return ids; +} + +// What the source indexer does when the transfer settles: the issues row, then the +// in-place tokens UPDATE (createToken's UPDATE binds owner_id and leaves action_index +// at the first issuance). No credit / debit / escrow row: a transfer moves no balance, +// which is the whole point of the repro. +async function applyTransferOnSource(db, ids){ + await seedIssue(db, ids, { action_index: 2, block_index: EDIT_BLOCK, transfer_id: ids.new, status_id: ids.valid }); + await db.doQuery('UPDATE tokens SET owner_id=?, last_action_index=1 WHERE tick_id=?', [ids.new, ids.tick]); +} + +async function ownerOf(db, ids){ + let rows = await db.doQuery('SELECT owner_id FROM tokens WHERE tick_id=? LIMIT 1', [ids.tick]); + return rows.length ? Number(rows[0].owner_id) : null; +} + +describe('Integration: tokens owner refresh after an edit-ISSUE (xchain-indexer#39)', function(){ + + let sourceDb, replicaDb; + + before(async function(){ + await setup.globalSetup(); + sourceDb = setup.getSourceDb(); + replicaDb = setup.getReplicaDb(); + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + }); + + after(async function(){ + sinon.restore(); + await setup.globalTeardown(); + }); + + beforeEach(async function(){ + await setup.resetDatabases(); + }); + + it('carries the new owner to the replica in the transfer block, with no ledger row anywhere', async function(){ + let ids = await seedTick(sourceDb); + // The replica is where the explorer reads: it holds the pre-transfer row. + let replicaIds = await seedTick(replicaDb); + assert.strictEqual(await ownerOf(replicaDb, replicaIds), replicaIds.old); + + await applyTransferOnSource(sourceDb, ids); + assert.strictEqual(await ownerOf(sourceDb, ids), ids.new, 'source indexer is right immediately'); + + // No ledger row exists at all, so the supply class contributes nothing and the + // edit class is the only thing that can carry this row. + for(let table of ['credits', 'debits', 'escrows']) + assert.strictEqual(await testDb.getRowCount(sourceDb, table), 0); + + let updated = await collectUpdatedRows(sourceDb, EDIT_BLOCK, EDIT_BLOCK, 6); + assert.ok(updated.tokens, 'the transfer block must carry the tokens row'); + assert.strictEqual(updated.tokens.length, 1); + assert.strictEqual(Number(updated.tokens[0].owner_id), ids.new); + + await new ClientApplier(replicaDb, testDb.util).upsertRows('tokens', updated.tokens); + assert.strictEqual(await ownerOf(replicaDb, replicaIds), replicaIds.new, + 'the replica the explorer reads must serve the new owner in this same block'); + }); + +}); + +describe('Integration: tokens owner refresh, the windows that must stay quiet', function(){ + + let sourceDb; + + before(async function(){ + await setup.globalSetup(); + sourceDb = setup.getSourceDb(); + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + }); + + after(async function(){ + sinon.restore(); + await setup.globalTeardown(); + }); + + beforeEach(async function(){ + await setup.resetDatabases(); + }); + + it('does not carry the row for a block whose only ISSUE on the tick is invalid', async function(){ + let ids = await seedTick(sourceDb); + // An ISSUE from the wrong address never reaches createToken, so no tokens row + // was mutated and there is nothing to refresh. + await seedIssue(sourceDb, ids, { action_index: 2, block_index: EDIT_BLOCK, transfer_id: ids.new, status_id: ids.invalid }); + + let updated = await collectUpdatedRows(sourceDb, EDIT_BLOCK, EDIT_BLOCK, 6); + assert.ok(!updated.tokens, 'an invalid ISSUE mutates nothing and must not re-emit the row'); + }); + + it('does not carry the row for a block the tick had no ISSUE in', async function(){ + let ids = await seedTick(sourceDb); + await applyTransferOnSource(sourceDb, ids); + + // The window the reporter polled through: every block after the transfer, none + // of which touches the tick. The class is keyed on the edit's own block. + let updated = await collectUpdatedRows(sourceDb, EDIT_BLOCK + 1, EDIT_BLOCK + 24, 6); + assert.ok(!updated.tokens, 'only the edit block re-emits the row'); + }); +}); diff --git a/test/integration/token_supply_recompute.test.js b/test/integration/tokens.test/supply_recompute.test.js similarity index 97% rename from test/integration/token_supply_recompute.test.js rename to test/integration/tokens.test/supply_recompute.test.js index 38d481ce..c1e92e27 100644 --- a/test/integration/token_supply_recompute.test.js +++ b/test/integration/tokens.test/supply_recompute.test.js @@ -10,9 +10,9 @@ const assert = require('assert'); const sinon = require('sinon'); -const setup = require('./helpers/setup'); -const testDb = require('./helpers/testDb'); -const balanceHelpers = require('../../src/db/balance_helpers'); +const setup = require('../helpers/setup'); +const testDb = require('../helpers/testDb'); +const balanceHelpers = require('../../../src/db/balance_helpers'); // Behavioural proof for recomputeTokenSupplies (the reorg tokens.supply fix). The // unit suite pins the SQL *shape*; this pins the arithmetic MariaDB actually diff --git a/test/unit/updated_rows.test.js b/test/unit/updated_rows.test.js index c732104d..cd1629e7 100644 --- a/test/unit/updated_rows.test.js +++ b/test/unit/updated_rows.test.js @@ -52,11 +52,11 @@ describe('updatedRows.collectUpdatedRows', function(){ let hitDeactivation = db.calls.some(c => c.sql.indexOf('deactivation_block') !== -1); assert.strictEqual(hitDeactivation, false); // The slash + delegation-rotation + request_status + poll-finalize + cooldown-status - // + bet-status + anchor_invalid + attest-batch-head + tokens-supply classes still run, - // none of which depend on the activation delay (4 slash + 2 rotation + 2 request - // + 1 poll + 2 cooldown-status + 2 bet-status + 1 anchor + 1 attest batch head - // + 1 tokens = 16). - assert.strictEqual(db.calls.length, 16); + // + bet-status + anchor_invalid + attest-batch-head + tokens-supply + tokens-edit + // classes still run, none of which depend on the activation delay (4 slash + // + 2 rotation + 2 request + 1 poll + 2 cooldown-status + 2 bet-status + 1 anchor + // + 1 attest batch head + 1 tokens supply + 1 tokens edit = 17). + assert.strictEqual(db.calls.length, 17); // And the cooldown status flip is keyed by cooldown_end_block, not the delay. let hitCooldown = db.calls.some(c => c.sql.indexOf('cooldown_end_block') !== -1); assert.strictEqual(hitCooldown, true); @@ -172,6 +172,50 @@ describe('updatedRows.collectUpdatedRows', function(){ }); }); +describe('updatedRows.collectUpdatedRows', function(){ + + afterEach(() => sinon.restore()); + + it('refreshes a surviving tokens row edited by an ISSUE that moved no balance (#39)', async function(){ + // The reported shape: an ownership transfer (ISSUE|0|||||||) rewrites + // tokens.owner_id in place but writes no credit / debit / escrow row in its own tick, + // so the ledger-keyed supply class above sees nothing and the action-scoped stream + // carries only the new `issues` row (tokens.action_index stays at the first issuance). + // Every follower served the PRE-TRANSFER owner until some later action on the tick + // happened to move a balance. Routed on the issues subquery alone, with NO route for + // the supply class, so this asserts the edit rides its own class. + let db = fakeDb([ + { match: 'SELECT i.tick_id FROM issues i', rows: [{ id: 5, tick_id: 42, action_index: 100, last_action_index: 100, owner_id: 77 }] } + ]); + let out = await collectUpdatedRows(db, 300, 300, 6); + let eq = db.calls.find(c => c.sql.indexOf('SELECT i.tick_id FROM issues i') !== -1); + assert.ok(eq, 'expected the tokens-edit refresh query'); + assert.deepStrictEqual(eq.args, [300, 300]); + // Keyed on the ISSUE action's block, and only a VALID ISSUE re-derives the row. + assert.ok(eq.sql.indexOf('JOIN actions a ON a.action_index = i.action_index') !== -1); + assert.match(eq.sql, /JOIN index_statuses s ON s\.id = i\.status_id AND s\.status = 'valid'/); + assert.ok(eq.sql.indexOf('a.block_index BETWEEN ? AND ?') !== -1); + // SELECT t.* carries the source `id` so the follower's upsert lands on the PK. + assert.ok(eq.sql.indexOf('SELECT t.*') !== -1); + assert.ok(out.tokens && out.tokens.length === 1); + assert.strictEqual(out.tokens[0].owner_id, 77); + }); + + it('emits one tokens row when the edit and supply classes both reach the same tick', async function(){ + // A MINT-bearing re-issuance moves a balance AND rewrites the row, so both token + // classes return it. add() dedups by the row's UNIQUE action_index (pinned at the + // first issuance), exactly as it does for a stake that was both deactivated and + // slashed, so the payload carries the tick once. + let db = fakeDb([ + { match: 'SELECT i.tick_id FROM issues i', rows: [{ id: 5, tick_id: 42, action_index: 100, owner_id: 77, supply: '1000' }] }, + { match: 'FROM `tokens` t WHERE t.tick_id IN', rows: [{ id: 5, tick_id: 42, action_index: 100, owner_id: 77, supply: '1000' }] } + ]); + let out = await collectUpdatedRows(db, 300, 300, 6); + assert.strictEqual(out.tokens.length, 1); + assert.strictEqual(out.tokens[0].action_index, 100); + }); +}); + describe('updatedRows.collectUpdatedRows', function(){ afterEach(() => sinon.restore()); From e091b6e463ad97080e4d89b967b36c2f53d7054a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 19 Sep 2026 11:00:21 -0700 Subject: [PATCH 12/62] fix(protocol): ship LTC:testnet mirror admission as null under dq4 (a) The armed LTC:testnet producer/consumer heights (4891504/4891766) are already behind the live TLTC tip. Ruling dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train and arms on a later train. Sets both heights to null in this repo's shared_rows_2.js twin, with the same replacement comment used across all six carriers. --- src/consensus/gate_registry/shared_rows_2.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index c7fd765d..b935b053 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -217,7 +217,7 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 153222, // SIZED 2026-09-16 20:41Z: epoch close 153,216 + 6 buried; tip 152,756 + 466 at 498.7 s/blk, about 64.5 h - 'LTC:testnet': 4891504, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 4,889,190 + 2314 at 82.5 s/blk + 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train 'DOGE:testnet': 67911796, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 67,904,912 + 6884 at 27.7 s/blk 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -229,7 +229,7 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 153266, // its producer + 44 blocks, about 6 h: strictly above, never equal - 'LTC:testnet': 4891766, // its producer + 262 blocks, about 6 h at 82.5 s/blk + 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train 'DOGE:testnet': 67912575, // its producer + 779 blocks, about 6 h at 27.7 s/blk 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration From e3b6f7ca92745c3ca419be3cf0034852d131314a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 19 Sep 2026 12:03:11 -0700 Subject: [PATCH 13/62] fix(consensus): re-slide v0.20.1 BTC and DOGE activation heights Propagate the canonical v0.20.1 patch-train reslide into sync's own copy of the shared consensus registry: BTC train height 153221, BTC mirror producer/consumer 153300/153328 (producer also carries ANCHOR_ATTEST_BARRIER_ACTIVATION), DOGE mirror producer/consumer 67916857/67917706. LTC:testnet stays null under dq4 (a) and is untouched. Re-pin bin/pins/identity.json (armed_map_fingerprint and the moved train_activation.TRAIN_ACTIVATION row) to match. --- bin/pins/identity.json | 4 ++-- src/consensus/gate_registry/shared_rows_1.js | 10 +++++----- src/consensus/gate_registry/shared_rows_2.js | 8 ++++---- src/consensus/gate_registry/shared_rows_5.js | 12 +++++++++--- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/bin/pins/identity.json b/bin/pins/identity.json index 0cd4d957..3193cb07 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -1,5 +1,5 @@ { - "armed_map_fingerprint": "f3d4455007a84ab8000c47f586c0806097449574002f4bcf0f043ef3998a87d1", + "armed_map_fingerprint": "a8be891d9286854dc136cc01abe9ebc97a3ca2c8c8cff496b23c76b88cfbed06", "armed_map_fingerprint_version": 2, "armedMapRows": { "archive_rollback_author_scope_activation.ARCHIVE_AUTHOR_SCOPE_JOIN_SQL": "c8b6c1753f86fb65689fbc5d72f001e9467d5553a91895a21c9cf48d643e8895", @@ -40,7 +40,7 @@ "swq_source_cap_activation.STAKE_WEIGHT_MAX_KEYS_PER_SOURCE": "a68b412c4282555f15546cf6e1fc42893b7e07f271557ceb021821098dd66c1b", "swq_source_cap_activation.STAKE_WEIGHT_MAX_SOURCES": "40510175845988f13f6162ed8526f0b09f73384467fa855e1e79b44a56562a58", "swq_source_cap_activation.SWQ_SOURCE_CAP_ACTIVATION": "d9105d53199963f94287f25b3d87d1e625b77d56ac8baab6d6ee64029b0fae74", - "train_activation.TRAIN_ACTIVATION": "3f842b0a9bec3dfc429b72e9172b941e262d8a1eef56eb2b4c60f63fdd90b8e4" + "train_activation.TRAIN_ACTIVATION": "05ed75dadd161735029c81ab58ba373dc68c6ae1a21260bd34ba75087faf78ce" }, "armed_map_rows": 39, "carrier_logic_digest": "f142db83339b2a74577e6e79d49cffc45c45ac642bcd57609b554f98a2f2e068", diff --git a/src/consensus/gate_registry/shared_rows_1.js b/src/consensus/gate_registry/shared_rows_1.js index bf311be0..742a811c 100644 --- a/src/consensus/gate_registry/shared_rows_1.js +++ b/src/consensus/gate_registry/shared_rows_1.js @@ -295,11 +295,11 @@ addGate('anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S', 'constant', 6 // that window. addGate('anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION', 'height', { mainnet: null, // INERT under the 2026-08-29 mainnet write hold - // SIZED 2026-09-16 20:41Z, on the BTC clock because this member is BTC-only: the same - // instant as the family's BTC CONSUMER height, so the one member that keeps BOTH - // certificates gains them together rather than carrying a lone extra rule for 6 h. - // Above the same roll and the same epoch close; the canon carries the measurement. - testnet: 153266, + // SIZED 2026-09-16 20:41Z, RE-SLID 2026-09-19, on the BTC clock because this member is + // BTC-only: the same instant as the family's BTC CONSUMER height, so the one member that + // keeps BOTH certificates gains them together rather than carrying a lone extra rule for + // 6 h. The canon carries the measurement. + testnet: 153328, regtest: UNPINNED, // shares the family's arming seam so one venue lever arms both }); diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index b935b053..c245769c 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -216,9 +216,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 153222, // SIZED 2026-09-16 20:41Z: epoch close 153,216 + 6 buried; tip 152,756 + 466 at 498.7 s/blk, about 64.5 h + 'BTC:testnet': 153300, // RE-SLID 2026-09-19: train 153,221 + 79 blocks (17 h at 781.078553 s/blk), the v0.20.1 patch reslide 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67911796, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 67,904,912 + 6884 at 27.7 s/blk + 'DOGE:testnet': 67916857, // RE-SLID 2026-09-19: tip 67,911,061 + 5796 blocks (41 h at 25.469118 s/blk, the same instant as the BTC producer), the v0.20.1 patch reslide 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -228,9 +228,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 153266, // its producer + 44 blocks, about 6 h: strictly above, never equal + 'BTC:testnet': 153328, // RE-SLID 2026-09-19: its producer + 28 blocks (6 h at 781.078553 s/blk), strictly above, never equal 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67912575, // its producer + 779 blocks, about 6 h at 27.7 s/blk + 'DOGE:testnet': 67917706, // RE-SLID 2026-09-19: its producer + 849 blocks (6 h at 25.469118 s/blk) 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration diff --git a/src/consensus/gate_registry/shared_rows_5.js b/src/consensus/gate_registry/shared_rows_5.js index cbf28976..291fc4fe 100644 --- a/src/consensus/gate_registry/shared_rows_5.js +++ b/src/consensus/gate_registry/shared_rows_5.js @@ -108,9 +108,15 @@ addGate('train_activation.TRAIN_ACTIVATION', 'ruleset', { // the LTC leg of this family two days off its BTC counterpart a day after the first cut. // That lead is the rolling-upgrade window the fleet roll must finish inside (24x the 90 // minute roll budget), and every testnet mirror-admission height sits above it on the same - // BTC clock (the BTC producer at 153,222 is 106 blocks and about 17.0 h further up), so a - // node lacking this rule set halts before it can grade an admission-stamped row. - '0.20.0': { mainnet: 9999999999, testnet: 153116, regtest: 0 }, + // BTC clock, so a node lacking this rule set halts before it can grade an admission-stamped + // row. + // RE-SLID 2026-09-19 for the v0.20.1 patch train: margin is 24 h measured from the freeze, + // converted at each coin's own measured cadence, not five days. Chain_tip TBTC 153,110 + 111 + // blocks, ceil(24 h / 781.078553 s per block, least-squares bound over the trailing 114-block + // window, about 25.2 h span, at least as long as the lead). The mirror-admission family + // below re-slides onto the same instant plus its own 17 h and 6 h offsets. LTC stays null + // under dq4 (a) and is untouched by this reslide. + '0.20.0': { mainnet: 9999999999, testnet: 153221, regtest: 0 }, }); // xchain_bridge_activation From 5fd36c02dbc06c242215ec0b3c6aed919c2aebec Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 17:41:27 -0700 Subject: [PATCH 14/62] test: parameterize fixture host ports Derive published fixture ports from the gate CI_PORT_OFFSET while preserving zero-offset defaults. Keep container ports fixed and route fixture clients through the same resolved host ports. --- bin/ci-full.sh | 7 +- bin/fixture-ports.js | 106 ++++++++++++++++++ package.json | 4 + test/chaos/fixtures/docker-compose.chaos.yml | 10 +- test/chaos/helpers/chaos-setup.js | 7 +- test/chaos/helpers/toxiproxy-client.js | 3 +- test/chaos/network_partition.test.js | 3 +- .../helpers/decoder_lifecycle_harness.js | 5 +- test/e2e/dispensers_reconcile.test.js | 5 +- test/e2e/docker-compose.e2e.yml | 6 +- test/e2e/helpers/testDb.js | 7 +- test/unit/fixture_ports.test.js | 27 +++++ 12 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 bin/fixture-ports.js create mode 100644 test/unit/fixture_ports.test.js diff --git a/bin/ci-full.sh b/bin/ci-full.sh index 67782992..fa88a6bf 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -97,12 +97,13 @@ run_tier "ci" npm run ci # helper already defaults to those ports and credentials, so no env override # is needed once the stack is up. E2E_COMPOSE="test/e2e/docker-compose.e2e.yml" +E2E_DB_PORT_RESOLVED="$(node bin/fixture-ports.js port E2E_DB_PORT)" || exit 1 e2e_compose_down() { - docker compose -f "$E2E_COMPOSE" down -v >/dev/null 2>&1 + node bin/fixture-ports.js compose "$E2E_COMPOSE" down -v >/dev/null 2>&1 } trap e2e_compose_down EXIT run_tier "e2e: bring up service containers (source-db, replica-db)" \ - docker compose -f "$E2E_COMPOSE" up -d --wait + node bin/fixture-ports.js compose "$E2E_COMPOSE" up -d --wait # Cross-repo consensus drift guards (rollback-coverage and friends) live in # the unit tier but the shared `ci` job never checks out a sibling, so they @@ -125,7 +126,7 @@ run_tier "e2e: e2e tier (test:e2e:ci)" npm run test:e2e:ci # Reuses source-db (:23306) with the admin credentials, not the e2e # xchain-node user, matching the workflow step exactly. run_tier "e2e: integration tier (green suites, test:integration:ci)" \ - env TEST_DB_HOST=127.0.0.1 TEST_DB_PORT=23306 TEST_DB_USER=root TEST_DB_PASS=test \ + env TEST_DB_HOST=127.0.0.1 TEST_DB_PORT="$E2E_DB_PORT_RESOLVED" TEST_DB_USER=root TEST_DB_PASS=test \ npm run test:integration:ci run_tier "e2e: tear down service containers" e2e_compose_down diff --git a/bin/fixture-ports.js b/bin/fixture-ports.js new file mode 100644 index 00000000..fe9927b4 --- /dev/null +++ b/bin/fixture-ports.js @@ -0,0 +1,106 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const REPO = path.join(__dirname, '..'); +const SPECS = Object.freeze({ + 'test/e2e/docker-compose.e2e.yml': Object.freeze({ + E2E_DB_PORT: 23306, + E2E_REPLICA_DB_PORT: 23307, + E2E_SOURCE2_DB_PORT: 23308 + }), + 'test/chaos/fixtures/docker-compose.chaos.yml': Object.freeze({ + SOURCE_DIRECT_PORT: 33065, + TOXIPROXY_PORT: 8474, + SOURCE_PROXY_PORT: 33060, + REPLICA_PROXY_PORT: 33061, + WS_PROXY_PORT: 33062 + }) +}); + +function integer(value, label) { + const text = String(value); + if (!/^(0|[1-9][0-9]*)$/.test(text)) { + throw new Error(`${label} must be a non-negative integer, got ${JSON.stringify(text)}`); + } + return Number(text); +} + +function offset(env) { + const source = env || process.env; + return source.CI_PORT_OFFSET === undefined || source.CI_PORT_OFFSET === '' + ? 0 + : integer(source.CI_PORT_OFFSET, 'CI_PORT_OFFSET'); +} + +function basePort(name) { + for (const spec of Object.values(SPECS)) { + if (Object.prototype.hasOwnProperty.call(spec, name)) return spec[name]; + } + throw new Error(`unknown fixture port ${name}`); +} + +function port(name, env) { + const source = env || process.env; + const value = source[name] === undefined || source[name] === '' + ? basePort(name) + offset(source) + : integer(source[name], name); + if (value < 1 || value > 65535) throw new Error(`${name} resolves outside the TCP port range: ${value}`); + return value; +} + +function composeEnvironment(file, env) { + const spec = SPECS[file]; + if (!spec) throw new Error(`unknown fixture compose file ${file}`); + const result = { ...(env || process.env) }; + for (const name of Object.keys(spec)) result[name] = String(port(name, result)); + return result; +} + +function render(file, env) { + const spec = SPECS[file]; + if (!spec) throw new Error(`unknown fixture compose file ${file}`); + let source = fs.readFileSync(path.join(REPO, file), 'utf8'); + for (const [name, base] of Object.entries(spec)) { + source = source.split(`\${${name}:-${base}}`).join(String(port(name, env))); + } + return source; +} + +function compose(file, args, env) { + const result = spawnSync('docker', ['compose', '-f', file, ...args], { + cwd: REPO, + env: composeEnvironment(file, env), + stdio: 'inherit' + }); + if (result.error) throw result.error; + return result.status === null ? 1 : result.status; +} + +function main(argv) { + const [command, name, ...args] = argv; + if (command === 'render' && name && args.length === 0) { + process.stdout.write(render(name)); + return 0; + } + if (command === 'port' && name && args.length === 0) { + process.stdout.write(String(port(name)) + '\n'); + return 0; + } + if (command === 'compose' && name && args.length > 0) return compose(name, args); + throw new Error('usage: fixture-ports.js '); +} + +if (require.main === module) { + try { + process.exitCode = main(process.argv.slice(2)); + } catch (err) { + console.error(err.message); + process.exitCode = 1; + } +} + +module.exports = { SPECS, offset, port, composeEnvironment, render }; diff --git a/package.json b/package.json index a8cb859f..113dd76e 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,8 @@ "test:integration:parity": "mocha --timeout 30000 --exit test/integration/index_map_parity.test.js test/integration/index_map_parity_http.test.js", "test:integration:ci": "mocha --require ./test/setup/index.js --timeout 120000 --exit 'test/integration/**/*.test.js' --exclude 'test/integration/client_bootstrap.test.js' --exclude 'test/integration/client_live_sync.test.js' --exclude 'test/integration/emissions_parity.test.js' --exclude 'test/integration/lifecycle.test.js'", "test:e2e": "mocha --timeout 600000 --slow 30000 --recursive 'test/e2e/**/*.test.js'", + "test:e2e:up": "node bin/fixture-ports.js compose test/e2e/docker-compose.e2e.yml up -d --wait", + "test:e2e:down": "node bin/fixture-ports.js compose test/e2e/docker-compose.e2e.yml down -v", "test:e2e:ci": "mocha --require ./test/setup/index.js --timeout 600000 --slow 30000 --recursive 'test/e2e/**/*.test.js' --exit", "test:regression": "mocha --timeout 10000 --exit --grep @regression --recursive 'test/unit/**/*.test.js'", "test:fuzz": "mocha --timeout 0 --recursive 'test/fuzz/suites/**/*.fuzz.js'", @@ -56,6 +58,8 @@ "test:perf:pool": "node test/perf/helpers/pool_fanout_load.js", "test:perf:stampede": "mocha --timeout 900000 --slow 30000 --exit test/perf/scenarios/08_bootstrap_stampede.test.js", "test:chaos": "mocha --timeout 240000 --recursive 'test/chaos/**/*.test.js'", + "test:chaos:up": "node bin/fixture-ports.js compose test/chaos/fixtures/docker-compose.chaos.yml up -d --wait", + "test:chaos:down": "node bin/fixture-ports.js compose test/chaos/fixtures/docker-compose.chaos.yml down -v", "test:mutate": "npx stryker run test/mutation/stryker.config.json", "test:mutate:quick": "npx stryker run test/mutation/stryker.quick.config.json", "test:mutate:check": "npx stryker run test/mutation/stryker.config.json --incremental", diff --git a/test/chaos/fixtures/docker-compose.chaos.yml b/test/chaos/fixtures/docker-compose.chaos.yml index c0b7df35..ec55dc5b 100644 --- a/test/chaos/fixtures/docker-compose.chaos.yml +++ b/test/chaos/fixtures/docker-compose.chaos.yml @@ -10,7 +10,7 @@ services: MARIADB_USER: xchain-node MARIADB_PASSWORD: xchain-fixture-throwaway ports: - - "33065:3306" # Direct access for admin seeding (bypasses toxiproxy) + - "${SOURCE_DIRECT_PORT:-33065}:3306" # Direct access for admin seeding (bypasses toxiproxy) networks: - chaos-net tmpfs: @@ -43,10 +43,10 @@ services: toxiproxy: image: ghcr.io/shopify/toxiproxy:2.9.0 ports: - - "8474:8474" # Toxiproxy HTTP API - - "33060:33060" # Source DB proxy endpoint - - "33061:33061" # Replica DB proxy endpoint - - "33062:33062" # WebSocket proxy endpoint (for CE-NET tests) + - "${TOXIPROXY_PORT:-8474}:8474" # Toxiproxy HTTP API + - "${SOURCE_PROXY_PORT:-33060}:33060" # Source DB proxy endpoint + - "${REPLICA_PROXY_PORT:-33061}:33061" # Replica DB proxy endpoint + - "${WS_PROXY_PORT:-33062}:33062" # WebSocket proxy endpoint (for CE-NET tests) extra_hosts: - "host-gateway:host-gateway" networks: diff --git a/test/chaos/helpers/chaos-setup.js b/test/chaos/helpers/chaos-setup.js index 250ea1a7..e9604227 100644 --- a/test/chaos/helpers/chaos-setup.js +++ b/test/chaos/helpers/chaos-setup.js @@ -32,12 +32,13 @@ const testDb = require('../../e2e/helpers/testDb'); const fixtures = require('../../e2e/helpers/fixtures'); const ServerProcess = require('../../e2e/helpers/serverProcess'); const ClientProcess = require('../../e2e/helpers/clientProcess'); +const fixturePorts = require('../../../bin/fixture-ports.js'); // Connection constants: proxied ports from docker-compose.chaos.yml const CHAOS_DB_HOST = process.env.CHAOS_DB_HOST || '127.0.0.1'; -const SOURCE_PROXY_PORT = parseInt(process.env.SOURCE_PROXY_PORT || '33060', 10); -const REPLICA_PROXY_PORT = parseInt(process.env.REPLICA_PROXY_PORT || '33061', 10); -const SOURCE_DIRECT_PORT = parseInt(process.env.SOURCE_DIRECT_PORT || '33065', 10); +const SOURCE_PROXY_PORT = fixturePorts.port('SOURCE_PROXY_PORT'); +const REPLICA_PROXY_PORT = fixturePorts.port('REPLICA_PROXY_PORT'); +const SOURCE_DIRECT_PORT = fixturePorts.port('SOURCE_DIRECT_PORT'); const CHAOS_DB_USER = 'xchain-node'; const CHAOS_DB_PASS = 'xchain-fixture-throwaway'; diff --git a/test/chaos/helpers/toxiproxy-client.js b/test/chaos/helpers/toxiproxy-client.js index c48065da..e8240e1e 100644 --- a/test/chaos/helpers/toxiproxy-client.js +++ b/test/chaos/helpers/toxiproxy-client.js @@ -25,9 +25,10 @@ 'use strict'; const http = require('http'); +const fixturePorts = require('../../../bin/fixture-ports.js'); const TOXIPROXY_HOST = process.env.TOXIPROXY_HOST || '127.0.0.1'; -const TOXIPROXY_PORT = parseInt(process.env.TOXIPROXY_PORT || '8474', 10); +const TOXIPROXY_PORT = fixturePorts.port('TOXIPROXY_PORT'); const SOURCE_PROXY = { name: 'source_db_chaos', diff --git a/test/chaos/network_partition.test.js b/test/chaos/network_partition.test.js index 119bb767..997e1a4a 100644 --- a/test/chaos/network_partition.test.js +++ b/test/chaos/network_partition.test.js @@ -37,6 +37,7 @@ const { expect } = require('chai'); const testDb = require('../e2e/helpers/testDb'); +const fixturePorts = require('../../bin/fixture-ports.js'); const { assertReplicaMatchesSource, @@ -71,7 +72,7 @@ describe('Chaos: Network Partition', function () { let server, client; const SERVER_PORT = 30300; - const WS_PROXY_PORT = 33062; + const WS_PROXY_PORT = fixturePorts.port('WS_PROXY_PORT'); before(async function () { await waitForToxiproxy(); diff --git a/test/e2e/decoder_lifecycle.test/helpers/decoder_lifecycle_harness.js b/test/e2e/decoder_lifecycle.test/helpers/decoder_lifecycle_harness.js index 2686b90a..1a77f893 100644 --- a/test/e2e/decoder_lifecycle.test/helpers/decoder_lifecycle_harness.js +++ b/test/e2e/decoder_lifecycle.test/helpers/decoder_lifecycle_harness.js @@ -50,11 +50,12 @@ const Utility = require('../../../../src/util'); const decoderFixtures = require('../../helpers/decoderFixtures'); const { getMariadb } = require('../../helpers/mariadbLoader'); const ServerProcess = require('../../helpers/serverProcess'); +const fixturePorts = require('../../../../bin/fixture-ports.js'); const SOURCE_HOST = process.env.E2E_DB_HOST || '127.0.0.1'; -const SOURCE_PORT = parseInt(process.env.E2E_DB_PORT) || 23306; +const SOURCE_PORT = fixturePorts.port('E2E_DB_PORT'); const REPLICA_HOST = process.env.E2E_REPLICA_DB_HOST || '127.0.0.1'; -const REPLICA_PORT = parseInt(process.env.E2E_REPLICA_DB_PORT) || 23307; +const REPLICA_PORT = fixturePorts.port('E2E_REPLICA_DB_PORT'); const DB_USER = process.env.E2E_DB_USER || 'xchain-node'; const DB_PASS = process.env.E2E_DB_PASS || 'xchain-fixture-throwaway'; diff --git a/test/e2e/dispensers_reconcile.test.js b/test/e2e/dispensers_reconcile.test.js index f38adc0b..4e8531b8 100644 --- a/test/e2e/dispensers_reconcile.test.js +++ b/test/e2e/dispensers_reconcile.test.js @@ -61,11 +61,12 @@ const Utility = require('../../src/util'); const decoderFixtures = require('./helpers/decoderFixtures'); const { getMariadb } = require('./helpers/mariadbLoader'); +const fixturePorts = require('../../bin/fixture-ports.js'); const SOURCE_HOST = process.env.E2E_DB_HOST || '127.0.0.1'; -const SOURCE_PORT = parseInt(process.env.E2E_DB_PORT) || 23306; +const SOURCE_PORT = fixturePorts.port('E2E_DB_PORT'); const REPLICA_HOST = process.env.E2E_REPLICA_DB_HOST || '127.0.0.1'; -const REPLICA_PORT = parseInt(process.env.E2E_REPLICA_DB_PORT) || 23307; +const REPLICA_PORT = fixturePorts.port('E2E_REPLICA_DB_PORT'); const DB_USER = process.env.E2E_DB_USER || 'xchain-node'; const DB_PASS = process.env.E2E_DB_PASS || 'xchain-fixture-throwaway'; diff --git a/test/e2e/docker-compose.e2e.yml b/test/e2e/docker-compose.e2e.yml index c0ba3ac4..8ee875e2 100644 --- a/test/e2e/docker-compose.e2e.yml +++ b/test/e2e/docker-compose.e2e.yml @@ -18,7 +18,7 @@ services: MARIADB_USER: xchain-node MARIADB_PASSWORD: xchain-fixture-throwaway ports: - - "23306:3306" + - "${E2E_DB_PORT:-23306}:3306" tmpfs: - /var/lib/mysql healthcheck: @@ -36,7 +36,7 @@ services: MARIADB_USER: xchain-node MARIADB_PASSWORD: xchain-fixture-throwaway ports: - - "23307:3306" + - "${E2E_REPLICA_DB_PORT:-23307}:3306" tmpfs: - /var/lib/mysql healthcheck: @@ -54,7 +54,7 @@ services: MARIADB_USER: xchain-node MARIADB_PASSWORD: xchain-fixture-throwaway ports: - - "23308:3306" + - "${E2E_SOURCE2_DB_PORT:-23308}:3306" tmpfs: - /var/lib/mysql healthcheck: diff --git a/test/e2e/helpers/testDb.js b/test/e2e/helpers/testDb.js index 84444ff2..64d600bf 100644 --- a/test/e2e/helpers/testDb.js +++ b/test/e2e/helpers/testDb.js @@ -14,19 +14,20 @@ const { getMariadb } = require('./mariadbLoader'); const Utility = require('../../../src/util'); const { splitSqlStatements } = require('../../../src/db/sql_util'); const validation = require('../../../src/util/validation'); +const fixturePorts = require('../../../bin/fixture-ports.js'); const TEST_DB_HOST = process.env.E2E_DB_HOST || '127.0.0.1'; -const TEST_DB_PORT = parseInt(process.env.E2E_DB_PORT) || 23306; +const TEST_DB_PORT = fixturePorts.port('E2E_DB_PORT'); const TEST_DB_USER = process.env.E2E_DB_USER || 'xchain-node'; const TEST_DB_PASS = process.env.E2E_DB_PASS || 'xchain-fixture-throwaway'; const REPLICA_DB_HOST = process.env.E2E_REPLICA_DB_HOST || '127.0.0.1'; -const REPLICA_DB_PORT = parseInt(process.env.E2E_REPLICA_DB_PORT) || 23307; +const REPLICA_DB_PORT = fixturePorts.port('E2E_REPLICA_DB_PORT'); const REPLICA_DB_USER = process.env.E2E_REPLICA_DB_USER || 'xchain-node'; const REPLICA_DB_PASS = process.env.E2E_REPLICA_DB_PASS || 'xchain-fixture-throwaway'; const SOURCE2_DB_HOST = process.env.E2E_SOURCE2_DB_HOST || '127.0.0.1'; -const SOURCE2_DB_PORT = parseInt(process.env.E2E_SOURCE2_DB_PORT) || 23308; +const SOURCE2_DB_PORT = fixturePorts.port('E2E_SOURCE2_DB_PORT'); const SOURCE2_DB_USER = process.env.E2E_SOURCE2_DB_USER || 'xchain-node'; const SOURCE2_DB_PASS = process.env.E2E_SOURCE2_DB_PASS || 'xchain-fixture-throwaway'; diff --git a/test/unit/fixture_ports.test.js b/test/unit/fixture_ports.test.js new file mode 100644 index 00000000..30dc4687 --- /dev/null +++ b/test/unit/fixture_ports.test.js @@ -0,0 +1,27 @@ +'use strict'; + +const assert = require('assert'); +const ports = require('../../bin/fixture-ports.js'); + +describe('fixture ports', function () { + it('keeps base ports when CI_PORT_OFFSET is absent or zero', function () { + assert.strictEqual(ports.port('E2E_DB_PORT', {}), 23306); + assert.strictEqual(ports.port('TOXIPROXY_PORT', { CI_PORT_OFFSET: '0' }), 8474); + }); + + it('adds CI_PORT_OFFSET to host ports', function () { + const env = { CI_PORT_OFFSET: '10700' }; + assert.strictEqual(ports.port('E2E_DB_PORT', env), 34006); + assert.strictEqual(ports.port('SOURCE_DIRECT_PORT', env), 43765); + }); + + it('does not add the offset to an explicit final port override', function () { + const env = { CI_PORT_OFFSET: '10700', E2E_DB_PORT: '41000' }; + assert.strictEqual(ports.port('E2E_DB_PORT', env), 41000); + }); + + it('rejects invalid offsets and out-of-range results', function () { + assert.throws(() => ports.port('E2E_DB_PORT', { CI_PORT_OFFSET: '-1' }), /non-negative integer/); + assert.throws(() => ports.port('SOURCE_DIRECT_PORT', { CI_PORT_OFFSET: '33000' }), /TCP port range/); + }); +}); From fa86b9afdffb6f3a8b4ca90cdbe85f642a904d29 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 08:50:38 -0700 Subject: [PATCH 15/62] refactor(observability): take the shared shim without underscore names Re-vendor src/observability/ from xchain-hub via bin/sync-observability.sh: the canonical module drops its underscore-prefixed method and handle names and corrects stale comments. The test-only reset hook is now resetObservability, so the suite that calls it follows the rename. --- src/observability/index.js | 76 +++++++++---------- src/observability/logShipper.js | 34 +++++---- src/observability/metrics.js | 48 ++++++------ test/unit/observability.test.js | 6 +- .../observability.test/02_log_shipper.test.js | 2 +- 5 files changed, 84 insertions(+), 82 deletions(-) diff --git a/src/observability/index.js b/src/observability/index.js index f07981c4..a0c27382 100644 --- a/src/observability/index.js +++ b/src/observability/index.js @@ -62,21 +62,21 @@ const { createLogShipper, readLogEnv } = require('./logShipper'); // Process-wide handles. A service is one process loading exactly one vendored // copy of this module, so module scope is the right scope: a globalThis key // would buy nothing and would collide across a monorepo test run. -let _logger = null; -let _registry = null; -let _patched = null; +let processLogger = null; +let processRegistry = null; +let patchHandle = null; // The shipper's housekeeping counters (log_lines_emitted_total and friends) // can only be registered once per registry. Now that the registry is shared and // always constructed, a second shipper on it would throw at construction, which // on the real wiring path (patchConsole at the top of api.js, then // installObservability further down) would take the service out at startup. -let _shipperAttached = false; +let shipperAttached = false; // The bound pre-patch console. Every shipper built after patchConsole must // write HERE, not to the global console: the shim's default sink is the global // object by reference, so a second shipper taking that default would emit its // formatted line INTO the patched console and get it formatted a second time // (` warn [svc] warn [svc] msg`). -let _sink = null; +let prePatchSink = null; const CONSOLE_METHODS = { log: 'info', info: 'info', warn: 'warn', error: 'error', debug: 'debug' }; @@ -178,11 +178,11 @@ function installObservability(app, opts = {}) { // caller wants no special sink or transport, adopt it rather than running a // second one: two shippers would split the line counters and each hold // their own ship buffer. - const adopt = _logger && !opts.console && !opts.logTransport; + const adopt = processLogger && !opts.console && !opts.logTransport; const logger = adopt - ? _logger + ? processLogger : newShipper({ service, version, env, console: sink, transport: opts.logTransport || null }); - if (!_logger) _logger = logger; + if (!processLogger) processLogger = logger; if (!config.metricsEnabled || !app || typeof app.use !== 'function') { return { @@ -215,14 +215,14 @@ function installObservability(app, opts = {}) { // The scrape itself is excluded: counting it makes every dashboard // show traffic that is only the monitoring system. if ((req.path || req.url || '').split('?')[0] === config.metricsPath) return next(); - const stop = process.hrtime.bigint(); + const startedAt = process.hrtime.bigint(); inFlight.inc({}, 1); let done = false; const finish = () => { if (done) return; done = true; inFlight.dec({}, 1); - const seconds = Number(process.hrtime.bigint() - stop) / 1e9; + const seconds = Number(process.hrtime.bigint() - startedAt) / 1e9; const route = routeLabel(req); const method = (req.method || 'GET').toUpperCase(); try { @@ -276,16 +276,16 @@ function installObservability(app, opts = {}) { * so this must not depend on the wiring order of any api.js. */ function getRegistry(info = {}) { - if (!_registry) { - _registry = new Registry(); - collectDefaultMetrics(_registry, { + if (!processRegistry) { + processRegistry = new Registry(); + collectDefaultMetrics(processRegistry, { service: info.service || 'xchain-service', version: info.version || '', coin: info.coin || '', network: info.network || '' }); } - return _registry; + return processRegistry; } // Returned once and resolved on every call, so a module can do @@ -293,9 +293,9 @@ function getRegistry(info = {}) { // once patchConsole/installObservability has run. Before either, it falls // through to the global console rather than throwing: a module that logs while // being required must not be able to kill the process. -const _lazyLogger = { +const lazyLogger = { log(level, msg, fields) { - if (_logger) return _logger.log(level, msg, fields); + if (processLogger) return processLogger.log(level, msg, fields); const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; fn(fields && Object.keys(fields).length ? `${msg} ${util.inspect(fields, { depth: 2 })}` : String(msg)); return null; @@ -306,24 +306,24 @@ const _lazyLogger = { error(msg, fields) { return this.log('error', msg, fields); } }; -function getLogger() { return _lazyLogger; } +function getLogger() { return lazyLogger; } // Attaches the shared registry to the FIRST shipper only; later shippers get // their own line accounting and leave the shared series alone. function newShipper(opts) { - const registry = _shipperAttached ? null : getRegistry(opts); - if (registry) _shipperAttached = true; - return createLogShipper({ ...opts, console: opts.console || _sink || console, registry }); + const registry = shipperAttached ? null : getRegistry(opts); + if (registry) shipperAttached = true; + return createLogShipper({ ...opts, console: opts.console || prePatchSink || console, registry }); } /** * Routes the service's existing bare console.* calls through the log shim, so - * levels, formats and redaction apply to the ~850 hub call sites and their - * siblings without rewriting one of them. + * levels, formats and redaction apply to every bare call site in the service + * without rewriting one of them. * * Called at the TOP of an entry file, before anything logs. Every service logs - * before installObservability runs today (hub api.js:29 vs :407, and the same - * shape in the decoder, indexer, encoder and tracker), and the lines that get + * before installObservability runs today (hub, decoder, indexer, encoder and + * tracker api.js all patch at the top and install far below), and the lines * lost that way are the env-validation and crash lines an operator most needs * framed. That is why this is a separate call rather than part of install. * @@ -338,7 +338,7 @@ function newShipper(opts) { function patchConsole(opts = {}) { const { service = 'xchain-service', version = '', coin = '', network = '', env = process.env } = opts; - if (_patched) return _patched; + if (patchHandle) return patchHandle; if (String(env.XCHAIN_LOG_PATCH || '') === '0') { return { patched: false, logger: getLogger(), unpatch: () => {} }; } @@ -355,10 +355,10 @@ function patchConsole(opts = {}) { sink[name] = fn.bind(console); } sink.log = sink.log || sink.info; - _sink = sink; + prePatchSink = sink; const logger = newShipper({ service, version, coin, network, env, console: sink }); - _logger = logger; + processLogger = logger; for (const [name, level] of Object.entries(CONSOLE_METHODS)) { // util.format is console's own argument semantics: printf-style format @@ -369,7 +369,7 @@ function patchConsole(opts = {}) { console[name] = (...args) => { logger.log(level, util.format(...args)); }; } - _patched = { + patchHandle = { patched: true, logger, unpatch() { @@ -377,23 +377,23 @@ function patchConsole(opts = {}) { if (fn === undefined) delete console[name]; else console[name] = fn; } - _patched = null; - _sink = null; - if (_logger === logger) _logger = null; + patchHandle = null; + prePatchSink = null; + if (processLogger === logger) processLogger = null; } }; - return _patched; + return patchHandle; } -function unpatchConsole() { if (_patched) _patched.unpatch(); } +function unpatchConsole() { if (patchHandle) patchHandle.unpatch(); } // Tests only: drops the process-wide handles so an assertion about a fresh // process does not inherit the previous test's shipper or registry. -function _resetObservability() { +function resetObservability() { unpatchConsole(); - _logger = null; - _registry = null; - _shipperAttached = false; + processLogger = null; + processRegistry = null; + shipperAttached = false; } module.exports = { @@ -407,5 +407,5 @@ module.exports = { unpatchConsole, getLogger, getRegistry, - _resetObservability + resetObservability }; diff --git a/src/observability/logShipper.js b/src/observability/logShipper.js index a36c8272..38cf5fa3 100644 --- a/src/observability/logShipper.js +++ b/src/observability/logShipper.js @@ -68,7 +68,7 @@ const REDACTED = '[redacted]'; // Deliberately NOT swept here: bare hex. Hub and indexer lines are full of // legitimate 64-char hex (txids, block hashes, state roots, digests) and -// redacting those would gut the logs this spec exists to make readable. The +// redacting those would gut the logs this shim exists to make readable. The // watch collector applies a hex-key sweep at its own boundary instead, where the // output is a committed report rather than an operator's live tail. function scrubMessage(msg) { @@ -104,7 +104,9 @@ function formatFieldValue(value) { * * The message stays immediately after the service tag so every existing * substring grep across the platform (handover greps, StatusService, the - * decoder's wait loops) keeps matching: the prefix is the only addition. The level token is lowercase on purpose: an uppercase ERROR would + * decoder's wait loops) keeps matching: the prefix is the only addition. + * + * The level token is lowercase on purpose: an uppercase ERROR would * be counted by the server-monitor's `grep -cE 'ERROR|FATAL'` rate alert on * every console.error line and page the fleet on first deploy. */ @@ -217,12 +219,12 @@ class LogShipper { this.pending = null; this.lastErrorNoteMs = 0; this.stats = { emitted: 0, shipped: 0, dropped: 0, failures: 0 }; - this.transport = transport || ((body) => this._post(body)); - if (registry) this._attachMetrics(registry); - if (this.config.shipEnabled) this._startTimer(); + this.transport = transport || ((body) => this.postBatch(body)); + if (registry) this.attachMetrics(registry); + if (this.config.shipEnabled) this.startFlushTimer(); } - _attachMetrics(registry) { + attachMetrics(registry) { const emitted = registry.counter({ name: 'log_lines_emitted_total', help: 'Log lines emitted by the structured log shim', labelNames: ['level'] }); // Cumulative totals are counters, not gauges: a _total-suffixed gauge // reads to a scraper as a resettable level, so rate() over it is @@ -233,7 +235,7 @@ class LogShipper { const failed = registry.counter({ name: 'log_ship_failures_total', help: 'Failed log-ship batch attempts' }); // Buffer depth IS a level, so it stays a gauge. const pending = registry.gauge({ name: 'log_ship_buffer_lines', help: 'Log lines currently buffered for shipping' }); - this._levelCounter = emitted; + this.levelCounter = emitted; registry.addCollector(() => { shipped.setMonotonic({}, this.stats.shipped); dropped.setMonotonic({}, this.stats.dropped); @@ -242,7 +244,7 @@ class LogShipper { }); } - _startTimer() { + startFlushTimer() { this.timer = setInterval(() => { this.flush(); }, this.config.intervalMs); // A logging timer must never be the reason the process refuses to exit. if (typeof this.timer.unref === 'function') this.timer.unref(); @@ -263,9 +265,9 @@ class LogShipper { Object.assign(record, safeFields); this.stats.emitted += 1; - if (this._levelCounter) this._levelCounter.inc({ level }, 1); - this._emitLocal(level, record); - if (this.config.shipEnabled) this._enqueue(record); + if (this.levelCounter) this.levelCounter.inc({ level }, 1); + this.emitLocal(level, record); + if (this.config.shipEnabled) this.enqueue(record); return record; } @@ -274,7 +276,7 @@ class LogShipper { warn(msg, fields) { return this.log('warn', msg, fields); } error(msg, fields) { return this.log('error', msg, fields); } - _emitLocal(level, record) { + emitLocal(level, record) { const fn = level === 'error' ? (this.console.error || this.console.log) : level === 'warn' ? (this.console.warn || this.console.log) : this.console.log; @@ -286,7 +288,7 @@ class LogShipper { else fn.call(this.console, formatTextLine(record)); } - _enqueue(record) { + enqueue(record) { if (this.buffer.length >= this.config.maxBuffer) { // Drop oldest: during an incident the newest lines are the ones worth having. this.buffer.shift(); @@ -317,7 +319,7 @@ class LogShipper { const keep = batch.slice(Math.max(0, batch.length - room)); this.stats.dropped += batch.length - keep.length; this.buffer.unshift(...keep); - this._noteError(err); + this.noteShipError(err); }) .finally(() => { this.inFlight = false; this.pending = null; }); return this.pending; @@ -325,7 +327,7 @@ class LogShipper { // At most one stderr line per minute: a collector outage must not itself // become the log flood that fills the disk. - _noteError(err) { + noteShipError(err) { const now = Date.now(); if (now - this.lastErrorNoteMs < 60000) return; this.lastErrorNoteMs = now; @@ -333,7 +335,7 @@ class LogShipper { if (sink) sink.call(this.console, `[log-ship] batch failed (${err && err.message ? err.message : 'unknown'}); buffered=${this.buffer.length} dropped=${this.stats.dropped}`); } - _post(body) { + postBatch(body) { const { url, token, timeoutMs } = this.config; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); diff --git a/src/observability/metrics.js b/src/observability/metrics.js index 656191a1..408aefc5 100644 --- a/src/observability/metrics.js +++ b/src/observability/metrics.js @@ -18,8 +18,8 @@ * (text/plain; version=0.0.4). It exists instead of prom-client because every * xchain-* service is an independent repo with its own package.json: a shared * npm dependency would need publishing and six lockfile bumps, while this file - * is vendored byte-identically by bin/sync-observability.sh and gated in CI by - * a parity check across those copies. Zero deps also means /metrics cannot pull + * is vendored byte-identically by bin/sync-observability.sh, whose --check runs + * as a drift tier of the hub's CI. Zero deps also means /metrics cannot pull * a new supply-chain surface into a consensus-critical service. * * Cardinality is the failure mode that kills a Prometheus server, so two guards @@ -68,8 +68,8 @@ function assertMetricName(name) { function assertLabelNames(labelNames) { for (const l of labelNames) { if (!LABEL_NAME_RE.test(l)) throw new Error(`invalid label name: ${l}`); - // Reserved by the exposition format itself: `le` belongs to histogram - // buckets and `__name__` is the series identity. + // `__name__` is the format's reserved series identity. `le` is reserved + // too, but only histograms carry it, so the Histogram constructor refuses it. if (l === '__name__') throw new Error('label name __name__ is reserved'); } } @@ -89,7 +89,7 @@ class Metric { // Series identity is the ordered label-value tuple. Declared order is used // (not caller order), so {a,b} and {b,a} address the same series. - _key(labels) { + seriesKey(labels) { if (this.labelNames.length === 0) return ''; const parts = []; for (const name of this.labelNames) { @@ -99,7 +99,7 @@ class Metric { return JSON.stringify(parts); } - _normalizeLabels(labels) { + normalizeLabels(labels) { const out = {}; for (const name of this.labelNames) { const v = labels[name]; @@ -115,15 +115,15 @@ class Metric { // Returns null when the series cap is hit; every mutator treats null as // "drop this observation" so a cardinality blowup degrades instead of OOMs. - _series(labels, makeState) { - const key = this._key(labels); + seriesFor(labels, makeState) { + const key = this.seriesKey(labels); let s = this.series.get(key); if (s) return s; if (this.series.size >= this.maxSeries) { - if (this.registry) this.registry._noteSeriesDrop(this.name); + if (this.registry) this.registry.noteSeriesDrop(this.name); return null; } - s = { labels: this._normalizeLabels(labels), ...makeState() }; + s = { labels: this.normalizeLabels(labels), ...makeState() }; this.series.set(key, s); return s; } @@ -139,7 +139,7 @@ class Counter extends Metric { if (!Number.isFinite(value) || value < 0) { throw new Error(`counter ${this.name}: inc value must be a non-negative finite number`); } - const s = this._series(labels, () => ({ value: 0 })); + const s = this.seriesFor(labels, () => ({ value: 0 })); if (s) s.value += value; return s ? s.value : undefined; } @@ -149,12 +149,12 @@ class Counter extends Metric { // ignored so a source reset cannot make a counter go backwards mid-scrape. setMonotonic(labels, value) { if (!Number.isFinite(value) || value < 0) return; - const s = this._series(labels, () => ({ value: 0 })); + const s = this.seriesFor(labels, () => ({ value: 0 })); if (s && value >= s.value) s.value = value; } get(labels = {}) { - const s = this.series.get(this._key(labels)); + const s = this.series.get(this.seriesKey(labels)); return s ? s.value : 0; } @@ -171,13 +171,13 @@ class Gauge extends Metric { set(labels = {}, value) { if (typeof labels === 'number') { value = labels; labels = {}; } if (!Number.isFinite(value)) throw new Error(`gauge ${this.name}: set value must be finite`); - const s = this._series(labels, () => ({ value: 0 })); + const s = this.seriesFor(labels, () => ({ value: 0 })); if (s) s.value = value; } inc(labels = {}, value = 1) { if (typeof labels === 'number') { value = labels; labels = {}; } - const s = this._series(labels, () => ({ value: 0 })); + const s = this.seriesFor(labels, () => ({ value: 0 })); if (s) s.value += value; } @@ -187,7 +187,7 @@ class Gauge extends Metric { } get(labels = {}) { - const s = this.series.get(this._key(labels)); + const s = this.series.get(this.seriesKey(labels)); return s ? s.value : 0; } @@ -211,7 +211,7 @@ class Histogram extends Metric { observe(labels = {}, value) { if (typeof labels === 'number') { value = labels; labels = {}; } if (!Number.isFinite(value)) return; - const s = this._series(labels, () => ({ counts: new Array(this.buckets.length).fill(0), sum: 0, count: 0 })); + const s = this.seriesFor(labels, () => ({ counts: new Array(this.buckets.length).fill(0), sum: 0, count: 0 })); if (!s) return; s.count += 1; s.sum += value; @@ -220,7 +220,7 @@ class Histogram extends Metric { } } - // Times a callback (sync or promise) and observes its wall duration in seconds. + // Starts a wall-clock timer; the returned stop function observes and returns elapsed seconds. startTimer(labels = {}) { const start = process.hrtime.bigint(); return () => { @@ -231,7 +231,7 @@ class Histogram extends Metric { } get(labels = {}) { - const s = this.series.get(this._key(labels)); + const s = this.series.get(this.seriesKey(labels)); return s ? { sum: s.sum, count: s.count, counts: s.counts.slice() } : { sum: 0, count: 0, counts: [] }; } @@ -273,7 +273,7 @@ class Registry { this.metrics.set(this.seriesDropped.name, this.seriesDropped); } - _noteSeriesDrop(metricName) { + noteSeriesDrop(metricName) { // Guarded: the drop counter itself is capped, and a runaway metric name // space must not turn the guard into the leak. this.seriesDropped.inc({ metric: metricName }, 1); @@ -288,7 +288,7 @@ class Registry { // to be required, and a service that wires observability twice must not die // at startup over a duplicate declaration that asks for exactly what is // already there. - _register(metric) { + registerMetric(metric) { const existing = this.metrics.get(metric.name); if (existing) { const same = existing.type === metric.type @@ -303,9 +303,9 @@ class Registry { return metric; } - counter(opts) { return this._register(new Counter({ maxSeries: this.maxSeries, ...opts, registry: this })); } - gauge(opts) { return this._register(new Gauge({ maxSeries: this.maxSeries, ...opts, registry: this })); } - histogram(opts) { return this._register(new Histogram({ maxSeries: this.maxSeries, ...opts, registry: this })); } + counter(opts) { return this.registerMetric(new Counter({ maxSeries: this.maxSeries, ...opts, registry: this })); } + gauge(opts) { return this.registerMetric(new Gauge({ maxSeries: this.maxSeries, ...opts, registry: this })); } + histogram(opts) { return this.registerMetric(new Histogram({ maxSeries: this.maxSeries, ...opts, registry: this })); } get(name) { return this.metrics.get(name) || null; } diff --git a/test/unit/observability.test.js b/test/unit/observability.test.js index b08350dd..071f1a47 100644 --- a/test/unit/observability.test.js +++ b/test/unit/observability.test.js @@ -89,7 +89,7 @@ describe('observability/installObservability', function () { // The registry and shipper are process-wide by design (one process is one // service), so a suite that installs many times has to drop them between // cases or it reads the previous case's service label and HTTP series. - afterEach(function () { require('../../src/observability/index.js')._resetObservability(); }); + afterEach(function () { require('../../src/observability/index.js').resetObservability(); }); registerRouteTests(testContext); }); @@ -106,10 +106,10 @@ describe('observability/logShipper: message redaction', function () { }); describe('observability/patchConsole', function () { - const { patchConsole, unpatchConsole, getLogger, getRegistry, _resetObservability } = + const { patchConsole, unpatchConsole, getLogger, getRegistry, resetObservability } = require('../../src/observability/index.js'); - afterEach(function () { _resetObservability(); }); + afterEach(function () { resetObservability(); }); registerFlushAndHealthTests({ ...testContext, patchConsole, unpatchConsole, getLogger, getRegistry }); diff --git a/test/unit/observability.test/02_log_shipper.test.js b/test/unit/observability.test/02_log_shipper.test.js index 27ba0a83..185f24a0 100644 --- a/test/unit/observability.test/02_log_shipper.test.js +++ b/test/unit/observability.test/02_log_shipper.test.js @@ -181,7 +181,7 @@ function registerLogShipperTestsPart4({ expect, http, createLogShipper, fakeCons expect(log.timer).to.equal(null); }); - // Exercises the real _post/fetch path. Every other test here injects a + // Exercises the real postBatch/fetch path. Every other test here injects a // transport, which is why the unreleased response body below went unseen. it('releases the response body so a stalled collector cannot pin the socket', async function () { this.timeout(5000); From 5feee57e85025b88daac9c311629adec24cc1bf1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:24:44 -0700 Subject: [PATCH 16/62] test: split proxy rate limit security coverage --- .../api_rate_limit_proxy_security.test.js | 240 +++--------------- .../api_rate_limit_proxy_security_suite.js | 143 +++++++++++ .../proxy_modes.test.js | 154 +++++++++++ 3 files changed, 330 insertions(+), 207 deletions(-) create mode 100644 test/security/api_rate_limit_proxy_security.test/helpers/api_rate_limit_proxy_security_suite.js create mode 100644 test/security/api_rate_limit_proxy_security.test/proxy_modes.test.js diff --git a/test/security/api_rate_limit_proxy_security.test.js b/test/security/api_rate_limit_proxy_security.test.js index 7a5666f1..a30e523a 100644 --- a/test/security/api_rate_limit_proxy_security.test.js +++ b/test/security/api_rate_limit_proxy_security.test.js @@ -15,140 +15,43 @@ // production limiter instances (createRateLimiters) and the production key // function (snapshotKey), not a re-declaration of them. -const assert = require('assert'); -const sinon = require('sinon'); -const express = require('express'); -const proxyquire = require('proxyquire'); // src/api.js is the process entry point and patches console as it loads. The unit // tier's bootstrap opts out of that; the security tier has no bootstrap, so this file // opts out itself, or every later suite that stubs console stops seeing logger output. process.env.XCHAIN_LOG_PATCH = '0'; -const { trustProxyHops, snapshotKey, createRateLimiters } = require('../../src/api'); - -// A stand-in for the co-located Apache: the socket peer is loopback and the -// real client address arrives appended to X-Forwarded-For. -const PROXY_HOST = '127.0.0.1'; - -const CLIENT_A = '198.51.100.7'; -const CLIENT_B = '198.51.100.8'; -const SPOOFED = '203.0.113.66'; - -// Boots a throwaway server carrying the same proxy-trust seam and the same -// snapshot limiter the service mounts. The echo route reports both the IP -// express resolved and the exact bucket key the limiter charged. -async function startHarness(options){ - let cfg = Object.assign({ - SNAPSHOT_RATE_FULL: 100, - SNAPSHOT_RATE_INCR: 100, - TRANSPARENCY_RATE_LIMIT: 100 - }, options.cfg || {}); - - let app = express(); - app.set('trust proxy', trustProxyHops(options.trustProxy)); - - let limiters = createRateLimiters(cfg); - app.use(limiters.backstopLimiter); - app.get('/snapshot/:dbType/:chain/:network', limiters.fullSnapshotLimiter, (req, res) => { - res.json({ ip: req.ip, key: snapshotKey(req) }); - }); - - let server = await new Promise((resolve) => { - let s = app.listen(0, PROXY_HOST, () => resolve(s)); - }); - - return { - port: server.address().port, - close: () => new Promise((resolve) => server.close(resolve)) - }; +const { + assert, + bootRealApi, + CLIENT_A, + CLIENT_B, + get, + listenRealApi, + PROXY_HOST, + registerHooks, + sinon, + SPOOFED, + startHarness, + trustProxyHops +} = require('./api_rate_limit_proxy_security.test/helpers/api_rate_limit_proxy_security_suite'); + +let harness = null; + +function beforeHook() { + // express-rate-limit logs a validation notice when it sees an + // X-Forwarded-For header with 'trust proxy' off; that is the very + // combination two of these tests exercise on purpose. + sinon.stub(console, 'error'); + sinon.stub(console, 'warn'); } -// One request through the harness. forwardedFor is the X-Forwarded-For value as -// it would look leaving Apache (client-supplied entries first, real client last). -async function get(harness, forwardedFor, path){ - let headers = {}; - if(forwardedFor) headers['x-forwarded-for'] = forwardedFor; - let res = await fetch('http://' + PROXY_HOST + ':' + harness.port + (path || '/snapshot/indexer/BTC/mainnet'), { headers }); - let body = null; - try { body = await res.json(); } catch(e){ body = null; } - return { status: res.status, body }; -} - -// Boots the REAL startApi() with the network edges stubbed out, and hands back -// the express app it built. Nothing else reaches the production wiring: the -// harness above builds its own app, so without this a deleted app.set() in -// startApi would go unnoticed. -async function bootRealApi(trustProxyEnv, extraEnv){ - let env = Object.assign({ TRUST_PROXY: trustProxyEnv }, extraEnv || {}); - let prior = {}; - for(let key of Object.keys(env)){ - prior[key] = process.env[key]; - if(env[key] === undefined) delete process.env[key]; - else process.env[key] = env[key]; - } - - let capturedApp = null; - let fakeServer = { on: () => {}, listen: () => {} }; - let fakeWss = { on: () => {}, clients: new Set(), handleUpgrade: () => {} }; - - class SyncServiceStub { - start(){ return Promise.resolve(); } - getBroadcaster(){ return null; } - getChains(){ return []; } - getDatabase(){ return null; } - } - - // startApi arms three long-lived intervals; a test process must not inherit them. - let intervals = sinon.stub(global, 'setInterval').returns(null); - - try { - let api = proxyquire('../../src/api', { - 'http': { createServer: (app) => { capturedApp = app; return fakeServer; } }, - 'ws': { Server: function(){ return fakeWss; } }, - './SyncService': SyncServiceStub - }); - await api.startApi(); - } finally { - intervals.restore(); - for(let key of Object.keys(prior)){ - if(prior[key] === undefined) delete process.env[key]; - else process.env[key] = prior[key]; - } - } - - return capturedApp; -} - -// Puts a real startApi()-built app on a loopback port. Its snapshot route 404s -// (the stubbed SyncService owns no databases), which is fine: the limiter has -// already charged the bucket by then, so 404 means "served" and 429 means -// "rate limited" just as they would against a real source. -async function listenRealApi(app){ - let server = await new Promise((resolve) => { - let s = app.listen(0, PROXY_HOST, () => resolve(s)); - }); - return { - port: server.address().port, - close: () => new Promise((resolve) => server.close(resolve)) - }; +async function afterHook() { + if(harness) await harness.close(); + harness = null; + sinon.restore(); } describe('API rate-limit proxy trust security', function(){ - - let harness = null; - - beforeEach(function(){ - // express-rate-limit logs a validation notice when it sees an - // X-Forwarded-For header with 'trust proxy' off; that is the very - // combination two of these tests exercise on purpose. - sinon.stub(console, 'error'); - sinon.stub(console, 'warn'); - }); - - afterEach(async function(){ - if(harness) await harness.close(); - harness = null; - sinon.restore(); - }); + registerHooks(beforeHook, afterHook); // ── trustProxyHops: one hop, never true ── @@ -169,6 +72,11 @@ describe('API rate-limit proxy trust security', function(){ }); }); +}); + +describe('API rate-limit proxy trust security', function(){ + registerHooks(beforeHook, afterHook); + // ── the setting reaches the app startApi actually serves ── describe('startApi wiring', function(){ @@ -207,86 +115,4 @@ describe('API rate-limit proxy trust security', function(){ }); }); - // ── TRUST_PROXY=false: the header is not evidence ── - - describe('TRUST_PROXY=false', function(){ - - it('keys on the socket address and ignores a spoofed X-Forwarded-For', async function(){ - harness = await startHarness({ trustProxy: false }); - let res = await get(harness, SPOOFED); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.ip, PROXY_HOST); - assert.strictEqual(res.body.key, PROXY_HOST + '|indexer/BTC/mainnet'); - }); - - it('does not let a spoofed header split one caller into separate buckets', async function(){ - harness = await startHarness({ trustProxy: false, cfg: { SNAPSHOT_RATE_FULL: 2 } }); - let first = await get(harness, '203.0.113.1'); - let second = await get(harness, '203.0.113.2'); - let third = await get(harness, '203.0.113.3'); - assert.strictEqual(first.status, 200); - assert.strictEqual(second.status, 200); - assert.strictEqual(third.status, 429, 'rotating X-Forwarded-For must not buy a fresh budget'); - }); - }); - - // ── TRUST_PROXY=true: one hop resolves the real client ── - - describe('TRUST_PROXY=true', function(){ - - it('keys on the client address the proxy appended, not the socket', async function(){ - harness = await startHarness({ trustProxy: true }); - let res = await get(harness, CLIENT_A); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.ip, CLIENT_A); - assert.strictEqual(res.body.key, CLIENT_A + '|indexer/BTC/mainnet'); - }); - - it('ignores a client-supplied entry to the LEFT of the proxy-appended address', async function(){ - harness = await startHarness({ trustProxy: true }); - let res = await get(harness, SPOOFED + ', ' + CLIENT_A); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.ip, CLIENT_A, 'one trusted hop must stop at the address Apache observed'); - assert.ok(!res.body.key.includes(SPOOFED), 'spoofed entry leaked into the rate-limit key'); - }); - - it('ignores a long spoofed prefix, however many entries the client prepends', async function(){ - harness = await startHarness({ trustProxy: true }); - let res = await get(harness, '203.0.113.1, 203.0.113.2, 203.0.113.3, ' + CLIENT_A); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.ip, CLIENT_A); - }); - - // The actual defect in : without the seam both clients resolve to - // 127.0.0.1, share one bucket, and the first caller 429s the second. - it('gives two different client addresses independent snapshot buckets', async function(){ - harness = await startHarness({ trustProxy: true, cfg: { SNAPSHOT_RATE_FULL: 2 } }); - - assert.strictEqual((await get(harness, CLIENT_A)).status, 200); - assert.strictEqual((await get(harness, CLIENT_A)).status, 200); - assert.strictEqual((await get(harness, CLIENT_A)).status, 429, 'client A should have exhausted its own budget'); - - let other = await get(harness, CLIENT_B); - assert.strictEqual(other.status, 200, 'client B was 429d by client A exhausting a shared global bucket'); - assert.strictEqual(other.body.key, CLIENT_B + '|indexer/BTC/mainnet'); - }); - - it('keeps one client on separate buckets per chain, as the key intends', async function(){ - harness = await startHarness({ trustProxy: true, cfg: { SNAPSHOT_RATE_FULL: 1 } }); - assert.strictEqual((await get(harness, CLIENT_A, '/snapshot/indexer/BTC/mainnet')).status, 200); - assert.strictEqual((await get(harness, CLIENT_A, '/snapshot/indexer/BTC/mainnet')).status, 429); - assert.strictEqual((await get(harness, CLIENT_A, '/snapshot/indexer/LTC/mainnet')).status, 200); - assert.strictEqual((await get(harness, CLIENT_A, '/snapshot/decoder/BTC/mainnet')).status, 200); - }); - - // Trusting the forwarded header is what makes IPv6 rotation reachable: - // a /64 holder could otherwise mint a fresh bucket per request. - it('collapses an IPv6 client to its network so rotation buys no new budget', async function(){ - harness = await startHarness({ trustProxy: true, cfg: { SNAPSHOT_RATE_FULL: 2 } }); - assert.strictEqual((await get(harness, '2001:db8:0:1::1')).status, 200); - assert.strictEqual((await get(harness, '2001:db8:0:1::2')).status, 200); - let third = await get(harness, '2001:db8:0:1::3'); - assert.strictEqual(third.status, 429, 'addresses in one IPv6 allocation must share a bucket'); - }); - }); }); diff --git a/test/security/api_rate_limit_proxy_security.test/helpers/api_rate_limit_proxy_security_suite.js b/test/security/api_rate_limit_proxy_security.test/helpers/api_rate_limit_proxy_security_suite.js new file mode 100644 index 00000000..0e274c98 --- /dev/null +++ b/test/security/api_rate_limit_proxy_security.test/helpers/api_rate_limit_proxy_security_suite.js @@ -0,0 +1,143 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Shared HTTP harness and hook registration. One part of api_rate_limit_proxy_security.test.js. +const assert = require('assert'); +const sinon = require('sinon'); +const express = require('express'); +const proxyquire = require('proxyquire'); +const { trustProxyHops, snapshotKey, createRateLimiters } = require('../../../../src/api'); + +// A stand-in for the co-located Apache: the socket peer is loopback and the +// real client address arrives appended to X-Forwarded-For. +const PROXY_HOST = '127.0.0.1'; + +const CLIENT_A = '198.51.100.7'; +const CLIENT_B = '198.51.100.8'; +const SPOOFED = '203.0.113.66'; + +// Boots a throwaway server carrying the same proxy-trust seam and the same +// snapshot limiter the service mounts. The echo route reports both the IP +// express resolved and the exact bucket key the limiter charged. +async function startHarness(options){ + let cfg = Object.assign({ + SNAPSHOT_RATE_FULL: 100, + SNAPSHOT_RATE_INCR: 100, + TRANSPARENCY_RATE_LIMIT: 100 + }, options.cfg || {}); + + let app = express(); + app.set('trust proxy', trustProxyHops(options.trustProxy)); + + let limiters = createRateLimiters(cfg); + app.use(limiters.backstopLimiter); + app.get('/snapshot/:dbType/:chain/:network', limiters.fullSnapshotLimiter, (req, res) => { + res.json({ ip: req.ip, key: snapshotKey(req) }); + }); + + let server = await new Promise((resolve) => { + let s = app.listen(0, PROXY_HOST, () => resolve(s)); + }); + + return { + port: server.address().port, + close: () => new Promise((resolve) => server.close(resolve)) + }; +} + +// One request through the harness. forwardedFor is the X-Forwarded-For value as +// it would look leaving Apache (client-supplied entries first, real client last). +async function get(harness, forwardedFor, path){ + let headers = {}; + if(forwardedFor) headers['x-forwarded-for'] = forwardedFor; + let res = await fetch('http://' + PROXY_HOST + ':' + harness.port + (path || '/snapshot/indexer/BTC/mainnet'), { headers }); + let body = null; + try { body = await res.json(); } catch(e){ body = null; } + return { status: res.status, body }; +} + +// Boots the REAL startApi() with the network edges stubbed out, and hands back +// the express app it built. Nothing else reaches the production wiring: the +// harness above builds its own app, so without this a deleted app.set() in +// startApi would go unnoticed. +async function bootRealApi(trustProxyEnv, extraEnv){ + let env = Object.assign({ TRUST_PROXY: trustProxyEnv }, extraEnv || {}); + let prior = {}; + for(let key of Object.keys(env)){ + prior[key] = process.env[key]; + if(env[key] === undefined) delete process.env[key]; + else process.env[key] = env[key]; + } + + let capturedApp = null; + let fakeServer = { on: () => {}, listen: () => {} }; + let fakeWss = { on: () => {}, clients: new Set(), handleUpgrade: () => {} }; + + class SyncServiceStub { + start(){ return Promise.resolve(); } + getBroadcaster(){ return null; } + getChains(){ return []; } + getDatabase(){ return null; } + } + + // startApi arms three long-lived intervals; a test process must not inherit them. + let intervals = sinon.stub(global, 'setInterval').returns(null); + + try { + let api = proxyquire('../../../../src/api', { + 'http': { createServer: (app) => { capturedApp = app; return fakeServer; } }, + 'ws': { Server: function(){ return fakeWss; } }, + './SyncService': SyncServiceStub + }); + await api.startApi(); + } finally { + intervals.restore(); + for(let key of Object.keys(prior)){ + if(prior[key] === undefined) delete process.env[key]; + else process.env[key] = prior[key]; + } + } + + return capturedApp; +} + +// Puts a real startApi()-built app on a loopback port. Its snapshot route 404s +// (the stubbed SyncService owns no databases), which is fine: the limiter has +// already charged the bucket by then, so 404 means "served" and 429 means +// "rate limited" just as they would against a real source. +async function listenRealApi(app){ + let server = await new Promise((resolve) => { + let s = app.listen(0, PROXY_HOST, () => resolve(s)); + }); + return { + port: server.address().port, + close: () => new Promise((resolve) => server.close(resolve)) + }; +} + +function registerHooks(beforeHook, afterHook) { + beforeEach(beforeHook); + afterEach(afterHook); +} + +module.exports = { + assert, + bootRealApi, + CLIENT_A, + CLIENT_B, + get, + listenRealApi, + PROXY_HOST, + registerHooks, + sinon, + SPOOFED, + startHarness, + trustProxyHops +}; diff --git a/test/security/api_rate_limit_proxy_security.test/proxy_modes.test.js b/test/security/api_rate_limit_proxy_security.test/proxy_modes.test.js new file mode 100644 index 00000000..83df5124 --- /dev/null +++ b/test/security/api_rate_limit_proxy_security.test/proxy_modes.test.js @@ -0,0 +1,154 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. +// +// Behind a co-located reverse proxy, express resolved every +// request to 127.0.0.1 because 'trust proxy' was never set, so the snapshot +// rate limiters keyed one shared global bucket and a single caller could drain +// every validator's snapshot budget. These tests drive real HTTP through the +// production limiter instances (createRateLimiters) and the production key +// function (snapshotKey), not a re-declaration of them. + +// src/api.js is the process entry point and patches console as it loads. The unit +// tier's bootstrap opts out of that; the security tier has no bootstrap, so this file +// opts out itself, or every later suite that stubs console stops seeing logger output. +process.env.XCHAIN_LOG_PATCH = '0'; +const { + assert, + bootRealApi, + CLIENT_A, + CLIENT_B, + get, + listenRealApi, + PROXY_HOST, + registerHooks, + sinon, + SPOOFED, + startHarness, + trustProxyHops +} = require('./helpers/api_rate_limit_proxy_security_suite'); + +let harness = null; + +function beforeHook() { + // express-rate-limit logs a validation notice when it sees an + // X-Forwarded-For header with 'trust proxy' off; that is the very + // combination two of these tests exercise on purpose. + sinon.stub(console, 'error'); + sinon.stub(console, 'warn'); +} + +async function afterHook() { + if(harness) await harness.close(); + harness = null; + sinon.restore(); +} + + +// Covers explicit proxy modes. One part of api_rate_limit_proxy_security.test.js. +describe('API rate-limit proxy trust security', function(){ + registerHooks(beforeHook, afterHook); + + // ── TRUST_PROXY=false: the header is not evidence ── + + describe('TRUST_PROXY=false', function(){ + + it('keys on the socket address and ignores a spoofed X-Forwarded-For', async function(){ + harness = await startHarness({ trustProxy: false }); + let res = await get(harness, SPOOFED); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.ip, PROXY_HOST); + assert.strictEqual(res.body.key, PROXY_HOST + '|indexer/BTC/mainnet'); + }); + + it('does not let a spoofed header split one caller into separate buckets', async function(){ + harness = await startHarness({ trustProxy: false, cfg: { SNAPSHOT_RATE_FULL: 2 } }); + let first = await get(harness, '203.0.113.1'); + let second = await get(harness, '203.0.113.2'); + let third = await get(harness, '203.0.113.3'); + assert.strictEqual(first.status, 200); + assert.strictEqual(second.status, 200); + assert.strictEqual(third.status, 429, 'rotating X-Forwarded-For must not buy a fresh budget'); + }); + }); + +}); + +describe('API rate-limit proxy trust security', function(){ + registerHooks(beforeHook, afterHook); + + // ── TRUST_PROXY=true: one hop resolves the real client ── + + describe('TRUST_PROXY=true', function(){ + + it('keys on the client address the proxy appended, not the socket', async function(){ + harness = await startHarness({ trustProxy: true }); + let res = await get(harness, CLIENT_A); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.ip, CLIENT_A); + assert.strictEqual(res.body.key, CLIENT_A + '|indexer/BTC/mainnet'); + }); + + it('ignores a client-supplied entry to the LEFT of the proxy-appended address', async function(){ + harness = await startHarness({ trustProxy: true }); + let res = await get(harness, SPOOFED + ', ' + CLIENT_A); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.ip, CLIENT_A, 'one trusted hop must stop at the address Apache observed'); + assert.ok(!res.body.key.includes(SPOOFED), 'spoofed entry leaked into the rate-limit key'); + }); + + it('ignores a long spoofed prefix, however many entries the client prepends', async function(){ + harness = await startHarness({ trustProxy: true }); + let res = await get(harness, '203.0.113.1, 203.0.113.2, 203.0.113.3, ' + CLIENT_A); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.ip, CLIENT_A); + }); + + }); +}); + +describe('API rate-limit proxy trust security', function(){ + registerHooks(beforeHook, afterHook); + + describe('TRUST_PROXY=true', function(){ + + // The actual defect in : without the seam both clients resolve to + // 127.0.0.1, share one bucket, and the first caller 429s the second. + it('gives two different client addresses independent snapshot buckets', async function(){ + harness = await startHarness({ trustProxy: true, cfg: { SNAPSHOT_RATE_FULL: 2 } }); + + assert.strictEqual((await get(harness, CLIENT_A)).status, 200); + assert.strictEqual((await get(harness, CLIENT_A)).status, 200); + assert.strictEqual((await get(harness, CLIENT_A)).status, 429, 'client A should have exhausted its own budget'); + + let other = await get(harness, CLIENT_B); + assert.strictEqual(other.status, 200, 'client B was 429d by client A exhausting a shared global bucket'); + assert.strictEqual(other.body.key, CLIENT_B + '|indexer/BTC/mainnet'); + }); + + it('keeps one client on separate buckets per chain, as the key intends', async function(){ + harness = await startHarness({ trustProxy: true, cfg: { SNAPSHOT_RATE_FULL: 1 } }); + assert.strictEqual((await get(harness, CLIENT_A, '/snapshot/indexer/BTC/mainnet')).status, 200); + assert.strictEqual((await get(harness, CLIENT_A, '/snapshot/indexer/BTC/mainnet')).status, 429); + assert.strictEqual((await get(harness, CLIENT_A, '/snapshot/indexer/LTC/mainnet')).status, 200); + assert.strictEqual((await get(harness, CLIENT_A, '/snapshot/decoder/BTC/mainnet')).status, 200); + }); + + // Trusting the forwarded header is what makes IPv6 rotation reachable: + // a /64 holder could otherwise mint a fresh bucket per request. + it('collapses an IPv6 client to its network so rotation buys no new budget', async function(){ + harness = await startHarness({ trustProxy: true, cfg: { SNAPSHOT_RATE_FULL: 2 } }); + assert.strictEqual((await get(harness, '2001:db8:0:1::1')).status, 200); + assert.strictEqual((await get(harness, '2001:db8:0:1::2')).status, 200); + let third = await get(harness, '2001:db8:0:1::3'); + assert.strictEqual(third.status, 429, 'addresses in one IPv6 allocation must share a bucket'); + }); + }); +}); + From 8ae06d284cf01951e899facd97090b2b728433cb Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:24:44 -0700 Subject: [PATCH 17/62] test: split API security coverage by behavior --- test/security/api_security.test.js | 111 ++++-------------- .../comparison_and_errors.test.js | 97 +++++++++++++++ .../helpers/api_security_suite.js | 51 ++++++++ 3 files changed, 169 insertions(+), 90 deletions(-) create mode 100644 test/security/api_security.test/comparison_and_errors.test.js create mode 100644 test/security/api_security.test/helpers/api_security_suite.js diff --git a/test/security/api_security.test.js b/test/security/api_security.test.js index 1c98798c..65db1646 100644 --- a/test/security/api_security.test.js +++ b/test/security/api_security.test.js @@ -8,38 +8,22 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const assert = require('assert'); -const sinon = require('sinon'); -const { createApiKeyMiddleware, safeEqual } = require('../../src/http/middleware'); - -function createMockReq(authHeader){ - let req = { headers: {} }; - if(authHeader !== undefined) - req.headers['authorization'] = authHeader; - return req; -} - -function createMockRes(){ - let res = { - _statusCode: null, - _body: null, - status: function(code){ - res._statusCode = code; - return res; - }, - json: function(body){ - res._body = body; - return res; - } - }; - return res; +const { + assert, + createApiKeyMiddleware, + createMockReq, + createMockRes, + registerHooks, + safeEqual, + sinon +} = require('./api_security.test/helpers/api_security_suite'); + +function afterHook() { + sinon.restore(); } describe('API security', function(){ - - afterEach(function(){ - sinon.restore(); - }); + registerHooks(afterHook); // ── createApiKeyMiddleware ── @@ -96,6 +80,14 @@ describe('API security', function(){ assert.strictEqual(res._statusCode, 401); }); + }); +}); + +describe('API security', function(){ + registerHooks(afterHook); + + describe('createApiKeyMiddleware', function(){ + it('returns 401 when header present but no Bearer prefix', function(){ let middleware = createApiKeyMiddleware('secret123'); let req = createMockReq('secret123'); @@ -146,65 +138,4 @@ describe('API security', function(){ }); }); - // Constant-time comparison used by every Bearer-key check (REST middleware, - // /halt/clear, and the WS upgrade). Locks in correctness and the null/length - // guards so a refactor can't silently reintroduce a `===` timing leak. - describe('safeEqual', function(){ - - it('true for identical strings', function(){ - assert.strictEqual(safeEqual('Bearer abc123', 'Bearer abc123'), true); - }); - - it('false for a one-character difference of equal length', function(){ - assert.strictEqual(safeEqual('Bearer abc123', 'Bearer abc124'), false); - }); - - it('false for a length mismatch (prefix of the key)', function(){ - assert.strictEqual(safeEqual('Bearer abc', 'Bearer abc123'), false); - }); - - it('false when either side is null or undefined', function(){ - assert.strictEqual(safeEqual(undefined, 'Bearer k'), false); - assert.strictEqual(safeEqual('Bearer k', null), false); - }); - - it('true for two empty/absent values (both coerce to empty)', function(){ - assert.strictEqual(safeEqual('', ''), true); - assert.strictEqual(safeEqual(undefined, null), true); - }); - }); - - // ── Error response sanitization ── - - describe('error response sanitization', function(){ - - it('API error responses should use generic message pattern', function(){ - // This test validates the pattern used in api.js error handlers. - // The actual routes use: res.status(500).json({ error: 'Internal server error' }) - // If the code accidentally leaks e.message, this test documents the expected contract. - let genericError = { error: 'Internal server error' }; - let sensitiveError = { error: 'ER_ACCESS_DENIED_ERROR: Access denied for user \'root\'@\'172.18.0.1\'' }; - - // The generic error should NOT contain connection details - assert.strictEqual(genericError.error.includes('Access denied'), false); - assert.strictEqual(genericError.error.includes('172.18'), false); - assert.strictEqual(genericError.error, 'Internal server error'); - - // A sensitive error would contain them; this is what we prevent - assert.strictEqual(sensitiveError.error.includes('Access denied'), true); - }); - - it('Internal server error message does not vary by exception type', function(){ - // Regardless of exception type, the API should always return the same message - let dbError = new Error('ER_TABLE_NOT_FOUND'); - let connError = new Error('ECONNREFUSED 172.18.0.1:3306'); - let syntaxError = new Error("SELECT * FROM `evil;DROP`"); - - // The sanitized response should be identical for all - let sanitized = 'Internal server error'; - assert.notStrictEqual(sanitized, dbError.message); - assert.notStrictEqual(sanitized, connError.message); - assert.notStrictEqual(sanitized, syntaxError.message); - }); - }); }); diff --git a/test/security/api_security.test/comparison_and_errors.test.js b/test/security/api_security.test/comparison_and_errors.test.js new file mode 100644 index 00000000..b73b845d --- /dev/null +++ b/test/security/api_security.test/comparison_and_errors.test.js @@ -0,0 +1,97 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { + assert, + createApiKeyMiddleware, + createMockReq, + createMockRes, + registerHooks, + safeEqual, + sinon +} = require('./helpers/api_security_suite'); + +function afterHook() { + sinon.restore(); +} + + +// Covers comparison and error responses. One part of api_security.test.js. +describe('API security', function(){ + registerHooks(afterHook); + + // Constant-time comparison used by every Bearer-key check (REST middleware, + // /halt/clear, and the WS upgrade). Locks in correctness and the null/length + // guards so a refactor can't silently reintroduce a `===` timing leak. + describe('safeEqual', function(){ + + it('true for identical strings', function(){ + assert.strictEqual(safeEqual('Bearer abc123', 'Bearer abc123'), true); + }); + + it('false for a one-character difference of equal length', function(){ + assert.strictEqual(safeEqual('Bearer abc123', 'Bearer abc124'), false); + }); + + it('false for a length mismatch (prefix of the key)', function(){ + assert.strictEqual(safeEqual('Bearer abc', 'Bearer abc123'), false); + }); + + it('false when either side is null or undefined', function(){ + assert.strictEqual(safeEqual(undefined, 'Bearer k'), false); + assert.strictEqual(safeEqual('Bearer k', null), false); + }); + + it('true for two empty/absent values (both coerce to empty)', function(){ + assert.strictEqual(safeEqual('', ''), true); + assert.strictEqual(safeEqual(undefined, null), true); + }); + }); + +}); + +describe('API security', function(){ + registerHooks(afterHook); + + // ── Error response sanitization ── + + describe('error response sanitization', function(){ + + it('API error responses should use generic message pattern', function(){ + // This test validates the pattern used in api.js error handlers. + // The actual routes use: res.status(500).json({ error: 'Internal server error' }) + // If the code accidentally leaks e.message, this test documents the expected contract. + let genericError = { error: 'Internal server error' }; + let sensitiveError = { error: 'ER_ACCESS_DENIED_ERROR: Access denied for user \'root\'@\'172.18.0.1\'' }; + + // The generic error should NOT contain connection details + assert.strictEqual(genericError.error.includes('Access denied'), false); + assert.strictEqual(genericError.error.includes('172.18'), false); + assert.strictEqual(genericError.error, 'Internal server error'); + + // A sensitive error would contain them; this is what we prevent + assert.strictEqual(sensitiveError.error.includes('Access denied'), true); + }); + + it('Internal server error message does not vary by exception type', function(){ + // Regardless of exception type, the API should always return the same message + let dbError = new Error('ER_TABLE_NOT_FOUND'); + let connError = new Error('ECONNREFUSED 172.18.0.1:3306'); + let syntaxError = new Error("SELECT * FROM `evil;DROP`"); + + // The sanitized response should be identical for all + let sanitized = 'Internal server error'; + assert.notStrictEqual(sanitized, dbError.message); + assert.notStrictEqual(sanitized, connError.message); + assert.notStrictEqual(sanitized, syntaxError.message); + }); + }); +}); + diff --git a/test/security/api_security.test/helpers/api_security_suite.js b/test/security/api_security.test/helpers/api_security_suite.js new file mode 100644 index 00000000..51322f92 --- /dev/null +++ b/test/security/api_security.test/helpers/api_security_suite.js @@ -0,0 +1,51 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Shared fixtures and hook registration. One part of api_security.test.js. +const assert = require('assert'); +const sinon = require('sinon'); +const { createApiKeyMiddleware, safeEqual } = require('../../../../src/http/middleware'); + +function createMockReq(authHeader){ + let req = { headers: {} }; + if(authHeader !== undefined) + req.headers['authorization'] = authHeader; + return req; +} + +function createMockRes(){ + let res = { + _statusCode: null, + _body: null, + status: function(code){ + res._statusCode = code; + return res; + }, + json: function(body){ + res._body = body; + return res; + } + }; + return res; +} + +function registerHooks(afterHook) { + afterEach(afterHook); +} + +module.exports = { + assert, + createApiKeyMiddleware, + createMockReq, + createMockRes, + registerHooks, + safeEqual, + sinon +}; From 2a3b3c44134ead91b501dd40710090c1c3eaf3f2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:24:44 -0700 Subject: [PATCH 18/62] test: split broadcaster security coverage by behavior --- .../block_broadcaster_security.test.js | 172 +++--------------- .../block_broadcaster_security_suite.js | 52 ++++++ .../per_ip_limits.test.js | 160 ++++++++++++++++ 3 files changed, 239 insertions(+), 145 deletions(-) create mode 100644 test/security/block_broadcaster_security.test/helpers/block_broadcaster_security_suite.js create mode 100644 test/security/block_broadcaster_security.test/per_ip_limits.test.js diff --git a/test/security/block_broadcaster_security.test.js b/test/security/block_broadcaster_security.test.js index 9da635cd..631ae128 100644 --- a/test/security/block_broadcaster_security.test.js +++ b/test/security/block_broadcaster_security.test.js @@ -8,43 +8,25 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const assert = require('assert'); -const sinon = require('sinon'); -const BlockBroadcaster = require('../../src/server/block_broadcaster'); - -function createMockWs(ip){ - return { - readyState: 1, // WebSocket.OPEN - close: sinon.stub(), - send: sinon.stub(), - on: sinon.stub(), - _syncChain: null, - _syncNetwork: null, - _syncIp: null, - _syncBuffered: 0, - bufferedAmount: 0 - }; +const { + assert, + BlockBroadcaster, + createMockReq, + createMockWs, + registerHooks, + sinon +} = require('./block_broadcaster_security.test/helpers/block_broadcaster_security_suite'); + +function beforeHook() { + sinon.stub(console, 'log'); } -function createMockReq(socketIp, forwardedFor){ - let req = { - headers: {}, - socket: { remoteAddress: socketIp } - }; - if(forwardedFor) - req.headers['x-forwarded-for'] = forwardedFor; - return req; +function afterHook() { + sinon.restore(); } describe('BlockBroadcaster security', function(){ - - beforeEach(function(){ - sinon.stub(console, 'log'); - }); - - afterEach(function(){ - sinon.restore(); - }); + registerHooks(beforeHook, afterHook); // ── getIp: TRUST_PROXY=false (default) ── @@ -72,6 +54,11 @@ describe('BlockBroadcaster security', function(){ }); }); +}); + +describe('BlockBroadcaster security', function(){ + registerHooks(beforeHook, afterHook); + // ── getIp: TRUST_PROXY=true ── // ── getIp: TRUST_PROXY=true ── @@ -115,6 +102,14 @@ describe('BlockBroadcaster security', function(){ assert.strictEqual(ip, '203.0.113.7'); }); + }); +}); + +describe('BlockBroadcaster security', function(){ + registerHooks(beforeHook, afterHook); + + describe('getIp: TRUST_PROXY=true', function(){ + it('trims whitespace around the appended address', function(){ let broadcaster = new BlockBroadcaster({ TRUST_PROXY: true, WS_MAX_PER_IP: 3, WS_BACKPRESSURE_LIMIT: 50 }); let req = createMockReq('192.168.1.1', '10.0.0.1 , 172.16.0.1 '); @@ -144,117 +139,4 @@ describe('BlockBroadcaster security', function(){ }); }); - // ── Per-IP limit through the trusted proxy ── - - describe('per-IP limit with TRUST_PROXY=true', function(){ - - it('rotating the forged leading entry does not buy extra connections', function(){ - let config = { TRUST_PROXY: true, WS_MAX_PER_IP: 2, WS_BACKPRESSURE_LIMIT: 50 }; - let broadcaster = new BlockBroadcaster(config); - - // All three arrive from the same real client 203.0.113.7 (appended by Apache); - // only the forgeable prefix changes between them. - let results = []; - for(let i = 1; i <= 3; i++){ - let ws = createMockWs(); - let req = createMockReq('127.0.0.1', '10.0.0.' + i + ', 203.0.113.7'); - results.push({ ws, added: broadcaster.addSubscription(ws, req, 'bitcoin', 'mainnet') }); - } - - assert.strictEqual(results[0].added, true); - assert.strictEqual(results[1].added, true); - assert.strictEqual(results[2].added, false); - assert.strictEqual(results[2].ws.close.calledOnce, true); - }); - - it('two real clients behind the proxy get independent buckets', function(){ - let config = { TRUST_PROXY: true, WS_MAX_PER_IP: 2, WS_BACKPRESSURE_LIMIT: 50 }; - let broadcaster = new BlockBroadcaster(config); - - // Client A exhausts its cap. - for(let i = 0; i < 2; i++){ - let ws = createMockWs(); - let added = broadcaster.addSubscription(ws, createMockReq('127.0.0.1', '203.0.113.7'), 'bitcoin', 'mainnet'); - assert.strictEqual(added, true); - } - let wsAOver = createMockWs(); - assert.strictEqual( - broadcaster.addSubscription(wsAOver, createMockReq('127.0.0.1', '203.0.113.7'), 'bitcoin', 'mainnet'), - false - ); - - // Client B is unaffected: a different appended address is a different bucket. - let wsB = createMockWs(); - assert.strictEqual( - broadcaster.addSubscription(wsB, createMockReq('127.0.0.1', '198.51.100.4'), 'bitcoin', 'mainnet'), - true - ); - assert.strictEqual(wsB._syncIp, '198.51.100.4'); - assert.strictEqual(wsB.close.called, false); - }); - - it('a client cannot exhaust another client bucket by claiming its address in the prefix', function(){ - let config = { TRUST_PROXY: true, WS_MAX_PER_IP: 2, WS_BACKPRESSURE_LIMIT: 50 }; - let broadcaster = new BlockBroadcaster(config); - - // Attacker names the victim in the forgeable prefix, twice. - for(let i = 0; i < 2; i++){ - let ws = createMockWs(); - let added = broadcaster.addSubscription( - ws, createMockReq('127.0.0.1', '198.51.100.4, 203.0.113.7'), 'bitcoin', 'mainnet'); - assert.strictEqual(added, true); - } - - // The victim still connects: the attacker's traffic was booked to 203.0.113.7. - let victim = createMockWs(); - assert.strictEqual( - broadcaster.addSubscription(victim, createMockReq('127.0.0.1', '198.51.100.4'), 'bitcoin', 'mainnet'), - true - ); - assert.strictEqual(victim.close.called, false); - }); - }); - - // ── Per-IP limit not bypassed by header spoofing ── - - describe('per-IP limit with TRUST_PROXY=false', function(){ - - it('spoofed x-forwarded-for cannot bypass per-IP limit', function(){ - let config = { TRUST_PROXY: false, WS_MAX_PER_IP: 2, WS_BACKPRESSURE_LIMIT: 50 }; - let broadcaster = new BlockBroadcaster(config); - - // Attacker sends 3 connections from same socket IP, each with different x-forwarded-for - let ws1 = createMockWs(); - let req1 = createMockReq('1.1.1.1', '10.0.0.1'); - let added1 = broadcaster.addSubscription(ws1, req1, 'bitcoin', 'mainnet'); - assert.strictEqual(added1, true); - - let ws2 = createMockWs(); - let req2 = createMockReq('1.1.1.1', '10.0.0.2'); - let added2 = broadcaster.addSubscription(ws2, req2, 'bitcoin', 'mainnet'); - assert.strictEqual(added2, true); - - let ws3 = createMockWs(); - let req3 = createMockReq('1.1.1.1', '10.0.0.3'); - let added3 = broadcaster.addSubscription(ws3, req3, 'bitcoin', 'mainnet'); - // Third connection should be rejected: all are from 1.1.1.1 regardless of spoofed header - assert.strictEqual(added3, false); - assert.strictEqual(ws3.close.calledOnce, true); - }); - - it('different socket IPs are not affected by per-IP limit', function(){ - let config = { TRUST_PROXY: false, WS_MAX_PER_IP: 1, WS_BACKPRESSURE_LIMIT: 50 }; - let broadcaster = new BlockBroadcaster(config); - - let ws1 = createMockWs(); - let req1 = createMockReq('1.1.1.1'); - let added1 = broadcaster.addSubscription(ws1, req1, 'bitcoin', 'mainnet'); - assert.strictEqual(added1, true); - - let ws2 = createMockWs(); - let req2 = createMockReq('2.2.2.2'); - let added2 = broadcaster.addSubscription(ws2, req2, 'bitcoin', 'mainnet'); - assert.strictEqual(added2, true); - }); - }); }); diff --git a/test/security/block_broadcaster_security.test/helpers/block_broadcaster_security_suite.js b/test/security/block_broadcaster_security.test/helpers/block_broadcaster_security_suite.js new file mode 100644 index 00000000..9eaa16d9 --- /dev/null +++ b/test/security/block_broadcaster_security.test/helpers/block_broadcaster_security_suite.js @@ -0,0 +1,52 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Shared fixtures and hook registration. One part of block_broadcaster_security.test.js. +const assert = require('assert'); +const sinon = require('sinon'); +const BlockBroadcaster = require('../../../../src/server/block_broadcaster'); + +function createMockWs(ip){ + return { + readyState: 1, // WebSocket.OPEN + close: sinon.stub(), + send: sinon.stub(), + on: sinon.stub(), + _syncChain: null, + _syncNetwork: null, + _syncIp: null, + _syncBuffered: 0, + bufferedAmount: 0 + }; +} + +function createMockReq(socketIp, forwardedFor){ + let req = { + headers: {}, + socket: { remoteAddress: socketIp } + }; + if(forwardedFor) + req.headers['x-forwarded-for'] = forwardedFor; + return req; +} + +function registerHooks(beforeHook, afterHook) { + beforeEach(beforeHook); + afterEach(afterHook); +} + +module.exports = { + assert, + BlockBroadcaster, + createMockReq, + createMockWs, + registerHooks, + sinon +}; diff --git a/test/security/block_broadcaster_security.test/per_ip_limits.test.js b/test/security/block_broadcaster_security.test/per_ip_limits.test.js new file mode 100644 index 00000000..1675dacf --- /dev/null +++ b/test/security/block_broadcaster_security.test/per_ip_limits.test.js @@ -0,0 +1,160 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { + assert, + BlockBroadcaster, + createMockReq, + createMockWs, + registerHooks, + sinon +} = require('./helpers/block_broadcaster_security_suite'); + +function beforeHook() { + sinon.stub(console, 'log'); +} + +function afterHook() { + sinon.restore(); +} + + +// Covers per-IP subscription limits. One part of block_broadcaster_security.test.js. +describe('BlockBroadcaster security', function(){ + registerHooks(beforeHook, afterHook); + + // ── Per-IP limit through the trusted proxy ── + + describe('per-IP limit with TRUST_PROXY=true', function(){ + + it('rotating the forged leading entry does not buy extra connections', function(){ + let config = { TRUST_PROXY: true, WS_MAX_PER_IP: 2, WS_BACKPRESSURE_LIMIT: 50 }; + let broadcaster = new BlockBroadcaster(config); + + // All three arrive from the same real client 203.0.113.7 (appended by Apache); + // only the forgeable prefix changes between them. + let results = []; + for(let i = 1; i <= 3; i++){ + let ws = createMockWs(); + let req = createMockReq('127.0.0.1', '10.0.0.' + i + ', 203.0.113.7'); + results.push({ ws, added: broadcaster.addSubscription(ws, req, 'bitcoin', 'mainnet') }); + } + + assert.strictEqual(results[0].added, true); + assert.strictEqual(results[1].added, true); + assert.strictEqual(results[2].added, false); + assert.strictEqual(results[2].ws.close.calledOnce, true); + }); + + }); +}); + +describe('BlockBroadcaster security', function(){ + registerHooks(beforeHook, afterHook); + + describe('per-IP limit with TRUST_PROXY=true', function(){ + + it('two real clients behind the proxy get independent buckets', function(){ + let config = { TRUST_PROXY: true, WS_MAX_PER_IP: 2, WS_BACKPRESSURE_LIMIT: 50 }; + let broadcaster = new BlockBroadcaster(config); + + // Client A exhausts its cap. + for(let i = 0; i < 2; i++){ + let ws = createMockWs(); + let added = broadcaster.addSubscription(ws, createMockReq('127.0.0.1', '203.0.113.7'), 'bitcoin', 'mainnet'); + assert.strictEqual(added, true); + } + let wsAOver = createMockWs(); + assert.strictEqual( + broadcaster.addSubscription(wsAOver, createMockReq('127.0.0.1', '203.0.113.7'), 'bitcoin', 'mainnet'), + false + ); + + // Client B is unaffected: a different appended address is a different bucket. + let wsB = createMockWs(); + assert.strictEqual( + broadcaster.addSubscription(wsB, createMockReq('127.0.0.1', '198.51.100.4'), 'bitcoin', 'mainnet'), + true + ); + assert.strictEqual(wsB._syncIp, '198.51.100.4'); + assert.strictEqual(wsB.close.called, false); + }); + + it('a client cannot exhaust another client bucket by claiming its address in the prefix', function(){ + let config = { TRUST_PROXY: true, WS_MAX_PER_IP: 2, WS_BACKPRESSURE_LIMIT: 50 }; + let broadcaster = new BlockBroadcaster(config); + + // Attacker names the victim in the forgeable prefix, twice. + for(let i = 0; i < 2; i++){ + let ws = createMockWs(); + let added = broadcaster.addSubscription( + ws, createMockReq('127.0.0.1', '198.51.100.4, 203.0.113.7'), 'bitcoin', 'mainnet'); + assert.strictEqual(added, true); + } + + // The victim still connects: the attacker's traffic was booked to 203.0.113.7. + let victim = createMockWs(); + assert.strictEqual( + broadcaster.addSubscription(victim, createMockReq('127.0.0.1', '198.51.100.4'), 'bitcoin', 'mainnet'), + true + ); + assert.strictEqual(victim.close.called, false); + }); + }); + +}); + +describe('BlockBroadcaster security', function(){ + registerHooks(beforeHook, afterHook); + + // ── Per-IP limit not bypassed by header spoofing ── + + describe('per-IP limit with TRUST_PROXY=false', function(){ + + it('spoofed x-forwarded-for cannot bypass per-IP limit', function(){ + let config = { TRUST_PROXY: false, WS_MAX_PER_IP: 2, WS_BACKPRESSURE_LIMIT: 50 }; + let broadcaster = new BlockBroadcaster(config); + + // Attacker sends 3 connections from same socket IP, each with different x-forwarded-for + let ws1 = createMockWs(); + let req1 = createMockReq('1.1.1.1', '10.0.0.1'); + let added1 = broadcaster.addSubscription(ws1, req1, 'bitcoin', 'mainnet'); + assert.strictEqual(added1, true); + + let ws2 = createMockWs(); + let req2 = createMockReq('1.1.1.1', '10.0.0.2'); + let added2 = broadcaster.addSubscription(ws2, req2, 'bitcoin', 'mainnet'); + assert.strictEqual(added2, true); + + let ws3 = createMockWs(); + let req3 = createMockReq('1.1.1.1', '10.0.0.3'); + let added3 = broadcaster.addSubscription(ws3, req3, 'bitcoin', 'mainnet'); + // Third connection should be rejected: all are from 1.1.1.1 regardless of spoofed header + assert.strictEqual(added3, false); + assert.strictEqual(ws3.close.calledOnce, true); + }); + + it('different socket IPs are not affected by per-IP limit', function(){ + let config = { TRUST_PROXY: false, WS_MAX_PER_IP: 1, WS_BACKPRESSURE_LIMIT: 50 }; + let broadcaster = new BlockBroadcaster(config); + + let ws1 = createMockWs(); + let req1 = createMockReq('1.1.1.1'); + let added1 = broadcaster.addSubscription(ws1, req1, 'bitcoin', 'mainnet'); + assert.strictEqual(added1, true); + + let ws2 = createMockWs(); + let req2 = createMockReq('2.2.2.2'); + let added2 = broadcaster.addSubscription(ws2, req2, 'bitcoin', 'mainnet'); + assert.strictEqual(added2, true); + }); + }); +}); + From 0fadb1bd3225b69beda6c779eb3594a1b91f0531 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:22:02 -0700 Subject: [PATCH 19/62] test: split scoped balance rebuild suite by behavior Move rebuild parity and client applier coverage into independent integration parts. Share the database hooks and fixtures without changing await counts or title order. --- .../balance_rebuild_scoped.test.js | 139 ------------------ .../01_full_rebuild.test.js | 40 +++++ .../02_client_applier.test.js | 41 ++++++ .../helpers/balance_rebuild_scoped_suite.js | 105 +++++++++++++ 4 files changed, 186 insertions(+), 139 deletions(-) create mode 100644 test/integration/balance_rebuild_scoped.test/01_full_rebuild.test.js create mode 100644 test/integration/balance_rebuild_scoped.test/02_client_applier.test.js create mode 100644 test/integration/balance_rebuild_scoped.test/helpers/balance_rebuild_scoped_suite.js diff --git a/test/integration/balance_rebuild_scoped.test.js b/test/integration/balance_rebuild_scoped.test.js index 0c3050ec..c92386b2 100644 --- a/test/integration/balance_rebuild_scoped.test.js +++ b/test/integration/balance_rebuild_scoped.test.js @@ -7,142 +7,3 @@ // General Public License v3.0 or later; see LICENSE.md. A commercial // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. - -const assert = require('assert'); -const sinon = require('sinon'); -const setup = require('./helpers/setup'); -const testDb = require('./helpers/testDb'); -const balanceHelpers = require('../../src/db/balance_helpers'); -const ClientApplier = require('../../src/client/applier'); - -// Behavioural proof for the scoped rebuild optimisation: against a real -// MariaDB, a rebuild scoped to the ids a batch of new rows touched must -// leave the aggregate table EXACTLY as a full-table rebuild would, with the same -// rows, same string amounts, untouched pairs preserved, zeroed pairs -// pruned, invalid-status custody excluded. The unit suite pins the SQL -// shape; this pins the arithmetic the database actually performs. - -async function snapshotBalances(db) { - let rows = await db.doQuery( - 'SELECT address_id, tick_id, amount FROM balances ORDER BY address_id, tick_id'); - return rows.map(r => [Number(r.address_id), Number(r.tick_id), String(r.amount)]); -} - -async function insertLedgerRows(db, table, rows) { - for (let r of rows) { - await db.doQuery( - 'INSERT INTO `' + table + '` (action_index, address_id, tick_id, amount) VALUES (?, ?, ?, ?)', - [r.action_index, r.address_id, r.tick_id, r.amount]); - } -} - -async function insertCustodyRows(db, table, rows) { - for (let r of rows) { - await db.doQuery( - 'INSERT INTO `' + table + '` (action_index, contract_index, source_id, tick_id, amount, status_id, block_index) VALUES (?, ?, ?, ?, ?, ?, ?)', - [r.action_index, r.contract_index, r.source_id || 1, r.tick_id, r.amount, r.status_id, r.block_index || 1]); - } -} - -describe('Integration: scoped balance rebuilds', function() { - - let db; - - before(async function() { - await setup.globalSetup(); - db = setup.getReplicaDb(); - sinon.stub(console, 'log'); - sinon.stub(console, 'error'); - }); - - after(async function() { - sinon.restore(); - await setup.globalTeardown(); - }); - - beforeEach(async function() { - await testDb.truncateAll(db); - }); - - describe('balances', function() { - - // History across addresses 1-5 / ticks 1-3, with big-number amounts. - // Pair (5,3) nets to exactly zero in history (must never appear). - const history = { - credits: [ - { action_index: 1, address_id: 1, tick_id: 1, amount: '1000' }, - { action_index: 2, address_id: 2, tick_id: 1, amount: '250' }, - { action_index: 3, address_id: 2, tick_id: 2, amount: '99999999999999999999999999999999999999' }, - { action_index: 4, address_id: 3, tick_id: 2, amount: '7' }, - { action_index: 5, address_id: 4, tick_id: 3, amount: '12345678901234567890' }, - { action_index: 6, address_id: 5, tick_id: 3, amount: '500' } - ], - debits: [ - { action_index: 7, address_id: 1, tick_id: 1, amount: '400' }, - { action_index: 8, address_id: 2, tick_id: 1, amount: '50' }, - { action_index: 9, address_id: 5, tick_id: 3, amount: '500' } - ] - }; - - // New rows touch addresses {2,3} × ticks {1,2}; they drive pair (2,1) - // to exactly zero, so the scoped rebuild must also PRUNE it. The scope - // rectangle includes pair (3,1) (no rows at all) and pair (2,2) - // (history only, untouched by the new rows); both must come out - // exactly as the full rebuild leaves them. - const newCredits = [ - { action_index: 10, address_id: 3, tick_id: 2, amount: '13' } - ]; - const newDebits = [ - { action_index: 11, address_id: 2, tick_id: 1, amount: '200' } - ]; - const scope = { addressIds: [2, 3], tickIds: [1, 2] }; - - it('a scoped rebuild leaves the table exactly as a full rebuild would', async function() { - await insertLedgerRows(db, 'credits', history.credits); - await insertLedgerRows(db, 'debits', history.debits); - await balanceHelpers.rebuildBalances(db); - - await insertLedgerRows(db, 'credits', newCredits); - await insertLedgerRows(db, 'debits', newDebits); - - await balanceHelpers.rebuildBalances(db, scope); - let scoped = await snapshotBalances(db); - - await balanceHelpers.rebuildBalances(db); - let full = await snapshotBalances(db); - - assert.deepStrictEqual(scoped, full); - - // Spot-checks on the interesting rows so a both-paths-wrong - // regression can't slip through the equivalence assertion. - assert.ok(!scoped.some(r => r[0] === 2 && r[1] === 1), 'zeroed pair (2,1) pruned'); - assert.ok(scoped.some(r => r[0] === 1 && r[1] === 1 && r[2] === '600'), 'untouched pair (1,1) intact'); - assert.ok(scoped.some(r => r[0] === 2 && r[1] === 2 && r[2] === '99999999999999999999999999999999999999'), - 'big-number amount survives the scoped recompute byte-for-byte'); - assert.ok(scoped.some(r => r[0] === 3 && r[1] === 2 && r[2] === '20'), 'touched pair (3,2) recomputed'); - }); - - it('ClientApplier.applyBlock drives the scoped rebuild end-to-end', async function() { - await insertLedgerRows(db, 'credits', history.credits); - await insertLedgerRows(db, 'debits', history.debits); - await balanceHelpers.rebuildBalances(db); - - db.dbType = 'indexer'; - let applier = new ClientApplier(db, testDb.util); - let spy = sinon.spy(balanceHelpers, 'rebuildBalances'); - try { - await applier.applyBlock({ block_index: 999, data: { credits: newCredits, debits: newDebits } }); - assert.deepStrictEqual(spy.firstCall.args[1], { addressIds: [3, 2], tickIds: [2, 1] }, - 'applier passed a scope (not a full rebuild)'); - } finally { - spy.restore(); - delete db.dbType; - } - - let applied = await snapshotBalances(db); - await balanceHelpers.rebuildBalances(db); - assert.deepStrictEqual(applied, await snapshotBalances(db)); - }); - }); - -}); diff --git a/test/integration/balance_rebuild_scoped.test/01_full_rebuild.test.js b/test/integration/balance_rebuild_scoped.test/01_full_rebuild.test.js new file mode 100644 index 00000000..b3518138 --- /dev/null +++ b/test/integration/balance_rebuild_scoped.test/01_full_rebuild.test.js @@ -0,0 +1,40 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers scoped and full rebuild parity. One part of balance_rebuild_scoped.test.js. +const assert = require('assert'); +const balanceHelpers = require('../../../src/db/balance_helpers'); +const suite = require('./helpers/balance_rebuild_scoped_suite'); + +describe('Integration: scoped balance rebuilds', function() { + suite.installHooks(); + describe('balances', function() { + it('a scoped rebuild leaves the table exactly as a full rebuild would', async function() { + await suite.insertLedgerRows(suite.db, 'credits', suite.history.credits); + await suite.insertLedgerRows(suite.db, 'debits', suite.history.debits); + await balanceHelpers.rebuildBalances(suite.db); + await suite.insertLedgerRows(suite.db, 'credits', suite.newCredits); + await suite.insertLedgerRows(suite.db, 'debits', suite.newDebits); + await balanceHelpers.rebuildBalances(suite.db, suite.scope); + let scoped = await suite.snapshotBalances(suite.db); + await balanceHelpers.rebuildBalances(suite.db); + let full = await suite.snapshotBalances(suite.db); + assert.deepStrictEqual(scoped, full); + + // Spot-checks on the interesting rows so a both-paths-wrong + // regression can't slip through the equivalence assertion. + assert.ok(!scoped.some(r => r[0] === 2 && r[1] === 1), 'zeroed pair (2,1) pruned'); + assert.ok(scoped.some(r => r[0] === 1 && r[1] === 1 && r[2] === '600'), 'untouched pair (1,1) intact'); + assert.ok(scoped.some(r => r[0] === 2 && r[1] === 2 && r[2] === '99999999999999999999999999999999999999'), + 'big-number amount survives the scoped recompute byte-for-byte'); + assert.ok(scoped.some(r => r[0] === 3 && r[1] === 2 && r[2] === '20'), 'touched pair (3,2) recomputed'); + }); + }); +}); diff --git a/test/integration/balance_rebuild_scoped.test/02_client_applier.test.js b/test/integration/balance_rebuild_scoped.test/02_client_applier.test.js new file mode 100644 index 00000000..7b31de76 --- /dev/null +++ b/test/integration/balance_rebuild_scoped.test/02_client_applier.test.js @@ -0,0 +1,41 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers the client applier rebuild path. One part of balance_rebuild_scoped.test.js. +const assert = require('assert'); +const sinon = require('sinon'); +const balanceHelpers = require('../../../src/db/balance_helpers'); +const ClientApplier = require('../../../src/client/applier'); +const suite = require('./helpers/balance_rebuild_scoped_suite'); + +describe('Integration: scoped balance rebuilds', function() { + suite.installHooks(); + describe('balances', function() { + it('ClientApplier.applyBlock drives the scoped rebuild end-to-end', async function() { + await suite.insertLedgerRows(suite.db, 'credits', suite.history.credits); + await suite.insertLedgerRows(suite.db, 'debits', suite.history.debits); + await balanceHelpers.rebuildBalances(suite.db); + suite.db.dbType = 'indexer'; + let applier = new ClientApplier(suite.db, suite.testDb.util); + let spy = sinon.spy(balanceHelpers, 'rebuildBalances'); + try { + await applier.applyBlock({ block_index: 999, data: { credits: suite.newCredits, debits: suite.newDebits } }); + assert.deepStrictEqual(spy.firstCall.args[1], { addressIds: [3, 2], tickIds: [2, 1] }, + 'applier passed a scope (not a full rebuild)'); + } finally { + spy.restore(); + delete suite.db.dbType; + } + let applied = await suite.snapshotBalances(suite.db); + await balanceHelpers.rebuildBalances(suite.db); + assert.deepStrictEqual(applied, await suite.snapshotBalances(suite.db)); + }); + }); +}); diff --git a/test/integration/balance_rebuild_scoped.test/helpers/balance_rebuild_scoped_suite.js b/test/integration/balance_rebuild_scoped.test/helpers/balance_rebuild_scoped_suite.js new file mode 100644 index 00000000..01b17db8 --- /dev/null +++ b/test/integration/balance_rebuild_scoped.test/helpers/balance_rebuild_scoped_suite.js @@ -0,0 +1,105 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Provides scoped balance fixtures and hooks. One part of balance_rebuild_scoped.test.js. +const sinon = require('sinon'); +const setup = require('../../helpers/setup'); +const testDb = require('../../helpers/testDb'); + +let db; + +// Behavioural proof for the scoped rebuild optimisation: against a real +// MariaDB, a rebuild scoped to the ids a batch of new rows touched must +// leave the aggregate table EXACTLY as a full-table rebuild would, with the same +// rows, same string amounts, untouched pairs preserved, zeroed pairs +// pruned, invalid-status custody excluded. The unit suite pins the SQL +// shape; this pins the arithmetic the database actually performs. + +async function snapshotBalances(db) { + let rows = await db.doQuery( + 'SELECT address_id, tick_id, amount FROM balances ORDER BY address_id, tick_id'); + return rows.map(r => [Number(r.address_id), Number(r.tick_id), String(r.amount)]); +} + +async function insertLedgerRows(db, table, rows) { + for (let r of rows) { + await db.doQuery( + 'INSERT INTO `' + table + '` (action_index, address_id, tick_id, amount) VALUES (?, ?, ?, ?)', + [r.action_index, r.address_id, r.tick_id, r.amount]); + } +} + +async function insertCustodyRows(db, table, rows) { + for (let r of rows) { + await db.doQuery( + 'INSERT INTO `' + table + '` (action_index, contract_index, source_id, tick_id, amount, status_id, block_index) VALUES (?, ?, ?, ?, ?, ?, ?)', + [r.action_index, r.contract_index, r.source_id || 1, r.tick_id, r.amount, r.status_id, r.block_index || 1]); + } +} + +// History across addresses 1-5 / ticks 1-3, with big-number amounts. +// Pair (5,3) nets to exactly zero in history (must never appear). +const history = { + credits: [ + { action_index: 1, address_id: 1, tick_id: 1, amount: '1000' }, + { action_index: 2, address_id: 2, tick_id: 1, amount: '250' }, + { action_index: 3, address_id: 2, tick_id: 2, amount: '99999999999999999999999999999999999999' }, + { action_index: 4, address_id: 3, tick_id: 2, amount: '7' }, + { action_index: 5, address_id: 4, tick_id: 3, amount: '12345678901234567890' }, + { action_index: 6, address_id: 5, tick_id: 3, amount: '500' } + ], + debits: [ + { action_index: 7, address_id: 1, tick_id: 1, amount: '400' }, + { action_index: 8, address_id: 2, tick_id: 1, amount: '50' }, + { action_index: 9, address_id: 5, tick_id: 3, amount: '500' } + ] +}; + +// New rows touch addresses {2,3} × ticks {1,2}; they drive pair (2,1) +// to exactly zero, so the scoped rebuild must also PRUNE it. The scope +// rectangle includes pair (3,1) (no rows at all) and pair (2,2) +// (history only, untouched by the new rows); both must come out +// exactly as the full rebuild leaves them. +const newCredits = [ + { action_index: 10, address_id: 3, tick_id: 2, amount: '13' } +]; +const newDebits = [ + { action_index: 11, address_id: 2, tick_id: 1, amount: '200' } +]; +const scope = { addressIds: [2, 3], tickIds: [1, 2] }; + +function installHooks() { + before(async function() { + await setup.globalSetup(); + db = setup.getReplicaDb(); + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + }); + after(async function() { + sinon.restore(); + await setup.globalTeardown(); + }); + beforeEach(async function() { + await testDb.truncateAll(db); + }); +} + +module.exports = { + get db() { return db; }, + history, + newCredits, + newDebits, + scope, + testDb, + installHooks, + snapshotBalances, + insertLedgerRows, + insertCustodyRows +}; From 657ff4955dc2744cc1cc59aef32ec3bd3939a2b7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:22:15 -0700 Subject: [PATCH 20/62] test: split server polling integration suite by behavior Partition polling, payload, probe, and status coverage into ordered parts. Share polling fixtures and hooks while preserving all nineteen test titles and await counts. --- test/integration/server_polling.test.js | 297 ------------------ .../server_polling.test/01_poll_start.test.js | 47 +++ .../02_poll_continuation.test.js | 60 ++++ .../03_payload_rows.test.js | 51 +++ .../04_payload_indexes.test.js | 36 +++ .../05_action_probe_payloads.test.js | 42 +++ .../06_action_probe_efficiency.test.js | 54 ++++ .../07_update_status.test.js | 30 ++ .../helpers/server_polling_suite.js | 77 +++++ 9 files changed, 397 insertions(+), 297 deletions(-) create mode 100644 test/integration/server_polling.test/01_poll_start.test.js create mode 100644 test/integration/server_polling.test/02_poll_continuation.test.js create mode 100644 test/integration/server_polling.test/03_payload_rows.test.js create mode 100644 test/integration/server_polling.test/04_payload_indexes.test.js create mode 100644 test/integration/server_polling.test/05_action_probe_payloads.test.js create mode 100644 test/integration/server_polling.test/06_action_probe_efficiency.test.js create mode 100644 test/integration/server_polling.test/07_update_status.test.js create mode 100644 test/integration/server_polling.test/helpers/server_polling_suite.js diff --git a/test/integration/server_polling.test.js b/test/integration/server_polling.test.js index 4cc40d26..c92386b2 100644 --- a/test/integration/server_polling.test.js +++ b/test/integration/server_polling.test.js @@ -7,300 +7,3 @@ // General Public License v3.0 or later; see LICENSE.md. A commercial // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. - -const assert = require('assert'); -const sinon = require('sinon'); -const setup = require('./helpers/setup'); -const testDb = require('./helpers/testDb'); -const fixtures = require('./helpers/fixtures'); -const ServerPoller = require('../../src/server/poller'); - -describe('Integration: ServerPoller', function() { - - let sourceDb, poller, broadcaster, transparencyLog, config; - - before(async function() { - await setup.globalSetup(); - }); - - after(async function() { - await setup.globalTeardown(); - }); - - beforeEach(async function() { - sourceDb = setup.getSourceDb(); - await testDb.truncateAll(sourceDb); - - broadcaster = { - broadcast: sinon.stub(), - updateStatus: sinon.stub(), - getSubscriberCount: sinon.stub().returns(0) - }; - transparencyLog = { - recordBlock: sinon.stub().resolves(), - pruneFrom: sinon.stub().resolves() - }; - config = { BLOCK_POLL_INTERVAL: 100 }; - - poller = new ServerPoller('bitcoin', 'mainnet', sourceDb, broadcaster, transparencyLog, config, testDb.util); - sinon.stub(console, 'log'); - sinon.stub(console, 'error'); - }); - - afterEach(function() { - sinon.restore(); - }); - - describe('poll', function() { - it('returns early when no blocks in DB', async function() { - await poller.poll(); - assert.strictEqual(broadcaster.broadcast.called, false); - }); - - it('initializes lastPolledBlock on first poll', async function() { - await fixtures.seedBlocks(sourceDb, 1, 3); - poller.lastPolledBlock = null; - - await poller.poll(); - - assert.strictEqual(poller.lastPolledBlock, 3); - assert.strictEqual(broadcaster.broadcast.called, false); // initialization only - assert.strictEqual(broadcaster.updateStatus.calledOnce, true); - }); - - it('detects and processes new blocks', async function() { - await fixtures.seedBlocks(sourceDb, 1, 1); - poller.lastPolledBlock = 0; - - await poller.poll(); - - assert.strictEqual(broadcaster.broadcast.calledOnce, true); - let event = broadcaster.broadcast.firstCall.args[2]; - assert.strictEqual(event.type, 'block'); - assert.strictEqual(event.block_index, 1); - assert.strictEqual(event.chain, 'bitcoin'); - assert.strictEqual(event.network, 'mainnet'); - assert.ok(event.ledger_hash); - assert.ok(event.actions_hash); - assert.ok(event.contract_hash); - assert.ok(event.data); - }); - - it('processes multiple sequential blocks', async function() { - await fixtures.seedBlocks(sourceDb, 1, 5); - poller.lastPolledBlock = 0; - - await poller.poll(); - - assert.strictEqual(broadcaster.broadcast.callCount, 5); - assert.strictEqual(poller.lastPolledBlock, 5); - - // Verify block order - for (let i = 0; i < 5; i++) { - let event = broadcaster.broadcast.getCall(i).args[2]; - assert.strictEqual(event.block_index, i + 1); - } - }); - - it('records each block in transparency log', async function() { - await fixtures.seedBlocks(sourceDb, 1, 3); - poller.lastPolledBlock = 0; - - await poller.poll(); - - assert.strictEqual(transparencyLog.recordBlock.callCount, 3); - }); - - it('detects reorg when block count decreases', async function() { - await fixtures.seedBlocks(sourceDb, 1, 10); - poller.lastPolledBlock = 10; - - // Simulate reorg at source - await fixtures.deleteBlocksFrom(sourceDb, 8); - - await poller.poll(); - - assert.strictEqual(broadcaster.broadcast.calledOnce, true); - let event = broadcaster.broadcast.firstCall.args[2]; - assert.strictEqual(event.type, 'reorg'); - assert.strictEqual(event.block_index, 8); // currentBlock(7) + 1 - assert.strictEqual(poller.lastPolledBlock, 7); - }); - - it('does nothing when no new blocks', async function() { - await fixtures.seedBlocks(sourceDb, 1, 5); - poller.lastPolledBlock = 5; - - await poller.poll(); - - assert.strictEqual(broadcaster.broadcast.called, false); - assert.strictEqual(transparencyLog.recordBlock.called, false); - }); - }); - - describe('buildBlockPayload', function() { - it('builds payload with correct structure from real DB', async function() { - await fixtures.seedBlocks(sourceDb, 1, 1); - - let payload = await poller.buildBlockPayload(1); - - assert.strictEqual(payload.type, 'block'); - assert.strictEqual(payload.chain, 'bitcoin'); - assert.strictEqual(payload.network, 'mainnet'); - assert.strictEqual(payload.block_index, 1); - assert.ok(payload.block_time > 0); - assert.ok(payload.ledger_hash); - assert.ok(payload.data); - }); - - it('includes block-scoped table rows', async function() { - await fixtures.seedBlocks(sourceDb, 1, 1); - let payload = await poller.buildBlockPayload(1); - - assert.ok(payload.data.blocks); - assert.strictEqual(payload.data.blocks.length, 1); - }); - - it('includes transactions', async function() { - await fixtures.seedBlocks(sourceDb, 1, 1); - let payload = await poller.buildBlockPayload(1); - - assert.ok(payload.data.transactions); - assert.strictEqual(payload.data.transactions.length, 1); - }); - - it('includes action-scoped rows (credits)', async function() { - await fixtures.seedBlocks(sourceDb, 1, 1); - let payload = await poller.buildBlockPayload(1); - - assert.ok(payload.data.credits); - assert.strictEqual(payload.data.credits.length, 1); - assert.strictEqual(payload.data.credits[0].amount, '1000'); - }); - - it('includes index_transactions referenced by block', async function() { - await fixtures.seedBlocks(sourceDb, 1, 1); - let payload = await poller.buildBlockPayload(1); - - assert.ok(payload.data.index_transactions); - assert.ok(payload.data.index_transactions.length >= 3); // ledger, actions, contract hashes - }); - - it('includes index_addresses referenced by transactions', async function() { - await fixtures.seedBlocks(sourceDb, 1, 1); - let payload = await poller.buildBlockPayload(1); - - assert.ok(payload.data.index_addresses); - assert.ok(payload.data.index_addresses.length >= 1); - }); - - it('returns null for non-existent block', async function() { - let payload = await poller.buildBlockPayload(999); - assert.strictEqual(payload, null); - }); - }); - - // The payload build used to issue one getActionScopedRows per registry table - // (86 today, growing with every replicated table added); it now asks - // getNonEmptyActionScopedTables once and fetches only what answers. payload.data - // feeds a consensus hash followers recompute, so the only acceptable evidence is - // byte-identity against a REAL database, not a mock: these run the same block - // through both paths on the same rows and compare the serialized payloads. - describe('buildBlockPayload action-scoped probe', function() { - // Same build with the probe removed from the db object, which is the pre-fix - // query-every-table path verbatim. - // getNonEmptyActionScopedTables lives on TestDatabase's PROTOTYPE, so it is - // shadowed with an own `undefined` rather than deleted: a delete removes nothing - // and the comparison would silently be probe-against-probe, which passes while - // proving nothing. - async function buildUnprobed(blockIndex) { - sourceDb.getNonEmptyActionScopedTables = undefined; - try { - assert.strictEqual(typeof sourceDb.getNonEmptyActionScopedTables, 'undefined'); - return await poller.buildBlockPayload(blockIndex); - } finally { - delete sourceDb.getNonEmptyActionScopedTables; - } - } - - it('emits a byte-identical payload on a block that has rows', async function() { - await fixtures.seedBlocks(sourceDb, 1, 1); - - let probed = await poller.buildBlockPayload(1); - let unprobed = await buildUnprobed(1); - - assert.ok(probed.data.credits && probed.data.credits.length === 1, - 'the block must carry action-scoped rows, or this proves nothing'); - assert.strictEqual(JSON.stringify(probed), JSON.stringify(unprobed)); - }); - - it('emits a byte-identical payload on a block with no action-scoped rows', async function() { - await fixtures.seedBlocks(sourceDb, 1, 2); - await sourceDb.doQuery("DELETE FROM credits"); - - let probed = await poller.buildBlockPayload(2); - let unprobed = await buildUnprobed(2); - - assert.ok(!probed.data.credits, 'no action-scoped rows in this block'); - assert.strictEqual(JSON.stringify(probed), JSON.stringify(unprobed)); - }); - - it('cuts the per-table round-trips to the tables that actually have rows', async function() { - await fixtures.seedBlocks(sourceDb, 1, 1); - const spy = sinon.spy(sourceDb, 'getActionScopedRows'); - - await poller.buildBlockPayload(1); - const probedFetches = spy.getCalls().map(c => c.args[0]); - spy.resetHistory(); - - await buildUnprobed(1); - const unprobedFetches = spy.getCalls().map(c => c.args[0]); - - assert.ok(unprobedFetches.length > 40, - 'the unprobed path really does walk the whole registry (' + unprobedFetches.length + ')'); - assert.ok(probedFetches.length < unprobedFetches.length, - 'the probe must remove round-trips (' + probedFetches.length + ' vs ' + unprobedFetches.length + ')'); - // Every table still fetched is one the unprobed path also fetched with rows. - for (const table of probedFetches) - assert.ok(unprobedFetches.includes(table), table + ' fetched by the probed path only'); - }); - - it('agrees with the real fetch on which tables are empty', async function() { - await fixtures.seedBlocks(sourceDb, 1, 1); - const candidates = poller.actionScopedTables - .filter(t => t !== 'actions' && t !== 'contract_emissions'); - - const nonEmpty = await sourceDb.getNonEmptyActionScopedTables(candidates, 1); - - // The safety property, checked table by table against the real database: - // probe membership must equal "getActionScopedRows returns rows". - for (const table of candidates) { - let rows = []; - try { - rows = await sourceDb.getActionScopedRows(table, 1); - } catch (e) { - continue; // table absent from this schema; the probe skips it too - } - assert.strictEqual(nonEmpty.has(table), rows.length > 0, - 'probe disagrees with the real fetch on ' + table); - } - }); - }); - - describe('updateStatus', function() { - it('updates broadcaster status with real block data', async function() { - await fixtures.seedBlocks(sourceDb, 1, 5); - poller.lastPolledBlock = 5; - - await poller.updateStatus(); - - assert.strictEqual(broadcaster.updateStatus.calledOnce, true); - let args = broadcaster.updateStatus.firstCall.args; - assert.strictEqual(args[0], 'bitcoin'); - assert.strictEqual(args[1], 'mainnet'); - assert.strictEqual(args[2].block_height, 5); - assert.ok(args[2].ledger_hash); - assert.ok(args[2].block_time > 0); - }); - }); -}); diff --git a/test/integration/server_polling.test/01_poll_start.test.js b/test/integration/server_polling.test/01_poll_start.test.js new file mode 100644 index 00000000..9488bc30 --- /dev/null +++ b/test/integration/server_polling.test/01_poll_start.test.js @@ -0,0 +1,47 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers initial polling outcomes. One part of server_polling.test.js. +const suite = require('./helpers/server_polling_suite'); + +describe('Integration: ServerPoller', function() { + suite.installHooks(); + describe('poll', function() { + it('returns early when no blocks in DB', async function() { + await suite.poller.poll(); + suite.assert.strictEqual(suite.broadcaster.broadcast.called, false); + }); + + it('initializes lastPolledBlock on first poll', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 3); + suite.poller.lastPolledBlock = null; + await suite.poller.poll(); + suite.assert.strictEqual(suite.poller.lastPolledBlock, 3); + suite.assert.strictEqual(suite.broadcaster.broadcast.called, false); // initialization only + suite.assert.strictEqual(suite.broadcaster.updateStatus.calledOnce, true); + }); + + it('detects and processes new blocks', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1); + suite.poller.lastPolledBlock = 0; + await suite.poller.poll(); + suite.assert.strictEqual(suite.broadcaster.broadcast.calledOnce, true); + let event = suite.broadcaster.broadcast.firstCall.args[2]; + suite.assert.strictEqual(event.type, 'block'); + suite.assert.strictEqual(event.block_index, 1); + suite.assert.strictEqual(event.chain, 'bitcoin'); + suite.assert.strictEqual(event.network, 'mainnet'); + suite.assert.ok(event.ledger_hash); + suite.assert.ok(event.actions_hash); + suite.assert.ok(event.contract_hash); + suite.assert.ok(event.data); + }); + }); +}); diff --git a/test/integration/server_polling.test/02_poll_continuation.test.js b/test/integration/server_polling.test/02_poll_continuation.test.js new file mode 100644 index 00000000..e31569ac --- /dev/null +++ b/test/integration/server_polling.test/02_poll_continuation.test.js @@ -0,0 +1,60 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers continued polling outcomes. One part of server_polling.test.js. +const suite = require('./helpers/server_polling_suite'); + +describe('Integration: ServerPoller', function() { + suite.installHooks(); + describe('poll', function() { + it('processes multiple sequential blocks', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 5); + suite.poller.lastPolledBlock = 0; + await suite.poller.poll(); + suite.assert.strictEqual(suite.broadcaster.broadcast.callCount, 5); + suite.assert.strictEqual(suite.poller.lastPolledBlock, 5); + + // Verify block order + for (let i = 0; i < 5; i++) { + let event = suite.broadcaster.broadcast.getCall(i).args[2]; + suite.assert.strictEqual(event.block_index, i + 1); + } + }); + + it('records each block in transparency log', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 3); + suite.poller.lastPolledBlock = 0; + await suite.poller.poll(); + suite.assert.strictEqual(suite.transparencyLog.recordBlock.callCount, 3); + }); + + it('detects reorg when block count decreases', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 10); + suite.poller.lastPolledBlock = 10; + + // Simulate reorg at source + await suite.fixtures.deleteBlocksFrom(suite.sourceDb, 8); + await suite.poller.poll(); + suite.assert.strictEqual(suite.broadcaster.broadcast.calledOnce, true); + let event = suite.broadcaster.broadcast.firstCall.args[2]; + suite.assert.strictEqual(event.type, 'reorg'); + suite.assert.strictEqual(event.block_index, 8); // currentBlock(7) + 1 + suite.assert.strictEqual(suite.poller.lastPolledBlock, 7); + }); + + it('does nothing when no new blocks', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 5); + suite.poller.lastPolledBlock = 5; + await suite.poller.poll(); + suite.assert.strictEqual(suite.broadcaster.broadcast.called, false); + suite.assert.strictEqual(suite.transparencyLog.recordBlock.called, false); + }); + }); +}); diff --git a/test/integration/server_polling.test/03_payload_rows.test.js b/test/integration/server_polling.test/03_payload_rows.test.js new file mode 100644 index 00000000..77373ca9 --- /dev/null +++ b/test/integration/server_polling.test/03_payload_rows.test.js @@ -0,0 +1,51 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers primary payload rows. One part of server_polling.test.js. +const suite = require('./helpers/server_polling_suite'); + +describe('Integration: ServerPoller', function() { + suite.installHooks(); + describe('buildBlockPayload', function() { + it('builds payload with correct structure from real DB', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1); + let payload = await suite.poller.buildBlockPayload(1); + suite.assert.strictEqual(payload.type, 'block'); + suite.assert.strictEqual(payload.chain, 'bitcoin'); + suite.assert.strictEqual(payload.network, 'mainnet'); + suite.assert.strictEqual(payload.block_index, 1); + suite.assert.ok(payload.block_time > 0); + suite.assert.ok(payload.ledger_hash); + suite.assert.ok(payload.data); + }); + + it('includes block-scoped table rows', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1); + let payload = await suite.poller.buildBlockPayload(1); + suite.assert.ok(payload.data.blocks); + suite.assert.strictEqual(payload.data.blocks.length, 1); + }); + + it('includes transactions', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1); + let payload = await suite.poller.buildBlockPayload(1); + suite.assert.ok(payload.data.transactions); + suite.assert.strictEqual(payload.data.transactions.length, 1); + }); + + it('includes action-scoped rows (credits)', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1); + let payload = await suite.poller.buildBlockPayload(1); + suite.assert.ok(payload.data.credits); + suite.assert.strictEqual(payload.data.credits.length, 1); + suite.assert.strictEqual(payload.data.credits[0].amount, '1000'); + }); + }); +}); diff --git a/test/integration/server_polling.test/04_payload_indexes.test.js b/test/integration/server_polling.test/04_payload_indexes.test.js new file mode 100644 index 00000000..3c6419fc --- /dev/null +++ b/test/integration/server_polling.test/04_payload_indexes.test.js @@ -0,0 +1,36 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers indexed payload rows and misses. One part of server_polling.test.js. +const suite = require('./helpers/server_polling_suite'); + +describe('Integration: ServerPoller', function() { + suite.installHooks(); + describe('buildBlockPayload', function() { + it('includes index_transactions referenced by block', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1); + let payload = await suite.poller.buildBlockPayload(1); + suite.assert.ok(payload.data.index_transactions); + suite.assert.ok(payload.data.index_transactions.length >= 3); // ledger, actions, contract hashes + }); + + it('includes index_addresses referenced by transactions', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1); + let payload = await suite.poller.buildBlockPayload(1); + suite.assert.ok(payload.data.index_addresses); + suite.assert.ok(payload.data.index_addresses.length >= 1); + }); + + it('returns null for non-existent block', async function() { + let payload = await suite.poller.buildBlockPayload(999); + suite.assert.strictEqual(payload, null); + }); + }); +}); diff --git a/test/integration/server_polling.test/05_action_probe_payloads.test.js b/test/integration/server_polling.test/05_action_probe_payloads.test.js new file mode 100644 index 00000000..23fb4de3 --- /dev/null +++ b/test/integration/server_polling.test/05_action_probe_payloads.test.js @@ -0,0 +1,42 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers probe payload parity. One part of server_polling.test.js. +const suite = require('./helpers/server_polling_suite'); + +describe('Integration: ServerPoller', function() { + suite.installHooks(); + + // The payload build asks getNonEmptyActionScopedTables once and fetches only + // the tables that answer, rather than one getActionScopedRows per registry + // table (86 today, growing with every replicated table added). payload.data + // feeds a consensus hash followers recompute, so the only acceptable evidence is + // byte-identity against a REAL database, not a mock: these run the same block + // through both paths on the same rows and compare the serialized payloads. + describe('buildBlockPayload action-scoped probe', function() { + it('emits a byte-identical payload on a block that has rows', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1); + let probed = await suite.poller.buildBlockPayload(1); + let unprobed = await suite.buildUnprobed(1); + suite.assert.ok(probed.data.credits && probed.data.credits.length === 1, + 'the block must carry action-scoped rows, or this proves nothing'); + suite.assert.strictEqual(JSON.stringify(probed), JSON.stringify(unprobed)); + }); + + it('emits a byte-identical payload on a block with no action-scoped rows', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 2); + await suite.sourceDb.doQuery("DELETE FROM credits"); + let probed = await suite.poller.buildBlockPayload(2); + let unprobed = await suite.buildUnprobed(2); + suite.assert.ok(!probed.data.credits, 'no action-scoped rows in this block'); + suite.assert.strictEqual(JSON.stringify(probed), JSON.stringify(unprobed)); + }); + }); +}); diff --git a/test/integration/server_polling.test/06_action_probe_efficiency.test.js b/test/integration/server_polling.test/06_action_probe_efficiency.test.js new file mode 100644 index 00000000..36d845f3 --- /dev/null +++ b/test/integration/server_polling.test/06_action_probe_efficiency.test.js @@ -0,0 +1,54 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers probe efficiency and empty tables. One part of server_polling.test.js. +const suite = require('./helpers/server_polling_suite'); + +describe('Integration: ServerPoller', function() { + suite.installHooks(); + describe('buildBlockPayload action-scoped probe', function() { + it('cuts the per-table round-trips to the tables that actually have rows', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1); + const spy = suite.sinon.spy(suite.sourceDb, 'getActionScopedRows'); + await suite.poller.buildBlockPayload(1); + const probedFetches = spy.getCalls().map(c => c.args[0]); + spy.resetHistory(); + await suite.buildUnprobed(1); + const unprobedFetches = spy.getCalls().map(c => c.args[0]); + suite.assert.ok(unprobedFetches.length > 40, + 'the unprobed path really does walk the whole registry (' + unprobedFetches.length + ')'); + suite.assert.ok(probedFetches.length < unprobedFetches.length, + 'the probe must remove round-trips (' + probedFetches.length + ' vs ' + unprobedFetches.length + ')'); + // Every table still fetched is one the unprobed path also fetched with rows. + for (const table of probedFetches) + suite.assert.ok(unprobedFetches.includes(table), table + ' fetched by the probed path only'); + }); + + it('agrees with the real fetch on which tables are empty', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1); + const candidates = suite.poller.actionScopedTables + .filter(t => t !== 'actions' && t !== 'contract_emissions'); + const nonEmpty = await suite.sourceDb.getNonEmptyActionScopedTables(candidates, 1); + + // The safety property, checked table by table against the real database: + // probe membership must equal "getActionScopedRows returns rows". + for (const table of candidates) { + let rows = []; + try { + rows = await suite.sourceDb.getActionScopedRows(table, 1); + } catch (e) { + continue; // table absent from this schema; the probe skips it too + } + suite.assert.strictEqual(nonEmpty.has(table), rows.length > 0, + 'probe disagrees with the real fetch on ' + table); + } + }); + }); +}); diff --git a/test/integration/server_polling.test/07_update_status.test.js b/test/integration/server_polling.test/07_update_status.test.js new file mode 100644 index 00000000..3fdb08a6 --- /dev/null +++ b/test/integration/server_polling.test/07_update_status.test.js @@ -0,0 +1,30 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers broadcaster status updates. One part of server_polling.test.js. +const suite = require('./helpers/server_polling_suite'); + +describe('Integration: ServerPoller', function() { + suite.installHooks(); + describe('updateStatus', function() { + it('updates broadcaster status with real block data', async function() { + await suite.fixtures.seedBlocks(suite.sourceDb, 1, 5); + suite.poller.lastPolledBlock = 5; + await suite.poller.updateStatus(); + suite.assert.strictEqual(suite.broadcaster.updateStatus.calledOnce, true); + let args = suite.broadcaster.updateStatus.firstCall.args; + suite.assert.strictEqual(args[0], 'bitcoin'); + suite.assert.strictEqual(args[1], 'mainnet'); + suite.assert.strictEqual(args[2].block_height, 5); + suite.assert.ok(args[2].ledger_hash); + suite.assert.ok(args[2].block_time > 0); + }); + }); +}); diff --git a/test/integration/server_polling.test/helpers/server_polling_suite.js b/test/integration/server_polling.test/helpers/server_polling_suite.js new file mode 100644 index 00000000..1ac40d8a --- /dev/null +++ b/test/integration/server_polling.test/helpers/server_polling_suite.js @@ -0,0 +1,77 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Provides the polling fixtures and hooks. One part of server_polling.test.js. +const assert = require('assert'); +const sinon = require('sinon'); +const setup = require('../../helpers/setup'); +const testDb = require('../../helpers/testDb'); +const fixtures = require('../../helpers/fixtures'); +const ServerPoller = require('../../../../src/server/poller'); + +let sourceDb, poller, broadcaster, transparencyLog, config; + +function installHooks() { + before(async function() { + await setup.globalSetup(); + }); + after(async function() { + await setup.globalTeardown(); + }); + beforeEach(async function() { + sourceDb = setup.getSourceDb(); + await testDb.truncateAll(sourceDb); + broadcaster = { + broadcast: sinon.stub(), + updateStatus: sinon.stub(), + getSubscriberCount: sinon.stub().returns(0) + }; + transparencyLog = { + recordBlock: sinon.stub().resolves(), + pruneFrom: sinon.stub().resolves() + }; + config = { BLOCK_POLL_INTERVAL: 100 }; + poller = new ServerPoller('bitcoin', 'mainnet', sourceDb, broadcaster, transparencyLog, config, testDb.util); + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + }); + afterEach(function() { + sinon.restore(); + }); +} + +// Same build with the probe removed from the db object, which is the pre-fix +// query-every-table path verbatim. +// getNonEmptyActionScopedTables lives on TestDatabase's PROTOTYPE, so it is +// shadowed with an own `undefined` rather than deleted: a delete removes nothing +// and the comparison would silently be probe-against-probe, which passes while +// proving nothing. +async function buildUnprobed(blockIndex) { + sourceDb.getNonEmptyActionScopedTables = undefined; + try { + assert.strictEqual(typeof sourceDb.getNonEmptyActionScopedTables, 'undefined'); + return await poller.buildBlockPayload(blockIndex); + } finally { + delete sourceDb.getNonEmptyActionScopedTables; + } +} + +module.exports = { + assert, + sinon, + testDb, + fixtures, + get sourceDb() { return sourceDb; }, + get poller() { return poller; }, + get broadcaster() { return broadcaster; }, + get transparencyLog() { return transparencyLog; }, + installHooks, + buildUnprobed +}; From 1956ae9d0bd184e46097155043d0183ab0d3ab24 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:22:53 -0700 Subject: [PATCH 21/62] refactor(sync): extract armed source handshake fixtures --- .../armed_source_handshake.test.js | 67 +--------------- .../helpers/armed_source_handshake_suite.js | 80 +++++++++++++++++++ 2 files changed, 84 insertions(+), 63 deletions(-) create mode 100644 test/integration/armed_source_handshake.test/helpers/armed_source_handshake_suite.js diff --git a/test/integration/armed_source_handshake.test.js b/test/integration/armed_source_handshake.test.js index 40ff5f1e..eff102b7 100644 --- a/test/integration/armed_source_handshake.test.js +++ b/test/integration/armed_source_handshake.test.js @@ -24,75 +24,16 @@ // anywhere, unlike the rest of test/integration. const assert = require('assert'); -const http = require('http'); -const express = require('express'); const axios = require('axios'); const WebSocket = require('ws'); -const { createApiKeyMiddleware, safeEqual } = require('../../src/http/middleware'); -const ClientSync = require('../../src/client/sync'); -const HashVerifier = require('../../src/client/hash_verifier'); -const Utility = require('../../src/util'); - -const PORT = 19477; -const SERVER_KEY = 'armed-source-key'; - -// Enough of a ClientSync to exercise the outbound credential path. The replication -// collaborators are never reached: every assertion here is decided by the source's -// guard, at or before the response. -function makeClient(configOverrides){ - const noop = () => {}; - const db = { query: async () => [], getConnection: async () => ({ release: noop }) }; - const stub = { apply: noop, rollback: noop }; - const config = Object.assign({ - SYNC_SOURCES: 'http://127.0.0.1:' + PORT, - VERIFY_HASHES: false, - CLIENT_RECONNECT_DELAY: 5000, - HASH_CONFIRM_TIMEOUT: 5000, - SNAPSHOT_MAX_CONTENT: 16 * 1024 * 1024, - WS_MAX_PAYLOAD: 16 * 1024 * 1024, - MAX_ROLLBACK_DEPTH: 10, - GAP_LOG_INTERVAL_MS: 30000 - }, configOverrides || {}); - return new ClientSync('bitcoin', 'mainnet', db, stub, stub, new HashVerifier(), config, new Utility()); -} +const { + PORT, SERVER_KEY, makeClient, registerArmedSourceHooks +} = require('./armed_source_handshake.test/helpers/armed_source_handshake_suite'); describe('Integration: armed source handshake (server-tier rollout rehearsal)', function(){ - let server, wss; - - // An ARMED source. The REST side mounts the SAME createApiKeyMiddleware api.js - // mounts app-wide. The WS side is a transcription of api.js's upgrade guard, - // because that handler is inline in startApi and not exported: it shares the - // real safeEqual, but a change to the guard's SHAPE would not fail here. What - // these cases are authoritative about is the CLIENT half, which is what the - // credential split changed. - before(function(done){ - const app = express(); - app.use(createApiKeyMiddleware(SERVER_KEY)); - app.get('/snapshot/:dbType/:chain/:network', (req, res) => res.json({ ok: true, rows: [] })); - app.get('/health', (req, res) => res.json({ status: 'healthy' })); - - server = http.createServer(app); - wss = new WebSocket.Server({ noServer: true }); - - server.on('upgrade', (request, socket, head) => { - const authHeader = request.headers['authorization']; - if(!authHeader || !safeEqual(authHeader, 'Bearer ' + SERVER_KEY)){ - socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); - socket.destroy(); - return; - } - wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request)); - }); - - server.listen(PORT, '127.0.0.1', done); - }); - - after(function(done){ - if(wss) wss.close(); - server.close(done); - }); + registerArmedSourceHooks(); it('REST: a client carrying the upstream key reads a snapshot from an armed source', async function(){ const sync = makeClient({ SYNC_UPSTREAM_KEY: SERVER_KEY }); diff --git a/test/integration/armed_source_handshake.test/helpers/armed_source_handshake_suite.js b/test/integration/armed_source_handshake.test/helpers/armed_source_handshake_suite.js new file mode 100644 index 00000000..eddcb003 --- /dev/null +++ b/test/integration/armed_source_handshake.test/helpers/armed_source_handshake_suite.js @@ -0,0 +1,80 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers shared armed source setup. One part of armed_source_handshake.test.js. +const http = require('http'); +const express = require('express'); +const WebSocket = require('ws'); +const { createApiKeyMiddleware, safeEqual } = require('../../../../src/http/middleware'); +const ClientSync = require('../../../../src/client/sync'); +const HashVerifier = require('../../../../src/client/hash_verifier'); +const Utility = require('../../../../src/util'); + +const PORT = 19477; +const SERVER_KEY = 'armed-source-key'; + +// Enough of a ClientSync to exercise the outbound credential path. The replication +// collaborators are never reached: every assertion here is decided by the source's +// guard, at or before the response. +function makeClient(configOverrides){ + const noop = () => {}; + const db = { query: async () => [], getConnection: async () => ({ release: noop }) }; + const stub = { apply: noop, rollback: noop }; + const config = Object.assign({ + SYNC_SOURCES: 'http://127.0.0.1:' + PORT, + VERIFY_HASHES: false, + CLIENT_RECONNECT_DELAY: 5000, + HASH_CONFIRM_TIMEOUT: 5000, + SNAPSHOT_MAX_CONTENT: 16 * 1024 * 1024, + WS_MAX_PAYLOAD: 16 * 1024 * 1024, + MAX_ROLLBACK_DEPTH: 10, + GAP_LOG_INTERVAL_MS: 30000 + }, configOverrides || {}); + return new ClientSync('bitcoin', 'mainnet', db, stub, stub, new HashVerifier(), config, new Utility()); +} + +function registerArmedSourceHooks(){ + let server, wss; + + // An ARMED source. The REST side mounts the SAME createApiKeyMiddleware api.js + // mounts app-wide. The WS side is a transcription of api.js's upgrade guard, + // because that handler is inline in startApi and not exported: it shares the + // real safeEqual, but a change to the guard's SHAPE would not fail here. What + // these cases are authoritative about is the CLIENT half, which is what the + // credential split changed. + before(function(done){ + const app = express(); + app.use(createApiKeyMiddleware(SERVER_KEY)); + app.get('/snapshot/:dbType/:chain/:network', (req, res) => res.json({ ok: true, rows: [] })); + app.get('/health', (req, res) => res.json({ status: 'healthy' })); + + server = http.createServer(app); + wss = new WebSocket.Server({ noServer: true }); + + server.on('upgrade', (request, socket, head) => { + const authHeader = request.headers['authorization']; + if(!authHeader || !safeEqual(authHeader, 'Bearer ' + SERVER_KEY)){ + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request)); + }); + + server.listen(PORT, '127.0.0.1', done); + }); + + after(function(done){ + if(wss) wss.close(); + server.close(done); + }); +} + +module.exports = { PORT, SERVER_KEY, makeClient, registerArmedSourceHooks }; From e2c9da98cc36c041aa50d486f69c64471a328239 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:22:53 -0700 Subject: [PATCH 22/62] refactor(sync): extract binary replication fixtures --- test/integration/binary_replication.test.js | 90 ++++--------------- .../helpers/binary_replication_suite.js | 84 +++++++++++++++++ 2 files changed, 100 insertions(+), 74 deletions(-) create mode 100644 test/integration/binary_replication.test/helpers/binary_replication_suite.js diff --git a/test/integration/binary_replication.test.js b/test/integration/binary_replication.test.js index b05d8ec7..62f67c9b 100644 --- a/test/integration/binary_replication.test.js +++ b/test/integration/binary_replication.test.js @@ -9,11 +9,8 @@ // contact legal@dankest.llc. const assert = require('assert'); -const zlib = require('zlib'); -const { PassThrough } = require('stream'); const WebSocket = require('ws'); -const setup = require('./helpers/setup'); const testDb = require('./helpers/testDb'); const fixtures = require('./helpers/fixtures'); @@ -21,84 +18,38 @@ const SnapshotBuilder = require('../../src/server/snapshot_builder'); const ClientApplier = require('../../src/client/applier'); const BlockBroadcaster = require('../../src/server/block_broadcaster'); const { BINARY_TAG } = require('../../src/util/wire_codec'); - -// Binary payload chosen to break a naive toString() round-trip: contains a NUL, -// 0xFF, and bytes that are not valid UTF-8. -const RAW_HEX = '00ff8042deadbeef13fe7f'; -const RAW_BUF = Buffer.from(RAW_HEX, 'hex'); - -// gated_files (indexer, MEDIUMBLOB raw_data) is the live-observed corruption -// site. Its raw_data exercises the exact same encode/decode path as the -// decoder's transactions.raw_data, so covering it here covers both columns. -async function seedGatedFile(db, actionIndex){ - await db.doQuery( - "INSERT INTO gated_files (action_index, gate_ticker, encryption_method, key_hash, status_id, raw_data) " + - "VALUES (?, ?, ?, ?, ?, UNHEX(?))", - [actionIndex, 'GATETOK', 1, 'ab'.repeat(32), null, RAW_HEX] - ); -} - -async function readRawData(db, actionIndex){ - let rows = await db.doQuery("SELECT raw_data FROM gated_files WHERE action_index = ?", [actionIndex]); - return rows.length > 0 ? rows[0].raw_data : null; -} - -// Capture a full snapshot stream into a parsed JSON object (gunzip the gzip body -// SnapshotBuilder pipes to its res). -async function captureFullSnapshot(builder, db){ - let chunks = []; - let res = new PassThrough(); - res.setHeader = () => {}; - res.status = () => ({ json: () => {} }); - res.on('data', c => chunks.push(c)); - let ended = new Promise(resolve => res.on('end', resolve)); - await builder.streamFullSnapshot(db, res); - await ended; - let body = zlib.gunzipSync(Buffer.concat(chunks)).toString('utf8'); - return { json: JSON.parse(body), body }; -} +const { + RAW_HEX, RAW_BUF, seedGatedFile, readRawData, captureFullSnapshot, + makeBroadcastEvent, registerBinaryReplicationHooks +} = require('./binary_replication.test/helpers/binary_replication_suite'); describe('Integration: binary column replication (F2)', function(){ - let sourceDb, replicaDb; - - before(async function(){ - await setup.globalSetup(); - }); - - after(async function(){ - await setup.globalTeardown(); - }); - - beforeEach(async function(){ - await setup.resetDatabases(); - sourceDb = setup.getSourceDb(); - replicaDb = setup.getReplicaDb(); - }); + const suite = registerBinaryReplicationHooks(); it('source stores true binary (sanity check: seed not corrupted by the helper)', async function(){ - await fixtures.seedBlocks(sourceDb, 1, 1); - await seedGatedFile(sourceDb, 100); - let src = await readRawData(sourceDb, 100); + await fixtures.seedBlocks(suite.sourceDb, 1, 1); + await seedGatedFile(suite.sourceDb, 100); + let src = await readRawData(suite.sourceDb, 100); assert.ok(Buffer.isBuffer(src), 'source raw_data should be a Buffer'); assert.ok(src.equals(RAW_BUF), 'source bytes must match the seed'); }); it('full snapshot round-trips a blob byte-for-byte', async function(){ - await fixtures.seedBlocks(sourceDb, 1, 1); - await seedGatedFile(sourceDb, 100); + await fixtures.seedBlocks(suite.sourceDb, 1, 1); + await seedGatedFile(suite.sourceDb, 100); let builder = new SnapshotBuilder(testDb.util); - let { json, body } = await captureFullSnapshot(builder, sourceDb); + let { json, body } = await captureFullSnapshot(builder, suite.sourceDb); // Wire format: base64 sentinel, NOT the mangled {"type":"Buffer"} shape. assert.ok(body.includes(BINARY_TAG), 'wire must carry the binary sentinel'); assert.ok(!body.includes('"type":"Buffer"'), 'wire must not contain a mangled Buffer object'); - let applier = new ClientApplier(replicaDb, testDb.util); + let applier = new ClientApplier(suite.replicaDb, testDb.util); await applier.applyFullSnapshot(json); - let dst = await readRawData(replicaDb, 100); + let dst = await readRawData(suite.replicaDb, 100); assert.ok(Buffer.isBuffer(dst), 'replica raw_data should be a Buffer'); assert.ok(dst.equals(RAW_BUF), 'replica bytes must equal source bytes'); }); @@ -106,16 +57,7 @@ describe('Integration: binary column replication (F2)', function(){ it('live block broadcast round-trips a blob byte-for-byte', async function(){ // Build the live block payload exactly as ServerPoller would, then push it // through the real BlockBroadcaster to capture the serialized wire message. - let event = { - type: 'block', chain: 'bitcoin', network: 'mainnet', dbType: 'indexer', - block_index: 1, block_time: 100, - data: { - gated_files: [ - { action_index: 200, gate_ticker: 'GATETOK', encryption_method: 1, - key_hash: 'cd'.repeat(32), status_id: null, raw_data: Buffer.from(RAW_HEX, 'hex') } - ] - } - }; + let event = makeBroadcastEvent(); let captured = []; let broadcaster = new BlockBroadcaster({ WS_MAX_PER_IP: 100, WS_BACKPRESSURE_LIMIT: 1000, TRUST_PROXY: false }); @@ -129,10 +71,10 @@ describe('Integration: binary column replication (F2)', function(){ assert.ok(!captured[0].includes('"type":"Buffer"'), 'wire must not contain a mangled Buffer object'); let payload = JSON.parse(captured[0]); - let applier = new ClientApplier(replicaDb, testDb.util); + let applier = new ClientApplier(suite.replicaDb, testDb.util); await applier.applyBlock(payload); - let dst = await readRawData(replicaDb, 200); + let dst = await readRawData(suite.replicaDb, 200); assert.ok(Buffer.isBuffer(dst), 'replica raw_data should be a Buffer'); assert.ok(dst.equals(RAW_BUF), 'replica bytes must equal source bytes'); }); diff --git a/test/integration/binary_replication.test/helpers/binary_replication_suite.js b/test/integration/binary_replication.test/helpers/binary_replication_suite.js new file mode 100644 index 00000000..00c1c365 --- /dev/null +++ b/test/integration/binary_replication.test/helpers/binary_replication_suite.js @@ -0,0 +1,84 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers shared binary replication fixtures. One part of binary_replication.test.js. +const zlib = require('zlib'); +const { PassThrough } = require('stream'); +const setup = require('../../helpers/setup'); + +// Binary payload chosen to break a naive toString() round-trip: contains a NUL, +// 0xFF, and bytes that are not valid UTF-8. +const RAW_HEX = '00ff8042deadbeef13fe7f'; +const RAW_BUF = Buffer.from(RAW_HEX, 'hex'); + +// gated_files (indexer, MEDIUMBLOB raw_data) is the live-observed corruption +// site. Its raw_data exercises the exact same encode/decode path as the +// decoder's transactions.raw_data, so covering it here covers both columns. +async function seedGatedFile(db, actionIndex){ + await db.doQuery( + "INSERT INTO gated_files (action_index, gate_ticker, encryption_method, key_hash, status_id, raw_data) " + + "VALUES (?, ?, ?, ?, ?, UNHEX(?))", + [actionIndex, 'GATETOK', 1, 'ab'.repeat(32), null, RAW_HEX] + ); +} + +async function readRawData(db, actionIndex){ + let rows = await db.doQuery("SELECT raw_data FROM gated_files WHERE action_index = ?", [actionIndex]); + return rows.length > 0 ? rows[0].raw_data : null; +} + +// Capture a full snapshot stream into a parsed JSON object (gunzip the gzip body +// SnapshotBuilder pipes to its res). +async function captureFullSnapshot(builder, db){ + let chunks = []; + let res = new PassThrough(); + res.setHeader = () => {}; + res.status = () => ({ json: () => {} }); + res.on('data', c => chunks.push(c)); + let ended = new Promise(resolve => res.on('end', resolve)); + await builder.streamFullSnapshot(db, res); + await ended; + let body = zlib.gunzipSync(Buffer.concat(chunks)).toString('utf8'); + return { json: JSON.parse(body), body }; +} + +function makeBroadcastEvent(){ + return { + type: 'block', chain: 'bitcoin', network: 'mainnet', dbType: 'indexer', + block_index: 1, block_time: 100, + data: { + gated_files: [ + { action_index: 200, gate_ticker: 'GATETOK', encryption_method: 1, + key_hash: 'cd'.repeat(32), status_id: null, raw_data: Buffer.from(RAW_HEX, 'hex') } + ] + } + }; +} + +function registerBinaryReplicationHooks(){ + const suite = {}; + before(async function(){ + await setup.globalSetup(); + }); + after(async function(){ + await setup.globalTeardown(); + }); + beforeEach(async function(){ + await setup.resetDatabases(); + suite.sourceDb = setup.getSourceDb(); + suite.replicaDb = setup.getReplicaDb(); + }); + return suite; +} + +module.exports = { + RAW_HEX, RAW_BUF, seedGatedFile, readRawData, captureFullSnapshot, + makeBroadcastEvent, registerBinaryReplicationHooks +}; From cc289e500c986c58c861f2b6802ed1cfa6373948 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:22:53 -0700 Subject: [PATCH 23/62] refactor(sync): extract emissions parity assertions --- test/integration/emissions_parity.test.js | 10 ++----- .../helpers/emissions_parity_suite.js | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 test/integration/emissions_parity.test/helpers/emissions_parity_suite.js diff --git a/test/integration/emissions_parity.test.js b/test/integration/emissions_parity.test.js index c948ef97..91ed22a1 100644 --- a/test/integration/emissions_parity.test.js +++ b/test/integration/emissions_parity.test.js @@ -34,6 +34,7 @@ const assert = require('assert'); const setup = require('./helpers/setup'); const testDb = require('./helpers/testDb'); const fixtures = require('./helpers/fixtures'); +const { assertStreamedEmissions } = require('./emissions_parity.test/helpers/emissions_parity_suite'); describe('Integration: contract_emissions reorg-safe streaming (emissions fix) @regression', function () { this.timeout(30000); @@ -83,15 +84,8 @@ describe('Integration: contract_emissions reorg-safe streaming (emissions fix) @ "INSERT INTO contract_emissions (execution_index, emitted_action, action_index, position) VALUES (?, ?, ?, ?)", [execAction, 'SLASH', null, 1]); - // The fix: execution_index-scoped stream returns BOTH, ORDER BY execution_index, position. const streamed = await sourceDb.getEmissionRowsForBlock(B); - assert.strictEqual(streamed.length, 2, - 'getEmissionRowsForBlock must include the NULL-action_index SLASH row the hash counts'); - assert.deepStrictEqual(streamed.map(r => r.emitted_action), ['ORDER', 'SLASH'], - 'rows ordered by execution_index then position'); - const slash = streamed.find(r => r.emitted_action === 'SLASH'); - assert.ok(slash, 'SLASH emission present in the streamed set'); - assert.strictEqual(slash.action_index, null, 'SLASH emission carries NULL action_index'); + assertStreamedEmissions(streamed); // The bug it fixes: the generic action-scoped INNER JOIN silently drops the NULL row, // so a follower fed by this path would recompute a divergent contract_hash and halt. diff --git a/test/integration/emissions_parity.test/helpers/emissions_parity_suite.js b/test/integration/emissions_parity.test/helpers/emissions_parity_suite.js new file mode 100644 index 00000000..469d7953 --- /dev/null +++ b/test/integration/emissions_parity.test/helpers/emissions_parity_suite.js @@ -0,0 +1,27 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. + * + ********************************************************************/ + +// Covers streamed emission assertions. One part of emissions_parity.test.js. +const assert = require('assert'); + +function assertStreamedEmissions(streamed){ + // The fix: execution_index-scoped stream returns BOTH, ORDER BY execution_index, position. + assert.strictEqual(streamed.length, 2, + 'getEmissionRowsForBlock must include the NULL-action_index SLASH row the hash counts'); + assert.deepStrictEqual(streamed.map(r => r.emitted_action), ['ORDER', 'SLASH'], + 'rows ordered by execution_index then position'); + const slash = streamed.find(r => r.emitted_action === 'SLASH'); + assert.ok(slash, 'SLASH emission present in the streamed set'); + assert.strictEqual(slash.action_index, null, 'SLASH emission carries NULL action_index'); +} + +module.exports = { assertStreamedEmissions }; From bbe87e75df7aad30b0e520f74a86bbb68973a81b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:22:53 -0700 Subject: [PATCH 24/62] refactor(sync): extract HTTP parity integration setup --- .../integration/index_map_parity_http.test.js | 147 +++--------------- .../helpers/index_map_parity_http_suite.js | 131 ++++++++++++++++ 2 files changed, 150 insertions(+), 128 deletions(-) create mode 100644 test/integration/index_map_parity_http.test/helpers/index_map_parity_http_suite.js diff --git a/test/integration/index_map_parity_http.test.js b/test/integration/index_map_parity_http.test.js index ad715a7d..0653fcda 100644 --- a/test/integration/index_map_parity_http.test.js +++ b/test/integration/index_map_parity_http.test.js @@ -22,160 +22,51 @@ // 100% shipped code. const assert = require('assert'); -const sinon = require('sinon'); -const http = require('http'); -const express = require('express'); -const cors = require('cors'); -const { parseCorsOrigin } = require('../../src/http/cors_origin'); -const setup = require('./helpers/setup'); -const testDb = require('./helpers/testDb'); -const fixtures = require('./helpers/fixtures'); -const Database = require('../../src/db'); -const BlockHasher = require('../../src/client/block_hasher'); -const HashVerifier = require('../../src/client/hash_verifier'); -const ClientApplier= require('../../src/client/applier'); -const ClientRollback = require('../../src/client/rollback'); -const ClientSync = require('../../src/client/sync'); -const { getReplicatedTables } = require('../../src/schema/replicated_tables'); - -const PORT = 19733; -const H = 3; // tip block both sides are seeded to -const CHAIN = 'bitcoin', NETWORK = 'mainnet'; - -// Deterministic in-block id->address subset (block_index set) shared by both DBs. -// High explicit ids avoid colliding with the AUTO_INCREMENT ids seedBlocks uses. -const STAMPED = [ - { id: 1001, address: 'bc1qstamped0alice000000000000000000000aaa', block_index: 1 }, - { id: 1002, address: 'bc1qstamped0bob00000000000000000000000bbb', block_index: 2 }, - { id: 1003, address: 'bc1qstamped0carol000000000000000000000ccc', block_index: 3 }, -]; - -async function stamp(db, rows){ - await db.doQuery("DELETE FROM index_addresses WHERE block_index IS NOT NULL"); - for(let r of rows) - await db.doQuery("INSERT INTO index_addresses (`id`,`address`,`block_index`) VALUES (?,?,?)", - [r.id, r.address, r.block_index]); -} - -// Faithful mirror of api.js buildStatusRow (server mode) for the fields the client -// reads. Uses the SHIPPED BlockHasher.computeIndexMapChecksum for the checksum. -function makeStatusHandler(sourceDb, util, cfg){ - return async function(req, res){ - try { - let hashRow = await sourceDb.getBlockHashRow(H); - let row = { - block_height: H, - source_height: H, - lag_blocks: 0, - ledger_hash: hashRow ? hashRow.ledger_hash : null, - actions_hash: hashRow ? hashRow.actions_hash : null, - contract_hash: hashRow ? hashRow.contract_hash : null, - }; - row.table_counts = {}; - for(let t of getReplicatedTables('indexer')){ - try { row.table_counts[t] = await sourceDb.getTableCount(t); } catch(e){} - } - row.index_map_checksum = null; - if(cfg['INDEX_MAP_PARITY_CHECK']) - row.index_map_checksum = await new BlockHasher(sourceDb, util).computeIndexMapChecksum(H); - res.json(row); - } catch(e){ res.status(500).json({ error: e.message }); } - }; -} +const { + PORT, H, STAMPED, stamp, registerIndexMapParityHttpHooks +} = require('./index_map_parity_http.test/helpers/index_map_parity_http_suite'); describe('Integration: index-map parity over HTTP (e2e)', function() { this.timeout(180000); - let sourceDb, replicaDb, realReplica, server, util, clientSync, warnSpy; - const SERVER_CFG = { INDEX_MAP_PARITY_CHECK: true }; const countKey = 'index_map_mismatch_count:indexer'; const lastKey = 'index_map_mismatch_last_block:indexer'; - - before(async function() { - await setup.globalSetup(); - sourceDb = setup.getSourceDb(); - replicaDb = setup.getReplicaDb(); - util = testDb.util; - - // Identical, deterministic block chain on both DBs so getBlockHashRow(H) - // and the three committed hashes match (no noisy cross-source error). - await fixtures.seedBlocks(sourceDb, 1, H); - await fixtures.seedBlocks(replicaDb, 1, H); - await stamp(sourceDb, STAMPED); - await stamp(replicaDb, STAMPED); - - // Real src/db.js Database for the replica: ClientSync needs getBlockHashRow, - // getTableCount, doQuery AND getSyncState/setSyncState (the TestDatabase - // wrapper lacks the sync_state methods the counter uses). - realReplica = new Database(testDb.TEST_DB_HOST, testDb.TEST_DB_PORT, - testDb.REPLICA_DB_NAME, testDb.TEST_DB_USER, testDb.TEST_DB_PASS, util, 'indexer'); - - // Real HTTP server publishing the shipped checksum on /status. - let app = express(); - app.use(cors({ origin: parseCorsOrigin(process.env.CORS_ORIGIN), methods: ['GET'] })); - app.get('/status/:dbType/:chain/:network', makeStatusHandler(sourceDb, util, SERVER_CFG)); - await new Promise(r => { server = http.createServer(app).listen(PORT, r); }); - - let applier = new ClientApplier(realReplica, util, CHAIN, NETWORK); - let rollback = new ClientRollback(realReplica, util, CHAIN, 'regtest'); - let cfg = { - SYNC_SOURCES: 'http://127.0.0.1:' + PORT, - INDEX_MAP_PARITY_CHECK: true, - VERIFY_HASHES: true, - VERIFY_RECOMPUTE: false, // skip the consensus-recompute halt path - VERIFY_STATE_HASH: false, - VERIFY_STATE_COMMITMENT: false, - SYNC_BOOTSTRAP_DEPTH: {} - }; - clientSync = new ClientSync(CHAIN, NETWORK, realReplica, applier, rollback, new HashVerifier(), cfg, util); - }); - - after(async function() { - if(server) await new Promise(r => server.close(r)); - if(realReplica && realReplica.close){ try { await realReplica.close(); } catch(e){} } - await setup.globalTeardown(); - }); - - beforeEach(function(){ warnSpy = sinon.spy(console, 'warn'); }); - afterEach(function(){ warnSpy.restore(); }); - - const sawParityWarn = () => warnSpy.getCalls().some(c => - String(c.args[0] || '').includes('INDEX_MAP_PARITY mismatch')); + const suite = registerIndexMapParityHttpHooks(); it('faithful replica: /status checksum matches, no mismatch, no counter', async function() { - await stamp(replicaDb, STAMPED); // ensure faithful - await clientSync.verifyAgainstSource('http://127.0.0.1:' + PORT, H); - assert.strictEqual(sawParityWarn(), false, 'no parity warning on a faithful replica'); - let c = await realReplica.getSyncState(countKey); + await stamp(suite.replicaDb, STAMPED); // ensure faithful + await suite.clientSync.verifyAgainstSource('http://127.0.0.1:' + PORT, H); + assert.strictEqual(suite.sawParityWarn(), false, 'no parity warning on a faithful replica'); + let c = await suite.realReplica.getSyncState(countKey); assert.strictEqual(c, null, 'counter unset when everything agrees'); }); it('divergence: equal-count swapped identity fires advisory mismatch, no halt', async function() { // Replica id=1002 locally points elsewhere; SAME row count, different content. - await realReplica.doQuery("UPDATE index_addresses SET address=? WHERE id=1002", + await suite.realReplica.doQuery("UPDATE index_addresses SET address=? WHERE id=1002", ['bc1qstamped0MALLORY00000000000000000mmmmmmm']); - await clientSync.verifyAgainstSource('http://127.0.0.1:' + PORT, H); + await suite.clientSync.verifyAgainstSource('http://127.0.0.1:' + PORT, H); - assert.strictEqual(sawParityWarn(), true, 'parity mismatch must be logged'); - assert.strictEqual(clientSync.isHalted(), false, 'advisory: client must NOT halt'); - let c = await realReplica.getSyncState(countKey); - let lb = await realReplica.getSyncState(lastKey); + assert.strictEqual(suite.sawParityWarn(), true, 'parity mismatch must be logged'); + assert.strictEqual(suite.clientSync.isHalted(), false, 'advisory: client must NOT halt'); + let c = await suite.realReplica.getSyncState(countKey); + let lb = await suite.realReplica.getSyncState(lastKey); assert.strictEqual(c, '1', 'mismatch counter incremented'); assert.strictEqual(lb, String(H), 'last divergent block recorded'); }); it('recovery: faithful again raises no new mismatch (counter steady)', async function() { - await realReplica.doQuery("UPDATE index_addresses SET address=? WHERE id=1002", + await suite.realReplica.doQuery("UPDATE index_addresses SET address=? WHERE id=1002", [STAMPED[1].address]); // restore // Add a benign NULL-block row the source lacks (API read-path seed): excluded. - await realReplica.doQuery("INSERT INTO index_addresses (`id`,`address`,`block_index`) VALUES (?,?,NULL)", + await suite.realReplica.doQuery("INSERT INTO index_addresses (`id`,`address`,`block_index`) VALUES (?,?,NULL)", [2001, 'bc1qapiseed00000000000000000000000000seed1']); - await clientSync.verifyAgainstSource('http://127.0.0.1:' + PORT, H); + await suite.clientSync.verifyAgainstSource('http://127.0.0.1:' + PORT, H); - assert.strictEqual(sawParityWarn(), false, 'no false alarm once faithful (NULL-block excluded)'); - let c = await realReplica.getSyncState(countKey); + assert.strictEqual(suite.sawParityWarn(), false, 'no false alarm once faithful (NULL-block excluded)'); + let c = await suite.realReplica.getSyncState(countKey); assert.strictEqual(c, '1', 'counter unchanged from the single real divergence'); }); }); diff --git a/test/integration/index_map_parity_http.test/helpers/index_map_parity_http_suite.js b/test/integration/index_map_parity_http.test/helpers/index_map_parity_http_suite.js new file mode 100644 index 00000000..737f1e55 --- /dev/null +++ b/test/integration/index_map_parity_http.test/helpers/index_map_parity_http_suite.js @@ -0,0 +1,131 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers shared HTTP parity setup. One part of index_map_parity_http.test.js. +const sinon = require('sinon'); +const http = require('http'); +const express = require('express'); +const cors = require('cors'); +const { parseCorsOrigin } = require('../../../../src/http/cors_origin'); +const setup = require('../../helpers/setup'); +const testDb = require('../../helpers/testDb'); +const fixtures = require('../../helpers/fixtures'); +const Database = require('../../../../src/db'); +const BlockHasher = require('../../../../src/client/block_hasher'); +const HashVerifier = require('../../../../src/client/hash_verifier'); +const ClientApplier= require('../../../../src/client/applier'); +const ClientRollback = require('../../../../src/client/rollback'); +const ClientSync = require('../../../../src/client/sync'); +const { getReplicatedTables } = require('../../../../src/schema/replicated_tables'); + +const PORT = 19733; +const H = 3; // tip block both sides are seeded to +const CHAIN = 'bitcoin', NETWORK = 'mainnet'; +const SERVER_CFG = { INDEX_MAP_PARITY_CHECK: true }; + +// Deterministic in-block id->address subset (block_index set) shared by both DBs. +// High explicit ids avoid colliding with the AUTO_INCREMENT ids seedBlocks uses. +const STAMPED = [ + { id: 1001, address: 'bc1qstamped0alice000000000000000000000aaa', block_index: 1 }, + { id: 1002, address: 'bc1qstamped0bob00000000000000000000000bbb', block_index: 2 }, + { id: 1003, address: 'bc1qstamped0carol000000000000000000000ccc', block_index: 3 }, +]; + +async function stamp(db, rows){ + await db.doQuery("DELETE FROM index_addresses WHERE block_index IS NOT NULL"); + for(let r of rows) + await db.doQuery("INSERT INTO index_addresses (`id`,`address`,`block_index`) VALUES (?,?,?)", + [r.id, r.address, r.block_index]); +} + +// Faithful mirror of api.js buildStatusRow (server mode) for the fields the client +// reads. Uses the SHIPPED BlockHasher.computeIndexMapChecksum for the checksum. +function makeStatusHandler(sourceDb, util, cfg){ + return async function(req, res){ + try { + let hashRow = await sourceDb.getBlockHashRow(H); + let row = { + block_height: H, + source_height: H, + lag_blocks: 0, + ledger_hash: hashRow ? hashRow.ledger_hash : null, + actions_hash: hashRow ? hashRow.actions_hash : null, + contract_hash: hashRow ? hashRow.contract_hash : null, + }; + row.table_counts = {}; + for(let t of getReplicatedTables('indexer')){ + try { row.table_counts[t] = await sourceDb.getTableCount(t); } catch(e){} + } + row.index_map_checksum = null; + if(cfg['INDEX_MAP_PARITY_CHECK']) + row.index_map_checksum = await new BlockHasher(sourceDb, util).computeIndexMapChecksum(H); + res.json(row); + } catch(e){ res.status(500).json({ error: e.message }); } + }; +} + +function createClientSync(realReplica, util){ + let applier = new ClientApplier(realReplica, util, CHAIN, NETWORK); + let rollback = new ClientRollback(realReplica, util, CHAIN, 'regtest'); + let cfg = { + SYNC_SOURCES: 'http://127.0.0.1:' + PORT, + INDEX_MAP_PARITY_CHECK: true, + VERIFY_HASHES: true, + VERIFY_RECOMPUTE: false, // skip the consensus-recompute halt path + VERIFY_STATE_HASH: false, + VERIFY_STATE_COMMITMENT: false, + SYNC_BOOTSTRAP_DEPTH: {} + }; + return new ClientSync(CHAIN, NETWORK, realReplica, applier, rollback, new HashVerifier(), cfg, util); +} + +function registerIndexMapParityHttpHooks(){ + const suite = {}; + before(async function() { + await setup.globalSetup(); + suite.sourceDb = setup.getSourceDb(); + suite.replicaDb = setup.getReplicaDb(); + const util = testDb.util; + + // Identical, deterministic block chain on both DBs so getBlockHashRow(H) + // and the three committed hashes match (no noisy cross-source error). + await fixtures.seedBlocks(suite.sourceDb, 1, H); + await fixtures.seedBlocks(suite.replicaDb, 1, H); + await stamp(suite.sourceDb, STAMPED); + await stamp(suite.replicaDb, STAMPED); + + // Real src/db.js Database for the replica: ClientSync needs getBlockHashRow, + // getTableCount, doQuery AND getSyncState/setSyncState (the TestDatabase + // wrapper lacks the sync_state methods the counter uses). + suite.realReplica = new Database(testDb.TEST_DB_HOST, testDb.TEST_DB_PORT, + testDb.REPLICA_DB_NAME, testDb.TEST_DB_USER, testDb.TEST_DB_PASS, util, 'indexer'); + + // Real HTTP server publishing the shipped checksum on /status. + let app = express(); + app.use(cors({ origin: parseCorsOrigin(process.env.CORS_ORIGIN), methods: ['GET'] })); + app.get('/status/:dbType/:chain/:network', makeStatusHandler(suite.sourceDb, util, SERVER_CFG)); + await new Promise(r => { suite.server = http.createServer(app).listen(PORT, r); }); + suite.clientSync = createClientSync(suite.realReplica, util); + }); + + after(async function() { + if(suite.server) await new Promise(r => suite.server.close(r)); + if(suite.realReplica && suite.realReplica.close){ try { await suite.realReplica.close(); } catch(e){} } + await setup.globalTeardown(); + }); + + beforeEach(function(){ suite.warnSpy = sinon.spy(console, 'warn'); }); + afterEach(function(){ suite.warnSpy.restore(); }); + suite.sawParityWarn = () => suite.warnSpy.getCalls().some(c => + String(c.args[0] || '').includes('INDEX_MAP_PARITY mismatch')); + return suite; +} + +module.exports = { PORT, H, STAMPED, stamp, registerIndexMapParityHttpHooks }; From 812cdbc3ad5261f24e2aeec75131566a49090b11 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:10:33 -0700 Subject: [PATCH 25/62] test: split circuit breaker boundary suite by behavior --- test/unit/boundaries/circuit_breaker.test.js | 179 +----------------- .../02_open_rejection.test.js | 45 +++++ .../circuit_breaker.test/03_half_open.test.js | 57 ++++++ .../circuit_breaker.test/04_max_retry.test.js | 45 +++++ .../circuit_breaker.test/05_backoff.test.js | 67 +++++++ .../06_transaction.test.js | 35 ++++ .../helpers/circuit_breaker_suite.js | 36 ++++ 7 files changed, 287 insertions(+), 177 deletions(-) create mode 100644 test/unit/boundaries/circuit_breaker.test/02_open_rejection.test.js create mode 100644 test/unit/boundaries/circuit_breaker.test/03_half_open.test.js create mode 100644 test/unit/boundaries/circuit_breaker.test/04_max_retry.test.js create mode 100644 test/unit/boundaries/circuit_breaker.test/05_backoff.test.js create mode 100644 test/unit/boundaries/circuit_breaker.test/06_transaction.test.js create mode 100644 test/unit/boundaries/circuit_breaker.test/helpers/circuit_breaker_suite.js diff --git a/test/unit/boundaries/circuit_breaker.test.js b/test/unit/boundaries/circuit_breaker.test.js index 13a59fb8..ef1ef75d 100644 --- a/test/unit/boundaries/circuit_breaker.test.js +++ b/test/unit/boundaries/circuit_breaker.test.js @@ -10,32 +10,11 @@ const assert = require('assert'); const sinon = require('sinon'); -const Utility = require('../../../src/util'); - -// We test the circuit breaker logic by constructing a Database instance -// with a stubbed pool. Since the mariadb import is at module level, we -// use proxyquire to inject a mock pool. -const proxyquire = require('proxyquire'); - -function createDatabase(poolStub){ - let mockMariadb = { - createPool: sinon.stub().returns(poolStub) - }; - let Database = proxyquire('../../../src/db', { 'mariadb': mockMariadb }); - let util = new Utility(); - return new Database('localhost', 3306, 'testdb', 'user', 'pass', util); -} +const { createDatabase, registerHooks } = require('./circuit_breaker.test/helpers/circuit_breaker_suite'); describe('Boundary: Circuit Breaker', function(){ - let db, pool; - - beforeEach(function(){ - sinon.stub(console, 'log'); - sinon.stub(console, 'error'); - }); - - afterEach(function(){ sinon.restore(); }); + registerHooks(); describe('failure threshold (10)', function(){ it('9 failures: circuit stays closed', async function(){ @@ -78,158 +57,4 @@ describe('Boundary: Circuit Breaker', function(){ assert.strictEqual(db.circuitFailures, 10); }); }); - - describe('circuit open rejection', function(){ - it('rejects immediately during cooldown', async function(){ - pool = { - getConnection: sinon.stub().rejects(new Error('fail')), - end: sinon.stub() - }; - db = createDatabase(pool); - sinon.stub(db.util, 'sleep').resolves(); - - // Open the circuit - try { await db.getConnection(); } catch(e) {} - assert.strictEqual(db.circuitState, 'open'); - - // Set cooldown to future - db.circuitOpenUntil = Date.now() + 30000; - - await assert.rejects( - () => db.getConnection(), - (err) => { - let msg = (err && err.message) ? err.message : String(err); - return msg.includes('Circuit breaker open'); - } - ); - }); - }); - - describe('half-open recovery', function(){ - it('transitions to half-open after cooldown expires', async function(){ - let conn = { release: sinon.stub() }; - pool = { - getConnection: sinon.stub().resolves(conn), - end: sinon.stub() - }; - db = createDatabase(pool); - - // Simulate open circuit with expired cooldown - db.circuitState = 'open'; - db.circuitFailures = 10; - db.circuitOpenUntil = Date.now() - 1; // expired - - let result = await db.getConnection(); - assert.strictEqual(result, conn); - assert.strictEqual(db.circuitState, 'closed'); - assert.strictEqual(db.circuitFailures, 0); - }); - - it('re-opens on failure during half-open', async function(){ - pool = { - getConnection: sinon.stub().rejects(new Error('still down')), - end: sinon.stub() - }; - db = createDatabase(pool); - sinon.stub(db.util, 'sleep').resolves(); - - // Simulate half-open state - db.circuitState = 'open'; - db.circuitFailures = 9; - db.circuitOpenUntil = Date.now() - 1; - - await assert.rejects(() => db.getConnection()); - assert.strictEqual(db.circuitState, 'open'); - }); - }); - - describe('max retry attempts (30)', function(){ - it('throws after 30 attempts without reaching circuit threshold', async function(){ - let callCount = 0; - pool = { - getConnection: sinon.stub().callsFake(async () => { - callCount++; - throw new Error('fail'); - }), - end: sinon.stub() - }; - db = createDatabase(pool); - sinon.stub(db.util, 'sleep').resolves(); - // Set high threshold so circuit doesn't trip first - db.circuitThreshold = 100; - - await assert.rejects( - () => db.getConnection(), - (err) => { - let msg = (err && err.message) ? err.message : String(err); - return msg.includes('30 attempts'); - } - ); - assert.strictEqual(callCount, 30); - }); - }); - - describe('backoff delay calculation', function(){ - it('uses correct exponential progression', async function(){ - let delays = []; - pool = { - getConnection: sinon.stub().rejects(new Error('fail')), - end: sinon.stub() - }; - db = createDatabase(pool); - db.circuitThreshold = 100; // prevent circuit from tripping - sinon.stub(db.util, 'sleep').callsFake(async (ms) => { delays.push(ms); }); - sinon.stub(Math, 'random').returns(0); // no jitter - - try { await db.getConnection(); } catch(e) {} - - // First 6 delays: 500, 1000, 2000, 4000, 8000, 15000 (capped) - assert.strictEqual(delays[0], 500); - assert.strictEqual(delays[1], 1000); - assert.strictEqual(delays[2], 2000); - assert.strictEqual(delays[3], 4000); - assert.strictEqual(delays[4], 8000); - assert.strictEqual(delays[5], 15000); // capped at maxDelay - - // All subsequent delays should also be 15000 (capped) - for(let i = 6; i < delays.length; i++){ - assert.strictEqual(delays[i], 15000); - } - }); - - it('adds up to 30% jitter', async function(){ - let delays = []; - pool = { - getConnection: sinon.stub().rejects(new Error('fail')), - end: sinon.stub() - }; - db = createDatabase(pool); - db.circuitThreshold = 100; - sinon.stub(db.util, 'sleep').callsFake(async (ms) => { delays.push(ms); }); - sinon.stub(Math, 'random').returns(1); // max jitter - - try { await db.getConnection(); } catch(e) {} - - // First delay: 500 + floor(1 * 500 * 0.3) = 500 + 150 = 650 - assert.strictEqual(delays[0], 650); - // Second: 1000 + floor(1 * 1000 * 0.3) = 1000 + 300 = 1300 - assert.strictEqual(delays[1], 1300); - }); - }); - - describe('transaction connection bypass', function(){ - it('returns transactionConnection when set (bypasses pool)', async function(){ - let txConn = { release: sinon.stub() }; - pool = { - getConnection: sinon.stub().rejects(new Error('should not be called')), - end: sinon.stub() - }; - db = createDatabase(pool); - db.transactionConnection = txConn; - - let result = await db.getConnection(); - assert.strictEqual(result, txConn); - assert.strictEqual(pool.getConnection.called, false); - }); - }); }); diff --git a/test/unit/boundaries/circuit_breaker.test/02_open_rejection.test.js b/test/unit/boundaries/circuit_breaker.test/02_open_rejection.test.js new file mode 100644 index 00000000..b97dcd65 --- /dev/null +++ b/test/unit/boundaries/circuit_breaker.test/02_open_rejection.test.js @@ -0,0 +1,45 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers open-circuit rejection. One part of circuit_breaker.test.js. +const assert = require('assert'); +const sinon = require('sinon'); +const { createDatabase, registerHooks } = require('./helpers/circuit_breaker_suite'); + +describe('Boundary: Circuit Breaker', function(){ + let db, pool; + registerHooks(); + + describe('circuit open rejection', function(){ + it('rejects immediately during cooldown', async function(){ + pool = { + getConnection: sinon.stub().rejects(new Error('fail')), + end: sinon.stub() + }; + db = createDatabase(pool); + sinon.stub(db.util, 'sleep').resolves(); + + // Open the circuit + try { await db.getConnection(); } catch(e) {} + assert.strictEqual(db.circuitState, 'open'); + + // Set cooldown to future + db.circuitOpenUntil = Date.now() + 30000; + + await assert.rejects( + () => db.getConnection(), + (err) => { + let msg = (err && err.message) ? err.message : String(err); + return msg.includes('Circuit breaker open'); + } + ); + }); + }); +}); diff --git a/test/unit/boundaries/circuit_breaker.test/03_half_open.test.js b/test/unit/boundaries/circuit_breaker.test/03_half_open.test.js new file mode 100644 index 00000000..382a7238 --- /dev/null +++ b/test/unit/boundaries/circuit_breaker.test/03_half_open.test.js @@ -0,0 +1,57 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers half-open recovery. One part of circuit_breaker.test.js. +const assert = require('assert'); +const sinon = require('sinon'); +const { createDatabase, registerHooks } = require('./helpers/circuit_breaker_suite'); + +describe('Boundary: Circuit Breaker', function(){ + let db, pool; + registerHooks(); + + describe('half-open recovery', function(){ + it('transitions to half-open after cooldown expires', async function(){ + let conn = { release: sinon.stub() }; + pool = { + getConnection: sinon.stub().resolves(conn), + end: sinon.stub() + }; + db = createDatabase(pool); + + // Simulate open circuit with expired cooldown + db.circuitState = 'open'; + db.circuitFailures = 10; + db.circuitOpenUntil = Date.now() - 1; // expired + + let result = await db.getConnection(); + assert.strictEqual(result, conn); + assert.strictEqual(db.circuitState, 'closed'); + assert.strictEqual(db.circuitFailures, 0); + }); + + it('re-opens on failure during half-open', async function(){ + pool = { + getConnection: sinon.stub().rejects(new Error('still down')), + end: sinon.stub() + }; + db = createDatabase(pool); + sinon.stub(db.util, 'sleep').resolves(); + + // Simulate half-open state + db.circuitState = 'open'; + db.circuitFailures = 9; + db.circuitOpenUntil = Date.now() - 1; + + await assert.rejects(() => db.getConnection()); + assert.strictEqual(db.circuitState, 'open'); + }); + }); +}); diff --git a/test/unit/boundaries/circuit_breaker.test/04_max_retry.test.js b/test/unit/boundaries/circuit_breaker.test/04_max_retry.test.js new file mode 100644 index 00000000..b829da12 --- /dev/null +++ b/test/unit/boundaries/circuit_breaker.test/04_max_retry.test.js @@ -0,0 +1,45 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers retry exhaustion. One part of circuit_breaker.test.js. +const assert = require('assert'); +const sinon = require('sinon'); +const { createDatabase, registerHooks } = require('./helpers/circuit_breaker_suite'); + +describe('Boundary: Circuit Breaker', function(){ + let db, pool; + registerHooks(); + + describe('max retry attempts (30)', function(){ + it('throws after 30 attempts without reaching circuit threshold', async function(){ + let callCount = 0; + pool = { + getConnection: sinon.stub().callsFake(async () => { + callCount++; + throw new Error('fail'); + }), + end: sinon.stub() + }; + db = createDatabase(pool); + sinon.stub(db.util, 'sleep').resolves(); + // Set high threshold so circuit doesn't trip first + db.circuitThreshold = 100; + + await assert.rejects( + () => db.getConnection(), + (err) => { + let msg = (err && err.message) ? err.message : String(err); + return msg.includes('30 attempts'); + } + ); + assert.strictEqual(callCount, 30); + }); + }); +}); diff --git a/test/unit/boundaries/circuit_breaker.test/05_backoff.test.js b/test/unit/boundaries/circuit_breaker.test/05_backoff.test.js new file mode 100644 index 00000000..46799577 --- /dev/null +++ b/test/unit/boundaries/circuit_breaker.test/05_backoff.test.js @@ -0,0 +1,67 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers backoff timing. One part of circuit_breaker.test.js. +const assert = require('assert'); +const sinon = require('sinon'); +const { createDatabase, registerHooks } = require('./helpers/circuit_breaker_suite'); + +describe('Boundary: Circuit Breaker', function(){ + let db, pool; + registerHooks(); + + describe('backoff delay calculation', function(){ + it('uses correct exponential progression', async function(){ + let delays = []; + pool = { + getConnection: sinon.stub().rejects(new Error('fail')), + end: sinon.stub() + }; + db = createDatabase(pool); + db.circuitThreshold = 100; // prevent circuit from tripping + sinon.stub(db.util, 'sleep').callsFake(async (ms) => { delays.push(ms); }); + sinon.stub(Math, 'random').returns(0); // no jitter + + try { await db.getConnection(); } catch(e) {} + + // First 6 delays: 500, 1000, 2000, 4000, 8000, 15000 (capped) + assert.strictEqual(delays[0], 500); + assert.strictEqual(delays[1], 1000); + assert.strictEqual(delays[2], 2000); + assert.strictEqual(delays[3], 4000); + assert.strictEqual(delays[4], 8000); + assert.strictEqual(delays[5], 15000); // capped at maxDelay + + // All subsequent delays should also be 15000 (capped) + for(let i = 6; i < delays.length; i++){ + assert.strictEqual(delays[i], 15000); + } + }); + + it('adds up to 30% jitter', async function(){ + let delays = []; + pool = { + getConnection: sinon.stub().rejects(new Error('fail')), + end: sinon.stub() + }; + db = createDatabase(pool); + db.circuitThreshold = 100; + sinon.stub(db.util, 'sleep').callsFake(async (ms) => { delays.push(ms); }); + sinon.stub(Math, 'random').returns(1); // max jitter + + try { await db.getConnection(); } catch(e) {} + + // First delay: 500 + floor(1 * 500 * 0.3) = 500 + 150 = 650 + assert.strictEqual(delays[0], 650); + // Second: 1000 + floor(1 * 1000 * 0.3) = 1000 + 300 = 1300 + assert.strictEqual(delays[1], 1300); + }); + }); +}); diff --git a/test/unit/boundaries/circuit_breaker.test/06_transaction.test.js b/test/unit/boundaries/circuit_breaker.test/06_transaction.test.js new file mode 100644 index 00000000..ec98adf9 --- /dev/null +++ b/test/unit/boundaries/circuit_breaker.test/06_transaction.test.js @@ -0,0 +1,35 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers transaction connection bypass. One part of circuit_breaker.test.js. +const assert = require('assert'); +const sinon = require('sinon'); +const { createDatabase, registerHooks } = require('./helpers/circuit_breaker_suite'); + +describe('Boundary: Circuit Breaker', function(){ + let db, pool; + registerHooks(); + + describe('transaction connection bypass', function(){ + it('returns transactionConnection when set (bypasses pool)', async function(){ + let txConn = { release: sinon.stub() }; + pool = { + getConnection: sinon.stub().rejects(new Error('should not be called')), + end: sinon.stub() + }; + db = createDatabase(pool); + db.transactionConnection = txConn; + + let result = await db.getConnection(); + assert.strictEqual(result, txConn); + assert.strictEqual(pool.getConnection.called, false); + }); + }); +}); diff --git a/test/unit/boundaries/circuit_breaker.test/helpers/circuit_breaker_suite.js b/test/unit/boundaries/circuit_breaker.test/helpers/circuit_breaker_suite.js new file mode 100644 index 00000000..fff62f9c --- /dev/null +++ b/test/unit/boundaries/circuit_breaker.test/helpers/circuit_breaker_suite.js @@ -0,0 +1,36 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers shared fixtures and hooks. One part of circuit_breaker.test.js. +const sinon = require('sinon'); +const Utility = require('../../../../../src/util'); +const proxyquire = require('proxyquire'); + +// We test the circuit breaker logic by constructing a Database instance +// with a stubbed pool. Since the mariadb import is at module level, we +// use proxyquire to inject a mock pool. +function createDatabase(poolStub){ + let mockMariadb = { + createPool: sinon.stub().returns(poolStub) + }; + let Database = proxyquire('../../../../../src/db', { 'mariadb': mockMariadb }); + let util = new Utility(); + return new Database('localhost', 3306, 'testdb', 'user', 'pass', util); +} + +function registerHooks(){ + beforeEach(function(){ + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + }); + afterEach(function(){ sinon.restore(); }); +} + +module.exports = { createDatabase, registerHooks }; From 4fac713d38fd385d27593a0749f5cb2692ea430d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:10:38 -0700 Subject: [PATCH 26/62] test: split config parsing boundary suite by behavior --- test/unit/boundaries/config_parsing.test.js | 200 +----------------- .../config_parsing.test/02_negative.test.js | 40 ++++ .../config_parsing.test/03_nan.test.js | 36 ++++ .../04_numeric_ranges.test.js | 38 ++++ .../05_verify_hashes.test.js | 40 ++++ .../06_sync_sources.test.js | 36 ++++ .../07_minimum_ports.test.js | 36 ++++ .../helpers/config_parsing_suite.js | 38 ++++ 8 files changed, 268 insertions(+), 196 deletions(-) create mode 100644 test/unit/boundaries/config_parsing.test/02_negative.test.js create mode 100644 test/unit/boundaries/config_parsing.test/03_nan.test.js create mode 100644 test/unit/boundaries/config_parsing.test/04_numeric_ranges.test.js create mode 100644 test/unit/boundaries/config_parsing.test/05_verify_hashes.test.js create mode 100644 test/unit/boundaries/config_parsing.test/06_sync_sources.test.js create mode 100644 test/unit/boundaries/config_parsing.test/07_minimum_ports.test.js create mode 100644 test/unit/boundaries/config_parsing.test/helpers/config_parsing_suite.js diff --git a/test/unit/boundaries/config_parsing.test.js b/test/unit/boundaries/config_parsing.test.js index ca071346..ead5e7b7 100644 --- a/test/unit/boundaries/config_parsing.test.js +++ b/test/unit/boundaries/config_parsing.test.js @@ -10,8 +10,11 @@ const assert = require('assert'); const config = require('../../../src/config'); +const { registerHooks } = require('./config_parsing.test/helpers/config_parsing_suite'); + +describe('Boundary: Config Parsing', function(){ + registerHooks(); -function registerZeroValueTests(){ describe('zero values (falsy-zero)', function(){ it('preserves SYNC_API_PORT=0', function(){ process.env.SYNC_API_PORT = '0'; @@ -33,199 +36,4 @@ function registerZeroValueTests(){ assert.strictEqual(config.getConfig().SNAPSHOT_RATE_INCR, 0); }); }); -} - -function registerNegativeValueTests(){ - describe('negative values clamped', function(){ - it('clamps WS_MAX_PER_IP=-1 to 1', function(){ - process.env.WS_MAX_PER_IP = '-1'; - assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 1); - }); - - it('clamps HUB_PORT=-1 to 1', function(){ - process.env.HUB_PORT = '-1'; - assert.strictEqual(config.getConfig().HUB_PORT, 1); - }); - - it('clamps REPLICA_DB_PORT=-1 to 1', function(){ - process.env.REPLICA_DB_PORT = '-1'; - assert.strictEqual(config.getConfig().REPLICA_DB_PORT, 1); - }); - - it('clamps BLOCK_POLL_INTERVAL=-100 to 0', function(){ - process.env.BLOCK_POLL_INTERVAL = '-100'; - assert.strictEqual(config.getConfig().BLOCK_POLL_INTERVAL, 0); - }); - - it('clamps SNAPSHOT_RATE_FULL=-5 to 0', function(){ - process.env.SNAPSHOT_RATE_FULL = '-5'; - assert.strictEqual(config.getConfig().SNAPSHOT_RATE_FULL, 0); - }); - }); -} - -function registerInvalidValueTests(){ - describe('NaN inputs default gracefully', function(){ - it('SYNC_API_PORT=abc defaults to 3006', function(){ - process.env.SYNC_API_PORT = 'abc'; - assert.strictEqual(config.getConfig().SYNC_API_PORT, 3006); - }); - - it('WS_MAX_PER_IP=xyz defaults to 100', function(){ - process.env.WS_MAX_PER_IP = 'xyz'; - assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 100); - }); - - it('HUB_PORT="" defaults to 10000', function(){ - process.env.HUB_PORT = ''; - assert.strictEqual(config.getConfig().HUB_PORT, 10000); - }); - - it('BLOCK_POLL_INTERVAL=undefined defaults to 3000', function(){ - delete process.env.BLOCK_POLL_INTERVAL; - assert.strictEqual(config.getConfig().BLOCK_POLL_INTERVAL, 3000); - }); - }); -} - -function registerFloatingPointTests(){ - describe('float strings truncated', function(){ - it('WS_MAX_PER_IP=3.7 becomes 3', function(){ - process.env.WS_MAX_PER_IP = '3.7'; - assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 3); - }); - - it('SYNC_API_PORT=3006.5 becomes 3006', function(){ - process.env.SYNC_API_PORT = '3006.5'; - assert.strictEqual(config.getConfig().SYNC_API_PORT, 3006); - }); - }); -} - -function registerLargeValueTests(){ - describe('large values', function(){ - it('SNAPSHOT_RATE_FULL=999999 preserved', function(){ - process.env.SNAPSHOT_RATE_FULL = '999999'; - assert.strictEqual(config.getConfig().SNAPSHOT_RATE_FULL, 999999); - }); - - it('BLOCK_POLL_INTERVAL=2147483647 preserved', function(){ - process.env.BLOCK_POLL_INTERVAL = '2147483647'; - assert.strictEqual(config.getConfig().BLOCK_POLL_INTERVAL, 2147483647); - }); - }); -} - -function registerBooleanTests(){ - describe('VERIFY_HASHES case insensitivity', function(){ - it('"false" disables', function(){ - process.env.VERIFY_HASHES = 'false'; - assert.strictEqual(config.getConfig().VERIFY_HASHES, false); - }); - - it('"FALSE" disables', function(){ - process.env.VERIFY_HASHES = 'FALSE'; - assert.strictEqual(config.getConfig().VERIFY_HASHES, false); - }); - - it('"False" disables', function(){ - process.env.VERIFY_HASHES = 'False'; - assert.strictEqual(config.getConfig().VERIFY_HASHES, false); - }); - - it('"0" does NOT disable (not the word false)', function(){ - process.env.VERIFY_HASHES = '0'; - assert.strictEqual(config.getConfig().VERIFY_HASHES, true); - }); - - it('unset defaults to true', function(){ - delete process.env.VERIFY_HASHES; - assert.strictEqual(config.getConfig().VERIFY_HASHES, true); - }); - }); -} - -function registerSourceListTests(){ - describe('SYNC_SOURCES parsing boundaries', function(){ - it('empty string yields empty after getConfig', function(){ - process.env.SYNC_SOURCES = ''; - assert.strictEqual(config.getConfig().SYNC_SOURCES, ''); - }); - - it('single URL preserved', function(){ - process.env.SYNC_SOURCES = 'http://server1'; - assert.strictEqual(config.getConfig().SYNC_SOURCES, 'http://server1'); - }); - - it('trailing comma preserved in raw config (parsed by ClientSync)', function(){ - process.env.SYNC_SOURCES = 'http://s1,'; - assert.strictEqual(config.getConfig().SYNC_SOURCES, 'http://s1,'); - }); - - it('whitespace preserved in raw config (parsed by ClientSync)', function(){ - process.env.SYNC_SOURCES = ' http://s1 , http://s2 '; - assert.strictEqual(config.getConfig().SYNC_SOURCES, ' http://s1 , http://s2 '); - }); - }); -} - -function registerPortBoundaryTests(){ - describe('minimum-one clamping for ports', function(){ - it('WS_MAX_PER_IP=0 clamps to 1', function(){ - process.env.WS_MAX_PER_IP = '0'; - assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 1); - }); - - it('WS_MAX_PER_IP=1 stays 1', function(){ - process.env.WS_MAX_PER_IP = '1'; - assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 1); - }); - - it('HUB_PORT=0 clamps to 1', function(){ - process.env.HUB_PORT = '0'; - assert.strictEqual(config.getConfig().HUB_PORT, 1); - }); - - it('REPLICA_DB_PORT=0 clamps to 1', function(){ - process.env.REPLICA_DB_PORT = '0'; - assert.strictEqual(config.getConfig().REPLICA_DB_PORT, 1); - }); - }); -} - -describe('Boundary: Config Parsing', function(){ - - const ENV_KEYS = [ - 'SYNC_MODE', 'SYNC_API_PORT', 'HUB_API_HOST', 'HUB_PORT', - 'CORS_ORIGIN', 'BLOCK_POLL_INTERVAL', 'WS_MAX_PER_IP', - 'SNAPSHOT_RATE_FULL', 'SNAPSHOT_RATE_INCR', 'SYNC_SOURCES', - 'VERIFY_HASHES', 'REPLICA_DB_HOST', 'REPLICA_DB_PORT', - 'REPLICA_DB_USER', 'REPLICA_DB_PASS' - ]; - let savedEnv = {}; - - beforeEach(function(){ - for(let key of ENV_KEYS){ - savedEnv[key] = process.env[key]; - delete process.env[key]; - } - }); - - afterEach(function(){ - for(let key of ENV_KEYS){ - if(savedEnv[key] !== undefined) - process.env[key] = savedEnv[key]; - else - delete process.env[key]; - } - }); - - registerZeroValueTests(); - registerNegativeValueTests(); - registerInvalidValueTests(); - registerFloatingPointTests(); - registerLargeValueTests(); - registerBooleanTests(); - registerSourceListTests(); - registerPortBoundaryTests(); }); diff --git a/test/unit/boundaries/config_parsing.test/02_negative.test.js b/test/unit/boundaries/config_parsing.test/02_negative.test.js new file mode 100644 index 00000000..ccdcd66b --- /dev/null +++ b/test/unit/boundaries/config_parsing.test/02_negative.test.js @@ -0,0 +1,40 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers negative values. One part of config_parsing.test.js. +const assert = require('assert'); +const config = require('../../../../src/config'); +const { registerHooks } = require('./helpers/config_parsing_suite'); + +describe('Boundary: Config Parsing', function(){ + registerHooks(); + describe('negative values clamped', function(){ + it('clamps WS_MAX_PER_IP=-1 to 1', function(){ + process.env.WS_MAX_PER_IP = '-1'; + assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 1); + }); + it('clamps HUB_PORT=-1 to 1', function(){ + process.env.HUB_PORT = '-1'; + assert.strictEqual(config.getConfig().HUB_PORT, 1); + }); + it('clamps REPLICA_DB_PORT=-1 to 1', function(){ + process.env.REPLICA_DB_PORT = '-1'; + assert.strictEqual(config.getConfig().REPLICA_DB_PORT, 1); + }); + it('clamps BLOCK_POLL_INTERVAL=-100 to 0', function(){ + process.env.BLOCK_POLL_INTERVAL = '-100'; + assert.strictEqual(config.getConfig().BLOCK_POLL_INTERVAL, 0); + }); + it('clamps SNAPSHOT_RATE_FULL=-5 to 0', function(){ + process.env.SNAPSHOT_RATE_FULL = '-5'; + assert.strictEqual(config.getConfig().SNAPSHOT_RATE_FULL, 0); + }); + }); +}); diff --git a/test/unit/boundaries/config_parsing.test/03_nan.test.js b/test/unit/boundaries/config_parsing.test/03_nan.test.js new file mode 100644 index 00000000..81470bfa --- /dev/null +++ b/test/unit/boundaries/config_parsing.test/03_nan.test.js @@ -0,0 +1,36 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers invalid numeric inputs. One part of config_parsing.test.js. +const assert = require('assert'); +const config = require('../../../../src/config'); +const { registerHooks } = require('./helpers/config_parsing_suite'); + +describe('Boundary: Config Parsing', function(){ + registerHooks(); + describe('NaN inputs default gracefully', function(){ + it('SYNC_API_PORT=abc defaults to 3006', function(){ + process.env.SYNC_API_PORT = 'abc'; + assert.strictEqual(config.getConfig().SYNC_API_PORT, 3006); + }); + it('WS_MAX_PER_IP=xyz defaults to 100', function(){ + process.env.WS_MAX_PER_IP = 'xyz'; + assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 100); + }); + it('HUB_PORT="" defaults to 10000', function(){ + process.env.HUB_PORT = ''; + assert.strictEqual(config.getConfig().HUB_PORT, 10000); + }); + it('BLOCK_POLL_INTERVAL=undefined defaults to 3000', function(){ + delete process.env.BLOCK_POLL_INTERVAL; + assert.strictEqual(config.getConfig().BLOCK_POLL_INTERVAL, 3000); + }); + }); +}); diff --git a/test/unit/boundaries/config_parsing.test/04_numeric_ranges.test.js b/test/unit/boundaries/config_parsing.test/04_numeric_ranges.test.js new file mode 100644 index 00000000..8bbc6a38 --- /dev/null +++ b/test/unit/boundaries/config_parsing.test/04_numeric_ranges.test.js @@ -0,0 +1,38 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers float and large values. One part of config_parsing.test.js. +const assert = require('assert'); +const config = require('../../../../src/config'); +const { registerHooks } = require('./helpers/config_parsing_suite'); + +describe('Boundary: Config Parsing', function(){ + registerHooks(); + describe('float strings truncated', function(){ + it('WS_MAX_PER_IP=3.7 becomes 3', function(){ + process.env.WS_MAX_PER_IP = '3.7'; + assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 3); + }); + it('SYNC_API_PORT=3006.5 becomes 3006', function(){ + process.env.SYNC_API_PORT = '3006.5'; + assert.strictEqual(config.getConfig().SYNC_API_PORT, 3006); + }); + }); + describe('large values', function(){ + it('SNAPSHOT_RATE_FULL=999999 preserved', function(){ + process.env.SNAPSHOT_RATE_FULL = '999999'; + assert.strictEqual(config.getConfig().SNAPSHOT_RATE_FULL, 999999); + }); + it('BLOCK_POLL_INTERVAL=2147483647 preserved', function(){ + process.env.BLOCK_POLL_INTERVAL = '2147483647'; + assert.strictEqual(config.getConfig().BLOCK_POLL_INTERVAL, 2147483647); + }); + }); +}); diff --git a/test/unit/boundaries/config_parsing.test/05_verify_hashes.test.js b/test/unit/boundaries/config_parsing.test/05_verify_hashes.test.js new file mode 100644 index 00000000..db0a6388 --- /dev/null +++ b/test/unit/boundaries/config_parsing.test/05_verify_hashes.test.js @@ -0,0 +1,40 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers hash verification parsing. One part of config_parsing.test.js. +const assert = require('assert'); +const config = require('../../../../src/config'); +const { registerHooks } = require('./helpers/config_parsing_suite'); + +describe('Boundary: Config Parsing', function(){ + registerHooks(); + describe('VERIFY_HASHES case insensitivity', function(){ + it('"false" disables', function(){ + process.env.VERIFY_HASHES = 'false'; + assert.strictEqual(config.getConfig().VERIFY_HASHES, false); + }); + it('"FALSE" disables', function(){ + process.env.VERIFY_HASHES = 'FALSE'; + assert.strictEqual(config.getConfig().VERIFY_HASHES, false); + }); + it('"False" disables', function(){ + process.env.VERIFY_HASHES = 'False'; + assert.strictEqual(config.getConfig().VERIFY_HASHES, false); + }); + it('"0" does NOT disable (not the word false)', function(){ + process.env.VERIFY_HASHES = '0'; + assert.strictEqual(config.getConfig().VERIFY_HASHES, true); + }); + it('unset defaults to true', function(){ + delete process.env.VERIFY_HASHES; + assert.strictEqual(config.getConfig().VERIFY_HASHES, true); + }); + }); +}); diff --git a/test/unit/boundaries/config_parsing.test/06_sync_sources.test.js b/test/unit/boundaries/config_parsing.test/06_sync_sources.test.js new file mode 100644 index 00000000..0588001c --- /dev/null +++ b/test/unit/boundaries/config_parsing.test/06_sync_sources.test.js @@ -0,0 +1,36 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers sync source parsing. One part of config_parsing.test.js. +const assert = require('assert'); +const config = require('../../../../src/config'); +const { registerHooks } = require('./helpers/config_parsing_suite'); + +describe('Boundary: Config Parsing', function(){ + registerHooks(); + describe('SYNC_SOURCES parsing boundaries', function(){ + it('empty string yields empty after getConfig', function(){ + process.env.SYNC_SOURCES = ''; + assert.strictEqual(config.getConfig().SYNC_SOURCES, ''); + }); + it('single URL preserved', function(){ + process.env.SYNC_SOURCES = 'http://server1'; + assert.strictEqual(config.getConfig().SYNC_SOURCES, 'http://server1'); + }); + it('trailing comma preserved in raw config (parsed by ClientSync)', function(){ + process.env.SYNC_SOURCES = 'http://s1,'; + assert.strictEqual(config.getConfig().SYNC_SOURCES, 'http://s1,'); + }); + it('whitespace preserved in raw config (parsed by ClientSync)', function(){ + process.env.SYNC_SOURCES = ' http://s1 , http://s2 '; + assert.strictEqual(config.getConfig().SYNC_SOURCES, ' http://s1 , http://s2 '); + }); + }); +}); diff --git a/test/unit/boundaries/config_parsing.test/07_minimum_ports.test.js b/test/unit/boundaries/config_parsing.test/07_minimum_ports.test.js new file mode 100644 index 00000000..c858d978 --- /dev/null +++ b/test/unit/boundaries/config_parsing.test/07_minimum_ports.test.js @@ -0,0 +1,36 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers minimum port values. One part of config_parsing.test.js. +const assert = require('assert'); +const config = require('../../../../src/config'); +const { registerHooks } = require('./helpers/config_parsing_suite'); + +describe('Boundary: Config Parsing', function(){ + registerHooks(); + describe('minimum-one clamping for ports', function(){ + it('WS_MAX_PER_IP=0 clamps to 1', function(){ + process.env.WS_MAX_PER_IP = '0'; + assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 1); + }); + it('WS_MAX_PER_IP=1 stays 1', function(){ + process.env.WS_MAX_PER_IP = '1'; + assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 1); + }); + it('HUB_PORT=0 clamps to 1', function(){ + process.env.HUB_PORT = '0'; + assert.strictEqual(config.getConfig().HUB_PORT, 1); + }); + it('REPLICA_DB_PORT=0 clamps to 1', function(){ + process.env.REPLICA_DB_PORT = '0'; + assert.strictEqual(config.getConfig().REPLICA_DB_PORT, 1); + }); + }); +}); diff --git a/test/unit/boundaries/config_parsing.test/helpers/config_parsing_suite.js b/test/unit/boundaries/config_parsing.test/helpers/config_parsing_suite.js new file mode 100644 index 00000000..c2b898ca --- /dev/null +++ b/test/unit/boundaries/config_parsing.test/helpers/config_parsing_suite.js @@ -0,0 +1,38 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers environment hooks. One part of config_parsing.test.js. +const ENV_KEYS = [ + 'SYNC_MODE', 'SYNC_API_PORT', 'HUB_API_HOST', 'HUB_PORT', + 'CORS_ORIGIN', 'BLOCK_POLL_INTERVAL', 'WS_MAX_PER_IP', + 'SNAPSHOT_RATE_FULL', 'SNAPSHOT_RATE_INCR', 'SYNC_SOURCES', + 'VERIFY_HASHES', 'REPLICA_DB_HOST', 'REPLICA_DB_PORT', + 'REPLICA_DB_USER', 'REPLICA_DB_PASS' +]; + +function registerHooks(){ + let savedEnv = {}; + beforeEach(function(){ + for(let key of ENV_KEYS){ + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + afterEach(function(){ + for(let key of ENV_KEYS){ + if(savedEnv[key] !== undefined) + process.env[key] = savedEnv[key]; + else + delete process.env[key]; + } + }); +} + +module.exports = { registerHooks }; From 6353fba0dc892464d4d3ff2924134dcef9a3dbea Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:10:42 -0700 Subject: [PATCH 27/62] test: split hash continuity boundary suite by behavior --- test/unit/boundaries/hash_continuity.test.js | 102 +----------------- .../02_null_inputs.test.js | 39 +++++++ .../03_hash_comparison.test.js | 56 ++++++++++ .../helpers/hash_continuity_suite.js | 21 ++++ 4 files changed, 120 insertions(+), 98 deletions(-) create mode 100644 test/unit/boundaries/hash_continuity.test/02_null_inputs.test.js create mode 100644 test/unit/boundaries/hash_continuity.test/03_hash_comparison.test.js create mode 100644 test/unit/boundaries/hash_continuity.test/helpers/hash_continuity_suite.js diff --git a/test/unit/boundaries/hash_continuity.test.js b/test/unit/boundaries/hash_continuity.test.js index 5b5902dc..f9655832 100644 --- a/test/unit/boundaries/hash_continuity.test.js +++ b/test/unit/boundaries/hash_continuity.test.js @@ -9,136 +9,42 @@ // contact legal@dankest.llc. const assert = require('assert'); -const HashVerifier = require('../../../src/client/hash_verifier'); +const { hashes, registerHooks } = require('./hash_continuity.test/helpers/hash_continuity_suite'); -let verifier; -const hashes = { ledger_hash: 'aaa', actions_hash: 'bbb', contract_hash: 'ccc' }; +describe('Boundary: Hash Continuity Check', function(){ + let verifier; + registerHooks(function(value){ verifier = value; }); -function registerBlockIndexContinuityTests(){ describe('block index continuity (exact +1 requirement)', function(){ it('valid: 10 → 11 (sequential)', function(){ let result = verifier.verifyChainContinuity(10, hashes, { block_index: 11 }); assert.strictEqual(result.valid, true); }); - it('invalid: 10 → 12 (gap of 1)', function(){ let result = verifier.verifyChainContinuity(10, hashes, { block_index: 12 }); assert.strictEqual(result.valid, false); assert.ok(result.reason.includes('expected 11')); assert.ok(result.reason.includes('got 12')); }); - it('invalid: 10 → 10 (same block)', function(){ let result = verifier.verifyChainContinuity(10, hashes, { block_index: 10 }); assert.strictEqual(result.valid, false); }); - it('invalid: 10 → 9 (backward)', function(){ let result = verifier.verifyChainContinuity(10, hashes, { block_index: 9 }); assert.strictEqual(result.valid, false); }); - it('valid: 0 → 1 (zero-based chain)', function(){ let result = verifier.verifyChainContinuity(0, hashes, { block_index: 1 }); assert.strictEqual(result.valid, true); }); - it('invalid: 0 → 2 (skip from zero)', function(){ let result = verifier.verifyChainContinuity(0, hashes, { block_index: 2 }); assert.strictEqual(result.valid, false); }); - it('invalid: 0 → 0 (repeat at zero)', function(){ let result = verifier.verifyChainContinuity(0, hashes, { block_index: 0 }); assert.strictEqual(result.valid, false); }); }); -} - -function registerBootstrapTests(){ - describe('null prevBlockIndex (bootstrap)', function(){ - it('valid: null → 1 (first block)', function(){ - let result = verifier.verifyChainContinuity(null, null, { block_index: 1 }); - assert.strictEqual(result.valid, true); - assert.strictEqual(result.reason, null); - }); - - it('valid: null → 0 (first block at zero)', function(){ - let result = verifier.verifyChainContinuity(null, null, { block_index: 0 }); - assert.strictEqual(result.valid, true); - }); - - it('valid: null → 999 (any block after bootstrap)', function(){ - let result = verifier.verifyChainContinuity(null, null, { block_index: 999 }); - assert.strictEqual(result.valid, true); - }); - }); -} - -function registerNullHashTests(){ - describe('null prevHashes', function(){ - it('valid: prevBlockIndex=5, prevHashes=null → skips check', function(){ - let result = verifier.verifyChainContinuity(null, null, { block_index: 6 }); - assert.strictEqual(result.valid, true); - }); - }); -} - -function registerHashComparisonTests(){ - describe('hash comparison boundaries', function(){ - it('match: all three hashes identical', function(){ - let result = verifier.compareBlockHashes(1, hashes, { ...hashes }); - assert.strictEqual(result.match, true); - assert.strictEqual(result.mismatches.length, 0); - }); - - it('mismatch: single field different', function(){ - let result = verifier.compareBlockHashes(1, hashes, { ...hashes, ledger_hash: 'zzz' }); - assert.strictEqual(result.match, false); - assert.strictEqual(result.mismatches.length, 1); - }); - - it('mismatch: all three fields different', function(){ - let result = verifier.compareBlockHashes(1, hashes, { ledger_hash: 'x', actions_hash: 'y', contract_hash: 'z' }); - assert.strictEqual(result.match, false); - assert.strictEqual(result.mismatches.length, 3); - }); - - it('null vs string is mismatch', function(){ - let result = verifier.compareBlockHashes(1, hashes, { ledger_hash: null, actions_hash: 'bbb', contract_hash: 'ccc' }); - assert.strictEqual(result.match, false); - }); - - it('null vs null is match', function(){ - let n = { ledger_hash: null, actions_hash: null, contract_hash: null }; - let result = verifier.compareBlockHashes(1, n, { ...n }); - assert.strictEqual(result.match, true); - }); - - it('empty string vs empty string is match', function(){ - let e = { ledger_hash: '', actions_hash: '', contract_hash: '' }; - let result = verifier.compareBlockHashes(1, e, { ...e }); - assert.strictEqual(result.match, true); - }); - - it('empty string vs null is mismatch', function(){ - let a = { ledger_hash: '', actions_hash: '', contract_hash: '' }; - let b = { ledger_hash: null, actions_hash: null, contract_hash: null }; - let result = verifier.compareBlockHashes(1, a, b); - assert.strictEqual(result.match, false); - assert.strictEqual(result.mismatches.length, 3); - }); - }); -} - -describe('Boundary: Hash Continuity Check', function(){ - - beforeEach(function(){ - verifier = new HashVerifier(); - }); - - registerBlockIndexContinuityTests(); - registerBootstrapTests(); - registerNullHashTests(); - registerHashComparisonTests(); }); diff --git a/test/unit/boundaries/hash_continuity.test/02_null_inputs.test.js b/test/unit/boundaries/hash_continuity.test/02_null_inputs.test.js new file mode 100644 index 00000000..0607c9bb --- /dev/null +++ b/test/unit/boundaries/hash_continuity.test/02_null_inputs.test.js @@ -0,0 +1,39 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers null continuity inputs. One part of hash_continuity.test.js. +const assert = require('assert'); +const { registerHooks } = require('./helpers/hash_continuity_suite'); + +describe('Boundary: Hash Continuity Check', function(){ + let verifier; + registerHooks(function(value){ verifier = value; }); + describe('null prevBlockIndex (bootstrap)', function(){ + it('valid: null → 1 (first block)', function(){ + let result = verifier.verifyChainContinuity(null, null, { block_index: 1 }); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.reason, null); + }); + it('valid: null → 0 (first block at zero)', function(){ + let result = verifier.verifyChainContinuity(null, null, { block_index: 0 }); + assert.strictEqual(result.valid, true); + }); + it('valid: null → 999 (any block after bootstrap)', function(){ + let result = verifier.verifyChainContinuity(null, null, { block_index: 999 }); + assert.strictEqual(result.valid, true); + }); + }); + describe('null prevHashes', function(){ + it('valid: prevBlockIndex=5, prevHashes=null → skips check', function(){ + let result = verifier.verifyChainContinuity(null, null, { block_index: 6 }); + assert.strictEqual(result.valid, true); + }); + }); +}); diff --git a/test/unit/boundaries/hash_continuity.test/03_hash_comparison.test.js b/test/unit/boundaries/hash_continuity.test/03_hash_comparison.test.js new file mode 100644 index 00000000..7871fee8 --- /dev/null +++ b/test/unit/boundaries/hash_continuity.test/03_hash_comparison.test.js @@ -0,0 +1,56 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers hash comparisons. One part of hash_continuity.test.js. +const assert = require('assert'); +const { hashes, registerHooks } = require('./helpers/hash_continuity_suite'); + +describe('Boundary: Hash Continuity Check', function(){ + let verifier; + registerHooks(function(value){ verifier = value; }); + describe('hash comparison boundaries', function(){ + it('match: all three hashes identical', function(){ + let result = verifier.compareBlockHashes(1, hashes, { ...hashes }); + assert.strictEqual(result.match, true); + assert.strictEqual(result.mismatches.length, 0); + }); + it('mismatch: single field different', function(){ + let result = verifier.compareBlockHashes(1, hashes, { ...hashes, ledger_hash: 'zzz' }); + assert.strictEqual(result.match, false); + assert.strictEqual(result.mismatches.length, 1); + }); + it('mismatch: all three fields different', function(){ + let result = verifier.compareBlockHashes(1, hashes, { ledger_hash: 'x', actions_hash: 'y', contract_hash: 'z' }); + assert.strictEqual(result.match, false); + assert.strictEqual(result.mismatches.length, 3); + }); + it('null vs string is mismatch', function(){ + let result = verifier.compareBlockHashes(1, hashes, { ledger_hash: null, actions_hash: 'bbb', contract_hash: 'ccc' }); + assert.strictEqual(result.match, false); + }); + it('null vs null is match', function(){ + let n = { ledger_hash: null, actions_hash: null, contract_hash: null }; + let result = verifier.compareBlockHashes(1, n, { ...n }); + assert.strictEqual(result.match, true); + }); + it('empty string vs empty string is match', function(){ + let e = { ledger_hash: '', actions_hash: '', contract_hash: '' }; + let result = verifier.compareBlockHashes(1, e, { ...e }); + assert.strictEqual(result.match, true); + }); + it('empty string vs null is mismatch', function(){ + let a = { ledger_hash: '', actions_hash: '', contract_hash: '' }; + let b = { ledger_hash: null, actions_hash: null, contract_hash: null }; + let result = verifier.compareBlockHashes(1, a, b); + assert.strictEqual(result.match, false); + assert.strictEqual(result.mismatches.length, 3); + }); + }); +}); diff --git a/test/unit/boundaries/hash_continuity.test/helpers/hash_continuity_suite.js b/test/unit/boundaries/hash_continuity.test/helpers/hash_continuity_suite.js new file mode 100644 index 00000000..96131a89 --- /dev/null +++ b/test/unit/boundaries/hash_continuity.test/helpers/hash_continuity_suite.js @@ -0,0 +1,21 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers hash fixtures and hooks. One part of hash_continuity.test.js. +const HashVerifier = require('../../../../../src/client/hash_verifier'); +const hashes = { ledger_hash: 'aaa', actions_hash: 'bbb', contract_hash: 'ccc' }; + +function registerHooks(setVerifier){ + beforeEach(function(){ + setVerifier(new HashVerifier()); + }); +} + +module.exports = { hashes, registerHooks }; From e699cc8ee130c43ce1484d7b6b60c831507f453b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:10:47 -0700 Subject: [PATCH 28/62] test: split hub port parsing boundary suite by behavior --- test/unit/boundaries/hub_port_parsing.test.js | 126 +----------------- .../02_parse_fallbacks.test.js | 42 ++++++ .../hub_port_parsing.test/03_db_port.test.js | 47 +++++++ .../04_legacy_port.test.js | 47 +++++++ .../05_default_port.test.js | 46 +++++++ .../helpers/hub_port_parsing_suite.js | 22 +++ 6 files changed, 206 insertions(+), 124 deletions(-) create mode 100644 test/unit/boundaries/hub_port_parsing.test/02_parse_fallbacks.test.js create mode 100644 test/unit/boundaries/hub_port_parsing.test/03_db_port.test.js create mode 100644 test/unit/boundaries/hub_port_parsing.test/04_legacy_port.test.js create mode 100644 test/unit/boundaries/hub_port_parsing.test/05_default_port.test.js create mode 100644 test/unit/boundaries/hub_port_parsing.test/helpers/hub_port_parsing_suite.js diff --git a/test/unit/boundaries/hub_port_parsing.test.js b/test/unit/boundaries/hub_port_parsing.test.js index d69e6b8c..ec03e0ba 100644 --- a/test/unit/boundaries/hub_port_parsing.test.js +++ b/test/unit/boundaries/hub_port_parsing.test.js @@ -9,154 +9,32 @@ // contact legal@dankest.llc. const assert = require('assert'); -const sinon = require('sinon'); const HubClient = require('../../../src/hub/client'); -const axios = require('axios'); +const { registerHooks } = require('./hub_port_parsing.test/helpers/hub_port_parsing_suite'); describe('Boundary: HubClient Port Parsing', function(){ - - beforeEach(function(){ - sinon.stub(console, 'log'); - sinon.stub(console, 'error'); - }); - - afterEach(function(){ sinon.restore(); }); - + registerHooks(); describe('parsePort static method', function(){ it('valid port: returns as-is', function(){ assert.strictEqual(HubClient.parsePort('3306', undefined), 3306); }); - it('zero: preserved (not treated as falsy)', function(){ assert.strictEqual(HubClient.parsePort('0', undefined), 0); }); - it('falls back to secondary when primary is null', function(){ assert.strictEqual(HubClient.parsePort(null, '5432'), 5432); }); - it('falls back to secondary when primary is undefined', function(){ assert.strictEqual(HubClient.parsePort(undefined, '5432'), 5432); }); - it('falls back to secondary when primary is empty string', function(){ assert.strictEqual(HubClient.parsePort('', '5432'), 5432); }); - it('defaults to 3306 when both are absent', function(){ assert.strictEqual(HubClient.parsePort(undefined, undefined), 3306); }); - it('defaults to 3306 when both are null', function(){ assert.strictEqual(HubClient.parsePort(null, null), 3306); }); - - it('defaults to 3306 when both are empty', function(){ - assert.strictEqual(HubClient.parsePort('', ''), 3306); - }); - - it('defaults to 3306 for non-numeric primary', function(){ - assert.strictEqual(HubClient.parsePort('abc', undefined), 3306); - }); - - it('uses secondary when primary is non-numeric', function(){ - // A non-numeric primary is not empty/null/undefined, so it is used directly (parseInt fails to NaN) rather than falling back to secondary. - assert.strictEqual(HubClient.parsePort('abc', '5432'), 3306); - }); - - it('negative port defaults to 3306', function(){ - assert.strictEqual(HubClient.parsePort('-1', undefined), 3306); - }); - - it('integer value (not string) works', function(){ - assert.strictEqual(HubClient.parsePort(3307, undefined), 3307); - }); - - it('integer 0 preserved', function(){ - assert.strictEqual(HubClient.parsePort(0, undefined), 0); - }); - - it('float string truncated', function(){ - assert.strictEqual(HubClient.parsePort('3306.5', undefined), 3306); - }); - }); - - describe('getIndexerConfigs integration', function(){ - it('uses db_port from hub config', async function(){ - let hub = new HubClient('localhost', 10000); - sinon.stub(axios, 'post').resolves({ - data: { - jsonrpc: '2.0', - result: { - bitcoin: { - mainnet: { - 'xchain-indexer': { - db_host: 'db.local', - db_port: 13306, - name: 'xchain_btc', - user: 'root', - pass: 'pass' - } - } - } - } - } - }); - - let configs = await hub.getIndexerConfigs(); - assert.strictEqual(configs[0].db_port, 13306); - axios.post.restore(); - }); - - it('falls back to port when db_port absent', async function(){ - let hub = new HubClient('localhost', 10000); - sinon.stub(axios, 'post').resolves({ - data: { - jsonrpc: '2.0', - result: { - bitcoin: { - mainnet: { - 'xchain-indexer': { - host: 'db.local', - port: 5432, - name: 'xchain_btc', - user: 'root', - pass: 'pass' - } - } - } - } - } - }); - - let configs = await hub.getIndexerConfigs(); - assert.strictEqual(configs[0].db_port, 5432); - axios.post.restore(); - }); - - it('defaults to 3306 when neither port field present', async function(){ - let hub = new HubClient('localhost', 10000); - sinon.stub(axios, 'post').resolves({ - data: { - jsonrpc: '2.0', - result: { - bitcoin: { - mainnet: { - 'xchain-indexer': { - host: 'db.local', - name: 'xchain_btc', - user: 'root', - pass: 'pass' - } - } - } - } - } - }); - - let configs = await hub.getIndexerConfigs(); - assert.strictEqual(configs[0].db_port, 3306); - axios.post.restore(); - }); }); }); diff --git a/test/unit/boundaries/hub_port_parsing.test/02_parse_fallbacks.test.js b/test/unit/boundaries/hub_port_parsing.test/02_parse_fallbacks.test.js new file mode 100644 index 00000000..df2ab936 --- /dev/null +++ b/test/unit/boundaries/hub_port_parsing.test/02_parse_fallbacks.test.js @@ -0,0 +1,42 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers port fallbacks. One part of hub_port_parsing.test.js. +const assert = require('assert'); +const HubClient = require('../../../../src/hub/client'); +const { registerHooks } = require('./helpers/hub_port_parsing_suite'); + +describe('Boundary: HubClient Port Parsing', function(){ + registerHooks(); + describe('parsePort static method', function(){ + it('defaults to 3306 when both are empty', function(){ + assert.strictEqual(HubClient.parsePort('', ''), 3306); + }); + it('defaults to 3306 for non-numeric primary', function(){ + assert.strictEqual(HubClient.parsePort('abc', undefined), 3306); + }); + it('uses secondary when primary is non-numeric', function(){ + // A non-numeric primary is not empty/null/undefined, so it is used directly (parseInt fails to NaN) rather than falling back to secondary. + assert.strictEqual(HubClient.parsePort('abc', '5432'), 3306); + }); + it('negative port defaults to 3306', function(){ + assert.strictEqual(HubClient.parsePort('-1', undefined), 3306); + }); + it('integer value (not string) works', function(){ + assert.strictEqual(HubClient.parsePort(3307, undefined), 3307); + }); + it('integer 0 preserved', function(){ + assert.strictEqual(HubClient.parsePort(0, undefined), 0); + }); + it('float string truncated', function(){ + assert.strictEqual(HubClient.parsePort('3306.5', undefined), 3306); + }); + }); +}); diff --git a/test/unit/boundaries/hub_port_parsing.test/03_db_port.test.js b/test/unit/boundaries/hub_port_parsing.test/03_db_port.test.js new file mode 100644 index 00000000..338efb99 --- /dev/null +++ b/test/unit/boundaries/hub_port_parsing.test/03_db_port.test.js @@ -0,0 +1,47 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers explicit database ports. One part of hub_port_parsing.test.js. +const assert = require('assert'); +const sinon = require('sinon'); +const HubClient = require('../../../../src/hub/client'); +const axios = require('axios'); +const { registerHooks } = require('./helpers/hub_port_parsing_suite'); + +describe('Boundary: HubClient Port Parsing', function(){ + registerHooks(); + describe('getIndexerConfigs integration', function(){ + it('uses db_port from hub config', async function(){ + let hub = new HubClient('localhost', 10000); + sinon.stub(axios, 'post').resolves({ + data: { + jsonrpc: '2.0', + result: { + bitcoin: { + mainnet: { + 'xchain-indexer': { + db_host: 'db.local', + db_port: 13306, + name: 'xchain_btc', + user: 'root', + pass: 'pass' + } + } + } + } + } + }); + + let configs = await hub.getIndexerConfigs(); + assert.strictEqual(configs[0].db_port, 13306); + axios.post.restore(); + }); + }); +}); diff --git a/test/unit/boundaries/hub_port_parsing.test/04_legacy_port.test.js b/test/unit/boundaries/hub_port_parsing.test/04_legacy_port.test.js new file mode 100644 index 00000000..ed93a6a0 --- /dev/null +++ b/test/unit/boundaries/hub_port_parsing.test/04_legacy_port.test.js @@ -0,0 +1,47 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers legacy database ports. One part of hub_port_parsing.test.js. +const assert = require('assert'); +const sinon = require('sinon'); +const HubClient = require('../../../../src/hub/client'); +const axios = require('axios'); +const { registerHooks } = require('./helpers/hub_port_parsing_suite'); + +describe('Boundary: HubClient Port Parsing', function(){ + registerHooks(); + describe('getIndexerConfigs integration', function(){ + it('falls back to port when db_port absent', async function(){ + let hub = new HubClient('localhost', 10000); + sinon.stub(axios, 'post').resolves({ + data: { + jsonrpc: '2.0', + result: { + bitcoin: { + mainnet: { + 'xchain-indexer': { + host: 'db.local', + port: 5432, + name: 'xchain_btc', + user: 'root', + pass: 'pass' + } + } + } + } + } + }); + + let configs = await hub.getIndexerConfigs(); + assert.strictEqual(configs[0].db_port, 5432); + axios.post.restore(); + }); + }); +}); diff --git a/test/unit/boundaries/hub_port_parsing.test/05_default_port.test.js b/test/unit/boundaries/hub_port_parsing.test/05_default_port.test.js new file mode 100644 index 00000000..98e93593 --- /dev/null +++ b/test/unit/boundaries/hub_port_parsing.test/05_default_port.test.js @@ -0,0 +1,46 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers default database ports. One part of hub_port_parsing.test.js. +const assert = require('assert'); +const sinon = require('sinon'); +const HubClient = require('../../../../src/hub/client'); +const axios = require('axios'); +const { registerHooks } = require('./helpers/hub_port_parsing_suite'); + +describe('Boundary: HubClient Port Parsing', function(){ + registerHooks(); + describe('getIndexerConfigs integration', function(){ + it('defaults to 3306 when neither port field present', async function(){ + let hub = new HubClient('localhost', 10000); + sinon.stub(axios, 'post').resolves({ + data: { + jsonrpc: '2.0', + result: { + bitcoin: { + mainnet: { + 'xchain-indexer': { + host: 'db.local', + name: 'xchain_btc', + user: 'root', + pass: 'pass' + } + } + } + } + } + }); + + let configs = await hub.getIndexerConfigs(); + assert.strictEqual(configs[0].db_port, 3306); + axios.post.restore(); + }); + }); +}); diff --git a/test/unit/boundaries/hub_port_parsing.test/helpers/hub_port_parsing_suite.js b/test/unit/boundaries/hub_port_parsing.test/helpers/hub_port_parsing_suite.js new file mode 100644 index 00000000..8dec01f2 --- /dev/null +++ b/test/unit/boundaries/hub_port_parsing.test/helpers/hub_port_parsing_suite.js @@ -0,0 +1,22 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers shared hooks. One part of hub_port_parsing.test.js. +const sinon = require('sinon'); + +function registerHooks(){ + beforeEach(function(){ + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + }); + afterEach(function(){ sinon.restore(); }); +} + +module.exports = { registerHooks }; From b2d49e46b8d1c73c0b33809179d28f30decfc770 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:10:50 -0700 Subject: [PATCH 29/62] test: split poll limit boundary suite by behavior --- test/unit/boundaries/poll_limit.test.js | 80 +------------------ .../poll_limit.test/02_capped_polls.test.js | 46 +++++++++++ .../helpers/poll_limit_suite.js | 64 +++++++++++++++ 3 files changed, 112 insertions(+), 78 deletions(-) create mode 100644 test/unit/boundaries/poll_limit.test/02_capped_polls.test.js create mode 100644 test/unit/boundaries/poll_limit.test/helpers/poll_limit_suite.js diff --git a/test/unit/boundaries/poll_limit.test.js b/test/unit/boundaries/poll_limit.test.js index f2709c55..a182116c 100644 --- a/test/unit/boundaries/poll_limit.test.js +++ b/test/unit/boundaries/poll_limit.test.js @@ -9,59 +9,11 @@ // contact legal@dankest.llc. const assert = require('assert'); -const sinon = require('sinon'); -const ServerPoller = require('../../../src/server/poller'); -const Utility = require('../../../src/util'); -const { withDbMixins } = require('../../helpers/db_mixins.js'); - -function createMockDb(){ - // Queries read through named Database methods. The real ones are installed for - // any this fake does not stub, so they still reach doQuery below and every - // doQuery call count these suites assert keeps counting them. - return withDbMixins({ - getLastBlock: sinon.stub().resolves(null), - getBlockHashRow: sinon.stub().resolves(null), - getBlockScopedRows: sinon.stub().resolves([]), - getTxScopedRows: sinon.stub().resolves([]), - getActionScopedRows: sinon.stub().resolves([]), - getEmissionRowsForBlock: sinon.stub().resolves([]), - getTransactions: sinon.stub().resolves([]), - getActions: sinon.stub().resolves([]), - getStatusId: sinon.stub().resolves(null), - doQuery: sinon.stub().resolves([]), - beginReadSnapshot: sinon.stub().resolves({ mockSnapshotConn: true }), - commitReadSnapshot: sinon.stub().resolves(), - rollbackReadSnapshot: sinon.stub().resolves() - }); -} - -function createMockBroadcaster(){ - return { broadcast: sinon.stub(), updateStatus: sinon.stub(), getSubscribers: sinon.stub().returns([]), getSubscriberCount: sinon.stub().returns(0) }; -} - -function createMockLog(){ - return { recordBlock: sinon.stub().resolves(), pruneFrom: sinon.stub().resolves() }; -} +const { registerHooks } = require('./poll_limit.test/helpers/poll_limit_suite'); describe('Boundary: Poll Loop Limit (100 blocks)', function(){ - let poller, db, broadcaster, log; - - beforeEach(function(){ - db = createMockDb(); - broadcaster = createMockBroadcaster(); - log = createMockLog(); - let util = new Utility(); - poller = new ServerPoller('bitcoin', 'mainnet', db, broadcaster, log, { BLOCK_POLL_INTERVAL: 100 }, util); - db.getBlockHashRow.callsFake(async (idx) => ({ - block_index: idx, block_time: idx * 10, - ledger_hash: 'l' + idx, actions_hash: 'a' + idx, contract_hash: 'c' + idx - })); - sinon.stub(console, 'log'); - sinon.stub(console, 'error'); - }); - - afterEach(function(){ sinon.restore(); }); + registerHooks(function(context){ ({ poller, db, broadcaster, log } = context); }); it('processes 0 blocks when at current (no-op)', async function(){ poller.lastPolledBlock = 50; @@ -94,32 +46,4 @@ describe('Boundary: Poll Loop Limit (100 blocks)', function(){ assert.strictEqual(broadcaster.broadcast.callCount, 100); assert.strictEqual(poller.lastPolledBlock, 100); }); - - it('caps at 100 blocks when 101 available', async function(){ - poller.lastPolledBlock = 0; - db.getLastBlock.resolves(101); - await poller.poll(); - assert.strictEqual(broadcaster.broadcast.callCount, 100); - assert.strictEqual(poller.lastPolledBlock, 100); - }); - - it('processes remaining 1 block on second poll after cap', async function(){ - poller.lastPolledBlock = 0; - db.getLastBlock.resolves(101); - await poller.poll(); // processes 1–100 - assert.strictEqual(poller.lastPolledBlock, 100); - - broadcaster.broadcast.resetHistory(); - await poller.poll(); // processes 101 - assert.strictEqual(broadcaster.broadcast.callCount, 1); - assert.strictEqual(poller.lastPolledBlock, 101); - }); - - it('caps at 100 blocks when 200 available', async function(){ - poller.lastPolledBlock = 0; - db.getLastBlock.resolves(200); - await poller.poll(); - assert.strictEqual(broadcaster.broadcast.callCount, 100); - assert.strictEqual(poller.lastPolledBlock, 100); - }); }); diff --git a/test/unit/boundaries/poll_limit.test/02_capped_polls.test.js b/test/unit/boundaries/poll_limit.test/02_capped_polls.test.js new file mode 100644 index 00000000..fbf3bc5b --- /dev/null +++ b/test/unit/boundaries/poll_limit.test/02_capped_polls.test.js @@ -0,0 +1,46 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers capped poll batches. One part of poll_limit.test.js. +const assert = require('assert'); +const { registerHooks } = require('./helpers/poll_limit_suite'); + +describe('Boundary: Poll Loop Limit (100 blocks)', function(){ + let poller, db, broadcaster, log; + registerHooks(function(context){ ({ poller, db, broadcaster, log } = context); }); + + it('caps at 100 blocks when 101 available', async function(){ + poller.lastPolledBlock = 0; + db.getLastBlock.resolves(101); + await poller.poll(); + assert.strictEqual(broadcaster.broadcast.callCount, 100); + assert.strictEqual(poller.lastPolledBlock, 100); + }); + + it('processes remaining 1 block on second poll after cap', async function(){ + poller.lastPolledBlock = 0; + db.getLastBlock.resolves(101); + await poller.poll(); // processes 1–100 + assert.strictEqual(poller.lastPolledBlock, 100); + + broadcaster.broadcast.resetHistory(); + await poller.poll(); // processes 101 + assert.strictEqual(broadcaster.broadcast.callCount, 1); + assert.strictEqual(poller.lastPolledBlock, 101); + }); + + it('caps at 100 blocks when 200 available', async function(){ + poller.lastPolledBlock = 0; + db.getLastBlock.resolves(200); + await poller.poll(); + assert.strictEqual(broadcaster.broadcast.callCount, 100); + assert.strictEqual(poller.lastPolledBlock, 100); + }); +}); diff --git a/test/unit/boundaries/poll_limit.test/helpers/poll_limit_suite.js b/test/unit/boundaries/poll_limit.test/helpers/poll_limit_suite.js new file mode 100644 index 00000000..ca3bfabe --- /dev/null +++ b/test/unit/boundaries/poll_limit.test/helpers/poll_limit_suite.js @@ -0,0 +1,64 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers poller fixtures and hooks. One part of poll_limit.test.js. +const sinon = require('sinon'); +const ServerPoller = require('../../../../../src/server/poller'); +const Utility = require('../../../../../src/util'); +const { withDbMixins } = require('../../../../helpers/db_mixins.js'); + +function createMockDb(){ + // Queries read through named Database methods. The real ones are installed for + // any this fake does not stub, so they still reach doQuery below and every + // doQuery call count these suites assert keeps counting them. + return withDbMixins({ + getLastBlock: sinon.stub().resolves(null), + getBlockHashRow: sinon.stub().resolves(null), + getBlockScopedRows: sinon.stub().resolves([]), + getTxScopedRows: sinon.stub().resolves([]), + getActionScopedRows: sinon.stub().resolves([]), + getEmissionRowsForBlock: sinon.stub().resolves([]), + getTransactions: sinon.stub().resolves([]), + getActions: sinon.stub().resolves([]), + getStatusId: sinon.stub().resolves(null), + doQuery: sinon.stub().resolves([]), + beginReadSnapshot: sinon.stub().resolves({ mockSnapshotConn: true }), + commitReadSnapshot: sinon.stub().resolves(), + rollbackReadSnapshot: sinon.stub().resolves() + }); +} + +function createMockBroadcaster(){ + return { broadcast: sinon.stub(), updateStatus: sinon.stub(), getSubscribers: sinon.stub().returns([]), getSubscriberCount: sinon.stub().returns(0) }; +} + +function createMockLog(){ + return { recordBlock: sinon.stub().resolves(), pruneFrom: sinon.stub().resolves() }; +} + +function registerHooks(setContext){ + beforeEach(function(){ + let db = createMockDb(); + let broadcaster = createMockBroadcaster(); + let log = createMockLog(); + let util = new Utility(); + let poller = new ServerPoller('bitcoin', 'mainnet', db, broadcaster, log, { BLOCK_POLL_INTERVAL: 100 }, util); + db.getBlockHashRow.callsFake(async (idx) => ({ + block_index: idx, block_time: idx * 10, + ledger_hash: 'l' + idx, actions_hash: 'a' + idx, contract_hash: 'c' + idx + })); + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + setContext({ poller, db, broadcaster, log }); + }); + afterEach(function(){ sinon.restore(); }); +} + +module.exports = { registerHooks }; From 5d93503d27f79b60bc3d2c452ef53791a84a7f74 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:10:54 -0700 Subject: [PATCH 30/62] test: split reorg detection boundary suite by behavior --- test/unit/boundaries/reorg_detection.test.js | 96 +------------------ .../02_mid_rewrite.test.js | 38 ++++++++ .../03_initialization.test.js | 51 ++++++++++ .../helpers/reorg_detection_suite.js | 55 +++++++++++ 4 files changed, 146 insertions(+), 94 deletions(-) create mode 100644 test/unit/boundaries/reorg_detection.test/02_mid_rewrite.test.js create mode 100644 test/unit/boundaries/reorg_detection.test/03_initialization.test.js create mode 100644 test/unit/boundaries/reorg_detection.test/helpers/reorg_detection_suite.js diff --git a/test/unit/boundaries/reorg_detection.test.js b/test/unit/boundaries/reorg_detection.test.js index f365963b..925ce1cb 100644 --- a/test/unit/boundaries/reorg_detection.test.js +++ b/test/unit/boundaries/reorg_detection.test.js @@ -9,50 +9,11 @@ // contact legal@dankest.llc. const assert = require('assert'); -const sinon = require('sinon'); -const ServerPoller = require('../../../src/server/poller'); -const Utility = require('../../../src/util'); -const { withDbMixins } = require('../../helpers/db_mixins.js'); - -function createMockDb(){ - // Queries read through named Database methods. The real ones are installed for - // any this fake does not stub, so they still reach doQuery below and every - // doQuery call count these suites assert keeps counting them. - return withDbMixins({ - getLastBlock: sinon.stub().resolves(null), - getBlockHashRow: sinon.stub().resolves(null), - getBlockScopedRows: sinon.stub().resolves([]), - getTxScopedRows: sinon.stub().resolves([]), - getActionScopedRows: sinon.stub().resolves([]), - getEmissionRowsForBlock: sinon.stub().resolves([]), - getTransactions: sinon.stub().resolves([]), - getActions: sinon.stub().resolves([]), - getStatusId: sinon.stub().resolves(null), - doQuery: sinon.stub().resolves([]), - beginReadSnapshot: sinon.stub().resolves({ mockSnapshotConn: true }), - commitReadSnapshot: sinon.stub().resolves(), - rollbackReadSnapshot: sinon.stub().resolves() - }); -} +const { registerHooks } = require('./reorg_detection.test/helpers/reorg_detection_suite'); describe('Boundary: Reorg Detection', function(){ - let poller, db, broadcaster; - - beforeEach(function(){ - db = createMockDb(); - broadcaster = { broadcast: sinon.stub(), updateStatus: sinon.stub(), getSubscribers: sinon.stub().returns([]), getSubscriberCount: sinon.stub().returns(0) }; - let log = { recordBlock: sinon.stub().resolves(), pruneFrom: sinon.stub().resolves() }; - poller = new ServerPoller('bitcoin', 'mainnet', db, broadcaster, log, { BLOCK_POLL_INTERVAL: 100 }, new Utility()); - db.getBlockHashRow.callsFake(async (idx) => ({ - block_index: idx, block_time: idx * 10, - ledger_hash: 'l', actions_hash: 'a', contract_hash: 'c' - })); - sinon.stub(console, 'log'); - sinon.stub(console, 'error'); - }); - - afterEach(function(){ sinon.restore(); }); + registerHooks(function(context){ ({ poller, db, broadcaster } = context); }); it('no change (currentBlock === lastPolledBlock): no-op', async function(){ poller.lastPolledBlock = 10; @@ -90,57 +51,4 @@ describe('Boundary: Reorg Detection', function(){ assert.strictEqual(event.block_index, 91); // currentBlock + 1 assert.strictEqual(poller.lastPolledBlock, 90); }); - - it('mid-rewrite height drop: walks back to the true fork point', async function(){ - // The source is observed mid-rewrite: the tip dropped from 10 to 8, but the rewrite - // actually forked at 5, so blocks 5-8 are already replacements. A height-only check - // would broadcast reorg@9 (too shallow), leaving followers on stale blocks 5-8 until a - // later poll caught the deeper rewrite, so the walk-back must resolve fork=5 in this poll. - poller.lastPolledBlock = 10; - db.getLastBlock.resolves(8); - // Recorded broadcast hashes: 4 matches the live source ('l'), 5..10 were broadcast pre-reorg with a different content hash. - poller.recentBroadcastHashes.set(4, 'l'); - for(let bi = 5; bi <= 10; bi++) poller.recentBroadcastHashes.set(bi, 'pre-reorg'); - await poller.poll(); - let event = broadcaster.broadcast.firstCall.args[2]; - assert.strictEqual(event.type, 'reorg'); - assert.strictEqual(event.block_index, 5); - assert.strictEqual(poller.lastPolledBlock, 4); - // Guard re-seeded from the recorded (still matching) hash at the fork parent. - assert.strictEqual(poller.lastPolledBlockHash, 'l'); - assert.strictEqual(poller.transparencyLog.pruneFrom.calledOnceWithExactly(5), true); - }); - - it('currentBlock = null (all blocks deleted): early return', async function(){ - poller.lastPolledBlock = 10; - db.getLastBlock.resolves(null); - await poller.poll(); - assert.strictEqual(broadcaster.broadcast.called, false); - assert.strictEqual(poller.lastPolledBlock, 10); // unchanged - }); - - it('first poll (lastPolledBlock = null): initializes without processing', async function(){ - poller.lastPolledBlock = null; - db.getLastBlock.resolves(50); - await poller.poll(); - assert.strictEqual(poller.lastPolledBlock, 50); - assert.strictEqual(broadcaster.broadcast.called, false); // no block broadcasts - assert.strictEqual(broadcaster.updateStatus.calledOnce, true); - }); - - it('first poll with empty DB: remains null', async function(){ - poller.lastPolledBlock = null; - db.getLastBlock.resolves(null); - await poller.poll(); - assert.strictEqual(poller.lastPolledBlock, null); - assert.strictEqual(broadcaster.broadcast.called, false); - }); - - it('same-height non-detection: no event when data changes at same block', async function(){ - poller.lastPolledBlock = 10; - db.getLastBlock.resolves(10); - // Even if underlying data changed at block 10, poller does not detect it - await poller.poll(); - assert.strictEqual(broadcaster.broadcast.called, false); - }); }); diff --git a/test/unit/boundaries/reorg_detection.test/02_mid_rewrite.test.js b/test/unit/boundaries/reorg_detection.test/02_mid_rewrite.test.js new file mode 100644 index 00000000..187abde4 --- /dev/null +++ b/test/unit/boundaries/reorg_detection.test/02_mid_rewrite.test.js @@ -0,0 +1,38 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers mid-rewrite fork discovery. One part of reorg_detection.test.js. +const assert = require('assert'); +const { registerHooks } = require('./helpers/reorg_detection_suite'); + +describe('Boundary: Reorg Detection', function(){ + let poller, db, broadcaster; + registerHooks(function(context){ ({ poller, db, broadcaster } = context); }); + + it('mid-rewrite height drop: walks back to the true fork point', async function(){ + // The source is observed mid-rewrite: the tip dropped from 10 to 8, but the rewrite + // actually forked at 5, so blocks 5-8 are already replacements. A height-only check + // would broadcast reorg@9 (too shallow), leaving followers on stale blocks 5-8 until a + // later poll caught the deeper rewrite, so the walk-back must resolve fork=5 in this poll. + poller.lastPolledBlock = 10; + db.getLastBlock.resolves(8); + // Recorded broadcast hashes: 4 matches the live source ('l'), 5..10 were broadcast pre-reorg with a different content hash. + poller.recentBroadcastHashes.set(4, 'l'); + for(let bi = 5; bi <= 10; bi++) poller.recentBroadcastHashes.set(bi, 'pre-reorg'); + await poller.poll(); + let event = broadcaster.broadcast.firstCall.args[2]; + assert.strictEqual(event.type, 'reorg'); + assert.strictEqual(event.block_index, 5); + assert.strictEqual(poller.lastPolledBlock, 4); + // Guard re-seeded from the recorded (still matching) hash at the fork parent. + assert.strictEqual(poller.lastPolledBlockHash, 'l'); + assert.strictEqual(poller.transparencyLog.pruneFrom.calledOnceWithExactly(5), true); + }); +}); diff --git a/test/unit/boundaries/reorg_detection.test/03_initialization.test.js b/test/unit/boundaries/reorg_detection.test/03_initialization.test.js new file mode 100644 index 00000000..0ac719f2 --- /dev/null +++ b/test/unit/boundaries/reorg_detection.test/03_initialization.test.js @@ -0,0 +1,51 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers empty and initial poll states. One part of reorg_detection.test.js. +const assert = require('assert'); +const { registerHooks } = require('./helpers/reorg_detection_suite'); + +describe('Boundary: Reorg Detection', function(){ + let poller, db, broadcaster; + registerHooks(function(context){ ({ poller, db, broadcaster } = context); }); + + it('currentBlock = null (all blocks deleted): early return', async function(){ + poller.lastPolledBlock = 10; + db.getLastBlock.resolves(null); + await poller.poll(); + assert.strictEqual(broadcaster.broadcast.called, false); + assert.strictEqual(poller.lastPolledBlock, 10); // unchanged + }); + + it('first poll (lastPolledBlock = null): initializes without processing', async function(){ + poller.lastPolledBlock = null; + db.getLastBlock.resolves(50); + await poller.poll(); + assert.strictEqual(poller.lastPolledBlock, 50); + assert.strictEqual(broadcaster.broadcast.called, false); // no block broadcasts + assert.strictEqual(broadcaster.updateStatus.calledOnce, true); + }); + + it('first poll with empty DB: remains null', async function(){ + poller.lastPolledBlock = null; + db.getLastBlock.resolves(null); + await poller.poll(); + assert.strictEqual(poller.lastPolledBlock, null); + assert.strictEqual(broadcaster.broadcast.called, false); + }); + + it('same-height non-detection: no event when data changes at same block', async function(){ + poller.lastPolledBlock = 10; + db.getLastBlock.resolves(10); + // Even if underlying data changed at block 10, poller does not detect it + await poller.poll(); + assert.strictEqual(broadcaster.broadcast.called, false); + }); +}); diff --git a/test/unit/boundaries/reorg_detection.test/helpers/reorg_detection_suite.js b/test/unit/boundaries/reorg_detection.test/helpers/reorg_detection_suite.js new file mode 100644 index 00000000..9524c33d --- /dev/null +++ b/test/unit/boundaries/reorg_detection.test/helpers/reorg_detection_suite.js @@ -0,0 +1,55 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers reorg fixtures and hooks. One part of reorg_detection.test.js. +const sinon = require('sinon'); +const ServerPoller = require('../../../../../src/server/poller'); +const Utility = require('../../../../../src/util'); +const { withDbMixins } = require('../../../../helpers/db_mixins.js'); + +function createMockDb(){ + // Queries read through named Database methods. The real ones are installed for + // any this fake does not stub, so they still reach doQuery below and every + // doQuery call count these suites assert keeps counting them. + return withDbMixins({ + getLastBlock: sinon.stub().resolves(null), + getBlockHashRow: sinon.stub().resolves(null), + getBlockScopedRows: sinon.stub().resolves([]), + getTxScopedRows: sinon.stub().resolves([]), + getActionScopedRows: sinon.stub().resolves([]), + getEmissionRowsForBlock: sinon.stub().resolves([]), + getTransactions: sinon.stub().resolves([]), + getActions: sinon.stub().resolves([]), + getStatusId: sinon.stub().resolves(null), + doQuery: sinon.stub().resolves([]), + beginReadSnapshot: sinon.stub().resolves({ mockSnapshotConn: true }), + commitReadSnapshot: sinon.stub().resolves(), + rollbackReadSnapshot: sinon.stub().resolves() + }); +} + +function registerHooks(setContext){ + beforeEach(function(){ + let db = createMockDb(); + let broadcaster = { broadcast: sinon.stub(), updateStatus: sinon.stub(), getSubscribers: sinon.stub().returns([]), getSubscriberCount: sinon.stub().returns(0) }; + let log = { recordBlock: sinon.stub().resolves(), pruneFrom: sinon.stub().resolves() }; + let poller = new ServerPoller('bitcoin', 'mainnet', db, broadcaster, log, { BLOCK_POLL_INTERVAL: 100 }, new Utility()); + db.getBlockHashRow.callsFake(async (idx) => ({ + block_index: idx, block_time: idx * 10, + ledger_hash: 'l', actions_hash: 'a', contract_hash: 'c' + })); + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + setContext({ poller, db, broadcaster }); + }); + afterEach(function(){ sinon.restore(); }); +} + +module.exports = { registerHooks }; From 3e11843780b3500e37c7e49bab1df16e5e1d9ea3 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:10:57 -0700 Subject: [PATCH 31/62] test: split transparency pagination boundary suite by behavior --- .../unit/boundaries/transparency_page.test.js | 95 +------------------ .../02_limit_lower.test.js | 49 ++++++++++ .../03_limit_upper.test.js | 49 ++++++++++ .../transparency_page.test/04_offset.test.js | 33 +++++++ .../helpers/transparency_page_suite.js | 29 ++++++ 5 files changed, 162 insertions(+), 93 deletions(-) create mode 100644 test/unit/boundaries/transparency_page.test/02_limit_lower.test.js create mode 100644 test/unit/boundaries/transparency_page.test/03_limit_upper.test.js create mode 100644 test/unit/boundaries/transparency_page.test/04_offset.test.js create mode 100644 test/unit/boundaries/transparency_page.test/helpers/transparency_page_suite.js diff --git a/test/unit/boundaries/transparency_page.test.js b/test/unit/boundaries/transparency_page.test.js index 7b9eb222..89285d79 100644 --- a/test/unit/boundaries/transparency_page.test.js +++ b/test/unit/boundaries/transparency_page.test.js @@ -9,24 +9,11 @@ // contact legal@dankest.llc. const assert = require('assert'); -const sinon = require('sinon'); const TransparencyLog = require('../../../src/server/transparency_log'); -const { withDbMixins } = require('../../helpers/db_mixins.js'); - -function createMockDb(total){ - return withDbMixins({ - doQuery: sinon.stub().callsFake(async (query, args) => { - if(query.includes('COUNT')) - return [{ total: total || 0 }]; - return []; - }) - }); -} +const { createMockDb, registerHooks } = require('./transparency_page.test/helpers/transparency_page_suite'); describe('Boundary: Transparency Log Pagination', function(){ - - afterEach(function(){ sinon.restore(); }); - + registerHooks(); describe('page parameter', function(){ it('page=0: offset is 0', async function(){ let log = new TransparencyLog(createMockDb(100)); @@ -80,82 +67,4 @@ describe('Boundary: Transparency Log Pagination', function(){ assert.strictEqual(result.page, 999999); }); }); - - describe('limit parameter', function(){ - it('limit=1: minimum valid', async function(){ - let log = new TransparencyLog(createMockDb(100)); - let result = await log.getPage(0, 1); - assert.strictEqual(result.limit, 1); - }); - - it('limit=0: falls back to 100 (0 is falsy)', async function(){ - let log = new TransparencyLog(createMockDb(100)); - let result = await log.getPage(0, 0); - assert.strictEqual(result.limit, 100); - }); - - it('limit=-1: clamped to 1', async function(){ - let log = new TransparencyLog(createMockDb(100)); - let result = await log.getPage(0, -1); - assert.strictEqual(result.limit, 1); - }); - - it('limit=100: default value', async function(){ - let log = new TransparencyLog(createMockDb(100)); - let result = await log.getPage(0, 100); - assert.strictEqual(result.limit, 100); - }); - - it('limit=999: just below max', async function(){ - let log = new TransparencyLog(createMockDb(100)); - let result = await log.getPage(0, 999); - assert.strictEqual(result.limit, 999); - }); - - it('limit=1000: at max', async function(){ - let log = new TransparencyLog(createMockDb(100)); - let result = await log.getPage(0, 1000); - assert.strictEqual(result.limit, 1000); - }); - - it('limit=1001: clamped to 1000', async function(){ - let log = new TransparencyLog(createMockDb(100)); - let result = await log.getPage(0, 1001); - assert.strictEqual(result.limit, 1000); - }); - - it('limit=999999: clamped to 1000', async function(){ - let log = new TransparencyLog(createMockDb(100)); - let result = await log.getPage(0, 999999); - assert.strictEqual(result.limit, 1000); - }); - - it('limit="abc": defaults to 100', async function(){ - let log = new TransparencyLog(createMockDb(100)); - let result = await log.getPage(0, 'abc'); - assert.strictEqual(result.limit, 100); - }); - - it('limit=undefined: defaults to 100', async function(){ - let log = new TransparencyLog(createMockDb(100)); - let result = await log.getPage(0); - assert.strictEqual(result.limit, 100); - }); - }); - - describe('offset calculation', function(){ - it('page=0, limit=10: offset=0', async function(){ - let log = new TransparencyLog(createMockDb(100)); - await log.getPage(0, 10); - let selectCall = log.db.doQuery.getCalls().find(c => c.args[0].includes('LIMIT')); - assert.strictEqual(selectCall.args[1][1], 0); - }); - - it('page=5, limit=20: offset=100', async function(){ - let log = new TransparencyLog(createMockDb(1000)); - await log.getPage(5, 20); - let selectCall = log.db.doQuery.getCalls().find(c => c.args[0].includes('LIMIT')); - assert.strictEqual(selectCall.args[1][1], 100); - }); - }); }); diff --git a/test/unit/boundaries/transparency_page.test/02_limit_lower.test.js b/test/unit/boundaries/transparency_page.test/02_limit_lower.test.js new file mode 100644 index 00000000..5f1376aa --- /dev/null +++ b/test/unit/boundaries/transparency_page.test/02_limit_lower.test.js @@ -0,0 +1,49 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers lower page limits. One part of transparency_page.test.js. +const assert = require('assert'); +const TransparencyLog = require('../../../../src/server/transparency_log'); +const { createMockDb, registerHooks } = require('./helpers/transparency_page_suite'); + +describe('Boundary: Transparency Log Pagination', function(){ + registerHooks(); + describe('limit parameter', function(){ + it('limit=1: minimum valid', async function(){ + let log = new TransparencyLog(createMockDb(100)); + let result = await log.getPage(0, 1); + assert.strictEqual(result.limit, 1); + }); + + it('limit=0: falls back to 100 (0 is falsy)', async function(){ + let log = new TransparencyLog(createMockDb(100)); + let result = await log.getPage(0, 0); + assert.strictEqual(result.limit, 100); + }); + + it('limit=-1: clamped to 1', async function(){ + let log = new TransparencyLog(createMockDb(100)); + let result = await log.getPage(0, -1); + assert.strictEqual(result.limit, 1); + }); + + it('limit=100: default value', async function(){ + let log = new TransparencyLog(createMockDb(100)); + let result = await log.getPage(0, 100); + assert.strictEqual(result.limit, 100); + }); + + it('limit=999: just below max', async function(){ + let log = new TransparencyLog(createMockDb(100)); + let result = await log.getPage(0, 999); + assert.strictEqual(result.limit, 999); + }); + }); +}); diff --git a/test/unit/boundaries/transparency_page.test/03_limit_upper.test.js b/test/unit/boundaries/transparency_page.test/03_limit_upper.test.js new file mode 100644 index 00000000..e233528f --- /dev/null +++ b/test/unit/boundaries/transparency_page.test/03_limit_upper.test.js @@ -0,0 +1,49 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers upper page limits. One part of transparency_page.test.js. +const assert = require('assert'); +const TransparencyLog = require('../../../../src/server/transparency_log'); +const { createMockDb, registerHooks } = require('./helpers/transparency_page_suite'); + +describe('Boundary: Transparency Log Pagination', function(){ + registerHooks(); + describe('limit parameter', function(){ + it('limit=1000: at max', async function(){ + let log = new TransparencyLog(createMockDb(100)); + let result = await log.getPage(0, 1000); + assert.strictEqual(result.limit, 1000); + }); + + it('limit=1001: clamped to 1000', async function(){ + let log = new TransparencyLog(createMockDb(100)); + let result = await log.getPage(0, 1001); + assert.strictEqual(result.limit, 1000); + }); + + it('limit=999999: clamped to 1000', async function(){ + let log = new TransparencyLog(createMockDb(100)); + let result = await log.getPage(0, 999999); + assert.strictEqual(result.limit, 1000); + }); + + it('limit="abc": defaults to 100', async function(){ + let log = new TransparencyLog(createMockDb(100)); + let result = await log.getPage(0, 'abc'); + assert.strictEqual(result.limit, 100); + }); + + it('limit=undefined: defaults to 100', async function(){ + let log = new TransparencyLog(createMockDb(100)); + let result = await log.getPage(0); + assert.strictEqual(result.limit, 100); + }); + }); +}); diff --git a/test/unit/boundaries/transparency_page.test/04_offset.test.js b/test/unit/boundaries/transparency_page.test/04_offset.test.js new file mode 100644 index 00000000..4d44e382 --- /dev/null +++ b/test/unit/boundaries/transparency_page.test/04_offset.test.js @@ -0,0 +1,33 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers offset calculations. One part of transparency_page.test.js. +const assert = require('assert'); +const TransparencyLog = require('../../../../src/server/transparency_log'); +const { createMockDb, registerHooks } = require('./helpers/transparency_page_suite'); + +describe('Boundary: Transparency Log Pagination', function(){ + registerHooks(); + describe('offset calculation', function(){ + it('page=0, limit=10: offset=0', async function(){ + let log = new TransparencyLog(createMockDb(100)); + await log.getPage(0, 10); + let selectCall = log.db.doQuery.getCalls().find(c => c.args[0].includes('LIMIT')); + assert.strictEqual(selectCall.args[1][1], 0); + }); + + it('page=5, limit=20: offset=100', async function(){ + let log = new TransparencyLog(createMockDb(1000)); + await log.getPage(5, 20); + let selectCall = log.db.doQuery.getCalls().find(c => c.args[0].includes('LIMIT')); + assert.strictEqual(selectCall.args[1][1], 100); + }); + }); +}); diff --git a/test/unit/boundaries/transparency_page.test/helpers/transparency_page_suite.js b/test/unit/boundaries/transparency_page.test/helpers/transparency_page_suite.js new file mode 100644 index 00000000..cb9913c3 --- /dev/null +++ b/test/unit/boundaries/transparency_page.test/helpers/transparency_page_suite.js @@ -0,0 +1,29 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers pagination fixtures and hooks. One part of transparency_page.test.js. +const sinon = require('sinon'); +const { withDbMixins } = require('../../../../helpers/db_mixins.js'); + +function createMockDb(total){ + return withDbMixins({ + doQuery: sinon.stub().callsFake(async (query, args) => { + if(query.includes('COUNT')) + return [{ total: total || 0 }]; + return []; + }) + }); +} + +function registerHooks(){ + afterEach(function(){ sinon.restore(); }); +} + +module.exports = { createMockDb, registerHooks }; From 3de1f46cea2b28558b85cb1467ad88a88d0f6cbf Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:11:02 -0700 Subject: [PATCH 32/62] test: split websocket limits boundary suite by behavior --- test/unit/boundaries/websocket_limits.test.js | 118 +----------------- .../02_single_connection.test.js | 34 +++++ .../03_backpressure_drop.test.js | 62 +++++++++ .../04_backpressure_recovery.test.js | 68 ++++++++++ .../helpers/websocket_limits_suite.js | 33 +++++ 5 files changed, 199 insertions(+), 116 deletions(-) create mode 100644 test/unit/boundaries/websocket_limits.test/02_single_connection.test.js create mode 100644 test/unit/boundaries/websocket_limits.test/03_backpressure_drop.test.js create mode 100644 test/unit/boundaries/websocket_limits.test/04_backpressure_recovery.test.js create mode 100644 test/unit/boundaries/websocket_limits.test/helpers/websocket_limits_suite.js diff --git a/test/unit/boundaries/websocket_limits.test.js b/test/unit/boundaries/websocket_limits.test.js index 84ab3aa3..b593ce0a 100644 --- a/test/unit/boundaries/websocket_limits.test.js +++ b/test/unit/boundaries/websocket_limits.test.js @@ -10,28 +10,12 @@ const assert = require('assert'); const sinon = require('sinon'); -const WebSocket = require('ws'); const BlockBroadcaster = require('../../../src/server/block_broadcaster'); - -function mockWs(){ - return { - readyState: WebSocket.OPEN, - bufferedAmount: 0, - _syncBuffered: 0, - _syncChain: null, _syncNetwork: null, _syncIp: null, - send: sinon.stub(), close: sinon.stub(), on: sinon.stub() - }; -} - -function mockReq(ip){ - return { headers: {}, socket: { remoteAddress: ip || '127.0.0.1' } }; -} +const { mockWs, mockReq, registerHooks } = require('./websocket_limits.test/helpers/websocket_limits_suite'); describe('Boundary: WebSocket Limits', function(){ - let broadcaster; - - afterEach(function(){ sinon.restore(); }); + registerHooks(); describe('per-IP connection limit', function(){ beforeEach(function(){ @@ -83,102 +67,4 @@ describe('Boundary: WebSocket Limits', function(){ assert.strictEqual(broadcaster.getSubscriberCount('bitcoin', 'mainnet'), 6); }); }); - - describe('per-IP limit = 1', function(){ - beforeEach(function(){ - broadcaster = new BlockBroadcaster({ WS_MAX_PER_IP: 1, WS_BACKPRESSURE_LIMIT: 50 }); - sinon.stub(console, 'log'); - }); - - it('accepts first, rejects second', function(){ - let ip = '6.6.6.6'; - assert.strictEqual(broadcaster.addSubscription(mockWs(), mockReq(ip), 'b', 'm'), true); - let ws2 = mockWs(); - assert.strictEqual(broadcaster.addSubscription(ws2, mockReq(ip), 'b', 'm'), false); - assert.strictEqual(ws2.close.calledOnce, true); - }); - }); - - describe('backpressure (item 5410: drop only genuinely stalled peers)', function(){ - const MAX_BYTES = 1000; - const STALL_MS = 30000; - beforeEach(function(){ - broadcaster = new BlockBroadcaster({ WS_MAX_PER_IP: 10, WS_BACKPRESSURE_MAX_BYTES: MAX_BYTES, WS_BACKPRESSURE_STALL_MS: STALL_MS }); - sinon.stub(console, 'log'); - }); - - it('slow-but-draining peer is NEVER dropped, across many buffered sends (the 5410 regression)', function(){ - let ws = mockWs(); - ws._syncIp = 'test'; - // Buffer trends DOWN each send (peer is draining) but stays > 0: must survive. - for(let b of [900, 800, 700, 600, 500, 400, 300, 200, 100, 50]){ - ws.bufferedAmount = b; - broadcaster.send(ws, 'msg'); - } - assert.strictEqual(ws.close.called, false); - assert.strictEqual(ws._syncBackpressureSince, null); // downward progress kept resetting it - assert.strictEqual(ws.send.callCount, 10); - }); - - it('drops a peer whose buffer exceeds the byte ceiling', function(){ - let ws = mockWs(); - ws._syncIp = 'test'; - ws.bufferedAmount = MAX_BYTES + 1; - broadcaster.send(ws, 'msg'); - assert.strictEqual(ws.close.calledOnce, true); - assert.strictEqual(ws.close.firstCall.args[0], 1008); - assert.strictEqual(ws.send.called, false); - }); - - it('drops a peer whose buffer is non-draining past the stall window', function(){ - let ws = mockWs(); - ws._syncIp = 'test'; - ws.bufferedAmount = 100; // below ceiling, non-empty - ws._syncLastBuffered = 100; // flat: no downward progress - ws._syncBackpressureSince = Date.now() - (STALL_MS + 1); // window already elapsed - broadcaster.send(ws, 'msg'); - assert.strictEqual(ws.close.calledOnce, true); - assert.strictEqual(ws.close.firstCall.args[0], 1008); - }); - - it('does NOT drop a stalled-then-recovered peer (drain resets the window)', function(){ - let ws = mockWs(); - ws._syncIp = 'test'; - ws.bufferedAmount = 100; - ws._syncLastBuffered = 200; // drained 200 -> 100: progress - ws._syncBackpressureSince = Date.now() - (STALL_MS + 1); // stale window, must be cleared - broadcaster.send(ws, 'msg'); - assert.strictEqual(ws.close.called, false); - assert.strictEqual(ws._syncBackpressureSince, null); - assert.strictEqual(ws.send.calledOnce, true); - }); - - it('a fully-drained buffer keeps the peer healthy and clears any stall window', function(){ - let ws = mockWs(); - ws._syncIp = 'test'; - ws.bufferedAmount = 0; - ws._syncBackpressureSince = Date.now() - (STALL_MS + 1); - broadcaster.send(ws, 'msg'); - assert.strictEqual(ws.close.called, false); - assert.strictEqual(ws._syncBackpressureSince, null); - assert.strictEqual(ws.send.calledOnce, true); - }); - - it('arms but does not trip the stall window on the first non-draining send', function(){ - let ws = mockWs(); - ws._syncIp = 'test'; - ws.bufferedAmount = 100; - broadcaster.send(ws, 'msg'); - assert.strictEqual(ws.close.called, false); - assert.notStrictEqual(ws._syncBackpressureSince, null); // window armed for next time - assert.strictEqual(ws.send.calledOnce, true); - }); - - it('skips closed WebSocket', function(){ - let ws = mockWs(); - ws.readyState = WebSocket.CLOSED; - broadcaster.send(ws, 'msg'); - assert.strictEqual(ws.send.called, false); - }); - }); }); diff --git a/test/unit/boundaries/websocket_limits.test/02_single_connection.test.js b/test/unit/boundaries/websocket_limits.test/02_single_connection.test.js new file mode 100644 index 00000000..cfbd2f86 --- /dev/null +++ b/test/unit/boundaries/websocket_limits.test/02_single_connection.test.js @@ -0,0 +1,34 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers a single allowed connection. One part of websocket_limits.test.js. +const assert = require('assert'); +const sinon = require('sinon'); +const BlockBroadcaster = require('../../../../src/server/block_broadcaster'); +const { mockWs, mockReq, registerHooks } = require('./helpers/websocket_limits_suite'); + +describe('Boundary: WebSocket Limits', function(){ + let broadcaster; + registerHooks(); + describe('per-IP limit = 1', function(){ + beforeEach(function(){ + broadcaster = new BlockBroadcaster({ WS_MAX_PER_IP: 1, WS_BACKPRESSURE_LIMIT: 50 }); + sinon.stub(console, 'log'); + }); + + it('accepts first, rejects second', function(){ + let ip = '6.6.6.6'; + assert.strictEqual(broadcaster.addSubscription(mockWs(), mockReq(ip), 'b', 'm'), true); + let ws2 = mockWs(); + assert.strictEqual(broadcaster.addSubscription(ws2, mockReq(ip), 'b', 'm'), false); + assert.strictEqual(ws2.close.calledOnce, true); + }); + }); +}); diff --git a/test/unit/boundaries/websocket_limits.test/03_backpressure_drop.test.js b/test/unit/boundaries/websocket_limits.test/03_backpressure_drop.test.js new file mode 100644 index 00000000..135db100 --- /dev/null +++ b/test/unit/boundaries/websocket_limits.test/03_backpressure_drop.test.js @@ -0,0 +1,62 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers backpressure drop boundaries. One part of websocket_limits.test.js. +const assert = require('assert'); +const sinon = require('sinon'); +const BlockBroadcaster = require('../../../../src/server/block_broadcaster'); +const { mockWs, registerHooks } = require('./helpers/websocket_limits_suite'); + +describe('Boundary: WebSocket Limits', function(){ + let broadcaster; + registerHooks(); + describe('backpressure (item 5410: drop only genuinely stalled peers)', function(){ + const MAX_BYTES = 1000; + const STALL_MS = 30000; + beforeEach(function(){ + broadcaster = new BlockBroadcaster({ WS_MAX_PER_IP: 10, WS_BACKPRESSURE_MAX_BYTES: MAX_BYTES, WS_BACKPRESSURE_STALL_MS: STALL_MS }); + sinon.stub(console, 'log'); + }); + + it('slow-but-draining peer is NEVER dropped, across many buffered sends (the 5410 regression)', function(){ + let ws = mockWs(); + ws._syncIp = 'test'; + // Buffer trends DOWN each send (peer is draining) but stays > 0: must survive. + for(let b of [900, 800, 700, 600, 500, 400, 300, 200, 100, 50]){ + ws.bufferedAmount = b; + broadcaster.send(ws, 'msg'); + } + assert.strictEqual(ws.close.called, false); + assert.strictEqual(ws._syncBackpressureSince, null); // downward progress kept resetting it + assert.strictEqual(ws.send.callCount, 10); + }); + + it('drops a peer whose buffer exceeds the byte ceiling', function(){ + let ws = mockWs(); + ws._syncIp = 'test'; + ws.bufferedAmount = MAX_BYTES + 1; + broadcaster.send(ws, 'msg'); + assert.strictEqual(ws.close.calledOnce, true); + assert.strictEqual(ws.close.firstCall.args[0], 1008); + assert.strictEqual(ws.send.called, false); + }); + + it('drops a peer whose buffer is non-draining past the stall window', function(){ + let ws = mockWs(); + ws._syncIp = 'test'; + ws.bufferedAmount = 100; // below ceiling, non-empty + ws._syncLastBuffered = 100; // flat: no downward progress + ws._syncBackpressureSince = Date.now() - (STALL_MS + 1); // window already elapsed + broadcaster.send(ws, 'msg'); + assert.strictEqual(ws.close.calledOnce, true); + assert.strictEqual(ws.close.firstCall.args[0], 1008); + }); + }); +}); diff --git a/test/unit/boundaries/websocket_limits.test/04_backpressure_recovery.test.js b/test/unit/boundaries/websocket_limits.test/04_backpressure_recovery.test.js new file mode 100644 index 00000000..39702b5f --- /dev/null +++ b/test/unit/boundaries/websocket_limits.test/04_backpressure_recovery.test.js @@ -0,0 +1,68 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers backpressure recovery. One part of websocket_limits.test.js. +const assert = require('assert'); +const sinon = require('sinon'); +const BlockBroadcaster = require('../../../../src/server/block_broadcaster'); +const { WebSocket, mockWs, registerHooks } = require('./helpers/websocket_limits_suite'); + +describe('Boundary: WebSocket Limits', function(){ + let broadcaster; + registerHooks(); + describe('backpressure (item 5410: drop only genuinely stalled peers)', function(){ + const MAX_BYTES = 1000; + const STALL_MS = 30000; + beforeEach(function(){ + broadcaster = new BlockBroadcaster({ WS_MAX_PER_IP: 10, WS_BACKPRESSURE_MAX_BYTES: MAX_BYTES, WS_BACKPRESSURE_STALL_MS: STALL_MS }); + sinon.stub(console, 'log'); + }); + + it('does NOT drop a stalled-then-recovered peer (drain resets the window)', function(){ + let ws = mockWs(); + ws._syncIp = 'test'; + ws.bufferedAmount = 100; + ws._syncLastBuffered = 200; // drained 200 -> 100: progress + ws._syncBackpressureSince = Date.now() - (STALL_MS + 1); // stale window, must be cleared + broadcaster.send(ws, 'msg'); + assert.strictEqual(ws.close.called, false); + assert.strictEqual(ws._syncBackpressureSince, null); + assert.strictEqual(ws.send.calledOnce, true); + }); + + it('a fully-drained buffer keeps the peer healthy and clears any stall window', function(){ + let ws = mockWs(); + ws._syncIp = 'test'; + ws.bufferedAmount = 0; + ws._syncBackpressureSince = Date.now() - (STALL_MS + 1); + broadcaster.send(ws, 'msg'); + assert.strictEqual(ws.close.called, false); + assert.strictEqual(ws._syncBackpressureSince, null); + assert.strictEqual(ws.send.calledOnce, true); + }); + + it('arms but does not trip the stall window on the first non-draining send', function(){ + let ws = mockWs(); + ws._syncIp = 'test'; + ws.bufferedAmount = 100; + broadcaster.send(ws, 'msg'); + assert.strictEqual(ws.close.called, false); + assert.notStrictEqual(ws._syncBackpressureSince, null); // window armed for next time + assert.strictEqual(ws.send.calledOnce, true); + }); + + it('skips closed WebSocket', function(){ + let ws = mockWs(); + ws.readyState = WebSocket.CLOSED; + broadcaster.send(ws, 'msg'); + assert.strictEqual(ws.send.called, false); + }); + }); +}); diff --git a/test/unit/boundaries/websocket_limits.test/helpers/websocket_limits_suite.js b/test/unit/boundaries/websocket_limits.test/helpers/websocket_limits_suite.js new file mode 100644 index 00000000..7b3c6681 --- /dev/null +++ b/test/unit/boundaries/websocket_limits.test/helpers/websocket_limits_suite.js @@ -0,0 +1,33 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers socket fixtures and hooks. One part of websocket_limits.test.js. +const sinon = require('sinon'); +const WebSocket = require('ws'); + +function mockWs(){ + return { + readyState: WebSocket.OPEN, + bufferedAmount: 0, + _syncBuffered: 0, + _syncChain: null, _syncNetwork: null, _syncIp: null, + send: sinon.stub(), close: sinon.stub(), on: sinon.stub() + }; +} + +function mockReq(ip){ + return { headers: {}, socket: { remoteAddress: ip || '127.0.0.1' } }; +} + +function registerHooks(){ + afterEach(function(){ sinon.restore(); }); +} + +module.exports = { WebSocket, mockWs, mockReq, registerHooks }; From 4aed5fd9f23140c4f526c1be1c2999501204d358 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 08:15:46 -0700 Subject: [PATCH 33/62] refactor(db): move the applier and updated-rows SQL into db mixins ClientApplier and the updated-rows collector now call named methods on the tables, credits, index_lookups, state_tree_roots and validator_rewards mixins, each carrying its statement verbatim, so neither file builds SQL any more. Unit fakes install the real mixins over their doQuery stub, and the rollback coverage guards read the collector together with the mixin it calls. --- src/client/applier.js | 86 ++------ src/db/credits.js | 27 +++ src/db/index_lookups.js | 22 ++ src/db/state_tree_roots.js | 15 ++ src/db/tables.js | 293 ++++++++++++++++++++++++++ src/db/validator_rewards.js | 20 ++ src/server/updated_rows.js | 98 ++++----- src/server/updated_rows/token_rows.js | 12 +- test/unit/rollback_coverage.test.js | 27 ++- 9 files changed, 464 insertions(+), 136 deletions(-) diff --git a/src/client/applier.js b/src/client/applier.js index ae8d1e41..591bfdd4 100644 --- a/src/client/applier.js +++ b/src/client/applier.js @@ -184,7 +184,7 @@ class ClientApplier { // // Nothing joins `blocks.id`: the source's own createBlock INSERTs without it, // the other *_hash_id columns point into index_transactions, and the client - // cursor is `SELECT MAX(block_index)` (db.js getLastBlock), never an id. + // cursor is the highest block_index (db getLastBlock), never an id. this.localSurrogateIdTables = new Map([ ['blocks', 'block_index'] ]); @@ -384,10 +384,8 @@ class ClientApplier { if(!pairs.size) return []; let aIn = Array.from(addrIds); let tIn = Array.from(tickIds); - let addrRows = await this.db.doQuery( - 'SELECT id, address FROM index_addresses WHERE id IN (' + aIn.map(() => '?').join(',') + ')', aIn); - let tickRows = await this.db.doQuery( - 'SELECT id, tick FROM index_tickers WHERE id IN (' + tIn.map(() => '?').join(',') + ')', tIn); + let addrRows = await this.db.findIndexAddressTextByIds(aIn); + let tickRows = await this.db.findIndexTickTextByIds(tIn); let addrMap = new Map(); for(let r of addrRows) addrMap.set(String(r.id), r.address); let tickMap = new Map(); for(let r of tickRows) tickMap.set(String(r.id), r.tick); let out = []; @@ -455,10 +453,7 @@ class ClientApplier { // the narrow catch at the escrow-gate rederive below. let localTables = []; try { - let schemaRows = await this.db.doQuery( - "SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE'", - [this.db.dbName] - ); + let schemaRows = await this.db.findStreamableTableNames(); localTables = (schemaRows || []) .map(r => r.table_name || r.TABLE_NAME) .filter(t => t && !OPERATOR_LOCAL_TABLES.has(t)); @@ -493,7 +488,7 @@ class ClientApplier { logger.error('Skipping clear of invalid table: ' + tables[i]); continue; } - await this.db.doQuery('DELETE FROM `' + tables[i] + '`'); + await this.db.deleteAllRows(tables[i]); } for(let table of tables){ @@ -524,9 +519,7 @@ class ClientApplier { // the hub's full lowercase coin name into the constructor), so the full name // matches zero rows and the cleanup silently no-ops on every production chain. try { - await this.db.doQuery( - 'DELETE FROM state_tree_roots WHERE chain = ? AND network = ? AND block_index >= ?', - [this.coinTicker, this.network, snapshotData.block_height]); + await this.db.deleteStateTreeRootsFromBlock(this.coinTicker, this.network, snapshotData.block_height); } catch(e){ if(e.errno !== 1146 && e.errno !== 1054) throw e; } @@ -622,7 +615,7 @@ class ClientApplier { if(!Array.isArray(rows)) return; await this.db.beginTransaction(); try { - await this.db.doQuery('DELETE FROM `dispensers`'); + await this.db.deleteAllDispensers(); if(rows.length) await this.insertRows('dispensers', rows); await this.db.commitTransaction(); } catch(e){ @@ -699,10 +692,7 @@ class ClientApplier { let deleteBatch = 500; for(let i = 0; i < keyValues.length; i += deleteBatch){ let slice = keyValues.slice(i, i + deleteBatch); - await this.db.doQuery( - 'DELETE FROM `' + table + '` WHERE `' + naturalKey + '` IN (' + - slice.map(() => '?').join(', ') + ')', - slice); + await this.db.deleteRowsByKeyValues(table, naturalKey, slice); } } @@ -714,27 +704,15 @@ class ClientApplier { throw new Error('Rejected column name in insertRows: ' + col + ' (' + colCheck.reason + ')'); } } - let colList = columns.map(c => '`' + c + '`').join(', '); - let placeholders = columns.map(() => '?').join(', '); - - let insertPrefix = useIgnore - ? 'INSERT IGNORE INTO `' + table + '` (' + colList + ') VALUES ' - : 'INSERT INTO `' + table + '` (' + colList + ') VALUES '; - // Mutable-aggregate full-dump tables overwrite their existing row so a - // re-dump on a non-empty replica refreshes (not skips) stale values. - let updateSuffix = useUpsert - ? ' ON DUPLICATE KEY UPDATE ' + columns.map(c => '`' + c + '` = VALUES(`' + c + '`)').join(', ') - : ''; - + // Mutable-aggregate full-dump tables (useUpsert) overwrite their existing row so + // a re-dump on a non-empty replica refreshes (not skips) stale values. // Batch inserts in groups of 100 for efficiency let batchSize = 100; for(let i = 0; i < rows.length; i += batchSize){ let batch = rows.slice(i, i + batchSize); - let valueClauses = []; let args = []; for(let row of batch){ - valueClauses.push('(' + placeholders + ')'); for(let col of columns){ // decodeValue restores base64 binary sentinels back to Buffers // before insert (the inverse of SnapshotBuilder/BlockBroadcaster @@ -743,8 +721,7 @@ class ClientApplier { } } - let query = insertPrefix + valueClauses.join(', ') + updateSuffix; - await this.db.doQuery(query, args); + await this.db.insertRowValues(table, columns, batch.length, args, useIgnore, useUpsert); // 5284: events rows >64KB silently truncate on a still-TEXT (pre-migration) // replica when INSERT IGNORE is used: the id collision guard skips the row @@ -798,7 +775,7 @@ class ClientApplier { if(suspect.length){ let retired = await this.retireStaleNaturalKeyRows(table, batch, rows, suspect); if(retired.length){ - await this.db.doQuery(query, args); + await this.db.insertRowValues(table, columns, batch.length, args, useIgnore, useUpsert); suspect = this.suspectIgnoreWarnings(await this.db.doQuery('SHOW WARNINGS')); } for(let w of suspect){ @@ -868,22 +845,19 @@ class ClientApplier { for(let row of batch){ let id = Number(row.id); - let mine = await this.db.doQuery('SELECT id FROM `' + table + '` WHERE id = ? LIMIT 1', [id]); + let mine = await this.db.findRowIdById(table, id); if(mine && mine.length) continue; // the source's row is already here let values = keyColumns.map(c => row[c]); if(values.some(v => v === undefined)) continue; - let holder = await this.db.doQuery( - 'SELECT id FROM `' + table + '` WHERE ' + - keyColumns.map(c => '`' + c + '` = ?').join(' AND ') + ' LIMIT 2', - values); + let holder = await this.db.findRowIdsByKeyColumns(table, keyColumns, values); if(!holder || holder.length !== 1) continue; // absent, or ambiguous: not this shape let holderId = Number(holder[0].id); if(holderId === id || carried.has(holderId)) continue; if(holderId < low || holderId > high) continue; - await this.db.doQuery('DELETE FROM `' + table + '` WHERE id = ?', [holderId]); + await this.db.deleteRowById(table, holderId); retired.push(holderId); logger.warn('STALE_LOOKUP_GENERATION_RETIRED table=' + table + ' key=' + indexName + ' natural_key=' + JSON.stringify(keyColumns.map((c, i) => c + '=' + values[i]).join(',')) + @@ -899,10 +873,7 @@ class ClientApplier { async uniqueKeyColumns(table, indexName){ let check = validation.validateIdentifier(indexName); if(!check.valid) return []; - let rows = await this.db.doQuery( - "SELECT column_name FROM information_schema.statistics " + - "WHERE table_schema = ? AND table_name = ? AND index_name = ? ORDER BY seq_in_index ASC", - [this.db.dbName, table, indexName]); + let rows = await this.db.findIndexColumnNames(table, indexName); let columns = []; for(let r of (rows || [])){ let name = String(r.column_name || r.COLUMN_NAME || ''); @@ -939,14 +910,7 @@ class ClientApplier { // (d.block_index = B live; d.block_index >= since on an incremental catch-up). async mirrorAnchorRewardReconcile(scopeSql, scopeArgs){ try { - await this.db.doQuery( - "DELETE vr FROM validator_rewards vr " + - "JOIN anchor_reward_reconcile_log d " + - " ON d.source_id = vr.source_id AND d.signing_pubkey_id = vr.signing_pubkey_id " + - " AND d.reward_type = vr.reward_type AND d.round_reference <=> vr.round_reference " + - " AND d.round_qualifier = vr.round_qualifier " + - "WHERE " + scopeSql, - scopeArgs); + await this.db.deleteReconciledValidatorRewards(scopeSql, scopeArgs); } catch(e){ // Schema-gap errors (log table / columns absent on an older replica) are safe // to skip: such a replica received no log rows either. Anything else must @@ -1013,27 +977,17 @@ class ClientApplier { throw new Error('Rejected column name in upsertRows: ' + col + ' (' + colCheck.reason + ')'); } } - let colList = columns.map(c => '`' + c + '`').join(', '); - let placeholders = columns.map(() => '?').join(', '); - // VALUES(col) back-reference is the MariaDB idiom for "the value this row - // would have inserted"; updating the key column to itself is a harmless no-op. - let updateList = columns.map(c => '`' + c + '` = VALUES(`' + c + '`)').join(', '); - - let insertPrefix = 'INSERT INTO `' + table + '` (' + colList + ') VALUES '; - let updateSuffix = ' ON DUPLICATE KEY UPDATE ' + updateList; - + // A plain INSERT with the ON DUPLICATE KEY UPDATE suffix: every carried column + // is written on both insert and update. let batchSize = 100; for(let i = 0; i < rows.length; i += batchSize){ let batch = rows.slice(i, i + batchSize); - let valueClauses = []; let args = []; for(let row of batch){ - valueClauses.push('(' + placeholders + ')'); for(let col of columns) args.push(decodeValue(row[col] !== undefined ? row[col] : null)); } - let query = insertPrefix + valueClauses.join(', ') + updateSuffix; - await this.db.doQuery(query, args); + await this.db.insertRowValues(table, columns, batch.length, args, false, true); } } diff --git a/src/db/credits.js b/src/db/credits.js index f4070728..b882469b 100644 --- a/src/db/credits.js +++ b/src/db/credits.js @@ -65,4 +65,31 @@ module.exports = { [completedStatusId, from, to], conn); }, + /** + * The current tokens row of every tick a credit, debit or escrow touched in + * this window, which is exactly the set whose supply may have moved. Each + * ledger table joins actions on its own and the tick ids are UNIONed, so the + * block-range predicate drives from actions into each table's index instead of + * materializing all three ledgers first. UNION, not UNION ALL, keeps the tick + * set distinct. + * + * @param {number} from first block of the window, inclusive + * @param {number} to last block of the window, inclusive + * @param {object} [conn] a connection to read on, when the caller holds one + * @returns {Promise} the driver's row array + */ + async findLedgerTouchedTokens(from, to, conn){ + return await this.doQuery( + "SELECT t.* FROM `tokens` t WHERE t.tick_id IN (" + + "SELECT c.tick_id FROM credits c JOIN actions a ON a.action_index = c.action_index " + + "WHERE a.block_index BETWEEN ? AND ? AND c.tick_id IS NOT NULL " + + "UNION " + + "SELECT d.tick_id FROM debits d JOIN actions a ON a.action_index = d.action_index " + + "WHERE a.block_index BETWEEN ? AND ? AND d.tick_id IS NOT NULL " + + "UNION " + + "SELECT e.tick_id FROM escrows e JOIN actions a ON a.action_index = e.action_index " + + "WHERE a.block_index BETWEEN ? AND ? AND e.tick_id IS NOT NULL)", + [from, to, from, to, from, to], conn); + }, + }; diff --git a/src/db/index_lookups.js b/src/db/index_lookups.js index 17cca073..aa77fe04 100644 --- a/src/db/index_lookups.js +++ b/src/db/index_lookups.js @@ -57,4 +57,26 @@ module.exports = { return await this.doQuery("SELECT * FROM pubkeys WHERE address_id IN (" + ids.map(() => '?').join(',') + ")", ids, conn); }, + /** + * The address string for each of a set of index_addresses ids. + * + * @param {Array} aIn distinct index_addresses ids, at least one + * @returns {Promise} the driver's row array, rows of { id, address } + */ + async findIndexAddressTextByIds(aIn){ + return await this.doQuery( + 'SELECT id, address FROM index_addresses WHERE id IN (' + aIn.map(() => '?').join(',') + ')', aIn); + }, + + /** + * The tick string for each of a set of index_tickers ids. + * + * @param {Array} tIn distinct index_tickers ids, at least one + * @returns {Promise} the driver's row array, rows of { id, tick } + */ + async findIndexTickTextByIds(tIn){ + return await this.doQuery( + 'SELECT id, tick FROM index_tickers WHERE id IN (' + tIn.map(() => '?').join(',') + ')', tIn); + }, + }; diff --git a/src/db/state_tree_roots.js b/src/db/state_tree_roots.js index fbab6036..690a1fe7 100644 --- a/src/db/state_tree_roots.js +++ b/src/db/state_tree_roots.js @@ -56,4 +56,19 @@ module.exports = { 'SELECT state_root, block_merkle_root FROM state_tree_roots WHERE block_index=? LIMIT 1', [blockIndex]); }, + /** + * Delete one chain's root rows at and above a height, the same predicate a + * rollback applies to this table. `chain` is the TICKER every writer stores. + * + * @param {string} chain the coin ticker + * @param {string} network + * @param {number} blockHeight first height to delete, inclusive + * @returns {Promise} the driver's result + */ + async deleteStateTreeRootsFromBlock(chain, network, blockHeight){ + return await this.doQuery( + 'DELETE FROM state_tree_roots WHERE chain = ? AND network = ? AND block_index >= ?', + [chain, network, blockHeight]); + }, + }; diff --git a/src/db/tables.js b/src/db/tables.js index c6e30318..6163f458 100644 --- a/src/db/tables.js +++ b/src/db/tables.js @@ -23,6 +23,7 @@ const path = require('path'); const lifecycle = require('../table_lifecycle'); const { assertValidIdentifier } = require('./shared.js'); +const { ARCHIVE_HEAD_VERSIONS_SQL, ARCHIVE_CHUNK_HEIGHT_COL } = require('../stateHash'); module.exports = { @@ -366,4 +367,296 @@ module.exports = { ); }, + /** + * Empty one table on the replica before a full snapshot re-imports it. DELETE + * rather than TRUNCATE, because MariaDB refuses TRUNCATE on a table a foreign + * key references. The caller validates the name first. + * + * @param {string} table + * @returns {Promise} the driver's result + */ + async deleteAllRows(table){ + return await this.doQuery('DELETE FROM `' + table + '`'); + }, + + /** + * Empty the decoder's dispensers table ahead of a reconcile re-insert, inside + * the caller's transaction. + * + * @returns {Promise} the driver's result + */ + async deleteAllDispensers(){ + return await this.doQuery('DELETE FROM `dispensers`'); + }, + + /** + * Clear every row already holding one of these natural-key values, so a + * re-sent row replaces rather than collides. Both names are interpolated + * because an identifier cannot be a bind parameter; the caller validates them. + * + * @param {string} table + * @param {string} naturalKey the column the values belong to + * @param {Array} slice the values, a bounded chunk of them + * @returns {Promise} the driver's result + */ + async deleteRowsByKeyValues(table, naturalKey, slice){ + return await this.doQuery( + 'DELETE FROM `' + table + '` WHERE `' + naturalKey + '` IN (' + + slice.map(() => '?').join(', ') + ')', + slice); + }, + + /** + * One multi-row INSERT of `rowCount` rows over `columns`, with `args` holding + * every row's values in column order. `useIgnore` skips a row whose key already + * exists; `useUpsert` overwrites the existing row with the carried values. The + * caller validates every identifier and decodes the values. + * + * @param {string} table + * @param {Array} columns + * @param {number} rowCount + * @param {Array} args + * @param {boolean} useIgnore + * @param {boolean} useUpsert + * @returns {Promise} the driver's result + */ + async insertRowValues(table, columns, rowCount, args, useIgnore, useUpsert){ + let colList = columns.map(c => '`' + c + '`').join(', '); + let placeholders = columns.map(() => '?').join(', '); + + let insertPrefix = useIgnore + ? 'INSERT IGNORE INTO `' + table + '` (' + colList + ') VALUES ' + : 'INSERT INTO `' + table + '` (' + colList + ') VALUES '; + // VALUES(col) back-reference is the MariaDB idiom for "the value this row + // would have inserted"; updating the key column to itself is a harmless no-op. + let updateSuffix = useUpsert + ? ' ON DUPLICATE KEY UPDATE ' + columns.map(c => '`' + c + '` = VALUES(`' + c + '`)').join(', ') + : ''; + + let valueClauses = []; + for(let i = 0; i < rowCount; i++) valueClauses.push('(' + placeholders + ')'); + + let query = insertPrefix + valueClauses.join(', ') + updateSuffix; + return await this.doQuery(query, args); + }, + + /** + * The row holding one surrogate id, if any. + * + * @param {string} table + * @param {number} id + * @returns {Promise} the driver's row array, at most one row + */ + async findRowIdById(table, id){ + return await this.doQuery('SELECT id FROM `' + table + '` WHERE id = ? LIMIT 1', [id]); + }, + + /** + * The ids of rows matching one natural key. LIMIT 2, because the caller only + * needs to tell "exactly one holder" from "none" or "ambiguous". + * + * @param {string} table + * @param {Array} keyColumns validated column names of the key + * @param {Array} values one value per key column + * @returns {Promise} the driver's row array, at most two rows + */ + async findRowIdsByKeyColumns(table, keyColumns, values){ + return await this.doQuery( + 'SELECT id FROM `' + table + '` WHERE ' + + keyColumns.map(c => '`' + c + '` = ?').join(' AND ') + ' LIMIT 2', + values); + }, + + /** + * Delete one row by its surrogate id. + * + * @param {string} table + * @param {number} holderId + * @returns {Promise} the driver's result + */ + async deleteRowById(table, holderId){ + return await this.doQuery('DELETE FROM `' + table + '` WHERE id = ?', [holderId]); + }, + + /** + * The ordered column names of one index of one table in this database. + * + * @param {string} table + * @param {string} indexName + * @returns {Promise} the driver's row array, one row per column in index order + */ + async findIndexColumnNames(table, indexName){ + return await this.doQuery( + "SELECT column_name FROM information_schema.statistics " + + "WHERE table_schema = ? AND table_name = ? AND index_name = ? ORDER BY seq_in_index ASC", + [this.dbName, table, indexName]); + }, + + // The updated-rows channel's reads (server/updated_rows.js). Each returns the + // CURRENT full state of surviving rows mutated in place inside a block window, + // which the action-scoped stream cannot carry because the row's own action is + // older than the window. Table names are interpolated because an identifier + // cannot be a bind parameter; every one comes from that module's fixed lists. + + /** + * Rows whose deactivation_block stamp falls in the window. The caller shifts + * the window by the chain's activation delay, because a stamp is written that + * many blocks ahead of the action that set it. + * + * @param {string} table + * @param {number} fromStamp first stamp value, inclusive + * @param {number} toStamp last stamp value, inclusive + * @param {object} [conn] a connection to read on, when the caller holds one + * @returns {Promise} the driver's row array + */ + async findDeactivationStampedRows(table, fromStamp, toStamp, conn){ + return await this.doQuery( + "SELECT * FROM `" + table + "` WHERE deactivation_block IS NOT NULL AND deactivation_block BETWEEN ? AND ?", + [fromStamp, toStamp], conn); + }, + + /** + * Stake or unstake rows a SLASH reduced in this window, reached through the + * debit log entry that records the reduction. + * + * @param {{table: string, debits: string, target: string}} spec one SLASH_SPECS entry + * @param {number} from first block of the window, inclusive + * @param {number} to last block of the window, inclusive + * @param {object} [conn] a connection to read on, when the caller holds one + * @returns {Promise} the driver's row array + */ + async findSlashDebitedRows(spec, from, to, conn){ + return await this.doQuery( + "SELECT t.* FROM `" + spec.table + "` t " + + "JOIN `" + spec.debits + "` d ON d.stake_action_index = t.action_index " + + "WHERE d.target_table = ? AND d.block_index BETWEEN ? AND ?", + [spec.target, from, to], conn); + }, + + /** + * Contract stake rows a DELEGATE v1 signing-key rotation rewrote in this + * window, pinned to a block only by the rotations journal. + * + * @param {string} rotTbl one ROTATION_TABLES entry + * @param {number} from first block of the window, inclusive + * @param {number} to last block of the window, inclusive + * @param {object} [conn] a connection to read on, when the caller holds one + * @returns {Promise} the driver's row array + */ + async findRotatedStakeRows(rotTbl, from, to, conn){ + return await this.doQuery( + "SELECT t.* FROM `" + rotTbl + "` t " + + "JOIN `contract_delegation_rotations` r ON r.stake_action_index = t.action_index " + + "WHERE r.target_table = ? AND r.block_index BETWEEN ? AND ?", + [rotTbl, from, to], conn); + }, + + /** + * Version 0 request rows whose request_status resolved in this window. + * + * @param {string} table one REQUEST_STATUS_TABLES entry + * @param {number} from first block of the window, inclusive + * @param {number} to last block of the window, inclusive + * @param {object} [conn] a connection to read on, when the caller holds one + * @returns {Promise} the driver's row array + */ + async findResolvedRequestRows(table, from, to, conn){ + return await this.doQuery( + "SELECT * FROM `" + table + "` WHERE version = 0 AND resolved_block BETWEEN ? AND ?", + [from, to], conn); + }, + + /** + * Poll rows finalized in this window, or whose deferred binding callback + * fired at a due block in this window. + * + * @param {string} table one POLL_FINALIZE_TABLES entry + * @param {number} from first block of the window, inclusive + * @param {number} to last block of the window, inclusive + * @param {object} [conn] a connection to read on, when the caller holds one + * @returns {Promise} the driver's row array + */ + async findFinalizedPollRows(table, from, to, conn){ + return await this.doQuery( + "SELECT * FROM `" + table + "` WHERE resolved_block BETWEEN ? AND ? " + + "OR (callback_due_block BETWEEN ? AND ? AND callback_execute_action_index IS NOT NULL)", + [from, to, from, to], conn); + }, + + /** + * Unstake rows whose cooldown matured in this window. + * + * @param {string} table one COOLDOWN_STATUS_TABLES entry + * @param {number} from first block of the window, inclusive + * @param {number} to last block of the window, inclusive + * @param {object} [conn] a connection to read on, when the caller holds one + * @returns {Promise} the driver's row array + */ + async findMaturedCooldownRows(table, from, to, conn){ + return await this.doQuery( + "SELECT * FROM `" + table + "` WHERE cooldown_end_block BETWEEN ? AND ?", + [from, to], conn); + }, + + /** + * BET rows matching a window predicate the caller built over the spec's + * stamp columns. + * + * @param {{table: string}} spec one BET_STATUS_SPECS entry + * @param {string} where the OR-joined stamp predicate + * @param {Array} args one window pair per stamp column + * @param {object} [conn] a connection to read on, when the caller holds one + * @returns {Promise} the driver's row array + */ + async findBetStampedRows(spec, where, args, conn){ + return await this.doQuery( + "SELECT * FROM `" + spec.table + "` WHERE " + where, args, conn); + }, + + /** + * Archive-head anchor parents stamped invalid_archive by a completing chunk + * that landed in this window. The height key is the shared chunk-height column + * from stateHash.js, which a v2 continuation row actually populates. + * + * @param {number} from first block of the window, inclusive + * @param {number} to last block of the window, inclusive + * @param {object} [conn] a connection to read on, when the caller holds one + * @returns {Promise} the driver's row array + */ + async findInvalidArchiveHeadRows(from, to, conn){ + return await this.doQuery( + "SELECT DISTINCT p.* FROM anchor_actions p " + + "JOIN anchor_actions c ON c.version = 2 AND c.match_batch_seq = p.match_batch_seq " + + "JOIN index_statuses ps ON ps.id = p.status_id AND ps.status = 'invalid_archive' " + + "JOIN index_statuses cs ON cs.id = c.status_id AND cs.status = 'valid' " + + "WHERE p.version " + ARCHIVE_HEAD_VERSIONS_SQL + " AND " + ARCHIVE_CHUNK_HEIGHT_COL + " BETWEEN ? AND ?", + [from, to], conn); + }, + + /** + * ATTEST batch heads whose failure verdict was stamped when a valid + * continuation by the same author completed the batch inside this window. + * + * @param {number} headVersion the batch head's attests version + * @param {number} continuationVersion the continuation chunk's attests version + * @param {string} completionStamp the status suffix a completion stamp carries + * @param {number} from first block of the window, inclusive + * @param {number} to last block of the window, inclusive + * @param {object} [conn] a connection to read on, when the caller holds one + * @returns {Promise} the driver's row array + */ + async findFailedAttestBatchHeads(headVersion, continuationVersion, completionStamp, from, to, conn){ + return await this.doQuery( + "SELECT ah.* FROM attests ah " + + "JOIN index_statuses ahs ON ahs.id = ah.status_id AND ahs.status LIKE ? " + + "JOIN actions aha ON aha.action_index = ah.action_index " + + "JOIN attests ac ON ac.request_id = ah.request_id " + + "AND ac.version = " + continuationVersion + " AND ac.batch_chunk_index IS NOT NULL " + + "JOIN index_statuses acs ON acs.id = ac.status_id AND acs.status = 'valid' " + + "JOIN actions aca ON aca.action_index = ac.action_index AND aca.source_id = aha.source_id " + + "WHERE ah.version = " + headVersion + " AND ah.batch_chunk_index = 0 " + + "AND ac.block_index BETWEEN ? AND ?", + ['%' + completionStamp, from, to], conn); + }, + }; diff --git a/src/db/validator_rewards.js b/src/db/validator_rewards.js index d5642568..895c44b5 100644 --- a/src/db/validator_rewards.js +++ b/src/db/validator_rewards.js @@ -73,4 +73,24 @@ module.exports = { [from, to], conn); }, + /** + * Delete every validator_rewards row a replicated reconcile-log pre-image + * names, keyed on the full five-column reward identity. round_qualifier is part + * of the key because two distinct archive rewards can share the other four. + * + * @param {string} scopeSql predicate over the log alias `d` bounding its rows to the applied window + * @param {Array} scopeArgs the predicate's bind values + * @returns {Promise} the driver's result + */ + async deleteReconciledValidatorRewards(scopeSql, scopeArgs){ + return await this.doQuery( + "DELETE vr FROM validator_rewards vr " + + "JOIN anchor_reward_reconcile_log d " + + " ON d.source_id = vr.source_id AND d.signing_pubkey_id = vr.signing_pubkey_id " + + " AND d.reward_type = vr.reward_type AND d.round_reference <=> vr.round_reference " + + " AND d.round_qualifier = vr.round_qualifier " + + "WHERE " + scopeSql, + scopeArgs); + }, + }; diff --git a/src/server/updated_rows.js b/src/server/updated_rows.js index 9fac63ac..22da0d70 100644 --- a/src/server/updated_rows.js +++ b/src/server/updated_rows.js @@ -106,11 +106,12 @@ async function collectDeactivationAndSlashRows(db, from, to, activationDelay, co if(activationDelay != null){ for(let table of DEACTIVATION_TABLES){ try { - let rows = await db.doQuery( - "SELECT * FROM `" + table + "` WHERE deactivation_block IS NOT NULL AND deactivation_block BETWEEN ? AND ?", - [from + activationDelay, to + activationDelay], conn); + let rows = await db.findDeactivationStampedRows(table, from + activationDelay, to + activationDelay, conn); add(acc, table, rows); - } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; } + } catch(e){ + if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; + // Table/column may not exist on older source schemas; skip. + } } } @@ -119,13 +120,12 @@ async function collectDeactivationAndSlashRows(db, from, to, activationDelay, co // (avoids DISTINCT over wide/blob columns). for(let spec of SLASH_SPECS){ try { - let rows = await db.doQuery( - "SELECT t.* FROM `" + spec.table + "` t " + - "JOIN `" + spec.debits + "` d ON d.stake_action_index = t.action_index " + - "WHERE d.target_table = ? AND d.block_index BETWEEN ? AND ?", - [spec.target, from, to], conn); + let rows = await db.findSlashDebitedRows(spec, from, to, conn); add(acc, spec.table, rows); - } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; } + } catch(e){ + if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; + // Table may not exist on older source schemas; skip. + } } } @@ -137,13 +137,12 @@ async function collectRotationAndRequestRows(db, from, to, conn, acc){ // staker set than the source (and slashes a key the source no longer carries). for(let rotTbl of ROTATION_TABLES){ try { - let rows = await db.doQuery( - "SELECT t.* FROM `" + rotTbl + "` t " + - "JOIN `contract_delegation_rotations` r ON r.stake_action_index = t.action_index " + - "WHERE r.target_table = ? AND r.block_index BETWEEN ? AND ?", - [rotTbl, from, to], conn); + let rows = await db.findRotatedStakeRows(rotTbl, from, to, conn); add(acc, rotTbl, rows); - } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; } + } catch(e){ + if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; + // Table may not exist on older source schemas; skip. + } } // 3. request_status flips on surviving v0 attest/xcall request rows. Keyed on @@ -151,11 +150,12 @@ async function collectRotationAndRequestRows(db, from, to, conn, acc){ // and the deadline-expiry flip paths, mirroring ClientRollback's reset key. for(let table of REQUEST_STATUS_TABLES){ try { - let rows = await db.doQuery( - "SELECT * FROM `" + table + "` WHERE version = 0 AND resolved_block BETWEEN ? AND ?", - [from, to], conn); + let rows = await db.findResolvedRequestRows(table, from, to, conn); add(acc, table, rows); - } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; } + } catch(e){ + if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; + // Table/column may not exist on older source schemas; skip. + } } } @@ -170,12 +170,12 @@ async function collectPollAndCooldownRows(db, from, to, conn, acc){ // predicate: polls has one row shape (the v0 create), unlike attests/xcalls. for(let table of POLL_FINALIZE_TABLES){ try { - let rows = await db.doQuery( - "SELECT * FROM `" + table + "` WHERE resolved_block BETWEEN ? AND ? " + - "OR (callback_due_block BETWEEN ? AND ? AND callback_execute_action_index IS NOT NULL)", - [from, to, from, to], conn); + let rows = await db.findFinalizedPollRows(table, from, to, conn); add(acc, table, rows); - } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; } + } catch(e){ + if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; + // Table/column may not exist on older source schemas; skip. + } } // 4. cooldown-maturity status_id flip on surviving unstakes / contract_unstakes. @@ -188,11 +188,12 @@ async function collectPollAndCooldownRows(db, from, to, conn, acc){ // the UNIQUE action_index against any SLASH row for the same unstake. for(let table of COOLDOWN_STATUS_TABLES){ try { - let rows = await db.doQuery( - "SELECT * FROM `" + table + "` WHERE cooldown_end_block BETWEEN ? AND ?", - [from, to], conn); + let rows = await db.findMaturedCooldownRows(table, from, to, conn); add(acc, table, rows); - } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; } + } catch(e){ + if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; + // Table/column may not exist on older source schemas; skip. + } } } @@ -208,10 +209,12 @@ async function collectBetStatusRows(db, from, to, conn, acc){ let where = spec.stamps.map(col => "`" + col + "` BETWEEN ? AND ?").join(' OR '); let args = []; for(let i = 0; i < spec.stamps.length; i++){ args.push(from); args.push(to); } - let rows = await db.doQuery( - "SELECT * FROM `" + spec.table + "` WHERE " + where, args, conn); + let rows = await db.findBetStampedRows(spec, where, args, conn); add(acc, spec.table, rows); - } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; } + } catch(e){ + if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; + // Table/columns may not exist on older source schemas; skip. + } } } @@ -235,15 +238,12 @@ async function collectInvalidArchiveRows(db, from, to, conn, acc){ // this fix must be live BEFORE the state-hash flag day or the follower halts on // a parent row it was never sent. try { - let anchorRows = await db.doQuery( - "SELECT DISTINCT p.* FROM anchor_actions p " + - "JOIN anchor_actions c ON c.version = 2 AND c.match_batch_seq = p.match_batch_seq " + - "JOIN index_statuses ps ON ps.id = p.status_id AND ps.status = 'invalid_archive' " + - "JOIN index_statuses cs ON cs.id = c.status_id AND cs.status = 'valid' " + - "WHERE p.version " + ARCHIVE_HEAD_VERSIONS_SQL + " AND " + ARCHIVE_CHUNK_HEIGHT_COL + " BETWEEN ? AND ?", - [from, to], conn); + let anchorRows = await db.findInvalidArchiveHeadRows(from, to, conn); add(acc, 'anchor_actions', anchorRows); - } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; } + } catch(e){ + if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; + // Table/columns may not exist on older source schemas; skip. + } } async function collectAttestBatchHeadRows(db, from, to, conn, acc){ @@ -288,19 +288,13 @@ async function collectAttestBatchHeadRows(db, from, to, conn, acc){ // carry must be live before any future state-hash twin of this class arms, or a // follower halts on a head row it was never sent. try { - let attestHeadRows = await db.doQuery( - "SELECT ah.* FROM attests ah " + - "JOIN index_statuses ahs ON ahs.id = ah.status_id AND ahs.status LIKE ? " + - "JOIN actions aha ON aha.action_index = ah.action_index " + - "JOIN attests ac ON ac.request_id = ah.request_id " + - "AND ac.version = " + ATTEST_BATCH_CONTINUATION_VERSION + " AND ac.batch_chunk_index IS NOT NULL " + - "JOIN index_statuses acs ON acs.id = ac.status_id AND acs.status = 'valid' " + - "JOIN actions aca ON aca.action_index = ac.action_index AND aca.source_id = aha.source_id " + - "WHERE ah.version = " + ATTEST_BATCH_HEAD_VERSION + " AND ah.batch_chunk_index = 0 " + - "AND ac.block_index BETWEEN ? AND ?", - ['%' + ATTEST_BATCH_COMPLETION_STAMP, from, to], conn); + let attestHeadRows = await db.findFailedAttestBatchHeads( + ATTEST_BATCH_HEAD_VERSION, ATTEST_BATCH_CONTINUATION_VERSION, ATTEST_BATCH_COMPLETION_STAMP, from, to, conn); add(acc, 'attests', attestHeadRows); - } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; } + } catch(e){ + if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; + // Table/columns may not exist on older source schemas (pre-batch-rail builds); skip. + } } // Collect the in-place-mutated surviving rows for the block window [fromBlock, toBlock]. diff --git a/src/server/updated_rows/token_rows.js b/src/server/updated_rows/token_rows.js index 2379ce54..f68a2958 100644 --- a/src/server/updated_rows/token_rows.js +++ b/src/server/updated_rows/token_rows.js @@ -60,17 +60,7 @@ async function collectTokenSupplyRows(db, from, to, conn, acc){ // let the optimiser drive from `actions` (block_index range) into each table // via its action_index index. UNION (not UNION ALL) preserves the original // SELECT DISTINCT semantics, so the emitted tick set is byte-identical. - let tokenRows = await db.doQuery( - "SELECT t.* FROM `tokens` t WHERE t.tick_id IN (" + - "SELECT c.tick_id FROM credits c JOIN actions a ON a.action_index = c.action_index " + - "WHERE a.block_index BETWEEN ? AND ? AND c.tick_id IS NOT NULL " + - "UNION " + - "SELECT d.tick_id FROM debits d JOIN actions a ON a.action_index = d.action_index " + - "WHERE a.block_index BETWEEN ? AND ? AND d.tick_id IS NOT NULL " + - "UNION " + - "SELECT e.tick_id FROM escrows e JOIN actions a ON a.action_index = e.action_index " + - "WHERE a.block_index BETWEEN ? AND ? AND e.tick_id IS NOT NULL)", - [from, to, from, to, from, to], conn); + let tokenRows = await db.findLedgerTouchedTokens(from, to, conn); add(acc, 'tokens', tokenRows); } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; } } diff --git a/test/unit/rollback_coverage.test.js b/test/unit/rollback_coverage.test.js index a483b07e..e7aad14b 100644 --- a/test/unit/rollback_coverage.test.js +++ b/test/unit/rollback_coverage.test.js @@ -101,6 +101,7 @@ const pathMod = require('path'); const fs = require('fs'); const assertLocal = require('assert'); const sh = require('../../src/consensus/state_hash'); +const { withDbMixins } = require('../helpers/db_mixins.js'); const widenSet = require('../../src/schema/utf8mb4_columns'); const { RECOMPUTED, SPECIAL_CASE, ROLLBACK_EXEMPT, INDEXER_LOCAL } = lifecycleTwin.replicaRollbackBuckets(); @@ -653,7 +654,10 @@ describe('Rollback coverage guard @regression', function(){ assert.deepStrictEqual(COOLDOWN_STATUS_TABLES, ['unstakes', 'contract_unstakes'], 'updated_rows must track the cooldown status flip on both unstake tables'); const fs = require('fs'), pathMod = require('path'); - const src = fs.readFileSync(pathMod.resolve(__dirname, '../../src/server/updated_rows.js'), 'utf8') + // The SELECT lives in the tables mixin and the collector calls it by name, so the + // guard reads both: the key it pins is the contract, not the file. + const src = ['../../src/server/updated_rows.js', '../../src/db/tables.js'] + .map((f) => fs.readFileSync(pathMod.resolve(__dirname, f), 'utf8')).join('\n') .replace(/[`"']/g, ' ').replace(/\s+\+\s+/g, ' ').replace(/\s+/g, ' '); assert.ok(/WHERE cooldown_end_block BETWEEN \? AND \?/.test(src), 'updatedRows.js must select the cooldown status flip by cooldown_end_block (the maturity-block key the reverse reset and the forward credit select share)'); @@ -720,7 +724,10 @@ describe('Rollback coverage guard @regression', function(){ assert.ok(/collectDerivedAnchorRewards\s*\(/.test(src), `${f} does not call collectDerivedAnchorRewards; its replication channel drops derived anchor/archive rewards`); } - const applier = norm(fs.readFileSync(pathMod.resolve(__dirname, '../../src/client/applier.js'), 'utf8')); + // The reconcile DELETE lives in the validator_rewards mixin and ClientApplier + // calls it by name, so the guard reads both. + const applier = norm(['../../src/client/applier.js', '../../src/db/validator_rewards.js'] + .map((f) => fs.readFileSync(pathMod.resolve(__dirname, f), 'utf8')).join('\n')); assert.ok(/DELETE vr FROM validator_rewards vr JOIN anchor_reward_reconcile_log d ON d\.source_id = vr\.source_id AND d\.signing_pubkey_id = vr\.signing_pubkey_id AND d\.reward_type = vr\.reward_type AND d\.round_reference <=> vr\.round_reference AND d\.round_qualifier = vr\.round_qualifier/.test(applier), 'ClientApplier.js must mirror the reconcile DELETE from the replicated pre-image log (forward twin of the RB-ANCHOR restore) on the FULL five-column reward identity; without round_qualifier the keyed delete also reaches the other archive snapshot\'s surviving reward'); // RB-ANCHOR restore parity on that same identity. The source twin @@ -908,7 +915,9 @@ describe('Rollback coverage guard @regression', function(){ 'ARCHIVE_HEAD_VERSIONS_SQL must render as IN (1)'); const fs = require('fs'), pathMod = require('path'); const norm = s => s.replace(/[`"']/g, ' ').replace(/\s+\+\s+/g, ' ').replace(/\s+/g, ' '); - const ur = norm(fs.readFileSync(pathMod.resolve(__dirname, '../../src/server/updated_rows.js'), 'utf8')); + // The anchor SELECT lives in the tables mixin and the collector calls it by name. + const ur = norm(['../../src/server/updated_rows.js', '../../src/db/tables.js'] + .map((f) => fs.readFileSync(pathMod.resolve(__dirname, f), 'utf8')).join('\n')); assertLocal.ok(/WHERE p\.version ARCHIVE_HEAD_VERSIONS_SQL AND ARCHIVE_CHUNK_HEIGHT_COL BETWEEN \? AND \?/.test(ur), 'updatedRows.js anchor class must select archive-head parents via ARCHIVE_HEAD_VERSIONS_SQL, ' + 'scoped by the shared ARCHIVE_CHUNK_HEIGHT_COL'); @@ -965,7 +974,9 @@ describe('Rollback coverage guard @regression', function(){ assert.deepStrictEqual(POLL_FINALIZE_TABLES, ['polls'], 'updated_rows must track the poll finalization flip on polls'); const fs = require('fs'), pathMod = require('path'); - const src = fs.readFileSync(pathMod.resolve(__dirname, '../../src/server/updated_rows.js'), 'utf8') + // The SELECT lives in the tables mixin and the collector calls it by name. + const src = ['../../src/server/updated_rows.js', '../../src/db/tables.js'] + .map((f) => fs.readFileSync(pathMod.resolve(__dirname, f), 'utf8')).join('\n') .replace(/[`"']/g, ' ').replace(/\s+\+\s+/g, ' ').replace(/\s+/g, ' '); assert.ok(/WHERE resolved_block BETWEEN \? AND \?/.test(src), 'updatedRows.js must select the poll finalization flip by resolved_block (the same key the reverse re-open resets)'); @@ -986,7 +997,9 @@ describe('Rollback coverage guard @regression', function(){ assert.deepStrictEqual(ROTATION_TABLES, ['contract_stakes', 'contract_unstakes'], 'updated_rows must track the rotation rewrite on both contract stake tables'); const fs = require('fs'), pathMod = require('path'); - const src = fs.readFileSync(pathMod.resolve(__dirname, '../../src/server/updated_rows.js'), 'utf8') + // The SELECT lives in the tables mixin and the collector calls it by name. + const src = ['../../src/server/updated_rows.js', '../../src/db/tables.js'] + .map((f) => fs.readFileSync(pathMod.resolve(__dirname, f), 'utf8')).join('\n') .replace(/[`"']/g, ' ').replace(/\s+\+\s+/g, ' ').replace(/\s+/g, ' '); assert.ok(/JOIN contract_delegation_rotations r ON r\.stake_action_index = t\.action_index WHERE r\.target_table = \? AND r\.block_index BETWEEN \? AND \?/.test(src), 'updatedRows.js must select rotated stake rows through the contract_delegation_rotations journal keyed by target_table and block_index window (the same journal ClientRollback restores from)'); @@ -1289,13 +1302,13 @@ describe('Rollback coverage guard @regression', function(){ const { collectUpdatedRows } = require('../../src/server/updated_rows'); // Fake DB: the anchor self-join query returns a v1 parent stamped invalid_archive. let anchorParent = { action_index: 301, version: 1, status_id: 99, match_batch_seq: 7 }; - let db = { + let db = withDbMixins({ dbType: 'indexer', doQuery: async (sql) => { if(sql.indexOf('anchor_actions p') !== -1) return [anchorParent]; return []; } - }; + }); let out = await collectUpdatedRows(db, 300, 300, null); assert.ok(Array.isArray(out.anchor_actions) && out.anchor_actions.length === 1, 'collectUpdatedRows must return the anchor_actions parent row for the CRC-failure window'); From 53f7070b2d11322c8257241e2064422e9d3bdd96 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 08:16:55 -0700 Subject: [PATCH 34/62] refactor(config): read the remaining environment through the config home The per-chain keys ClientSync and the pinned-validator overrides compute, and the injectable environment the pool sizing, shutdown and override scanner fall back to, now go through two call-time accessors on src/config.js, so it stays the one module that names process.env. Both read live, so a variable set after boot is still seen. --- src/client/sync.js | 4 ++-- src/config.js | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/client/sync.js b/src/client/sync.js index d7e1908e..5e8e40ac 100644 --- a/src/client/sync.js +++ b/src/client/sync.js @@ -133,7 +133,7 @@ class ClientSync { // weakening gates the repo declares UNSAFE to turn off (operator decision // 2026-06-12): the all-gates-off posture stays an explicit operator choice. let modeKey = 'SYNC_MODE_' + String(this.chain).toUpperCase(); - this._syncMode = process.env[modeKey] || this.config[modeKey] || 'full'; + this._syncMode = envConfig.envValueByName(modeKey) || this.config[modeKey] || 'full'; if(this.dbType === 'indexer' && this._syncMode === 'infra-only'){ let haltingGates = []; if(this.config['VERIFY_RECOMPUTE']) haltingGates.push('VERIFY_RECOMPUTE'); @@ -2358,7 +2358,7 @@ class ClientSync { // every sweep or never). numericSetting(key, fallback, min){ let raw = (this.config && this.config[key] != null && this.config[key] !== '') - ? this.config[key] : process.env[key]; + ? this.config[key] : envConfig.envValueByName(key); let n = Number(raw); if(!Number.isFinite(n)) n = fallback; if(min != null && n < min) n = min; diff --git a/src/config.js b/src/config.js index bb970bba..5a83c05b 100644 --- a/src/config.js +++ b/src/config.js @@ -163,10 +163,17 @@ module.exports = { /** The replication connection to measure replica lag on, empty when unset. */ replicaConnectionFromEnv: () => process.env.SYNC_REPLICA_CONNECTION, - /** One variable whose NAME the caller computes (SYNC_MODE_, a pinned-validator override). */ + /** + * One variable whose NAME the caller computes (a per-chain key such as + * SYNC_MODE_ or a pinned-validator override), read when called, so a + * variable set after boot is still seen. undefined when unset. + */ envValueByName: (name) => process.env[name], - /** The live environment, for a caller that also accepts an injected one in tests. */ + /** + * The live environment object, for a resolver or startup scanner that also + * accepts an injected environment in tests and falls back to this one. + */ envSource: () => process.env, bootstrapDepthKey, From 7209373aaca598e9fefd0600a798a7d49b84610b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 08:17:43 -0700 Subject: [PATCH 35/62] docs(comments): restore the ClientApplier method summaries The block and incremental-snapshot apply methods get back the summaries an earlier comment sweep dropped, the identifier checks in insertRows gain a line saying why they exist, and internal ledger ids leave three comments. No executable line changes. --- src/client/applier.js | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/client/applier.js b/src/client/applier.js index 591bfdd4..6c8f1cfd 100644 --- a/src/client/applier.js +++ b/src/client/applier.js @@ -100,7 +100,7 @@ class ClientApplier { // overlap) a no-op, mirroring the server's recordBlock INSERT IGNORE. 'sync_meta', // merkle_epochs is append-only (epoch UNIQUE); INSERT IGNORE makes its - // full-dump re-send on an incremental catch-up idempotent (item 4622). + // full-dump re-send on an incremental catch-up idempotent. 'merkle_epochs', // validator_rewards has a UNIQUE key (source_id, signing_pubkey_id, // reward_type, round_reference, round_qualifier). The recovery-redriven collector @@ -152,7 +152,7 @@ class ClientApplier { // value (markets = OHLCV; attest_validator_stats = running counters). On a // non-empty replica a plain INSERT collides on their UNIQUE key (ER_DUP_ENTRY, // which aborts the catch-up transaction) and INSERT IGNORE would keep the - // STALE row, so they must UPSERT to overwrite with the source values (4622). + // STALE row, so they must UPSERT to overwrite with the source values. this.upsertFullDumpTables = new Set([ 'markets', 'attest_validator_stats' @@ -232,6 +232,16 @@ class ClientApplier { ]); } + /** + * Apply a single block payload from a WebSocket event. + * + * Runs the whole block inside one transaction: a duplicate or malformed payload + * returns before anything is written, and any failure rolls the block back so + * ClientSync retries it rather than committing part of it. + * + * @param {object} payload the server's block event: block_index, data (a + * { table: [rows] } map) and any updated_rows + */ async applyBlock(payload){ // Clear any prior block's computed roots up front: on an early return // (malformed payload or an already-applied duplicate) ClientSync must NOT @@ -540,9 +550,16 @@ class ClientApplier { } } - // opts.strictIgnoreCheck: see the SHOW WARNINGS block in insertRows. - // Set only by ClientSync's from-zero lookup repair; every other caller (ordinary - // live/catch-up apply) omits it and keeps the cheap, silent INSERT IGNORE path. + /** + * Apply an incremental snapshot. + * + * opts.strictIgnoreCheck: see the SHOW WARNINGS block in insertRows. + * Set only by ClientSync's from-zero lookup repair; every other caller (ordinary + * live/catch-up apply) omits it and keeps the cheap, silent INSERT IGNORE path. + * + * @param {object} snapshotData the server's catch-up payload since a block + * @param {object} [opts] + */ async applyIncrementalSnapshot(snapshotData, opts){ if(!snapshotData || !snapshotData.tables) return; @@ -628,6 +645,9 @@ class ClientApplier { async insertRows(table, rows, opts){ if(!rows || rows.length === 0) return; + // The table name is spliced into every statement below rather than bound as a + // parameter, so refuse anything that is not a plain identifier before it can + // reach the database. let tableCheck = validation.validateIdentifier(table); if(!tableCheck.valid){ // Fail closed, not open: a `return` here silently drops every row for this @@ -696,6 +716,7 @@ class ClientApplier { } } + // Column names are spliced in the same way, so each one gets the same check. for(let col of columns){ let colCheck = validation.validateIdentifier(col); if(!colCheck.valid){ @@ -723,7 +744,7 @@ class ClientApplier { await this.db.insertRowValues(table, columns, batch.length, args, useIgnore, useUpsert); - // 5284: events rows >64KB silently truncate on a still-TEXT (pre-migration) + // events rows >64KB silently truncate on a still-TEXT (pre-migration) // replica when INSERT IGNORE is used: the id collision guard skips the row // on re-send, so the truncated copy is never healed. Detect this by reading // SHOW WARNINGS immediately after (SHOW WARNINGS is session-scoped and is From 71cb8f3e9d0cd73b4785aba7c1295dcf876a4479 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 08:27:33 -0700 Subject: [PATCH 36/62] refactor(naming): drop the underscore from the stake-weight SQL builders stakeWeightsSql and cappedStakeWeightsSql lose their underscore prefix at the definition, every in-repo caller, the integration harness adopt list and the tests. The two cross-repo drift guards extract the builders by their new names, anchored so a copy still carrying the old name no longer matches, and the suite-title name map declares the three titles that quote them. --- bin/pins/m2-name-delta.json | 2 ++ src/SyncService.js | 2 +- src/db/index.js | 2 +- src/db/stakes.js | 22 ++++++------- test/integration/helpers/testDb.js | 4 +-- test/unit/db_stake_weight_collation.test.js | 2 +- test/unit/db_stake_weights_as_of.test.js | 6 ++-- test/unit/rollback_coverage.test.js | 31 ++++++++----------- test/unit/stakes_validator_set_parity.test.js | 2 +- 9 files changed, 35 insertions(+), 38 deletions(-) diff --git a/bin/pins/m2-name-delta.json b/bin/pins/m2-name-delta.json index 7105372b..a5e23678 100644 --- a/bin/pins/m2-name-delta.json +++ b/bin/pins/m2-name-delta.json @@ -19,6 +19,7 @@ "_bootstrapRotateSources": "bootstrapRotateSources", "_buildBlockPayload": "buildBlockPayload", "_call": "call", + "_cappedStakeWeightsSql": "cappedStakeWeightsSql", "_checkpointRootsMatchLocal": "checkpointRootsMatchLocal", "_clearBootstrapBase": "clearBootstrapBase", "_collectSmtTouchedKeys": "collectSmtTouchedKeys", @@ -85,6 +86,7 @@ "_sendRestHeartbeat": "sendRestHeartbeat", "_shouldReconcileDispensers": "shouldReconcileDispensers", "_sourceBlockHash": "sourceBlockHash", + "_stakeWeightsSql": "stakeWeightsSql", "_startClientMode": "startClientMode", "_startClientSyncForChain": "startClientSyncForChain", "_startPollerForChain": "startPollerForChain", diff --git a/src/SyncService.js b/src/SyncService.js index c3fa6a5e..7a4add97 100644 --- a/src/SyncService.js +++ b/src/SyncService.js @@ -284,7 +284,7 @@ class SyncService { await db.ensureReplicaUtf8mb4Columns(); // Fail closed on collation drift in the columns the stake-weight // snapshot orders on. The follower rebuilds stakes_root from the - // byte-mirrored _cappedStakeWeightsSql, whose window caps truncate on + // byte-mirrored cappedStakeWeightsSql, whose window caps truncate on // that order, so a replica whose index_addresses.address collation // deviates from the source's picks different cap survivors and then // halts on a root it computed wrong. Runs AFTER the schema self-heal diff --git a/src/db/index.js b/src/db/index.js index 0209ee8f..8c48eeac 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -749,7 +749,7 @@ class Database { // assertStakeWeightOrderingCollation, sharing that module's ONE definition of the // declared contract so the two services cannot disagree about what "undrifted" // means. The follower rebuilds stakes_root from the byte-mirrored - // _cappedStakeWeightsSql; its window caps truncate on this order, so a drifted + // cappedStakeWeightsSql; its window caps truncate on this order, so a drifted // collation selects different cap survivors and the replica halts on a root it // computed wrong. Once the collation gate is armed, a drifted CHARSET fails the // query outright (errno 1253), so halting at boot with the column named is the diff --git a/src/db/stakes.js b/src/db/stakes.js index 4ad2a98c..e108566b 100644 --- a/src/db/stakes.js +++ b/src/db/stakes.js @@ -32,13 +32,13 @@ module.exports = { // Light-client stakes_root support (SPV spec sec.4.1, BTC-only). Source-deduped // capability stake-weight query, ported VERBATIM from xchain-indexer/src/db.js - // _stakeWeightsSql: it MUST produce a byte-identical SQL string + arg order or + // stakeWeightsSql: it MUST produce a byte-identical SQL string + arg order or // the follower's stakes_root diverges from the indexer's committed root and the // state-commitment check false-halts. The cross-repo drift guard in // test/unit/rollback/rollback_coverage.test.js locks the two together. Reads only tables // xchain-sync replicates (stakes, delegations, stake_key_revocations, // capability_slash_events, index_addresses, index_pubkeys). - _stakeWeightsSql(valid_id, blockIndex, minStake){ + stakeWeightsSql(valid_id, blockIndex, minStake){ // Precision: DECIMAL(30,8) (22 integer digits, 8 fractional) is sufficient because the // staking tick is XCHAIN at 8 decimals and total supply stays far below 10^22; every // same-version node truncates identically, so the stake-weight tally is deterministic. @@ -96,7 +96,7 @@ module.exports = { }, // SWQ source-cap wrapper (SWQ-TRUNC-1 liveness half). Wraps an inner source-keyed - // stake-weight builder ({sql,args} from _stakeWeightsSql or the sync AsOf variant) + // stake-weight builder ({sql,args} from stakeWeightsSql or the sync AsOf variant) // and replaces the raw key-row LIMIT with a windowed cap on the consensus UNIT: // DISTINCT staking SOURCES (DENSE_RANK over source) plus a per-source key bound // (ROW_NUMBER per source). One source can no longer fill the window and evict @@ -117,7 +117,7 @@ module.exports = { // truncate on, so the collation decides which sources and which keys survive into // the hashed stakes_root. Below the height the emitted SQL is byte-identical to // what shipped before the gate; the suffix is '' and concatenates away. - _cappedStakeWeightsSql(inner, maxSources, maxKeys, binCollation){ + cappedStakeWeightsSql(inner, maxSources, maxKeys, binCollation){ let c = stakeWeightCollation.stakeWeightCollate(binCollation); let sql = `SELECT r.pubkey AS pubkey, r.source AS source, r.weight AS weight, r._sr AS _sr FROM ( @@ -135,8 +135,8 @@ module.exports = { // Apply the cap regime in force for `coin`/`network` at `blockIndex` to an inner // source-keyed stake-weight builder, returning { rows:[{pubkey,source,weight}], // truncated }. Twin of the indexer's stakeWeightsWithCap gate: at/after - // SWQ_SOURCE_CAP_ACTIVATION the windowed source-cap (_cappedStakeWeightsSql); - // below it the legacy uncapped key-row LIMIT. The gate + caps + _cappedStakeWeightsSql + // SWQ_SOURCE_CAP_ACTIVATION the windowed source-cap (cappedStakeWeightsSql); + // below it the legacy uncapped key-row LIMIT. The gate + caps + cappedStakeWeightsSql // are byte-mirrored to the indexer so the follower's stakes_root set is identical on // both sides of the height. Sync reads coin/network from the caller (it has no // per-chain config); a null coin/network stays inert (legacy uncapped path). @@ -149,7 +149,7 @@ module.exports = { if(swqCap.isSwqSourceCapActive(blockIndex, network, coin)){ let maxSources = swqCap.STAKE_WEIGHT_MAX_SOURCES; let maxKeys = swqCap.STAKE_WEIGHT_MAX_KEYS_PER_SOURCE; - let capped = this._cappedStakeWeightsSql(inner, maxSources, maxKeys, binCollation); + let capped = this.cappedStakeWeightsSql(inner, maxSources, maxKeys, binCollation); // Strict for the M-17 reason getBlockLeafRows is: this row set IS the // stakes_root, and the SPV checkpoint forward-follow // (ClientSync.oraclePublishSetAt) reads it with NO transaction open, so a @@ -190,7 +190,7 @@ module.exports = { // rethrow: a null here would silently return the empty stake set (M-17). let valid_id = await this.getStatusId('valid', { rethrow: true }); if(valid_id === null) return []; - let sw = this._stakeWeightsSql(valid_id, blockIndex, String(minStake)); + let sw = this.stakeWeightsSql(valid_id, blockIndex, String(minStake)); let { rows } = await this.applyStakeWeightCap(sw, blockIndex, limit, coin, network, 'getStakeWeightsByCapability(' + capability + ')'); return rows; }, @@ -212,14 +212,14 @@ module.exports = { // add-back there would double-count and fork the committed ledger. This is a NO-OP // when no slash with block_index > S exists (addback is NULL -> COALESCE 0), so it // returns a result byte-identical to getStakeWeightsByCapability(cap, S) computed - // in order at S. _stakeWeightsSql (the cross-repo byte-identical twin) is deliberately + // in order at S. stakeWeightsSql (the cross-repo byte-identical twin) is deliberately // NOT reused/modified here so the drift guard and the consensus query stay untouched. async getStakeWeightsByCapabilityAsOf(capability, snapshotBlock, minStake, limit, coin, network){ // rethrow for the same M-17 reason, and this is the caller that runs with NO // transaction open (ClientSync.oraclePublishSetAt). let valid_id = await this.getStatusId('valid', { rethrow: true }); if(valid_id === null) return []; - // Membership exclusion is identical to _stakeWeightsSql: a key slashed at + // Membership exclusion is identical to stakeWeightsSql: a key slashed at // block > S has cse.block_index > S, so NOT EXISTS is TRUE and the key is // correctly KEPT in the set at S. Only the q-subquery AMOUNT is reconstructed. const slashExcl = (keyCol) => @@ -272,7 +272,7 @@ module.exports = { ) ek ON ek.source_id = q.source_id JOIN index_pubkeys ip ON ip.id = ek.pubkey_id`; // Arg order tracks the placeholders left-to-right: the addback block bound - // first, then the same sequence _stakeWeightsSql uses (all historical-block + // first, then the same sequence stakeWeightsSql uses (all historical-block // args bound to snapshotBlock), then the LIMIT. let args = [snapshotBlock, valid_id, snapshotBlock, snapshotBlock, String(minStake), diff --git a/test/integration/helpers/testDb.js b/test/integration/helpers/testDb.js index a17bc448..858f196c 100644 --- a/test/integration/helpers/testDb.js +++ b/test/integration/helpers/testDb.js @@ -268,8 +268,8 @@ class TestDatabase { // wrapper already provides. const RealDatabase = require('../../../src/db'); for (const method of ['getStateRootsRow', 'getBlockLeafRows', - 'getStakeWeightsByCapability', '_stakeWeightsSql', - 'applyStakeWeightCap', '_cappedStakeWeightsSql', + 'getStakeWeightsByCapability', 'stakeWeightsSql', + 'applyStakeWeightCap', 'cappedStakeWeightsSql', // getBlockScopedRows keys by lifecycle.blockKey(table), never // a fixed block_index (rollcalls scope by close_block). 'getBlockScopedRows']) diff --git a/test/unit/db_stake_weight_collation.test.js b/test/unit/db_stake_weight_collation.test.js index f96d9177..cd129e22 100644 --- a/test/unit/db_stake_weight_collation.test.js +++ b/test/unit/db_stake_weight_collation.test.js @@ -9,7 +9,7 @@ // Replica half of the stake-weight ordering-collation flag-day (see // src/stake_weight_collation_activation.js, a byte-identical twin of the // xchain-indexer copy). The follower rebuilds stakes_root from -// _cappedStakeWeightsSql, whose window caps truncate on an ORDER over +// cappedStakeWeightsSql, whose window caps truncate on an ORDER over // index_addresses.address / index_pubkeys.pubkey. Those columns are declared // utf8_general_ci (folding), so the collation decides WHICH sources and keys // survive the cap - and pinning it on ONE side of the seam is itself the fork diff --git a/test/unit/db_stake_weights_as_of.test.js b/test/unit/db_stake_weights_as_of.test.js index 2b6a58ed..eaafecfe 100644 --- a/test/unit/db_stake_weights_as_of.test.js +++ b/test/unit/db_stake_weights_as_of.test.js @@ -133,10 +133,10 @@ describe(STAKE_WEIGHTS_AS_OF_TITLE, function(){ /denominator S/); }); - it('does not reference _stakeWeightsSql (keeps the drift-guarded twin untouched)', async function(){ - // The reconstruction is self-contained; _stakeWeightsSql must remain the + it('does not reference stakeWeightsSql (keeps the drift-guarded twin untouched)', async function(){ + // The reconstruction is self-contained; stakeWeightsSql must remain the // byte-identical consensus twin used only by the live, in-order callers. - let spy = sinon.spy(db, '_stakeWeightsSql'); + let spy = sinon.spy(db, 'stakeWeightsSql'); await db.getStakeWeightsByCapabilityAsOf('oracle_publish', 106, '500', 1000); assert.strictEqual(spy.called, false); }); diff --git a/test/unit/rollback_coverage.test.js b/test/unit/rollback_coverage.test.js index e7aad14b..3f44917f 100644 --- a/test/unit/rollback_coverage.test.js +++ b/test/unit/rollback_coverage.test.js @@ -454,30 +454,27 @@ describe('Rollback coverage guard @regression', function(){ }); // Cross-repo drift guard for the light-client stakes_root query (SPV spec sec.4.1). - // The follower rebuilds the BTC stakes_root from db._stakeWeightsSql; it MUST stay - // byte-identical to the xchain-indexer _stakeWeightsSql, or the follower's + // The follower rebuilds the BTC stakes_root from db.stakeWeightsSql; it MUST stay + // byte-identical to the xchain-indexer stakeWeightsSql, or the follower's // stakes_root (hence state_root) diverges from the source and the state-commitment // check false-halts. Both files carry the method verbatim; this extracts the body // and asserts whitespace-normalised equality. If you edit one, edit the other. - it('_stakeWeightsSql is identical across xchain-indexer and xchain-sync (cross-repo drift guard)', function(){ + it('stakeWeightsSql is identical across xchain-indexer and xchain-sync (cross-repo drift guard)', function(){ function stakeSql(p){ const src = fs.readFileSync(p, 'utf8'); - // The indexer spells the method stakeWeightsSql (its code-structure pass dropped - // the underscore prefix); this repo still spells it _stakeWeightsSql. The optional - // prefix accepts both, and the body comparison below is unchanged. - const m = src.match(/_?stakeWeightsSql\(valid_id, blockIndex, minStake\)\{([\s\S]*?)return \{ sql, args \};/); - assert.ok(m, `_stakeWeightsSql not found in ${p}`); + const m = src.match(/(? Date: Sat, 19 Sep 2026 21:28:18 -0700 Subject: [PATCH 37/62] fix(db): point tables.js at the renamed consensus/state_hash module The mixin extraction cherry-pick carried the pre-rename require path (src/stateHash.js), which moved to src/consensus/state_hash.js on develop before this landed. Update the import to match. --- src/db/tables.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/tables.js b/src/db/tables.js index 6163f458..d7875ef1 100644 --- a/src/db/tables.js +++ b/src/db/tables.js @@ -23,7 +23,7 @@ const path = require('path'); const lifecycle = require('../table_lifecycle'); const { assertValidIdentifier } = require('./shared.js'); -const { ARCHIVE_HEAD_VERSIONS_SQL, ARCHIVE_CHUNK_HEIGHT_COL } = require('../stateHash'); +const { ARCHIVE_HEAD_VERSIONS_SQL, ARCHIVE_CHUNK_HEIGHT_COL } = require('../consensus/state_hash'); module.exports = { From 8fb8e4d5af1d69509250b0692419a667c3898b80 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 02:09:52 -0700 Subject: [PATCH 38/62] docs(ci): correct .ci-siblings dispatcher comment to match ci-dispatch.sh The stale comment claimed the dispatcher ships at origin/master with a fallback to local HEAD. The actual script prefers the pushed branch before falling back. Matched against xchain-documentation/.ci-siblings which has the correct wording. (cherry picked from commit 31c6472e3a3c420d1601795e5385bdbe580eb770) --- .ci-siblings | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.ci-siblings b/.ci-siblings index ece25c0c..a4133e21 100644 --- a/.ci-siblings +++ b/.ci-siblings @@ -9,9 +9,9 @@ # test/unit/sibling-coverage.test.js, which reports the surface this file leaves # uncovered; the same gap applies to a sibling that is declared but missing. # -# ci-dispatch.sh ships each of these at its origin/master (falling back to local -# HEAD) and REFUSES the push if one is not a checkout beside this repo, so a -# declared sibling can never be silently absent. +# ci-dispatch.sh ships each of these at the pushed branch (falling back to its +# origin/master, then local HEAD) and REFUSES the push if one is not a checkout +# beside this repo, so a declared sibling can never be silently absent. # Consensus-primitive conformance and the state-commitment reference. xchain-documentation From 25b2215b8957b1a76ae2f756c05a77d8d794e0a2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 19:22:22 -0700 Subject: [PATCH 39/62] Re-vendor the observability shim from canonical The canonical module in xchain-hub/src/observability moved without its consumers, so xchain-hub's check:observability-sync tier was red for every pusher on three files here. Propagated with the sanctioned bin/sync-observability.sh; the check now reports 24 files matching across all six consumers. --- src/observability/index.js | 76 ++++++++++++++++----------------- src/observability/logShipper.js | 34 +++++++-------- src/observability/metrics.js | 48 ++++++++++----------- 3 files changed, 78 insertions(+), 80 deletions(-) diff --git a/src/observability/index.js b/src/observability/index.js index a0c27382..f07981c4 100644 --- a/src/observability/index.js +++ b/src/observability/index.js @@ -62,21 +62,21 @@ const { createLogShipper, readLogEnv } = require('./logShipper'); // Process-wide handles. A service is one process loading exactly one vendored // copy of this module, so module scope is the right scope: a globalThis key // would buy nothing and would collide across a monorepo test run. -let processLogger = null; -let processRegistry = null; -let patchHandle = null; +let _logger = null; +let _registry = null; +let _patched = null; // The shipper's housekeeping counters (log_lines_emitted_total and friends) // can only be registered once per registry. Now that the registry is shared and // always constructed, a second shipper on it would throw at construction, which // on the real wiring path (patchConsole at the top of api.js, then // installObservability further down) would take the service out at startup. -let shipperAttached = false; +let _shipperAttached = false; // The bound pre-patch console. Every shipper built after patchConsole must // write HERE, not to the global console: the shim's default sink is the global // object by reference, so a second shipper taking that default would emit its // formatted line INTO the patched console and get it formatted a second time // (` warn [svc] warn [svc] msg`). -let prePatchSink = null; +let _sink = null; const CONSOLE_METHODS = { log: 'info', info: 'info', warn: 'warn', error: 'error', debug: 'debug' }; @@ -178,11 +178,11 @@ function installObservability(app, opts = {}) { // caller wants no special sink or transport, adopt it rather than running a // second one: two shippers would split the line counters and each hold // their own ship buffer. - const adopt = processLogger && !opts.console && !opts.logTransport; + const adopt = _logger && !opts.console && !opts.logTransport; const logger = adopt - ? processLogger + ? _logger : newShipper({ service, version, env, console: sink, transport: opts.logTransport || null }); - if (!processLogger) processLogger = logger; + if (!_logger) _logger = logger; if (!config.metricsEnabled || !app || typeof app.use !== 'function') { return { @@ -215,14 +215,14 @@ function installObservability(app, opts = {}) { // The scrape itself is excluded: counting it makes every dashboard // show traffic that is only the monitoring system. if ((req.path || req.url || '').split('?')[0] === config.metricsPath) return next(); - const startedAt = process.hrtime.bigint(); + const stop = process.hrtime.bigint(); inFlight.inc({}, 1); let done = false; const finish = () => { if (done) return; done = true; inFlight.dec({}, 1); - const seconds = Number(process.hrtime.bigint() - startedAt) / 1e9; + const seconds = Number(process.hrtime.bigint() - stop) / 1e9; const route = routeLabel(req); const method = (req.method || 'GET').toUpperCase(); try { @@ -276,16 +276,16 @@ function installObservability(app, opts = {}) { * so this must not depend on the wiring order of any api.js. */ function getRegistry(info = {}) { - if (!processRegistry) { - processRegistry = new Registry(); - collectDefaultMetrics(processRegistry, { + if (!_registry) { + _registry = new Registry(); + collectDefaultMetrics(_registry, { service: info.service || 'xchain-service', version: info.version || '', coin: info.coin || '', network: info.network || '' }); } - return processRegistry; + return _registry; } // Returned once and resolved on every call, so a module can do @@ -293,9 +293,9 @@ function getRegistry(info = {}) { // once patchConsole/installObservability has run. Before either, it falls // through to the global console rather than throwing: a module that logs while // being required must not be able to kill the process. -const lazyLogger = { +const _lazyLogger = { log(level, msg, fields) { - if (processLogger) return processLogger.log(level, msg, fields); + if (_logger) return _logger.log(level, msg, fields); const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; fn(fields && Object.keys(fields).length ? `${msg} ${util.inspect(fields, { depth: 2 })}` : String(msg)); return null; @@ -306,24 +306,24 @@ const lazyLogger = { error(msg, fields) { return this.log('error', msg, fields); } }; -function getLogger() { return lazyLogger; } +function getLogger() { return _lazyLogger; } // Attaches the shared registry to the FIRST shipper only; later shippers get // their own line accounting and leave the shared series alone. function newShipper(opts) { - const registry = shipperAttached ? null : getRegistry(opts); - if (registry) shipperAttached = true; - return createLogShipper({ ...opts, console: opts.console || prePatchSink || console, registry }); + const registry = _shipperAttached ? null : getRegistry(opts); + if (registry) _shipperAttached = true; + return createLogShipper({ ...opts, console: opts.console || _sink || console, registry }); } /** * Routes the service's existing bare console.* calls through the log shim, so - * levels, formats and redaction apply to every bare call site in the service - * without rewriting one of them. + * levels, formats and redaction apply to the ~850 hub call sites and their + * siblings without rewriting one of them. * * Called at the TOP of an entry file, before anything logs. Every service logs - * before installObservability runs today (hub, decoder, indexer, encoder and - * tracker api.js all patch at the top and install far below), and the lines + * before installObservability runs today (hub api.js:29 vs :407, and the same + * shape in the decoder, indexer, encoder and tracker), and the lines that get * lost that way are the env-validation and crash lines an operator most needs * framed. That is why this is a separate call rather than part of install. * @@ -338,7 +338,7 @@ function newShipper(opts) { function patchConsole(opts = {}) { const { service = 'xchain-service', version = '', coin = '', network = '', env = process.env } = opts; - if (patchHandle) return patchHandle; + if (_patched) return _patched; if (String(env.XCHAIN_LOG_PATCH || '') === '0') { return { patched: false, logger: getLogger(), unpatch: () => {} }; } @@ -355,10 +355,10 @@ function patchConsole(opts = {}) { sink[name] = fn.bind(console); } sink.log = sink.log || sink.info; - prePatchSink = sink; + _sink = sink; const logger = newShipper({ service, version, coin, network, env, console: sink }); - processLogger = logger; + _logger = logger; for (const [name, level] of Object.entries(CONSOLE_METHODS)) { // util.format is console's own argument semantics: printf-style format @@ -369,7 +369,7 @@ function patchConsole(opts = {}) { console[name] = (...args) => { logger.log(level, util.format(...args)); }; } - patchHandle = { + _patched = { patched: true, logger, unpatch() { @@ -377,23 +377,23 @@ function patchConsole(opts = {}) { if (fn === undefined) delete console[name]; else console[name] = fn; } - patchHandle = null; - prePatchSink = null; - if (processLogger === logger) processLogger = null; + _patched = null; + _sink = null; + if (_logger === logger) _logger = null; } }; - return patchHandle; + return _patched; } -function unpatchConsole() { if (patchHandle) patchHandle.unpatch(); } +function unpatchConsole() { if (_patched) _patched.unpatch(); } // Tests only: drops the process-wide handles so an assertion about a fresh // process does not inherit the previous test's shipper or registry. -function resetObservability() { +function _resetObservability() { unpatchConsole(); - processLogger = null; - processRegistry = null; - shipperAttached = false; + _logger = null; + _registry = null; + _shipperAttached = false; } module.exports = { @@ -407,5 +407,5 @@ module.exports = { unpatchConsole, getLogger, getRegistry, - resetObservability + _resetObservability }; diff --git a/src/observability/logShipper.js b/src/observability/logShipper.js index 38cf5fa3..a36c8272 100644 --- a/src/observability/logShipper.js +++ b/src/observability/logShipper.js @@ -68,7 +68,7 @@ const REDACTED = '[redacted]'; // Deliberately NOT swept here: bare hex. Hub and indexer lines are full of // legitimate 64-char hex (txids, block hashes, state roots, digests) and -// redacting those would gut the logs this shim exists to make readable. The +// redacting those would gut the logs this spec exists to make readable. The // watch collector applies a hex-key sweep at its own boundary instead, where the // output is a committed report rather than an operator's live tail. function scrubMessage(msg) { @@ -104,9 +104,7 @@ function formatFieldValue(value) { * * The message stays immediately after the service tag so every existing * substring grep across the platform (handover greps, StatusService, the - * decoder's wait loops) keeps matching: the prefix is the only addition. - * - * The level token is lowercase on purpose: an uppercase ERROR would + * decoder's wait loops) keeps matching: the prefix is the only addition. The level token is lowercase on purpose: an uppercase ERROR would * be counted by the server-monitor's `grep -cE 'ERROR|FATAL'` rate alert on * every console.error line and page the fleet on first deploy. */ @@ -219,12 +217,12 @@ class LogShipper { this.pending = null; this.lastErrorNoteMs = 0; this.stats = { emitted: 0, shipped: 0, dropped: 0, failures: 0 }; - this.transport = transport || ((body) => this.postBatch(body)); - if (registry) this.attachMetrics(registry); - if (this.config.shipEnabled) this.startFlushTimer(); + this.transport = transport || ((body) => this._post(body)); + if (registry) this._attachMetrics(registry); + if (this.config.shipEnabled) this._startTimer(); } - attachMetrics(registry) { + _attachMetrics(registry) { const emitted = registry.counter({ name: 'log_lines_emitted_total', help: 'Log lines emitted by the structured log shim', labelNames: ['level'] }); // Cumulative totals are counters, not gauges: a _total-suffixed gauge // reads to a scraper as a resettable level, so rate() over it is @@ -235,7 +233,7 @@ class LogShipper { const failed = registry.counter({ name: 'log_ship_failures_total', help: 'Failed log-ship batch attempts' }); // Buffer depth IS a level, so it stays a gauge. const pending = registry.gauge({ name: 'log_ship_buffer_lines', help: 'Log lines currently buffered for shipping' }); - this.levelCounter = emitted; + this._levelCounter = emitted; registry.addCollector(() => { shipped.setMonotonic({}, this.stats.shipped); dropped.setMonotonic({}, this.stats.dropped); @@ -244,7 +242,7 @@ class LogShipper { }); } - startFlushTimer() { + _startTimer() { this.timer = setInterval(() => { this.flush(); }, this.config.intervalMs); // A logging timer must never be the reason the process refuses to exit. if (typeof this.timer.unref === 'function') this.timer.unref(); @@ -265,9 +263,9 @@ class LogShipper { Object.assign(record, safeFields); this.stats.emitted += 1; - if (this.levelCounter) this.levelCounter.inc({ level }, 1); - this.emitLocal(level, record); - if (this.config.shipEnabled) this.enqueue(record); + if (this._levelCounter) this._levelCounter.inc({ level }, 1); + this._emitLocal(level, record); + if (this.config.shipEnabled) this._enqueue(record); return record; } @@ -276,7 +274,7 @@ class LogShipper { warn(msg, fields) { return this.log('warn', msg, fields); } error(msg, fields) { return this.log('error', msg, fields); } - emitLocal(level, record) { + _emitLocal(level, record) { const fn = level === 'error' ? (this.console.error || this.console.log) : level === 'warn' ? (this.console.warn || this.console.log) : this.console.log; @@ -288,7 +286,7 @@ class LogShipper { else fn.call(this.console, formatTextLine(record)); } - enqueue(record) { + _enqueue(record) { if (this.buffer.length >= this.config.maxBuffer) { // Drop oldest: during an incident the newest lines are the ones worth having. this.buffer.shift(); @@ -319,7 +317,7 @@ class LogShipper { const keep = batch.slice(Math.max(0, batch.length - room)); this.stats.dropped += batch.length - keep.length; this.buffer.unshift(...keep); - this.noteShipError(err); + this._noteError(err); }) .finally(() => { this.inFlight = false; this.pending = null; }); return this.pending; @@ -327,7 +325,7 @@ class LogShipper { // At most one stderr line per minute: a collector outage must not itself // become the log flood that fills the disk. - noteShipError(err) { + _noteError(err) { const now = Date.now(); if (now - this.lastErrorNoteMs < 60000) return; this.lastErrorNoteMs = now; @@ -335,7 +333,7 @@ class LogShipper { if (sink) sink.call(this.console, `[log-ship] batch failed (${err && err.message ? err.message : 'unknown'}); buffered=${this.buffer.length} dropped=${this.stats.dropped}`); } - postBatch(body) { + _post(body) { const { url, token, timeoutMs } = this.config; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); diff --git a/src/observability/metrics.js b/src/observability/metrics.js index 408aefc5..656191a1 100644 --- a/src/observability/metrics.js +++ b/src/observability/metrics.js @@ -18,8 +18,8 @@ * (text/plain; version=0.0.4). It exists instead of prom-client because every * xchain-* service is an independent repo with its own package.json: a shared * npm dependency would need publishing and six lockfile bumps, while this file - * is vendored byte-identically by bin/sync-observability.sh, whose --check runs - * as a drift tier of the hub's CI. Zero deps also means /metrics cannot pull + * is vendored byte-identically by bin/sync-observability.sh and gated in CI by + * a parity check across those copies. Zero deps also means /metrics cannot pull * a new supply-chain surface into a consensus-critical service. * * Cardinality is the failure mode that kills a Prometheus server, so two guards @@ -68,8 +68,8 @@ function assertMetricName(name) { function assertLabelNames(labelNames) { for (const l of labelNames) { if (!LABEL_NAME_RE.test(l)) throw new Error(`invalid label name: ${l}`); - // `__name__` is the format's reserved series identity. `le` is reserved - // too, but only histograms carry it, so the Histogram constructor refuses it. + // Reserved by the exposition format itself: `le` belongs to histogram + // buckets and `__name__` is the series identity. if (l === '__name__') throw new Error('label name __name__ is reserved'); } } @@ -89,7 +89,7 @@ class Metric { // Series identity is the ordered label-value tuple. Declared order is used // (not caller order), so {a,b} and {b,a} address the same series. - seriesKey(labels) { + _key(labels) { if (this.labelNames.length === 0) return ''; const parts = []; for (const name of this.labelNames) { @@ -99,7 +99,7 @@ class Metric { return JSON.stringify(parts); } - normalizeLabels(labels) { + _normalizeLabels(labels) { const out = {}; for (const name of this.labelNames) { const v = labels[name]; @@ -115,15 +115,15 @@ class Metric { // Returns null when the series cap is hit; every mutator treats null as // "drop this observation" so a cardinality blowup degrades instead of OOMs. - seriesFor(labels, makeState) { - const key = this.seriesKey(labels); + _series(labels, makeState) { + const key = this._key(labels); let s = this.series.get(key); if (s) return s; if (this.series.size >= this.maxSeries) { - if (this.registry) this.registry.noteSeriesDrop(this.name); + if (this.registry) this.registry._noteSeriesDrop(this.name); return null; } - s = { labels: this.normalizeLabels(labels), ...makeState() }; + s = { labels: this._normalizeLabels(labels), ...makeState() }; this.series.set(key, s); return s; } @@ -139,7 +139,7 @@ class Counter extends Metric { if (!Number.isFinite(value) || value < 0) { throw new Error(`counter ${this.name}: inc value must be a non-negative finite number`); } - const s = this.seriesFor(labels, () => ({ value: 0 })); + const s = this._series(labels, () => ({ value: 0 })); if (s) s.value += value; return s ? s.value : undefined; } @@ -149,12 +149,12 @@ class Counter extends Metric { // ignored so a source reset cannot make a counter go backwards mid-scrape. setMonotonic(labels, value) { if (!Number.isFinite(value) || value < 0) return; - const s = this.seriesFor(labels, () => ({ value: 0 })); + const s = this._series(labels, () => ({ value: 0 })); if (s && value >= s.value) s.value = value; } get(labels = {}) { - const s = this.series.get(this.seriesKey(labels)); + const s = this.series.get(this._key(labels)); return s ? s.value : 0; } @@ -171,13 +171,13 @@ class Gauge extends Metric { set(labels = {}, value) { if (typeof labels === 'number') { value = labels; labels = {}; } if (!Number.isFinite(value)) throw new Error(`gauge ${this.name}: set value must be finite`); - const s = this.seriesFor(labels, () => ({ value: 0 })); + const s = this._series(labels, () => ({ value: 0 })); if (s) s.value = value; } inc(labels = {}, value = 1) { if (typeof labels === 'number') { value = labels; labels = {}; } - const s = this.seriesFor(labels, () => ({ value: 0 })); + const s = this._series(labels, () => ({ value: 0 })); if (s) s.value += value; } @@ -187,7 +187,7 @@ class Gauge extends Metric { } get(labels = {}) { - const s = this.series.get(this.seriesKey(labels)); + const s = this.series.get(this._key(labels)); return s ? s.value : 0; } @@ -211,7 +211,7 @@ class Histogram extends Metric { observe(labels = {}, value) { if (typeof labels === 'number') { value = labels; labels = {}; } if (!Number.isFinite(value)) return; - const s = this.seriesFor(labels, () => ({ counts: new Array(this.buckets.length).fill(0), sum: 0, count: 0 })); + const s = this._series(labels, () => ({ counts: new Array(this.buckets.length).fill(0), sum: 0, count: 0 })); if (!s) return; s.count += 1; s.sum += value; @@ -220,7 +220,7 @@ class Histogram extends Metric { } } - // Starts a wall-clock timer; the returned stop function observes and returns elapsed seconds. + // Times a callback (sync or promise) and observes its wall duration in seconds. startTimer(labels = {}) { const start = process.hrtime.bigint(); return () => { @@ -231,7 +231,7 @@ class Histogram extends Metric { } get(labels = {}) { - const s = this.series.get(this.seriesKey(labels)); + const s = this.series.get(this._key(labels)); return s ? { sum: s.sum, count: s.count, counts: s.counts.slice() } : { sum: 0, count: 0, counts: [] }; } @@ -273,7 +273,7 @@ class Registry { this.metrics.set(this.seriesDropped.name, this.seriesDropped); } - noteSeriesDrop(metricName) { + _noteSeriesDrop(metricName) { // Guarded: the drop counter itself is capped, and a runaway metric name // space must not turn the guard into the leak. this.seriesDropped.inc({ metric: metricName }, 1); @@ -288,7 +288,7 @@ class Registry { // to be required, and a service that wires observability twice must not die // at startup over a duplicate declaration that asks for exactly what is // already there. - registerMetric(metric) { + _register(metric) { const existing = this.metrics.get(metric.name); if (existing) { const same = existing.type === metric.type @@ -303,9 +303,9 @@ class Registry { return metric; } - counter(opts) { return this.registerMetric(new Counter({ maxSeries: this.maxSeries, ...opts, registry: this })); } - gauge(opts) { return this.registerMetric(new Gauge({ maxSeries: this.maxSeries, ...opts, registry: this })); } - histogram(opts) { return this.registerMetric(new Histogram({ maxSeries: this.maxSeries, ...opts, registry: this })); } + counter(opts) { return this._register(new Counter({ maxSeries: this.maxSeries, ...opts, registry: this })); } + gauge(opts) { return this._register(new Gauge({ maxSeries: this.maxSeries, ...opts, registry: this })); } + histogram(opts) { return this._register(new Histogram({ maxSeries: this.maxSeries, ...opts, registry: this })); } get(name) { return this.metrics.get(name) || null; } From aed07020a01cdc8fe5ecbf16338dd43031e05d2b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 19:31:25 -0700 Subject: [PATCH 40/62] Update observability reset test hook --- test/unit/observability.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/observability.test.js b/test/unit/observability.test.js index 071f1a47..b08350dd 100644 --- a/test/unit/observability.test.js +++ b/test/unit/observability.test.js @@ -89,7 +89,7 @@ describe('observability/installObservability', function () { // The registry and shipper are process-wide by design (one process is one // service), so a suite that installs many times has to drop them between // cases or it reads the previous case's service label and HTTP series. - afterEach(function () { require('../../src/observability/index.js').resetObservability(); }); + afterEach(function () { require('../../src/observability/index.js')._resetObservability(); }); registerRouteTests(testContext); }); @@ -106,10 +106,10 @@ describe('observability/logShipper: message redaction', function () { }); describe('observability/patchConsole', function () { - const { patchConsole, unpatchConsole, getLogger, getRegistry, resetObservability } = + const { patchConsole, unpatchConsole, getLogger, getRegistry, _resetObservability } = require('../../src/observability/index.js'); - afterEach(function () { resetObservability(); }); + afterEach(function () { _resetObservability(); }); registerFlushAndHealthTests({ ...testContext, patchConsole, unpatchConsole, getLogger, getRegistry }); From 5ba735bd597a2b571067c2f2753bbe25ac0bfb63 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 22:44:37 -0700 Subject: [PATCH 41/62] fix(sync): reconcile unstable index status ids --- src/client/applier.js | 53 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/client/applier.js b/src/client/applier.js index 6c8f1cfd..41214df2 100644 --- a/src/client/applier.js +++ b/src/client/applier.js @@ -148,6 +148,18 @@ class ClientApplier { 'rollcall_gates' ]); + // Repair-only identities for replicated lookup tables whose natural value must + // agree with the source at the source's carried id. These tables are normally + // INSERT IGNORE because every block may re-send them, but that cannot repair an + // id mapping changed by first-seen AUTO_INCREMENT order: PRIMARY collisions look + // benign even when the row at that id has a different value, while natural-key + // collisions silently keep the same value at the wrong id. Upsert-only replicated + // tables therefore need ID-stable migrations; this map is the bounded recovery + // path for an already-unstable replica during a from-zero lookup repair. + this.repairNaturalKeyColumns = new Map([ + ['index_statuses', ['status']] + ]); + // Mutable aggregates that the indexer full-dump re-sends with their CURRENT // value (markets = OHLCV; attest_validator_stats = running counters). On a // non-empty replica a plain INSERT collides on their UNIQUE key (ER_DUP_ENTRY, @@ -577,6 +589,12 @@ class ClientApplier { for(let table in snapshotData.tables){ let rows = snapshotData.tables[table]; if(!rows || rows.length === 0) continue; + // The strict option is only set by the from-zero lookup repair. Reconcile + // the carried id/status pairs before INSERT IGNORE so both a natural-key + // collision and a wrong row hidden by a PRIMARY collision are corrected. + let repairKeyColumns = this.repairNaturalKeyColumns.get(table); + if(opts && opts.strictIgnoreCheck && repairKeyColumns) + await this.reconcileLookupRows(table, rows, repairKeyColumns); await this.insertRows(table, rows, opts); } // Rebuild balances if this snapshot touched credits/debits. The @@ -725,6 +743,7 @@ class ClientApplier { throw new Error('Rejected column name in insertRows: ' + col + ' (' + colCheck.reason + ')'); } } + // Mutable-aggregate full-dump tables (useUpsert) overwrite their existing row so // a re-dump on a non-empty replica refreshes (not skips) stale values. // Batch inserts in groups of 100 for efficiency @@ -827,6 +846,40 @@ class ClientApplier { return suspect; } + // Remove rows that conflict with the source page by either surrogate id or natural + // key. The caller immediately re-inserts the page in the same transaction. Probing + // exact pairs first keeps an already-converged repair idempotent and avoids writes. + async reconcileLookupRows(table, rows, keyColumns){ + let retireIds = new Set(); + for(let row of rows){ + let id = row ? row.id : undefined; + if(id === undefined || id === null) + throw new Error('Lookup repair row for ' + table + ' is missing id'); + + let values = []; + for(let column of keyColumns){ + if(row[column] === undefined) + throw new Error('Lookup repair row for ' + table + ' is missing natural key ' + column); + values.push(row[column]); + } + + let keyHolders = await this.db.findRowIdsByKeyColumns(table, keyColumns, values); + if(keyHolders && keyHolders.length === 1 && Number(keyHolders[0].id) === Number(id)) + continue; + + let idHolder = await this.db.findRowIdById(table, id); + for(let holder of (idHolder || [])) retireIds.add(Number(holder.id)); + + for(let holder of (keyHolders || [])) retireIds.add(Number(holder.id)); + } + + for(let id of retireIds){ + await this.db.deleteRowById(table, id); + logger.warn('LOOKUP_ID_STATUS_RECONCILED table=' + table + ' retired_id=' + id + + ' cause=first_seen_auto_increment_id_instability'); + } + } + // Retire the rows of a superseded lookup generation so the source's rows can land. // // Bounded by the page's own id window, which is what makes the "the source does not From 7d0efc22f7d897adf869ddeb325a55fa81caf950 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 09:03:39 -0700 Subject: [PATCH 42/62] docs(comments): reflow scrubbed explanations --- src/client/hash_verifier.js | 6 ++-- src/config.js | 12 ++++---- src/db/index.js | 8 ++--- src/http/cors_origin.js | 4 +-- .../broadcast_and_heartbeats.js | 18 ++++++----- src/server/poller.js | 30 +++++++++---------- 6 files changed, 40 insertions(+), 38 deletions(-) diff --git a/src/client/hash_verifier.js b/src/client/hash_verifier.js index 0c053305..83d92087 100644 --- a/src/client/hash_verifier.js +++ b/src/client/hash_verifier.js @@ -56,9 +56,9 @@ class HashVerifier { }; } - // Advisory: compare local vs remote per-table content checksums (NON-consensus, - // ). Both sides are BlockHasher.computeTableContentChecksums results over - // the SAME window and the same published bounds. + // Advisory: compare local vs remote per-table content checksums (NON-consensus). + // Both sides are BlockHasher.computeTableContentChecksums results over the SAME + // window and the same published bounds. // // The comparison is deliberately gated on EQUAL ROW COUNTS, and that gate is what // makes the signal trustworthy rather than noisy: diff --git a/src/config.js b/src/config.js index 5a83c05b..5cfdec8d 100644 --- a/src/config.js +++ b/src/config.js @@ -306,12 +306,12 @@ module.exports = { // Transparency endpoint rate limit (requests per minute per IP) config['TRANSPARENCY_RATE_LIMIT'] = parseInt(process.env.TRANSPARENCY_RATE_LIMIT) || 10; - // WebSocket backpressure (item 5410): a replica is dropped only when its send buffer - // is genuinely stuck, not merely slow. MAX_BYTES caps per-peer server memory (a peer - // accumulating past this is not draining); STALL_MS is how long the buffer may go - // without making downward progress before the peer is dropped. This replaces the old - // count-based WS_BACKPRESSURE_LIMIT, which dropped slow-but-draining replicas and - // thrashed them into re-bootstraps. + // WebSocket backpressure: a replica is dropped only when its send buffer is + // genuinely stuck, not merely slow. MAX_BYTES caps per-peer server memory + // (a peer accumulating past this is not draining); STALL_MS is how long the + // buffer may go without making downward progress before the peer is dropped. + // This replaces the old count-based WS_BACKPRESSURE_LIMIT, which dropped + // slow-but-draining replicas and thrashed them into re-bootstraps. config['WS_BACKPRESSURE_MAX_BYTES'] = parseIntMin1(process.env.WS_BACKPRESSURE_MAX_BYTES, 16777216); // 16 MiB config['WS_BACKPRESSURE_STALL_MS'] = parseIntMin1(process.env.WS_BACKPRESSURE_STALL_MS, 30000); // 30 s if(process.env.WS_BACKPRESSURE_LIMIT !== undefined) diff --git a/src/db/index.js b/src/db/index.js index 8c48eeac..b938f629 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -610,10 +610,10 @@ class Database { definition: 'CHAR(64) NULL AFTER `block_merkle_root`' }, { table: 'state_tree_roots', column: 'contract_state_root_shadow', definition: 'CHAR(64) NULL AFTER `contract_state_root`' }, - // Stage B's shadow column ( B3), same reasoning as the two - // above: state_tree_roots is follower-derived, so an aged replica - // never gains it from the definition file and the first shadow-window - // block would fail its INSERT with errno 1054. + // Stage B's shadow column, with the same reasoning as the two above: + // state_tree_roots is follower-derived, so an aged replica never gains + // it from the definition file and the first shadow-window block would + // fail its INSERT with errno 1054. { table: 'state_tree_roots', column: 'balances_root_escrow_shadow', definition: 'CHAR(64) NULL AFTER `contract_state_root_shadow`' }, // The key-rebuild preconditions ride the same ADD COLUMN loop, so the columns diff --git a/src/http/cors_origin.js b/src/http/cors_origin.js index b28a427f..ad5561c2 100644 --- a/src/http/cors_origin.js +++ b/src/http/cors_origin.js @@ -38,8 +38,8 @@ * * Identical by intent to xchain-encoder/src/corsOrigin.js, xchain-hub's * src/lib/corsOrigin.js, xchain-indexer/src/corsOrigin.js, - * xchain-utxo-tracker/src/corsOrigin.js and xchain-sdk/src/corsOrigin.js; keep - * the six in step . + * xchain-utxo-tracker/src/corsOrigin.js and xchain-sdk/src/corsOrigin.js; + * keep the six in step. * * @param {string|undefined|null} raw - the raw CORS_ORIGIN value * @returns {false|string|string[]} `false` (disabled), `'*'` (any), one origin, or an allowlist diff --git a/src/server/block_broadcaster/broadcast_and_heartbeats.js b/src/server/block_broadcaster/broadcast_and_heartbeats.js index ad0240f6..2aeb53a5 100644 --- a/src/server/block_broadcaster/broadcast_and_heartbeats.js +++ b/src/server/block_broadcaster/broadcast_and_heartbeats.js @@ -206,14 +206,16 @@ module.exports = { let data = isPreSerialized ? message : JSON.stringify(message, bigIntReplacer); - // Backpressure (item 5410): drop a peer only when it is genuinely stuck, not merely - // slow. Two independent signals on the OS send buffer: - // 1) a hard byte ceiling - the peer is accumulating unboundedly (server-memory risk); - // 2) a non-draining stall timeout - the buffer has not made any downward progress for - // WS_BACKPRESSURE_STALL_MS. The stall timer resets on ANY drop in bufferedAmount, - // so a slow-but-draining replica keeps resetting and stays connected instead of - // being force-dropped into a re-bootstrap thrash loop (the old count-based check - // dropped it because the buffer rarely returned to exactly zero under load). + // Backpressure: drop a peer only when it is genuinely stuck, not merely slow. + // Two independent signals on the OS send buffer: + // 1) a hard byte ceiling - the peer is accumulating unboundedly + // (server-memory risk); + // 2) a non-draining stall timeout - the buffer has not made any downward + // progress for WS_BACKPRESSURE_STALL_MS. The stall timer resets on ANY + // drop in bufferedAmount, so a slow-but-draining replica keeps resetting + // and stays connected instead of being force-dropped into a re-bootstrap + // thrash loop (the old count-based check dropped it because the buffer + // rarely returned to exactly zero under load). let buffered = ws.bufferedAmount; let drop = null; if(buffered > this.config['WS_BACKPRESSURE_MAX_BYTES']){ diff --git a/src/server/poller.js b/src/server/poller.js index e26bf452..a50aad77 100644 --- a/src/server/poller.js +++ b/src/server/poller.js @@ -43,10 +43,10 @@ const envConfig = require('../config'); const logger = getLogger(); // How many recently broadcast block hashes to retain in memory for the -// net-forward reorg walk-back (item 4830). Comfortably above the source -// indexer's MAX_ROLLBACK_DEPTH (100) so a deep same-interval reorg can be -// walked back one height per poll against the pre-reorg hash we recorded, -// rather than against a fresh (post-reorg) source read that always matches. +// net-forward reorg walk-back. Comfortably above the source indexer's +// MAX_ROLLBACK_DEPTH (100), so a deep same-interval reorg can be walked back one +// height per poll against the pre-reorg hash we recorded, rather than against a +// fresh (post-reorg) source read that always matches. const RECENT_HASH_CAP = 256; // A per-table read in buildBlockPayload may legitimately fail because the source @@ -93,16 +93,16 @@ class ServerPoller { this.activationDelay = (delay === undefined) ? null : delay; this.lastPolledBlock = null; - // Hash of lastPolledBlock's content on the source, so a net-forward reorg + // Hash of lastPolledBlock's content on the source. A net-forward reorg // (rollback + readvance within one poll interval, which keeps the height - // monotonic) is detectable by a changed hash, not just a lower height (4623). + // monotonic) is detectable by a changed hash, not just a lower height. this.lastPolledBlockHash = null; // Bounded map of recently broadcast block hashes (block_index -> content // hash WE broadcast for that height). On a net-forward reorg the walk-back // seeds lastPolledBlockHash from the PRE-reorg hash recorded here, so a - // reorg deeper than one block keeps walking back over subsequent polls - // (item 4830). Works for both dbTypes (the decoder has no sync_meta to read - // a recorded hash from). Capped to the last RECENT_HASH_CAP heights. + // reorg deeper than one block keeps walking back over subsequent polls. This + // works for both dbTypes (the decoder has no sync_meta to read a recorded + // hash from). Capped to the last RECENT_HASH_CAP heights. this.recentBroadcastHashes = new Map(); this.running = false; @@ -268,8 +268,8 @@ class ServerPoller { // leaves currentBlock >= lastPolledBlock, so the height-only check below never // fires, yet the block we already broadcast was orphaned and re-mined. Detect // it by re-reading the source hash at lastPolledBlock; a change means the chain - // forked at or below it. Roll back one block and re-read the prior hash so a - // deeper reorg is walked back over subsequent polls (item 4623). + // forked at or below it. Roll back one block and re-read the prior hash so + // a deeper reorg is walked back over subsequent polls. if(this.lastPolledBlockHash !== null){ let srcHash = await this.sourceBlockHash(this.lastPolledBlock); if(srcHash !== null && srcHash !== this.lastPolledBlockHash){ @@ -417,11 +417,11 @@ class ServerPoller { // Broadcast to subscribers (infraTables enables filtering for infra-only subscribers) this.broadcaster.broadcast(this.chain, this.network, payload, this.infraTables); - // Track the hash we just broadcast so the next poll can detect a - // net-forward reorg that rewrites this block (item 4623). + // Track the hash we just broadcast so the next poll can detect + // a net-forward reorg that rewrites this block. this.lastPolledBlockHash = (this.dbType === 'decoder') ? payload.block_hash : payload.ledger_hash; - // Record it for the net-forward walk-back so a deeper reorg can be - // detected against this pre-reorg hash on a later poll (item 4830). + // Record it for the net-forward walk-back so a deeper reorg can + // be detected against this pre-reorg hash on a later poll. this.recentBroadcastHashes.set(nextBlock, this.lastPolledBlockHash); if(nextBlock > RECENT_HASH_CAP) this.recentBroadcastHashes.delete(nextBlock - RECENT_HASH_CAP - 1); From d4a119bfbff7885dde0eef3d4bd2d2b631a37162 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 16:09:26 -0700 Subject: [PATCH 43/62] test(sync): stub indexer util in rollback coverage guard --- test/unit/rollback_coverage.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/rollback_coverage.test.js b/test/unit/rollback_coverage.test.js index 3f44917f..2ce63c90 100644 --- a/test/unit/rollback_coverage.test.js +++ b/test/unit/rollback_coverage.test.js @@ -240,7 +240,7 @@ describe('Rollback coverage guard @regression', function(){ const IndexerRollback = require(rbPath); // Rollback's constructor only assigns config aliases + the static table // arrays (no DB/network work), so a bare stub yields the lists we need. - const indexer = new IndexerRollback({}); + const indexer = new IndexerRollback({ util: { resetLists () {} } }); const indexerRollback = new Set([...indexer.dataTables, ...indexer.blockTables]); // INDEXER_LOCAL (tables the indexer rolls back that sync intentionally @@ -290,7 +290,7 @@ describe('Rollback coverage guard @regression', function(){ const rbPath = indexerFile(INDEXER_ROLLBACK_ENTRY); if(!requireSibling(this, rbPath)) return; const IndexerRollback = require(rbPath); - const indexer = new IndexerRollback({}); + const indexer = new IndexerRollback({ util: { resetLists () {} } }); assert.deepStrictEqual( [...(indexer.indexTables || [])].sort(), [...rollback.indexTables].sort(), From 4b1078d4c950b7171dc44b3e0404890674569e6d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 21:05:20 -0700 Subject: [PATCH 44/62] test(sync): drop dead fixture-credential helper, de-namify hub_client literals sync_service.test.js's indexerCfg() built a config with db_user 'u'/db_pass 'p' but was never called since the 2026-09-15 suite split; removed as dead code. hub_client.test/01 and 02 held the same literal 'u'/'p' as inert config-mapping fixture data (HubClient never opens a DB connection); renamed to fixture-user/fixture-pass so the strings stop reading as credentials. Neither file constructs a Database or opens a socket (confirmed with NODE_DEBUG=net: zero NET events across both files, 57/57 passing). The live socket leak the ledger tracks (70,991 access-denied entries on one CI venue) traces to other test/unit/*.js files outside this row's declared surface that call new Database(...) without stubbing mariadb; see the lane report. --- .../hub_client.test/01_get_indexer_configs.test.js | 12 ++++++------ .../hub_client.test/02_get_decoder_configs.test.js | 2 +- test/unit/sync_service.test.js | 7 ------- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/test/unit/hub_client.test/01_get_indexer_configs.test.js b/test/unit/hub_client.test/01_get_indexer_configs.test.js index 451a26e8..d624ba3f 100644 --- a/test/unit/hub_client.test/01_get_indexer_configs.test.js +++ b/test/unit/hub_client.test/01_get_indexer_configs.test.js @@ -32,7 +32,7 @@ describe('HubClient', function(){ bitcoin: { mainnet: { 'xchain-indexer': { - db_host: 'db1', db_port: '3307', name: 'btc_main', user: 'u', pass: 'p' + db_host: 'db1', db_port: '3307', name: 'btc_main', user: 'fixture-user', pass: 'fixture-pass' } } } @@ -50,7 +50,7 @@ describe('HubClient', function(){ sinon.stub(axios, 'post').resolves({ data: { result: { litecoin: { testnet: { - 'xchain-indexer': { host: 'fallback_host', port: '3308', name: 'ltc', user: 'u', pass: 'p' } + 'xchain-indexer': { host: 'fallback_host', port: '3308', name: 'ltc', user: 'fixture-user', pass: 'fixture-pass' } } } }}}); @@ -61,7 +61,7 @@ describe('HubClient', function(){ it('defaults db_host to 127.0.0.1 when neither present', async function(){ sinon.stub(axios, 'post').resolves({ data: { result: { - doge: { regtest: { 'xchain-indexer': { name: 'd', user: 'u', pass: 'p' } } } + doge: { regtest: { 'xchain-indexer': { name: 'd', user: 'fixture-user', pass: 'fixture-pass' } } } }}}); let configs = await hub.getIndexerConfigs(); assert.strictEqual(configs[0].db_host, '127.0.0.1'); @@ -102,7 +102,7 @@ describe('HubClient', function(){ it('skips empty coin keys', async function(){ sinon.stub(axios, 'post').resolves({ data: { result: { - '': { mainnet: { 'xchain-indexer': { name: 'x', user: 'u', pass: 'p' } } } + '': { mainnet: { 'xchain-indexer': { name: 'x', user: 'fixture-user', pass: 'fixture-pass' } } } }}}); let configs = await hub.getIndexerConfigs(); assert.strictEqual(configs.length, 0); @@ -110,8 +110,8 @@ describe('HubClient', function(){ it('handles multiple chains', async function(){ sinon.stub(axios, 'post').resolves({ data: { result: { - bitcoin: { mainnet: { 'xchain-indexer': { name: 'b', user: 'u', pass: 'p' } } }, - litecoin: { mainnet: { 'xchain-indexer': { name: 'l', user: 'u', pass: 'p' } } } + bitcoin: { mainnet: { 'xchain-indexer': { name: 'b', user: 'fixture-user', pass: 'fixture-pass' } } }, + litecoin: { mainnet: { 'xchain-indexer': { name: 'l', user: 'fixture-user', pass: 'fixture-pass' } } } }}}); let configs = await hub.getIndexerConfigs(); assert.strictEqual(configs.length, 2); diff --git a/test/unit/hub_client.test/02_get_decoder_configs.test.js b/test/unit/hub_client.test/02_get_decoder_configs.test.js index f85a3325..dcf5a240 100644 --- a/test/unit/hub_client.test/02_get_decoder_configs.test.js +++ b/test/unit/hub_client.test/02_get_decoder_configs.test.js @@ -29,7 +29,7 @@ describe('HubClient', function(){ describe('getDecoderConfigs', function(){ it('extracts xchain-decoder entries', async function(){ sinon.stub(axios, 'post').resolves({ data: { result: { - bitcoin: { mainnet: { 'xchain-decoder': { db_host: 'dh', db_port: '3309', name: 'dec', user: 'u', pass: 'p' } } } + bitcoin: { mainnet: { 'xchain-decoder': { db_host: 'dh', db_port: '3309', name: 'dec', user: 'fixture-user', pass: 'fixture-pass' } } } }}}); let configs = await hub.getDecoderConfigs(); assert.strictEqual(configs.length, 1); diff --git a/test/unit/sync_service.test.js b/test/unit/sync_service.test.js index 69dbfb63..b4aad5d1 100644 --- a/test/unit/sync_service.test.js +++ b/test/unit/sync_service.test.js @@ -33,13 +33,6 @@ const ServerPoller = require('../../src/server/poller'); const fs = require('fs'); const path = require('path'); -function indexerCfg(over){ - return Object.assign({ - coin: 'bitcoin', network: 'mainnet', dbType: 'indexer', - db_host: 'srchost', db_port: 3306, db_name: 'btc_idx', db_user: 'u', db_pass: 'p' - }, over || {}); -} - let service, config; function registerHooks(){ From 77706f71933d71a618f5c7009c855a9c44c573dc Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 08:53:05 -0700 Subject: [PATCH 45/62] chore(observability): vendor the hub canonical module, unmatched-route label cap Byte-identical copy of xchain-hub/src/observability/index.js via bin/sync-observability.sh: unmatched request paths now share one overflow label past a fixed number of distinct first segments, so a flood of unmatched URLs cannot spend the shared per-metric series budget. --- src/observability/index.js | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/observability/index.js b/src/observability/index.js index f07981c4..26b8de5b 100644 --- a/src/observability/index.js +++ b/src/observability/index.js @@ -78,6 +78,17 @@ let _shipperAttached = false; // (` warn [svc] warn [svc] msg`). let _sink = null; +// routeLabel's unmatched-path fallback (below) hands out one label per +// distinct first path segment, and that segment is chosen by whoever sends +// the request. Without a cap, a flood of distinct unmatched segments buys one +// series per request against the SHARED per-metric budget every route draws +// from (Registry maxSeries, metrics.js), and once that budget is spent the +// service's own routes can no longer register a series either. Past +// UNMATCHED_ROUTE_LABEL_CAP distinct segments, every further one collapses +// onto a single overflow label instead of buying its own series. +const UNMATCHED_ROUTE_LABEL_CAP = 20; +const _unmatchedRouteLabels = new Set(); + const CONSOLE_METHODS = { log: 'info', info: 'info', warn: 'warn', error: 'error', debug: 'debug' }; function toBool(v, fallback = false) { @@ -110,7 +121,10 @@ function timingSafeEqual(a, b) { // Path label for HTTP metrics. Express route patterns ("/hub-db/snapshot/:t") // are already low-cardinality; a raw URL is not, so anything without a matched // route falls back to its first path segment. This is the difference between a -// dozen series and one per block height. +// dozen series and one per block height. The first segment is still whatever +// the requester sent, so past UNMATCHED_ROUTE_LABEL_CAP distinct segments seen +// (above), later ones share a fixed overflow label instead of each buying a +// new series. function routeLabel(req) { if (req.route && req.route.path) { const base = req.baseUrl || ''; @@ -119,7 +133,14 @@ function routeLabel(req) { } const raw = (req.originalUrl || req.url || '/').split('?')[0]; const seg = raw.split('/').filter(Boolean)[0]; - return seg ? `/${seg}` : '/'; + if (!seg) return '/'; + const label = `/${seg}`; + if (_unmatchedRouteLabels.has(label)) return label; + if (_unmatchedRouteLabels.size < UNMATCHED_ROUTE_LABEL_CAP) { + _unmatchedRouteLabels.add(label); + return label; + } + return '/_unmatched'; } // Express dispatches its router stack in registration order, so a timing @@ -394,6 +415,7 @@ function _resetObservability() { _logger = null; _registry = null; _shipperAttached = false; + _unmatchedRouteLabels.clear(); } module.exports = { From cb1c908e477028ec3b5aefc27d9cb0154848d077 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 09:17:00 -0700 Subject: [PATCH 46/62] refactor(applier): name the incremental snapshot table loop as its own step applyIncrementalSnapshot grew past the 60-line readability limit when the lookup-row reconcile step was added, which put the repository one function over its structure baseline and refused every push. The per-table insert loop is now insertSnapshotTables, called inside the same transaction; behaviour is unchanged and the applier unit suites pass. --- src/client/applier.js | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/client/applier.js b/src/client/applier.js index 41214df2..bac0ee02 100644 --- a/src/client/applier.js +++ b/src/client/applier.js @@ -586,17 +586,7 @@ class ClientApplier { await this.db.beginTransaction(); try { - for(let table in snapshotData.tables){ - let rows = snapshotData.tables[table]; - if(!rows || rows.length === 0) continue; - // The strict option is only set by the from-zero lookup repair. Reconcile - // the carried id/status pairs before INSERT IGNORE so both a natural-key - // collision and a wrong row hidden by a PRIMARY collision are corrected. - let repairKeyColumns = this.repairNaturalKeyColumns.get(table); - if(opts && opts.strictIgnoreCheck && repairKeyColumns) - await this.reconcileLookupRows(table, rows, repairKeyColumns); - await this.insertRows(table, rows, opts); - } + await this.insertSnapshotTables(snapshotData.tables, opts); // Rebuild balances if this snapshot touched credits/debits. The // incremental catch-up inserts new credit/debit rows, but the // balances table is a derived aggregate. Without recomputing it @@ -635,6 +625,22 @@ class ClientApplier { } } + // One incremental snapshot's tables, inserted in payload order inside the + // caller's transaction. The strict option is only set by the from-zero lookup + // repair: reconcile the carried id/status pairs before INSERT IGNORE so both a + // natural-key collision and a wrong row hidden by a PRIMARY collision are + // corrected. + async insertSnapshotTables(tables, opts){ + for(let table in tables){ + let rows = tables[table]; + if(!rows || rows.length === 0) continue; + let repairKeyColumns = this.repairNaturalKeyColumns.get(table); + if(opts && opts.strictIgnoreCheck && repairKeyColumns) + await this.reconcileLookupRows(table, rows, repairKeyColumns); + await this.insertRows(table, rows, opts); + } + } + // Replace the decoder `dispensers` table wholesale from a freshly-fetched full // set. dispensers is excluded from the block stream and the id-cursor lookup // paging (no monotonic id; the decoder soft-expires then hard-purges rows), so From 6c972a69e23af3471ab9ba54e15ac9def28109fb Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 10:02:37 -0700 Subject: [PATCH 47/62] build(ci): grade the fast tier on a push, defer the heavy tiers to the sweep Generated block: do not hand-edit it. Change the tier config and re-run the tier wirer, which owns both this file and the hook so the two cannot drift. The pre-push venue gate ran the whole GitHub transcript on every push, coverage re-runs and perf scenarios included. That is the right set to grade a release with and the wrong set to pay for on every push, especially while three venues serve every repo on the platform: a long gate does not just cost its own minutes, it forms a queue behind itself for every other session pushing that hour. A push now grades the fast tier. The tiers named in the generated block move to the scheduled full sweep, which already runs against every repo every three hours and before any release or deploy, so nothing stops being graded. The verdict line is rewritten with it, which is the part that matters: a fast run can no longer print "all tiers green (same set GitHub CI runs)". It prints which tiers were deferred and states plainly that they were NOT graded there, so a fast green can never be mistaken for a full one. The dispatcher's verdict cache is keyed on repo + sha + cmd, so a fast green cannot stand in for a full one either. --- bin/ci-full.sh | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/bin/ci-full.sh b/bin/ci-full.sh index fa88a6bf..0450c82e 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -49,7 +49,35 @@ SELF="$(pwd)" SIB="$(cd .. && pwd)" FAILED="" +# >>> ci-tier (generated block; re-run the tier wirer to update) >>> +# Tier classes. A push grades the FAST tier only: the unit job, the pin and +# drift guards, and the structure and hygiene checks the hook runs before it +# dispatches. The tiers named below (coverage re-runs, perf scenarios) are +# skipped when the gate sets CI_TIER=fast, and each skip is recorded so the +# closing verdict can never claim a green it did not earn. Nothing stops +# being graded: a scheduled sweep re-runs this same script with CI_TIER=full +# on every repo every three hours and before any release or deploy, and a +# red there is tracked down and fixed first. CI_TIER is unset for a hand +# run, so a bare `npm run ci:full` still runs every tier as it always did. +CI_TIER_FULL_ONLY=( + "coverage ratchet (coverage:check)" +) +DEFERRED="" +ci_tier_deferred() { + [ "${CI_TIER:-full}" = "fast" ] || return 1 + local t + for t in ${CI_TIER_FULL_ONLY[@]+"${CI_TIER_FULL_ONLY[@]}"}; do + if [ "$t" = "$1" ]; then + DEFERRED="$DEFERRED [$1]" + echo; echo "ci:full ===== $1 DEFERRED (CI_TIER=fast, runs in the full sweep) =====" + return 0 + fi + done + return 1 +} +# <<< ci-tier <<< run_tier() { + ci_tier_deferred "$1" && return 0 # ci-tier guard (generated) local name="$1"; shift echo; echo "ci:full ===== $name =====" if "$@"; then @@ -157,8 +185,20 @@ run_tier "identity pin (armed map, vendored coins)" node bin/pin-identity.js --c run_tier "coverage ratchet (coverage:check)" npm run coverage:check echo +# >>> ci-tier summary (generated) >>> +echo "ci:full: tier class ${CI_TIER:-full}" +if [ -n "${DEFERRED:-}" ]; then + echo "ci:full: DEFERRED to the full sweep:$DEFERRED" +fi +# <<< ci-tier summary <<< if [ -n "$FAILED" ]; then echo "ci:full: RED tiers:$FAILED" exit 1 fi -echo "ci:full: all tiers green (same set GitHub CI runs)" +# >>> ci-tier verdict (generated) >>> +if [ "${CI_TIER:-full}" = "fast" ]; then + echo "ci:full: all FAST tiers green; the DEFERRED tiers above were NOT graded here" +else + echo "ci:full: all tiers green (same set GitHub CI runs)" +fi +# <<< ci-tier verdict <<< From 02b1d2827b6589e41a991722f45e833d962f3090 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 13:49:59 -0700 Subject: [PATCH 48/62] fix(sync): own-property lookup for generatedColumns table names Prevent prototype keys like constructor from reaching Object.prototype and throwing when passed to new Set(); unknown tables now always answer an empty Set. --- src/schema/generated_columns.js | 2 +- ...a_generated_columns_prototype_keys.test.js | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 test/unit/schema_generated_columns_prototype_keys.test.js diff --git a/src/schema/generated_columns.js b/src/schema/generated_columns.js index b41b54b3..df8f668a 100644 --- a/src/schema/generated_columns.js +++ b/src/schema/generated_columns.js @@ -44,7 +44,7 @@ const GENERATED_COLUMNS = Object.freeze({ const _sets = new Map(); function generatedColumns(table){ if(!_sets.has(table)) - _sets.set(table, new Set(GENERATED_COLUMNS[table] || [])); + _sets.set(table, new Set(Object.prototype.hasOwnProperty.call(GENERATED_COLUMNS, table) ? GENERATED_COLUMNS[table] : [])); return _sets.get(table); } diff --git a/test/unit/schema_generated_columns_prototype_keys.test.js b/test/unit/schema_generated_columns_prototype_keys.test.js new file mode 100644 index 00000000..78f2a5c7 --- /dev/null +++ b/test/unit/schema_generated_columns_prototype_keys.test.js @@ -0,0 +1,44 @@ +/******************************************************************** + * + * Copyright © 2025-2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC - https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ******************************************************************** + * test/unit/schema_generated_columns_prototype_keys.test.js + * + * generatedColumns(table) used a plain `GENERATED_COLUMNS[table]` lookup, so a + * prototype key such as `constructor` resolved through Object.prototype to a + * function, and `new Set(function)` throws instead of yielding an empty Set. An + * unknown table name must always answer an empty Set, prototype keys included. + */ + +'use strict'; + +const assert = require('assert'); + +const { generatedColumns } = require('../../src/schema/generated_columns'); + +describe('generatedColumns: prototype-key table names @regression', function(){ + + it('returns an empty Set for constructor, __proto__ and toString', function(){ + assert.strictEqual(generatedColumns('constructor').size, 0); + assert.strictEqual(generatedColumns('__proto__').size, 0); + assert.strictEqual(generatedColumns('toString').size, 0); + }); + + it('still returns contract_state\'s own generated column', function(){ + assert.deepStrictEqual([...generatedColumns('contract_state')], ['state_key_bin']); + }); + + it('caches the same Set across calls for a prototype-key table', function(){ + const first = generatedColumns('constructor'); + assert.strictEqual(generatedColumns('constructor'), first); + }); +}); From 60ae9d58ad26aefc9533a8e17927723336ff31be Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 16:40:11 -0700 Subject: [PATCH 49/62] fix(test): independent FK oracle for tier1 applier fuzz suite Replace the reverse-alphabetical assumption (and later the production orderSnapshotTables import) in the FK-safe delete-order property with an oracle built from the FK edges documented independently at applier.js and sync.js, so the test can no longer pass just because it asserts against the function it is supposed to be checking. Also feeds every applyFullSnapshot/applyIncrementalSnapshot property a blocks row with its required block_index natural key, fixing the one-in-five flake where a generated row lacking it threw before the property under test ran. --- test/fuzz/suites/tier1_client_applier.fuzz.js | 104 +++++++++++++----- 1 file changed, 74 insertions(+), 30 deletions(-) diff --git a/test/fuzz/suites/tier1_client_applier.fuzz.js b/test/fuzz/suites/tier1_client_applier.fuzz.js index 39c95388..d98fa595 100644 --- a/test/fuzz/suites/tier1_client_applier.fuzz.js +++ b/test/fuzz/suites/tier1_client_applier.fuzz.js @@ -61,6 +61,71 @@ async function runAndLog(fn, input) { } } +// snapshotTablesObject (helpers/generators/payloads.js) fills every table, +// including blocks, with genericDataRow()'s random column names, which essentially +// never happen to name the column block_index. ClientApplier.insertRows requires +// that natural key for blocks (localSurrogateIdTables), so an unpatched generated +// snapshot throws on an apply path unrelated to what these properties exercise. +// Patching it in here keeps the fix inside this suite rather than reshaping the +// shared generator for every other suite that uses it. +function withBlockNaturalKeys(snapshot) { + if (!snapshot || !snapshot.tables || !snapshot.tables.blocks) return snapshot; + return { + ...snapshot, + tables: { + ...snapshot.tables, + blocks: snapshot.tables.blocks.map((row, index) => ({ + ...row, + block_index: row.block_index == null ? index : row.block_index, + })), + }, + }; +} + +// FK edges documented independently of the ordering algorithm: pubkeys.address_id -> +// index_addresses.id (applier.js) and blocks.*_hash_id -> index_transactions.id +// (sync.js). Used as the oracle for the FK-safe delete-order property below instead +// of the production orderSnapshotTables function, since asserting against that +// function's own output would pass even if its ordering logic regressed. +const BLOCKS_FK_CHILD_BEFORE_PARENT = [ + ['pubkeys', 'index_addresses'], + ['blocks', 'index_transactions'], +]; +const BLOCKS_FK_CANDIDATE_TABLES = [ + 'pubkeys', 'index_addresses', 'blocks', 'index_transactions', + 'index_actions', 'transactions', 'actions', 'balances', 'sync_meta', + 'fuzz_alpha', 'fuzz_omega', +]; + +// Property body for the FK-safe delete-order test: applies a full snapshot naming +// exactly tableNames and asserts every table was deleted exactly once, with every +// known FK child deleted before its parent. +async function assertFkSafeDeleteOrder(tableNames) { + let snapshot = withBlockNaturalKeys({ + schema_version: SCHEMA_VERSION.indexer, + block_height: 100, + tables: Object.fromEntries(tableNames.map(n => [n, [{ id: 1 }]])), + }); + + db.doQuery.resetHistory(); + await applier.applyFullSnapshot(snapshot); + + let deleteOrder = []; + for (let i = 0; i < db.doQuery.callCount; i++) { + let sql = db.doQuery.getCall(i).args[0]; + let m = typeof sql === 'string' && sql.match(/^DELETE FROM `(.+)`$/); + if (m) deleteOrder.push(m[1]); + } + + assert.deepStrictEqual([...deleteOrder].sort(), [...tableNames].sort()); + + for (let [child, parent] of BLOCKS_FK_CHILD_BEFORE_PARENT) { + if (!tableNames.includes(child) || !tableNames.includes(parent)) continue; + assert.ok(deleteOrder.indexOf(child) < deleteOrder.indexOf(parent), + child + ' must be deleted before ' + parent + ', got order: ' + deleteOrder.join(',')); + } +} + describe('Tier 1 - ClientApplier @tier1', function () { this.timeout(0); useClientApplierHooks(); @@ -148,40 +213,19 @@ describe('Tier 1 - ClientApplier @tier1', function () { return fc.assert(fc.asyncProperty( partialSnapshotPayload(), async (snapshot) => { - await applier.applyFullSnapshot(snapshot); + await applier.applyFullSnapshot(withBlockNaturalKeys(snapshot)); } ), { numRuns: NUM_RUNS }); }); - it('clears tables in reverse order via DELETE (FK-safe)', function () { + it('clears tables via DELETE, children before parents (FK-safe)', function () { // applyFullSnapshot clears via `DELETE FROM` (not TRUNCATE) for FK - // compatibility, iterating the table keys in reverse so child tables are - // cleared before their parents. Capture the DELETE order from doQuery. + // compatibility. assertFkSafeDeleteOrder checks the result against the + // documented FK graph rather than the production ordering function. return fc.assert(fc.asyncProperty( - fc.array( - fc.string({ unit: fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')), minLength: 1, maxLength: 15 }), - { minLength: 2, maxLength: 8 } - ).filter(names => new Set(names).size === names.length), - async (tableNames) => { - let snapshot = { - schema_version: SCHEMA_VERSION.indexer, - block_height: 100, - tables: Object.fromEntries(tableNames.map(n => [n, [{ id: 1 }]])), - }; - - db.doQuery.resetHistory(); - await applier.applyFullSnapshot(snapshot); - - let deleteOrder = []; - for (let i = 0; i < db.doQuery.callCount; i++) { - let sql = db.doQuery.getCall(i).args[0]; - let m = typeof sql === 'string' && sql.match(/^DELETE FROM `(.+)`$/); - if (m) deleteOrder.push(m[1]); - } - - let expectedOrder = [...tableNames].reverse(); - assert.deepStrictEqual(deleteOrder, expectedOrder); - } + fc.uniqueArray(fc.constantFrom(...BLOCKS_FK_CANDIDATE_TABLES), + { minLength: 2, maxLength: BLOCKS_FK_CANDIDATE_TABLES.length }), + assertFkSafeDeleteOrder ), { numRuns: Math.min(NUM_RUNS, 500) }); }); }); @@ -197,7 +241,7 @@ describe('Tier 1 - ClientApplier @tier1', function () { return fc.assert(fc.asyncProperty( partialSnapshotPayload(), async (snapshot) => { - await applier.applyIncrementalSnapshot(snapshot); + await applier.applyIncrementalSnapshot(withBlockNaturalKeys(snapshot)); } ), { numRuns: NUM_RUNS }); }); @@ -209,7 +253,7 @@ describe('Tier 1 - ClientApplier @tier1', function () { // Add since_block to make it incremental snapshot.since_block = 1; db.truncateTable.resetHistory(); - await applier.applyIncrementalSnapshot(snapshot); + await applier.applyIncrementalSnapshot(withBlockNaturalKeys(snapshot)); assert.strictEqual(db.truncateTable.callCount, 0); } ), { numRuns: NUM_RUNS }); From 73c718ca449ac862b086c475517b4521790711f1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 18:33:47 -0700 Subject: [PATCH 50/62] docs(sync): align the bridge_transfers table-lifecycle note with the rollback sweep The twin of the indexer canonical: the note claimed no local pre-delete leg exists while sweeps.js carries one inside the markers. --- src/table_lifecycle/block_and_special_tables.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/table_lifecycle/block_and_special_tables.js b/src/table_lifecycle/block_and_special_tables.js index 7e237aa6..644b6bbe 100644 --- a/src/table_lifecycle/block_and_special_tables.js +++ b/src/table_lifecycle/block_and_special_tables.js @@ -126,7 +126,7 @@ const TABLES = [ note: 'Hub-mirrored XCALL relay rows; the indexer only SELECTs them. Same local pre-delete + hub-retraction-backstop model as cross_chain_matches.' }, { table: 'bridge_transfers', owner: 'indexer', replication: 'hub-mirror', rollback: 'exempt', replicaRollback: 'special', hashed: { classes: ['quorum'], note: 'Hub-federation co-signed XBRIDGE transfer rows; the settle pass re-verifies the signature set against the cross_chain capability snapshot at snapshot_block before it credits anything.' }, - note: 'Hub-mirrored one-sided bridge state (src_chain/src_action_index name the lock or burn leg), not produced by local block processing; the indexer only SELECTs it. Hub retraction is the unwind: _applyRetraction deletes the mirrored row under the mandatory push_generation fence. UNLIKE cross_chain_matches/calls there is as yet NO local pre-delete leg inside the CROSS-CHAIN-MIRROR-REORG-DELETE markers in rollback.js / ClientRollback.js, so the hub-blip window those markers close is still open for this table; adding it is a byte-identical twin edit in both files and is tracked outside this registry. An APPLIED leg is unwound by its bridge_settlements row instead, which is rollback \'action\'. Exempt = not a generic-list delete.' }, + note: 'Hub-mirrored one-sided bridge state (src_chain/src_action_index name the lock or burn leg), not produced by local block processing; the indexer only SELECTs it. Hub retraction is the unwind: _applyRetraction deletes the mirrored row under the mandatory push_generation fence. Like cross_chain_matches/calls, both sides also locally pre-delete the orphaned range inside the CROSS-CHAIN-MIRROR-REORG-DELETE markers (indexer src/db/rollback/sweeps.js, sync src/client/rollback.js) with a one-sided src_chain/src_action_index predicate, closing the hub-blip window before that retraction lands; one marker-guarded delete per side is the whole leg. An APPLIED leg is unwound by its bridge_settlements row instead, which is rollback \'action\'. Exempt = not a generic-list delete.' }, { table: 'oracle_prices', owner: 'indexer', replication: 'hub-mirror', rollback: 'exempt', replicaRollback: 'special', hashed: { classes: [], note: 'Hub-mirrored permissionless PRICE v1 rows; consensus effects (fee quotes) re-verify against them deterministically per block.' }, note: 'action_index here refers to the row\'s SOURCE chain, usually a different chain from the one reorging, so a blanket local-height delete would corrupt the mirror; both sides delete only rows tagged with the local chain, and source-chain reorgs converge mirror-side via the pushpricereorg rail.' }, From 5dba4de4207db83a4bbccf95c50de1e78e77cfec Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 18:59:06 -0700 Subject: [PATCH 51/62] test(sync): cover /halt/clear auth, the WS upgrade guard, and the checkpoint reads /halt/clear and the raw server.on('upgrade', ...) guard in api.js ran in no tier; the armed_source_handshake suite only exercised a hand-transcribed copy of the upgrade check, which shared safeEqual but not the real handler's shape. Added a bootRealApi() that proxyquires the real startApi() (network edges stubbed) and captures the actual upgrade handler off the fake http.Server, then drives both surfaces directly: /halt/clear's 401 with no key configured, 401 on a wrong key, and 403 in server mode; the WS guard's 401 on no/wrong key and its pass-through to wss.handleUpgrade on a correct one. Also added direct unit coverage for the three state_checkpoints.js reads (getLatestCheckpoint, findCheckpointsInRange, getCheckpointAtHeight), which had none: each is asserted against its literal SQL shape, its bound params (including the caller-supplied LIMIT), and an empty-result response. --- .../helpers/armed_source_handshake_suite.js | 255 +++++++++++++++++- 1 file changed, 249 insertions(+), 6 deletions(-) diff --git a/test/integration/armed_source_handshake.test/helpers/armed_source_handshake_suite.js b/test/integration/armed_source_handshake.test/helpers/armed_source_handshake_suite.js index eddcb003..1ddb15f2 100644 --- a/test/integration/armed_source_handshake.test/helpers/armed_source_handshake_suite.js +++ b/test/integration/armed_source_handshake.test/helpers/armed_source_handshake_suite.js @@ -9,13 +9,17 @@ // contact legal@dankest.llc. // Covers shared armed source setup. One part of armed_source_handshake.test.js. -const http = require('http'); -const express = require('express'); -const WebSocket = require('ws'); +const assert = require('assert'); +const http = require('http'); +const express = require('express'); +const WebSocket = require('ws'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); const { createApiKeyMiddleware, safeEqual } = require('../../../../src/http/middleware'); -const ClientSync = require('../../../../src/client/sync'); -const HashVerifier = require('../../../../src/client/hash_verifier'); -const Utility = require('../../../../src/util'); +const ClientSync = require('../../../../src/client/sync'); +const HashVerifier = require('../../../../src/client/hash_verifier'); +const Utility = require('../../../../src/util'); +const checkpointReads = require('../../../../src/db/state_checkpoints'); const PORT = 19477; const SERVER_KEY = 'armed-source-key'; @@ -77,4 +81,243 @@ function registerArmedSourceHooks(){ }); } +// Boots the REAL startApi() with the network edges stubbed out. Hands back the +// express app it built, the raw `server.on('upgrade', ...)` handler (captured +// off the fake http.Server so it can be invoked directly, since it is declared +// inline in startApi and never exported), and the fake WebSocket.Server so a +// test can see whether handleUpgrade was ever reached. Same shape as the +// api_rate_limit_proxy_security suite's bootRealApi, plus the upgrade capture: +// without capturing the real handler, the WS guard above was only ever +// exercised by registerArmedSourceHooks's hand-transcribed copy, which shares +// safeEqual but not the handler's own shape. +async function bootRealApi(envOverrides){ + let prior = {}; + for(let key of Object.keys(envOverrides || {})){ + prior[key] = process.env[key]; + let val = envOverrides[key]; + if(val === undefined) delete process.env[key]; + else process.env[key] = val; + } + + let capturedApp = null; + let upgradeHandler = null; + let fakeServer = { + on: (event, handler) => { if(event === 'upgrade') upgradeHandler = handler; }, + listen: () => {} + }; + let fakeWss = { + on: () => {}, + clients: new Set(), + handleUpgrade: sinon.spy((request, socket, head, cb) => {}), + emit: () => {} + }; + + class SyncServiceStub { + start(){ return Promise.resolve(); } + getBroadcaster(){ return { addSubscription: () => {} }; } + getChains(){ return []; } + getDatabase(){ return { fake: true }; } + getClientSync(){ return null; } + } + + // startApi arms long-lived intervals; a test process must not inherit them. + // express-rate-limit's MemoryStore also calls setInterval and unrefs the + // handle it gets back, so the stub returns an unref-able stand-in rather + // than null. + let intervals = sinon.stub(global, 'setInterval').returns({ unref(){}, ref(){} }); + + try { + let api = proxyquire('../../../../src/api', { + 'http': { createServer: (app) => { capturedApp = app; return fakeServer; } }, + 'ws': { Server: function(){ return fakeWss; } }, + './SyncService': SyncServiceStub + }); + await api.startApi(); + } finally { + intervals.restore(); + for(let key of Object.keys(prior)){ + if(prior[key] === undefined) delete process.env[key]; + else process.env[key] = prior[key]; + } + } + + return { app: capturedApp, upgradeHandler, wss: fakeWss }; +} + +// Puts a real startApi()-built app on a loopback port for HTTP assertions. +async function listenRealApi(app){ + let server = await new Promise((resolve) => { + let s = app.listen(0, '127.0.0.1', () => resolve(s)); + }); + return { + port: server.address().port, + close: () => new Promise((resolve) => server.close(resolve)) + }; +} + +function makeUpgradeReq(headers){ + return { headers: headers || {}, url: '/subscribe/indexer/bitcoin/mainnet' }; +} + +// Enough of a raw net.Socket for the guard's rejection path: it only ever +// writes the 401 status line and destroys the connection. +function makeSocket(){ + return { + written: [], + destroyed: false, + write(chunk){ this.written.push(String(chunk)); }, + destroy(){ this.destroyed = true; } + }; +} + +describe('Security: /halt/clear and the WebSocket upgrade guard, against the REAL api.js', function(){ + let harness = null; + + afterEach(async function(){ + if(harness) await harness.close(); + harness = null; + sinon.restore(); + }); + + describe('POST /halt/clear/:dbType/:chain/:network', function(){ + + it('401s when no SYNC_API_KEY is configured at all, even carrying a header', async function(){ + let { app } = await bootRealApi({ SYNC_API_KEY: undefined, SYNC_MODE: 'client' }); + harness = await listenRealApi(app); + + let res = await fetch('http://127.0.0.1:' + harness.port + '/halt/clear/indexer/bitcoin/mainnet', { + method: 'POST', + headers: { authorization: 'Bearer whatever' } + }); + assert.strictEqual(res.status, 401); + }); + + it('401s on the wrong key once a key IS configured', async function(){ + let { app } = await bootRealApi({ SYNC_API_KEY: 'test-halt-key', SYNC_MODE: 'client' }); + harness = await listenRealApi(app); + + let res = await fetch('http://127.0.0.1:' + harness.port + '/halt/clear/indexer/bitcoin/mainnet', { + method: 'POST', + headers: { authorization: 'Bearer wrong-key' } + }); + assert.strictEqual(res.status, 401); + }); + + it('403s in server mode even carrying the correct key: halt-clear applies to client mode only', async function(){ + let { app } = await bootRealApi({ SYNC_API_KEY: 'test-halt-key', SYNC_MODE: 'server' }); + harness = await listenRealApi(app); + + let res = await fetch('http://127.0.0.1:' + harness.port + '/halt/clear/indexer/bitcoin/mainnet', { + method: 'POST', + headers: { authorization: 'Bearer test-halt-key' } + }); + assert.strictEqual(res.status, 403); + }); + }); + + describe('WebSocket upgrade guard (server.on(\'upgrade\', ...))', function(){ + + it('401s a keyless upgrade once a key IS configured', async function(){ + let { upgradeHandler } = await bootRealApi({ SYNC_API_KEY: 'test-ws-key' }); + let socket = makeSocket(); + + upgradeHandler(makeUpgradeReq(), socket, Buffer.alloc(0)); + + assert.strictEqual(socket.destroyed, true); + assert.ok(socket.written.join('').includes('401')); + }); + + it('401s an upgrade carrying the wrong key', async function(){ + let { upgradeHandler } = await bootRealApi({ SYNC_API_KEY: 'test-ws-key' }); + let socket = makeSocket(); + + upgradeHandler(makeUpgradeReq({ authorization: 'Bearer nope' }), socket, Buffer.alloc(0)); + + assert.strictEqual(socket.destroyed, true); + assert.ok(socket.written.join('').includes('401')); + }); + + it('does not reject a correctly-keyed upgrade: it reaches wss.handleUpgrade instead', async function(){ + let { upgradeHandler, wss } = await bootRealApi({ SYNC_API_KEY: 'test-ws-key' }); + let socket = makeSocket(); + + upgradeHandler(makeUpgradeReq({ authorization: 'Bearer test-ws-key' }), socket, Buffer.alloc(0)); + + assert.strictEqual(socket.written.length, 0, 'no 401 status line should be written on a valid key'); + assert.ok(wss.handleUpgrade.called); + }); + }); +}); + +// The signed-checkpoint reads api.js's /checkpoint/... routes call through to +// (src/db/state_checkpoints.js, a Database mixin). Exercised directly against +// the mixin's own SQL, not the HTTP layer above it, since what varies between +// the three reads is the query shape and its bound params, not how api.js +// turns a 404/500 around them. +describe('Unit: state_checkpoints reads (SQL shape, limit, empty result)', function(){ + + function fakeDb(rows){ + return { doQuery: sinon.stub().resolves(rows) }; + } + + describe('getLatestCheckpoint', function(){ + + it('takes the newest row overall: DESC by block_index then checkpoint_seq, capped at one', async function(){ + let db = fakeDb([{ block_index: 5 }]); + await checkpointReads.getLatestCheckpoint.call(db); + + let [sql, params] = db.doQuery.firstCall.args; + assert.ok(/FROM state_checkpoints\b/.test(sql)); + assert.ok(/ORDER BY block_index DESC, checkpoint_seq DESC/.test(sql)); + assert.ok(/LIMIT 1\s*$/.test(sql.trim())); + assert.strictEqual(params, undefined); + }); + + it('returns an empty array when the table has no rows', async function(){ + let db = fakeDb([]); + let rows = await checkpointReads.getLatestCheckpoint.call(db); + assert.deepStrictEqual(rows, []); + }); + }); + + describe('findCheckpointsInRange', function(){ + + it('bounds block_index to [from, to], keeps only the max checkpoint_seq per height, and passes the caller limit through', async function(){ + let db = fakeDb([]); + await checkpointReads.findCheckpointsInRange.call(db, 10, 20, 500); + + let [sql, params] = db.doQuery.firstCall.args; + assert.ok(/WHERE block_index >= \? AND block_index <= \?/.test(sql)); + assert.ok(/checkpoint_seq = \(SELECT MAX\(s2\.checkpoint_seq\) FROM state_checkpoints s2 WHERE s2\.block_index = sc\.block_index\)/.test(sql)); + assert.ok(/ORDER BY block_index ASC LIMIT \?/.test(sql)); + assert.deepStrictEqual(params, [10, 20, 500]); + }); + + it('returns an empty array when nothing falls in range', async function(){ + let db = fakeDb([]); + let rows = await checkpointReads.findCheckpointsInRange.call(db, 1, 2, 10); + assert.deepStrictEqual(rows, []); + }); + }); + + describe('getCheckpointAtHeight', function(){ + + it('filters on the exact height and takes the newest checkpoint_seq there', async function(){ + let db = fakeDb([]); + await checkpointReads.getCheckpointAtHeight.call(db, 42); + + let [sql, params] = db.doQuery.firstCall.args; + assert.ok(/WHERE block_index=\?/.test(sql)); + assert.ok(/ORDER BY checkpoint_seq DESC LIMIT 1/.test(sql)); + assert.deepStrictEqual(params, [42]); + }); + + it('returns an empty array when no checkpoint exists at that height', async function(){ + let db = fakeDb([]); + let rows = await checkpointReads.getCheckpointAtHeight.call(db, 999); + assert.deepStrictEqual(rows, []); + }); + }); +}); + module.exports = { PORT, SERVER_KEY, makeClient, registerArmedSourceHooks }; From 83f284a27b1be4bf1816d6f618b1befab4d7b8b1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 19:24:30 -0700 Subject: [PATCH 52/62] test(sync): check every carrier twin instead of skipping on the first absent sibling, correct the drain comment --- src/http/shutdown.js | 10 ++++++---- .../unit/repo_guards/carrier_logic_pin.test.js | 18 +++++++++++++----- test/unit/shutdown.test.js | 2 ++ 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/http/shutdown.js b/src/http/shutdown.js index b1bd92dd..47bc8651 100644 --- a/src/http/shutdown.js +++ b/src/http/shutdown.js @@ -30,10 +30,12 @@ const envConfig = require('../config'); -// Hard-exit budget for the whole drain. Docker's default stop grace is 10s and -// xchain-node issues a bare `docker stop`, so the default sits under it: an -// overrun that ends in our own logged exit is diagnosable, one that ends in the -// daemon's SIGKILL is not. SHUTDOWN_TIMEOUT_MS overrides. +// Hard-exit budget for the whole drain. xchain-node stops a sync service with a +// 30 s budget (stamped on the container as --stop-timeout), and a container +// created before that budget existed still gets docker's ten seconds, so the +// default sits under both: an overrun that ends in our own logged exit is +// diagnosable, one that ends in the daemon's SIGKILL is not. +// SHUTDOWN_TIMEOUT_MS overrides. const DEFAULT_SHUTDOWN_TIMEOUT_MS = 8000; function resolveTimeoutMs(timeoutMs, env){ diff --git a/test/unit/repo_guards/carrier_logic_pin.test.js b/test/unit/repo_guards/carrier_logic_pin.test.js index cbcfd93b..87f95eba 100644 --- a/test/unit/repo_guards/carrier_logic_pin.test.js +++ b/test/unit/repo_guards/carrier_logic_pin.test.js @@ -42,9 +42,13 @@ function siblingWith(repo, rel) { return fs.existsSync(path.join(dir, rel)) ? dir : null; } -/** Skip or fail on a missing sibling, by the environment's rule. */ -function missingSibling(test, repo) { - if (REQUIRE_SIBLINGS) assert.fail(`${repo} is not checked out beside this repo and XCHAIN_REQUIRE_SIBLINGS=1`); +/** Skip or fail once for every missing sibling, by the environment's rule; call it after the assertions (skip throws). */ +function missingSiblings(test, repos) { + const names = [...new Set(repos)].sort(); + if (!names.length) return; + if (REQUIRE_SIBLINGS) { + assert.fail(`${names.join(', ')} ${names.length > 1 ? 'are' : 'is'} not checked out beside this repo and XCHAIN_REQUIRE_SIBLINGS=1`); + } test.skip(); } @@ -70,15 +74,17 @@ describe('bin/pins/carrier-logic.json: the carrier logic pin', function () { it('(c) every twin id hashes the same in the sibling pin', function () { const mismatches = []; + const missing = []; for (const id of Object.keys(pin.entries)) { for (const repo of pin.entries[id].twins || []) { const dir = siblingWith(repo, pinModule.PIN_REL); - if (!dir) { missingSibling(this, repo); continue; } + if (!dir) { missing.push(repo); continue; } const theirs = (pinModule.readPin(dir).entries[id] || {}).hash; if (theirs !== pin.entries[id].hash) mismatches.push(`${id}: ${repo} pins ${theirs}, this repo pins ${pin.entries[id].hash}`); } } assert.deepStrictEqual(mismatches, [], 'a logic change to a twin re-pins every copy in one change set'); + missingSiblings(this, missing); }); }); @@ -105,10 +111,11 @@ describe('bin/pins/carrier-logic.json: the carrier logic pin', function () { it('(e) every twin file\'s bytes equal every sibling copy', function () { assert.ok(pinModule.MODULE_TWIN_FILES.length >= 2, 'the module and its ops half are both twins'); + const missing = []; for (const repo of pinModule.MODULE_TWINS) { if (repo === pinModule.repoName(REPO_ROOT)) continue; const dir = siblingWith(repo, pinModule.MODULE_REL); - if (!dir) { missingSibling(this, repo); continue; } + if (!dir) { missing.push(repo); continue; } for (const rel of pinModule.MODULE_TWIN_FILES) { const theirs = path.join(dir, rel); assert.ok(fs.existsSync(theirs), `${rel} is missing from the ${repo} copy: cp it there`); @@ -116,6 +123,7 @@ describe('bin/pins/carrier-logic.json: the carrier logic pin', function () { `${rel} differs from the ${repo} copy: edit one and cp it to the others`); } } + missingSiblings(this, missing); }); it('(g) the membership rule and the pin name the same files, both ways', () => { diff --git a/test/unit/shutdown.test.js b/test/unit/shutdown.test.js index 99f56784..2548c110 100644 --- a/test/unit/shutdown.test.js +++ b/test/unit/shutdown.test.js @@ -135,6 +135,8 @@ describe('graceful shutdown', function(){ }); it('stays under Docker\'s 10s default stop grace', function(){ + // xchain-node stamps a 30 s budget; docker's ten seconds is the bound + // left on a container created before that budget existed. assert.ok(DEFAULT_SHUTDOWN_TIMEOUT_MS < 10000); }); }); From 5d2609732d2fbe726a3e5409a191adee3be04246 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 20:11:14 -0700 Subject: [PATCH 53/62] fix(sync): populate the dispenser reconcile knobs, arm the wall-clock bound after bootstrap, fail closed on block-hash reads The reconcile cadence knobs come from config, the wall-clock bound arms after a full-snapshot bootstrap, BlockHasher fails closed on a read error, decoder events leave content parity, pubkeys honor skip_lookups, the decoder scoping sets derive from TOPOLOGY, and the snapshot-dispensers route docs match the stream. --- src/api.js | 21 ++-- src/checkpoint.js | 5 +- src/client/block_hasher.js | 30 +++-- src/client/rollback.js | 9 +- src/client/sync.js | 62 +++++---- src/config.js | 23 ++++ src/schema/replicated_tables.js | 30 +++-- src/server/snapshot_builder.js | 118 ++++++++++-------- test/e2e/dispensers_reconcile.test.js | 10 +- test/e2e/helpers/fixtures.js | 8 +- test/fixtures/gen-block-hash-vectors.js | 3 +- test/unit/block_hasher.test.js | 26 +++- .../08_client_reconcile_knobs.test.js | 69 ++++++++++ test/unit/client_sync.test/04_start.test.js | 30 +++++ ...ient_sync_dispenser_reconcile_tick.test.js | 111 +++++++++++++++- .../01_independent_recompute_halt.test.js | 14 ++- .../unit/decoder_table_classification.test.js | 30 ++++- .../repo_guards/config_key_coverage.test.js | 107 ++++++++++++++++ .../04_stream_dispensers.test.js | 12 +- .../07_paging_stream_client_abort.test.js | 2 +- ...apshot_snapshot_replication_tables.test.js | 82 +++++++----- .../05_operational_log_exclusion.test.js | 102 +++++++++++++++ 22 files changed, 736 insertions(+), 168 deletions(-) create mode 100644 test/unit/boundaries/config_parsing.test/08_client_reconcile_knobs.test.js create mode 100644 test/unit/repo_guards/config_key_coverage.test.js create mode 100644 test/unit/table_content_parity.test/05_operational_log_exclusion.test.js diff --git a/src/api.js b/src/api.js index b978abc7..a90e4d9e 100644 --- a/src/api.js +++ b/src/api.js @@ -893,13 +893,16 @@ async function startApi(){ } }); - // GET /snapshot-dispensers/:dbType/:chain/:network?after_tx=&after_addr=&limit= - // One keyset page of the decoder `dispensers` table for the client's replace-table - // reconcile. dispensers rides neither the block stream nor the id-cursor lookup - // paging (no monotonic id; the decoder soft-expires/hard-purges rows), so the - // client periodically re-dumps the full table and swaps it in atomically; see - // SnapshotBuilder.streamDispensers + ClientSync.reconcileDispensers. Decoder-only; - // rate-limited as an incremental fetch. + // GET /snapshot-dispensers/:dbType/:chain/:network?after_tx=&after_addr= + // The WHOLE decoder `dispensers` table in one statement-consistent response + // (has_more always false) for the client's replace-table reconcile. dispensers + // rides neither the block stream nor the id-cursor lookup paging (no monotonic id; + // the decoder soft-expires/hard-purges rows), so the client periodically re-dumps + // it and swaps it in atomically; see SnapshotBuilder.streamDispensers + + // ClientSync.reconcileDispensers. The cursor params filter within that one query; + // a `limit` param from an older client is ignored, and the response's only size + // ceiling is the client's SNAPSHOT_MAX_CONTENT. Decoder-only; rate-limited as an + // incremental fetch. app.get('/snapshot-dispensers/:dbType/:chain/:network', incrSnapshotLimiter, async (req, res) => { if(cfg['SYNC_MODE'] !== 'server') return res.status(403).json({ error: 'Snapshots only available in server mode', code: 'FORBIDDEN' }); @@ -914,8 +917,6 @@ async function startApi(){ if((req.query.after_tx !== undefined && (isNaN(afterTx) || afterTx < 0)) || (req.query.after_addr !== undefined && (isNaN(afterAddr) || afterAddr < 0))) return res.status(400).json({ error: 'Invalid cursor', code: 'BAD_REQUEST' }); - let limit = parseInt(req.query.limit); - if(isNaN(limit)) limit = undefined; // builder applies its default let db = syncService.getDatabase(chain, network, dbType); if(!db) return res.status(404).json({ error: 'Chain/network/dbType not found', code: 'NOT_FOUND' }); @@ -924,7 +925,7 @@ async function startApi(){ if(!builder) return res.status(500).json({ error: 'Snapshot builder not initialized', code: 'INTERNAL_ERROR' }); try { - await builder.streamDispensers(db, afterTx, afterAddr, limit, res); + await builder.streamDispensers(db, afterTx, afterAddr, res); } catch(e){ console.error('[API error] /snapshot-dispensers/:dbType/:chain/:network:', e); if(!res.headersSent) diff --git a/src/checkpoint.js b/src/checkpoint.js index dfbfa9af..6a5a1a0f 100644 --- a/src/checkpoint.js +++ b/src/checkpoint.js @@ -43,8 +43,9 @@ const SPKI_ED25519_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); // The canonical signing string: // XCHECKPOINT|CHAIN|NETWORK|BLOCK_INDEX|BLOCK_HASH|LEDGER_HASH|ACTIONS_HASH|CONTRACT_HASH|CHECKPOINT_SEQ|SNAPSHOT_BLOCK // It MUST stay byte-identical to every other producer and verifier of it: the hub's -// StateCheckpointEngine and StateAnchorPublisher, the indexer's ANCHOR verifier and -// recovery wrapper, the SDK's checkpoint.js and the explorer's canonicalCheckpointString. +// StateCheckpointEngine and StateAnchorPublisher, the indexer's ANCHOR verifier, recovery +// wrapper and bridge-proof checkpoint_source.js, the SDK's checkpoint.js and the explorer's +// canonicalCheckpointString. function canonicalCheckpoint(cp){ if (!cp) throw new Error('CheckpointVerifier: checkpoint object required'); let raw = ['XCHECKPOINT', cp.chain, cp.network, String(cp.block_index), cp.block_hash, diff --git a/src/client/block_hasher.js b/src/client/block_hasher.js index 35b5ae71..58d3c794 100644 --- a/src/client/block_hasher.js +++ b/src/client/block_hasher.js @@ -65,8 +65,9 @@ const STATE_KEY_COLLATION_KEY = 'state_key_collation_activation.STATE_KEY_COLLAT class BlockHasher { - // db: a DB handle exposing async doQuery(sql, params) against the REPLICA - // (schema-identical to the indexer, with surrogate ids preserved). + // db: a DB handle exposing async doQuery(sql, params) and + // doQueryStrict(sql, params) against the REPLICA (schema-identical to + // the indexer, with surrogate ids preserved). // util: xchain-sync Utility; its getDataHash() is the conformance copy of // the indexer's (JSON.stringify(Object.assign({}, data), bigint->string), // SHA-256 hex). @@ -82,6 +83,9 @@ class BlockHasher { // omitted -> legacy folding collation, matching pre-activation blocks. Live // recompute callers MUST pass them or the replica gates differently than the // source at/after an armed height and false-halts on divergence. + // Gather every preimage row set with doQueryStrict: a swallowed read error + // returning [] would hash a truncated preimage and halt on a false divergence + // instead of surfacing as a recompute error (same rule as db/actions.js). async computeBlockHashes(block_index, network, coin){ let query = null; let actions = []; @@ -108,7 +112,7 @@ class BlockHasher { a.block_index=? ORDER BY c.action_index ASC, a1.address COLLATE utf8_bin ASC, t1.tick COLLATE utf8mb4_bin ASC, c.amount ASC`; - ledger.credits = await this.db.doQuery(query, [block_index]); + ledger.credits = await this.db.doQueryStrict(query, [block_index]); query = `SELECT d.action_index, a1.address AS address, @@ -122,7 +126,7 @@ class BlockHasher { a.block_index=? ORDER BY d.action_index ASC, a1.address COLLATE utf8_bin ASC, t1.tick COLLATE utf8mb4_bin ASC, d.amount ASC`; - ledger.debits = await this.db.doQuery(query, [block_index]); + ledger.debits = await this.db.doQueryStrict(query, [block_index]); query = `SELECT e.action_index, a1.address AS address, @@ -136,7 +140,7 @@ class BlockHasher { a.block_index=? ORDER BY e.action_index ASC, a1.address COLLATE utf8_bin ASC, t1.tick COLLATE utf8mb4_bin ASC, e.amount ASC`; - ledger.escrows = await this.db.doQuery(query, [block_index]); + ledger.escrows = await this.db.doQueryStrict(query, [block_index]); // CONSENSUS: canonicalize protocol special addresses (BURN/GAS/DONATE/REWARD) // to their chain-independent role token, byte-for-byte mirror of // xchain-indexer/src/db/actions.js getBlockHashes. A per-chain special address (e.g. an @@ -157,7 +161,7 @@ class BlockHasher { a.block_index=? ORDER BY a.action_index ASC`; - actions = await this.db.doQuery(query, [block_index]); + actions = await this.db.doQueryStrict(query, [block_index]); let contracts_data = { contracts: [], state: [], @@ -173,7 +177,7 @@ class BlockHasher { LEFT JOIN index_statuses s1 ON (s1.id=c.status_id) WHERE a.block_index=? ORDER BY c.action_index ASC`; - contracts_data.contracts = await this.db.doQuery(query, [block_index]); + contracts_data.contracts = await this.db.doQueryStrict(query, [block_index]); // contract state (latest value per key written in this block). // state_key collation is flag-day gated, byte-for-byte mirror of // xchain-indexer/src/db/actions.js getBlockHashes(): legacy folding @@ -190,7 +194,7 @@ class BlockHasher { GROUP BY contract_index, state_key` + stateKeyCollate + ` ) latest ON cs.id = latest.max_id ORDER BY cs.contract_index ASC, cs.state_key` + stateKeyCollate + ` ASC`; - contracts_data.state = await this.db.doQuery(query, [block_index]); + contracts_data.state = await this.db.doQueryStrict(query, [block_index]); // Executions, resolved the same way as deployments above. query = `SELECT ce.action_index, ce.contract_index, a1.address AS caller_address, ce.gas_used, s1.status AS status, ce.emitted_count FROM contract_executions ce @@ -198,7 +202,7 @@ class BlockHasher { LEFT JOIN index_statuses s1 ON (s1.id=ce.status_id) WHERE a.block_index=? ORDER BY ce.action_index ASC`; - contracts_data.executions = await this.db.doQuery(query, [block_index]); + contracts_data.executions = await this.db.doQueryStrict(query, [block_index]); // Emissions carry no block column, so scope comes through their execution. query = `SELECT em.execution_index, em.emitted_action, em.action_index, em.position FROM contract_emissions em @@ -206,7 +210,7 @@ class BlockHasher { INNER JOIN actions a ON (a.action_index=ce.action_index) WHERE a.block_index=? ORDER BY em.execution_index ASC, em.position ASC`; - contracts_data.emissions = await this.db.doQuery(query, [block_index]); + contracts_data.emissions = await this.db.doQueryStrict(query, [block_index]); // Deposits, with the resolved secondary sort keys pinned to a BINARY collation so // the tie-break order cannot vary with a node's default collation. query = `SELECT d.action_index, d.contract_index, a1.address AS source_address, t1.tick AS tick, d.amount, s1.status AS status @@ -216,7 +220,7 @@ class BlockHasher { LEFT JOIN index_statuses s1 ON (s1.id=d.status_id) WHERE a.block_index=? ORDER BY d.action_index ASC, d.contract_index ASC, a1.address COLLATE utf8_bin ASC, t1.tick COLLATE utf8mb4_bin ASC, d.amount ASC, s1.status COLLATE utf8_bin ASC`; - contracts_data.deposits = await this.db.doQuery(query, [block_index]); + contracts_data.deposits = await this.db.doQueryStrict(query, [block_index]); // Same resolution and tie-order treatment as deposits. query = `SELECT w.action_index, w.contract_index, a1.address AS source_address, t1.tick AS tick, w.amount, s1.status AS status FROM withdrawals w @@ -225,7 +229,7 @@ class BlockHasher { LEFT JOIN index_statuses s1 ON (s1.id=w.status_id) WHERE a.block_index=? ORDER BY w.action_index ASC, w.contract_index ASC, a1.address COLLATE utf8_bin ASC, t1.tick COLLATE utf8mb4_bin ASC, w.amount ASC, s1.status COLLATE utf8_bin ASC`; - contracts_data.withdrawals = await this.db.doQuery(query, [block_index]); + contracts_data.withdrawals = await this.db.doQueryStrict(query, [block_index]); // Previous block's committed hashes, which chain this block to the last. let prev_block_index = block_index - 1; query = `SELECT @@ -239,7 +243,7 @@ class BlockHasher { LEFT JOIN index_transactions t3 ON (t3.id=b.contract_hash_id) WHERE b.block_index=?`; - let results = await this.db.doQuery(query, [prev_block_index]); + let results = await this.db.doQueryStrict(query, [prev_block_index]); if(results.length > 0){ hashes['ledger'] = results[0].ledger; hashes['actions'] = results[0].actions; diff --git a/src/client/rollback.js b/src/client/rollback.js index 61e861ee..f184a373 100644 --- a/src/client/rollback.js +++ b/src/client/rollback.js @@ -120,10 +120,11 @@ class ClientRollback { // Tx-scoped tables, deleted by tx_index for the rolled-back blocks' transactions. // Also topology-derived. dispensers is absent from the topology's txScoped by // design: it is not per-block replicated (the decoder live-prunes it, which - // the block stream can't model (see replicatedTables.js)); it SEEDS from the - // full snapshot and is then held in parity by the periodic apply-side reconcile - // (ClientSync.reconcileDispensers -> ClientApplier.applyDispensersReplace), - // which replicatedTables.js:47-49 names as the whole of its parity story. + // the block stream can't model (see src/schema/replicated_tables.js)); it SEEDS from + // the full snapshot and is then held in parity by the periodic apply-side reconcile + // (ClientSync.reconcileDispensers -> ClientApplier.applyDispensersReplace), which + // the dispensers (decoder) carve-out in src/schema/replicated_tables.js names as + // the whole of its parity story. // Deleting its rows on a reorg would corrupt that replicated state with no // per-block stream to restore them before the next reconcile, so a reorg leaves // dispensers untouched. diff --git a/src/client/sync.js b/src/client/sync.js index 5e8e40ac..599ffa3c 100644 --- a/src/client/sync.js +++ b/src/client/sync.js @@ -51,9 +51,9 @@ const envConfig = require('../config'); // Tables whose row counts cannot converge between source and replica, and so are // never a completeness signal. See the exclusion in verifyTableCounts for the -// mechanism; kept here as a named set so a second such table is added in one place -// rather than at each call site's excludeTables argument. -const OPERATIONAL_LOG_TABLES = new Set(['events']); +// mechanism. Declared once in replicated_tables.js, which the content-parity plan +// also reads, so the count and content checks cannot disagree about a table. +const OPERATIONAL_LOG_TABLES = new Set(replicatedTables.OPERATIONAL_LOG_TABLES); // Permanent bootstrap exhaustion. start()-time throws already unwind to // SyncService's sync.start().catch(... process.exit(1)) restart contract on their @@ -67,16 +67,24 @@ class BootstrapExhaustedError extends Error {} // Is the decoder `dispensers` table due a wall-clock reconcile? The one term of the // reconcile decision that carries no cycle-counter side effect, so the recurring status -// tick can sample it without corrupting the every-Nth catch-up cadence. Due only once -// some reconcile has stamped a time: a replica that has never converged dispensers is the -// firstResume case, owned by the catch-up path, and firing that from a tick would retry a -// failing re-dump on every tick instead of once per interval. -function dispenserIntervalDue(config, lastReconcileAt, nowMs){ +// tick can sample it without corrupting the every-Nth catch-up cadence. Measured from the +// later of the last success and `since` (the tick's last attempt, else when live-follow +// began), so a snapshot-bootstrapped replica that never reconciled is still bounded and a +// failing re-dump retries once per interval, not once per tick. No time known: not due. +function dispenserIntervalDue(config, lastReconcileAt, nowMs, since){ let maxIntervalMs = parseInt(config['DISPENSERS_RECONCILE_MAX_INTERVAL_MS'], 10); if(isNaN(maxIntervalMs) || maxIntervalMs < 0) maxIntervalMs = 1800000; if(maxIntervalMs === 0) return false; // explicitly disabled - if(lastReconcileAt == null) return false; - return (nowMs - lastReconcileAt) >= maxIntervalMs; + let from = latestTime(lastReconcileAt, since); + if(from == null) return false; + return (nowMs - from) >= maxIntervalMs; +} + +// Return the later of two optional epoch-ms times, or null when neither is set. +function latestTime(a, b){ + if(a == null) return (b == null) ? null : b; + if(b == null) return a; + return Math.max(a, b); } class ClientSync { @@ -688,6 +696,8 @@ class ClientSync { this.lastHashes = await this.db.getBlockHashRow(this.lastAppliedBlock); + // Start the dispensers wall clock at live-follow: a full snapshot seeds the table at parity. + if(this.dbType === 'decoder') this._dispenserClockArmedAt = Date.now(); this.connectWebSockets(); // Keep alive @@ -2527,14 +2537,6 @@ class ClientSync { return this._exactParityTableSet; } - // Re-fetch the decoder `dispensers` table in full and replace the local copy. - // dispensers cannot ride the block stream or the id-cursor lookup paging (no - // monotonic id; the decoder soft-expires then hard-purges rows), so a truncated - // bootstrap never seeds it and an incremental catch-up lets it drift. This keyset- - // paged re-dump + atomic replace (ClientApplier.applyDispensersReplace) is the - // convergence path; verifyDecoderCompleteness then verifies row counts without - // false alarms. Decoder-only, best-effort: any fetch/parse failure aborts WITHOUT - // touching the local table (the replace runs only once every page is in hand). // Decide whether to reconcile the decoder `dispensers` table on this catch-up cycle // (advances the per-process cycle counter as a side effect). Reconcile when: // (a) firstResume - nothing reconciled yet this process (a resume that skipped @@ -2550,7 +2552,7 @@ class ClientSync { // healthy live-following replica never enters a catch-up at all, which is // precisely the cadence this clause claims to bound. // `_lastDispenserReconcileAt` is stamped by reconcileDispensers on success (covering - // the bootstrap reconcile too), so firstResume is false once any reconcile has run. + // the from-height bootstrap reconcile too), so firstResume is false once any has run. shouldReconcileDispensers(nowMs){ this._catchUpCount = (this._catchUpCount || 0) + 1; let every = parseInt(this.config['DISPENSERS_RECONCILE_EVERY'], 10); @@ -2566,22 +2568,32 @@ class ClientSync { // Wall-clock term of the reconcile decision, WITHOUT shouldReconcileDispensers' // cycle-counter side effect, so a recurring caller can sample the same bound without - // corrupting the every-Nth catch-up cadence. + // corrupting the every-Nth catch-up cadence. Falls back to the last attempt, else the + // live-follow start, so a replica that never reconciled is bounded too. dispenserReconcileIntervalDue(nowMs){ - return dispenserIntervalDue(this.config, this._lastDispenserReconcileAt, nowMs); + let since = latestTime(this._lastDispenserReconcileAttemptAt, this._dispenserClockArmedAt); + return dispenserIntervalDue(this.config, this._lastDispenserReconcileAt, nowMs, since); } + // Re-fetch the decoder `dispensers` table in full and replace the local copy. + // dispensers cannot ride the block stream or the id-cursor lookup paging (no + // monotonic id; the decoder soft-expires then hard-purges rows), so a truncated + // bootstrap never seeds it and an incremental catch-up lets it drift. The source + // serves the whole table in one statement-consistent response (the has_more walk + // stays so a source that still pages completes too), then an atomic replace + // (ClientApplier.applyDispensersReplace) converges it. Decoder-only, best-effort: + // any fetch/parse failure aborts WITHOUT touching the local table. async reconcileDispensers(source){ if(this.dbType !== 'decoder') return; if(!source) return; + // Stamp the attempt so a failing re-dump is retried once per interval, not per tick. + this._lastDispenserReconcileAttemptAt = Date.now(); try { let all = []; let afterTx = null, afterAddr = null; - let pageSize = this.lookupPageSize(); for(let guard = 0; guard < 1000000; guard++){ let url = source + '/snapshot-dispensers/' + this.dbType + '/' + this.chain + '/' + this.network + - '?limit=' + pageSize + - (afterTx !== null ? '&after_tx=' + afterTx + '&after_addr=' + afterAddr : ''); + (afterTx !== null ? '?after_tx=' + afterTx + '&after_addr=' + afterAddr : ''); let response = await axios.get(url, { headers: this.upstreamHeaders(), responseType: 'arraybuffer', @@ -2769,7 +2781,7 @@ class ClientSync { // it: the one cadence the bound claims to protect against (no catch-ups at // all) was the one it could not reach, and the replica went on serving rows // the source soft-expired or hard-purged for the life of the process. Row - // counts cannot substitute (replicatedTables.js: a soft-expire leaves counts + // counts cannot substitute (src/schema/replicated_tables.js: a soft-expire leaves counts // equal, a hard-purge leaves the replica ahead, reported for indexer only). // Deliberately NOT folded into maybeVerifyCompleteness: that sweep returns // early when COMPLETENESS_CHECK_INTERVAL is falsy and when the heights differ, diff --git a/src/config.js b/src/config.js index 5cfdec8d..298ff1fe 100644 --- a/src/config.js +++ b/src/config.js @@ -42,6 +42,13 @@ function parseIntMin1(val, defaultVal){ return Math.max(1, parseIntSafe(val, defaultVal)); } +// Parse an integer, falling back to the default (not clamping) below the minimum, +// so an out-of-range value can never select a disable or a one-row page by accident. +function parseIntAtLeast(val, min, defaultVal){ + let parsed = parseIntSafe(val, defaultVal); + return parsed < min ? defaultVal : parsed; +} + // A comma-separated list as trimmed, de-duplicated, non-empty entries; unset -> []. function parseCsvSet(val){ return [...new Set((val || '').split(',').map(s => s.trim()).filter(s => s.length > 0))]; @@ -429,6 +436,22 @@ module.exports = { // that endpoint is operator-polled rather than hot. config['COMPLETENESS_CHECK_INTERVAL'] = parseIntMin0(process.env.COMPLETENESS_CHECK_INTERVAL, 3600000); + // DISPENSERS_RECONCILE_EVERY: a decoder client replaces its `dispensers` table + // every Nth incremental catch-up, since the table rides no block stream (>= 1). + config['DISPENSERS_RECONCILE_EVERY'] = parseIntAtLeast(process.env.DISPENSERS_RECONCILE_EVERY, 1, 20); + + // DISPENSERS_RECONCILE_MAX_INTERVAL_MS: wall-clock bound (ms) on that reconcile, + // sampled on catch-ups and on the live status tick. 0 disables the bound. + config['DISPENSERS_RECONCILE_MAX_INTERVAL_MS'] = parseIntAtLeast(process.env.DISPENSERS_RECONCILE_MAX_INTERVAL_MS, 0, 1800000); + + // LOOKUP_PAGE_SIZE: rows per page when a client pages the append-only lookup + // tables by id cursor (>= 1); the client clamps it to 100000. + config['LOOKUP_PAGE_SIZE'] = parseIntAtLeast(process.env.LOOKUP_PAGE_SIZE, 1, 50000); + + // GAP_LOG_INTERVAL_MS: throttle window (ms) for the client's catch-up gap log + // summaries (>= 1). + config['GAP_LOG_INTERVAL_MS'] = parseIntAtLeast(process.env.GAP_LOG_INTERVAL_MS, 1, 30000); + // INDEX_MAP_PARITY_CHECK: advisory id->address map parity. Default OFF, and // UNLIKE the VERIFY_* gates above it NEVER halts: a mismatch is logged + counted // only. It catches a replica whose index_addresses id->address map content diff --git a/src/schema/replicated_tables.js b/src/schema/replicated_tables.js index fe4679d1..9154461f 100644 --- a/src/schema/replicated_tables.js +++ b/src/schema/replicated_tables.js @@ -84,7 +84,7 @@ const lifecycle = require('../table_lifecycle'); // verification path consumes the flattened union via getReplicatedTables(). // // The INDEXER topology is generated from the table-lifecycle registry -// (src/tableLifecycle.js, byte-identical twin of the xchain-indexer copy): +// (src/table_lifecycle.js, byte-identical twin of the xchain-indexer copy): // each indexer table's registry entry declares its stream scope, so adding a // table there simultaneously adds it to the per-block stream, the /status // completeness count, and both rollback sets. The DECODER topology stays @@ -128,9 +128,9 @@ const TOPOLOGY = { // it converges via full snapshot + the periodic re-dump/replace reconcile, // which is the ONLY thing keeping it in parity. The count is a post-replace // equality sanity check, not a backstop: the hard-purge DELETE gap leaves - // the replica ahead (_verifyTableCounts flags remote > local only) and a - // soft-expire UPDATE leaves counts equal, so neither can ever fire, and - // _incrementalCatchUp excludes the table on every non-reconcile cycle. + // the replica ahead (ClientSync.verifyTableCounts flags remote > local only) + // and a soft-expire UPDATE leaves counts equal, so neither can ever fire, and + // ClientSync.incrementalCatchUp excludes the table on every non-reconcile cycle. special: ['dispensers'] }, @@ -163,15 +163,26 @@ function getReplicatedTables(dbType){ return [...new Set(all)]; } +// Replicated tables whose content cannot converge between source and replica, so +// neither the /status row-count check (ClientSync.verifyTableCounts) nor the +// content-parity plan may compare them. Both read this one declaration. +// +// `events` is an append-only operational log keyed by an AUTO_INCREMENT id both +// sides generate independently, applied with INSERT IGNORE, so a source row whose +// id the replica already used is dropped and the replica keeps its own row there. +// The id-windowed content digest then sees equal counts over different rows. +const OPERATIONAL_LOG_TABLES = Object.freeze(['events']); + // The advisory content-parity plan for a dbType: the replicated tables whose // CONTENT (not merely their row count) a follower can prove against the source, // each paired with the bound its checksum window uses. // // Coverage is the per-block replicated set minus the two exclusion classes the -// registry declares (src/tableLifecycle.js CONTENT_PARITY_*): the operator +// registry declares (src/table_lifecycle.js CONTENT_PARITY_*): the operator // carve-outs (markets, decoder dispensers) and the in-place mutated tables, // which the enforced state_hash already commits and which have no stable window -// content. Derived from the same topology the stream and the row counts use, so +// content. The operational logs above are out too, as the count check leaves +// them out. Derived from the same topology the stream and the row counts use, so // a table added to replication joins this check with no second list to update. // // bound values, consumed by BlockHasher.computeTableContentChecksums: @@ -192,6 +203,7 @@ function contentParityPlan(dbType){ let add = (table, bound) => { if(mutable.has(table)) return; // committed by state_hash instead if(lifecycle.contentParityCarveOut(table, type) !== null) return; // operator ruling + if(OPERATIONAL_LOG_TABLES.includes(table)) return; // ids diverge by construction if(plan.some(p => p.table === table)) return; // topology buckets can overlap plan.push({ table: table, bound: bound }); }; @@ -207,7 +219,7 @@ function contentParityPlan(dbType){ // Every replicated table that is NOT in the content-parity plan, mapped to the // reason it is out. Exists so the coverage guard can assert the complement is -// exactly the two declared exclusion classes and nothing has silently fallen +// exactly the declared exclusions and nothing has silently fallen // through: a replicated table that is neither checked nor knowingly excluded is // the defect was raised for. function contentParityExclusions(dbType){ @@ -218,6 +230,8 @@ function contentParityExclusions(dbType){ let carve = lifecycle.contentParityCarveOut(table, type); if(carve !== null) out[table] = 'operator-carve-out: ' + carve; else if(mutable.has(table)) out[table] = 'in-place mutated; committed by the enforced state_hash class instead'; + else if(OPERATIONAL_LOG_TABLES.includes(table)) + out[table] = 'operational log: independently generated ids applied with INSERT IGNORE; neither count nor id-windowed content can converge'; } return out; } @@ -265,5 +279,5 @@ function lookupCursorColumn(table){ module.exports = { getTopology, getReplicatedTables, missingReplicatedTables, lookupCursorColumn, - contentParityPlan, contentParityExclusions + contentParityPlan, contentParityExclusions, OPERATIONAL_LOG_TABLES }; diff --git a/src/server/snapshot_builder.js b/src/server/snapshot_builder.js index 9ad73d1c..350b60f0 100644 --- a/src/server/snapshot_builder.js +++ b/src/server/snapshot_builder.js @@ -92,15 +92,10 @@ const bigIntReplacer = (k, v) => typeof v === 'bigint' ? v.toString() : v; const OPERATOR_LOCAL_TABLES = new Set([ ...tableLifecycle.tablesWhere(t => ['local', 'hub-mirror', 'follower-derived'].includes(t.replication)), - // mempool_transactions: node-local, non-deterministic observation state (its own - // schema comment forbids sharing raw values across nodes). It is a DECODER-DB - // table with no registry entry (the registry covers the indexer DB). Every other - // channel already excludes it: the per-block stream (replicatedTables.js), the - // incremental snapshot (decoderSkip in streamIncrementalSnapshot), and the - // /status completeness count (getReplicatedTables). Listing it here closes the - // one remaining leak: the FULL snapshot used to ship the source's - // bootstrap-instant mempool, freezing it forever on full-bootstrap decoder - // replicas while incremental-bootstrap replicas held zero rows for the same table. + // Keep mempool_transactions out of the FULL snapshot too: it is node-local decoder + // state with no registry entry, and every other channel (block stream, incremental + // snapshot, /status count) already skips it, so shipping it would freeze the + // source's bootstrap-instant mempool on full-bootstrap replicas. 'mempool_transactions', // sync_halt, sync_state: replica-local durable CONTROL tables the source never // ships (created by db.verifySyncTables for both dbTypes; no registry entry). @@ -226,6 +221,14 @@ const PRIORITY_TABLES = [ // sync_meta carries a FK on pubkeys. const TRAILING_TABLES = ['balances', 'sync_meta', 'pubkeys']; +// Indexer full-dump tables outside the lookup topology that are still append-only, +// mapped to the column that pages them in-band. Neither is synced out of band, so +// both stream under skipLookups too. pubkeys has no surrogate id and pages by its +// address_id PRIMARY KEY: sound only inside one read view, since across requests +// that cursor skips late rows (see replicatedTables.lookupCursorColumn), so it +// must never join the out-of-band rows route. +const INDEXER_INBAND_PAGED = Object.freeze({ events: 'id', pubkeys: 'address_id' }); + // Order a set of snapshot table names into the builder's dependency order: // priority tables first (in declared order), everything else alphabetically, // trailing tables last. Exported (alongside OPERATOR_LOCAL_TABLES) so @@ -249,6 +252,42 @@ function orderSnapshotTables(allTables){ return ordered; } +// Classify the decoder tables for an incremental snapshot. The three streamed +// buckets derive from TOPOLOGY.decoder so they cannot drift from the per-block +// stream; only the skip set is declared, and a unit test proves the four exhaustive. +// +// Decoder full-dump tables: index_* and pubkeys are small + append-only; +// the client uses INSERT IGNORE so re-sending existing rows is a no-op. +// `events` is full-dumped too: it carries no block_index/tx_index cursor +// to scope incrementally, so the only way an incrementally-caught-up +// follower converges its events table is a complete re-dump. It is safe +// to re-send because events has an AUTO_INCREMENT `id` PK and the client +// applies all incremental rows with INSERT IGNORE (existing ids are no-ops). +// +// dispensers is skipped here for the same reason it is not per-block +// streamed: the decoder soft-expires dispensers (UPDATE expired_block_index) +// and defers the hard-purge to purgeExpiredDispensers. An insert-only +// incremental delta (the tx_index->block_index join) would re-introduce the +// count divergence on any follower that catches up incrementally, and a plain +// re-dump would collide on the (tx_index, address_id) PK (dispensers is not in +// ClientApplier.ignoreTables, so it is not INSERT IGNORE). dispensers seeds +// from the full snapshot and is then held in parity SOLELY by the apply-side +// reconcile: ClientApplier.applyDispensersReplace via +// ClientSync.reconcileDispensers, gated by DISPENSERS_RECONCILE_EVERY / +// DISPENSERS_RECONCILE_MAX_INTERVAL_MS. Its decoder /status completeness count +// (replicatedTables `special`) is a post-replace equality sanity check, not a +// backstop: a soft-expire UPDATE leaves counts equal and a hard-purge DELETE +// leaves the replica ahead, which verifyTableCounts does not report. +function decoderIncrementalSets(){ + let topology = replicatedTables.getTopology('decoder'); + return { + blockScoped: new Set(topology.blockScoped), + txScoped: new Set(topology.txScoped), + fullDump: new Set(topology.index), + skip: new Set(['mempool_transactions', 'dispensers']) + }; +} + class SnapshotBuilder { constructor(util) { @@ -540,32 +579,12 @@ class SnapshotBuilder { let tableOrder = await this.getOrderedTables(db, conn); let first = true; - // Scoping rules per dbType. - // Decoder full-dump tables: index_* and pubkeys are small + append-only; - // the client uses INSERT IGNORE so re-sending existing rows is a no-op. - // `events` is full-dumped too: it carries no block_index/tx_index cursor - // to scope incrementally, so the only way an incrementally-caught-up - // follower converges its events table is a complete re-dump. It is safe - // to re-send because events has an AUTO_INCREMENT `id` PK and the client - // applies all incremental rows with INSERT IGNORE (existing ids are no-ops). - let decoderBlockScoped = new Set(['blocks', 'transactions']); - let decoderTxScoped = new Set(['transaction_outputs']); - let decoderFullDump = new Set(['index_addresses', 'index_transactions', 'pubkeys', 'events']); - // dispensers is skipped here for the same reason it is not per-block - // streamed: the decoder soft-expires dispensers (UPDATE expired_block_index) - // and defers the hard-purge to purgeExpiredDispensers. An insert-only - // incremental delta (the tx_index->block_index join) would re-introduce the - // count divergence on any follower that catches up incrementally, and a plain - // re-dump would collide on the (tx_index, address_id) PK (dispensers is not in - // ClientApplier.ignoreTables, so it is not INSERT IGNORE). dispensers seeds - // from the full snapshot and is then held in parity SOLELY by the apply-side - // reconcile: ClientApplier.applyDispensersReplace via - // ClientSync.reconcileDispensers, gated by DISPENSERS_RECONCILE_EVERY / - // DISPENSERS_RECONCILE_MAX_INTERVAL_MS. Its decoder /status completeness count - // (replicatedTables `special`) is a post-replace equality sanity check, not a - // backstop: a soft-expire UPDATE leaves counts equal and a hard-purge DELETE - // leaves the replica ahead, which verifyTableCounts does not report. - let decoderSkip = new Set(['mempool_transactions', 'dispensers']); + // Scoping rules per dbType. Decoder buckets: see decoderIncrementalSets. + let decoderSets = decoderIncrementalSets(); + let decoderBlockScoped = decoderSets.blockScoped; + let decoderTxScoped = decoderSets.txScoped; + let decoderFullDump = decoderSets.fullDump; + let decoderSkip = decoderSets.skip; // Indexer block-scoped set. These tables carry a block_index but no // action_index, so the action_index branch below cannot reach them. @@ -685,17 +704,16 @@ class SnapshotBuilder { continue; } - // The indexer `events` log is the one indexerFullDump member that is - // append-only on an AUTO_INCREMENT id yet absent from lookupSet: its + // The indexer `events` log and `pubkeys` cache are the indexerFullDump + // members that are append-only yet absent from lookupSet: their // replication class is 'snapshot', not 'stream:index', so the branch - // above cannot reach it and it fell to the bundled SELECT * below, - // materializing the whole audit log per catch-up. Page it - // by the same id cursor, which emits a byte-identical "events":[...] - // key, so no client, protocol, or schema change is implied. Unlike the - // lookupSet tables it is NOT synced out of band, so it must still be - // streamed under skipLookups rather than skipped. - if(dbType === 'indexer' && table === 'events' && indexerFullDump.has(table)){ - first = await this.streamLookupPaged(writer, db, table, conn, first); + // above cannot reach them and the bundled SELECT * below would + // materialize the whole table per catch-up. Page each by its + // INDEXER_INBAND_PAGED cursor, which emits a byte-identical key, so no + // client, protocol, or schema change is implied. Unlike the lookupSet + // tables they are NOT synced out of band, so they stream under skipLookups. + if(dbType === 'indexer' && Object.hasOwn(INDEXER_INBAND_PAGED, table) && indexerFullDump.has(table)){ + first = await this.streamLookupPaged(writer, db, table, conn, first, INDEXER_INBAND_PAGED[table]); continue; } @@ -929,8 +947,9 @@ class SnapshotBuilder { // array (or gzip buffer) all at once. `first` tracks whether any table key has // been written yet (for the inter-table comma); returns the updated value. // Uses the shared REPEATABLE READ conn, so paging is consistent across batches. - async streamLookupPaged(writer, db, table, conn, first){ - let col = replicatedTables.lookupCursorColumn(table); + // `cursorCol` overrides the lookup cursor for a table paged only in-band. + async streamLookupPaged(writer, db, table, conn, first, cursorCol){ + let col = cursorCol || replicatedTables.lookupCursorColumn(table); let after = 0; let wrote = false; let firstRow = true; @@ -1024,7 +1043,7 @@ class SnapshotBuilder { } } - // Stream one keyset-ordered page of the decoder `dispensers` table. dispensers + // Stream the whole decoder `dispensers` table, keyset-ordered. dispensers // is excluded from both the incremental block stream and the id-cursor lookup // paging (streamTableRowsById): it has no monotonic surrogate id (PK is // (tx_index, address_id)) and the decoder soft-expires then hard-purges rows, so @@ -1049,7 +1068,7 @@ class SnapshotBuilder { // cursor params stay honoured (filtered within the same single query) and // has_more is always false, so an old paging client simply completes its walk // in one round trip. - async streamDispensers(db, afterTx, afterAddr, limit, res){ + async streamDispensers(db, afterTx, afterAddr, res){ let dbType = (db && db.dbType) || 'indexer'; if(dbType !== 'decoder'){ return res.status(400).json({ error: 'dispensers reconcile is decoder-only' }); @@ -1107,5 +1126,6 @@ SnapshotBuilder.SnapshotStreamWriter = SnapshotStreamWriter; SnapshotBuilder.OPERATOR_LOCAL_TABLES = OPERATOR_LOCAL_TABLES; SnapshotBuilder.SOURCE_UNSTREAMED_TABLES = SOURCE_UNSTREAMED_TABLES; SnapshotBuilder.orderSnapshotTables = orderSnapshotTables; +SnapshotBuilder.decoderIncrementalSets = decoderIncrementalSets; module.exports = SnapshotBuilder; diff --git a/test/e2e/dispensers_reconcile.test.js b/test/e2e/dispensers_reconcile.test.js index 4e8531b8..f0def7e3 100644 --- a/test/e2e/dispensers_reconcile.test.js +++ b/test/e2e/dispensers_reconcile.test.js @@ -16,7 +16,7 @@ * * dispensers rides neither the block stream nor the id-cursor lookup * paging (no monotonic id; the decoder soft-expires via UPDATE then - * hard-purges via DELETE). SnapshotBuilder's decoderSkip set lives inside + * hard-purges via DELETE). SnapshotBuilder's decoder skip set applies only to * streamIncrementalSnapshot, so the INCREMENTAL path skips dispensers while * the FULL snapshot carries it: the full-snapshot table filter excludes only * OPERATOR_LOCAL_TABLES / SOURCE_UNSTREAMED_TABLES, and dispensers is in @@ -125,9 +125,7 @@ function buildServer(sourceDb, broadcaster, snapshotBuilder){ if((req.query.after_tx !== undefined && (isNaN(afterTx) || afterTx < 0)) || (req.query.after_addr !== undefined && (isNaN(afterAddr) || afterAddr < 0))) return res.status(400).json({ error: 'Invalid cursor' }); - let limit = parseInt(req.query.limit); - if(isNaN(limit)) limit = undefined; - try { await snapshotBuilder.streamDispensers(sourceDb, afterTx, afterAddr, limit, res); } + try { await snapshotBuilder.streamDispensers(sourceDb, afterTx, afterAddr, res); } catch(e){ if(!res.headersSent) res.status(500).json({ error: e.message }); } }); @@ -235,8 +233,8 @@ describe('E2E: Decoder dispensers reconcile', function() { } // `every` => DISPENSERS_RECONCILE_EVERY; `pageSize` => LOOKUP_PAGE_SIZE - // (forced smaller than the fixture row count to prove a small page size does - // NOT split the dump: streamDispensers is deliberately single-response and + // (forced smaller than the fixture row count to prove the lookup page size does + // NOT bound the dump: streamDispensers is deliberately single-response and // answers has_more=false whatever the cursor params say, so the client's // fetch walk completes in one round trip. That contract is pinned directly in // test/unit/snapshot_builder.test.js, describe('streamDispensers')). diff --git a/test/e2e/helpers/fixtures.js b/test/e2e/helpers/fixtures.js index d917628a..596c1749 100644 --- a/test/e2e/helpers/fixtures.js +++ b/test/e2e/helpers/fixtures.js @@ -40,8 +40,12 @@ function blockHash(blockIndex, label) { async function computeAndInsertBlockHashes(db, blockIndex, conn) { // When called inside a fixture block transaction, every read/write must ride // that transaction's connection or the hasher can't see the block's own - // uncommitted rows. BlockHasher only needs doQuery, so a thin facade pins it. - let hasherDb = conn ? { doQuery: (q, a) => db.doQuery(q, a, conn) } : db; + // uncommitted rows. BlockHasher only needs doQuery and doQueryStrict, so a thin + // facade pins both. + let hasherDb = conn ? { + doQuery: (q, a) => db.doQuery(q, a, conn), + doQueryStrict: (q, a) => db.doQueryStrict(q, a, conn) + } : db; let computed = await new BlockHasher(hasherDb, _util).computeBlockHashes(blockIndex); let ids = {}; for (let [field, hash] of [['ledger_hash_id', computed.ledger_hash], diff --git a/test/fixtures/gen-block-hash-vectors.js b/test/fixtures/gen-block-hash-vectors.js index 994e2c87..97e5e3d4 100644 --- a/test/fixtures/gen-block-hash-vectors.js +++ b/test/fixtures/gen-block-hash-vectors.js @@ -71,7 +71,8 @@ const results = [ async function main(){ let call = 0; - const mockDb = { doQuery: async () => results[call++] }; + const next = async () => results[call++]; + const mockDb = { doQuery: next, doQueryStrict: next }; // Hash with the INDEXER's getDataHash so the expected values are authentic. const hasher = new BlockHasher(mockDb, new IndexerUtil()); const expected = await hasher.computeBlockHashes(BLOCK_INDEX); diff --git a/test/unit/block_hasher.test.js b/test/unit/block_hasher.test.js index dcf89422..b344aaec 100644 --- a/test/unit/block_hasher.test.js +++ b/test/unit/block_hasher.test.js @@ -26,10 +26,14 @@ const BlockHasher = require('../../src/client/block_hasher'); const Utility = require('../../src/util'); const vectors = require('../fixtures/block-hash-vectors.json'); -// A BlockHasher whose db.doQuery returns the canned result-sets in CALL ORDER. +// A BlockHasher whose strict reader returns the canned result-sets in CALL ORDER. +// The fail-soft reader throws, so a gather that regresses to doQuery fails here. function hasherFor(results){ let i = 0; - const db = { doQuery: async () => results[i++] }; + const db = { + doQuery: async () => { throw new Error('fail-soft doQuery used for a consensus preimage gather'); }, + doQueryStrict: async () => results[i++] + }; return new BlockHasher(db, new Utility()); } @@ -66,6 +70,21 @@ describe('BlockHasher: independent recompute conformance @regression', function( assert.ok(got.ledger_hash && got.actions_hash && got.contract_hash, 'empty block still yields the three chained hashes'); }); + + it('a read error rejects instead of hashing a truncated preimage', async function(){ + // Model the production reader: doQuery swallows the error into [], doQueryStrict throws. + let call = 0; + const db = { + doQuery: async () => [], + doQueryStrict: async () => { + if(++call === 3) throw new Error('lock wait timeout on escrows'); + return []; + } + }; + await assert.rejects(new BlockHasher(db, new Utility()).computeBlockHashes(vectors.block_index), + /lock wait timeout on escrows/, + 'a failed gather must surface as a recompute error, never as an empty row set'); + }); }); describe('BlockHasher: independent recompute conformance @regression', function(){ @@ -77,7 +96,8 @@ describe('BlockHasher: independent recompute conformance @regression', function( // Capture emitted SQL while feeding empty result-sets. function capturingHasher(calls){ - const db = { doQuery: async (sql) => { calls.push(sql); return []; } }; + const capture = async (sql) => { calls.push(sql); return []; }; + const db = { doQuery: capture, doQueryStrict: capture }; return new BlockHasher(db, new Utility()); } const stateQueryOf = (calls) => { diff --git a/test/unit/boundaries/config_parsing.test/08_client_reconcile_knobs.test.js b/test/unit/boundaries/config_parsing.test/08_client_reconcile_knobs.test.js new file mode 100644 index 00000000..ce5e4b57 --- /dev/null +++ b/test/unit/boundaries/config_parsing.test/08_client_reconcile_knobs.test.js @@ -0,0 +1,69 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Covers the client reconcile and paging knobs. One part of config_parsing.test.js. +const assert = require('assert'); +const config = require('../../../../src/config'); + +const KNOBS = ['DISPENSERS_RECONCILE_EVERY', 'DISPENSERS_RECONCILE_MAX_INTERVAL_MS', + 'LOOKUP_PAGE_SIZE', 'GAP_LOG_INTERVAL_MS']; + +// Clear the knobs around each test so an ambient value cannot leak in. +function registerKnobHooks(){ + let saved = {}; + beforeEach(function(){ + for(let key of KNOBS){ saved[key] = process.env[key]; delete process.env[key]; } + }); + afterEach(function(){ + for(let key of KNOBS){ + if(saved[key] !== undefined) process.env[key] = saved[key]; + else delete process.env[key]; + } + }); +} + +describe('Boundary: Config Parsing', function(){ + registerKnobHooks(); + describe('client reconcile and paging knobs reach the live config', function(){ + it('keep the reader defaults when unset', function(){ + let cfg = config.getConfig(); + assert.strictEqual(cfg.DISPENSERS_RECONCILE_EVERY, 20); + assert.strictEqual(cfg.DISPENSERS_RECONCILE_MAX_INTERVAL_MS, 1800000); + assert.strictEqual(cfg.LOOKUP_PAGE_SIZE, 50000); + assert.strictEqual(cfg.GAP_LOG_INTERVAL_MS, 30000); + }); + it('pass a set value through', function(){ + process.env.DISPENSERS_RECONCILE_EVERY = '5'; + process.env.DISPENSERS_RECONCILE_MAX_INTERVAL_MS = '60000'; + process.env.LOOKUP_PAGE_SIZE = '1000'; + process.env.GAP_LOG_INTERVAL_MS = '5000'; + let cfg = config.getConfig(); + assert.strictEqual(cfg.DISPENSERS_RECONCILE_EVERY, 5); + assert.strictEqual(cfg.DISPENSERS_RECONCILE_MAX_INTERVAL_MS, 60000); + assert.strictEqual(cfg.LOOKUP_PAGE_SIZE, 1000); + assert.strictEqual(cfg.GAP_LOG_INTERVAL_MS, 5000); + }); + it('keeps DISPENSERS_RECONCILE_MAX_INTERVAL_MS=0 as the documented disable', function(){ + process.env.DISPENSERS_RECONCILE_MAX_INTERVAL_MS = '0'; + assert.strictEqual(config.getConfig().DISPENSERS_RECONCILE_MAX_INTERVAL_MS, 0); + }); + it('fall back to the default on a non-numeric or out-of-range value', function(){ + process.env.DISPENSERS_RECONCILE_EVERY = 'later'; + process.env.DISPENSERS_RECONCILE_MAX_INTERVAL_MS = '-1'; + process.env.LOOKUP_PAGE_SIZE = '0'; + process.env.GAP_LOG_INTERVAL_MS = '0'; + let cfg = config.getConfig(); + assert.strictEqual(cfg.DISPENSERS_RECONCILE_EVERY, 20); + assert.strictEqual(cfg.DISPENSERS_RECONCILE_MAX_INTERVAL_MS, 1800000, 'a negative value must not read as the disable'); + assert.strictEqual(cfg.LOOKUP_PAGE_SIZE, 50000, '0 must not become a one-row page'); + assert.strictEqual(cfg.GAP_LOG_INTERVAL_MS, 30000); + }); + }); +}); diff --git a/test/unit/client_sync.test/04_start.test.js b/test/unit/client_sync.test/04_start.test.js index 771e94bd..630ed134 100644 --- a/test/unit/client_sync.test/04_start.test.js +++ b/test/unit/client_sync.test/04_start.test.js @@ -92,8 +92,38 @@ function registerStartGroup2Tests(){ }); } +// A full-snapshot bootstrap never reconciles dispensers, so live-follow is the only +// point the status tick's wall-clock bound can measure from on such a replica. +function registerStartDispenserClockTests(){ + describe('start', function(){ + it('starts the dispensers wall clock when a snapshot-bootstrapped decoder enters live-follow', async function(){ + sync.dbType = 'decoder'; + db.getLastBlock.resolves(null); + sinon.stub(sync, 'bootstrapFromSnapshot').callsFake(async () => { sync.lastAppliedBlock = 10; }); + sinon.stub(sync, 'connectWebSockets').callsFake(() => { sync.running = false; }); + let before = Date.now(); + + await sync.start(); + + assert.strictEqual(sync._lastDispenserReconcileAt, undefined, 'the snapshot path stamps no reconcile'); + assert.ok(sync._dispenserClockArmedAt >= before, 'the clock must be armed at live-follow'); + }); + + it('leaves the dispensers clock alone on an indexer replica', async function(){ + db.getLastBlock.resolves(null); + sinon.stub(sync, 'bootstrapFromSnapshot').callsFake(async () => { sync.lastAppliedBlock = 10; }); + sinon.stub(sync, 'connectWebSockets').callsFake(() => { sync.running = false; }); + + await sync.start(); + + assert.strictEqual(sync._dispenserClockArmedAt, undefined); + }); + }); +} + describe('ClientSync', function(){ registerClientSyncHooks(assignState); registerStartGroup1Tests(); registerStartGroup2Tests(); + registerStartDispenserClockTests(); }); diff --git a/test/unit/client_sync_dispenser_reconcile_tick.test.js b/test/unit/client_sync_dispenser_reconcile_tick.test.js index 9f8352c6..35d7197a 100644 --- a/test/unit/client_sync_dispenser_reconcile_tick.test.js +++ b/test/unit/client_sync_dispenser_reconcile_tick.test.js @@ -59,8 +59,7 @@ describe('ClientSync.dispenserReconcileIntervalDue (wall-clock term)', function( }); it('is not due before any reconcile has stamped a time', function(){ - // firstResume belongs to the catch-up path: firing it from a recurring tick - // would retry a failing re-dump on every tick instead of once per interval. + // A bare context carries no success, attempt or live-follow time to measure from. let ctx = { config: { DISPENSERS_RECONCILE_MAX_INTERVAL_MS: '60000' }, _lastDispenserReconcileAt: null }; assert.strictEqual(due(ctx, 99999999), false); @@ -174,3 +173,111 @@ describe('ClientSync status tick fires the stale dispensers reconcile', function assert.strictEqual(ctx.reconcileDispensers.calledOnce, true); }); }); + +// A replica that bootstrapped from a full snapshot never reconciles, so the bound +// measures from live-follow, and a failed attempt defers the retry one interval. +describe('ClientSync.dispenserReconcileIntervalDue without a successful reconcile', function(){ + function due(over, now){ + let ctx = Object.assign({ config: { DISPENSERS_RECONCILE_MAX_INTERVAL_MS: '60000' }, + _lastDispenserReconcileAt: null }, over); + return ClientSync.prototype.dispenserReconcileIntervalDue.call(ctx, now); + } + + it('is due once live-follow began longer than the interval ago', function(){ + assert.strictEqual(due({ _dispenserClockArmedAt: 1000 }, 1000 + 60000), true); + }); + + it('is not due inside the interval from live-follow', function(){ + assert.strictEqual(due({ _dispenserClockArmedAt: 1000 }, 1000 + 59999), false); + }); + + it('waits a full interval after a failed attempt, then is due again', function(){ + let over = { _dispenserClockArmedAt: 1000, _lastDispenserReconcileAttemptAt: 70000 }; + assert.strictEqual(due(over, 70000 + 59999), false); + assert.strictEqual(due(over, 70000 + 60000), true); + }); + + it('does not retry a failing re-dump every tick after an old success', function(){ + let over = { _lastDispenserReconcileAt: 1000, _lastDispenserReconcileAttemptAt: 90000 }; + assert.strictEqual(due(over, 90001), false); + }); + + it('measures from a success newer than both fallbacks', function(){ + let over = { _lastDispenserReconcileAt: 100000, _lastDispenserReconcileAttemptAt: 99000, + _dispenserClockArmedAt: 1000 }; + assert.strictEqual(due(over, 100000 + 59999), false); + }); + + it('stays disabled by 0 whatever the fallbacks say', function(){ + let over = { config: { DISPENSERS_RECONCILE_MAX_INTERVAL_MS: '0' }, _dispenserClockArmedAt: 1 }; + assert.strictEqual(due(over, 99999999), false); + }); + + it('fires the status tick on a snapshot-bootstrapped replica', async function(){ + let ctx = { + dbType: 'decoder', config: { DISPENSERS_RECONCILE_MAX_INTERVAL_MS: '60000' }, + sources: ['http://source1:3006'], lastKnownServerBlock: 500, lastAppliedBlock: 500, + _halted: null, _lastDispenserReconcileAt: null, _dispenserClockArmedAt: 1000, + _dispenserReconcileInFlight: false, + recordUpstreamStatus: sinon.stub(), logGap: sinon.stub(), + incrementalCatchUp: sinon.stub().resolves(), maybeVerifyCompleteness: sinon.stub().resolves(), + reconcileDispensers: sinon.stub().resolves(), + dispenserReconcileIntervalDue: ClientSync.prototype.dispenserReconcileIntervalDue + }; + let logStub = sinon.stub(console, 'log'); + let clock = sinon.useFakeTimers({ now: 1000 + 60000, toFake: ['Date'] }); + try { + await ClientSync.prototype.handleEvent.call(ctx, { type: 'status', block_height: 500 }, 0); + } finally { + clock.restore(); + logStub.restore(); + } + assert.strictEqual(ctx.reconcileDispensers.calledOnce, true); + }); +}); + +describe('ClientSync.reconcileDispensers attempt stamp and request', function(){ + const axios = require('axios'); + + function reconcileCtx(){ + return { + dbType: 'decoder', chain: 'bitcoin', network: 'mainnet', + config: { SNAPSHOT_MAX_CONTENT: 1024 * 1024 }, + upstreamHeaders: () => ({}), + withApplyLock: (fn) => fn(), + applier: { applyDispensersReplace: sinon.stub().resolves() } + }; + } + + beforeEach(function(){ sinon.stub(console, 'log'); sinon.stub(console, 'error'); }); + afterEach(function(){ sinon.restore(); }); + + it('stamps the attempt but not the success when the re-dump fails', async function(){ + sinon.stub(axios, 'get').rejects(new Error('ECONNRESET')); + let ctx = reconcileCtx(); + let clock = sinon.useFakeTimers({ now: 42000, toFake: ['Date'] }); + try { await ClientSync.prototype.reconcileDispensers.call(ctx, 'http://source1:3006'); } + finally { clock.restore(); } + assert.strictEqual(ctx._lastDispenserReconcileAttemptAt, 42000); + assert.strictEqual(ctx._lastDispenserReconcileAt, undefined); + assert.strictEqual(ctx.applier.applyDispensersReplace.called, false, 'local table left intact'); + }); + + it('requests the whole table with no page-size parameter and stamps the success', async function(){ + let body = JSON.stringify({ has_more: false, rows: [{ tx_index: 1, address_id: 2 }] }); + let get = sinon.stub(axios, 'get').resolves({ data: Buffer.from(body) }); + let ctx = reconcileCtx(); + await ClientSync.prototype.reconcileDispensers.call(ctx, 'http://source1:3006'); + assert.strictEqual(get.firstCall.args[0], 'http://source1:3006/snapshot-dispensers/decoder/bitcoin/mainnet'); + assert.strictEqual(ctx.applier.applyDispensersReplace.firstCall.args[0].length, 1); + assert.ok(ctx._lastDispenserReconcileAt >= ctx._lastDispenserReconcileAttemptAt); + }); +}); + +describe('ClientSync.shouldReconcileDispensers ignores the tick-only fallbacks', function(){ + it('still treats a replica with no successful reconcile as the first resume', function(){ + let ctx = { config: { DISPENSERS_RECONCILE_EVERY: '1000' }, _lastDispenserReconcileAt: null, + _dispenserClockArmedAt: 5000, _lastDispenserReconcileAttemptAt: 5000, _catchUpCount: 0 }; + assert.strictEqual(ClientSync.prototype.shouldReconcileDispensers.call(ctx, 5001), true); + }); +}); diff --git a/test/unit/client_sync_halt.test/01_independent_recompute_halt.test.js b/test/unit/client_sync_halt.test/01_independent_recompute_halt.test.js index 867b3618..35ba7a58 100644 --- a/test/unit/client_sync_halt.test/01_independent_recompute_halt.test.js +++ b/test/unit/client_sync_halt.test/01_independent_recompute_halt.test.js @@ -27,16 +27,18 @@ const Utility = require('../../../src/util'); const HashVerifier = require('../../../src/client/hash_verifier'); const vectors = require('../../fixtures/block-hash-vectors.json'); -// db.doQuery feeds BlockHasher the canned golden rows IN CALL ORDER (one -// sequence per computeBlockHashes pass), so the recompute yields the +// db.doQuery / doQueryStrict feed BlockHasher the canned golden rows IN CALL +// ORDER (one sequence per computeBlockHashes pass), so the recompute yields the // indexer-authentic committed hashes from vectors.expected. function seqDb(results){ let i = 0; + const next = async () => results[i++]; return { dbName: 'test_db', dbType: 'indexer', getLastBlock: sinon.stub().resolves(null), getBlockHashRow: sinon.stub().resolves(null), - doQuery: sinon.stub().callsFake(async () => results[i++]), + doQuery: sinon.stub().callsFake(next), + doQueryStrict: sinon.stub().callsFake(next), recordHalt: sinon.stub().resolves({ block_index: 0 }), getActiveHalt: sinon.stub().resolves(null), clearHalt: sinon.stub().resolves(1) @@ -116,11 +118,15 @@ describe('ClientSync: independent recompute halt @regression', function(){ await sync.applyBlockEvent(event); assert.strictEqual(sync.isHalted(), false, 'no recompute, no halt when opted out'); assert.strictEqual(db.doQuery.called, false, 'recompute queries must not run when disabled'); + assert.strictEqual(db.doQueryStrict.called, false, 'recompute queries must not run when disabled'); assert.strictEqual(sync.lastAppliedBlock, vectors.block_index); }); it('a recompute DB error is logged but does NOT halt (no self-inflicted fork on infra faults)', async function(){ - db.doQuery = sinon.stub().rejects(new Error('transient DB error')); + // Model the production reader: doQuery swallows the error into [], doQueryStrict throws. + // A preimage gathered fail-soft would hash the empty rows and halt on a false divergence. + db.doQuery = sinon.stub().resolves([]); + db.doQueryStrict = sinon.stub().rejects(new Error('transient DB error')); const event = { block_index: vectors.block_index, block_time: 123, ledger_hash: 'x', actions_hash: 'y', contract_hash: 'z' }; await sync.applyBlockEvent(event); assert.strictEqual(sync.isHalted(), false, 'an infra error must not halt the validator'); diff --git a/test/unit/decoder_table_classification.test.js b/test/unit/decoder_table_classification.test.js index c031f8de..2af583d4 100644 --- a/test/unit/decoder_table_classification.test.js +++ b/test/unit/decoder_table_classification.test.js @@ -42,7 +42,7 @@ const fs = require('fs'); const path = require('path'); const { getReplicatedTables } = require('../../src/schema/replicated_tables'); -const { OPERATOR_LOCAL_TABLES } = require('../../src/server/snapshot_builder'); +const { OPERATOR_LOCAL_TABLES, decoderIncrementalSets } = require('../../src/server/snapshot_builder'); // Resolved exactly as the other two decoder-schema readers resolve it // (generatedColumns.test.js, replicatedDatetimeColumns.test.js), so this guard @@ -64,8 +64,8 @@ function requireSibling(ctx){ // Decoder tables deliberately NOT replicated. Each entry is a decision, not an // oversight, and the same decision is repeated by hand in SnapshotBuilder -// (OPERATOR_LOCAL_TABLES, and the function-local decoderSkip inside -// streamIncrementalSnapshot), which is why the last case below cross-checks it. +// (OPERATOR_LOCAL_TABLES, and the skip set of decoderIncrementalSets), which is +// why the cases below cross-check it. // // mempool_transactions: node-local, non-deterministic observation state; its own // schema comment forbids sharing raw values across nodes. @@ -75,7 +75,7 @@ const ADD_INSTRUCTIONS = '\n\nEither add it to TOPOLOGY.decoder in src/schema/replicated_tables.js (and update the ' + 'by-value pin in test/unit/replicated_tables.test.js), or add it to DECODER_EXCLUDED ' + 'here with a written reason. Either way check SnapshotBuilder OPERATOR_LOCAL_TABLES / ' + - 'decoderSkip and ClientRollback decoderBlockTables / decoderTxScopedTables.'; + 'decoderIncrementalSets and ClientRollback decoderBlockTables / decoderTxScopedTables.'; function decoderSqlTables(){ return fs.readdirSync(DECODER_SQL_DIR) @@ -141,3 +141,25 @@ describe('decoder table classification (schema exhaustiveness) @regression', fun table + ' is in both TOPOLOGY.decoder and DECODER_EXCLUDED - pick one'); }); }); + +describe('decoder incremental-snapshot buckets (topology exhaustiveness) @regression', function(){ + + it('places every replicated decoder table in exactly one incremental-snapshot bucket', function(){ + // No sibling needed. A table no bucket names falls to the bare `continue` in + // streamIncrementalSnapshot, so derivation alone cannot cover special/actionScoped. + const buckets = Object.values(decoderIncrementalSets()); + const replicated = getReplicatedTables('decoder'); + assert.ok(replicated.length >= 8, + 'decoder topology enumeration looks broken: ' + replicated.join(',')); + + const misplaced = replicated.filter(t => buckets.filter(b => b.has(t)).length !== 1); + assert.deepStrictEqual(misplaced, [], + misplaced.length + ? 'decoder table(s) replicated but not in exactly one SnapshotBuilder ' + + 'decoderIncrementalSets bucket: ' + misplaced.join(', ') + '. An ' + + 'unclassified table rides no incremental snapshot, so every incrementally-' + + 'caught-up replica freezes it at bootstrap height and shows a permanent ' + + '/status count shortfall until a full re-snapshot. Classify it there.' + : undefined); + }); +}); diff --git a/test/unit/repo_guards/config_key_coverage.test.js b/test/unit/repo_guards/config_key_coverage.test.js new file mode 100644 index 00000000..b3471d79 --- /dev/null +++ b/test/unit/repo_guards/config_key_coverage.test.js @@ -0,0 +1,107 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Every service config key read under src/ is declared by getConfig(). + * + * The live config object is exactly what getConfig() builds, so a key the + * code reads but the builder never assigns is always undefined in production + * and its env var is silently inert. Unit tests hand-build partial config + * objects, so nothing else in the suite can see that gap. + ********************************************************************/ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const acorn = require('acorn'); +const config = require('../../../src/config'); + +const SRC_DIR = path.resolve(__dirname, '..', '..', '..', 'src'); + +// Names the service config object is held under at its read sites. +const CONFIG_NAMES = new Set(['config', 'cfg']); + +// Keys read but deliberately not declared yet. Each must still be read and still +// be undeclared, so this list can only shrink. +const PENDING = { + // Selects which release manifest the train-activation gate enforces; its env + // wiring is decided together with the indexer's train gate, not here alone. + RELEASE_MANIFEST_PATH: 'src/client/sync.js' +}; + +// List every .js file under a directory, recursively. +function jsFiles(dir, out){ + for(const entry of fs.readdirSync(dir, { withFileTypes: true })){ + const full = path.join(dir, entry.name); + if(entry.isDirectory()) jsFiles(full, out); + else if(entry.name.endsWith('.js')) out.push(full); + } + return out; +} + +// Collect `config['KEY']` / `cfg['KEY']` reads from code tokens (comments excluded). +function collectReads(file, reads){ + const source = fs.readFileSync(file, 'utf8'); + const tokens = [...acorn.tokenizer(source, { ecmaVersion: 'latest', allowHashBang: true, locations: true })]; + for(let i = 0; i + 3 < tokens.length; i++){ + const [name, open, key, close] = tokens.slice(i, i + 4); + if(name.type.label !== 'name' || !CONFIG_NAMES.has(name.value)) continue; + if(open.type.label !== '[' || close.type.label !== ']') continue; + if(key.type.label !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(key.value)) continue; + const site = path.relative(path.dirname(SRC_DIR), file) + ':' + key.loc.start.line; + if(!reads.has(key.value)) reads.set(key.value, []); + reads.get(key.value).push(site); + } + return reads; +} + +function allReads(){ + const reads = new Map(); + for(const file of jsFiles(SRC_DIR, [])) collectReads(file, reads); + return reads; +} + +describe('config key coverage: every config key src/ reads is declared by getConfig()', function(){ + let reads; + let declared; + before(function(){ + reads = allReads(); + declared = new Set(Object.keys(config.getConfig())); + }); + + it('finds the known reads, so an empty scan cannot pass', function(){ + for(const key of ['SNAPSHOT_MAX_CONTENT', 'DISPENSERS_RECONCILE_EVERY', 'DISPENSERS_RECONCILE_MAX_INTERVAL_MS']){ + assert.ok(reads.has(key), 'the scan no longer sees the read of ' + key); + } + }); + + it('declares every key that is read', function(){ + const missing = []; + for(const [key, sites] of reads){ + if(declared.has(key) || Object.prototype.hasOwnProperty.call(PENDING, key)) continue; + missing.push(key + ' (read at ' + sites.join(', ') + ')'); + } + assert.deepStrictEqual(missing, [], + 'read off the service config but never assigned in getConfig(), so the env var is inert:\n ' + + missing.join('\n ')); + }); + + it('keeps every pending key both read and undeclared', function(){ + for(const [key, file] of Object.entries(PENDING)){ + assert.ok((reads.get(key) || []).some(site => site.startsWith(file + ':')), + key + ' is no longer read in ' + file + '; drop it from PENDING'); + assert.ok(!declared.has(key), key + ' is now declared; drop it from PENDING'); + } + }); +}); diff --git a/test/unit/snapshot_builder.test/04_stream_dispensers.test.js b/test/unit/snapshot_builder.test/04_stream_dispensers.test.js index eec891f8..27c067f9 100644 --- a/test/unit/snapshot_builder.test/04_stream_dispensers.test.js +++ b/test/unit/snapshot_builder.test/04_stream_dispensers.test.js @@ -45,7 +45,7 @@ function streamDispensersPagingTests(){ it('rejects a non-decoder dbType with 400', async function(){ let db = createMockDb(); // dbType defaults to indexer-shaped (undefined) let res = createMockRes(); - await builder.streamDispensers(db, NaN, NaN, 50000, res); + await builder.streamDispensers(db, NaN, NaN, res); assert.ok(res.status.calledWith(400), 'dispensers reconcile is decoder-only'); }); @@ -59,7 +59,7 @@ function streamDispensersPagingTests(){ res.setHeader = sinon.stub(); await new Promise((resolve) => { res.on('finish', resolve); - builder.streamDispensers(db, NaN, NaN, 50000, res); + builder.streamDispensers(db, NaN, NaN, res); }); let parsed = JSON.parse(zlib.gunzipSync(Buffer.concat(chunks)).toString()); assert.strictEqual(parsed.rows.length, 2); @@ -84,14 +84,14 @@ function streamDispensersPagingTests(){ res.setHeader = sinon.stub(); await new Promise((resolve) => { res.on('finish', resolve); - builder.streamDispensers(db, 8, 1, 3, res); + builder.streamDispensers(db, 8, 1, res); }); let parsed = JSON.parse(zlib.gunzipSync(Buffer.concat(chunks)).toString()); assert.strictEqual(parsed.has_more, false, 'has_more is always false so an old paging client completes in one round trip'); let q = db.doQueryStrict.firstCall.args[0]; assert.ok(/WHERE \(tx_index > \? OR \(tx_index = \? AND address_id > \?\)\)/.test(q), 'composite keyset predicate'); - assert.ok(!/LIMIT/.test(q), 'legacy limit arg is ignored: no paging'); + assert.ok(!/LIMIT/.test(q), 'no paging within the cursor branch either'); assert.deepStrictEqual(db.doQueryStrict.firstCall.args[1], [8, 8, 1]); }); } @@ -113,7 +113,7 @@ function streamDispensersFailureTests(){ res.setHeader = sinon.stub(); await assert.rejects( - () => builder.streamDispensers(db, NaN, NaN, 50000, res), + () => builder.streamDispensers(db, NaN, NaN, res), /errno 1205/, 'the read error must reach the route handler, which answers 500'); assert.strictEqual(res.setHeader.called, false, 'no response headers before the read succeeds'); @@ -129,7 +129,7 @@ function streamDispensersFailureTests(){ res.setHeader = sinon.stub(); await assert.rejects( - () => builder.streamDispensers(db, 8, 1, 3, res), + () => builder.streamDispensers(db, 8, 1, res), /errno 1205/); }); } diff --git a/test/unit/snapshot_builder.test/07_paging_stream_client_abort.test.js b/test/unit/snapshot_builder.test/07_paging_stream_client_abort.test.js index 9488cb5c..786361f3 100644 --- a/test/unit/snapshot_builder.test/07_paging_stream_client_abort.test.js +++ b/test/unit/snapshot_builder.test/07_paging_stream_client_abort.test.js @@ -72,7 +72,7 @@ function pagingStreamAbortTests(){ sinon.stub(zlib, 'createGzip').returns(fake); let res = new PassThrough(); res.setHeader = sinon.stub(); res.on('data', () => {}); - let p = builder.streamDispensers(db, NaN, NaN, 50000, res); + let p = builder.streamDispensers(db, NaN, NaN, res); await new Promise(r => setImmediate(r)); res.emit('close'); await p; diff --git a/test/unit/snapshot_builder.test/11_stream_incremental_snapshot_snapshot_replication_tables.test.js b/test/unit/snapshot_builder.test/11_stream_incremental_snapshot_snapshot_replication_tables.test.js index 78bd89d3..5e427ae8 100644 --- a/test/unit/snapshot_builder.test/11_stream_incremental_snapshot_snapshot_replication_tables.test.js +++ b/test/unit/snapshot_builder.test/11_stream_incremental_snapshot_snapshot_replication_tables.test.js @@ -41,40 +41,66 @@ function restoreSnapshotBuilder(){ // pubkeys rides NO incremental snapshot and freezes at bootstrap // height on every incrementally-caught-up follower, invisibly (it is excluded // from the /status count check and is not consensus-hashed). +// +// It is full-dumped instead, paged by its address_id PRIMARY KEY inside the one +// read view: a bare SELECT * would materialize the whole table per catch-up. +const PUBKEYS = [{ address_id: 7, pubkey: 'pk7' }, { address_id: 9, pubkey: 'pk9' }, { address_id: 12, pubkey: 'pk12' }]; + +// Serve pubkeys by address_id page, 1054 on any action-scoped read, and log every query. +function pubkeysDb(queries){ + let db = createMockDb(); + db.dbType = 'indexer'; + db.getLastBlock.resolves(100); + db.getBlockHashRow.resolves({ ledger_hash: 'l', actions_hash: 'a', contract_hash: 'c' }); + // Non-null: the action_index fallback is reachable precisely here. + db.getFirstActionIndex.resolves(500); + db.doQuery.callsFake(async (query, args) => { + if(query.includes('information_schema')) return [{ table_name: 'pubkeys' }]; + queries.push(query); + if(/SELECT \* FROM `pubkeys` WHERE `address_id` > \? ORDER BY `address_id` ASC LIMIT \?/.test(query)) + return PUBKEYS.filter(r => r.address_id > args[0]).slice(0, args[1]); + if(query.includes('`pubkeys`') && query.includes('action_index')){ + let e = new Error("Unknown column 'action_index' in 'where clause'"); + e.errno = 1054; + throw e; + } + return []; + }); + return db; +} + +// Stream one incremental snapshot and return its parsed body. +async function streamParsed(db, opts){ + let res = new PassThrough(); + let chunks = []; + res.on('data', c => chunks.push(c)); + res.setHeader = sinon.stub(); + await new Promise((resolve) => { res.on('finish', resolve); builder.streamIncrementalSnapshot(db, 80, res, undefined, opts); }); + return JSON.parse(zlib.gunzipSync(Buffer.concat(chunks)).toString()); +} + function streamIncrementalSnapshotReplicationTests(){ it('full-dumps indexer pubkeys instead of action-scoping it into errno 1054', async function(){ - let db = createMockDb(); - db.dbType = 'indexer'; - db.getLastBlock.resolves(100); - db.getBlockHashRow.resolves({ ledger_hash: 'l', actions_hash: 'a', contract_hash: 'c' }); - // Non-null: the action_index fallback is reachable precisely here. - db.getFirstActionIndex.resolves(500); let queries = []; - db.doQuery.callsFake(async (query) => { - if(query.includes('information_schema')) return [{ table_name: 'pubkeys' }]; - queries.push(query); - if(/SELECT \* FROM `pubkeys`\s*$/.test(query.trim())){ - return [{ address_id: 7, pubkey: 'pk7' }, { address_id: 9, pubkey: 'pk9' }]; - } - if(query.includes('`pubkeys`') && query.includes('action_index')){ - let e = new Error("Unknown column 'action_index' in 'where clause'"); - e.errno = 1054; - throw e; - } - return []; - }); - - let res = new PassThrough(); - let chunks = []; - res.on('data', c => chunks.push(c)); - res.setHeader = sinon.stub(); - await new Promise((resolve) => { res.on('finish', resolve); builder.streamIncrementalSnapshot(db, 80, res); }); - - let parsed = JSON.parse(zlib.gunzipSync(Buffer.concat(chunks)).toString()); - assert.deepStrictEqual(parsed.tables.pubkeys.map(r => r.address_id), [7, 9], 'pubkeys rides the incremental payload'); + let parsed = await streamParsed(pubkeysDb(queries)); + assert.deepStrictEqual(parsed.tables.pubkeys.map(r => r.address_id), [7, 9, 12], 'pubkeys rides the incremental payload'); assert.ok(!queries.some(q => q.includes('`pubkeys`') && q.includes('action_index')), 'pubkeys is never action-scoped (that query 1054s and is swallowed)'); }); + + it('pages indexer pubkeys by address_id rather than one unbounded SELECT *', async function(){ + builder.pageSize = 2; + let queries = []; + let parsed = await streamParsed(pubkeysDb(queries)); + assert.deepStrictEqual(parsed.tables.pubkeys.map(r => r.address_id), [7, 9, 12], 'every page lands, none twice'); + assert.ok(!queries.some(q => /SELECT \* FROM `pubkeys`\s*$/.test(q.trim())), 'no unbounded read of pubkeys'); + assert.strictEqual(queries.filter(q => q.includes('`address_id` >')).length, 2, 'two pages of at most 2 rows'); + }); + + it('still streams indexer pubkeys under skip_lookups, which has no out-of-band route for it', async function(){ + let parsed = await streamParsed(pubkeysDb([]), { skipLookups: true }); + assert.deepStrictEqual(parsed.tables.pubkeys.map(r => r.address_id), [7, 9, 12]); + }); } function snapshotBuilderTests(){ diff --git a/test/unit/table_content_parity.test/05_operational_log_exclusion.test.js b/test/unit/table_content_parity.test/05_operational_log_exclusion.test.js new file mode 100644 index 00000000..8a7638e3 --- /dev/null +++ b/test/unit/table_content_parity.test/05_operational_log_exclusion.test.js @@ -0,0 +1,102 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. +// +// The operational-log boundary: a table the row-count check declares unconvergent +// must not be content-compared either, or an id-offset replica reports it as +// "content diverged at equal row count". + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const BlockHasher = require('../../../src/client/block_hasher'); +const HashVerifier = require('../../../src/client/hash_verifier'); +const Utility = require('../../../src/util'); +const replicatedTables = require('../../../src/schema/replicated_tables'); + +// Serve canned decoder rows by id window; every listed table exists. +function decoderDb(tables){ + return { + dbType: 'decoder', + async listExistingTables(){ return new Set(Object.keys(tables)); }, + async getContentWindowRows(table){ return (tables[table] || []).slice(); }, + async getMaxRowId(table){ + let rows = tables[table] || []; + return rows.length ? Math.max(...rows.map(r => Number(r.id))) : null; + }, + async getContentIdWindowRows(table, fromId, toId){ + return (tables[table] || []).filter(r => Number(r.id) > fromId && Number(r.id) <= toId); + } + }; +} + +// Return the source and follower checksum maps, the follower reusing the source ceilings. +async function sourceAndFollower(sourceTables, followerTables){ + let src = await new BlockHasher(decoderDb(sourceTables), new Utility()).computeTableContentChecksums(9, { window: 5 }); + let idBounds = {}; + for(let [table, entry] of Object.entries(src.tables)) if(entry.id_max !== undefined) idBounds[table] = entry.id_max; + let local = await new BlockHasher(decoderDb(followerTables), new Utility()) + .computeTableContentChecksums(9, { window: 5, idBounds: idBounds }); + return { src, local }; +} + +describe('Advisory table-content parity', function(){ + + describe('operational-log exclusion', function(){ + + it('no content-parity plan compares a table the count check declares unconvergent', function(){ + assert.ok(replicatedTables.OPERATIONAL_LOG_TABLES.includes('events')); + for(const dbType of ['indexer', 'decoder']){ + let planned = new Set(replicatedTables.contentParityPlan(dbType).map(p => p.table)); + let excluded = replicatedTables.contentParityExclusions(dbType); + let replicated = new Set(replicatedTables.getReplicatedTables(dbType)); + for(const table of replicatedTables.OPERATIONAL_LOG_TABLES){ + assert.ok(!planned.has(table), dbType + ' plan must not content-compare ' + table); + if(replicated.has(table)) + assert.match(excluded[table] || '', /^operational log: /, dbType + ' ' + table + ' needs a declared reason'); + } + } + }); + + it('stays scoped to the operational logs: the decoder lookups are still compared', function(){ + let decoder = {}; + for(let p of replicatedTables.contentParityPlan('decoder')) decoder[p.table] = p.bound; + for(const table of ['index_addresses', 'index_transactions', 'pubkeys']) + assert.strictEqual(decoder[table], 'id', table + ' must stay in the decoder plan'); + }); + + it('the count check reads the same declaration rather than a hand list', function(){ + let src = fs.readFileSync(path.join(__dirname, '../../../src/client/sync.js'), 'utf8'); + assert.ok(/new Set\(replicatedTables\.OPERATIONAL_LOG_TABLES\)/.test(src), + 'ClientSync must build its count exclusion from replicated_tables.js'); + assert.ok(!/new Set\(\['events'\]\)/.test(src), 'a second hand-listed copy can drift from the plan'); + }); + + it('an id-offset replica with equal window counts raises no content mismatch', async function(){ + let lookups = [{ id: 1, address: 'a' }, { id: 2, address: 'b' }]; + let { src, local } = await sourceAndFollower( + { index_addresses: lookups, events: [{ id: 7, code: 'REORG', data: '[9]' }] }, + { index_addresses: lookups, events: [{ id: 7, code: 'LOCAL', data: 'own row' }] }); + assert.ok(!('events' in src.tables) && !('events' in local.tables), 'events must not be published or recomputed'); + + let res = new HashVerifier().compareTableContent(9, local, src); + assert.deepStrictEqual(res.mismatches, []); + assert.strictEqual(res.compared, 1, 'the lookup beside it is still compared'); + }); + + it('a real lookup divergence on the same replica is still reported', async function(){ + let { src, local } = await sourceAndFollower( + { index_addresses: [{ id: 1, address: 'a' }] }, + { index_addresses: [{ id: 1, address: 'forged' }] }); + let res = new HashVerifier().compareTableContent(9, local, src); + assert.deepStrictEqual(res.mismatches.map(m => m.table), ['index_addresses']); + }); + }); +}); From 85f5818274acc52643d31f94e8e6cfd08d8fc175 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 20:49:13 -0700 Subject: [PATCH 54/62] test(sync): wait on named observables in the chaos suites, correct twin and reference-impl headers --- src/consensus/equivocation_header.js | 19 +++++--- src/consensus/stake_weighted_quorum.js | 17 ++++--- src/table_lifecycle.js | 14 +++--- test/chaos/replica_db_resilience.test.js | 16 ++++++- test/chaos/source_db_resilience.test.js | 42 +++++++++++++---- test/chaos/sync_resilience.test.js | 46 +++++++++++++------ .../consensus_primitive_conformance.test.js | 10 ++-- 7 files changed, 116 insertions(+), 48 deletions(-) diff --git a/src/consensus/equivocation_header.js b/src/consensus/equivocation_header.js index 48e11991..c043bdec 100644 --- a/src/consensus/equivocation_header.js +++ b/src/consensus/equivocation_header.js @@ -18,11 +18,15 @@ * EQUIV||||| * * The canonical source of record is - * xchain-documentation/protocol/reference-impl/equivocation_header.js; it is - * vendored BYTE-IDENTICALLY into xchain-hub, xchain-indexer, xchain-explorer, - * xchain-sdk and xchain-sync. Every PBFT/consensus engine prefixes its canonical - * through here, the settlement gates (cross_settle, xexec, xcall, anchor, price, - * attest) and the recovery verifier re-derive it to re-verify quorum signatures, + * xchain-indexer/src/consensus/equivocation_header.js; it is vendored + * BYTE-IDENTICALLY into xchain-hub, xchain-explorer, xchain-sdk, xchain-sync and + * xchain-documentation/protocol/reference-impl/consensus/equivocation_header.js. + * Edit the indexer copy only and re-run reconcile-twins.sh to re-vendor every + * other copy; never edit a vendored copy. + * + * Every PBFT/consensus engine prefixes its canonical through here, the settlement + * gates (cross_settle, xexec, xcall, anchor, price, attest) and the recovery + * verifier re-derive it to re-verify quorum signatures, * and the SLASH v0 action verifies equivocation proofs against it. Adding `` * makes equivocation provable WITHOUT false-positiving honest view changes (which * re-sign different content for the same round under a different view). Equivocation @@ -38,8 +42,9 @@ * the same anchor. The cross-service conformance suite * (ConsensusPrimitiveConformance.test.js, driven by * xchain-documentation/protocol/test-vectors) runs in every repo and asserts BOTH the - * behavior (canonical vectors) AND byte-identity of the local copy to this canonical - * source, so any unmirrored edit fails CI everywhere (a divergence forks the chain). + * behavior (canonical vectors) AND byte-identity of the local copy to the + * xchain-documentation copy, so any unmirrored edit fails CI everywhere (a + * divergence forks the chain). * ********************************************************************/ diff --git a/src/consensus/stake_weighted_quorum.js b/src/consensus/stake_weighted_quorum.js index 76fdd4ed..11665534 100644 --- a/src/consensus/stake_weighted_quorum.js +++ b/src/consensus/stake_weighted_quorum.js @@ -14,17 +14,20 @@ * * THE single, CONSENSUS-CRITICAL implementation of the stake-weighted quorum * predicate. The canonical source of record is - * xchain-documentation/protocol/reference-impl/stake_weighted_quorum.js; it is - * vendored BYTE-IDENTICALLY into xchain-hub, xchain-indexer, xchain-explorer, - * xchain-sdk and xchain-sync. Every PBFT tally engine, every settlement gate - * (cross_settle, xexec, xcall, anchor, price, attest), the recovery verifier and - * the client/explorer checkpoint verifiers resolve through this predicate so they - * can never drift; a divergence forks the chain. + * xchain-indexer/src/consensus/stake_weighted_quorum.js; it is vendored + * BYTE-IDENTICALLY into xchain-hub, xchain-explorer, xchain-sdk, xchain-sync and + * xchain-documentation/protocol/reference-impl/consensus/stake_weighted_quorum.js. + * Edit the indexer copy only and re-run reconcile-twins.sh to re-vendor every + * other copy; never edit a vendored copy. Every PBFT tally engine, every + * settlement gate (cross_settle, xexec, xcall, anchor, price, attest), the + * recovery verifier and the client/explorer checkpoint verifiers resolve through + * this predicate so they can never drift; a divergence forks the chain. * * The cross-service conformance suite (ConsensusPrimitiveConformance.test.js, * driven by xchain-documentation/protocol/test-vectors) runs in every repo and * asserts BOTH the behavior (canonical vectors) AND byte-identity of the local - * copy to this canonical source, so any unmirrored edit fails CI everywhere. + * copy to the xchain-documentation copy, so any unmirrored edit fails CI + * everywhere. * * Self-contained on mathjs bignumber (exact, never a JS double): the predicate is * a pure function with no injected utility instance, so it is identical in every diff --git a/src/table_lifecycle.js b/src/table_lifecycle.js index af5c299d..f24c8d6d 100644 --- a/src/table_lifecycle.js +++ b/src/table_lifecycle.js @@ -21,7 +21,7 @@ * * 1. REPLICATION - how (and whether) xchain-sync delivers the table to * followers. Generates the per-block stream topology - * (xchain-sync/src/replicatedTables.js TOPOLOGY.indexer). + * (xchain-sync/src/schema/replicated_tables.js TOPOLOGY.indexer). * 2. ROLLBACK - how a chain reorg unwinds the table, on the source * indexer (src/rollback.js) and on every replica * (xchain-sync/src/ClientRollback.js). Generates both @@ -42,7 +42,7 @@ * understanding the table, not by silencing the tests. * * BYTE-ALIGNED TWIN: copied verbatim, with its table_lifecycle/ parts, into - * xchain-sync/src/tableLifecycle.js (sync has no dependency on this package + * xchain-sync/src/table_lifecycle.js (sync has no dependency on this package * by design; same convention as stateHash.js / merkle.js). Edit here, then * `cp` to the twin; the sync rollback-coverage suite asserts byte-identity. * @@ -152,7 +152,7 @@ const ORPHAN_SWEEPS = [ // per-table content checksum over a bounded block window: xchain-sync computes // it in BlockHasher.computeTableContentChecksums, the source publishes it on // /status (api.js) and a follower at the same height recomputes and compares in -// ClientSync._verifyAgainstSource. Because both sides run the SAME method over +// ClientSync.verifyTableContentParity. Because both sides run the SAME method over // the SAME published bound, equal count + different checksum means content // divergence, which is exactly the class the row counts cannot see. // @@ -169,7 +169,7 @@ const ORPHAN_SWEEPS = [ // purge deferred out of band. Neither has a block bound a source and a // follower can agree on, so a checksum over them would false-alarm rather // than detect. Both keep the convergence channel they already have (the -// snapshot upsert; ClientSync._reconcileDispensers' periodic replace). +// snapshot upsert; ClientSync.reconcileDispensers' periodic replace). // // 2. IN-PLACE MUTATED tables: exactly the tables declaring the 'state_hash' // class above. A row of theirs written in block N is edited again in a @@ -261,10 +261,10 @@ function replicaRollbackTables(){ }; } -// Per-block stream topology for the indexer DB (xchain-sync/src/ -// replicatedTables.js TOPOLOGY.indexer). The decoder DB topology is NOT +// Per-block stream topology for the indexer DB (xchain-sync/src/schema/ +// replicated_tables.js TOPOLOGY.indexer). The decoder DB topology is NOT // generated from this registry: that schema is owned by xchain-decoder and -// stays declared literally in replicatedTables.js. +// stays declared literally in replicated_tables.js. function streamTopology(){ let scoped = (scope) => tablesWhere(t => t.replication === 'stream:' + scope); return { diff --git a/test/chaos/replica_db_resilience.test.js b/test/chaos/replica_db_resilience.test.js index 7fd94a21..8537f474 100644 --- a/test/chaos/replica_db_resilience.test.js +++ b/test/chaos/replica_db_resilience.test.js @@ -154,6 +154,8 @@ describe('CE-DST-01: Complete Replica DB Unavailability', function () { // Seed blocks while replica is down await seedSourceBlocks(11, 20); await server.poll(); + // Keep the replica down while the broadcast lands (exposure window; each + // event against a dead replica costs a 10s acquire, and recovery gates). await sleep(3000); // Restore replica @@ -229,6 +231,8 @@ describe('CE-DST-03: Connection Pool Exhaustion', function () { // Seed blocks; server broadcasts, client receives but can't write await seedSourceBlocks(11, 15); await server.poll(); + // Hold the client stuck on held writes, then prove the server still + // answers (negative window: a stuck write never settles to poll on). await sleep(8000); // Server must stay reachable even though the client is stuck @@ -245,6 +249,8 @@ describe('CE-DST-03: Connection Pool Exhaustion', function () { // Seed new blocks into source; server will broadcast them await seedSourceBlocks(11, 15); await server.poll(); + // Keep writes held before lifting the toxic (exposure window, not a + // gate: the recovery wait below decides). await sleep(5000); await replicaFaults.reset(); @@ -273,6 +279,7 @@ describe('CE-DST-04: Intermittent Connection Drops', function () { // Force multiple poll cycles to broadcast blocks for (let i = 0; i < 15; i++) { try { await server.poll(); } catch { /* expected */ } + // Pace the forced polls so resets land across many writes. await sleep(300); } @@ -287,6 +294,8 @@ describe('CE-DST-04: Intermittent Connection Drops', function () { it('all blocks eventually reach replica after toxic removal', async function () { await replicaFaults.resetConnections(0.3); + // Expose writes to resets before lifting the toxic (exposure window; + // the recovery wait below decides). await sleep(3000); await replicaFaults.reset(); @@ -313,6 +322,8 @@ describe('CE-DST-05: Replica Down → Blocks Accumulate → Recovery', function // Take replica DB down await replicaFaults.dbDown(); + // Hold the outage before blocks appear (exposure window; the client + // exposes no replica-down state to wait on). await sleep(2000); // Seed a batch of blocks into source while replica is down @@ -321,14 +332,17 @@ describe('CE-DST-05: Replica Down → Blocks Accumulate → Recovery', function // Server polls and broadcasts (client receives via WebSocket but can't write) for (let i = 0; i < 10; i++) { try { await server.poll(); } catch { /* expected */ } + // Pace the forced polls. await sleep(300); } - // Wait for blocks to be broadcast and fail on client + // Let broadcasts fail on the client before restore (exposure window: + // settling all 20 at a 10s acquire each would outrun the test budget). await sleep(5000); // Restore replica DB await replicaFaults.dbUp(); + // Settle after restore (not a gate: the recovery wait below decides). await sleep(2000); // Client should detect gap and perform incremental catch-up diff --git a/test/chaos/source_db_resilience.test.js b/test/chaos/source_db_resilience.test.js index 1e0646d1..16450095 100644 --- a/test/chaos/source_db_resilience.test.js +++ b/test/chaos/source_db_resilience.test.js @@ -71,6 +71,19 @@ const { REPLICA_PROXY } = require('./helpers/toxiproxy-client'); +// Seed through the proxied source, retrying until it answers after a restore +// (the budget covers the 30s breaker cooldown; seedBlocks skips written blocks). +async function seedWhenSourceAnswers(start, end, timeoutMs = 60000) { + const deadline = Date.now() + timeoutMs; + let lastError; + while (Date.now() < deadline) { + try { return await seedSourceBlocks(start, end); } catch (e) { lastError = e; } + // Pace the retries. + await sleep(250); + } + throw lastError; +} + describe('Chaos: Source Database Resilience', function () { let server, client; @@ -141,11 +154,11 @@ describe('CE-SRC-01: Complete Source DB Unavailability', function () { it('server recovers and resumes sync after source DB is restored', async function () { await sourceFaults.dbDown(); - await sleep(5000); + // Restore only once the server has FELT the outage (a failed poll cycle). + await waitForServerPollFailures(server, 1, 30000); await sourceFaults.dbUp(); - await sleep(2000); // allow pool to reconnect - await seedSourceBlocks(21, 25); + await seedWhenSourceAnswers(21, 25); await server.poll(); @@ -228,7 +241,8 @@ describe('CE-SRC-03: Connection Pool Exhaustion', function () { it('server stays alive when all DB connections are held for 30s', async function () { await sourceFaults.timeout(30000); - // Wait for several poll cycles to fail (exhaust the 10-connection pool) + // Hold the pool exhausted, then prove the server still answers (negative + // window: held reads hang rather than fail, so there is nothing to poll). await sleep(8000); const alive = await isServerAlive(server.getUrl()); @@ -238,6 +252,8 @@ describe('CE-SRC-03: Connection Pool Exhaustion', function () { it('server recovers after timeout toxic is removed', async function () { await sourceFaults.timeout(30000); + // Keep the pool held long enough to stall polls (exposure window, not a + // gate: the recovery wait below decides). await sleep(5000); await sourceFaults.reset(); @@ -245,7 +261,8 @@ describe('CE-SRC-03: Connection Pool Exhaustion', function () { const sourceDbDirect = require('./helpers/chaos-setup').getSourceDbDirect(); await fixtures.seedBlocks(sourceDbDirect, 21, 23); - // Allow circuit breaker to recover (up to 30s cooldown + half-open attempt) + // Give held reads time to release before the forced poll (cooldown + // settle; the breaker exposes no state to wait on). await sleep(5000); await server.poll(); @@ -269,6 +286,7 @@ describe('CE-SRC-04: Intermittent Connection Drops', function () { for (let i = 0; i < 20; i++) { try { await server.poll(); } catch { /* expected failures */ } + // Pace the forced polls so resets land across many cycles. await sleep(200); } @@ -286,6 +304,7 @@ describe('CE-SRC-04: Intermittent Connection Drops', function () { for (let i = 0; i < 10; i++) { try { await server.poll(); } catch { /* expected */ } + // Pace the forced polls so resets land across many cycles. await sleep(300); } @@ -296,6 +315,8 @@ describe('CE-SRC-04: Intermittent Connection Drops', function () { it('success rate returns to 100% after toxic is removed', async function () { await sourceFaults.resetConnections(0.3); + // Expose the pool to resets before lifting the toxic (exposure window; + // the five polls below are the assertion). await sleep(2000); await sourceFaults.reset(); @@ -326,19 +347,24 @@ describe('CE-SRC-05: Source Down → Blocks Accumulate → Recovery', function ( expect(preOutageBlock).to.be.at.least(20); await sourceFaults.dbDown(); - await sleep(3000); + // Accumulate blocks only once the server has FELT the outage. + await waitForServerPollFailures(server, 1, 30000); // Seed blocks via DIRECT connection (bypasses disabled proxy) await seedSourceDirect(21, 35); - // Let poll failures accumulate against the circuit breaker. - await sleep(5000); + // Let poll failures accumulate against the circuit breaker: one more + // failed cycle, which waits out a 10s pool acquire timeout. + await waitForServerPollFailures(server, 1, 60000); await sourceFaults.dbUp(); + // Settle after restore (not a gate: the polls below tolerate failure + // and the recovery wait decides). await sleep(2000); for (let i = 0; i < 10; i++) { try { await server.poll(); } catch { /* circuit may still be half-open */ } + // Pace the forced polls. await sleep(500); } diff --git a/test/chaos/sync_resilience.test.js b/test/chaos/sync_resilience.test.js index 1e26aae3..dd2892e8 100644 --- a/test/chaos/sync_resilience.test.js +++ b/test/chaos/sync_resilience.test.js @@ -46,7 +46,7 @@ const { assertHashesMatch } = require('../e2e/helpers/assertions'); -const { waitForClientDisconnect } = require('../e2e/helpers/waitFor'); +const { waitForClientDisconnect, waitForServerPollFailures } = require('../e2e/helpers/waitFor'); const { bootstrapDatabases, @@ -113,18 +113,22 @@ async function recoverFromCompoundFailure() { // Seed blocks via DIRECT connection while proxy is disabled await seedSourceDirect(16, 25); - // Let the server's circuit breaker start failing before crashing it. - await sleep(5000); + // Crash the server only once it has FELT the outage: a failed poll cycle, + // not a duration (the first failure lands on the severed pooled socket). + await waitForServerPollFailures(server, 1, 30000); // Phase 2: Server crashes (while source is still down) await server.stop(); server = null; - // Client loses WebSocket connection - await sleep(3000); + // Restart only after the client has SEEN the socket close, so the reconnect + // path is what heals the gap. + await waitForClientDisconnect(client); // Phase 3: Recovery (source comes back, server restarts) await sourceFaults.dbUp(); + // Hold the restored source briefly before restart (settle, not a gate: + // pool acquires back off and retry, and the recovery wait below gates). await sleep(2000); server = createServer(SERVER_PORT); @@ -133,6 +137,21 @@ async function recoverFromCompoundFailure() { // Force server to poll and catch up on blocks 16-25 for (let i = 0; i < 10; i++) { try { await server.poll(); } catch { /* circuit may be recovering */ } + // Pace the forced polls (the background loop also polls every 200ms). + await sleep(500); + } +} + +// Lift the source latency, settle, then force paced polls over re-seeded blocks. +async function liftLatencyAndForcePolls() { + await sourceFaults.reset(); + // Settle after the toxic is lifted (not a gate: the polls below tolerate + // failure and the recovery wait decides). + await sleep(1000); + + for (let i = 0; i < 10; i++) { + try { await server.poll(); } catch { /* recovery in progress */ } + // Pace the forced polls. await sleep(500); } } @@ -223,6 +242,8 @@ describe('CE-SYNC-02: Block Gap Detection → Incremental Catch-Up', function () // Seed more blocks while client is disconnected await seedSourceBlocks(31, 50); await server.poll(); + // Let the 31-50 broadcast pass while no client is attached, so the gap + // is real (no observable: a broadcast to zero subscribers leaves no trace). await sleep(1000); // Don't do full bootstrap; just connect live sync @@ -274,10 +295,14 @@ describe('CE-SYNC-03: Reorg During Active Sync', function () { // Force server poll; will detect reorg (currentBlock=17 < lastPolled=20) try { await server.poll(); } catch { /* may fail under latency */ } + // Keep the reorg in flight under latency before re-seeding (exposure + // window, not a gate: the recovery wait and hash checks below decide). await sleep(2000); // Server broadcasts reorg event; client should rollback to block 17 const replicaDb = require('./helpers/chaos-setup').getReplicaDb(); + // Give the reorg broadcast time to land before the replacement blocks + // appear (exposure window; the outcome is asserted after recovery). await sleep(3000); // Different creditAmount so the assertions below can confirm this is @@ -285,15 +310,8 @@ describe('CE-SYNC-03: Reorg During Active Sync', function () { // Re-seed blocks 18-22 with different data await fixtures.seedBlocks(sourceDbDirect, 18, 22, { creditAmount: '7777' }); - // Remove latency for recovery - await sourceFaults.reset(); - await sleep(1000); - - // Force polls to process re-seeded blocks - for (let i = 0; i < 10; i++) { - try { await server.poll(); } catch { /* recovery in progress */ } - await sleep(500); - } + // Remove latency for recovery, then force polls over the re-seeded blocks + await liftLatencyAndForcePolls(); // Wait for replica to reach block 22 const recoveryMs = await waitForSyncRecovery(22, 60000); diff --git a/test/unit/consensus_primitive_conformance.test.js b/test/unit/consensus_primitive_conformance.test.js index 8f523994..3c8c25bf 100644 --- a/test/unit/consensus_primitive_conformance.test.js +++ b/test/unit/consensus_primitive_conformance.test.js @@ -18,11 +18,13 @@ // predicate and the equivocation-header builder are CONSENSUS-CRITICAL and // vendored byte-identically into five services (xchain-hub, xchain-indexer, // xchain-explorer, xchain-sdk, xchain-sync); a divergence in their logic forks -// the chain. The canonical source of record lives in xchain-documentation -// (protocol/reference-impl + protocol/test-vectors). This guard runs in every +// the chain. Their canonical source of record is xchain-indexer/src/consensus, +// which reconcile-twins.sh vendors into every other copy, including +// xchain-documentation/protocol/reference-impl/consensus; the canonical vectors +// live in xchain-documentation/protocol/test-vectors. This guard runs in every // repo and asserts BOTH: // 1. BEHAVIOR - the local copy matches the canonical vectors. -// 2. IDENTITY - the local copy is byte-identical to the canonical source. +// 2. IDENTITY - the local copy is byte-identical to the xchain-documentation copy. // (1) catches a logic change that happens to pass the local unit suite; (2) // catches ANY edit to one copy that was not propagated to the others. When the // sibling xchain-documentation repo is not checked out (standalone deploy), skip @@ -117,7 +119,7 @@ describe('consensus-primitive conformance: byte-identity to canonical source @re const canon = fs.readFileSync(path.join(CANON_DIR, 'consensus', f), 'utf8'); assert.strictEqual(local, canon, 'this repo\'s consensus/' + f + ' has drifted from the canonical source; ' + - 'edit xchain-documentation/protocol/reference-impl/consensus/' + f + ' and re-vendor all five copies.'); + 'edit xchain-indexer/src/consensus/' + f + ' and re-run reconcile-twins.sh to re-vendor every copy.'); }); }); }); From af6d3e8898a59bb603b77d6e7e5cb30c2e5f649b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 21:01:34 -0700 Subject: [PATCH 55/62] fix(sync): halt durably on a repeating markets duplicate-key loop, add a cross-source markets id parity tool A same-target duplicate-key failure that repeats now sets a visible halt with its reason instead of retrying forever, and bin/markets-id-parity.js compares markets ids by ticker across two sync sources. Stale lifecycle comments are corrected. --- bin/markets-id-parity.js | 187 ++++++++++++++++++ src/client/applier.js | 13 +- src/client/rollback.js | 23 ++- src/client/sync.js | 58 ++++++ src/db/stakes.js | 5 +- .../06_insert_rows_upsert_error_tag.test.js | 49 +++++ ...catch_up_upsert_duplicate_key_halt.test.js | 133 +++++++++++++ .../markets_id_parity_tool.test.js | 92 +++++++++ test/unit/rollback_coverage.test.js | 12 +- 9 files changed, 553 insertions(+), 19 deletions(-) create mode 100644 bin/markets-id-parity.js create mode 100644 test/unit/client_applier.test/06_insert_rows_upsert_error_tag.test.js create mode 100644 test/unit/client_sync.test/19_run_incremental_catch_up_upsert_duplicate_key_halt.test.js create mode 100644 test/unit/repo_guards/markets_id_parity_tool.test.js diff --git a/bin/markets-id-parity.js b/bin/markets-id-parity.js new file mode 100644 index 00000000..9aedc0f2 --- /dev/null +++ b/bin/markets-id-parity.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/********************************************************************* + * + * Copyright © 2025-2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC - https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. + * + ********************************************************************** + * + * Do the configured sync sources agree on markets.id for each traded pair? + * + * A replica applies the markets full-dump as an UPSERT that carries the + * source's AUTO_INCREMENT id, so two sources that number the same pair + * differently hand a replica two id spaces, and a replica that holds a row at + * an id the other source gives a different pair fails that upsert with + * ER_DUP_ENTRY (ClientSync halts on it as apply-duplicate-key). This measures + * whether that skew exists. + * + * READ-ONLY. Three GET routes per source, the same ones a replica calls: + * /status/indexer// the source tip + * /snapshot/indexer///since/?skip_lookups=1 + * the markets full-dump + * /snapshot-rows/indexer///index_tickers ticker names, paged + * Pairs are compared by ticker NAME, because index_tickers ids are node-local + * too; a native-coin side (tick id 0) is keyed by its coin id. + * + * USAGE + * SYNC_SOURCES=http://a:3006,http://b:3006 node bin/markets-id-parity.js --chain BTC --network mainnet + * node bin/markets-id-parity.js --chain BTC --network mainnet --sources http://a:3006,http://b:3006 + * SYNC_UPSTREAM_KEY is sent as the bearer token when set. + * + * EXIT 0 every source agrees, 1 ids differ or pairs are missing, 2 usage or fetch error. + */ + +'use strict'; + +const zlib = require('zlib'); +const axios = require('axios'); + +const TICKER_PAGE = 50000; + +// Parse argv and env into the run's options, or throw a usage error. +function parseArgs(argv, env){ + let opts = { chain: null, network: null, sources: null }; + for(let i = 0; i < argv.length; i++){ + let a = argv[i]; + if(a === '--chain') opts.chain = argv[++i]; + else if(a === '--network') opts.network = argv[++i]; + else if(a === '--sources') opts.sources = argv[++i]; + else throw new Error('unknown argument: ' + a); + } + let raw = opts.sources || env.SYNC_SOURCES || ''; + opts.sources = raw.split(',').map(s => s.trim().replace(/\/+$/, '')).filter(s => s); + if(!opts.chain || !opts.network) throw new Error('--chain and --network are required'); + if(opts.sources.length < 2) throw new Error('need at least two sources (--sources or SYNC_SOURCES)'); + opts.headers = env.SYNC_UPSTREAM_KEY ? { Authorization: 'Bearer ' + env.SYNC_UPSTREAM_KEY } : {}; + return opts; +} + +// GET a JSON body that may arrive gzipped. +async function getJson(url, headers){ + let res = await axios.get(url, { headers, responseType: 'arraybuffer', timeout: 600000, decompress: true }); + let body = res.data; + if(body && typeof body === 'object' && !Buffer.isBuffer(body)) return body; + if(Buffer.isBuffer(body)){ + try { body = zlib.gunzipSync(body); } catch(e){ /* already inflated */ } + } + return JSON.parse(body.toString()); +} + +// Ticker id to name for one source, paged by id. +async function fetchTickers(base, headers){ + let names = new Map(); + let after = 0; + for(;;){ + let page = await getJson(base.rows + '/index_tickers?after_id=' + after + '&limit=' + TICKER_PAGE, headers); + for(let r of page.rows || []) names.set(String(r.id), String(r.tick)); + if(!page.has_more) return names; + after = page.max_id; + } +} + +// The markets rows and ticker names one source serves at its current tip. +async function fetchSource(source, opts){ + let path = '/indexer/' + opts.chain + '/' + opts.network; + let status = await getJson(source + '/status' + path, opts.headers); + // Refuse a null height: since/0 would pull the whole history, not one block. + let tip = (status.block_height == null) ? NaN : Number(status.block_height); + if(!Number.isInteger(tip) || tip < 1) throw new Error(source + ' reported no block_height'); + let snap = await getJson(source + '/snapshot' + path + '/since/' + tip + '?skip_lookups=1', opts.headers); + let tickers = await fetchTickers({ rows: source + '/snapshot-rows' + path }, opts.headers); + return { tip, markets: (snap.tables && snap.tables.markets) || [], tickers }; +} + +// Name one side of a pair by ticker, or by coin id when it is the native coin. +function sideName(tickId, coinId, tickers){ + let t = String(tickId == null ? '' : tickId); + if(t === '0') return 'coin#' + String(coinId == null ? 0 : coinId); + return tickers.has(t) ? tickers.get(t) : '?tick#' + t; +} + +// Map each pair name to its markets.id for one source. +function idsByPair(markets, tickers){ + let out = new Map(); + for(let m of markets){ + let pair = sideName(m.tick1_id, m.coin1_id, tickers) + '/' + sideName(m.tick2_id, m.coin2_id, tickers); + out.set(pair, String(m.id)); + } + return out; +} + +// Compare per-source pair maps: pairs missing somewhere, pairs whose id differs, +// and ids that name different pairs on different sources (the upsert collision shape). +function compareSources(maps){ + let names = Object.keys(maps); + let allPairs = new Set(); + for(let n of names) for(let p of maps[n].keys()) allPairs.add(p); + let missing = [], differing = []; + for(let pair of [...allPairs].sort()){ + let ids = {}; + for(let n of names) ids[n] = maps[n].has(pair) ? maps[n].get(pair) : null; + let present = Object.values(ids).filter(v => v !== null); + if(present.length < names.length) missing.push({ pair, ids }); + if(new Set(present).size > 1) differing.push({ pair, ids }); + } + return { pairs: allPairs.size, missing, differing, collisions: idCollisions(maps) }; +} + +// Ids that name one pair on one source and a different pair on another. +function idCollisions(maps){ + let byId = new Map(); + for(let [src, m] of Object.entries(maps)){ + for(let [pair, id] of m){ + if(!byId.has(id)) byId.set(id, {}); + byId.get(id)[src] = pair; + } + } + let out = []; + for(let [id, pairs] of byId) + if(new Set(Object.values(pairs)).size > 1) out.push({ id, pairs }); + return out.sort((a, b) => Number(a.id) - Number(b.id)); +} + +// Human-readable report, ending with the verdict line. +function renderReport(opts, fetched, result){ + let lines = ['markets id parity: ' + opts.chain + '/' + opts.network]; + for(let s of opts.sources) + lines.push(' ' + s + ' tip ' + fetched[s].tip + ' markets ' + fetched[s].markets.length); + lines.push('pairs seen: ' + result.pairs); + lines.push('pairs missing on some source: ' + result.missing.length); + for(let d of result.missing.slice(0, 20)) lines.push(' ' + d.pair + ' ' + JSON.stringify(d.ids)); + lines.push('pairs whose id differs: ' + result.differing.length); + for(let d of result.differing.slice(0, 20)) lines.push(' ' + d.pair + ' ' + JSON.stringify(d.ids)); + lines.push('ids naming different pairs across sources: ' + result.collisions.length); + for(let c of result.collisions.slice(0, 20)) lines.push(' id ' + c.id + ' ' + JSON.stringify(c.pairs)); + let agree = !result.differing.length && !result.missing.length; + lines.push(agree ? 'VERDICT: sources agree on markets.id for every pair' + : 'VERDICT: markets ids are NOT reproducible across these sources'); + return { text: lines.join('\n'), agree }; +} + +async function main(){ + let opts; + try { opts = parseArgs(process.argv.slice(2), process.env); } + catch(e){ console.error('usage error: ' + e.message); process.exitCode = 2; return; } + let fetched = {}; + try { + for(let s of opts.sources) fetched[s] = await fetchSource(s, opts); + } catch(e){ + console.error('fetch failed: ' + (e.message || e)); + process.exitCode = 2; + return; + } + let maps = {}; + for(let s of opts.sources) maps[s] = idsByPair(fetched[s].markets, fetched[s].tickers); + let report = renderReport(opts, fetched, compareSources(maps)); + console.log(report.text); + process.exitCode = report.agree ? 0 : 1; +} + +if(require.main === module) main(); + +module.exports = { parseArgs, fetchSource, sideName, idsByPair, compareSources, idCollisions, renderReport }; diff --git a/src/client/applier.js b/src/client/applier.js index bac0ee02..4abcf77f 100644 --- a/src/client/applier.js +++ b/src/client/applier.js @@ -238,7 +238,9 @@ class ClientApplier { // would leave the re-dump with nothing to collide on and append duplicate rows // silently. attest_validator_stats has no such gap: validator_pubkey_provider has // been in its CREATE TABLE since the table was introduced and no migration adds - // it, so every replica that has the table has the key. + // it, so every replica that has the table has the key. Keeping the source id means + // a replica whose markets ids have skewed from the source's can hit that same 1062; + // ClientSync.noteUpsertDuplicateKey turns a repeat of it into a durable halt. this.localSurrogateIdOnlyTables = new Set([ 'attest_validator_stats' ]); @@ -767,7 +769,14 @@ class ClientApplier { } } - await this.db.insertRowValues(table, columns, batch.length, args, useIgnore, useUpsert); + try { + await this.db.insertRowValues(table, columns, batch.length, args, useIgnore, useUpsert); + } catch(e){ + // Name the upsert table on the error so ClientSync can tell a repeating + // full-dump duplicate key apart from any other apply failure. + if(useUpsert && e && typeof e === 'object' && e.upsertTable === undefined) e.upsertTable = table; + throw e; + } // events rows >64KB silently truncate on a still-TEXT (pre-migration) // replica when INSERT IGNORE is used: the id collision guard skips the row diff --git a/src/client/rollback.js b/src/client/rollback.js index f184a373..02db2cd2 100644 --- a/src/client/rollback.js +++ b/src/client/rollback.js @@ -15,9 +15,12 @@ * XChain Indexer Sync - Client Rollback * * Handles rolling back the local replica database to a given block. - * Table lists are copied from xchain-indexer/src/rollback.js and - * MUST be kept in sync when new tables are added to the indexer. - * test/unit/rollback/rollback_coverage.test.js enforces that the sync set + * The generic indexer table lists come from lifecycle.replicaRollbackTables() + * (src/table_lifecycle.js) and the decoder lists from TOPOLOGY.decoder + * (src/schema/replicated_tables.js): a new table is declared there, never + * added to a list here. Only the bespoke in-place resets and restores are + * hand-written, mirroring xchain-indexer/src/rollback/. + * test/unit/rollback_coverage.test.js enforces that the sync set * covers every table ServerPoller replicates. * ********************************************************************/ @@ -80,7 +83,7 @@ class ClientRollback { this.activationDelay = delay; // Generic rollback table lists, generated from the table-lifecycle - // registry (src/tableLifecycle.js, the byte-identical twin of the + // registry (src/table_lifecycle.js, the byte-identical twin of the // xchain-indexer copy). replicaRollbackTables() yields exactly the // source indexer's generic lists minus indexer-local tables that never // exist on a replica (e.g. pending_hub_pushes), so the two rollbacks @@ -88,7 +91,7 @@ class ClientRollback { // registry joins both sides at once. Per-table rationale lives with // the registry entries; the bespoke in-place resets/restores below // stay hand-written (and remain drift-guarded by the parity tests in - // test/unit/rollback/rollback_coverage.test.js). + // test/unit/rollback_coverage.test.js). let rollbackLists = lifecycle.replicaRollbackTables(); this.blockTables = rollbackLists.blockTables; this.indexTables = rollbackLists.indexTables; @@ -104,7 +107,7 @@ class ClientRollback { // purely local artifacts that no longer feed any consensus value: under the // current BLOCK_HASH_VERSION the block hashes are computed from the RESOLVED strings // (address/tick/action/status), not from address_id/tick_id/etc. (see - // xchain-indexer/src/db/actions.js getBlockHashes + xchain-sync/src/BlockHasher.js). If a + // xchain-indexer/src/db/actions.js getBlockHashes + xchain-sync/src/client/block_hasher.js). If a // lookup id is ever reintroduced into a consensus-visible projection, these orphan // rows would silently fork hashes after a reorg and this skip would become a bug. @@ -1251,8 +1254,8 @@ class ClientRollback { // after orphaned offers/statuses are deleted) and ClientApplier (forward-apply // path, after the block's offers/statuses are inserted) so both derive // byte-identical gate values. The SQL between the // markers -// is kept logically identical with xchain-indexer/src/rollback.js (cross-repo drift -// guard in test/unit/rollback/rollback_coverage.test.js). Uses db.doQuery so it joins +// is kept logically identical with xchain-indexer/src/rollback/rederive.js (cross-repo +// drift guard in test/unit/rollback_coverage.test.js). Uses db.doQuery so it joins // whatever transaction the caller already opened. // Affected set = currently-escrowed tokens (Class A) UNION tokens with a // surviving still-escrowed GIVE_OWNERSHIP offer (Class B). @@ -1297,8 +1300,8 @@ async function rederiveEscrowGate(db){ // coinpay_action_index IS the match's action_index. Both statements touch only rows whose // status disagrees and no-op when the target status has never been minted locally, so // neither can blank a status_id. The SQL between the // -// markers is kept logically identical with xchain-indexer/src/rollback.js (cross-repo -// drift guard in test/unit/rollback/rollback_coverage.test.js). Uses db.doQuery so it joins +// markers is kept logically identical with xchain-indexer/src/rollback/rederive.js +// (cross-repo drift guard in test/unit/rollback_coverage.test.js). Uses db.doQuery so it joins // whatever transaction the caller already opened. // // Skipped on a truncated replica: it holds only [base..tip] of coinpay_statuses, so diff --git a/src/client/sync.js b/src/client/sync.js index 599ffa3c..0466ffec 100644 --- a/src/client/sync.js +++ b/src/client/sync.js @@ -936,6 +936,62 @@ class ClientSync { this._applyTimers.clear(); } + // Table and key of an upsert full-dump duplicate-key failure (errno 1062), or null + // for any other error. An upsert absorbs its own row's key collision, so a 1062 means + // the UPDATE leg moved a key (markets.id) onto a value another replica row holds. + upsertDuplicateKeyTarget(e){ + if(!e || e.errno !== 1062 || typeof e.upsertTable !== 'string') return null; + let m = /Duplicate entry '(.*?)' for key '([^']*)'/.exec(String(e.sqlMessage || e.message || '')); + return { table: e.upsertTable, entry: m ? m[1] : null, key: m ? m[2] : null }; + } + + // Halt when an upsert full-dump fails on the same table and key twice with no + // successful apply between (id-space skew no retry can clear); a first hit retries. + async noteUpsertDuplicateKey(source, e){ + let target = this.upsertDuplicateKeyTarget(e); + if(!target) return false; + let id = target.table + '|' + target.key + '|' + target.entry; + let repeat = (this._upsertDupKeyTarget === id); + this._upsertDupKeyTarget = id; + if(!repeat){ + getLogger().warn('Upsert full-dump of ' + target.table + ' hit a duplicate key (' + + target.entry + ' on ' + target.key + '); retrying once before halting'); + return false; + } + // Reset so a replica resumed after an operator clear gets its one retry again. + this._upsertDupKeyTarget = null; + await this.haltOnUpsertDuplicateKey(source, target); + return true; + } + + // Durable halt for a repeating upsert duplicate key: same recordHalt/isHalted + // persistence and /status surface as the schema-apply halt, its own reason. + async haltOnUpsertDuplicateKey(source, target){ + if(this._halted) return; + let blockIndex = (this.lastAppliedBlock != null) ? this.lastAppliedBlock : 0; + let detail = [target]; + this._halted = { + blockIndex, reason: 'apply-duplicate-key', + mismatches: detail, sources: [source], + at: new Date().toISOString() + }; + try { await this.db.recordHalt(this.dbType, blockIndex, this._halted.reason, detail, [source]); } + catch(e){ getLogger().error(util.format('CRITICAL: failed to persist duplicate-key halt (still halting in-memory):', e)); } + getLogger().error('================================================================'); + getLogger().error('UPSERT DUPLICATE KEY HALT: ' + this.chain + '/' + this.network + '/' + this.dbType); + getLogger().error('the ' + target.table + ' full-dump from ' + source + ' failed twice on duplicate entry ' + + target.entry + ' for key ' + target.key + ': a replica row already holds a key the'); + getLogger().error('source assigns to a different row (id-space skew from a source re-index, a'); + getLogger().error('cross-source bootstrap, or a row the source deleted and this replica kept).'); + getLogger().error('Retrying the same payload cannot clear it. HALTING (applying no further blocks).'); + getLogger().error('Operator must re-bootstrap this replica or reconcile the colliding row, then clear the halt.'); + getLogger().error('================================================================'); + this.pendingHashes.clear(); + this._strictConfirmPending.clear(); + for(let [, timer] of this._applyTimers) clearTimeout(timer); + this._applyTimers.clear(); + } + // True when a snapshot download aborted because the body outgrew the axios // ceiling (SNAPSHOT_MAX_CONTENT). One definition for both the incremental // fallback and the bootstrap halt, so the two can never disagree on what the @@ -1708,6 +1764,7 @@ class ClientSync { (typeof snapshotData.block_height === 'number') ? snapshotData.block_height : sinceBlock)) return; await this.withApplyLock(() => this.applier.applyIncrementalSnapshot(snapshotData)); + this._upsertDupKeyTarget = null; if(typeof snapshotData.block_height === 'number') this.lastAppliedBlock = snapshotData.block_height; @@ -1825,6 +1882,7 @@ class ClientSync { return; } getLogger().error(util.format('Incremental catch-up failed:', e)); + if(await this.noteUpsertDuplicateKey(source, e)) return; // Schema-gap failures are fixable right now: heal and retry once. // The heal's debounce bounds the recursion: a second schema-gap // failure inside the window returns false and falls through. diff --git a/src/db/stakes.js b/src/db/stakes.js index e108566b..66b480ac 100644 --- a/src/db/stakes.js +++ b/src/db/stakes.js @@ -31,11 +31,12 @@ const logger = getLogger(); module.exports = { // Light-client stakes_root support (SPV spec sec.4.1, BTC-only). Source-deduped - // capability stake-weight query, ported VERBATIM from xchain-indexer/src/db.js + // capability stake-weight query, ported VERBATIM from + // xchain-indexer/src/db/stakes/effective_set_sql.js // stakeWeightsSql: it MUST produce a byte-identical SQL string + arg order or // the follower's stakes_root diverges from the indexer's committed root and the // state-commitment check false-halts. The cross-repo drift guard in - // test/unit/rollback/rollback_coverage.test.js locks the two together. Reads only tables + // test/unit/rollback_coverage.test.js locks the two together. Reads only tables // xchain-sync replicates (stakes, delegations, stake_key_revocations, // capability_slash_events, index_addresses, index_pubkeys). stakeWeightsSql(valid_id, blockIndex, minStake){ diff --git a/test/unit/client_applier.test/06_insert_rows_upsert_error_tag.test.js b/test/unit/client_applier.test/06_insert_rows_upsert_error_tag.test.js new file mode 100644 index 00000000..e7d1e5b3 --- /dev/null +++ b/test/unit/client_applier.test/06_insert_rows_upsert_error_tag.test.js @@ -0,0 +1,49 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// insertRows names the upsert full-dump table on a failing statement's error, which +// is what lets ClientSync halt on a repeating markets duplicate key and on nothing else. + +const assert = require('assert'); +const sinon = require('sinon'); +const ClientApplier = require('../../../src/client/applier'); +const Utility = require('../../../src/util'); +const { withDbMixins } = require('../../helpers/db_mixins.js'); + +function dupKeyError(){ + return Object.assign(new Error("Duplicate entry '42' for key 'PRIMARY'"), { errno: 1062 }); +} + +describe('ClientApplier.insertRows upsert error tag', function(){ + let applier, db; + beforeEach(function(){ + db = withDbMixins({ doQuery: sinon.stub().rejects(dupKeyError()) }); + applier = new ClientApplier(db, new Utility()); + sinon.stub(console, 'log'); + sinon.stub(console, 'error'); + }); + afterEach(function(){ sinon.restore(); }); + + it('tags a failing markets upsert with its table and keeps the source id', async function(){ + let err = await applier.insertRows('markets', [{ id: 42, tick1_id: 1, tick2_id: 2 }]).then(() => null, e => e); + assert.ok(err, 'the duplicate key propagates'); + assert.strictEqual(err.errno, 1062); + assert.strictEqual(err.upsertTable, 'markets'); + let sql = db.doQuery.firstCall.args[0]; + assert.ok(sql.includes('ON DUPLICATE KEY UPDATE'), 'markets upserts'); + assert.ok(sql.includes('`id` = VALUES(`id`)'), 'markets carries the source id'); + }); + + it('leaves a plain INSERT table untagged', async function(){ + let err = await applier.insertRows('credits', [{ id: 1, block_index: 5 }]).then(() => null, e => e); + assert.ok(err); + assert.strictEqual(err.upsertTable, undefined); + }); +}); diff --git a/test/unit/client_sync.test/19_run_incremental_catch_up_upsert_duplicate_key_halt.test.js b/test/unit/client_sync.test/19_run_incremental_catch_up_upsert_duplicate_key_halt.test.js new file mode 100644 index 00000000..cc6fa2e0 --- /dev/null +++ b/test/unit/client_sync.test/19_run_incremental_catch_up_upsert_duplicate_key_halt.test.js @@ -0,0 +1,133 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// An upsert full-dump (markets) that keeps failing on the same duplicate key must +// end in a durable, visible halt rather than a catch-up that re-fails forever while +// the replica reports halted:false. + +const { + assert, sinon, axios, registerClientSyncHooks +} = require('./support'); + +let sync, db, applier; + +function assignState(state){ + ({ sync, db, applier } = state); +} + +function dupKey(entry, table){ + let msg = "Duplicate entry '" + entry + "' for key 'PRIMARY'"; + return Object.assign(new Error(msg), { errno: 1062, sqlMessage: msg, upsertTable: table || 'markets' }); +} + +function serveSnapshot(){ + db.getLastBlock.resolves(5); + db.recordHalt = sinon.stub().resolves({ block_index: 5 }); + sinon.stub(console, 'warn'); + let gz = require('zlib').gzipSync(JSON.stringify({ schema_version: 'x', block_height: 9, tables: {} })); + sinon.stub(axios, 'get').resolves({ data: gz }); +} + +function registerHaltCases(){ + it('halts durably when the same upsert duplicate key fails twice in a row', async function(){ + serveSnapshot(); + applier.applyIncrementalSnapshot.rejects(dupKey('42')); + + await sync.runIncrementalCatchUp(); + assert.strictEqual(sync.isHalted(), false, 'the first hit retries'); + await sync.runIncrementalCatchUp(); + + assert.strictEqual(sync.isHalted(), true); + let info = sync.getHaltInfo(); + assert.strictEqual(info.reason, 'apply-duplicate-key'); + assert.deepStrictEqual(info.mismatches, [{ table: 'markets', entry: '42', key: 'PRIMARY' }]); + assert.strictEqual(db.recordHalt.callCount, 1); + assert.strictEqual(db.recordHalt.firstCall.args[2], 'apply-duplicate-key'); + }); + + it('refuses further catch-ups once halted', async function(){ + serveSnapshot(); + applier.applyIncrementalSnapshot.rejects(dupKey('42')); + await sync.runIncrementalCatchUp(); + await sync.runIncrementalCatchUp(); + + await sync.incrementalCatchUp(6); + assert.strictEqual(applier.applyIncrementalSnapshot.callCount, 2); + }); + + it('stays halted in memory when persisting the halt fails', async function(){ + serveSnapshot(); + db.recordHalt.rejects(new Error('db down')); + applier.applyIncrementalSnapshot.rejects(dupKey('42')); + await sync.runIncrementalCatchUp(); + await sync.runIncrementalCatchUp(); + assert.strictEqual(sync.getHaltInfo().reason, 'apply-duplicate-key'); + }); +} + +function registerNoHaltCases(){ + it('does not halt on a single duplicate key', async function(){ + serveSnapshot(); + applier.applyIncrementalSnapshot.rejects(dupKey('42')); + await sync.runIncrementalCatchUp(); + assert.strictEqual(sync.isHalted(), false); + assert.strictEqual(db.recordHalt.called, false); + }); + + it('does not halt when two failures name different keys', async function(){ + serveSnapshot(); + applier.applyIncrementalSnapshot + .onFirstCall().rejects(dupKey('42')) + .onSecondCall().rejects(dupKey('43')); + await sync.runIncrementalCatchUp(); + await sync.runIncrementalCatchUp(); + assert.strictEqual(sync.isHalted(), false); + }); + + it('resets on a successful apply between two identical failures', async function(){ + serveSnapshot(); + applier.applyIncrementalSnapshot + .onFirstCall().rejects(dupKey('42')) + .onSecondCall().resolves() + .onThirdCall().rejects(dupKey('42')); + await sync.runIncrementalCatchUp(); + await sync.runIncrementalCatchUp(); + await sync.runIncrementalCatchUp(); + assert.strictEqual(sync.isHalted(), false); + }); + + it('does not halt on a repeated duplicate key from a non-upsert insert', async function(){ + serveSnapshot(); + let plain = Object.assign(new Error("Duplicate entry '7' for key 'PRIMARY'"), { errno: 1062 }); + applier.applyIncrementalSnapshot.rejects(plain); + await sync.runIncrementalCatchUp(); + await sync.runIncrementalCatchUp(); + assert.strictEqual(sync.isHalted(), false); + }); + + it('still routes a schema gap to the heal path', async function(){ + serveSnapshot(); + applier.applyIncrementalSnapshot + .onFirstCall().rejects(Object.assign(new Error('no table'), { errno: 1146 })) + .onSecondCall().resolves(); + let heal = sinon.stub(sync, 'fetchAndApplySchema').resolves(); + await sync.runIncrementalCatchUp(); + assert.strictEqual(heal.calledOnce, true); + assert.strictEqual(sync.isHalted(), false); + }); +} + +describe('ClientSync', function(){ + registerClientSyncHooks(assignState); + describe('runIncrementalCatchUp upsert duplicate-key halt', function(){ + registerHaltCases(); + registerNoHaltCases(); + }); +}); diff --git a/test/unit/repo_guards/markets_id_parity_tool.test.js b/test/unit/repo_guards/markets_id_parity_tool.test.js new file mode 100644 index 00000000..c2ced8c7 --- /dev/null +++ b/test/unit/repo_guards/markets_id_parity_tool.test.js @@ -0,0 +1,92 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// bin/markets-id-parity.js compares markets.id by pair NAME across sync sources. + +const assert = require('assert'); +const sinon = require('sinon'); +const zlib = require('zlib'); +const axios = require('axios'); +const tool = require('../../../bin/markets-id-parity.js'); + +function source(tickers, markets){ + return tool.idsByPair(markets, new Map(Object.entries(tickers))); +} + +function gz(obj){ return { data: zlib.gzipSync(JSON.stringify(obj)) }; } + +describe('bin/markets-id-parity fetch', function(){ + afterEach(function(){ sinon.restore(); }); + const opts = { chain: 'BTC', network: 'mainnet', headers: {} }; + + it('reads markets at the tip and pages every ticker', async function(){ + let get = sinon.stub(axios, 'get'); + get.withArgs('http://a/status/indexer/BTC/mainnet').resolves({ data: Buffer.from('{"block_height":90}') }); + get.withArgs('http://a/snapshot/indexer/BTC/mainnet/since/90?skip_lookups=1') + .resolves(gz({ tables: { markets: [{ id: 1, tick1_id: 1, tick2_id: 2 }] } })); + get.withArgs('http://a/snapshot-rows/indexer/BTC/mainnet/index_tickers?after_id=0&limit=50000') + .resolves(gz({ has_more: true, max_id: 1, rows: [{ id: 1, tick: 'XCP' }] })); + get.withArgs('http://a/snapshot-rows/indexer/BTC/mainnet/index_tickers?after_id=1&limit=50000') + .resolves(gz({ has_more: false, max_id: 2, rows: [{ id: 2, tick: 'PEPE' }] })); + let got = await tool.fetchSource('http://a', opts); + assert.strictEqual(got.tip, 90); + assert.deepStrictEqual([...tool.idsByPair(got.markets, got.tickers)], [['XCP/PEPE', '1']]); + }); + + it('refuses a source with no polled height instead of pulling since/0', async function(){ + let get = sinon.stub(axios, 'get').resolves({ data: { block_height: null } }); + await assert.rejects(() => tool.fetchSource('http://a', opts), /no block_height/); + assert.strictEqual(get.callCount, 1, 'no snapshot is requested'); + }); +}); + +describe('bin/markets-id-parity', function(){ + it('keys pairs by ticker name so differing ticker ids still match', function(){ + let a = source({ 1: 'XCP', 2: 'PEPE' }, [{ id: 5, tick1_id: 1, tick2_id: 2 }]); + let b = source({ 7: 'XCP', 9: 'PEPE' }, [{ id: 5, tick1_id: '7', tick2_id: '9' }]); + let r = tool.compareSources({ a, b }); + assert.strictEqual(r.pairs, 1); + assert.deepStrictEqual(r.differing, []); + assert.deepStrictEqual(r.missing, []); + assert.deepStrictEqual(r.collisions, []); + }); + + it('names a native-coin side by its coin id', function(){ + let a = source({ 1: 'XCP' }, [{ id: 3, tick1_id: 1, tick2_id: 0, coin2_id: 4 }]); + assert.deepStrictEqual([...a.keys()], ['XCP/coin#4']); + }); + + it('reports differing ids and the id that names two pairs', function(){ + let t = { 1: 'XCP', 2: 'PEPE', 3: 'RARE' }; + let a = source(t, [{ id: 1, tick1_id: 1, tick2_id: 2 }, { id: 2, tick1_id: 1, tick2_id: 3 }]); + let b = source(t, [{ id: 2, tick1_id: 1, tick2_id: 2 }, { id: 1, tick1_id: 1, tick2_id: 3 }]); + let r = tool.compareSources({ a, b }); + assert.strictEqual(r.differing.length, 2); + assert.deepStrictEqual(r.collisions.map(c => c.id), ['1', '2']); + let report = tool.renderReport({ chain: 'BTC', network: 'mainnet', sources: ['a', 'b'] }, + { a: { tip: 9, markets: [1, 2] }, b: { tip: 9, markets: [1, 2] } }, r); + assert.strictEqual(report.agree, false); + assert.match(report.text, /NOT reproducible/); + }); + + it('reports a pair only one source holds', function(){ + let a = source({ 1: 'XCP', 2: 'PEPE' }, [{ id: 1, tick1_id: 1, tick2_id: 2 }]); + let b = source({ 1: 'XCP', 2: 'PEPE' }, []); + let r = tool.compareSources({ a, b }); + assert.deepStrictEqual(r.missing, [{ pair: 'XCP/PEPE', ids: { a: '1', b: null } }]); + }); + + it('refuses fewer than two sources', function(){ + assert.throws(() => tool.parseArgs(['--chain', 'BTC', '--network', 'mainnet'], { SYNC_SOURCES: 'http://a' }), + /two sources/); + let o = tool.parseArgs(['--chain', 'BTC', '--network', 'mainnet'], { SYNC_SOURCES: 'http://a/, http://b' }); + assert.deepStrictEqual(o.sources, ['http://a', 'http://b']); + }); +}); diff --git a/test/unit/rollback_coverage.test.js b/test/unit/rollback_coverage.test.js index 2ce63c90..b1860164 100644 --- a/test/unit/rollback_coverage.test.js +++ b/test/unit/rollback_coverage.test.js @@ -22,10 +22,12 @@ * diverges from the source (the exact failure xchain-indexer's rollback guard * prevents on the source side). * - * The risk here is sharper than on the source: ClientRollback's table lists are - * a hand-maintained mirror of xchain-indexer/src/rollback.js (see the header - * comment there). They have drifted before: `prices` was added to the indexer's - * rollback set but not here, so reorged price rows lingered on every replica. + * The risk here is sharper than on the source: ClientRollback's generic lists + * derive from the table-lifecycle registry and the decoder topology (see the + * header of src/client/rollback.js), but its bespoke resets and restores are a + * hand-maintained mirror of xchain-indexer/src/rollback/. Hand-copied lists + * drift: `prices` joined the indexer's rollback set but not the replica's, so + * reorged price rows lingered on every replica. * This test fails when a table ServerPoller replicates is not handled by * ClientRollback for that dbType, catching the drift at CI time. * @@ -94,7 +96,7 @@ const isLookupTable = (t) => t.startsWith('index_') || t === 'pubkeys'; // Coverage that lives outside ClientRollback's table arrays: the replica-side // rollback buckets, derived from the table-lifecycle registry twin -// (src/tableLifecycle.js, byte-identical to the xchain-indexer copy; asserted +// (src/table_lifecycle.js, byte-identical to the xchain-indexer copy; asserted // below). Per-table rationale lives with each registry entry. const lifecycleTwin = require('../../src/table_lifecycle'); const pathMod = require('path'); From d04ba91a52281290639dad248983108c47979136 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 21:15:22 -0700 Subject: [PATCH 56/62] fix(sync): check schema_version on every dispensers reconcile page, re-copy the table-lifecycle twin reconcileDispensers now refuses a page whose schema_version does not match the replica before collecting rows, so a version-mismatched follower no longer replaces its dispensers table from the status tick. --- src/client/sync.js | 10 ++- src/table_lifecycle.js | 36 ++++----- src/table_lifecycle/action_tables.js | 2 +- ...ile_dispensers_schema_version_gate.test.js | 81 +++++++++++++++++++ ...ient_sync_dispenser_reconcile_tick.test.js | 4 +- 5 files changed, 112 insertions(+), 21 deletions(-) create mode 100644 test/unit/client_sync.test/20_reconcile_dispensers_schema_version_gate.test.js diff --git a/src/client/sync.js b/src/client/sync.js index 0466ffec..fdd6d8ec 100644 --- a/src/client/sync.js +++ b/src/client/sync.js @@ -2640,12 +2640,16 @@ class ClientSync { // serves the whole table in one statement-consistent response (the has_more walk // stays so a source that still pages completes too), then an atomic replace // (ClientApplier.applyDispensersReplace) converges it. Decoder-only, best-effort: - // any fetch/parse failure aborts WITHOUT touching the local table. + // any fetch/parse failure aborts WITHOUT touching the local table. Every page is + // checked against SCHEMA_VERSION like the lookup-page and snapshot channels, so a + // code-version mismatch aborts before the replace (the status-tick caller has no + // earlier version-checked apply in front of it). async reconcileDispensers(source){ if(this.dbType !== 'decoder') return; if(!source) return; // Stamp the attempt so a failing re-dump is retried once per interval, not per tick. this._lastDispenserReconcileAttemptAt = Date.now(); + let expected = SCHEMA_VERSION[this.dbType]; try { let all = []; let afterTx = null, afterAddr = null; @@ -2664,6 +2668,10 @@ class ClientSync { try { jsonStr = zlib.gunzipSync(jsonStr); } catch(e){} } let page = JSON.parse(jsonStr.toString()); + if(page.schema_version !== expected){ + throw new Error('Dispensers page schema mismatch: server=' + + page.schema_version + ' client=' + expected); + } let rows = Array.isArray(page.rows) ? page.rows : []; for(let r of rows) all.push(r); if(!page.has_more || rows.length === 0) break; diff --git a/src/table_lifecycle.js b/src/table_lifecycle.js index f24c8d6d..c3277b39 100644 --- a/src/table_lifecycle.js +++ b/src/table_lifecycle.js @@ -23,12 +23,12 @@ * followers. Generates the per-block stream topology * (xchain-sync/src/schema/replicated_tables.js TOPOLOGY.indexer). * 2. ROLLBACK - how a chain reorg unwinds the table, on the source - * indexer (src/rollback.js) and on every replica - * (xchain-sync/src/ClientRollback.js). Generates both + * indexer (src/rollback/index.js) and on every replica + * (xchain-sync/src/client/rollback.js). Generates both * sets of generic delete lists. * 3. HASH COVERAGE- which integrity hash (if any) would catch a divergence * in the table. Declarative: guards in - * test/unit/hash-coverage.test.js bind the declarations + * test/unit/hub/hash_coverage.test.js bind the declarations * to the actual hashing code. * * A fourth artifact, the advisory TABLE_CONTENT_PARITY_CHECK coverage set, @@ -37,13 +37,13 @@ * * Adding a table: create src/sql/.sql, then add ONE entry to the * registry parts under table_lifecycle/ declaring all three dimensions. - * test/unit/rollback-coverage.test.js fails until the entry exists, and the + * test/unit/rollback/rollback_coverage.test.js fails until the entry exists, and the * per-dimension guards fail until the entry matches reality. Classify by * understanding the table, not by silencing the tests. * * BYTE-ALIGNED TWIN: copied verbatim, with its table_lifecycle/ parts, into * xchain-sync/src/table_lifecycle.js (sync has no dependency on this package - * by design; same convention as stateHash.js / merkle.js). Edit here, then + * by design; same convention as state_hash.js / merkle.js). Edit here, then * `cp` to the twin; the sync rollback-coverage suite asserts byte-identity. * * Entry fields: @@ -67,10 +67,10 @@ * a reader assuming the default raises errno 1054, which every * forward channel swallows as an older source schema: silent * non-delivery, never an error. - * xchain-sync test/unit/streamScopeColumns.test.js binds this + * xchain-sync test/unit/stream_scope_columns.test.js binds this * field to the owning DDL, so a scope column the schema does not * have fails the build instead of shipping un-replicated. - * rollback source-indexer reorg handling (src/rollback.js): + * rollback source-indexer reorg handling (src/rollback/index.js): * 'action' generic DELETE by action_index (dataTables) * 'block' generic DELETE by block_index (blockTables) * 'index' block-scoped lookup delete (indexTables) @@ -80,7 +80,7 @@ * 'lookup' append-only id-keyed dedup lookup; orphaned * rows are inert (only ever referenced by id) * null for owner 'sync' (not a source-indexer table) - * replicaRollback replica-side reorg handling (ClientRollback.js): + * replicaRollback replica-side reorg handling (xchain-sync/src/client/rollback.js): * 'mirror' same generic list as the source * 'recomputed' | 'special' | 'exempt' | 'lookup' as above * 'local' indexer-local; table never exists on replicas @@ -88,13 +88,13 @@ * refreshed by the recompute pass (coverage is a union) * hashed { classes: [...], note }. classes from: * 'ledger' | 'actions' | 'contracts' the three consensus - * block hashes (db.js getBlockHashes) + * block hashes (src/db/actions.js getBlockHashes) * 'state_hash' replication-integrity 4th hash over in-place - * mutations + backdated credits (stateHash.js) + * mutations + backdated credits (src/consensus/state_hash.js) * 'state_commitment' light-client SMT roots - * (stateCommitment.js: balances + BTC stakes) + * (src/state_commitment/: balances + BTC stakes) * 'index_map' id->string delta class of state_hash - * (armed per-chain; stateHash.js) + * (armed per-chain; src/consensus/state_hash.js) * 'quorum' not block-hashed, but every row carries (or * is derived under) federation quorum signatures * classes may be empty; the note must then say why no hash is @@ -157,8 +157,8 @@ const ORPHAN_SWEEPS = [ // divergence, which is exactly the class the row counts cannot see. // // This block is the coverage contract. The guards bind it to the code: -// xchain-sync test/unit/tableContentParity.test.js (every replicated table is -// committed by something) and this repo's test/unit/hash-coverage.test.js (the +// xchain-sync test/unit/table_content_parity.test.js (every replicated table is +// committed by something) and this repo's test/unit/hub/hash_coverage.test.js (the // carve-outs stay pinned to the operator ruling). // // There are exactly TWO exclusion classes, and a table outside both is covered: @@ -209,8 +209,8 @@ const CONTENT_PARITY_CARVE_OUTS = Object.freeze([ // preimage, so the check still covers everything the two sides must agree on. // `validator_rewards.id` reaches the same place by a different route: the row // normally streams with every column, carrying the source id, but the RB-ANCHOR -// reorg restore (xchain-indexer/src/rollback.js and its mirror in -// xchain-sync/src/ClientRollback.js) re-INSERTs a deleted loser naming only +// reorg restore (xchain-indexer/src/rollback/index.js and its mirror in +// xchain-sync/src/client/rollback.js) re-INSERTs a deleted loser naming only // source_id/signing_pubkey_id/reward_type/round_reference/amount/block_index/ // derive_block_index, so each side mints its own AUTO_INCREMENT value off a // counter the other never sees (the source burns values on every ignored @@ -242,7 +242,7 @@ function tablesWhere(fn){ return TABLES.filter(fn).map(t => t.table); } -// Source-indexer generic rollback lists (xchain-indexer/src/rollback.js). +// Source-indexer generic rollback lists (xchain-indexer/src/rollback/index.js). function rollbackTables(){ return { dataTables: tablesWhere(t => t.rollback === 'action'), @@ -251,7 +251,7 @@ function rollbackTables(){ }; } -// Replica generic rollback lists (xchain-sync/src/ClientRollback.js): the +// Replica generic rollback lists (xchain-sync/src/client/rollback.js): the // source lists minus indexer-local tables that never exist on a replica. function replicaRollbackTables(){ return { diff --git a/src/table_lifecycle/action_tables.js b/src/table_lifecycle/action_tables.js index f41d2602..22cec61e 100644 --- a/src/table_lifecycle/action_tables.js +++ b/src/table_lifecycle/action_tables.js @@ -165,7 +165,7 @@ const TABLES = [ note: 'The in-place invalid_archive stamp on a surviving v1 parent is covered by the state_hash anchor_invalid class. New rows are otherwise action-derived; status_id is deliberately in no block-hash projection.' } }, { table: 'attests', owner: 'indexer', replication: 'stream:action', rollback: 'action', replicaRollback: 'mirror', hashed: { classes: ['state_hash'], - note: 'The v0 request_status terminal flip (updateAttestationRequestStatus) is an in-place mutation on a surviving row; the state_hash request_status class covers it. Its reorg-side reset, resetOrphanedAttestRequests (src/db/rollback/in_place_flips.js), puts an orphaned flip back to pending on both the source and the replica. Four more in-place writers exist. setAttestationResponseCallbackIndex and setAttestationResponseBatchIndex stamp action_index links (callback_execute_action_index, batch_action_index) on a surviving v1 row; both are display-only and deliberately in no hash projection (state_hash reads attests at version 0 only). setAttestBatchStatus re-stamps status_id on a surviving v5 batch head when the completing v6 continuation fails reassembly or quorum, and that stamp is a KNOWN GAP: it is in no state_hash class and not carried by the updated_rows forward channel. Its reorg-side reset is restoreStampedAttestHeads (src/db/rollback/batch_heads.js), which puts the head back to valid on both the source and the replica (ClientRollback.js mirrors it); only the forward carry and the hash class remain open. Closing the remainder needs a flag-day-gated class in the anchor_invalid shape plus the forward carry.' } }, + note: 'The v0 request_status terminal flip (updateAttestationRequestStatus) is an in-place mutation on a surviving row; the state_hash request_status class covers it. Its reorg-side reset, resetOrphanedAttestRequests (src/db/rollback/in_place_flips.js), puts an orphaned flip back to pending on both the source and the replica. Four more in-place writers exist. setAttestationResponseCallbackIndex and setAttestationResponseBatchIndex stamp action_index links (callback_execute_action_index, batch_action_index) on a surviving v1 row; both are display-only and deliberately in no hash projection (state_hash reads attests at version 0 only). setAttestBatchStatus re-stamps status_id on a surviving v5 batch head when the completing v6 continuation fails reassembly or quorum, and that stamp is a KNOWN GAP: it is in no state_hash class and not carried by the updated_rows forward channel. Its reorg-side reset is restoreStampedAttestHeads (src/db/rollback/batch_heads.js), which puts the head back to valid on both the source and the replica (xchain-sync/src/client/rollback.js mirrors it); only the forward carry and the hash class remain open. Closing the remainder needs a flag-day-gated class in the anchor_invalid shape plus the forward carry.' } }, { table: 'prices', owner: 'indexer', replication: 'stream:action', rollback: 'action', replicaRollback: 'mirror', hashed: DERIVED }, { table: 'pending_hub_pushes', owner: 'indexer', replication: 'local', rollback: 'action', replicaRollback: 'local', hashed: { classes: [], note: 'Indexer-local outbound hub-push queue; never replicated (OPERATOR_LOCAL) and meaningless on a replica.' } }, diff --git a/test/unit/client_sync.test/20_reconcile_dispensers_schema_version_gate.test.js b/test/unit/client_sync.test/20_reconcile_dispensers_schema_version_gate.test.js new file mode 100644 index 00000000..92fee348 --- /dev/null +++ b/test/unit/client_sync.test/20_reconcile_dispensers_schema_version_gate.test.js @@ -0,0 +1,81 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const zlib = require('zlib'); +const { assert, sinon, axios, ClientSync } = require('./support'); +const { SCHEMA_VERSION } = require('../../../src/schema/version'); + +const SOURCE = 'http://source1:3006'; + +// Gzipped page body as the /snapshot-dispensers route serves it. +function pageBuffer(page){ + return zlib.gzipSync(Buffer.from(JSON.stringify(page))); +} + +function decoderCtx(){ + return { + dbType: 'decoder', chain: 'bitcoin', network: 'mainnet', config: {}, + _lastDispenserReconcileAt: null, + upstreamHeaders: () => ({}), + withApplyLock: (fn) => fn(), + applier: { applyDispensersReplace: sinon.stub().resolves() }, + }; +} + +function stubPages(pages){ + let get = sinon.stub(axios, 'get'); + pages.forEach((p, i) => get.onCall(i).resolves({ data: pageBuffer(p) })); + return get; +} + +function reconcile(ctx){ + return ClientSync.prototype.reconcileDispensers.call(ctx, SOURCE); +} + +describe('ClientSync.reconcileDispensers (schema_version gate)', function(){ + afterEach(function(){ sinon.restore(); }); + + it('replaces the table from a page carrying the client schema version', async function(){ + let ctx = decoderCtx(); + stubPages([{ schema_version: SCHEMA_VERSION.decoder, has_more: false, rows: [{ tx_index: 1 }] }]); + await reconcile(ctx); + assert.strictEqual(ctx.applier.applyDispensersReplace.calledOnce, true); + assert.deepStrictEqual(ctx.applier.applyDispensersReplace.firstCall.args[0], [{ tx_index: 1 }]); + assert.ok(ctx._lastDispenserReconcileAt > 0); + }); + + it('leaves the table intact when the page schema version differs', async function(){ + let ctx = decoderCtx(); + stubPages([{ schema_version: SCHEMA_VERSION.decoder + 1, has_more: false, rows: [{ tx_index: 1 }] }]); + await reconcile(ctx); + assert.strictEqual(ctx.applier.applyDispensersReplace.called, false); + assert.strictEqual(ctx._lastDispenserReconcileAt, null); + }); + + it('fails closed on a page with no schema_version field', async function(){ + let ctx = decoderCtx(); + stubPages([{ has_more: false, rows: [{ tx_index: 1 }] }]); + await reconcile(ctx); + assert.strictEqual(ctx.applier.applyDispensersReplace.called, false); + assert.strictEqual(ctx._lastDispenserReconcileAt, null); + }); + + it('applies nothing when a later page mismatches after an accepted first page', async function(){ + let ctx = decoderCtx(); + let get = stubPages([ + { schema_version: SCHEMA_VERSION.decoder, has_more: true, max_tx: 5, max_addr: 'a', rows: [{ tx_index: 1 }] }, + { schema_version: SCHEMA_VERSION.decoder - 1, has_more: false, rows: [{ tx_index: 6 }] }, + ]); + await reconcile(ctx); + assert.strictEqual(get.callCount, 2); + assert.strictEqual(ctx.applier.applyDispensersReplace.called, false); + assert.strictEqual(ctx._lastDispenserReconcileAt, null); + }); +}); diff --git a/test/unit/client_sync_dispenser_reconcile_tick.test.js b/test/unit/client_sync_dispenser_reconcile_tick.test.js index 35d7197a..51d2c27f 100644 --- a/test/unit/client_sync_dispenser_reconcile_tick.test.js +++ b/test/unit/client_sync_dispenser_reconcile_tick.test.js @@ -27,6 +27,7 @@ const assert = require('assert'); const sinon = require('sinon'); const ClientSync = require('../../src/client/sync'); +const { SCHEMA_VERSION } = require('../../src/schema/version'); describe('ClientSync.dispenserReconcileIntervalDue (wall-clock term)', function(){ function due(ctx, now){ @@ -264,7 +265,8 @@ describe('ClientSync.reconcileDispensers attempt stamp and request', function(){ }); it('requests the whole table with no page-size parameter and stamps the success', async function(){ - let body = JSON.stringify({ has_more: false, rows: [{ tx_index: 1, address_id: 2 }] }); + let body = JSON.stringify({ schema_version: SCHEMA_VERSION.decoder, has_more: false, + rows: [{ tx_index: 1, address_id: 2 }] }); let get = sinon.stub(axios, 'get').resolves({ data: Buffer.from(body) }); let ctx = reconcileCtx(); await ClientSync.prototype.reconcileDispensers.call(ctx, 'http://source1:3006'); From 846ecc53796b4e8f99870efb0d7de4cbb4005108 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 09:04:51 -0700 Subject: [PATCH 57/62] fix(consensus): re-slide v0.20.1 BTC and DOGE activation heights, 40h margin Live testnet tips (TBTC 153698, TDOGE 67924122 at 2026-09-23T15:55Z) have passed every height the 2026-09-19 reslide armed, so the freeze preflight refuses them. The operator ruled a second reslide to 40 h of margin to the nearest armed height at the fastest defensible cadence (BTC least-squares bound 383.04 s/block, DOGE median 11 s/block), keeping the prior spacing. Propagate it into sync's own copy of the shared consensus registry: BTC train height 154074 (tip + 376 blocks, 40 h), BTC mirror producer/consumer 154234/154291 (train + 160, 17 h; producer + 57, 6 h; the consumer also carries ANCHOR_ATTEST_BARRIER_ACTIVATION), DOGE mirror producer/consumer 67942777/67944741 (tip + 18655, 57 h, the same instant as the BTC producer; producer + 1964, 6 h). LTC:testnet stays null under dq4 (a) and is untouched. Re-pin bin/pins/identity.json (armed_map_fingerprint and the moved train_activation.TRAIN_ACTIVATION row) to match, regenerated with bin/pin-identity.js --out. --- bin/pins/identity.json | 4 ++-- src/consensus/gate_registry/shared_rows_1.js | 10 +++++----- src/consensus/gate_registry/shared_rows_2.js | 8 ++++---- src/consensus/gate_registry/shared_rows_5.js | 14 +++++++------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/bin/pins/identity.json b/bin/pins/identity.json index 3193cb07..bd356877 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -1,5 +1,5 @@ { - "armed_map_fingerprint": "a8be891d9286854dc136cc01abe9ebc97a3ca2c8c8cff496b23c76b88cfbed06", + "armed_map_fingerprint": "4ca8bc02fc0247f255ab10ab56325f04f8d75486b855f6d4ca0e9331582be429", "armed_map_fingerprint_version": 2, "armedMapRows": { "archive_rollback_author_scope_activation.ARCHIVE_AUTHOR_SCOPE_JOIN_SQL": "c8b6c1753f86fb65689fbc5d72f001e9467d5553a91895a21c9cf48d643e8895", @@ -40,7 +40,7 @@ "swq_source_cap_activation.STAKE_WEIGHT_MAX_KEYS_PER_SOURCE": "a68b412c4282555f15546cf6e1fc42893b7e07f271557ceb021821098dd66c1b", "swq_source_cap_activation.STAKE_WEIGHT_MAX_SOURCES": "40510175845988f13f6162ed8526f0b09f73384467fa855e1e79b44a56562a58", "swq_source_cap_activation.SWQ_SOURCE_CAP_ACTIVATION": "d9105d53199963f94287f25b3d87d1e625b77d56ac8baab6d6ee64029b0fae74", - "train_activation.TRAIN_ACTIVATION": "05ed75dadd161735029c81ab58ba373dc68c6ae1a21260bd34ba75087faf78ce" + "train_activation.TRAIN_ACTIVATION": "49f6acf148e96dd7ec179071e452506a31d007794b2e7a4c683be688ccc1bea9" }, "armed_map_rows": 39, "carrier_logic_digest": "f142db83339b2a74577e6e79d49cffc45c45ac642bcd57609b554f98a2f2e068", diff --git a/src/consensus/gate_registry/shared_rows_1.js b/src/consensus/gate_registry/shared_rows_1.js index 742a811c..568d8ff5 100644 --- a/src/consensus/gate_registry/shared_rows_1.js +++ b/src/consensus/gate_registry/shared_rows_1.js @@ -295,11 +295,11 @@ addGate('anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S', 'constant', 6 // that window. addGate('anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION', 'height', { mainnet: null, // INERT under the 2026-08-29 mainnet write hold - // SIZED 2026-09-16 20:41Z, RE-SLID 2026-09-19, on the BTC clock because this member is - // BTC-only: the same instant as the family's BTC CONSUMER height, so the one member that - // keeps BOTH certificates gains them together rather than carrying a lone extra rule for - // 6 h. The canon carries the measurement. - testnet: 153328, + // SIZED 2026-09-16 20:41Z, RE-SLID 2026-09-19 and 2026-09-23, on the BTC clock because this + // member is BTC-only: the same instant as the family's BTC CONSUMER height, so the one + // member that keeps BOTH certificates gains them together rather than carrying a lone extra + // rule for 6 h. The canon carries the measurement. + testnet: 154291, regtest: UNPINNED, // shares the family's arming seam so one venue lever arms both }); diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index c245769c..a50d42c9 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -216,9 +216,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 153300, // RE-SLID 2026-09-19: train 153,221 + 79 blocks (17 h at 781.078553 s/blk), the v0.20.1 patch reslide + 'BTC:testnet': 154234, // RE-SLID 2026-09-23: train 154,074 + 160 blocks (17 h at 383.04 s/blk), the v0.20.1 patch reslide 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67916857, // RE-SLID 2026-09-19: tip 67,911,061 + 5796 blocks (41 h at 25.469118 s/blk, the same instant as the BTC producer), the v0.20.1 patch reslide + 'DOGE:testnet': 67942777, // RE-SLID 2026-09-23: tip 67,924,122 at 15:55Z + 18655 blocks (57 h at 11 s/blk median, the same instant as the BTC producer), the v0.20.1 patch reslide 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -228,9 +228,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 153328, // RE-SLID 2026-09-19: its producer + 28 blocks (6 h at 781.078553 s/blk), strictly above, never equal + 'BTC:testnet': 154291, // RE-SLID 2026-09-23: its producer + 57 blocks (6 h at 383.04 s/blk), strictly above, never equal 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67917706, // RE-SLID 2026-09-19: its producer + 849 blocks (6 h at 25.469118 s/blk) + 'DOGE:testnet': 67944741, // RE-SLID 2026-09-23: its producer + 1964 blocks (6 h at 11 s/blk median) 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration diff --git a/src/consensus/gate_registry/shared_rows_5.js b/src/consensus/gate_registry/shared_rows_5.js index 291fc4fe..26ada069 100644 --- a/src/consensus/gate_registry/shared_rows_5.js +++ b/src/consensus/gate_registry/shared_rows_5.js @@ -110,13 +110,13 @@ addGate('train_activation.TRAIN_ACTIVATION', 'ruleset', { // minute roll budget), and every testnet mirror-admission height sits above it on the same // BTC clock, so a node lacking this rule set halts before it can grade an admission-stamped // row. - // RE-SLID 2026-09-19 for the v0.20.1 patch train: margin is 24 h measured from the freeze, - // converted at each coin's own measured cadence, not five days. Chain_tip TBTC 153,110 + 111 - // blocks, ceil(24 h / 781.078553 s per block, least-squares bound over the trailing 114-block - // window, about 25.2 h span, at least as long as the lead). The mirror-admission family - // below re-slides onto the same instant plus its own 17 h and 6 h offsets. LTC stays null - // under dq4 (a) and is untouched by this reslide. - '0.20.0': { mainnet: 9999999999, testnet: 153221, regtest: 0 }, + // RE-SLID 2026-09-23 for the v0.20.1 patch train, after the live tips overran the + // 2026-09-19 slide before the freeze: margin is 40 h to the nearest armed height, converted + // at each coin's fastest defensible cadence. Chain_tip TBTC 153,698 at 2026-09-23T15:55Z + // + 376 blocks, ceil(40 h / 383.04 s per block, the least-squares bound). The + // mirror-admission family below re-slides onto the same instant plus its own 17 h and 6 h + // offsets. LTC stays null under dq4 (a) and is untouched by this reslide. + '0.20.0': { mainnet: 9999999999, testnet: 154074, regtest: 0 }, }); // xchain_bridge_activation From b98aa70605f5325d5f4df9328761bbb43e69a6a8 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 09:46:20 -0700 Subject: [PATCH 58/62] chore(release): v0.20.1 --- CHANGELOG.md | 6 ++++++ README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01e24357..cf7ac3ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.1] - 2026-09-23 + +### Fixed +- Re-slid BTC and DOGE testnet activations with LTC inert, and made replication halt durably on repeated market key conflicts. + + ## [0.20.0] - 2026-09-17 ### Changed diff --git a/README.md b/README.md index 196dd717..91388f51 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # XChain Sync

- Version + Version Tests Node License diff --git a/package-lock.json b/package-lock.json index f242cfa6..14733368 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-sync", - "version": "0.20.0", + "version": "0.20.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-sync", - "version": "0.20.0", + "version": "0.20.1", "license": "AGPL-3.0-or-later", "dependencies": { "acorn": "8.18.0", diff --git a/package.json b/package.json index 113dd76e..6a69137f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xchain-sync", "description": "Database replication service for the XChain Platform: syncs indexer and decoder databases to validators and consumers via REST snapshots and WebSocket streaming", - "version": "0.20.0", + "version": "0.20.1", "license": "AGPL-3.0-or-later", "repository": { "type": "git", From 834a300b7e642c2743a7aafd068dda827490ae98 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 10:57:42 -0700 Subject: [PATCH 59/62] chore: remove internal references from public source Replace the opaque dq4 (a) decision label and an issue-tracker id with self-contained public rationale. Comment text only; the table-lifecycle twin stays byte-identical to xchain-indexer's canonical copy, and the shared gate rows stay byte-identical to the other five carriers. --- src/consensus/gate_registry/shared_rows_2.js | 4 ++-- src/consensus/gate_registry/shared_rows_5.js | 3 ++- src/server/updated_rows/token_rows.js | 2 +- src/table_lifecycle/action_tables.js | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index a50d42c9..2b87dd32 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -217,7 +217,7 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 154234, // RE-SLID 2026-09-23: train 154,074 + 160 blocks (17 h at 383.04 s/blk), the v0.20.1 patch reslide - 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train + 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train 'DOGE:testnet': 67942777, // RE-SLID 2026-09-23: tip 67,924,122 at 15:55Z + 18655 blocks (57 h at 11 s/blk median, the same instant as the BTC producer), the v0.20.1 patch reslide 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -229,7 +229,7 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 154291, // RE-SLID 2026-09-23: its producer + 57 blocks (6 h at 383.04 s/blk), strictly above, never equal - 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train + 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train 'DOGE:testnet': 67944741, // RE-SLID 2026-09-23: its producer + 1964 blocks (6 h at 11 s/blk median) 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration diff --git a/src/consensus/gate_registry/shared_rows_5.js b/src/consensus/gate_registry/shared_rows_5.js index 26ada069..fde6a96c 100644 --- a/src/consensus/gate_registry/shared_rows_5.js +++ b/src/consensus/gate_registry/shared_rows_5.js @@ -115,7 +115,8 @@ addGate('train_activation.TRAIN_ACTIVATION', 'ruleset', { // at each coin's fastest defensible cadence. Chain_tip TBTC 153,698 at 2026-09-23T15:55Z // + 376 blocks, ceil(40 h / 383.04 s per block, the least-squares bound). The // mirror-admission family below re-slides onto the same instant plus its own 17 h and 6 h - // offsets. LTC stays null under dq4 (a) and is untouched by this reslide. + // offsets. LTC:testnet mirror admission ships disabled on this train and is + // untouched by this reslide; it arms on a later train. '0.20.0': { mainnet: 9999999999, testnet: 154074, regtest: 0 }, }); diff --git a/src/server/updated_rows/token_rows.js b/src/server/updated_rows/token_rows.js index f68a2958..68fb200e 100644 --- a/src/server/updated_rows/token_rows.js +++ b/src/server/updated_rows/token_rows.js @@ -83,7 +83,7 @@ async function collectTokenEditRows(db, from, to, conn, acc){ // action on the tick moved a balance and re-emitted it. An ownership TRANSFER // (`ISSUE|0|||||||`) is exactly that shape, so explorer read APIs // served the OLD owner indefinitely while consensus had the new one - // (xchain-indexer#39; ownership-gated client UI reads this field). + // until this class ships (ownership-gated client UI reads this field). // // Keyed on the tick's valid `issues` rows in the window rather than on the ISSUE // action alone: `issues` stores every ISSUE, valid or not, and only a valid one diff --git a/src/table_lifecycle/action_tables.js b/src/table_lifecycle/action_tables.js index 22cec61e..b155a6b5 100644 --- a/src/table_lifecycle/action_tables.js +++ b/src/table_lifecycle/action_tables.js @@ -126,7 +126,7 @@ const TABLES = [ { table: 'sweeps', owner: 'indexer', replication: 'stream:action', rollback: 'action', replicaRollback: 'mirror', hashed: DERIVED }, { table: 'tokens', owner: 'indexer', replication: 'stream:action', rollback: 'action', replicaRollback: 'mirror', alsoRecomputed: true, hashed: { classes: ['state_hash'], - note: 'The in-place supply mutation on a surviving token row (carried forward by the updated_rows tokens-supply class) is covered by the state_hash token_supply class: (tick, supply) per ledger-touched tick, flag-day gated per chain (TOKEN_SUPPLY_STATE_HASH_ACTIVATION, armed 2026-07-07 at tip + margin). Supply is also recomputed and sanity-checked against credits/debits/escrows each block; new rows are otherwise action-derived. This closes a supply-forward gap that would otherwise exist. The row\'s OTHER derived columns (owner_id, description, the locks, the callback and list fields, the bridge opt-in) are re-derived from the `issues` history by every valid ISSUE, an in-place mutation with the same pinned action_index; the updated_rows tokens-EDIT class carries those forward, keyed on the tick\'s valid issues rows in the window. That class has NO state_hash twin, so a follower that misses one diverges silently rather than halting (xchain-indexer#39).' } }, + note: 'The in-place supply mutation on a surviving token row (carried forward by the updated_rows tokens-supply class) is covered by the state_hash token_supply class: (tick, supply) per ledger-touched tick, flag-day gated per chain (TOKEN_SUPPLY_STATE_HASH_ACTIVATION, armed 2026-07-07 at tip + margin). Supply is also recomputed and sanity-checked against credits/debits/escrows each block; new rows are otherwise action-derived. This closes a supply-forward gap that would otherwise exist. The row\'s OTHER derived columns (owner_id, description, the locks, the callback and list fields, the bridge opt-in) are re-derived from the `issues` history by every valid ISSUE, an in-place mutation with the same pinned action_index; the updated_rows tokens-EDIT class carries those forward, keyed on the tick\'s valid issues rows in the window. That class has NO state_hash twin, so a follower that misses one diverges silently rather than halting; closing that gap needs the tokens-EDIT class to carry its own state_hash coverage.' } }, { table: 'stakes', owner: 'indexer', replication: 'stream:action', rollback: 'action', replicaRollback: 'mirror', hashed: { classes: ['state_hash', 'state_commitment'], note: 'state_hash covers the in-place deactivation_block stamps and capability SLASH amount cuts; the light-client state commitment covers active BTC stake weights.' } }, From a7931d0f7c84beb1dc17453bdb8ce61f6bc78852 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 10:57:46 -0700 Subject: [PATCH 60/62] fix(sync): refold an orphaned ISSUE edit off the replica tokens row on reorg Updated-rows class 7 carries an ISSUE edit's tokens row forward, but a reorg that orphaned the edit left the replica on the edited row while the source refolded it from the surviving issues (rollback/commit.js updateTokens). The row's action_index is the first issuance, so the generic delete never touched it and no later window re-sent it: a silent replica divergence on owner, the seven locks, callback, lists, mint window and bridge policy. ClientRollback now collects every tick the orphaned range issued on before the delete and, after it and before the supply recompute, rewrites each tick's fold columns from its surviving valid issues inside the rollback transaction. The port (src/db/token_refold.js) restates getTokenInfo's replay and createToken's normalize and fields step for step; a tick whose first issuance is not held locally (truncated replica) is skipped and logged rather than folded from a tail. The fold columns sat in no hash, so an advisory TOKEN_FOLD_PARITY_CHECK (default off, never halts) now digests them on /status and at the follower's bootstrap cross-check, bounded on the source by the published height's last action. Tests run each reorg (ownership, lock, bridge policy, callback/list/mint window) against the xchain-indexer's own fold when the sibling is present. --- src/api.js | 12 + src/client/block_hasher.js | 26 ++ src/client/rollback.js | 30 ++ src/client/sync.js | 45 +++ src/config.js | 9 + src/db/token_refold.js | 298 ++++++++++++++++++ src/server/updated_rows/token_rows.js | 17 +- .../07_token_refold.test.js | 278 ++++++++++++++++ 8 files changed, 707 insertions(+), 8 deletions(-) create mode 100644 src/db/token_refold.js create mode 100644 test/unit/client_rollback.test/07_token_refold.test.js diff --git a/src/api.js b/src/api.js index a90e4d9e..172b7302 100644 --- a/src/api.js +++ b/src/api.js @@ -316,6 +316,18 @@ async function buildStatusRow(syncService, db, dbType, chain, network){ console.error('[API] index_map_checksum compute failed for %s/%s at block %s (advisory, returning null):', chain, network, polledBlock, e.message); } } + // Advisory tokens fold-column parity (NON-consensus, default off), the only + // check that sees an ISSUE edit or its reorg reversal missing on a replica. + // See BlockHasher.computeTokenFoldChecksum; null => follower skips. + row.token_fold_parity = null; + if(cfg['TOKEN_FOLD_PARITY_CHECK'] && polledBlock !== null){ + try { + row.token_fold_parity = await new BlockHasher(db, statusUtil).computeTokenFoldChecksum(polledBlock); + } catch(e){ + getLogger().error('[API] token_fold_parity compute failed for ' + chain + '/' + network + + ' at block ' + polledBlock + ' (advisory, returning null): ' + e.message); + } + } } // Advisory per-table CONTENT parity (NON-consensus, default off). // Published for BOTH dbTypes, unlike the three hashes and the index-map diff --git a/src/client/block_hasher.js b/src/client/block_hasher.js index 58d3c794..31522726 100644 --- a/src/client/block_hasher.js +++ b/src/client/block_hasher.js @@ -55,6 +55,7 @@ const DEFAULT_CONTENT_PARITY_WINDOW = 100; const replicatedTables = require('../schema/replicated_tables'); const lifecycle = require('../table_lifecycle'); +const tokenRefold = require('../db/token_refold'); const { buildStateHashData } = require('../consensus/state_hash'); const { gasTickSymbol } = require('../consensus-constants'); const { canonicalizeHashAddress } = require('../util/protocol_address_roles'); @@ -330,6 +331,31 @@ class BlockHasher { return this.util.getDataHash({ index_map: mapped }); } + // ADVISORY, NON-CONSENSUS, same posture as computeIndexMapChecksum. The tokens + // metadata columns ISSUE folds in place (owner, locks, callback, lists, mint window, + // bridge policy) sit in no block hash, no state_hash class and no content-parity + // window, so a replica that missed an edit (updated_rows class 7) or its reversal on a + // reorg (ClientRollback -> token_refold.refoldTokenRows) diverged with nothing to see + // it. This digests every tokens row's fold columns (token_refold.FOLD_COLUMNS). + // + // The table is current state, not a block window, so the SOURCE bounds it instead: + // a row whose last_action_index is above the last action at uptoBlock was edited + // after the height this status publishes, and is left out and named in `ahead`. A + // FOLLOWER passes that list back as opts.exclude and applies no bound of its own, so + // a replica row wrongly carrying a later last_action_index still lands in its digest. + async computeTokenFoldChecksum(uptoBlock, opts){ + let follower = !!(opts && Array.isArray(opts.exclude)); + let bound = null; + if(!follower){ + let r = await this.db.doQuery("SELECT MAX(action_index) AS m FROM actions WHERE block_index <= ?", + [uptoBlock], null, { rethrow: true }); + bound = (r && r.length && r[0].m !== null && r[0].m !== undefined) ? String(r[0].m) : '0'; + } + let res = await tokenRefold.tokenFoldRows(this.db, bound, follower ? opts.exclude : []); + return { h: this.util.getDataHash({ token_fold: res.rows }), n: res.rows.length, + ahead: follower ? opts.exclude.map(String) : res.ahead }; + } + // ADVISORY, NON-CONSENSUS. Same posture as computeIndexMapChecksum // above: not a block hash, not in BLOCK_HASH_VERSION, no indexer twin. Its only // conformance requirement is server-vs-client agreement, and both sides call diff --git a/src/client/rollback.js b/src/client/rollback.js index 02db2cd2..beef30ab 100644 --- a/src/client/rollback.js +++ b/src/client/rollback.js @@ -26,6 +26,7 @@ ********************************************************************/ const balanceHelpers = require('../db/balance_helpers'); +const tokenRefold = require('../db/token_refold'); const lifecycle = require('../table_lifecycle'); const replicatedTables = require('../schema/replicated_tables'); const { activationDelayBlocks, gasTickSymbol } = require('../consensus-constants'); @@ -801,6 +802,17 @@ class ClientRollback { } } + // Ticks whose `issues` history this rollback truncates, read while the orphaned + // rows still exist. Refolded near the end (see refoldTokenRows below). + let issueTickIds = []; + if(firstActionIndex !== null){ + try { + issueTickIds = await tokenRefold.collectIssueTickIds(this.db, firstActionIndex); + } catch(e){ + if(e.errno !== 1146 && e.errno !== 1054) throw e; + } + } + if(firstActionIndex !== null){ for(let table of this.dataTables){ try { @@ -1167,6 +1179,24 @@ class ClientRollback { if(e.errno !== 1146){ logger.error(util.format('rebuildBalances after rollback failed:', e)); throw e; } } + // Refold the tokens metadata of every tick the orphaned range issued on, the + // reverse leg of updated_rows class 7. An orphaned ISSUE that edited a surviving + // token (owner, a lock, the callback, a list, the mint window, the bridge policy) + // rewrote the row in place, and the generic delete cannot touch it: its + // action_index is the FIRST issuance. The source refolds it from the surviving + // issues (xchain-indexer rollback/commit.js updateTokens), and no forward window + // ever re-sends it, so without this the replica kept the orphaned edit forever. + // Runs after every delete, as the source's refresh does, and BEFORE the supply + // recompute below, which reads tokens.decimals. + try { + let refold = await tokenRefold.refoldTokenRows(this.db, issueTickIds); + if(refold.skipped.length) + logger.warn('ClientRollback: tokens refold skipped ' + refold.skipped.length + ' tick(s) whose first ' + + 'issuance is not held locally (truncated replica?); tick_ids ' + refold.skipped.slice(0, 20).join(',')); + } catch(e){ + if(e.errno !== 1146 && e.errno !== 1054){ logger.error(util.format('refoldTokenRows after rollback failed:', e)); throw e; } + } + // Recompute tokens.supply from the surviving credits/debits/escrows. The // orphaned MINT's credit row is deleted above, but tokens.supply was mutated // IN PLACE (no new row), so it survives the block-scoped deletes with the diff --git a/src/client/sync.js b/src/client/sync.js index fdd6d8ec..a2cd1a8e 100644 --- a/src/client/sync.js +++ b/src/client/sync.js @@ -2042,6 +2042,7 @@ class ClientSync { } await this.verifyTableContentParity(source, blockHeight, remoteStatus); + await this.verifyTokenFoldParity(source, blockHeight, remoteStatus); return verdict; } catch(e){ getLogger().error(util.format('Hash verification failed against ' + source + ':', e)); @@ -2121,6 +2122,50 @@ class ClientSync { } } + // Advisory tokens fold-column parity (NON-consensus; never halts, never throws). + // Same preconditions as the two checks above: both sides opted in and we are AT + // the source's published height. The source's `ahead` ticks (edited after that + // height on its live table) are fed back as the exclusion, so both digests cover + // the same ticks. A mismatch means this replica holds an ISSUE edit the source + // does not, or lacks one it has: the forward class-7 carry or the rollback refold + // went wrong. + async verifyTokenFoldParity(source, blockHeight, remoteStatus){ + if(!this.config['TOKEN_FOLD_PARITY_CHECK']) return null; + let remote = remoteStatus && remoteStatus.token_fold_parity; + if(!remote || typeof remote.h !== 'string') return null; + if(Number(remoteStatus.block_height) !== Number(blockHeight)) return null; + try { + let local = await this.blockHasher.computeTokenFoldChecksum(blockHeight, { exclude: remote.ahead || [] }); + if(local.h !== remote.h){ + getLogger().warn('TOKEN_FOLD_PARITY mismatch at block ' + blockHeight + ' against ' + source + + ': local=' + local.h + ' (' + local.n + ' rows) source=' + remote.h + ' (' + remote.n + ' rows)' + + ' (advisory, NOT halting; tokens owner/lock/callback/list/mint-window/bridge columns diverged)'); + await this.recordSyncStateCounter('token_fold_mismatch', blockHeight); + return false; + } + getLogger().info('Token fold parity passed against ' + source + ' (' + local.n + ' rows)'); + return true; + } catch(e){ + getLogger().error(util.format('Token fold parity check errored at block %s (advisory, ignoring):', blockHeight, e.message)); + return null; + } + } + + // Durable running count plus last block for one advisory mismatch kind, under + // dbType-namespaced sync-state keys. Best-effort health signal; never throws. + async recordSyncStateCounter(kind, blockIndex){ + try { + if(!this.db || typeof this.db.setSyncState !== 'function') return; + let countKey = kind + '_count:' + this.dbType; + let cur = (typeof this.db.getSyncState === 'function') ? await this.db.getSyncState(countKey) : null; + let n = (cur != null && Number.isFinite(Number(cur))) ? Number(cur) + 1 : 1; + await this.db.setSyncState(countKey, String(n)); + await this.db.setSyncState(kind + '_last_block:' + this.dbType, String(blockIndex)); + } catch(e){ + // advisory; swallow + } + } + // Durably count advisory table-content parity mismatches, the twin of // recordIndexMapMismatch above and never a consensus gate. Never throws. Also // stores the diverging TABLE NAMES, because unlike the index-map counter this diff --git a/src/config.js b/src/config.js index 298ff1fe..de930b6e 100644 --- a/src/config.js +++ b/src/config.js @@ -476,6 +476,15 @@ module.exports = { // OFF by default: it reads a window of ~93 indexer tables per status poll. config['TABLE_CONTENT_PARITY_CHECK'] = (process.env.TABLE_CONTENT_PARITY_CHECK || '').toLowerCase() === 'true'; + // TOKEN_FOLD_PARITY_CHECK: advisory digest of the tokens metadata columns ISSUE + // folds in place (BlockHasher.computeTokenFoldChecksum). Those columns are in no + // consensus hash and are excluded from the content-parity windows as in-place + // state, so a replica missing an edit or its reorg reversal is visible only here. + // Same posture as the two checks above: read on both sides, NEVER halts, a + // mismatch is logged and durably counted, OFF by default (one tokens scan per + // status poll on the source). + config['TOKEN_FOLD_PARITY_CHECK'] = (process.env.TOKEN_FOLD_PARITY_CHECK || '').toLowerCase() === 'true'; + // TABLE_CONTENT_PARITY_WINDOW: how many blocks (and, for the append-only // lookups that carry no block column, how many ids) each content checksum // spans. Server-side setting: the source publishes the window it used and a diff --git a/src/db/token_refold.js b/src/db/token_refold.js new file mode 100644 index 00000000..a5a2cff1 --- /dev/null +++ b/src/db/token_refold.js @@ -0,0 +1,298 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * Replica refold of the `tokens` metadata columns from surviving `issues` + * + * The reverse leg of updated_rows class 7 (src/server/updated_rows/token_rows.js). + * The source rewrites the WHOLE tokens row from the tick's valid ISSUE history on + * every refresh, and its reorg refreshes every tick the orphaned range touched: + * + * xchain-indexer/src/rollback/index.js:165 collectAffectedEntities (issues read + * at src/db/rollback/read_phase.js:136) + * xchain-indexer/src/rollback/commit.js:60 updateTokens(tickers, true) + * xchain-indexer/src/db/database/ledger_checks.js:130 updateTokenInfo + * xchain-indexer/src/db/issues/token_info.js:29 getTokenInfo, whose replay is + * rowsQuery (:84), rowValues (:131) and foldRow (:169) + * xchain-indexer/src/db/tokens/token_writer.js:26 createToken, via + * normalizeDataValues (src/db/database/normalize.js:136), fields (:57) and + * updateSql (:113) + * + * A replica cannot call that code, and nothing streams the refolded row back: the + * row's action_index is pinned at the first issuance, and the surviving issues are + * older than any window the stream will carry again. So this module restates the + * fold, step for step, over the replica's own replicated `issues` rows, and writes + * the same column values the source's UPDATE binds. `supply` is left to + * balance_helpers.recomputeTokenSupplies, which already mirrors getTokenSupply and + * must run AFTER this (it reads tokens.decimals). `bridged`, `escrow_action_index`, + * `coin_price` and `coin_floor` are not fold output and are never touched. + * + * test/unit/token_refold.test.js runs the indexer's own getTokenInfo + createToken + * beside this port over the same issue rows when the sibling checkout is present. + * + ********************************************************************/ + +'use strict'; + +const mathjs = require('mathjs'); + +// Wire-field lists the source's normalizeDataValues applies, restricted to the keys +// the fold produces (xchain-indexer/src/config/wire_fields.js, token_limits.js). +const NUMBER_FIELDS = ['ALLOW_LIST', 'BLOCK_LIST', 'CALLBACK_AMOUNT', 'CALLBACK_BLOCK', 'DECIMALS', + 'MAX_SUPPLY', 'MAX_MINT', 'MINT_ADDRESS_MAX', 'MINT_START_BLOCK', 'MINT_STOP_BLOCK']; +const LIST_FIELDS = ['ALLOW_LIST', 'BLOCK_LIST']; +const INTEGER_FIELDS = ['ALLOW_LIST', 'BLOCK_LIST', 'CALLBACK_BLOCK', 'MINT_START_BLOCK', 'MINT_STOP_BLOCK']; +const U64_MAX = '18446744073709551615'; +// LOCK_BRIDGE is deliberately absent: the source's LOCK_FIELDS list omits it too. +const LOCK_FIELDS = ['LOCK_MAX_SUPPLY', 'LOCK_MINT', 'LOCK_MINT_SUPPLY', 'LOCK_MAX_MINT', + 'LOCK_DESCRIPTION', 'LOCK_SLEEP', 'LOCK_CALLBACK']; +const MIN_TOKEN_DECIMALS = 0; +const MAX_TOKEN_DECIMALS = 18; + +// The fold-owned columns, in the order the source's UPDATE lists them (minus supply). +// Shared with the advisory parity digest so the refold and its detector cover one set. +const FOLD_COLUMNS = Object.freeze(['max_supply', 'max_mint', 'decimals', 'description', + 'lock_max_supply', 'lock_mint', 'lock_mint_supply', 'lock_max_mint', 'lock_description', + 'lock_sleep', 'lock_callback', 'callback_block', 'callback_tick_id', 'callback_amount', + 'allow_list', 'block_list', 'mint_address_max', 'mint_start_block', 'mint_stop_block', + 'bridge_chains', 'min_depth', 'lock_bridge', 'owner_id', 'last_action_index']); + +const CHUNK = 500; + +// Source Utility.isNull / isNumeric / exceedsUnsignedColumn / bcformat, restated. +function isNull(v){ return (v === null || v === undefined || v === ''); } +function isNumeric(v){ return typeof v === 'bigint' || (!isNaN(parseFloat(v)) && isFinite(v)); } +function exceedsU64(v){ + let raw = String(v).trim(); + if(/^[+-]?[0-9]+$/.test(raw)){ let n = BigInt(raw); return (n < 0n || n > BigInt(U64_MAX)); } + let approx = Number(raw); + return (Number.isFinite(approx) && (approx < 0 || approx > Number(U64_MAX))); +} +function bcformat(num, decimals){ + let str = String(num).trim(); + let bn = (str === 'NaN' || str === 'Infinity' || str === '-Infinity' || !isNumeric(num)) + ? mathjs.bignumber(0) : mathjs.bignumber(str); + return mathjs.format(bn, { notation: 'fixed', precision: isNull(decimals) ? 0 : parseInt(decimals) }); +} + +// One issues row keyed as the source's token-info fields (rowValues). Ids stand in for +// the strings the source later interns back to the same ids: the tick, the owner +// (transfer address when it resolves, else the action's source address) and the +// callback tick (only when it resolves, since the source's LEFT JOIN gives null). +function rowValues(row){ + return { + ACTION_INDEX: row.action_index, + TICK_ID: row.tick_id, + OWNER_ID: (row.transfer_addr_id !== null && row.transfer_addr_id !== undefined) ? row.transfer_addr_id : row.source_addr_id, + MAX_SUPPLY: row.max_supply, MAX_MINT: row.max_mint, + DECIMALS: (!isNull(row.decimals)) ? parseInt(row.decimals) : 0, + DESCRIPTION: row.description, + LOCK_MAX_SUPPLY: row.lock_max_supply, LOCK_MINT_SUPPLY: row.lock_mint_supply, + LOCK_MINT: row.lock_mint, LOCK_MAX_MINT: row.lock_max_mint, + LOCK_DESCRIPTION: row.lock_description, LOCK_SLEEP: row.lock_sleep, LOCK_CALLBACK: row.lock_callback, + CALLBACK_TICK_ID: row.callback_tick_ref, CALLBACK_BLOCK: row.callback_block, CALLBACK_AMOUNT: row.callback_amount, + ALLOW_LIST: row.allow_list, BLOCK_LIST: row.block_list, + BRIDGE_CHAINS: row.bridge_chains, MIN_DEPTH: row.min_depth, LOCK_BRIDGE: row.lock_bridge, + MINT_ADDRESS_MAX: row.mint_address_max, MINT_START_BLOCK: row.mint_start_block, MINT_STOP_BLOCK: row.mint_stop_block, + }; +} + +// foldRow, verbatim in effect: a set LOCK_ never unsets, DECIMALS never drops, an empty +// value inherits. ACTION_INDEX is overwritten by every row (the source's first-issuance +// branch does not `continue`), so it ends on the LAST valid issue, which is the value the +// source's UPDATE writes to last_action_index. +function foldRow(data, arr){ + for(let key in arr){ + let value = arr[key]; + if(key === 'ACTION_INDEX' && isNull(data[key])) data[key] = value; + if(key.substr(0, 5) === 'LOCK_' && data[key] == 1) continue; + if(key === 'DECIMALS' && data[key] > value) continue; + if(isNull(value)) continue; + data[key] = value; + } +} + +// normalizeDataValues over the fold's keys. The fold carries no ACTION key, so the +// source's per-action text truncations never apply. +function normalize(input){ + let data = Object.assign({}, input); + for(let key in data) + if(!isNull(data[key]) && typeof data[key] === 'object' && !Buffer.isBuffer(data[key])) data[key] = String(data[key]); + for(let f of LIST_FIELDS) if(!isNull(data[f]) && !isNumeric(data[f])) data[f] = null; + for(let f of NUMBER_FIELDS) if(isNull(data[f]) || !isNumeric(data[f])) data[f] = null; + for(let f of INTEGER_FIELDS) if(!isNull(data[f]) && exceedsU64(data[f])) data[f] = null; + for(let f of LOCK_FIELDS){ + let v = data[f]; + if(typeof v === 'string' && isNumeric(v)) v = parseInt(v); + data[f] = ([0, 1].indexOf(v) === -1) ? null : v; + } + if(!isNull(data.DECIMALS) && (data.DECIMALS < MIN_TOKEN_DECIMALS || data.DECIMALS > MAX_TOKEN_DECIMALS)) data.DECIMALS = null; + return data; +} + +// createToken's fields(): the fold state rendered as the column values its UPDATE binds. +function tokenColumns(folded){ + let d = normalize(folded); + let num = (v) => (!isNull(v) && isNumeric(v)) ? v : 0; + let decimals = (!isNull(d.DECIMALS) && isNumeric(d.DECIMALS)) ? parseInt(d.DECIMALS) : 0; + let c = { + max_supply: num(d.MAX_SUPPLY), max_mint: num(d.MAX_MINT), mint_address_max: num(d.MINT_ADDRESS_MAX), + decimals: decimals, description: (d.DESCRIPTION === undefined) ? null : d.DESCRIPTION, + lock_max_supply: (d.LOCK_MAX_SUPPLY == 1) ? 1 : 0, lock_mint: (d.LOCK_MINT == 1) ? 1 : 0, + lock_mint_supply: (d.LOCK_MINT_SUPPLY == 1) ? 1 : 0, lock_max_mint: (d.LOCK_MAX_MINT == 1) ? 1 : 0, + lock_description: (d.LOCK_DESCRIPTION == 1) ? 1 : 0, lock_sleep: (d.LOCK_SLEEP == 1) ? 1 : 0, + lock_callback: (d.LOCK_CALLBACK == 1) ? 1 : 0, + callback_block: (d.CALLBACK_BLOCK > 0) ? d.CALLBACK_BLOCK : 0, + callback_tick_id: isNull(d.CALLBACK_TICK_ID) ? null : d.CALLBACK_TICK_ID, + callback_amount: num(d.CALLBACK_AMOUNT), + allow_list: (!isNull(d.ALLOW_LIST) && isNumeric(d.ALLOW_LIST)) ? parseInt(d.ALLOW_LIST) : null, + block_list: (!isNull(d.BLOCK_LIST) && isNumeric(d.BLOCK_LIST)) ? parseInt(d.BLOCK_LIST) : null, + mint_start_block: num(d.MINT_START_BLOCK), mint_stop_block: num(d.MINT_STOP_BLOCK), + bridge_chains: (!isNull(d.BRIDGE_CHAINS) && String(d.BRIDGE_CHAINS) !== '-') ? String(d.BRIDGE_CHAINS) : null, + min_depth: (!isNull(d.MIN_DEPTH) && isNumeric(d.MIN_DEPTH)) ? parseInt(d.MIN_DEPTH) : null, + lock_bridge: (d.LOCK_BRIDGE == 1) ? 1 : 0, + owner_id: isNull(d.OWNER_ID) ? null : d.OWNER_ID, + last_action_index: d.ACTION_INDEX, + }; + // The source formats the amount limits at the token's precision; callback_amount stays raw. + if(decimals >= MIN_TOKEN_DECIMALS && decimals <= MAX_TOKEN_DECIMALS){ + c.max_supply = bcformat(c.max_supply, decimals); + c.max_mint = bcformat(c.max_mint, decimals); + c.mint_address_max = bcformat(c.mint_address_max, decimals); + } + return c; +} + +// Fold rows already grouped in (tick_id, action_index) order into one column set per +// tick. Each set also carries first_action_index (not a FOLD_COLUMN, never written): the +// first surviving valid issue, which refoldTokenRows checks against tokens.action_index. +function foldIssueRows(rows){ + let byTick = new Map(); + let first = new Map(); + for(let row of (rows || [])){ + let key = String(row.tick_id); + if(!byTick.has(key)){ byTick.set(key, {}); first.set(key, row.action_index); } + foldRow(byTick.get(key), rowValues(row)); + } + let out = new Map(); + for(let [tick, data] of byTick) out.set(tick, Object.assign(tokenColumns(data), { first_action_index: first.get(tick) })); + return out; +} + +// rowsQuery for a set of ticks. The INNER JOINs are the source's, so an issue whose +// action, transaction, ticker, source address or status does not resolve drops out on +// both sides alike. +function foldRowsSql(n){ + return "SELECT i.tick_id, i.action_index, i.max_supply, i.max_mint, i.decimals, i.description, " + + "i.lock_max_supply, i.lock_mint_supply, i.lock_mint, i.lock_max_mint, i.lock_description, " + + "i.lock_sleep, i.lock_callback, i.callback_block, i.callback_amount, i.mint_address_max, " + + "i.mint_start_block, i.mint_stop_block, i.allow_list, i.block_list, i.bridge_chains, " + + "i.min_depth, i.lock_bridge, a2.id AS source_addr_id, a3.id AS transfer_addr_id, " + + "t3.id AS callback_tick_ref " + + "FROM issues i " + + "INNER JOIN actions a1 ON (a1.action_index=i.action_index) " + + "INNER JOIN transactions t1 ON (t1.tx_index=a1.tx_index) " + + "INNER JOIN index_tickers t2 ON (t2.id=i.tick_id) " + + "INNER JOIN index_addresses a2 ON (a2.id=a1.source_id) " + + "INNER JOIN index_statuses s1 ON (s1.id=i.status_id) " + + "LEFT JOIN index_addresses a3 ON (a3.id=i.transfer_id) " + + "LEFT JOIN index_tickers t3 ON (t3.id=i.callback_tick_id) " + + "WHERE s1.status='valid' AND i.tick_id IN (" + new Array(n).fill('?').join(',') + ") " + + "ORDER BY i.tick_id ASC, i.action_index ASC"; +} + +async function foldTicks(db, tickIds){ + let out = new Map(); + for(let i = 0; i < tickIds.length; i += CHUNK){ + let part = tickIds.slice(i, i + CHUNK); + let rows = await db.doQuery(foldRowsSql(part.length), part, null, { rethrow: true }); + for(let [k, v] of foldIssueRows(rows)) out.set(k, v); + } + return out; +} + +// Ticks named by any issues row in the orphaned range, valid or not, read BEFORE the +// dataTables delete removes those rows. The source's read phase reads `issues` with no +// status filter too, so both sides refresh the same set. An empty result makes the +// refold below a no-op, which is what a rollback that touched no ISSUE must be. +async function collectIssueTickIds(db, firstActionIndex){ + let rows = await db.doQuery( + "SELECT DISTINCT tick_id FROM issues WHERE action_index >= ? AND tick_id IS NOT NULL ORDER BY tick_id", + [firstActionIndex], null, { rethrow: true }); + return (rows || []).map(r => r.tick_id); +} + +// The surviving tokens rows' first-issuance action_index, by tick_id. +async function tokenAnchors(db, tickIds){ + let out = new Map(); + for(let i = 0; i < tickIds.length; i += CHUNK){ + let part = tickIds.slice(i, i + CHUNK); + let rows = await db.doQuery("SELECT tick_id, action_index FROM tokens WHERE tick_id IN (" + + new Array(part.length).fill('?').join(',') + ")", part, null, { rethrow: true }); + for(let r of (rows || [])) out.set(String(r.tick_id), String(r.action_index)); + } + return out; +} + +// Rewrite each collected tick's fold columns from its surviving valid issues. A tick +// with no surviving valid issue is skipped, as the source skips it (getTokenInfo +// returns false); its row, if it had one, went with the dataTables delete. UPDATE only: +// the replica never mints a tokens id the source did not stream. +// +// A tick is refolded only when its FIRST surviving valid issue is the row's own +// first issuance (tokens.action_index). Otherwise this replica does not hold the +// tick's whole history (a truncated SYNC_BOOTSTRAP_DEPTH replica keeps only +// [base..tip] of issues), and a fold over the tail would write a row the source never +// had. Those ticks are returned as skipped, left as they are, and reported by the +// caller; TOKEN_FOLD_PARITY_CHECK is what then shows whether they diverged. +async function refoldTokenRows(db, tickIds){ + let result = { refolded: 0, skipped: [] }; + if(!tickIds || tickIds.length === 0) return result; + let folded = await foldTicks(db, tickIds); + let anchors = await tokenAnchors(db, [...folded.keys()]); + let sets = FOLD_COLUMNS.map(c => c + '=?').join(', '); + for(let [tick, cols] of folded){ + if(!anchors.has(tick)) continue; + if(anchors.get(tick) !== String(cols.first_action_index)){ result.skipped.push(tick); continue; } + await db.doQuery("UPDATE tokens SET " + sets + " WHERE tick_id=?", + FOLD_COLUMNS.map(c => cols[c]).concat([tick]), null, { rethrow: true }); + result.refolded++; + } + return result; +} + +// Advisory parity digest input: every tokens row's fold columns (supply and the +// non-fold columns excluded, since they move outside the fold) in tick_id order. +// Rows whose last_action_index is above `maxActionIndex` are left out and returned by +// tick_id, so a source whose indexer has run past the published height can name the +// ticks it edited since, and the follower can leave out the same ones. +async function tokenFoldRows(db, maxActionIndex, excludeTickIds){ + let rows = await db.doQuery( + "SELECT tick_id, action_index, " + FOLD_COLUMNS.join(', ') + " FROM tokens ORDER BY tick_id ASC", + [], null, { rethrow: true }); + let skip = new Set((excludeTickIds || []).map(String)); + let kept = [], ahead = []; + for(let r of (rows || [])){ + let tick = String(r.tick_id); + let beyond = (maxActionIndex !== null && maxActionIndex !== undefined && r.last_action_index !== null && + BigInt(String(r.last_action_index)) > BigInt(String(maxActionIndex))); + if(beyond) ahead.push(tick); + if(beyond || skip.has(tick)) continue; + let o = { tick_id: tick, action_index: r.action_index === null ? null : String(r.action_index) }; + for(let c of FOLD_COLUMNS) o[c] = (r[c] === null || r[c] === undefined) ? null : String(r[c]); + kept.push(o); + } + return { rows: kept, ahead: ahead }; +} + +module.exports = { FOLD_COLUMNS, foldIssueRows, collectIssueTickIds, refoldTokenRows, tokenFoldRows }; diff --git a/src/server/updated_rows/token_rows.js b/src/server/updated_rows/token_rows.js index f68a2958..83f7e6c0 100644 --- a/src/server/updated_rows/token_rows.js +++ b/src/server/updated_rows/token_rows.js @@ -92,17 +92,18 @@ async function collectTokenEditRows(db, from, to, conn, acc){ // add()-by-action_index dedup are class 6's, so a tick reached by both classes in // one window emits once. // - // FORWARD ONLY, deliberately. A reorg that orphans an edit-ISSUE leaves no valid - // `issues` row behind for the tick, so nothing re-emits the row and the follower - // keeps the orphaned edit's values while the source re-folds back (rollback.js -> - // updateTokens). That reverse leg needs a replica-side re-derive beside the escrow - // one in ClientRollback and is not built here. + // FORWARD ONLY here. A reorg that orphans an edit-ISSUE leaves no valid `issues` + // row in any later window, so nothing re-emits the row while the source re-folds + // back (rollback/commit.js -> updateTokens). The reverse leg is replica-side: + // ClientRollback refolds every tick the orphaned range issued on from the surviving + // issues (src/db/token_refold.js), inside the rollback transaction. // // UN-GATED, like classes 5 and 5b: shipping a row is not a hash preimage, and no // state_hash class covers these columns (the token_supply twin hashes (tick, supply) - // only), so a follower that never receives the edit diverges silently instead of - // halting. That is the gap this closes, and it must be live before any future - // state-hash twin arms or a follower would halt on a row it was never sent. + // only). Both legs are instead watched by the advisory TOKEN_FOLD_PARITY_CHECK + // digest (BlockHasher.computeTokenFoldChecksum), which logs and counts a divergence + // but never halts. This carry must stay live before any future state-hash twin + // arms, or a follower would halt on a row it was never sent. try { let tokenRows = await db.doQuery( "SELECT t.* FROM `tokens` t WHERE t.tick_id IN (" + diff --git a/test/unit/client_rollback.test/07_token_refold.test.js b/test/unit/client_rollback.test/07_token_refold.test.js new file mode 100644 index 00000000..82d358e3 --- /dev/null +++ b/test/unit/client_rollback.test/07_token_refold.test.js @@ -0,0 +1,278 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// The reverse leg of updated_rows class 7: a reorg that orphans an ISSUE edit must leave +// the replica's tokens row equal to the source's refold from the surviving issues. Each +// scenario runs ClientRollback against an in-memory replica, checks the row against +// hand-written expectations, and, when the xchain-indexer sibling is present, against +// the indexer's OWN getTokenInfo + createToken run over the same surviving issues. + +'use strict'; + +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +const sinon = require('sinon'); +const ClientRollback = require('../../../src/client/rollback'); +const BlockHasher = require('../../../src/client/block_hasher'); +const Utility = require('../../../src/util'); +const tokenRefold = require('../../../src/db/token_refold'); +const { siblingCheckout, skipOrFail } = require('../../helpers/sibling_checkout.js'); + +const ADDRESSES = { 1: 'addrAlice', 2: 'addrBob', 3: 'addrCarol' }; +const TICKERS = { 10: 'TOKA', 11: 'CBTICK' }; +const TICK = 10; + +function issue(action_index, fields){ + return Object.assign({ + tick_id: TICK, action_index, status: 'valid', source_addr_id: 1, transfer_addr_id: null, + callback_tick_ref: null, max_supply: '', max_mint: '', decimals: '', description: '', + lock_max_supply: '', lock_mint_supply: '', lock_mint: '', lock_max_mint: '', + lock_description: '', lock_sleep: '', lock_callback: '', callback_block: '', + callback_amount: '', mint_address_max: '', mint_start_block: '', mint_stop_block: '', + allow_list: null, block_list: null, bridge_chains: '', min_depth: '', lock_bridge: '', + }, fields); +} + +const GENESIS = issue(100, { decimals: '8', max_supply: '1000', max_mint: '10', description: 'first', + lock_max_supply: '0', lock_mint: '0', lock_description: '0', mint_start_block: '50', + mint_stop_block: '900', bridge_chains: 'LTC', min_depth: '6', lock_bridge: '0' }); +const SURVIVING_EDIT = issue(300, { description: 'second', callback_tick_ref: 11, + callback_block: '700', callback_amount: '1.5', allow_list: 42 }); + +// The replica row as the orphaned edit left it (forward class 7 carried it). +function editedRow(over){ + return Object.assign({ tick_id: TICK, action_index: 100, supply: '5', bridged: 0, escrow_action_index: null, + max_supply: '1000.00000000', max_mint: '10.00000000', decimals: 8, description: 'second', + lock_max_supply: 0, lock_mint: 0, lock_mint_supply: 0, lock_max_mint: 0, lock_description: 0, + lock_sleep: 0, lock_callback: 0, callback_block: '700', callback_tick_id: 11, callback_amount: '1.5', + allow_list: 42, block_list: null, mint_address_max: '0.00000000', mint_start_block: '50', + mint_stop_block: '900', bridge_chains: 'LTC', min_depth: 6, lock_bridge: 0, owner_id: 1, + last_action_index: 500 }, over); +} + +// What the source's refold writes after the reorg drops the edit at 500. +const EXPECTED = editedRow({ last_action_index: 300 }); + +// An in-memory replica answering exactly the statements the refold path issues. +function replica(issues, tokenRow, firstActionIndex){ + let world = { issues: issues.slice(), token: Object.assign({}, tokenRow), updates: 0 }; + let doQuery = sinon.stub().callsFake(async (sql, args) => { + if(/^SELECT DISTINCT tick_id FROM issues WHERE action_index >= \?/.test(sql)) + return [...new Set(world.issues.filter(i => i.action_index >= args[0]).map(i => i.tick_id))].map(t => ({ tick_id: t })); + if(sql === 'DELETE FROM `issues` WHERE action_index >= ?'){ + world.issues = world.issues.filter(i => i.action_index < args[0]); + return { affectedRows: 1 }; + } + if(/FROM issues i INNER JOIN actions a1/.test(sql)) + return world.issues.filter(i => i.status === 'valid' && args.map(Number).includes(i.tick_id)) + .sort((a, b) => (a.tick_id - b.tick_id) || (a.action_index - b.action_index)); + if(/^SELECT tick_id, action_index FROM tokens WHERE tick_id IN/.test(sql)) + return args.map(Number).includes(world.token.tick_id) ? [{ tick_id: world.token.tick_id, action_index: world.token.action_index }] : []; + if(/^UPDATE tokens SET max_supply=\?/.test(sql)){ + world.updates++; + if(Number(args[args.length - 1]) === world.token.tick_id) + tokenRefold.FOLD_COLUMNS.forEach((c, k) => { world.token[c] = args[k]; }); + return { affectedRows: 1 }; + } + return []; + }); + let db = { doQuery, getFirstActionIndex: sinon.stub().resolves(firstActionIndex), + getStatusId: sinon.stub().resolves(null), beginTransaction: sinon.stub().resolves(), + commitTransaction: sinon.stub().resolves(), rollbackTransaction: sinon.stub().resolves() }; + return { db, world }; +} + +// Storage view of a bound value: every fold column is VARCHAR or an integer column, so +// String() is what the row holds, and undefined binds as NULL. +function stored(row){ + let o = {}; + for(let c of tokenRefold.FOLD_COLUMNS) o[c] = (row[c] === null || row[c] === undefined) ? null : String(row[c]); + return o; +} + +// ── The indexer's own fold, loaded from the sibling checkout ────────────────── +function indexerRoot(){ + if(process.env.XCHAIN_INDEXER_SQL_PATH) return { usable: true, path: path.resolve(process.env.XCHAIN_INDEXER_SQL_PATH, '..', '..'), reason: null }; + return siblingCheckout(__dirname, '../../../../xchain-indexer'); +} + +// Runs getTokenInfo + createToken exactly as updateTokenInfo does, over a stub whose +// doQuery answers the replay SELECT with the surviving issues in the source's row shape, +// and returns the UPDATE's bound values keyed by FOLD_COLUMNS. +async function sourceFold(root, issues){ + // Runtime path: the sibling root is resolved per run (env override or sibling checkout). + let req = (rel) => require(path.join(root, rel)); + let config = {}; + req('src/config/wire_fields.js').applyWireFields(config); + req('src/config/token_limits.js').applyTokenSupplyLimits(config); + let util = Object.assign({ safeToString: (v) => (v === null || v === undefined) ? null : String(v) }, + req('src/utility/value_checks.js'), req('src/utility/bcmath.js')); + let byName = (map, v) => { let k = Object.keys(map).find(id => map[id] === v); return k === undefined ? null : Number(k); }; + let captured = null; + let db = Object.assign({ util, config }, req('src/db/issues/token_info.js'), + req('src/db/tokens/token_writer.js'), req('src/db/database/normalize.js')); + db.createTicker = async (t) => util.isNull(t) ? null : byName(TICKERS, t); + db.createAddress = async (a) => util.isNull(a) ? null : byName(ADDRESSES, a); + db.getTokenSupply = async () => '5'; + db.doQuery = async (sql, args) => { + if(/FROM\s+issues i/.test(sql)) return issues.filter(i => i.status === 'valid').map(i => Object.assign({}, i, { + tick: TICKERS[i.tick_id], callback_tick: i.callback_tick_ref ? TICKERS[i.callback_tick_ref] : null, + owner: ADDRESSES[i.source_addr_id], transfer: i.transfer_addr_id ? ADDRESSES[i.transfer_addr_id] : null, + bridged: 0, block_index: 1 })); + if(/SELECT id FROM tokens/.test(sql)) return [{ id: 1 }]; + if(/^\s*UPDATE\s+tokens/.test(sql)){ captured = args; return {}; } + return []; + }; + let data = await db.getTokenInfo(TICKERS[TICK]); + await db.createToken(data); + // updateArgs order: the 22 fold columns, supply, owner_id, last_action_index, tick_id. + let cols = tokenRefold.FOLD_COLUMNS.filter(c => c !== 'owner_id' && c !== 'last_action_index'); + let out = {}; + cols.forEach((c, k) => { out[c] = captured[k]; }); + out.owner_id = captured[23]; + out.last_action_index = captured[24]; + return out; +} + +async function reorg(edit, rowAfterEdit){ + let { db, world } = replica([GENESIS, SURVIVING_EDIT, edit], rowAfterEdit, 500); + await new ClientRollback(db, new Utility(), 'BTC', 'regtest').rollback(1000); + return world; +} + +function indexerOrSkip(ctx){ + let root = indexerRoot(); + let ok = root.usable && fs.existsSync(path.join(root.path, 'src/db/issues/token_info.js')); + if(!skipOrFail(ctx, ok ? root : { usable: false, reason: root.reason || 'token_info.js absent' }, 'the indexer fold parity guard')) return null; + return root.path; +} + +describe('ClientRollback token refold (reverse leg of updated_rows class 7)', function(){ + + beforeEach(function(){ sinon.stub(console, 'log'); sinon.stub(console, 'error'); }); + afterEach(function(){ sinon.restore(); }); + + const SCENARIOS = { + 'ownership transfer': [issue(500, { transfer_addr_id: 2 }), editedRow({ owner_id: 2 })], + 'lock edit': [issue(500, { lock_mint: '1', lock_description: '1', lock_callback: '1' }), + editedRow({ lock_mint: 1, lock_description: 1, lock_callback: 1 })], + 'bridge-policy edit': [issue(500, { bridge_chains: '-', min_depth: '12', lock_bridge: '1' }), + editedRow({ bridge_chains: null, min_depth: 12, lock_bridge: 1 })], + 'callback, list and mint-window edit': [ + issue(500, { callback_block: '999', callback_amount: '2', block_list: 7, mint_stop_block: '5000', max_mint: '20' }), + editedRow({ callback_block: '999', callback_amount: '2', block_list: 7, mint_stop_block: '5000', max_mint: '20.00000000' })], + }; + + for(let name of Object.keys(SCENARIOS)){ + it('a reorg of the ' + name + ' leaves the replica row equal to the refold of the surviving issues', async function(){ + let [edit, row] = SCENARIOS[name]; + let world = await reorg(edit, row); + assert.strictEqual(world.updates, 1); + assert.deepStrictEqual(stored(world.token), stored(EXPECTED)); + // Columns the fold does not own are left alone (supply is recomputed separately). + assert.strictEqual(world.token.supply, '5'); + assert.strictEqual(world.token.action_index, 100); + assert.strictEqual(world.token.bridged, 0); + }); + + it('a reorg of the ' + name + ' matches the xchain-indexer fold byte for byte', async function(){ + let root = indexerOrSkip(this); + if(!root) return; + let [edit, row] = SCENARIOS[name]; + let world = await reorg(edit, row); + let source = await sourceFold(root, [GENESIS, SURVIVING_EDIT]); + assert.deepStrictEqual(stored(world.token), stored(source)); + }); + } +}); + +describe('ClientRollback token refold: no-op and failure paths', function(){ + + beforeEach(function(){ sinon.stub(console, 'log'); sinon.stub(console, 'error'); }); + afterEach(function(){ sinon.restore(); }); + + it('a rollback touching no ISSUE issues no tokens UPDATE and changes nothing', async function(){ + let row = editedRow({ last_action_index: 300 }); + let { db, world } = replica([GENESIS, SURVIVING_EDIT], row, 500); + await new ClientRollback(db, new Utility(), 'BTC', 'regtest').rollback(1000); + assert.strictEqual(world.updates, 0); + assert.deepStrictEqual(world.token, row); + assert.ok(!db.doQuery.getCalls().some(c => /FROM issues i INNER JOIN/.test(c.args[0]))); + }); + + it('a tick whose only issues were orphaned is left to the generic delete (no UPDATE)', async function(){ + let { db, world } = replica([issue(600, { decimals: '0' })], editedRow({}), 500); + await new ClientRollback(db, new Utility(), 'BTC', 'regtest').rollback(1000); + assert.strictEqual(world.updates, 0); + }); + + it('a replica without the tick\'s first issuance (truncated) skips the refold instead of folding a tail', async function(){ + let row = editedRow({ owner_id: 2 }); + let { db, world } = replica([SURVIVING_EDIT, issue(500, { transfer_addr_id: 2 })], row, 500); + await new ClientRollback(db, new Utility(), 'BTC', 'regtest').rollback(1000); + assert.strictEqual(world.updates, 0); + assert.deepStrictEqual(world.token, row); + }); + + it('a failed refold aborts the rollback transaction', async function(){ + let { db } = replica([GENESIS, issue(500, { transfer_addr_id: 2 })], editedRow({}), 500); + let inner = db.doQuery; + db.doQuery = sinon.stub().callsFake(async (sql, args) => { + if(/^UPDATE tokens SET max_supply=\?/.test(sql)){ let e = new Error('lock wait'); e.errno = 1205; throw e; } + return inner(sql, args); + }); + await assert.rejects(new ClientRollback(db, new Utility(), 'BTC', 'regtest').rollback(1000), /lock wait/); + assert.ok(db.rollbackTransaction.calledOnce); + assert.ok(db.commitTransaction.notCalled); + }); +}); + +describe('foldIssueRows', function(){ + it('keeps a set lock, never lowers decimals, and inherits empty fields', function(){ + let out = tokenRefold.foldIssueRows([ + issue(1, { decimals: '8', lock_mint: '1', description: 'a', max_supply: '5' }), + issue(2, { decimals: '2', lock_mint: '0', description: '' }), + ]).get(String(TICK)); + assert.strictEqual(out.lock_mint, 1); + assert.strictEqual(out.decimals, 8); + assert.strictEqual(out.description, 'a'); + assert.strictEqual(out.max_supply, '5.00000000'); + assert.strictEqual(out.last_action_index, 2); + }); +}); + +describe('BlockHasher.computeTokenFoldChecksum (advisory token fold parity)', function(){ + function hasherOver(rows, maxAction){ + let db = { doQuery: sinon.stub().callsFake(async (sql) => { + if(/MAX\(action_index\)/.test(sql)) return [{ m: maxAction }]; + if(/FROM tokens ORDER BY tick_id/.test(sql)) return rows.map(r => Object.assign({}, r)); + return []; + }) }; + return new BlockHasher(db, new Utility()); + } + const A = Object.assign(editedRow({ last_action_index: 300 }), { tick_id: 10 }); + const B = Object.assign(editedRow({ last_action_index: 800 }), { tick_id: 12 }); + + it('agrees when the follower holds the source rows, leaving out ticks the source edited past the height', async function(){ + let source = await hasherOver([A, B], 500).computeTokenFoldChecksum(1000); + assert.deepStrictEqual(source.ahead, ['12']); + let follower = await hasherOver([A, Object.assign({}, B, { last_action_index: 400, owner_id: 3 })], 999) + .computeTokenFoldChecksum(1000, { exclude: source.ahead }); + assert.strictEqual(follower.h, source.h); + }); + + it('detects a replica that kept an orphaned edit', async function(){ + let source = await hasherOver([A], 500).computeTokenFoldChecksum(1000); + let follower = await hasherOver([editedRow({ owner_id: 2, last_action_index: 500 })], 500) + .computeTokenFoldChecksum(1000, { exclude: source.ahead }); + assert.notStrictEqual(follower.h, source.h); + }); +}); From 2b356a907f9cd4cc6c928d7bfd175f3091bd7458 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 11:12:01 -0700 Subject: [PATCH 61/62] fix(consensus): re-slide DOGE:testnet mirror admission to the 84h trailing mean Operator ruling D11 (a) 2026-09-23: producer moves from 67942777 to 67936053 and consumer from 67944741 to 67936888, the same-instant rule computed off the 84h trailing block-time mean. BTC:testnet heights are unchanged; their comments now also carry the 84h trailing mean cadence bound. Byte-identical to the other five shared-row carriers; sync's own bin/pins/identity.json does not track this gate and is unchanged. --- src/consensus/gate_registry/shared_rows_2.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index 2b87dd32..0063a165 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -216,9 +216,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 154234, // RE-SLID 2026-09-23: train 154,074 + 160 blocks (17 h at 383.04 s/blk), the v0.20.1 patch reslide + 'BTC:testnet': 154234, // RE-SLID 2026-09-23: train 154,074 + 160 blocks (17 h at the 383.04 s/blk bound, 25.6 h at the 575.89 s/blk 84 h trailing mean), the v0.20.1 patch reslide 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67942777, // RE-SLID 2026-09-23: tip 67,924,122 at 15:55Z + 18655 blocks (57 h at 11 s/blk median, the same instant as the BTC producer), the v0.20.1 patch reslide + 'DOGE:testnet': 67936053, // RE-SLID 2026-09-23: tip 67,924,397 at 17:48Z + 11656 blocks (83.8 h at 25.89 s/blk, the 84 h trailing mean, the same instant as the BTC producer), the v0.20.1 patch reslide 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -228,9 +228,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 154291, // RE-SLID 2026-09-23: its producer + 57 blocks (6 h at 383.04 s/blk), strictly above, never equal + 'BTC:testnet': 154291, // RE-SLID 2026-09-23: its producer + 57 blocks (6 h at the 383.04 s/blk bound, 9.1 h at the 575.89 s/blk 84 h trailing mean), strictly above, never equal 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67944741, // RE-SLID 2026-09-23: its producer + 1964 blocks (6 h at 11 s/blk median) + 'DOGE:testnet': 67936888, // RE-SLID 2026-09-23: its producer + 835 blocks (6 h at 25.89 s/blk, the 84 h trailing mean), strictly above, never equal 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration From 516affd6245ff93140f3c1a2af7856d71c16291f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 17:50:40 -0700 Subject: [PATCH 62/62] Pin the Node base image by digest to the consensus runtime The floating node:22-bookworm tag can move to a Node patch whose V8/ICU no longer match xchain-vm's consensus runtime pin, so validators built from it would fail to join the fleet. Pinning by digest to a measured match removes that risk. --- Dockerfile | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index b6d7575f..1a2ca0e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,11 @@ -# Pinned to node:22-bookworm, the tag .nvmrc and package.json engines already -# declare and the sibling service images already build on. `node:latest` floats: -# xchain-node rebuilds this image on every update (ModuleService.buildAndUp), so -# a routine rolling upgrade silently moves the runtime off the declared Node 22 -# with no signal anywhere. -FROM node:22-bookworm +# Pinned by digest to the node:22.23.2-bookworm image whose V8/ICU build match +# xchain-vm's consensus runtime pin: the floating node:22-bookworm tag moved to +# a Node 22 patch whose V8/ICU no longer match, so validators built from it +# would fail checkConsensusRuntime(). `node:latest` floats too: xchain-node +# rebuilds this image on every update (ModuleService.buildAndUp), so a routine +# rolling upgrade silently moves the runtime off the declared Node 22 with no +# signal anywhere. +FROM node:22.23.2-bookworm@sha256:dd5847a04b0deee391fa145f1f4c6d214196668b6bcc7988ebed67249f226844 RUN mkdir /XChainIndexerSync/ COPY ./package.json /XChainIndexerSync/package.json