From 4e3e1786817f8a55fce68c92472fe7e82ab68a6b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 09:49:04 -0700 Subject: [PATCH 01/15] ci: sibling checkouts follow the pull request's base branch, keeping push, release and hotfix refs unchanged --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3a03c6..41638bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: # touch neither the guard nor the file it reads. Empty everywhere # else, which keeps the existing behaviour. Same expression as # xchain-indexer and xchain-explorer. - 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 || '' }} # Cross-repo drift guards: this repo vendors the canonical coin registry from # xchain-hub, and per-repo CI cannot see sibling repos, so a drifted vendored @@ -47,7 +47,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 @@ -134,7 +134,7 @@ jobs: # whose cross-repo guards disagree with the gate for a reason that exists # nowhere but in CI. 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 12de40b9eafa7e465590cf929a64aff64e4ef62f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 11:27:01 -0700 Subject: [PATCH 02/15] test: split decoder halt diagnostics --- bin/pins/suite-title-splits.json | 6 + test/unit/decoder_halt_diagnostics.test.js | 214 +----------------- .../01_reorg_halt_records.test.js | 132 +++++++++++ .../02_health_probe_records.test.js | 117 ++++++++++ .../03_db_cleanup_record.test.js | 27 +++ 5 files changed, 292 insertions(+), 204 deletions(-) create mode 100644 test/unit/decoder_halt_diagnostics.test/01_reorg_halt_records.test.js create mode 100644 test/unit/decoder_halt_diagnostics.test/02_health_probe_records.test.js create mode 100644 test/unit/decoder_halt_diagnostics.test/03_db_cleanup_record.test.js diff --git a/bin/pins/suite-title-splits.json b/bin/pins/suite-title-splits.json index 78df576..f4e5ccf 100644 --- a/bin/pins/suite-title-splits.json +++ b/bin/pins/suite-title-splits.json @@ -63,6 +63,12 @@ "test/unit/dispenser_cancel_grace.test.js", "test/unit/dispenser_cancel_grace.test/01_database_get_all_open_dispenser_addresses_grace_floor.test.js" ], + "test/unit/decoder_halt_diagnostics.test.js": [ + "test/unit/decoder_halt_diagnostics.test.js", + "test/unit/decoder_halt_diagnostics.test/01_reorg_halt_records.test.js", + "test/unit/decoder_halt_diagnostics.test/02_health_probe_records.test.js", + "test/unit/decoder_halt_diagnostics.test/03_db_cleanup_record.test.js" + ], "test/e2e/action_decoding.test.js": [ "test/e2e/action_decoding.test.js", "test/e2e/action_decoding.test/01_encoding_types.test.js", diff --git a/test/unit/decoder_halt_diagnostics.test.js b/test/unit/decoder_halt_diagnostics.test.js index 294eabc..4848045 100644 --- a/test/unit/decoder_halt_diagnostics.test.js +++ b/test/unit/decoder_halt_diagnostics.test.js @@ -30,17 +30,20 @@ const { resetProbeLogState, ageProbeLogState, PROBE_LOG_WINDOW_MS } = require('../../src/api') const observability = require('../../src/observability') +const registerReorgHaltRecords = require('./decoder_halt_diagnostics.test/01_reorg_halt_records.test') +const registerHealthProbeRecords = require('./decoder_halt_diagnostics.test/02_health_probe_records.test') +const registerDbCleanupRecord = require('./decoder_halt_diagnostics.test/03_db_cleanup_record.test') // DISPENSER_EXPIRE_SAFE_DEPTH, the rollback ceiling verifyReorg aborts at. const SAFE_DEPTH = 126 -let sink +const sink = { lines: [] } // getLogger() routes to whatever shipper the process installed, so a capture // sink on that shipper sees the formatted line with its fields. function installSink() { observability._resetObservability() - sink = { lines: [] } + sink.lines = [] const push = (m) => sink.lines.push(m) observability.installObservability(null, { service: 'xchain-decoder', env: {}, @@ -87,110 +90,7 @@ describe('REORG_HALT: a halt the marker cannot record still leaves a record', fu beforeEach(function () { installSink() }) afterEach(function () { observability._resetObservability() }) - it('emits REORG_HALT with reason and depth when db.markReorgHalted is missing', async function () { - // The db deliberately has no markReorgHalted: this is the bare return. - const decoder = haltingDecoder({}) - await assert.rejects(() => decoder.verifyReorg(NODE_TIP), /safe-depth/) - - const halts = linesFor('REORG_HALT') - assert.strictEqual(halts.length, 1, 'the halt must produce exactly one record') - const line = halts[0] - assert.ok(line.includes(' error '), 'REORG_HALT is an error-level event: ' + line) - assert.ok(line.includes('coin=BTC'), 'the record must name the coin: ' + line) - assert.ok(line.includes('network=regtest'), 'the record must name the network: ' + line) - assert.ok(line.includes('depth=' + SAFE_DEPTH), - 'the record must carry the depth it was about to persist: ' + line) - assert.ok(/reason="[^"]*safe-depth[^"]*"/.test(line), - 'the record must carry the reason it was about to persist: ' + line) - assert.ok(line.includes('marker_write=unavailable'), - 'the record must say the marker could not be written: ' + line) - assert.ok(line.includes('/status') && line.includes('/live'), - 'the record must say which surfaces will NOT report the halt: ' + line) - // Nothing was attempted, so nothing may report an outcome. - assert.strictEqual(linesFor('REORG_HALT_MARKER').length, 0) - assert.strictEqual(decoder.getReorgHaltStatus().marker_persisted, false) - }) - - it('still emits REORG_HALT on the normal path, and says the marker was written', async function () { - let marked = null - // The db contract markReorgHalted answers on: TRUE only once a REORG_HALT row - // is readable. A stub returning undefined would be a stub asserting a write it - // never confirmed, which is the exact defect these cases exist for. - const decoder = haltingDecoder({ markReorgHalted: async (r) => { marked = r; return true } }) - await assert.rejects(() => decoder.verifyReorg(NODE_TIP), /safe-depth/) - - const halts = linesFor('REORG_HALT') - assert.strictEqual(halts.length, 1) - assert.ok(halts[0].includes('marker_write=attempting'), halts[0]) - // The pre-write record cannot know the outcome, so it must not claim one. - assert.ok(!halts[0].includes('marker_persisted='), - 'the pre-write record must not assert persistence: ' + halts[0]) - - const outcome = linesFor('REORG_HALT_MARKER') - assert.strictEqual(outcome.length, 1, 'the write outcome must produce exactly one record') - assert.ok(outcome[0].includes('marker_persisted=true'), outcome[0]) - assert.ok(outcome[0].includes('attempts=1'), outcome[0]) - assert.ok(marked && /safe-depth/.test(marked), 'the durable marker is still written') - assert.strictEqual(decoder.getReorgHaltStatus().marker_persisted, true) - }) - - // The failure the bootstrap gate exists to stop: the marker write fails, the - // process exits, the restart policy recycles the container, the entry guard reads - // a row that was never written, and the gate counts zero markers and publishes the - // database as known-good. Before this, insertEvent swallowed the write error and - // returned false, markReorgHalted handed that straight back, haltReorg discarded - // it, and the one structured record said marker_persisted=true regardless. - it('reports marker_persisted=false when the durable write is refused, and still aborts', async function () { - let attempts = 0 - const decoder = haltingDecoder({ markReorgHalted: async () => { attempts++; return false } }) - await assert.rejects(() => decoder.verifyReorg(NODE_TIP), /safe-depth/, - 'a marker failure must never mask or replace the abort') - - const outcome = linesFor('REORG_HALT_MARKER') - assert.strictEqual(outcome.length, 1) - assert.ok(outcome[0].includes('marker_persisted=false'), - 'a refused write must never report as persisted: ' + outcome[0]) - assert.strictEqual(attempts, 2, 'a refused write is retried once on a fresh connection') - assert.ok(outcome[0].includes('attempts=2'), outcome[0]) - assert.strictEqual(decoder.getReorgHaltStatus().marker_persisted, false) - assert.strictEqual(decoder.getReorgHaltStatus().halted, true) - - // Read the operator line off the SINK, not off console.error. The halt - // path now goes through the one logger like everything else, so the - // shipper this suite already installs is where the line lands; a - // console capture would see nothing and report the line as missing. - const critical = sink.lines.filter((l) => l.includes('could NOT be persisted')) - assert.strictEqual(critical.length, 1, - 'the only live evidence of an unrecorded halt must be logged: ' + JSON.stringify(sink.lines)) - assert.ok(/full resync/i.test(critical[0]), - 'the line must name the required operator action: ' + critical[0]) - assert.ok(/not a valid bootstrap source/i.test(critical[0]), critical[0]) - }) - - it('carries the cause when the marker write throws rather than returning false', async function () { - const realError = console.error - console.error = () => {} - try { - const decoder = haltingDecoder({ - markReorgHalted: async () => { throw new Error('lost connection to server') } - }) - await assert.rejects(() => decoder.verifyReorg(NODE_TIP), /safe-depth/) - const outcome = linesFor('REORG_HALT_MARKER') - assert.strictEqual(outcome.length, 1) - assert.ok(outcome[0].includes('marker_persisted=false'), outcome[0]) - assert.ok(outcome[0].includes('lost connection to server'), - 'the cause must ride the record: ' + outcome[0]) - } finally { - console.error = realError - } - }) - - it('reports the halt in memory even when nothing durable can be written', async function () { - const decoder = haltingDecoder({}) - await assert.rejects(() => decoder.verifyReorg(NODE_TIP), /safe-depth/) - assert.strictEqual(decoder.reorgHalted, true) - assert.match(decoder.reorgHaltReason, /safe-depth/) - }) + registerReorgHaltRecords({ assert, haltingDecoder, NODE_TIP, SAFE_DEPTH, linesFor, sink }) }) describe('health probes: a failing probe stops being silent', function () { @@ -234,91 +134,9 @@ describe('health probes: a failing probe stops being silent', function () { return decoder } - it('names the db_ping probe on /live when the ping throws', async function () { - const decoder = probeDecoder() - decoder.db = { ping: async () => { throw new Error('pool timeout acquiring connection') } } - - const res = await getLive(liveApp(decoder)) - // Control: the route still answers, with the code it always answered. - assert.strictEqual(res.status, 503) - assert.strictEqual(res.body.db, false) - - const warned = linesFor('HEALTH_PROBE_FAILED') - assert.strictEqual(warned.length, 1, 'the failure must produce one record') - assert.ok(warned[0].includes(' warn '), warned[0]) - assert.ok(warned[0].includes('probe=db_ping'), warned[0]) - assert.ok(warned[0].includes('route=/live'), warned[0]) - assert.ok(warned[0].includes('pool timeout'), 'the cause rides the record: ' + warned[0]) - }) - - it('names the reorg_halt probe on /live, the failure that makes a halted decoder read clean', async function () { - const decoder = probeDecoder() - decoder.checkReorgHalt = async () => { throw new Error('events table is gone') } - - const res = await getLive(liveApp(decoder)) - // The wrong-but-alive shape this exists for: the route reports no halt - // because it could not ask, and that is now the difference between a - // silent lie and a warned one. - assert.strictEqual(res.status, 200) - assert.strictEqual(res.body.reorg_halted, false) - - const warned = linesFor('HEALTH_PROBE_FAILED') - assert.strictEqual(warned.length, 1) - assert.ok(warned[0].includes('probe=reorg_halt'), warned[0]) - assert.ok(warned[0].includes('route=/live'), warned[0]) - assert.ok(warned[0].includes('events table is gone'), warned[0]) - }) - - it('throttles a repeating probe failure to one line per window and counts the rest', async function () { - const decoder = probeDecoder() - decoder.db = { ping: async () => { throw new Error('pool timeout') } } - const app = liveApp(decoder) - - for (let i = 0; i < 5; i++) await getLive(app) - assert.strictEqual(linesFor('HEALTH_PROBE_FAILED').length, 1, - 'a caller-driven route must not turn one outage into one line per request') - - // Age the window rather than sleeping through it, so the suppressed count - // the next line has to report survives. - ageProbeLogState() - await getLive(app) - const warned = linesFor('HEALTH_PROBE_FAILED') - assert.strictEqual(warned.length, 2) - assert.ok(warned[1].includes('suppressed=4'), - 'a throttled flood must stay countable, not merely quiet: ' + warned[1]) - assert.ok(PROBE_LOG_WINDOW_MS > 0, 'the window is a real duration, not a disabled guard') - }) - - it('carries a cause even when the probe threw something that is not an Error', function () { - noteProbeFailure('db_ping', '/status', 'ECONNREFUSED') - const warned = linesFor('HEALTH_PROBE_FAILED') - assert.strictEqual(warned.length, 1) - assert.ok(warned[0].includes('err=ECONNREFUSED'), warned[0]) - }) - - it('answers null instead of throwing when the error itself cannot be read', function () { - // A diagnostic that throws inside a health route would turn a reportable - // probe failure into a 500 on the route the healthcheck polls. - const hostile = { get message() { throw new Error('unreadable') } } - assert.strictEqual(noteProbeFailure('db_ping', '/live', hostile), null) - assert.strictEqual(linesFor('HEALTH_PROBE_FAILED').length, 0) - }) - - it('keeps the two probes on separate throttles, so one failure cannot mask the other', async function () { - const decoder = probeDecoder() - decoder.db = { ping: async () => { throw new Error('pool timeout') } } - decoder.checkReorgHalt = async () => { throw new Error('events table is gone') } - - await getLive(liveApp(decoder)) - // db_ping fails first, so dbOk is false and the halt probe is not reached - // on this route. Drive the halt probe with a working ping. - decoder.db = { ping: async () => true } - await getLive(liveApp(decoder)) - - const warned = linesFor('HEALTH_PROBE_FAILED') - assert.strictEqual(warned.length, 2) - assert.ok(warned.some((l) => l.includes('probe=db_ping'))) - assert.ok(warned.some((l) => l.includes('probe=reorg_halt'))) + registerHealthProbeRecords({ + assert, probeDecoder, getLive, liveApp, linesFor, + ageProbeLogState, PROBE_LOG_WINDOW_MS, noteProbeFailure }) }) @@ -346,19 +164,7 @@ describe('db: a failed temp-table drop stops being silent', function () { return { getConnection: async () => conn } } - it('records the drop failure with the table and the cause', async function () { - const db = new Database('127.0.0.1', 3306, 'xchain_btc_regtest', 'u', 'p') - db.pool = poolWhoseDropFails() - - // Control: the drop is cleanup, so the call still returns its result. - const r = await db.deleteAndCompareTxsNotInList([]) - assert.strictEqual(r.transactionsDeleted, 0) - - const warned = linesFor('DB_TEMP_TABLE_DROP_FAILED') - assert.strictEqual(warned.length, 1) - assert.ok(warned[0].includes('table=_mempool_node_snapshot'), warned[0]) - assert.ok(warned[0].includes('lost connection to server'), warned[0]) - }) + registerDbCleanupRecord({ assert, Database, poolWhoseDropFails, linesFor }) }) // api.js registers GET /status inside startApi(), which builds a real decoder and diff --git a/test/unit/decoder_halt_diagnostics.test/01_reorg_halt_records.test.js b/test/unit/decoder_halt_diagnostics.test/01_reorg_halt_records.test.js new file mode 100644 index 0000000..166f495 --- /dev/null +++ b/test/unit/decoder_halt_diagnostics.test/01_reorg_halt_records.test.js @@ -0,0 +1,132 @@ +// 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. + +function registerAvailableMarkerRecords(context) { + const { assert, haltingDecoder, NODE_TIP, SAFE_DEPTH, linesFor } = context + + it('emits REORG_HALT with reason and depth when db.markReorgHalted is missing', async function () { + // The db deliberately has no markReorgHalted: this is the bare return. + const decoder = haltingDecoder({}) + await assert.rejects(() => decoder.verifyReorg(NODE_TIP), /safe-depth/) + + const halts = linesFor('REORG_HALT') + assert.strictEqual(halts.length, 1, 'the halt must produce exactly one record') + const line = halts[0] + assert.ok(line.includes(' error '), 'REORG_HALT is an error-level event: ' + line) + assert.ok(line.includes('coin=BTC'), 'the record must name the coin: ' + line) + assert.ok(line.includes('network=regtest'), 'the record must name the network: ' + line) + assert.ok(line.includes('depth=' + SAFE_DEPTH), + 'the record must carry the depth it was about to persist: ' + line) + assert.ok(/reason="[^"]*safe-depth[^"]*"/.test(line), + 'the record must carry the reason it was about to persist: ' + line) + assert.ok(line.includes('marker_write=unavailable'), + 'the record must say the marker could not be written: ' + line) + assert.ok(line.includes('/status') && line.includes('/live'), + 'the record must say which surfaces will NOT report the halt: ' + line) + // Nothing was attempted, so nothing may report an outcome. + assert.strictEqual(linesFor('REORG_HALT_MARKER').length, 0) + assert.strictEqual(decoder.getReorgHaltStatus().marker_persisted, false) + }) + + it('still emits REORG_HALT on the normal path, and says the marker was written', async function () { + let marked = null + // The db contract markReorgHalted answers on: TRUE only once a REORG_HALT row + // is readable. A stub returning undefined would be a stub asserting a write it + // never confirmed, which is the exact defect these cases exist for. + const decoder = haltingDecoder({ markReorgHalted: async (r) => { marked = r; return true } }) + await assert.rejects(() => decoder.verifyReorg(NODE_TIP), /safe-depth/) + + const halts = linesFor('REORG_HALT') + assert.strictEqual(halts.length, 1) + assert.ok(halts[0].includes('marker_write=attempting'), halts[0]) + // The pre-write record cannot know the outcome, so it must not claim one. + assert.ok(!halts[0].includes('marker_persisted='), + 'the pre-write record must not assert persistence: ' + halts[0]) + + const outcome = linesFor('REORG_HALT_MARKER') + assert.strictEqual(outcome.length, 1, 'the write outcome must produce exactly one record') + assert.ok(outcome[0].includes('marker_persisted=true'), outcome[0]) + assert.ok(outcome[0].includes('attempts=1'), outcome[0]) + assert.ok(marked && /safe-depth/.test(marked), 'the durable marker is still written') + assert.strictEqual(decoder.getReorgHaltStatus().marker_persisted, true) + }) +} + +function registerRefusedMarkerRecord(context) { + const { assert, haltingDecoder, NODE_TIP, linesFor, sink } = context + + // The failure the bootstrap gate exists to stop: the marker write fails, the + // process exits, the restart policy recycles the container, the entry guard reads + // a row that was never written, and the gate counts zero markers and publishes the + // database as known-good. Before this, insertEvent swallowed the write error and + // returned false, markReorgHalted handed that straight back, haltReorg discarded + // it, and the one structured record said marker_persisted=true regardless. + it('reports marker_persisted=false when the durable write is refused, and still aborts', async function () { + let attempts = 0 + const decoder = haltingDecoder({ markReorgHalted: async () => { attempts++; return false } }) + await assert.rejects(() => decoder.verifyReorg(NODE_TIP), /safe-depth/, + 'a marker failure must never mask or replace the abort') + + const outcome = linesFor('REORG_HALT_MARKER') + assert.strictEqual(outcome.length, 1) + assert.ok(outcome[0].includes('marker_persisted=false'), + 'a refused write must never report as persisted: ' + outcome[0]) + assert.strictEqual(attempts, 2, 'a refused write is retried once on a fresh connection') + assert.ok(outcome[0].includes('attempts=2'), outcome[0]) + assert.strictEqual(decoder.getReorgHaltStatus().marker_persisted, false) + assert.strictEqual(decoder.getReorgHaltStatus().halted, true) + + // Read the operator line off the SINK, not off console.error. The halt + // path now goes through the one logger like everything else, so the + // shipper this suite already installs is where the line lands; a + // console capture would see nothing and report the line as missing. + const critical = sink.lines.filter((l) => l.includes('could NOT be persisted')) + assert.strictEqual(critical.length, 1, + 'the only live evidence of an unrecorded halt must be logged: ' + JSON.stringify(sink.lines)) + assert.ok(/full resync/i.test(critical[0]), + 'the line must name the required operator action: ' + critical[0]) + assert.ok(/not a valid bootstrap source/i.test(critical[0]), critical[0]) + }) +} + +function registerThrownMarkerAndMemoryRecords(context) { + const { assert, haltingDecoder, NODE_TIP, linesFor } = context + + it('carries the cause when the marker write throws rather than returning false', async function () { + const realError = console.error + console.error = () => {} + try { + const decoder = haltingDecoder({ + markReorgHalted: async () => { throw new Error('lost connection to server') } + }) + await assert.rejects(() => decoder.verifyReorg(NODE_TIP), /safe-depth/) + const outcome = linesFor('REORG_HALT_MARKER') + assert.strictEqual(outcome.length, 1) + assert.ok(outcome[0].includes('marker_persisted=false'), outcome[0]) + assert.ok(outcome[0].includes('lost connection to server'), + 'the cause must ride the record: ' + outcome[0]) + } finally { + console.error = realError + } + }) + + it('reports the halt in memory even when nothing durable can be written', async function () { + const decoder = haltingDecoder({}) + await assert.rejects(() => decoder.verifyReorg(NODE_TIP), /safe-depth/) + assert.strictEqual(decoder.reorgHalted, true) + assert.match(decoder.reorgHaltReason, /safe-depth/) + }) +} + +module.exports = function registerReorgHaltRecords(context) { + registerAvailableMarkerRecords(context) + registerRefusedMarkerRecord(context) + registerThrownMarkerAndMemoryRecords(context) +} diff --git a/test/unit/decoder_halt_diagnostics.test/02_health_probe_records.test.js b/test/unit/decoder_halt_diagnostics.test/02_health_probe_records.test.js new file mode 100644 index 0000000..0a420d0 --- /dev/null +++ b/test/unit/decoder_halt_diagnostics.test/02_health_probe_records.test.js @@ -0,0 +1,117 @@ +// 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. + +function registerNamedProbeRecords(context) { + const { assert, probeDecoder, getLive, liveApp, linesFor } = context + + it('names the db_ping probe on /live when the ping throws', async function () { + const decoder = probeDecoder() + decoder.db = { ping: async () => { throw new Error('pool timeout acquiring connection') } } + + const res = await getLive(liveApp(decoder)) + // Control: the route still answers, with the code it always answered. + assert.strictEqual(res.status, 503) + assert.strictEqual(res.body.db, false) + + const warned = linesFor('HEALTH_PROBE_FAILED') + assert.strictEqual(warned.length, 1, 'the failure must produce one record') + assert.ok(warned[0].includes(' warn '), warned[0]) + assert.ok(warned[0].includes('probe=db_ping'), warned[0]) + assert.ok(warned[0].includes('route=/live'), warned[0]) + assert.ok(warned[0].includes('pool timeout'), 'the cause rides the record: ' + warned[0]) + }) + + it('names the reorg_halt probe on /live, the failure that makes a halted decoder read clean', async function () { + const decoder = probeDecoder() + decoder.checkReorgHalt = async () => { throw new Error('events table is gone') } + + const res = await getLive(liveApp(decoder)) + // The wrong-but-alive shape this exists for: the route reports no halt + // because it could not ask, and that is now the difference between a + // silent lie and a warned one. + assert.strictEqual(res.status, 200) + assert.strictEqual(res.body.reorg_halted, false) + + const warned = linesFor('HEALTH_PROBE_FAILED') + assert.strictEqual(warned.length, 1) + assert.ok(warned[0].includes('probe=reorg_halt'), warned[0]) + assert.ok(warned[0].includes('route=/live'), warned[0]) + assert.ok(warned[0].includes('events table is gone'), warned[0]) + }) +} + +function registerThrottleAndDefensiveRecords(context) { + const { + assert, probeDecoder, getLive, liveApp, linesFor, + ageProbeLogState, PROBE_LOG_WINDOW_MS, noteProbeFailure + } = context + + it('throttles a repeating probe failure to one line per window and counts the rest', async function () { + const decoder = probeDecoder() + decoder.db = { ping: async () => { throw new Error('pool timeout') } } + const app = liveApp(decoder) + + for (let i = 0; i < 5; i++) await getLive(app) + assert.strictEqual(linesFor('HEALTH_PROBE_FAILED').length, 1, + 'a caller-driven route must not turn one outage into one line per request') + + // Age the window rather than sleeping through it, so the suppressed count + // the next line has to report survives. + ageProbeLogState() + await getLive(app) + const warned = linesFor('HEALTH_PROBE_FAILED') + assert.strictEqual(warned.length, 2) + assert.ok(warned[1].includes('suppressed=4'), + 'a throttled flood must stay countable, not merely quiet: ' + warned[1]) + assert.ok(PROBE_LOG_WINDOW_MS > 0, 'the window is a real duration, not a disabled guard') + }) + + it('carries a cause even when the probe threw something that is not an Error', function () { + noteProbeFailure('db_ping', '/status', 'ECONNREFUSED') + const warned = linesFor('HEALTH_PROBE_FAILED') + assert.strictEqual(warned.length, 1) + assert.ok(warned[0].includes('err=ECONNREFUSED'), warned[0]) + }) + + it('answers null instead of throwing when the error itself cannot be read', function () { + // A diagnostic that throws inside a health route would turn a reportable + // probe failure into a 500 on the route the healthcheck polls. + const hostile = { get message() { throw new Error('unreadable') } } + assert.strictEqual(noteProbeFailure('db_ping', '/live', hostile), null) + assert.strictEqual(linesFor('HEALTH_PROBE_FAILED').length, 0) + }) +} + +function registerSeparateThrottleRecord(context) { + const { assert, probeDecoder, getLive, liveApp, linesFor } = context + + it('keeps the two probes on separate throttles, so one failure cannot mask the other', async function () { + const decoder = probeDecoder() + decoder.db = { ping: async () => { throw new Error('pool timeout') } } + decoder.checkReorgHalt = async () => { throw new Error('events table is gone') } + + await getLive(liveApp(decoder)) + // db_ping fails first, so dbOk is false and the halt probe is not reached + // on this route. Drive the halt probe with a working ping. + decoder.db = { ping: async () => true } + await getLive(liveApp(decoder)) + + const warned = linesFor('HEALTH_PROBE_FAILED') + assert.strictEqual(warned.length, 2) + assert.ok(warned.some((l) => l.includes('probe=db_ping'))) + assert.ok(warned.some((l) => l.includes('probe=reorg_halt'))) + }) +} + +module.exports = function registerHealthProbeRecords(context) { + registerNamedProbeRecords(context) + registerThrottleAndDefensiveRecords(context) + registerSeparateThrottleRecord(context) +} diff --git a/test/unit/decoder_halt_diagnostics.test/03_db_cleanup_record.test.js b/test/unit/decoder_halt_diagnostics.test/03_db_cleanup_record.test.js new file mode 100644 index 0000000..4142d2e --- /dev/null +++ b/test/unit/decoder_halt_diagnostics.test/03_db_cleanup_record.test.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. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +module.exports = function registerDbCleanupRecord(context) { + const { assert, Database, poolWhoseDropFails, linesFor } = context + + it('records the drop failure with the table and the cause', async function () { + const db = new Database('127.0.0.1', 3306, 'xchain_btc_regtest', 'u', 'p') + db.pool = poolWhoseDropFails() + + // Control: the drop is cleanup, so the call still returns its result. + const r = await db.deleteAndCompareTxsNotInList([]) + assert.strictEqual(r.transactionsDeleted, 0) + + const warned = linesFor('DB_TEMP_TABLE_DROP_FAILED') + assert.strictEqual(warned.length, 1) + assert.ok(warned[0].includes('table=_mempool_node_snapshot'), warned[0]) + assert.ok(warned[0].includes('lost connection to server'), warned[0]) + }) +} From 11ab78ec3eb73a74f268735e3a27fe9c72e75aa4 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 10:58:53 -0700 Subject: [PATCH 03/15] fix(reorg): widen Litecoin testnet rollback window --- src/XChainDecoder.js | 4 +- src/XChainDecoder/block_ingest.js | 3 +- src/XChainDecoder/constants.js | 21 +++++--- src/XChainDecoder/reorg_verification.js | 18 ++++--- src/db/reorg_halt.js | 6 +-- test/unit/dispenser_safe_depth.test.js | 53 +++++++++++--------- test/unit/reorg_depth_across_restart.test.js | 7 +++ test/unit/verify_reorg_retry.test.js | 14 +++++- 8 files changed, 80 insertions(+), 46 deletions(-) diff --git a/src/XChainDecoder.js b/src/XChainDecoder.js index 6710076..0846167 100644 --- a/src/XChainDecoder.js +++ b/src/XChainDecoder.js @@ -26,7 +26,7 @@ const BlockchainConnector = require('./chain/blockchain_connector') const CryptoNetworks = require('./chain/crypto_networks') const XChainBlockDecoder = require('./chain/XChainBlockDecoder') const { format: formatLogLine } = require('node:util'); -const { logger, FUNDING_VOUT_BASE, DISPENSER_EXPIRE_SAFE_DEPTH, VALID_ACTION_NAMES, AUXPOW_REASSEMBLE_AFTER } = require('./XChainDecoder/constants.js') +const { logger, FUNDING_VOUT_BASE, DISPENSER_EXPIRE_SAFE_DEPTH, resolveDispenserExpireSafeDepth, VALID_ACTION_NAMES, AUXPOW_REASSEMBLE_AFTER } = require('./XChainDecoder/constants.js') const { nodeStillCatchingUp, compiledPushSize, canonicalizeActionPayload, bigIntBufferutilsActive } = require('./XChainDecoder/payload_helpers.js') const syncStatusMethods = require('./XChainDecoder/sync_status.js') const chainIntegrityMethods = require('./XChainDecoder/chain_integrity.js') @@ -104,6 +104,7 @@ function initializeDecoderIdentity(decoder, network, dbUrl, dbPort, dbName, dbUs // getBitcoinJsNetwork call above already threw on an unknown key, so the // suffix is guaranteed to be a valid network name here. decoder.consensusNetwork = String(network).slice(String(network).lastIndexOf('-') + 1) + decoder.dispenserExpireSafeDepth = resolveDispenserExpireSafeDepth(decoder.coinTick, decoder.consensusNetwork) // Coin/network-prefixed loggers so cadence/reorg/stall lines are self-describing // even when a log pipeline strips container labels. Reads the fields at call time. @@ -311,6 +312,7 @@ Object.assign(XChainDecoder, { OP_RETURN_PUSH_OVERHEAD, // Exported so a regression test can pin it >= the deepest per-chain reorg window. DISPENSER_EXPIRE_SAFE_DEPTH, + resolveDispenserExpireSafeDepth, nodeStillCatchingUp, // Exported so the funding-fee-output collision regression test can assert attributed // funding outputs are stored at vout + FUNDING_VOUT_BASE (never colliding with real vouts). diff --git a/src/XChainDecoder/block_ingest.js b/src/XChainDecoder/block_ingest.js index d530f48..2ec1b8c 100644 --- a/src/XChainDecoder/block_ingest.js +++ b/src/XChainDecoder/block_ingest.js @@ -221,7 +221,8 @@ async function commitBlockBatch(loop, nextBlockHeight, nextBlockHash){ // AFTER the block transaction commits (a transient failure here // must not roll back committed block data) and is deterministic // across nodes (keyed off canonical height, not wall clock). - await this.db.purgeExpiredDispensers(nextBlockHeight - DISPENSER_EXPIRE_SAFE_DEPTH) + const safeDepth = this.dispenserExpireSafeDepth || DISPENSER_EXPIRE_SAFE_DEPTH + await this.db.purgeExpiredDispensers(nextBlockHeight - safeDepth) loop.blocksCount = 0 loop.transactionsCount = 0 diff --git a/src/XChainDecoder/constants.js b/src/XChainDecoder/constants.js index 3b8af7c..ea2fed0 100644 --- a/src/XChainDecoder/constants.js +++ b/src/XChainDecoder/constants.js @@ -90,17 +90,21 @@ const SYNCED_THRESHOLD = 3 //Maximum blocks behind to be synced // so every node purges identically. This MUST stay >= the deepest per-chain // reorg-recovery window, or a row is deleted before a legal in-window reorg can // restore it (deleteBlockByIndex then matches zero rows), permanently losing a -// money-bearing dispenser on the reorged node. The platform's deepest window is -// 120, and TWO chains now sit on it (xchain-utxo-tracker DEFAULT_UNDO_BLOCKS: -// BTC 12 / LTC 120 / DOGE 120; LTC was 48 until a 2026-09-01 testnet fork walked -// past it); the previous flat 100 sat BELOW that window. Invariant: SAFE_DEPTH >= -// deepest undo window + margin. The +6 margin means a small undo-window re-tune +// money-bearing dispenser on the reorged node. Standard networks use 126 and +// Litecoin testnet uses 5006. Invariant: SAFE_DEPTH >= matching undo window + +// margin. The +6 margin means a small undo-window re-tune // cannot land exactly at the purge threshold; dispenserSafeDepth.test.js -// enforces the invariant with a conformance read of undo-blocks.js, so raising -// any chain's window past the margin fails the suite until this is bumped. +// enforces the invariant with a conformance read of undo-blocks.js. // Purging deeper is the conservative direction (rows are merely retained longer // before hard-purge; expiry semantics and action evaluation are unchanged). -const DISPENSER_EXPIRE_SAFE_DEPTH = 126 // 120 (deepest undo window, LTC and DOGE) + 6 margin +const DISPENSER_EXPIRE_SAFE_DEPTH = 126 // 120 (deepest standard window) + 6 margin +const LTC_TESTNET_DISPENSER_EXPIRE_SAFE_DEPTH = 5006 + +function resolveDispenserExpireSafeDepth(coin, network){ + return String(coin).toUpperCase() === 'LTC' && String(network).toLowerCase() === 'testnet' + ? LTC_TESTNET_DISPENSER_EXPIRE_SAFE_DEPTH + : DISPENSER_EXPIRE_SAFE_DEPTH +} // There is deliberately no DISPENSER_CLOSE_DELAY twin of the indexer's here: the decoder // does not mirror dispenser cancels, so it never needs to close a row at the height the @@ -171,6 +175,7 @@ module.exports = { FUNDING_VOUT_BASE, SYNCED_THRESHOLD, DISPENSER_EXPIRE_SAFE_DEPTH, + resolveDispenserExpireSafeDepth, MIN_VERIFICATION_PROGRESS_TO_PARSE, TAPROOT_LEAF_VERSION, TAPROOT_ANNEX_MARKER, diff --git a/src/XChainDecoder/reorg_verification.js b/src/XChainDecoder/reorg_verification.js index b296ce0..1082765 100644 --- a/src/XChainDecoder/reorg_verification.js +++ b/src/XChainDecoder/reorg_verification.js @@ -23,6 +23,10 @@ const { chainTierMismatch } = require('../protocol/chain_identity') const { logger, DISPENSER_EXPIRE_SAFE_DEPTH } = require('./constants.js') const { haltReorg } = require('./reorg_halt.js') +function safeDepthFor(decoder){ + return decoder.dispenserExpireSafeDepth || DISPENSER_EXPIRE_SAFE_DEPTH +} + function refuseHaltedRollback(){ // Mirror the durable marker into the in-memory health state so the health // surface agrees with the abort even before the next TTL probe. @@ -30,7 +34,7 @@ function refuseHaltedRollback(){ this.reorgHaltCheckedAt = Date.now() const msg = "verifyReorg: decoder is HALTED from a prior over-deep reorg abort. Refusing to " + "roll back further: a restart must not silently resume a rollback past the dispenser " - + "safe-depth window (DISPENSER_EXPIRE_SAFE_DEPTH=" + DISPENSER_EXPIRE_SAFE_DEPTH + "), which " + + "safe-depth window (DISPENSER_EXPIRE_SAFE_DEPTH=" + safeDepthFor(this) + "), which " + "would permanently lose money-bearing dispenser state. Recovery: perform a full resync " + "from a known-good snapshot." logger.error(msg) @@ -47,7 +51,7 @@ async function readPriorRollbackDepth(){ let seedErr = null for (let attempt = 1; attempt <= 3; attempt++){ try { - priorDepth = await this.db.countReorgDeletesAboveTip() + priorDepth = await this.db.countReorgDeletesAboveTip(safeDepthFor(this) + 1) seedErr = null break } catch (err){ @@ -83,9 +87,10 @@ async function readPriorRollbackDepth(){ // tip either way, and counting only the current invocation is what let a // restart finish an aborted over-deep rollback. async function assertWithinSafeDepth(lastBlockIndex, priorDepth, blocksDeleted){ - if (priorDepth + blocksDeleted.length >= DISPENSER_EXPIRE_SAFE_DEPTH){ + const safeDepth = safeDepthFor(this) + if (priorDepth + blocksDeleted.length >= safeDepth){ const msg = "verifyReorg: reorg depth exceeds the dispenser safe-depth window " - + "(DISPENSER_EXPIRE_SAFE_DEPTH=" + DISPENSER_EXPIRE_SAFE_DEPTH + "). Already rolled back " + + "(DISPENSER_EXPIRE_SAFE_DEPTH=" + safeDepth + "). Already rolled back " + (priorDepth + blocksDeleted.length) + " blocks (" + blocksDeleted.length + " in this run, resumed from " + priorDepth + " already deleted above the tip); " + "soft-expired dispenser rows for block height " @@ -122,12 +127,13 @@ async function deleteAboveTipBlock(lastBlockIndex, lastBlock, nodeTip, priorDept // instead of exiting into a restart loop. const aboveTip = lastBlockIndex - nodeTip const alreadyRolledBack = priorDepth + blocksDeleted.length - if (alreadyRolledBack + aboveTip > DISPENSER_EXPIRE_SAFE_DEPTH){ + const safeDepth = safeDepthFor(this) + if (alreadyRolledBack + aboveTip > safeDepth){ const msg = "verifyReorg: the node's tip (" + nodeTip + ") is " + aboveTip + " blocks below the stored tip (" + lastBlockIndex + "), which" + (alreadyRolledBack > 0 ? " with " + alreadyRolledBack + " block(s) already rolled back" : "") + " exceeds the dispenser safe-depth window (DISPENSER_EXPIRE_SAFE_DEPTH=" - + DISPENSER_EXPIRE_SAFE_DEPTH + "). Refusing before any further delete: nothing has been " + + safeDepth + "). Refusing before any further delete: nothing has been " + "rolled back past the window, no REORG_HALT marker was written and this database needs " + "no resync. Either the node is still catching up (wait for it to pass " + lastBlockIndex + ") or it was rolled back below this database's tip (operator action)." diff --git a/src/db/reorg_halt.js b/src/db/reorg_halt.js index 36f8006..97dceeb 100644 --- a/src/db/reorg_halt.js +++ b/src/db/reorg_halt.js @@ -120,12 +120,12 @@ module.exports = { // marker are atomic, so the marker rows above the tip ARE the rollback depth. // // Distinct heights, not a row count: a height deleted, re-synced and deleted - // again writes two markers and is one block of depth. Bounded scan: the ceiling - // is 126, so the newest few thousand REORG rows cover every reachable depth, and + // again writes two markers and is one block of depth. Bounded scan: the largest + // configured ceiling is 5006, so the newest 10000 REORG rows cover every reachable depth, and // (code, id) is indexed (src/sql/events.sql). THROWS on an unreadable or // unparseable result - "we could not tell" must never reach the caller as "no // prior rollback", which is the exact collapse this whole guard exists to stop. - async countReorgDeletesAboveTip(scanLimit = 5000){ + async countReorgDeletesAboveTip(scanLimit = 10000){ // Throws (after its own retries) rather than returning a sentinel, so an // unknown tip cannot silently become "everything is above it" or "nothing is". const tip = await this.getLastBlockIndex() diff --git a/test/unit/dispenser_safe_depth.test.js b/test/unit/dispenser_safe_depth.test.js index 992c211..9b8b8d7 100644 --- a/test/unit/dispenser_safe_depth.test.js +++ b/test/unit/dispenser_safe_depth.test.js @@ -19,7 +19,7 @@ * xchain-utxo-tracker/src/chain/undo_blocks.js when that sibling repo is checked * out (conformance read, skip-if-absent per the ConsensusPrimitiveConformance * convention), with a hand-copied floor kept as the always-on baseline. - * It also pins the tracker's own MAX_SAFE_UNDO_BLOCKS equal to this constant, + * It also pins the tracker's network-specific safe ceiling to the decoder resolver, * which the one-directional runtime warning in resolveUndoBlocks() cannot do. */ @@ -32,7 +32,7 @@ const XChainDecoder = require('../../src/XChainDecoder.js'); // Baseline floor (always asserted, even without the sibling checkout). // Mirrors xchain-utxo-tracker/src/chain/undo_blocks.js DEFAULT_UNDO_BLOCKS. -const DEEPEST_UNDO_WINDOW = 120; // LTC and DOGE (BTC 12 / LTC 120 / DOGE 120) +const DEFAULT_UNDO_WINDOW = 120; // Headroom above the deepest window so a small undo-window re-tune can never // land exactly at the purge threshold. Matches the margin baked into @@ -40,12 +40,11 @@ const DEEPEST_UNDO_WINDOW = 120; // LTC and DOGE (BTC 12 / LTC 120 / DOGE 120) const SAFETY_MARGIN = 6; describe('DISPENSER_EXPIRE_SAFE_DEPTH', function () { - it('is at least as deep as the deepest per-chain reorg window (LTC and DOGE = 120) + margin', function () { - assert.ok( - XChainDecoder.DISPENSER_EXPIRE_SAFE_DEPTH >= DEEPEST_UNDO_WINDOW + SAFETY_MARGIN, - `SAFE_DEPTH (${XChainDecoder.DISPENSER_EXPIRE_SAFE_DEPTH}) must be >= ${DEEPEST_UNDO_WINDOW + SAFETY_MARGIN} ` + - 'so a soft-expired dispenser survives every in-window reorg with headroom' - ); + it('raises only litecoin testnet to the 5000-block window plus margin', function () { + assert.strictEqual(XChainDecoder.resolveDispenserExpireSafeDepth('LTC', 'testnet'), 5006); + assert.strictEqual(XChainDecoder.resolveDispenserExpireSafeDepth('LTC', 'mainnet'), 126); + assert.strictEqual(XChainDecoder.resolveDispenserExpireSafeDepth('LTC', 'regtest'), 126); + assert.strictEqual(XChainDecoder.resolveDispenserExpireSafeDepth('BTC', 'testnet'), 126); }); // CONFORMANCE: read the canonical per-chain undo windows instead of @@ -61,34 +60,38 @@ describe('DISPENSER_EXPIRE_SAFE_DEPTH', function () { it('SAFE_DEPTH exceeds every canonical per-chain undo window by the margin', function () { const { DEFAULT_UNDO_BLOCKS } = require(UNDO); - const deepest = Math.max(...Object.values(DEFAULT_UNDO_BLOCKS)); - assert.ok( - XChainDecoder.DISPENSER_EXPIRE_SAFE_DEPTH >= deepest + SAFETY_MARGIN, - `SAFE_DEPTH (${XChainDecoder.DISPENSER_EXPIRE_SAFE_DEPTH}) must be >= canonical deepest ` + - `undo window (${deepest}) + margin (${SAFETY_MARGIN}); a chain's undo window was raised ` + - 'without bumping DISPENSER_EXPIRE_SAFE_DEPTH in XChainDecoder.js' - ); + const cases = [ + ['BTC', 'mainnet'], ['BTC', 'testnet'], ['BTC', 'regtest'], + ['LTC', 'mainnet'], ['LTC', 'testnet'], ['LTC', 'regtest'], + ['DOGE', 'mainnet'], ['DOGE', 'testnet'], ['DOGE', 'regtest'] + ]; + for (const [coin, network] of cases) { + const window = DEFAULT_UNDO_BLOCKS[coin + '_' + network.toUpperCase()]; + const safeDepth = XChainDecoder.resolveDispenserExpireSafeDepth(coin, network); + assert.ok(safeDepth >= window + SAFETY_MARGIN, + `${coin}/${network} SAFE_DEPTH (${safeDepth}) must be >= undo window (${window}) + margin (${SAFETY_MARGIN})`); + } }); - it('the hand-copied baseline floor still matches the canonical deepest window', function () { + it('the hand-copied standard floor still matches the unchanged mainnet window', function () { const { DEFAULT_UNDO_BLOCKS } = require(UNDO); - const deepest = Math.max(...Object.values(DEFAULT_UNDO_BLOCKS)); assert.strictEqual( - DEEPEST_UNDO_WINDOW, deepest, - 'update DEEPEST_UNDO_WINDOW in this test to match undo_blocks.js' + DEFAULT_UNDO_WINDOW, DEFAULT_UNDO_BLOCKS.LTC_MAINNET, + 'update DEFAULT_UNDO_WINDOW in this test to match the unchanged mainnet window' ); }); // Pins the tracker's hand-mirrored ceiling to the decoder's constant in BOTH // directions. resolveUndoBlocks() only warns when the resolved window EXCEEDS - // MAX_SAFE_UNDO_BLOCKS, so LOWERING DISPENSER_EXPIRE_SAFE_DEPTH alone is silent + // The tracker ceiling warning is one-directional, so lowering the decoder depth alone is silent // at runtime; this equality is the only thing that catches it. - it('tracker MAX_SAFE_UNDO_BLOCKS equals the decoder SAFE_DEPTH', function () { - const { MAX_SAFE_UNDO_BLOCKS } = require(UNDO); + it('tracker LTC testnet ceiling equals the decoder LTC testnet depth', function () { + const { safeUndoBlocksCeiling } = require(UNDO); + const decoderDepth = XChainDecoder.resolveDispenserExpireSafeDepth('LTC', 'testnet'); assert.strictEqual( - MAX_SAFE_UNDO_BLOCKS, XChainDecoder.DISPENSER_EXPIRE_SAFE_DEPTH, - `tracker MAX_SAFE_UNDO_BLOCKS (${MAX_SAFE_UNDO_BLOCKS}) must EQUAL ` + - `DISPENSER_EXPIRE_SAFE_DEPTH (${XChainDecoder.DISPENSER_EXPIRE_SAFE_DEPTH}); ` + + safeUndoBlocksCeiling('litecoin-testnet'), decoderDepth, + `tracker safe ceiling (${safeUndoBlocksCeiling('litecoin-testnet')}) must EQUAL ` + + `decoder SAFE_DEPTH (${decoderDepth}); ` + 'a split lets the decoder abort reorg recovery at one depth while the tracker auto-recovers to another' ); }); diff --git a/test/unit/reorg_depth_across_restart.test.js b/test/unit/reorg_depth_across_restart.test.js index fee9bfb..22449b7 100644 --- a/test/unit/reorg_depth_across_restart.test.js +++ b/test/unit/reorg_depth_across_restart.test.js @@ -234,6 +234,13 @@ describe('Database#countReorgDeletesAboveTip()', function () { await assert.rejects(() => db.countReorgDeletesAboveTip(0), /out-of-range scan limit/) }) + it('scans beyond the 5006-block litecoin testnet ceiling by default', async function () { + const { db, query } = dbWith([{ max_height: 200n }], []) + await db.countReorgDeletesAboveTip() + const scan = query.getCalls().map(c => String(c.args[0])).find(s => /code = 'REORG'/.test(s)) + assert.match(scan, /ORDER BY id DESC LIMIT 10000;/) + }) + it('propagates a tip read that could not be answered, rather than counting against a guess', async function () { const db = new Database('127.0.0.1', 3306, 'xchain_btc_mainnet', 'u', 'p') db.sleep = async () => {} diff --git a/test/unit/verify_reorg_retry.test.js b/test/unit/verify_reorg_retry.test.js index bfde460..3ef7905 100644 --- a/test/unit/verify_reorg_retry.test.js +++ b/test/unit/verify_reorg_retry.test.js @@ -104,9 +104,9 @@ const SAFE_DEPTH = XChainDecoder.DISPENSER_EXPIRE_SAFE_DEPTH // Decoder whose DB disagrees with the node for `divergentBlocks` blocks below // the tip; below that the hashes match and the backward walk stops. -function buildDeepReorgDecoder(divergentBlocks) { +function buildDeepReorgDecoder(divergentBlocks, network = 'bitcoin-regtest') { const decoder = new XChainDecoder( - 'bitcoin-regtest', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null + network, 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null ) decoder.startBlockIndex = 0 decoder.sleep = async () => {} @@ -135,6 +135,16 @@ describe('XChainDecoder.verifyReorg depth guard', function () { assert.strictEqual(deleted.length, SAFE_DEPTH - 1) }) + it('recovers a 134-block litecoin testnet reorg without changing standard networks', async function () { + const { decoder, deleted } = buildDeepReorgDecoder(134, 'litecoin-testnet') + assert.strictEqual(decoder.dispenserExpireSafeDepth, 5006) + assert.strictEqual(await decoder.verifyReorg(), true) + assert.strictEqual(deleted.length, 134) + assert.strictEqual(new XChainDecoder( + 'litecoin-mainnet', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null + ).dispenserExpireSafeDepth, SAFE_DEPTH) + }) + it('aborts fail-closed once the walk reaches the safe depth instead of deleting past purged dispenser rows', async function () { const { decoder, deleted } = buildDeepReorgDecoder(SAFE_DEPTH + 20) await assert.rejects(() => decoder.verifyReorg(), /dispenser safe-depth window/) From 9934191ac79b9ae5eac1bf7bc3c2424dd63a809e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 17:41:27 -0700 Subject: [PATCH 04/15] 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/fixture-ports.js | 102 ++++++++++++++++++ bin/run-e2e.sh | 9 +- bin/run-integration.sh | 9 +- package.json | 8 +- test/e2e/fixtures/docker-compose.test.yml | 4 +- test/e2e/support/setup.js | 5 +- .../fixtures/docker-compose.test.yml | 4 +- test/integration/support/setup.js | 5 +- test/unit/repo/fixture_ports.test.js | 27 +++++ 9 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 bin/fixture-ports.js create mode 100644 test/unit/repo/fixture_ports.test.js diff --git a/bin/fixture-ports.js b/bin/fixture-ports.js new file mode 100644 index 0000000..899a5dd --- /dev/null +++ b/bin/fixture-ports.js @@ -0,0 +1,102 @@ +#!/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/integration/fixtures/docker-compose.test.yml': Object.freeze({ + XCHAIN_TEST_NODE_PORT: 18544, + XCHAIN_TEST_DB_PORT: 13318 + }), + 'test/e2e/fixtures/docker-compose.test.yml': Object.freeze({ + XCHAIN_E2E_NODE_PORT: 18545, + XCHAIN_E2E_DB_PORT: 13319 + }) +}); + +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/bin/run-e2e.sh b/bin/run-e2e.sh index 5695073..40215dd 100755 --- a/bin/run-e2e.sh +++ b/bin/run-e2e.sh @@ -30,6 +30,7 @@ cd "$(dirname "$0")/.." || exit 1 COMPOSE_FILE="test/e2e/fixtures/docker-compose.test.yml" KEEP_VENUE="${KEEP_VENUE:-0}" +COMPOSE=(node bin/fixture-ports.js compose "$COMPOSE_FILE") teardown() { if [ "$KEEP_VENUE" = "1" ]; then @@ -37,7 +38,7 @@ teardown() { return fi echo "[e2e] Tearing down the venue" - docker compose -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 + "${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 } trap teardown EXIT @@ -47,12 +48,12 @@ if ! command -v docker >/dev/null 2>&1; then fi # Start from a clean slate even if a previous run was killed before its teardown. -docker compose -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 +"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 echo "[e2e] Bringing up the venue" -if ! docker compose -f "$COMPOSE_FILE" up -d --wait; then +if ! "${COMPOSE[@]}" up -d --wait; then echo "[e2e] Venue failed to become healthy" >&2 - docker compose -f "$COMPOSE_FILE" ps + "${COMPOSE[@]}" ps exit 1 fi diff --git a/bin/run-integration.sh b/bin/run-integration.sh index ceb7e62..862ac4d 100755 --- a/bin/run-integration.sh +++ b/bin/run-integration.sh @@ -30,6 +30,7 @@ cd "$(dirname "$0")/.." || exit 1 COMPOSE_FILE="test/integration/fixtures/docker-compose.test.yml" KEEP_VENUE="${KEEP_VENUE:-0}" +COMPOSE=(node bin/fixture-ports.js compose "$COMPOSE_FILE") teardown() { if [ "$KEEP_VENUE" = "1" ]; then @@ -37,7 +38,7 @@ teardown() { return fi echo "[integration] Tearing down the venue" - docker compose -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 + "${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 } trap teardown EXIT @@ -47,12 +48,12 @@ if ! command -v docker >/dev/null 2>&1; then fi # Start from a clean slate even if a previous run was killed before its teardown. -docker compose -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 +"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 echo "[integration] Bringing up the venue" -if ! docker compose -f "$COMPOSE_FILE" up -d --wait; then +if ! "${COMPOSE[@]}" up -d --wait; then echo "[integration] Venue failed to become healthy" >&2 - docker compose -f "$COMPOSE_FILE" ps + "${COMPOSE[@]}" ps exit 1 fi diff --git a/package.json b/package.json index 617baef..c0515bb 100644 --- a/package.json +++ b/package.json @@ -43,12 +43,12 @@ "ci:fuzz": "FUZZ_ITERATIONS=100 mocha --timeout 60000 --exit --require ./test/fuzz/support/setup.js 'test/fuzz/harness/**/*.fuzz.js'", "test:integration": "bash bin/run-integration.sh", "test:integration:mocha": "mocha --timeout 0 --exit --require ./test/integration/support/setup.js 'test/integration/**/*.test.js'", - "test:integration:up": "docker compose -f test/integration/fixtures/docker-compose.test.yml up -d --wait", - "test:integration:down": "docker compose -f test/integration/fixtures/docker-compose.test.yml down -v", + "test:integration:up": "node bin/fixture-ports.js compose test/integration/fixtures/docker-compose.test.yml up -d --wait", + "test:integration:down": "node bin/fixture-ports.js compose test/integration/fixtures/docker-compose.test.yml down -v", "test:e2e": "bash bin/run-e2e.sh", "test:e2e:mocha": "mocha --timeout 0 --exit --require ./test/e2e/support/setup.js 'test/e2e/**/*.test.js'", - "test:e2e:up": "docker compose -f test/e2e/fixtures/docker-compose.test.yml up -d --wait", - "test:e2e:down": "docker compose -f test/e2e/fixtures/docker-compose.test.yml down -v", + "test:e2e:up": "node bin/fixture-ports.js compose test/e2e/fixtures/docker-compose.test.yml up -d --wait", + "test:e2e:down": "node bin/fixture-ports.js compose test/e2e/fixtures/docker-compose.test.yml down -v", "test:fuzz": "mocha --timeout 300000 --require ./test/fuzz/support/setup.js 'test/fuzz/harness/**/*.fuzz.js'", "test:fuzz:quick": "FUZZ_ITERATIONS=100 mocha --timeout 60000 --require ./test/fuzz/support/setup.js 'test/fuzz/harness/**/*.fuzz.js'", "test:fuzz:deobfuscation": "mocha --timeout 120000 --require ./test/fuzz/support/setup.js 'test/fuzz/harness/remove_obfuscation.fuzz.js'", diff --git a/test/e2e/fixtures/docker-compose.test.yml b/test/e2e/fixtures/docker-compose.test.yml index 738f272..0958e22 100644 --- a/test/e2e/fixtures/docker-compose.test.yml +++ b/test/e2e/fixtures/docker-compose.test.yml @@ -58,7 +58,7 @@ services: - -addresstype=legacy - -printtoconsole=1 ports: - - "18545:18443" + - "${XCHAIN_E2E_NODE_PORT:-18545}:18443" tmpfs: - /home/bitcoin/.bitcoin healthcheck: @@ -72,7 +72,7 @@ services: environment: MARIADB_ROOT_PASSWORD: e2efixture ports: - - "13319:3306" + - "${XCHAIN_E2E_DB_PORT:-13319}:3306" tmpfs: - /var/lib/mysql healthcheck: diff --git a/test/e2e/support/setup.js b/test/e2e/support/setup.js index 91d8c32..b9bd6ae 100644 --- a/test/e2e/support/setup.js +++ b/test/e2e/support/setup.js @@ -46,14 +46,15 @@ const BitcoinCore = require('bitcoin-core') const nodeHelper = require('../../helpers/node_helper') const XChainDecoder = require('../../../src/XChainDecoder') const Database = require('../../../src/db.js') +const fixturePorts = require('../../../bin/fixture-ports.js') // Fixture venue. Must match fixtures/docker-compose.test.yml. const NODE_HOST = process.env.XCHAIN_E2E_NODE_HOST || '127.0.0.1' -const NODE_PORT = process.env.XCHAIN_E2E_NODE_PORT || '18545' +const NODE_PORT = String(fixturePorts.port('XCHAIN_E2E_NODE_PORT')) const NODE_USER = process.env.XCHAIN_E2E_NODE_USER || 'e2efixture' const NODE_PASSWORD = process.env.XCHAIN_E2E_NODE_PASS || 'e2efixture' const DB_HOST = process.env.XCHAIN_E2E_DB_HOST || '127.0.0.1' -const DB_PORT = process.env.XCHAIN_E2E_DB_PORT || '13319' +const DB_PORT = String(fixturePorts.port('XCHAIN_E2E_DB_PORT')) const DB_USER = process.env.XCHAIN_E2E_DB_USER || 'root' const DB_PASSWORD = process.env.XCHAIN_E2E_DB_PASS || 'e2efixture' const DB_NAME = 'xchain_decoder_e2e_regtest' diff --git a/test/integration/fixtures/docker-compose.test.yml b/test/integration/fixtures/docker-compose.test.yml index bed92c6..4f4f21f 100644 --- a/test/integration/fixtures/docker-compose.test.yml +++ b/test/integration/fixtures/docker-compose.test.yml @@ -53,7 +53,7 @@ services: - -addresstype=legacy - -printtoconsole=1 ports: - - "18544:18443" + - "${XCHAIN_TEST_NODE_PORT:-18544}:18443" tmpfs: - /home/bitcoin/.bitcoin healthcheck: @@ -67,7 +67,7 @@ services: environment: MARIADB_ROOT_PASSWORD: itfixture ports: - - "13318:3306" + - "${XCHAIN_TEST_DB_PORT:-13318}:3306" tmpfs: - /var/lib/mysql healthcheck: diff --git a/test/integration/support/setup.js b/test/integration/support/setup.js index 13e8285..37343a0 100644 --- a/test/integration/support/setup.js +++ b/test/integration/support/setup.js @@ -41,14 +41,15 @@ const BitcoinCore = require('bitcoin-core') const nodeHelper = require('../../helpers/node_helper') const XChainDecoder = require('../../../src/XChainDecoder') const Database = require('../../../src/db.js') +const fixturePorts = require('../../../bin/fixture-ports.js') // Fixture venue. Must match fixtures/docker-compose.test.yml. const NODE_HOST = process.env.XCHAIN_TEST_NODE_HOST || '127.0.0.1' -const NODE_PORT = process.env.XCHAIN_TEST_NODE_PORT || '18544' +const NODE_PORT = String(fixturePorts.port('XCHAIN_TEST_NODE_PORT')) const NODE_USER = process.env.XCHAIN_TEST_NODE_USER || 'itfixture' const NODE_PASSWORD = process.env.XCHAIN_TEST_NODE_PASS || 'itfixture' const DB_HOST = process.env.XCHAIN_TEST_DB_HOST || '127.0.0.1' -const DB_PORT = process.env.XCHAIN_TEST_DB_PORT || '13318' +const DB_PORT = String(fixturePorts.port('XCHAIN_TEST_DB_PORT')) const DB_USER = process.env.XCHAIN_TEST_DB_USER || 'root' const DB_PASSWORD = process.env.XCHAIN_TEST_DB_PASS || 'itfixture' const DB_NAME = 'xchain_decoder_integration_regtest' diff --git a/test/unit/repo/fixture_ports.test.js b/test/unit/repo/fixture_ports.test.js new file mode 100644 index 0000000..85fe0aa --- /dev/null +++ b/test/unit/repo/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('XCHAIN_TEST_NODE_PORT', {}), 18544) + assert.strictEqual(ports.port('XCHAIN_TEST_DB_PORT', { CI_PORT_OFFSET: '0' }), 13318) + }) + + it('adds CI_PORT_OFFSET to host ports', function () { + const env = { CI_PORT_OFFSET: '10700' } + assert.strictEqual(ports.port('XCHAIN_TEST_NODE_PORT', env), 29244) + assert.strictEqual(ports.port('XCHAIN_E2E_DB_PORT', env), 24019) + }) + + it('does not add the offset to an explicit final port override', function () { + const env = { CI_PORT_OFFSET: '10700', XCHAIN_TEST_NODE_PORT: '41000' } + assert.strictEqual(ports.port('XCHAIN_TEST_NODE_PORT', env), 41000) + }) + + it('rejects invalid offsets and out-of-range results', function () { + assert.throws(() => ports.port('XCHAIN_TEST_NODE_PORT', { CI_PORT_OFFSET: '-1' }), /non-negative integer/) + assert.throws(() => ports.port('XCHAIN_TEST_NODE_PORT', { CI_PORT_OFFSET: '50000' }), /TCP port range/) + }) +}) From 6fde4da163a7b2be7028fac14d7aa567af43c7bc Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 13:34:14 -0700 Subject: [PATCH 05/15] refactor(decoder): move migrate.js into src/db/ --- eslint.config.js | 2 +- package.json | 2 +- src/{ => db}/migrate.js | 2 +- test/unit/migrate.test.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename src/{ => db}/migrate.js (99%) diff --git a/eslint.config.js b/eslint.config.js index 48001c9..810ab10 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -77,7 +77,7 @@ const src = { }; const configAndEntry = { - files: ['src/config.js', 'src/api.js', 'src/migrate.js', 'src/index.js', 'src/clear_reorg_halt.js', 'bin/**/*.js'], + files: ['src/config.js', 'src/api.js', 'src/db/migrate.js', 'src/index.js', 'src/clear_reorg_halt.js', 'bin/**/*.js'], rules: { 'no-console': 'off', 'no-restricted-syntax': ['error', diff --git a/package.json b/package.json index c0515bb..1d6eb88 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "scripts": { "api": "node ./src/api.js", - "migrate": "node ./src/migrate.js", + "migrate": "node ./src/db/migrate.js", "clear-reorg-halt": "node ./src/clear_reorg_halt.js", "lint": "eslint .", "test": "mocha --timeout 5000 --require ./test/unit/support/setup.js 'test/unit/**/*.test.js' --exit", diff --git a/src/migrate.js b/src/db/migrate.js similarity index 99% rename from src/migrate.js rename to src/db/migrate.js index 441f960..2bee83b 100644 --- a/src/migrate.js +++ b/src/db/migrate.js @@ -44,7 +44,7 @@ const dotenv = require('dotenv'); dotenv.config(); -const Database = require('./db.js'); +const Database = require('../db.js'); // Spelled out for an operator reading it mid-incident: the difference between a // blanket run and a scoped one is the whole risk of this command, so each mode diff --git a/test/unit/migrate.test.js b/test/unit/migrate.test.js index 927d473..a33c12f 100644 --- a/test/unit/migrate.test.js +++ b/test/unit/migrate.test.js @@ -31,7 +31,7 @@ const assert = require('assert'); const sinon = require('sinon'); const DB_PATH = require.resolve('../../src/db.js'); -const MIGRATE_PATH = require.resolve('../../src/migrate.js'); +const MIGRATE_PATH = require.resolve('../../src/db/migrate.js'); const DOTENV_PATH = require.resolve('dotenv'); const ENV_KEYS = ['DECODER_DB_HOST', 'DECODER_DB_PORT', 'DECODER_DB_NAME', From ae5bae277e5311c59ce4f71340ad30e720d707f1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 08:42:29 -0700 Subject: [PATCH 06/15] docs(decoder): preserve rewritten comment detail --- src/XChainDecoder.js | 6 +++--- src/XChainDecoder/payload_helpers.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/XChainDecoder.js b/src/XChainDecoder.js index 0846167..6015a94 100644 --- a/src/XChainDecoder.js +++ b/src/XChainDecoder.js @@ -55,9 +55,9 @@ bitcoin.initEccLib(ecc); // DROP -> AIRDROP 3 each), so a payload compiled to exactly 8192 bytes is stored as // an 8197-byte BROADCAST string. That is intended and harmless: transactions.data is // MEDIUMTEXT, so nothing truncates. It is deliberately not "fixed" by re-measuring -// the canonical buffer at -// the gate: tightening it would drop transactions whose on-chain push is legal and -// that other nodes accept, forking the fleet and retroactively invalidating +// the canonical buffer at the gate: tightening it would drop transactions whose +// on-chain push is legal and that other nodes accept, forking the fleet and +// retroactively invalidating // already-decoded near-cap alias history. Moving the measurement point is a // consensus change needing a flag-day (a *_ACTIVATION entry in // ./protocol/constants.js keyed on block height and network, deployed fleet-wide diff --git a/src/XChainDecoder/payload_helpers.js b/src/XChainDecoder/payload_helpers.js index 4b6c4a0..97bdfe8 100644 --- a/src/XChainDecoder/payload_helpers.js +++ b/src/XChainDecoder/payload_helpers.js @@ -65,7 +65,7 @@ function compiledPushSize(byteLength){ // the returned buffer themselves, so U+FFFD substitution for invalid UTF-8 is // applied exactly once, at the call site. // -// Returns { buffer, rawActionName, actionName, isKnown }: +// The return object carries { buffer, rawActionName, actionName, isKnown }: // buffer - the payload with its name portion rewritten to the canonical // ASCII spelling when the name was a recognized alias; the // original reference, unmodified, otherwise, which includes From c384a932c1cdc0feaf943949fc5a84d531a8c573 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 08:53:06 -0700 Subject: [PATCH 07/15] 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 f07981c..26b8de5 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 003848eef115bbbaee1c40cd8db2f5797131668b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 09:54:22 -0700 Subject: [PATCH 08/15] 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 80793c2..9e13257 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -59,7 +59,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 @@ -129,8 +157,20 @@ run_tier "docker: end-to-end tier (test:e2e)" npm run test:e2e 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 b1d58c01706ad3e3ffdee53c18153f7f778996ac Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 13:50:18 -0700 Subject: [PATCH 09/15] test(decoder): add coverage for db/query_helpers --- test/unit/db_query_helpers.test.js | 63 ++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 test/unit/db_query_helpers.test.js diff --git a/test/unit/db_query_helpers.test.js b/test/unit/db_query_helpers.test.js new file mode 100644 index 0000000..8eaebab --- /dev/null +++ b/test/unit/db_query_helpers.test.js @@ -0,0 +1,63 @@ +// 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 { resolveQueryTimeout, jsonBigIntSafe, opensBackslashEscape } = require('../../src/db/query_helpers.js'); + +describe('db/query_helpers', () => { + describe('resolveQueryTimeout', () => { + it('keeps an explicit 0 (no timeout)', () => { + assert.strictEqual(resolveQueryTimeout('0'), 0); + }); + + it('falls back to the 30000 default when unset', () => { + assert.strictEqual(resolveQueryTimeout(undefined), 30000); + }); + + it('falls back to the default for a negative value', () => { + assert.strictEqual(resolveQueryTimeout('-5'), 30000); + }); + + it('parses a positive value', () => { + assert.strictEqual(resolveQueryTimeout('45000'), 45000); + }); + }); + + describe('jsonBigIntSafe', () => { + it('converts a safe BigInt to a plain number', () => { + assert.strictEqual(jsonBigIntSafe('k', 123n), 123); + assert.strictEqual(typeof jsonBigIntSafe('k', 123n), 'number'); + }); + + it('passes non-BigInt values through unchanged', () => { + assert.strictEqual(jsonBigIntSafe('k', 'abc'), 'abc'); + }); + + it('converts an unsafe BigInt to its decimal string', () => { + const unsafe = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + assert.strictEqual(jsonBigIntSafe('k', unsafe), unsafe.toString()); + }); + }); + + describe('opensBackslashEscape', () => { + it('is true for a backslash before the closing quote inside a single-quoted span', () => { + assert.strictEqual(opensBackslashEscape("a\\'b", 1, "'"), true); + }); + + it('is false for the same position inside a backtick-quoted span', () => { + assert.strictEqual(opensBackslashEscape("a\\'b", 1, '`'), false); + }); + + it('is false for a trailing backslash at end of string', () => { + const str = 'a\\'; + assert.strictEqual(opensBackslashEscape(str, 1, "'"), false); + }); + }); +}); From 92e80d4b7e838dadc355624cbd14e2d4e6d39959 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 19:24:29 -0700 Subject: [PATCH 10/15] test(decoder): pin the 120 s shutdown contract against the tracker's table, drop an unused export --- .ci-siblings | 2 ++ bin/ci-full.sh | 2 +- .../batch_sub_command_capture/sub_commands.js | 1 - test/unit/shutdown.test.js | 33 +++++++++++++++++++ test/unit/sibling_coverage.test.js | 3 ++ 5 files changed, 39 insertions(+), 2 deletions(-) diff --git a/.ci-siblings b/.ci-siblings index 5e2213a..1e91eee 100644 --- a/.ci-siblings +++ b/.ci-siblings @@ -26,3 +26,5 @@ xchain-hub xchain-indexer # AuxPoW strip parity and the dispenser safe-depth twin. xchain-utxo-tracker +# The shutdown drain staying under the stop budget xchain-node gives a decoder. +xchain-node diff --git a/bin/ci-full.sh b/bin/ci-full.sh index 9e13257..047f8fa 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -119,7 +119,7 @@ need_docker() { } } -need_sib xchain-encoder xchain-documentation xchain-hub xchain-indexer xchain-utxo-tracker +need_sib xchain-encoder xchain-documentation xchain-hub xchain-indexer xchain-utxo-tracker xchain-node # --- job: ci (XChain-Platform/.github ci-reusable.yml -> npm run ci) ------- run_tier "ci" npm run ci diff --git a/src/protocol/batch_sub_command_capture/sub_commands.js b/src/protocol/batch_sub_command_capture/sub_commands.js index acae58b..af64e44 100644 --- a/src/protocol/batch_sub_command_capture/sub_commands.js +++ b/src/protocol/batch_sub_command_capture/sub_commands.js @@ -345,7 +345,6 @@ module.exports = { hasProvablyRejectedSubCommand, expandSubCommandAlias, expandAliasName, - isNumeric, isLegacyActionFormat, subCommandTick, subCommandLimitKey, diff --git a/test/unit/shutdown.test.js b/test/unit/shutdown.test.js index f86f895..0d81eb5 100644 --- a/test/unit/shutdown.test.js +++ b/test/unit/shutdown.test.js @@ -14,8 +14,28 @@ // and the pool kept the process alive, and every stop ended in SIGKILL (exit 137). const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); const { createShutdown, createDecoderDrain, closeServer, closeDatabases, resolveTimeoutMs, DEFAULT_SHUTDOWN_TIMEOUT_MS } = require('../../src/shutdown'); +const NODE_DIR = process.env.XCHAIN_NODE_DIR || path.join(__dirname, '..', '..', '..', 'xchain-node'); +const NODE_STOP_BUDGET_SRC = path.join(NODE_DIR, 'src', 'services', 'stop_budget_service.js'); +const REQUIRE_SIBLINGS = process.env.XCHAIN_REQUIRE_SIBLINGS === '1'; + +// Read the budget xchain-node stops `module` with, from its source text (the +// sibling's npm deps are not installed on the venue, so it is not required). +// Throws on a shape it cannot read, so a moved table fails instead of skipping. +function nodeStopBudgetSeconds(module){ + const src = fs.readFileSync(NODE_STOP_BUDGET_SRC, 'utf8'); + const table = /MODULE_STOP_TIMEOUT_SECONDS\s*=\s*Object\.freeze\(\{([\s\S]*?)\}\)/.exec(src); + if(!table) throw new Error('no MODULE_STOP_TIMEOUT_SECONDS table in ' + NODE_STOP_BUDGET_SRC); + const row = new RegExp("'" + module + "'\\s*:\\s*(\\d+)").exec(table[1]); + if(row) return parseInt(row[1], 10); + const fallback = /DEFAULT_MODULE_STOP_TIMEOUT_SECONDS\s*=\s*(\d+)/.exec(src); + if(!fallback) throw new Error('no DEFAULT_MODULE_STOP_TIMEOUT_SECONDS in ' + NODE_STOP_BUDGET_SRC); + return parseInt(fallback[1], 10); +} + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function waitUntil(predicate, timeoutMs = 5000, intervalMs = 10){ const deadline = Date.now() + timeoutMs; @@ -192,6 +212,19 @@ describe('graceful shutdown', function(){ assert.ok(DEFAULT_SHUTDOWN_TIMEOUT_MS > 10000, 'a block boundary on a mainnet chain is not reached in docker\'s ten seconds'); }); + + // The literal above is a copy of xchain-node's number; this holds the + // relation against the sibling's own table, so a one-sided edit goes red. + it('stays under the stop budget in xchain-node\'s own table when that checkout is beside this one', function(){ + if(!fs.existsSync(NODE_STOP_BUDGET_SRC)){ + if(REQUIRE_SIBLINGS) throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but ' + NODE_STOP_BUDGET_SRC + ' is absent'); + this.skip(); + } + const budgetSeconds = nodeStopBudgetSeconds('xchain-decoder'); + assert.ok(DEFAULT_SHUTDOWN_TIMEOUT_MS < budgetSeconds * 1000, + 'xchain-decoder drains for ' + DEFAULT_SHUTDOWN_TIMEOUT_MS + ' ms but xchain-node stops it after ' + + budgetSeconds + ' s (' + NODE_STOP_BUDGET_SRC + '), so every overrun ends in the daemon\'s SIGKILL'); + }); }); describe('closeServer', function(){ diff --git a/test/unit/sibling_coverage.test.js b/test/unit/sibling_coverage.test.js index 2d464c7..294b5a7 100644 --- a/test/unit/sibling_coverage.test.js +++ b/test/unit/sibling_coverage.test.js @@ -73,6 +73,9 @@ const SIBLINGS = [ { repo: 'xchain-utxo-tracker', envs: ['XCHAIN_UTXO_TRACKER_DIR'], marker: path.join('src', 'chain', 'blockchain_connector.js'), guards: 'AuxPoW strip parity and the dispenser safe-depth twin' }, + { repo: 'xchain-node', envs: ['XCHAIN_NODE_DIR'], + marker: path.join('src', 'services', 'stop_budget_service.js'), + guards: 'the shutdown drain staying under the stop budget xchain-node gives a decoder' }, ]; function resolve(entry) { From 8be531531f01d3b46aaa36d294cbc0232e4f6bfc Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 20:11:16 -0700 Subject: [PATCH 11/15] fix(decoder): pin the undo ceiling to the dispenser safe depth, correct flag and path claims --- bin/pins/identity.json | 2 +- bin/sync-batch-limits.js | 51 ++++---- src/XChainDecoder/dispenser_registration.js | 2 +- src/XChainDecoder/transaction_ingest.js | 3 +- .../blockchain_connector/auxpow_codec.js | 2 +- src/clear_reorg_halt.js | 10 +- src/protocol/action_aliases.js | 6 +- src/protocol/batch_sub_command_capture.js | 8 +- .../batch_sub_command_capture/sub_commands.js | 19 +-- src/protocol/constants.js | 18 +-- src/protocol/indexer_batch_limits.js | 22 ++-- test/fixtures/action-manifest.json | 4 +- ...elimiter_above_the_gate_invariants.test.js | 2 +- test/unit/batch_limits_vendoring.test.js | 2 +- ...only_one_this_decoder_can_ever_see.test.js | 2 +- ..._suppresses_the_whole_capture_view.test.js | 3 +- test/unit/batch_whole_batch_rejection.test.js | 2 +- test/unit/dispenser_field_offsets.test.js | 4 +- test/unit/dispenser_safe_depth.test.js | 110 +++++++++++------- test/unit/reorg_halt_clear.test.js | 8 +- .../05_constants_conformance.test.js | 3 +- 21 files changed, 167 insertions(+), 116 deletions(-) diff --git a/bin/pins/identity.json b/bin/pins/identity.json index 45c9530..7c60c33 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -9,7 +9,7 @@ "src/coins/index.js": "dd350bc0ec999849fe302ac4381f37b7be3eaa866f019a35cbcceebb4d5ebd4b" }, "twinFixtures": { - "test/fixtures/action-manifest.json": "93ac85b4d76f078951a2e95eb3ca303f39fb9f18d0fb91b1ae16693716bef72f", + "test/fixtures/action-manifest.json": "05813d667976ec49bdeb001fc7f5aea41fce4f343a37576a2716d176b334a823", "test/fixtures/roundtrip-conformance.json": "d9963ac7c35bdcfab99595fec040d7791d73119cb62fef20db136f8ea56236ed" } } diff --git a/bin/sync-batch-limits.js b/bin/sync-batch-limits.js index 0e217f5..a62cba4 100644 --- a/bin/sync-batch-limits.js +++ b/bin/sync-batch-limits.js @@ -20,12 +20,13 @@ * node bin/sync-batch-limits.js --check # exit 1 if it has drifted * * WHY A GENERATOR AND NOT A HAND COPY. src/protocol/indexer_batch_limits.js decides which - * BATCHes the decoder refuses to capture for, and it must agree with - * xchain-indexer/src/actions/batch.js exactly: a cap that exists here and not there - * SUPPRESSES capture for a batch the indexer dispatches, which is the money-bearing - * under-capture direction. Two hand-maintained copies of one consensus table can never - * re-converge once they diverge (the platform's own coins-registry lesson), so the vendored - * file is written FROM the sibling and a unit test re-derives it on every run. + * BATCHes the decoder refuses to capture for, and it must agree with the indexer's Batch + * handler (xchain-indexer/src/actions/batch/, caps installed by limits.js) exactly: a cap + * that exists here and not there SUPPRESSES capture for a batch the indexer dispatches, + * which is the money-bearing under-capture direction. Two hand-maintained copies of one + * consensus table can never re-converge once they diverge (the platform's own + * coins-registry lesson), so the vendored file is written FROM the sibling and a unit test + * re-derives it on every run. * * WHAT IT READS. The real Batch class, INSTANTIATED, not a regex over its source: the caps * live on `this` in the constructor, so a rename, a reformat or a move to a computed value @@ -36,7 +37,7 @@ * WHAT IT DELIBERATELY DOES NOT VENDOR: the BATCH_ISSUANCE_LIMITS activation INSTANT. * The decoder never evaluates that flag at runtime. Its own gate * (BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION) is required by - * batchSubCommandOutputCaptureActivation.test.js to sit at or after the indexer's + * batch_sub_command_output_capture_activation.test.js to sit at or after the indexer's * BATCH_ISSUANCE_LIMITS instant on every ARMED network, so at every block time where this * module's rules run, that flag is provably already on. Copying the instant here would add a * second thing to drift and would answer a question the ordering invariant has already @@ -45,12 +46,14 @@ * * WHAT IT DOES VENDOR, AND WHY THE PARAGRAPH ABOVE DOES NOT COVER IT: the * BATCH_COST_WEIGHTING activation INSTANT. That ordering invariant is specific to - * BATCH_ISSUANCE_LIMITS and it does NOT hold for the weighting flag: mainnet capture is - * ARMED at 1786838400 while the weighting instant is still the 9999999999 house sentinel, - * so mainnet is a live block time where this module's rules run and the weight budget does - * NOT apply. Applying the budget there would suppress capture for batches the indexer - * dispatches, which is the money-bearing under-capture direction. The instant is therefore - * carried per network and the rule is gated on it, rather than assumed on. + * BATCH_ISSUANCE_LIMITS and does NOT bind the weighting flag, which may sit BELOW capture: + * mainnet's weighting instant is genesis (0) while capture is armed at 1786838400. That is + * safe because the indexer applies the budget only inside its BATCH_ISSUANCE_LIMITS guard, + * which shares capture's instant, so below it neither side weighs. The instant is still + * carried per network and the rule gated on it, rather than assumed on, because a network + * may be DISARMED (null): applying the budget where the indexer does not would suppress + * capture for batches the indexer dispatches, which is the money-bearing under-capture + * direction. * * Lives under bin/ as an operator tool rather than inside a suite file, because it is a * maintenance tool for the conformance suite that consumes it: batch_limits_vendoring.test.js @@ -189,10 +192,11 @@ function renderModule(derived){ * unit run; skips only when the sibling checkout is absent, and * XCHAIN_REQUIRE_SIBLINGS=1 turns that skip into a failure) * - * SOURCE OF TRUTH: xchain-indexer/src/actions/batch.js, read by instantiating the real Batch - * class. These are the tables whose breach makes the indexer reject a BATCH AS A WHOLE, so - * that not one of its sub-commands runs. The decoder mirrors them to stop capturing outputs - * for commands nothing will execute (batchSubCommandCapture.hasProvablyRejectedBatch). + * SOURCE OF TRUTH: xchain-indexer/src/actions/batch/ (entry index.js, caps in limits.js), read + * by instantiating the real Batch class. These are the tables whose breach makes the indexer + * reject a BATCH AS A WHOLE, so that not one of its sub-commands runs. The decoder mirrors + * them to stop capturing outputs for commands nothing will execute + * (batchSubCommandCapture.hasProvablyRejectedBatch). * * THE DIRECTION OF ERROR IS NOT SYMMETRIC, which is why this file is generated rather than * typed: a cap that is TIGHTER here than in the indexer suppresses capture for a batch the @@ -202,9 +206,9 @@ function renderModule(derived){ ********************************************************************/ // Global per-BATCH command cap. Breached => 'invalid: COMMAND (limit)', whole batch. -// GATED on BATCH_ISSUANCE_LIMITS in the indexer; see the module header of -// batchSubCommandCapture.js for why that flag is provably active wherever the decoder's -// own capture gate is. +// GATED on BATCH_ISSUANCE_LIMITS in the indexer; see the WHICH FLAG STATE block in +// batch_sub_command_capture/sub_commands.js for why that flag is provably active wherever +// the decoder's own capture gate is. const COMMAND_LIMIT = ${literal(derived.COMMAND_LIMIT)}; // Per-ACTION caps in force in BOTH flag states (indexer: this.actionLimits). @@ -229,9 +233,10 @@ const WEIGHT_BUDGET = ${literal(derived.WEIGHT_BUDGET)}; const COMMAND_WEIGHTS = ${renderTable(derived.COMMAND_WEIGHTS, 0)}; // Per-network BATCH_COST_WEIGHTING activation instants (block TIME, >=), read off the -// sibling's protocol-change registry. Unlike BATCH_ISSUANCE_LIMITS this flag is NOT provably -// on wherever the decoder's capture gate is: mainnet capture is armed while this instant is -// still the house sentinel. null means DISARMED, which is inactive at every block time. +// sibling's protocol-change registry. Unlike BATCH_ISSUANCE_LIMITS this flag is NOT ordered +// against the decoder's capture gate; an instant below capture is safe because the indexer +// weighs only inside its BATCH_ISSUANCE_LIMITS guard, which shares capture's instant. +// null means DISARMED, which is inactive at every block time. const COST_WEIGHTING_ACTIVATION = ${renderTable(derived.COST_WEIGHTING_ACTIVATION, 0)}; module.exports = { diff --git a/src/XChainDecoder/dispenser_registration.js b/src/XChainDecoder/dispenser_registration.js index 8b17898..bc9b050 100644 --- a/src/XChainDecoder/dispenser_registration.js +++ b/src/XChainDecoder/dispenser_registration.js @@ -73,7 +73,7 @@ const { isBatchSubCommandCaptureActive } = require('../protocol/batch_sub_comman // indexer's own default does), and the operating address from THIS // command's GET_ADDRESS. There is no per-sub-command DISPENSER_ACTION_INDEX // to reproduce: the indexer mints one per sub-command from its own -// action_index sequence (actions/batch.js -> db.createActionIndex -> +// action_index sequence (actions/batch/index.js -> db.createActionIndex -> // getNextActionIndex), an id space the decoder has never held for // top-level dispensers either. These rows are keyed on // (tx_index, operating address) and nothing here is keyed on an diff --git a/src/XChainDecoder/transaction_ingest.js b/src/XChainDecoder/transaction_ingest.js index 01a79a7..6baacba 100644 --- a/src/XChainDecoder/transaction_ingest.js +++ b/src/XChainDecoder/transaction_ingest.js @@ -177,7 +177,8 @@ async function storeDispenseOutput(loop, nextOutput, nextBlockHeight){ // this list is exactly [decodedData] and both tests reduce to the // startsWith they replace; at/above the gate a BATCH yields its // SUB-COMMANDS instead, split to agree with -// xchain-indexer/src/actions/batch.js (see batchSubCommandCapture). +// xchain-indexer/src/actions/batch/validate.js readCommands (see +// src/protocol/batch_sub_command_capture.js). async function capturePaymentOutputs(loop, block, parseResult, nextTransactionHash, nextBlockHeight, decodedData){ let commands = captureCommands(decodedData, this.consensusNetwork, block.timestamp) let isCoinpay = commands.some(nextCommand => nextCommand.startsWith("COINPAY|")) diff --git a/src/chain/blockchain_connector/auxpow_codec.js b/src/chain/blockchain_connector/auxpow_codec.js index f16b02f..3a4615d 100644 --- a/src/chain/blockchain_connector/auxpow_codec.js +++ b/src/chain/blockchain_connector/auxpow_codec.js @@ -143,7 +143,7 @@ function skipAuxPow(buf, start) { // returns exactly 160 chars, requiring the AuxPoW size to be parsed structurally from // the block hex (skipAuxPow). Non-AuxPoW blocks pass through unchanged. // Keep in sync with xchain-utxo-tracker/src/chain/blockchain_connector.js stripAuxPowFromBlockHex. -// test/unit/auxpowStripParity.test.js asserts byte identity of the two function bodies, +// test/unit/auxpow_strip_parity.test.js asserts byte identity of the two function bodies, // so a strip correction cannot land in one repo alone. function stripAuxPowFromBlockHex(headerHex, blockHex) { const dataToRemove = headerHex.length - 160 // 160 hex chars = 80-byte standard header diff --git a/src/clear_reorg_halt.js b/src/clear_reorg_halt.js index 3a52dfa..a96f6fd 100644 --- a/src/clear_reorg_halt.js +++ b/src/clear_reorg_halt.js @@ -30,8 +30,9 @@ * supersedes, and db.readReorgHaltState lets the newest row decide. Preconditions: * * 1. no block is still missing above the tip (countReorgDeletesAboveTip == 0): - * the rolled-back range has been re-parsed. Cannot be forced; wait for the - * decoder to catch up. + * the rolled-back range has been re-parsed. Cannot be forced. A decoder still + * parsing forward on a dormant marker catches up on its own; a PARKED one + * (reorg_halt_parked) never re-parses the range, so its recovery is a full resync. * 2. the database holds no dispenser state and never decoded a DISPENSER * action, so the purge cannot have lost anything. --force overrides this one * for an operator who has compared the dispensers table against a known-good @@ -93,7 +94,10 @@ async function checkClearPreconditions(db, args, error){ const deletesAboveTip = await db.countReorgDeletesAboveTip() if (deletesAboveTip > 0){ error('clear-reorg-halt: REFUSED. ' + deletesAboveTip + ' block(s) rolled back above the current tip have not been re-parsed yet. ' - + 'Wait for the decoder to catch up past the halt height, then run this again. This check cannot be forced.') + + 'This check cannot be forced. If the decoder is parked on this halt (reorg_halt_parked: true on health, ' + + '/status or /live), it parses nothing and will never re-parse this range: recover with a full resync from ' + + 'a known-good snapshot. If it is still parsing forward on a dormant marker (reorg_halt_parked: false), wait ' + + 'for it to pass the halt height, then run this again.') return { exitCode: EXIT.NOT_RESYNCED } } diff --git a/src/protocol/action_aliases.js b/src/protocol/action_aliases.js index 9986354..cc709d6 100644 --- a/src/protocol/action_aliases.js +++ b/src/protocol/action_aliases.js @@ -17,7 +17,7 @@ * Lives in its own module because TWO decode-time readers need it and one of * them cannot require the other. XChainDecoder.js expands the alias on the * TOP-LEVEL action name (canonicalizeActionPayload), and - * batchSubCommandCapture.js expands it on a BATCH's SUB-COMMAND names; the + * batch_sub_command_capture.js expands it on a BATCH's SUB-COMMAND names; the * capture module is required BY XChainDecoder.js, so reaching back for the * table would be a require cycle. XChainDecoder.js re-exports this object * under its historical name, so every existing reader @@ -36,8 +36,8 @@ // payload to the canonical form, so the decoder DB never holds aliased names and // every downstream consumer sees one spelling per action. // -// The indexer's twin is `actions.js actionAliases`, applied to a BATCH's -// sub-actions by `batch.js normalizeSubAction` at/after the +// The indexer's twin is `actions/index.js actionAliases`, applied to a BATCH's +// sub-actions by `batch/sub_command.js normalizeSubAction` at/after the // BATCH_SUBACTION_NORMALIZATION flag-day. Both tables are pinned to the same // canonical manifest, which is what makes the decoder's sub-command view and the // indexer's dispatch agree about what a batched `TRANSFER` IS. diff --git a/src/protocol/batch_sub_command_capture.js b/src/protocol/batch_sub_command_capture.js index ff78744..b9e7c61 100644 --- a/src/protocol/batch_sub_command_capture.js +++ b/src/protocol/batch_sub_command_capture.js @@ -39,8 +39,8 @@ * split MUST agree with the indexer's, because a decoder * that disagrees about what the sub-commands ARE captures for actions the indexer never * runs (or misses ones it does) - a worse fault than the one being fixed. See - * batchSubCommands below for the equivalence argument against - * xchain-indexer/src/actions/batch.js. + * batchSubCommands in batch_sub_command_capture/sub_commands.js for the equivalence argument + * against xchain-indexer/src/actions/batch/validate.js readCommands. * ********************************************************************/ @@ -98,8 +98,8 @@ const { hasProvablyRejectedBatch, // or after BATCH_SUBACTION_NORMALIZATION. Below THAT flag an aliased sub-command is an // unregistered name and whole-batch-rejects instead of dispatching, so expanding it there // would over-capture. The ordering holds today (testnet/regtest genesis-on for both, -// mainnet normalization active since 2026-08-07 with this gate still disarmed) and -// batchSubCommandOutputCaptureActivation.test.js drives it against the sibling indexer +// mainnet normalization at 2026-08-07, below this gate's 2026-08-16 instant) and +// batch_sub_command_output_capture_activation.test.js drives it against the sibling indexer // rather than leaving it as a comment. function captureCommands(decodedData, consensusNetwork, blockTime){ if (!isBatchSubCommandCaptureActive(consensusNetwork, blockTime)) diff --git a/src/protocol/batch_sub_command_capture/sub_commands.js b/src/protocol/batch_sub_command_capture/sub_commands.js index af64e44..8aaf279 100644 --- a/src/protocol/batch_sub_command_capture/sub_commands.js +++ b/src/protocol/batch_sub_command_capture/sub_commands.js @@ -17,7 +17,7 @@ const { BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION } = require('../constants.js') const { CHILD_ISSUE_KEY } = require('../indexer_batch_limits.js') -// The BATCH FORMAT versions the indexer registers (xchain-indexer/src/actions/batch.js +// The BATCH FORMAT versions the indexer registers (xchain-indexer/src/actions/batch/index.js // `this.formats`, which today holds only 0 = 'VERSION|COMMAND'). A BATCH whose FORMAT is // not registered is whole-batch rejected there with 'invalid: VERSION (unknown)' and no // sub-command ever runs, so capture must not see sub-commands in one either. Adding a @@ -51,7 +51,8 @@ function isBatchSubCommandCaptureActive(consensusNetwork, blockTime){ // The sub-commands of a BATCH action string, or null when the string is not a BATCH at all. // An empty array means "a BATCH, but one whose sub-commands never execute". // -// EQUIVALENCE WITH THE INDEXER (xchain-indexer/src/actions/batch.js run()): +// EQUIVALENCE WITH THE INDEXER (xchain-indexer/src/actions/batch/validate.js readCommands, +// reached from parse() in batch/index.js): // // let commands = String(data['TX_DATA']).split(';'); // commands[0] = commands[0].replace('BATCH|' + format + '|',''); @@ -66,7 +67,8 @@ function isBatchSubCommandCaptureActive(consensusNetwork, blockTime){ // head whose FORMAT token reads exactly as the derived integer. A token that derives // to 0 by another spelling ('', '"0"', ' 0 ', '00') leaves the head intact. // 3. When the head is NOT stripped, element 0's action name is still BATCH, and -// actionLimits['BATCH'] is 0, so the scan sets 'invalid: BATCH (limit)' and again no +// actionLimits['BATCH'] is 0 (batch/limits.js), so the per-ACTION cap scan +// (batch/validate.js actionCapError) sets 'invalid: BATCH (limit)' and again no // sub-command runs. (This also covers the case where the replace fires on a LATER // 'BATCH|0|' occurrence inside element 0: the head survives, so the action is BATCH.) // @@ -107,7 +109,8 @@ function subCommandActionName(command){ // Does this BATCH carry a sub-command whose ACTION NAME the indexer's activation scan // PROVABLY rejects, taking the whole batch down with it? // -// WHY CAPTURE HAS TO CARE. batch.js runs, before any dispatch: +// WHY CAPTURE HAS TO CARE. The indexer's activation scan (batch/validate.js activationError) +// runs, before any dispatch: // // for(let command of commands){ // let action = String(command).split('|')[0]; @@ -140,7 +143,7 @@ function subCommandActionName(command){ // The rest of the class is now closed as far as it is provable, in hasProvablyRejectedBatch // below: the nested BATCH, the per-ACTION caps, the 250-command cap and the // BATCH_COST_WEIGHTING weight budget, against the indexer's tables vendored canonically in -// src/protocol/indexerBatchLimits.js. The UNKNOWN NAME is still the one cause left open, and +// src/protocol/indexer_batch_limits.js. The UNKNOWN NAME is still the one cause left open, and // deliberately, for the reason this paragraph gives: a vendored name LIST is not closed under // registry growth, so a stale one under-captures. // @@ -152,7 +155,7 @@ function hasProvablyRejectedSubCommand(subCommands){ } // Expand a short-form ACTION alias on a sub-command, mirroring the alias half of the -// indexer's `batch.js normalizeSubAction`. Only the NAME is rewritten; every character +// indexer's `batch/sub_command.js normalizeSubAction`. Only the NAME is rewritten; every character // from the first '|' onward is returned verbatim. // // The VERSION-0 injection normalizeSubAction also performs is deliberately NOT mirrored: @@ -218,7 +221,7 @@ function expandAliasName(actionName, aliases){ // // 1. Nothing here can run below BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION, and that gate // is REQUIRED to sit at or after the indexer's BATCH_ISSUANCE_LIMITS instant on every -// armed network - the LEDGER tier of batchSubCommandOutputCaptureActivation.test.js, +// armed network - the LEDGER tier of batch_sub_command_output_capture_activation.test.js, // which predates this change and exists for the settlement ledger. So at every block // time these rules are evaluated, that flag is already on. batch_limits_vendoring.test.js // completes the argument by pinning the other two halves of the indexer's own gate @@ -270,7 +273,7 @@ function isLegacyActionFormat(params){ // // `normalize` is not a parameter: every block time this module runs at is at/after // BATCH_SUBACTION_NORMALIZATION, asserted by the NORMALIZATION tier of -// batchSubCommandOutputCaptureActivation.test.js, so the indexer's `normalize` is true. +// batch_sub_command_output_capture_activation.test.js, so the indexer's `normalize` is true. // Returns '' when there is no TICK at all - never a token named the empty string. // Never throws: a classifier crash here would take down block decoding. function subCommandTick(action, command){ diff --git a/src/protocol/constants.js b/src/protocol/constants.js index a081a08..dc1613e 100644 --- a/src/protocol/constants.js +++ b/src/protocol/constants.js @@ -497,9 +497,9 @@ const DISPENSER_CANCEL_GRACE_ACTIVATION = { // nothing ("COINPAY (skip): destination mismatch tx= payee=", witnessed on regtest), and // a batched Mode B DISPENSER is rejected for a missing oracle fee whether or not the payer paid. // Both are money-bearing: the payer's coin is spent and nothing settles. At/above the gate the -// capture decision runs over the batch's sub-command list, split exactly as -// xchain-indexer/src/actions/batch.js splits it, so a batched COINPAY captures the same outputs a -// top-level COINPAY does. +// capture decision runs over the batch's sub-command list, split exactly as the indexer's +// xchain-indexer/src/actions/batch/validate.js readCommands splits it, so a batched COINPAY +// captures the same outputs a top-level COINPAY does. // // THE OPEN-DISPENSER REGISTRY RIDES THE SAME INSTANT, deliberately, because it is the same // blindness and the same decision. `decodedData.startsWith("DISPENSER")` is false for @@ -509,7 +509,7 @@ const DISPENSER_CANCEL_GRACE_ACTIVATION = { // user could open a dispenser in a batch, fund it, and it would never dispense. That registry IS // the address set the dispense half of output capture tests against, so splitting the two across // two flag-days would leave the decoder half-batch-aware for a stretch of chain with nothing -// gained. One instant arms both; xchain-decoder/test/unit/batchDispenserRegistration.test.js +// gained. One instant arms both; xchain-decoder/test/unit/batch_dispenser_registration.test.js // drives the coupling rather than asserting it in prose. // // CONSENSUS-AFFECTING: it changes the set of rows written to transaction_outputs, which changes @@ -518,7 +518,7 @@ const DISPENSER_CANCEL_GRACE_ACTIVATION = { // BELOW the gate and pre-flag-day history re-decodes byte-identically. // // NEVER ARM IT BELOW two sibling instants, both asserted in -// test/unit/batchSubCommandOutputCaptureActivation.test.js: +// test/unit/batch_sub_command_output_capture_activation.test.js: // * the indexer's FIX_OUTPUT_FANOUT. A BATCH is a data-bearing, non-COINPAY row, so the extra // captured outputs fan it out to several rows, and BELOW that flag-day // output_fanout.collapseOutputFanout treats that as a consensus-critical fault and HALTS the @@ -527,12 +527,12 @@ const DISPENSER_CANCEL_GRACE_ACTIVATION = { // Capture without that ledger lets N COINPAY sub-commands settle N obligations from ONE // payment, which is the defect this spec's R5 closes; arming capture first would open it. // -// null means DISARMED (never active), the fail-closed default: mainnet keeps the legacy +// null means DISARMED (never active), the fail-closed default: a network keeps the legacy // top-level-only view until the operator ratifies an instant, chosen with the fleet's upgrade // state in hand, because arming it too early forks the chain and arming it in the past rewrites -// agreed history. testnet and regtest are genesis-on, matching BOTH sibling gates there -// (FIX_OUTPUT_FANOUT and BATCH_ISSUANCE_LIMITS are all-zeros off mainnet), so the venues exercise -// the sub-command path from block 0. +// agreed history. Mainnet is ARMED (below). testnet and regtest are genesis-on, matching BOTH +// sibling gates there (FIX_OUTPUT_FANOUT and BATCH_ISSUANCE_LIMITS are all-zeros off mainnet), +// so the venues exercise the sub-command path from block 0. // // DEPLOY DEADLINE, once an instant is armed: EVERY decoder on that network MUST be running the // armed value before the instant, or the fleet splits on the first BATCH carrying a COINPAY or a diff --git a/src/protocol/indexer_batch_limits.js b/src/protocol/indexer_batch_limits.js index fe11ec8..18da635 100644 --- a/src/protocol/indexer_batch_limits.js +++ b/src/protocol/indexer_batch_limits.js @@ -21,10 +21,11 @@ * unit run; skips only when the sibling checkout is absent, and * XCHAIN_REQUIRE_SIBLINGS=1 turns that skip into a failure) * - * SOURCE OF TRUTH: xchain-indexer/src/actions/batch.js, read by instantiating the real Batch - * class. These are the tables whose breach makes the indexer reject a BATCH AS A WHOLE, so - * that not one of its sub-commands runs. The decoder mirrors them to stop capturing outputs - * for commands nothing will execute (batchSubCommandCapture.hasProvablyRejectedBatch). + * SOURCE OF TRUTH: xchain-indexer/src/actions/batch/ (entry index.js, caps in limits.js), read + * by instantiating the real Batch class. These are the tables whose breach makes the indexer + * reject a BATCH AS A WHOLE, so that not one of its sub-commands runs. The decoder mirrors + * them to stop capturing outputs for commands nothing will execute + * (batchSubCommandCapture.hasProvablyRejectedBatch). * * THE DIRECTION OF ERROR IS NOT SYMMETRIC, which is why this file is generated rather than * typed: a cap that is TIGHTER here than in the indexer suppresses capture for a batch the @@ -34,9 +35,9 @@ ********************************************************************/ // Global per-BATCH command cap. Breached => 'invalid: COMMAND (limit)', whole batch. -// GATED on BATCH_ISSUANCE_LIMITS in the indexer; see the module header of -// batchSubCommandCapture.js for why that flag is provably active wherever the decoder's -// own capture gate is. +// GATED on BATCH_ISSUANCE_LIMITS in the indexer; see the WHICH FLAG STATE block in +// batch_sub_command_capture/sub_commands.js for why that flag is provably active wherever +// the decoder's own capture gate is. const COMMAND_LIMIT = 250; // Per-ACTION caps in force in BOTH flag states (indexer: this.actionLimits). @@ -73,9 +74,10 @@ const COMMAND_WEIGHTS = { }; // Per-network BATCH_COST_WEIGHTING activation instants (block TIME, >=), read off the -// sibling's protocol-change registry. Unlike BATCH_ISSUANCE_LIMITS this flag is NOT provably -// on wherever the decoder's capture gate is: mainnet capture is armed while this instant is -// still the house sentinel. null means DISARMED, which is inactive at every block time. +// sibling's protocol-change registry. Unlike BATCH_ISSUANCE_LIMITS this flag is NOT ordered +// against the decoder's capture gate; an instant below capture is safe because the indexer +// weighs only inside its BATCH_ISSUANCE_LIMITS guard, which shares capture's instant. +// null means DISARMED, which is inactive at every block time. const COST_WEIGHTING_ACTIVATION = { "mainnet": 0, "testnet": 0, diff --git a/test/fixtures/action-manifest.json b/test/fixtures/action-manifest.json index 43a8712..a834354 100644 --- a/test/fixtures/action-manifest.json +++ b/test/fixtures/action-manifest.json @@ -2,8 +2,8 @@ "$schema_note": "Authoritative registry of every XChain protocol ACTION and which repos must wire it. Single source of truth for the cross-repo action lockstep. Adding an action = add one entry here, re-vendor the copies, and the per-repo ActionManifestConformance guards force every repo to wire it (or fail CI). Generated from live wiring at HEAD; it codifies what IS, not an aspiration.", "authority": "xchain-documentation/protocol/action-manifest.json is authoritative. xchain-{decoder,encoder,indexer,sdk,wallet,explorer}/test/fixtures/action-manifest.json vendor byte-identical copies so each repo CI asserts its slice without a sibling checkout. Keep all copies identical.", "flags": { - "wireDecoded": "top-level ACTION-encoded on-chain tx the DECODER must decode (xchain-decoder VALID_ACTION_NAMES); also enforced pre-broadcast by the ENCODER's own VALID_ACTION_NAMES/ACTION_ALIASES gate (xchain-encoder src/validator.js)", - "indexerHandled": "the INDEXER dispatches a handler for it (xchain-indexer src/actions.js action== switch)", + "wireDecoded": "top-level ACTION-encoded on-chain tx the DECODER must decode (xchain-decoder VALID_ACTION_NAMES); also enforced pre-broadcast by the ENCODER's own VALID_ACTION_NAMES/ACTION_ALIASES gate (xchain-encoder src/common/validator/constants.js, applied in src/common/validator/action_data_checks.js)", + "indexerHandled": "the INDEXER dispatches a handler for it (xchain-indexer src/actions/actions_class/dispatch.js action== switch)", "userEncodable": "the SDK can author it (xchain-sdk Formats keys)", "userEncodableVersions": "REQUIRED on every userEncodable action, forbidden on the rest: the exact list of FORMAT versions a user may author (xchain-sdk Formats[ACTION] keys). Present because userEncodable alone is action-level, so a version the indexer accepts only when it synthesizes it could be added to the SDK Formats without any guard noticing. Each entry is audited against the indexer handler's own this.formats map plus its system-only gates, so a version listed here is one a user-broadcast tx can legitimately carry.", "explorerRender": "the EXPLORER renders it (xchain-explorer getActionData)", diff --git a/test/unit/batch_dispenser_registration.test/08_the_dispenser_prefix_carries_its_delimiter_above_the_gate_invariants.test.js b/test/unit/batch_dispenser_registration.test/08_the_dispenser_prefix_carries_its_delimiter_above_the_gate_invariants.test.js index 5bbf689..8d22219 100644 --- a/test/unit/batch_dispenser_registration.test/08_the_dispenser_prefix_carries_its_delimiter_above_the_gate_invariants.test.js +++ b/test/unit/batch_dispenser_registration.test/08_the_dispenser_prefix_carries_its_delimiter_above_the_gate_invariants.test.js @@ -60,7 +60,7 @@ describe('BATCH dispenser registration', function () { BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION.mainnet = saved } // Give the map back exactly what was borrowed; the value itself is pinned in - // test/unit/batchSubCommandOutputCaptureActivation.test.js, not re-litigated here. + // test/unit/batch_sub_command_output_capture_activation.test.js, not re-litigated here. assert.strictEqual(BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION.mainnet, saved, 'the map must be back to its pre-probe value') const belowRestored = typeof saved === 'number' ? saved - 1 : ARMED diff --git a/test/unit/batch_limits_vendoring.test.js b/test/unit/batch_limits_vendoring.test.js index 28e3072..33eff1f 100644 --- a/test/unit/batch_limits_vendoring.test.js +++ b/test/unit/batch_limits_vendoring.test.js @@ -22,7 +22,7 @@ // 2. FLAG STATE - the decoder applies the POST-flag rule set unconditionally, which is only // sound because its own capture gate cannot precede the indexer's // BATCH_ISSUANCE_LIMITS activation. That ordering is checked on TIME by -// batchSubCommandOutputCaptureActivation.test.js; the other two legs of the +// batch_sub_command_output_capture_activation.test.js; the other two legs of the // indexer's own gate (block-index thresholds, consensus version) are checked // here, because "the time has passed" only means "the flag is on" when // those two cannot independently hold it off. diff --git a/test/unit/batch_limits_vendoring.test/tier_2_the_post_flag_rule_set_is_the_only_one_this_decoder_can_ever_see.test.js b/test/unit/batch_limits_vendoring.test/tier_2_the_post_flag_rule_set_is_the_only_one_this_decoder_can_ever_see.test.js index 6310d38..9a31658 100644 --- a/test/unit/batch_limits_vendoring.test/tier_2_the_post_flag_rule_set_is_the_only_one_this_decoder_can_ever_see.test.js +++ b/test/unit/batch_limits_vendoring.test/tier_2_the_post_flag_rule_set_is_the_only_one_this_decoder_can_ever_see.test.js @@ -148,7 +148,7 @@ describe(OUTER_TITLE, function () { // merely being a fact about two numbers: // // * the indexer's budget is a strict refinement of BATCH_ISSUANCE_LIMITS. - // src/actions/batch.js reads its BATCH_COST_WEIGHTING verdict ONLY inside + // src/actions/batch/ reads its BATCH_COST_WEIGHTING verdict ONLY inside // `if(limitsActive)` blocks, so below that gate's mainnet instant no bound // runs at all, whatever the weighting instant says; // * this decoder cannot suppress there either, because captureCommands exits diff --git a/test/unit/batch_sub_command_name_gate.test/a_provably_rejected_sub_command_suppresses_the_whole_capture_view.test.js b/test/unit/batch_sub_command_name_gate.test/a_provably_rejected_sub_command_suppresses_the_whole_capture_view.test.js index f06f204..b31cfd8 100644 --- a/test/unit/batch_sub_command_name_gate.test/a_provably_rejected_sub_command_suppresses_the_whole_capture_view.test.js +++ b/test/unit/batch_sub_command_name_gate.test/a_provably_rejected_sub_command_suppresses_the_whole_capture_view.test.js @@ -23,7 +23,8 @@ // file's two halves: // // 1. WHOLE-BATCH REJECTION, live today. The indexer's activation scan -// (batch.js parse(): isEnabled(split('|')[0]) over every command) invalidates the +// (batch/validate.js activationError: isEnabled(split('|')[0]) over every command) +// invalidates the // ENTIRE batch as one record when any sub-command name is unregistered, so NO // sub-command runs - not the bad one and not its well-formed siblings. Capture kept // reading those siblings. `BATCH|0|DISPENSER|0|...;` - one trailing semicolon - diff --git a/test/unit/batch_whole_batch_rejection.test.js b/test/unit/batch_whole_batch_rejection.test.js index 77c1139..7732aa4 100644 --- a/test/unit/batch_whole_batch_rejection.test.js +++ b/test/unit/batch_whole_batch_rejection.test.js @@ -55,7 +55,7 @@ const reject = (subCommands) => hasProvablyRejectedBatch(subCommands, ACTION_ALI // view the live fleet wrote. Derived from the map so it stays below the gate wherever the // operator ratified it; a DISARMED mainnet is inactive at every block time, so an absurd one // serves there. The instant itself is pinned in -// test/unit/batchSubCommandOutputCaptureActivation.test.js. +// test/unit/batch_sub_command_output_capture_activation.test.js. const BELOW_MAINNET_GATE = typeof BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION.mainnet === 'number' ? BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION.mainnet - 1 diff --git a/test/unit/dispenser_field_offsets.test.js b/test/unit/dispenser_field_offsets.test.js index 7f9a5cb..80bf344 100644 --- a/test/unit/dispenser_field_offsets.test.js +++ b/test/unit/dispenser_field_offsets.test.js @@ -20,8 +20,8 @@ // (xchain-indexer/src/actions/dispenser/index.js this.formats, moved there from the former // single-file src/actions/dispenser.js by the indexer M3 directory split), and until this // guard existed the only thing binding the two was a prose comment, while every comparable -// dependency at this seam already had a mechanical gate (indexerBatchLimits.js vendoring, -// oracleFeeOutputActivationConformance.js). +// dependency at this seam already had a mechanical gate (indexer_batch_limits.js vendoring, +// oracle_fee_output_activation_conformance.test.js). // // Drift is money-bearing in both directions: a field inserted ahead of ORACLE_ADDRESS makes // capture key on the wrong token, so the indexer rejects every fee-bearing Mode B create diff --git a/test/unit/dispenser_safe_depth.test.js b/test/unit/dispenser_safe_depth.test.js index 9b8b8d7..99f4538 100644 --- a/test/unit/dispenser_safe_depth.test.js +++ b/test/unit/dispenser_safe_depth.test.js @@ -52,48 +52,76 @@ describe('DISPENSER_EXPIRE_SAFE_DEPTH', function () { // xchain-utxo-tracker fails this suite until the purge depth is re-bumped. // Skips when the sibling repo is not checked out (matching the existing // ActionManifestConformance / ConsensusPrimitiveConformance convention). - describe('conformance to canonical undo_blocks.js', function () { - const TRACKER = process.env.XCHAIN_UTXO_TRACKER_DIR || - path.join(__dirname, '..', '..', '..', 'xchain-utxo-tracker'); - const UNDO = path.join(TRACKER, 'src', 'chain', 'undo_blocks.js'); - before(function () { if (!fs.existsSync(UNDO)) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('xchain-utxo-tracker sibling not found at ' + UNDO + ' but XCHAIN_REQUIRE_SIBLINGS=1'); this.skip(); } }); + describe('conformance to canonical undo_blocks.js', trackerConformanceSuite); +}); - it('SAFE_DEPTH exceeds every canonical per-chain undo window by the margin', function () { - const { DEFAULT_UNDO_BLOCKS } = require(UNDO); - const cases = [ - ['BTC', 'mainnet'], ['BTC', 'testnet'], ['BTC', 'regtest'], - ['LTC', 'mainnet'], ['LTC', 'testnet'], ['LTC', 'regtest'], - ['DOGE', 'mainnet'], ['DOGE', 'testnet'], ['DOGE', 'regtest'] - ]; - for (const [coin, network] of cases) { - const window = DEFAULT_UNDO_BLOCKS[coin + '_' + network.toUpperCase()]; - const safeDepth = XChainDecoder.resolveDispenserExpireSafeDepth(coin, network); - assert.ok(safeDepth >= window + SAFETY_MARGIN, - `${coin}/${network} SAFE_DEPTH (${safeDepth}) must be >= undo window (${window}) + margin (${SAFETY_MARGIN})`); - } - }); +// Reads the sibling tracker's undo_blocks.js; skips when that checkout is absent. +function trackerConformanceSuite() { + const TRACKER = process.env.XCHAIN_UTXO_TRACKER_DIR || + path.join(__dirname, '..', '..', '..', 'xchain-utxo-tracker'); + const UNDO = path.join(TRACKER, 'src', 'chain', 'undo_blocks.js'); + before(function () { if (!fs.existsSync(UNDO)) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('xchain-utxo-tracker sibling not found at ' + UNDO + ' but XCHAIN_REQUIRE_SIBLINGS=1'); this.skip(); } }); - it('the hand-copied standard floor still matches the unchanged mainnet window', function () { - const { DEFAULT_UNDO_BLOCKS } = require(UNDO); - assert.strictEqual( - DEFAULT_UNDO_WINDOW, DEFAULT_UNDO_BLOCKS.LTC_MAINNET, - 'update DEFAULT_UNDO_WINDOW in this test to match the unchanged mainnet window' - ); - }); + it('SAFE_DEPTH exceeds every canonical per-chain undo window by the margin', function () { + const { DEFAULT_UNDO_BLOCKS } = require(UNDO); + const cases = [ + ['BTC', 'mainnet'], ['BTC', 'testnet'], ['BTC', 'regtest'], + ['LTC', 'mainnet'], ['LTC', 'testnet'], ['LTC', 'regtest'], + ['DOGE', 'mainnet'], ['DOGE', 'testnet'], ['DOGE', 'regtest'] + ]; + for (const [coin, network] of cases) { + const window = DEFAULT_UNDO_BLOCKS[coin + '_' + network.toUpperCase()]; + const safeDepth = XChainDecoder.resolveDispenserExpireSafeDepth(coin, network); + assert.ok(safeDepth >= window + SAFETY_MARGIN, + `${coin}/${network} SAFE_DEPTH (${safeDepth}) must be >= undo window (${window}) + margin (${SAFETY_MARGIN})`); + } + }); - // Pins the tracker's hand-mirrored ceiling to the decoder's constant in BOTH - // directions. resolveUndoBlocks() only warns when the resolved window EXCEEDS - // The tracker ceiling warning is one-directional, so lowering the decoder depth alone is silent - // at runtime; this equality is the only thing that catches it. - it('tracker LTC testnet ceiling equals the decoder LTC testnet depth', function () { - const { safeUndoBlocksCeiling } = require(UNDO); - const decoderDepth = XChainDecoder.resolveDispenserExpireSafeDepth('LTC', 'testnet'); - assert.strictEqual( - safeUndoBlocksCeiling('litecoin-testnet'), decoderDepth, - `tracker safe ceiling (${safeUndoBlocksCeiling('litecoin-testnet')}) must EQUAL ` + - `decoder SAFE_DEPTH (${decoderDepth}); ` + - 'a split lets the decoder abort reorg recovery at one depth while the tracker auto-recovers to another' - ); - }); + it('the hand-copied standard floor still matches the unchanged mainnet window', function () { + const { DEFAULT_UNDO_BLOCKS } = require(UNDO); + assert.strictEqual( + DEFAULT_UNDO_WINDOW, DEFAULT_UNDO_BLOCKS.LTC_MAINNET, + 'update DEFAULT_UNDO_WINDOW in this test to match the unchanged mainnet window' + ); }); -}); + + // Pins the tracker's hand-mirrored ceiling to the decoder's constant in BOTH + // directions. resolveUndoBlocks() only warns when the resolved window EXCEEDS the + // ceiling, so lowering the decoder depth alone is silent at runtime; this equality + // is the only thing that catches it. + it('tracker LTC testnet ceiling equals the decoder LTC testnet depth', function () { + const { safeUndoBlocksCeiling } = require(UNDO); + const decoderDepth = XChainDecoder.resolveDispenserExpireSafeDepth('LTC', 'testnet'); + assert.strictEqual( + safeUndoBlocksCeiling('litecoin-testnet'), decoderDepth, + `tracker safe ceiling (${safeUndoBlocksCeiling('litecoin-testnet')}) must EQUAL ` + + `decoder SAFE_DEPTH (${decoderDepth}); ` + + 'a split lets the decoder abort reorg recovery at one depth while the tracker auto-recovers to another' + ); + }); + + // Pins the standard half of the same lockstep on every network. The margin test above + // reads the per-chain DEFAULTS, so it stays green when MAX_SAFE_UNDO_BLOCKS is raised alone. + it('tracker MAX_SAFE_UNDO_BLOCKS equals the decoder SAFE_DEPTH', function () { + assertStandardCeilingLockstep(require(UNDO)); + }); +} + +// Assert the tracker's standard ceiling equals the decoder depth, directly and per network. +function assertStandardCeilingLockstep({ safeUndoBlocksCeiling, coinFromNetwork, MAX_SAFE_UNDO_BLOCKS }) { + assert.strictEqual(MAX_SAFE_UNDO_BLOCKS, XChainDecoder.DISPENSER_EXPIRE_SAFE_DEPTH, + `tracker MAX_SAFE_UNDO_BLOCKS (${MAX_SAFE_UNDO_BLOCKS}) must EQUAL decoder ` + + `DISPENSER_EXPIRE_SAFE_DEPTH (${XChainDecoder.DISPENSER_EXPIRE_SAFE_DEPTH})`); + for (const [coin, chain] of [['BTC', 'bitcoin'], ['LTC', 'litecoin'], ['DOGE', 'dogecoin']]) { + for (const network of ['mainnet', 'testnet', 'regtest']) { + const name = chain + '-' + network; + // Refuse an unresolved name, which would fall through to the standard ceiling. + assert.strictEqual(coinFromNetwork(name), coin, `tracker must resolve ${name} to ${coin}`); + const ceiling = safeUndoBlocksCeiling(name); + const depth = XChainDecoder.resolveDispenserExpireSafeDepth(coin, network); + assert.strictEqual(ceiling, depth, + `${name} tracker safe ceiling (${ceiling}) must EQUAL decoder SAFE_DEPTH (${depth}); ` + + 'a split lets the decoder abort reorg recovery at one depth while the tracker auto-recovers to another'); + } + } +} diff --git a/test/unit/reorg_halt_clear.test.js b/test/unit/reorg_halt_clear.test.js index 64b2c37..09f23dd 100644 --- a/test/unit/reorg_halt_clear.test.js +++ b/test/unit/reorg_halt_clear.test.js @@ -239,8 +239,14 @@ describe('clear-reorg-halt CLI', function () { it('refuses, and cannot be forced, while rolled-back blocks are still missing above the tip', async function () { const { db, calls } = fakeDb({ deletesAboveTip: 5 }) - assert.strictEqual(await run({ db, argv: ['--reason', REASON, '--force'], ...quiet }), EXIT.NOT_RESYNCED) + const errors = [] + assert.strictEqual(await run({ db, argv: ['--reason', REASON, '--force'], log: () => {}, error: (l) => errors.push(l) }), EXIT.NOT_RESYNCED) assert.strictEqual(calls.clear.length, 0) + // Names the parked discriminator and the resync a parked decoder needs, not only a wait. + const refusal = errors.join('\n') + assert.match(refusal, /reorg_halt_parked: true/) + assert.match(refusal, /full resync from a known-good snapshot/) + assert.match(refusal, /cannot be forced/) }) }) diff --git a/test/unit/taproot_envelope.test/05_constants_conformance.test.js b/test/unit/taproot_envelope.test/05_constants_conformance.test.js index d611895..1b439f0 100644 --- a/test/unit/taproot_envelope.test/05_constants_conformance.test.js +++ b/test/unit/taproot_envelope.test/05_constants_conformance.test.js @@ -88,10 +88,11 @@ describe('Taproot envelope recognition', function () { const DOCS_CONSTANTS = path.join(DOCS, 'protocol', 'constants.js') before(function () { if (!fs.existsSync(DOCS_CONSTANTS)) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('xchain-documentation sibling not found at ' + DOCS_CONSTANTS + ' but XCHAIN_REQUIRE_SIBLINGS=1'); this.skip(); } }) - it('ENVELOPE_MAX_PAYLOAD and the activation map are byte-equal to the canonical copy', function () { + it('ENVELOPE_MAX_PAYLOAD and both activation maps are byte-equal to the canonical copy', function () { const docs = require(DOCS_CONSTANTS) assert.strictEqual(docs.ENVELOPE_MAX_PAYLOAD, CONSTANTS.ENVELOPE_MAX_PAYLOAD) assert.deepStrictEqual(docs.ENVELOPE_RECOGNITION_ACTIVATION, CONSTANTS.ENVELOPE_RECOGNITION_ACTIVATION) + assert.deepStrictEqual(docs.ENVELOPE_CARRIER_RECOGNITION_ACTIVATION, CONSTANTS.ENVELOPE_CARRIER_RECOGNITION_ACTIVATION) }) it('the inlined golden bytes match the frozen vector file', function () { From 1a25b76640c3867451356dbbab7f856240b2e686 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 20:49:14 -0700 Subject: [PATCH 12/15] test(decoder): resolve the manifest byte-identity guard through the shared sibling helper --- test/helpers/sibling_checkout.js | 123 ++++++++++++++++++ test/unit/action_manifest_conformance.test.js | 8 +- 2 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 test/helpers/sibling_checkout.js diff --git a/test/helpers/sibling_checkout.js b/test/helpers/sibling_checkout.js new file mode 100644 index 0000000..afcbac8 --- /dev/null +++ b/test/helpers/sibling_checkout.js @@ -0,0 +1,123 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * May a cross-repo guard trust the sibling file it is about to read? + * + * The guards in this tree reach a sibling repo as `../../../xchain-`, + * which is only a statement about the directory layout, not about which + * commit of the sibling is sitting there. Two layouts give an honest answer: + * a main checkout beside its sibling main checkouts (a developer's tree), and + * a CI venue, which clones every declared sibling as a real directory beside + * the repo under test. A third layout gives a dishonest one: a linked worktree + * cut under a lane directory whose `xchain-` entries are SYMLINKS into + * the platform's main checkouts. There the path resolves into a peer + * session's live working tree, uncommitted edits and all, and a guard reads + * green against state no commit holds. On 2026-09-14 a cross-lineage + * falsification returned a false pass for exactly that reason. + * + * So a sibling is refused when it is absent, and also when this checkout is a + * linked worktree and the sibling entry beside it is a symlink whose target is + * a main checkout (its `.git` is a directory). A symlink into another linked + * worktree is allowed: that is a deliberately cut tree at a known ref. + * + * The verdict never decides soft versus strict on its own. Guards skip on a + * refusal in soft mode and fail naming the reason when the run declared its + * siblings supplied with XCHAIN_REQUIRE_SIBLINGS=1, which is what skipOrFail() + * does. Guards keep their own path literals, so every census that greps for + * `xchain-/...` still sees what each guard reads. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const OWN_ROOT = path.resolve(__dirname, '..', '..'); + +/** True when the sibling checkouts were declared supplied for this run. */ +function siblingsRequired(env = process.env) { + return env.XCHAIN_REQUIRE_SIBLINGS === '1'; +} + +/** A linked worktree carries a `.git` FILE pointing at its common dir. */ +function isLinkedWorktree(root) { + try { return fs.lstatSync(path.join(root, '.git')).isFile(); } + catch (e) { return false; } +} + +/** The nearest ancestor of `dir` (inclusive) that holds a `.git` entry, or null. */ +function checkoutRootOf(dir) { + for (let d = dir; ; d = path.dirname(d)) { + if (fs.existsSync(path.join(d, '.git'))) return d; + if (path.dirname(d) === d) return null; + } +} + +/** + * Judge one sibling path. + * + * @param {string} fromDir the directory the guard's literal is relative to (its __dirname) + * @param {string} target the guard's own path literal, relative or absolute + * @param {object} [opts] + * @param {string} [opts.ownRoot] the checkout the guard runs in; defaults to this repo + * @returns {{usable: boolean, path: string, reason: string|null}} + */ +function siblingCheckout(fromDir, target, opts = {}) { + const ownRoot = opts.ownRoot || OWN_ROOT; + const abs = path.resolve(fromDir, target); + + if (!fs.existsSync(abs)) + return { usable: false, path: abs, reason: 'sibling path absent: ' + abs }; + + // Only the entry directly beside this checkout can be the lane symlink. A + // path that does not pass through that parent is not a sibling reference. + const parent = path.dirname(ownRoot); + const rel = path.relative(parent, abs); + if (rel.startsWith('..') || path.isAbsolute(rel)) + return { usable: true, path: abs, reason: null }; + const entry = path.join(parent, rel.split(path.sep)[0]); + + if (!isLinkedWorktree(ownRoot) || !fs.lstatSync(entry).isSymbolicLink()) + return { usable: true, path: abs, reason: null }; + + const real = fs.realpathSync(entry); + const targetRoot = checkoutRootOf(real); + const targetIsMain = targetRoot !== null + && fs.lstatSync(path.join(targetRoot, '.git')).isDirectory(); + if (targetIsMain) + return { + usable: false, path: abs, + reason: 'sibling ' + path.basename(entry) + ' resolves through a symlink into the live main checkout ' + + targetRoot + ', which no commit pins; cut a real sibling worktree to test against it', + }; + return { usable: true, path: abs, reason: null }; +} + +/** + * Act on a refusal inside a mocha test or hook: fail when siblings were + * declared supplied, otherwise skip. Returns false so a caller can write + * `if (!verdict.usable) return skipOrFail(this, verdict, 'what this guard checks');`. + * + * @param {object} ctx the mocha `this` + * @param {{usable: boolean, reason: string|null}} verdict from siblingCheckout() + * @param {string} what one clause naming the guard, for the failure message + */ +function skipOrFail(ctx, verdict, what) { + if (verdict.usable) return true; + if (siblingsRequired()) + throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but ' + what + ' cannot run: ' + verdict.reason); + ctx.skip(); + return false; +} + +module.exports = { siblingCheckout, skipOrFail, siblingsRequired, isLinkedWorktree }; diff --git a/test/unit/action_manifest_conformance.test.js b/test/unit/action_manifest_conformance.test.js index bbcb87b..fd5b200 100644 --- a/test/unit/action_manifest_conformance.test.js +++ b/test/unit/action_manifest_conformance.test.js @@ -65,13 +65,13 @@ describe('ACTION manifest conformance: decoder wireDecoded set @regression', fun '. Edit xchain-documentation/protocol/action-manifest.json + re-vendor, or wire src/XChainDecoder.js.'); }); - // IDENTITY: the vendored copy must match the canonical source (skip when the - // sibling xchain-documentation is not checked out, matching the existing - // ConsensusPrimitiveConformance convention). + // IDENTITY: the vendored copy must match the canonical source. Refuses an + // absent docs checkout and a lane symlink into a live main checkout alike. describe('byte-identity to canonical manifest', function () { const DOCS = process.env.XCHAIN_DOCS_DIR || path.join(__dirname, '..', '..', '..', 'xchain-documentation'); const CANON = path.join(DOCS, 'protocol', 'action-manifest.json'); - before(function () { if (!fs.existsSync(CANON)) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but canonical action-manifest.json not found at ' + CANON); this.skip(); } }); + const { siblingCheckout, skipOrFail } = require('../helpers/sibling_checkout.js'); + before(function () { const docs = siblingCheckout(__dirname, CANON); if (!docs.usable) skipOrFail(this, docs, 'the canonical action-manifest.json byte-identity guard'); }); it('vendored test/fixtures/action-manifest.json is byte-identical to canonical', function () { assert.strictEqual(fs.readFileSync(VENDORED, 'utf8'), fs.readFileSync(CANON, 'utf8'), 'vendored action-manifest.json drifted from canonical; edit ' + From ace01ef50e2dbf1ba347b3a504c038a2b4c2f09c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 23:50:56 -0700 Subject: [PATCH 13/15] test(decoder): cover reorg halt reconciliation call sites Exercise forward hash mismatches and equal-height tip replacements through the live parse loop. Pin cursor reset, transient RPC skip, and halt parking behavior. --- test/unit/reorg_halt_park.test.js | 68 +++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/test/unit/reorg_halt_park.test.js b/test/unit/reorg_halt_park.test.js index bfc5678..f170578 100644 --- a/test/unit/reorg_halt_park.test.js +++ b/test/unit/reorg_halt_park.test.js @@ -183,6 +183,74 @@ describe('the parse loop parks on a REORG_HALT instead of exiting', function () }) +function reorgHaltError(message){ const error = new Error(message); error.reorgHalt = true; return error } +function buildEqualHeightDecoder({ rpcThrows = false, halt = false, resetTip = STORED_TIP } = {}){ + let initialTipRead = true + const calls = { verifyReorg: 0 } + const dbOverrides = { + getLastBlockIndex: async () => { + const tip = initialTipRead ? STORED_TIP : resetTip + initialTipRead = false + return tip + }, + getBlockByIndex: async () => ({ block_hash: 'stored-tip-hash' }) + } + if (halt) dbOverrides.isReorgHalted = async () => true + const built = buildDecoder({ nodeTips: [STORED_TIP], dbOverrides }) + built.decoder.mempoolInterval = 'test-placeholder' + built.decoder.connector.getBlockHash = async () => { + if (rpcThrows) throw new Error('rpc: connection reset') + return 'node-tip-hash' + } + built.decoder.verifyReorg = async () => { + calls.verifyReorg++ + if (halt) throw reorgHaltError('equal-height refusal') + } + return { ...built, calls } +} +describe('REORG_HALT call-site coverage', function () { + this.timeout(0) + beforeEach(function (){ installSink(); clock.install() }) + afterEach(function (){ clock.restore(); observability._resetObservability() }) + it('parks and stays alive during a forward hash-mismatch reorg', async function () { + const { decoder, sleepCount } = buildDecoder({ + nodeTips: [STORED_TIP + 5], + dbOverrides: { + getBlockByIndex: async () => ({ block_hash: 'ff'.repeat(32) }), + isReorgHalted: async () => true + } + }) + let verifyReorgCalls = 0 + decoder.fetchBlockHex = async () => 'deadbeef' + decoder.xchainBlockDecoder = { blockFromHex: () => ({ prevHash: Buffer.alloc(32, 0x11) }) } + decoder.verifyReorg = async () => { verifyReorgCalls++; throw reorgHaltError('forward refusal') } + await decoder.start() + assert.strictEqual(verifyReorgCalls, 1) + assert.strictEqual(decoder.reorgHaltParked, true) + assert.ok(sleepCount() > 1, 'the parked loop must stay alive') + }) + it('resets the cursor after an equal-height tip replacement', async function () { + const { decoder, calls } = buildEqualHeightDecoder({ resetTip: STORED_TIP - 3 }) + await decoder.start() + assert.strictEqual(calls.verifyReorg, 1) + assert.strictEqual(decoder.lastProcessedBlockIndex, STORED_TIP - 3) + }) + it('skips an equal-height reconcile when its RPC read throws', async function () { + const { decoder, calls } = buildEqualHeightDecoder({ rpcThrows: true }) + await decoder.start() + assert.strictEqual(calls.verifyReorg, 0) + assert.strictEqual(decoder.lastProcessedBlockIndex, STORED_TIP) + assert.ok(linesMatching(/equal-height tip-hash detection reads, skipping/).length > 0) + }) + it('parks during an equal-height reconcile refusal', async function () { + const { decoder, calls, sleepCount } = buildEqualHeightDecoder({ halt: true }) + await decoder.start() + assert.strictEqual(calls.verifyReorg, 1) + assert.strictEqual(decoder.reorgHaltParked, true) + assert.ok(sleepCount() > 1, 'the parked loop must stay alive') + }) +}) + describe('a failure that is not a halt refusal still escapes start()', function () { this.timeout(0) From 30063d82e922768253affcd1e1a58548fab17d80 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 23:52:36 -0700 Subject: [PATCH 14/15] test(decoder): avoid a new underscored hook lint finding Use bracket access for the new teardown so the focused coverage does not increase the file's existing lint count. --- test/unit/reorg_halt_park.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/reorg_halt_park.test.js b/test/unit/reorg_halt_park.test.js index f170578..ac87ece 100644 --- a/test/unit/reorg_halt_park.test.js +++ b/test/unit/reorg_halt_park.test.js @@ -211,7 +211,7 @@ function buildEqualHeightDecoder({ rpcThrows = false, halt = false, resetTip = S describe('REORG_HALT call-site coverage', function () { this.timeout(0) beforeEach(function (){ installSink(); clock.install() }) - afterEach(function (){ clock.restore(); observability._resetObservability() }) + afterEach(function (){ clock.restore(); observability['_resetObservability']() }) it('parks and stays alive during a forward hash-mismatch reorg', async function () { const { decoder, sleepCount } = buildDecoder({ nodeTips: [STORED_TIP + 5], From d9bf9b40534a1c2aff95c546459021be233dd8ed Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 09:46:20 -0700 Subject: [PATCH 15/15] 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 2394cd8..5bb8fe1 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 +- Raised the Litecoin testnet rollback window and pinned decoder undo depth to the dispenser safe depth. + + ## [0.20.0] - 2026-09-17 ### Fixed diff --git a/README.md b/README.md index ebbff76..57d4fd2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # XChain Platform Decoder

- Version + Version Tests Node License diff --git a/package-lock.json b/package-lock.json index 11bd988..e054b60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-decoder", - "version": "0.20.0", + "version": "0.20.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-decoder", - "version": "0.20.0", + "version": "0.20.1", "license": "AGPL-3.0-or-later", "dependencies": { "axios": "^1.18.1", diff --git a/package.json b/package.json index 1d6eb88..251cd62 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xchain-decoder", "description": "xchain-decoder decodes XChain platform transactions from a given blockchain and populates a database with the decoded data.", - "version": "0.20.0", + "version": "0.20.1", "license": "AGPL-3.0-or-later", "repository": { "type": "git",