From 830aaf57bebf8cb38b933886febf38b4a7657f07 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 31 Aug 2026 21:33:15 -0700 Subject: [PATCH 01/10] docs: the README badges and test counts describe what the repo ships --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3445870..73c98ca 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ # XChain Platform Decoder

- Version - Tests + Version + Tests Node License

@@ -114,16 +114,16 @@ defaults hold on an unconfigured box: | `npm run migrate` | Apply pending database migrations (auto + manual; `--file ` scopes to specific migration(s)) | | `npm run ci` | The full no-external-services gate: unit, security, smoke, regression, chaos, and a 100-iteration fuzz pass (about a minute) | | `npm run test:smoke` | Smoke tests (58 tests, no external services) | -| `npm run test:unit` | Unit tests (954 tests, no external services) | +| `npm run test:unit` | Unit tests (1,447 tests, no external services) | | `npm run test:security` | Security tests (83 tests, no external services) | | `npm run test:integration` | Integration tests (30 tests; brings up its own throwaway regtest node and MariaDB, requires Docker) | | `npm run test:e2e` | End-to-end tests (72 tests; brings up its own throwaway regtest node and MariaDB on separate ports, requires Docker) | -| `npm run test:fuzz` | Fuzz tests (5 harnesses, 1000-5000 iterations each depending on harness) | -| `npm run test:fuzz:quick` | Quick fuzz (100 iterations) | -| `npm run test:chaos` | Chaos engineering tests (59 tests) | -| `npm run test:regression` | Regression tests P0+P1 (85 tests) | +| `npm run test:fuzz` | Fuzz tests (5 harnesses, 1000-5000 iterations each depending on harness, 177 tests) | +| `npm run test:fuzz:quick` | Quick fuzz (100 iterations, 177 tests) | +| `npm run test:chaos` | Chaos engineering tests (61 tests) | +| `npm run test:regression` | Regression tests P0+P1 (87 tests) | | `npm run test:regression:critical` | Regression tests P0 only (54 tests, <1s) | -| `npm run test:regression:full` | Full regression suite (104 tests) | +| `npm run test:regression:full` | Full regression suite (106 tests) | | `npm run test:bench` | Performance benchmarks (7 scenarios) | | `npm run test:bench:quick` | Quick benchmarks | | `npm run test:mutation` | Mutation testing (Stryker Mutator) | From 48f07fb8b293779f56b3505272e0bd37eaee08aa Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 2 Sep 2026 00:16:35 -0700 Subject: [PATCH 02/10] fix(decoder): move mariadb off the cleartext-credential advisory range and pin the floor in the dependency gate --- .../dependency-advisories.test.js | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/test/unit/security/configuration/dependency-advisories.test.js b/test/unit/security/configuration/dependency-advisories.test.js index 1c5cee8..9d2903a 100644 --- a/test/unit/security/configuration/dependency-advisories.test.js +++ b/test/unit/security/configuration/dependency-advisories.test.js @@ -89,6 +89,23 @@ describe('Security: remediated dependency advisories @regression @tier4', functi // reach here, not dev-only: express-rate-limit and geoip-lite both parse the // client request IP, so a padded or mapped form could key a different // rate-limit bucket than its canonical address. + + // mariadb <=3.5.2 is the production DB driver every service here opens its + // pool with, not a dev-only reach. GHSA-cqhc-2h57-wpxf (HIGH) sends the + // password in the clear to a man in the middle even when the pool asked for + // `ssl: true`, because the connector falls back to a plaintext handshake + // rather than failing closed; GHSA-42r5-vhpq-m858 is the same + // cleartext-credential exposure stated as its own advisory; and + // GHSA-g5xc-5w98-jfvm is SQL injection through Buffer parameter escaping + // under the big5, gbk, sjis, cp932 and gb18030 client charsets. Measured + // exposure in this topology is nil today (no repo passes `ssl:` to a pool, + // every DB connection is host-local, and none selects one of those + // charsets), which is why the fix rode the ordinary fleet path instead of a + // hotfix. The guard is what stops a lockfile refresh, or the first service + // that does dial a remote database over TLS, from landing back inside the + // range. 3.5.3 is the patch, and it also moves the driver's own lru-cache + // onto the 11.x line, so a splice that bumps mariadb and leaves lru-cache + // at 10.4.3 has not actually installed the fixed driver. const advisories = [ { name: 'fast-uri', minSafe: [3, 1, 5], majorSeries: 3 }, { name: 'brace-expansion', minSafe: [5, 0, 9], majorSeries: 5 }, @@ -103,7 +120,8 @@ describe('Security: remediated dependency advisories @regression @tier4', functi { name: 'shell-quote', minSafe: [1, 9, 0], majorSeries: 1 }, { name: 'form-data', minSafe: [4, 0, 6], majorSeries: 4 }, { name: 'tmp', minSafe: [0, 2, 6], majorSeries: 0 }, - { name: 'ip-address', minSafe: [10, 3, 1], majorSeries: 10 } + { name: 'ip-address', minSafe: [10, 3, 1], majorSeries: 10 }, + { name: 'mariadb', minSafe: [3, 5, 3], majorSeries: 3 } ]; // Compares dotted numeric version triples without pulling in semver. @@ -201,4 +219,18 @@ describe('Security: remediated dependency advisories @regression @tier4', functi assert.ok(cmp(parse(axios.VERSION), [1, 18, 0]) >= 0, `installed axios is ${axios.VERSION}, inside the vulnerable range (fixed in 1.18.0)`); }); + + // Same reasoning as ADV-5, for the one entry in this list every service + // opens a socket with. mariadb's `exports` block hides its own + // package.json from require() and the module exports no version constant, + // so read the installed manifest off disk instead. + it('ADV-10: the installed mariadb reports a patched runtime version', function () { + if (!lockEntries('mariadb').length) return this.skip(); + const manifest = path.join(root, 'node_modules', 'mariadb', 'package.json'); + if (!fs.existsSync(manifest)) return this.skip(); + + const installed = JSON.parse(fs.readFileSync(manifest, 'utf8')).version; + assert.ok(cmp(parse(installed), [3, 5, 3]) >= 0, + `installed mariadb is ${installed}, inside the vulnerable range (fixed in 3.5.3)`); + }); }); From d5336c49101f5fd63f592d576340c39e6c8d293d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 12:33:24 -0700 Subject: [PATCH 03/10] chore(coins): sync the vendored coin registry --- src/coins/BTC.js | 20 ++++++++++++++++++++ src/coins/DOGE.js | 20 ++++++++++++++++++++ src/coins/LTC.js | 20 ++++++++++++++++++++ src/coins/consensus_pin.js | 21 +++++++++++++++------ 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/coins/BTC.js b/src/coins/BTC.js index f36c86f..c846405 100644 --- a/src/coins/BTC.js +++ b/src/coins/BTC.js @@ -284,6 +284,26 @@ module.exports = { OWNERSHIP_ESCROW: 50000, AIRDROP_PER_RECIPIENT: 100, DIVIDEND_PER_RECIPIENT: 100, + // SWEEP / CALLBACK, priced on the unified schedule from the + // UNIFIED_FEES_SWEEP_CALLBACK flag day. The legacy flat per-DB-hit fee prices a + // small action BELOW the dust threshold of a native-fee chain (LTC/DOGE, where a + // missing fee output is rejected outright rather than falling back to an XCHAIN + // balance debit), so the fee output cannot be created and the action cannot be + // submitted at all: a Litecoin SWEEP needs ~273 DB hits before it clears LTC's + // 5460-satoshi floor at LTC $100 / XCHAIN $2. + // + // The BASE keys are what close that: gas is what buys the output, so the SMALLEST + // possible SWEEP or CALLBACK has to buy an above-dust one on its own. The floor a + // chain demands is dust_sats * COIN_USD / (1000 * XCHAIN_USD) gas units, so 5000 gas + // (0.05 XCHAIN) clears Litecoin while COIN/XCHAIN stays under ~915 and Dogecoin + // while it stays under ~50, both far outside any plausible band. The PER_ITEM keys + // hold the marginal cost at AIRDROP/DIVIDEND per-recipient parity; a SWEEP item is + // one swept balance, one closed order/swap/dispenser escrow, or one transferred + // ownership. + SWEEP_BASE: 5000, + SWEEP_PER_ITEM: 100, + CALLBACK_BASE: 5000, + CALLBACK_PER_RECIPIENT: 100, // BET (parimutuel betting, spec decision F): feed creation is duration- // metered like ORDER/SWAP/DISPENSER expiration (same free window via // UNIFIED_EXPIRATION_FEE_FREE_DAYS) but under its OWN per-day key so the diff --git a/src/coins/DOGE.js b/src/coins/DOGE.js index 71bd5d5..0ec1a85 100644 --- a/src/coins/DOGE.js +++ b/src/coins/DOGE.js @@ -218,6 +218,26 @@ module.exports = { OWNERSHIP_ESCROW: 50000, AIRDROP_PER_RECIPIENT: 100, DIVIDEND_PER_RECIPIENT: 100, + // SWEEP / CALLBACK, priced on the unified schedule from the + // UNIFIED_FEES_SWEEP_CALLBACK flag day. The legacy flat per-DB-hit fee prices a + // small action BELOW the dust threshold of a native-fee chain (LTC/DOGE, where a + // missing fee output is rejected outright rather than falling back to an XCHAIN + // balance debit), so the fee output cannot be created and the action cannot be + // submitted at all: a Litecoin SWEEP needs ~273 DB hits before it clears LTC's + // 5460-satoshi floor at LTC $100 / XCHAIN $2. + // + // The BASE keys are what close that: gas is what buys the output, so the SMALLEST + // possible SWEEP or CALLBACK has to buy an above-dust one on its own. The floor a + // chain demands is dust_sats * COIN_USD / (1000 * XCHAIN_USD) gas units, so 5000 gas + // (0.05 XCHAIN) clears Litecoin while COIN/XCHAIN stays under ~915 and Dogecoin + // while it stays under ~50, both far outside any plausible band. The PER_ITEM keys + // hold the marginal cost at AIRDROP/DIVIDEND per-recipient parity; a SWEEP item is + // one swept balance, one closed order/swap/dispenser escrow, or one transferred + // ownership. + SWEEP_BASE: 5000, + SWEEP_PER_ITEM: 100, + CALLBACK_BASE: 5000, + CALLBACK_PER_RECIPIENT: 100, // BET (parimutuel betting, spec decision F): feed creation is duration- // metered like ORDER/SWAP/DISPENSER expiration (same free window via // UNIFIED_EXPIRATION_FEE_FREE_DAYS) but under its OWN per-day key so the diff --git a/src/coins/LTC.js b/src/coins/LTC.js index f02d1e3..3f26fe9 100644 --- a/src/coins/LTC.js +++ b/src/coins/LTC.js @@ -213,6 +213,26 @@ module.exports = { OWNERSHIP_ESCROW: 50000, AIRDROP_PER_RECIPIENT: 100, DIVIDEND_PER_RECIPIENT: 100, + // SWEEP / CALLBACK, priced on the unified schedule from the + // UNIFIED_FEES_SWEEP_CALLBACK flag day. The legacy flat per-DB-hit fee prices a + // small action BELOW the dust threshold of a native-fee chain (LTC/DOGE, where a + // missing fee output is rejected outright rather than falling back to an XCHAIN + // balance debit), so the fee output cannot be created and the action cannot be + // submitted at all: a Litecoin SWEEP needs ~273 DB hits before it clears LTC's + // 5460-satoshi floor at LTC $100 / XCHAIN $2. + // + // The BASE keys are what close that: gas is what buys the output, so the SMALLEST + // possible SWEEP or CALLBACK has to buy an above-dust one on its own. The floor a + // chain demands is dust_sats * COIN_USD / (1000 * XCHAIN_USD) gas units, so 5000 gas + // (0.05 XCHAIN) clears Litecoin while COIN/XCHAIN stays under ~915 and Dogecoin + // while it stays under ~50, both far outside any plausible band. The PER_ITEM keys + // hold the marginal cost at AIRDROP/DIVIDEND per-recipient parity; a SWEEP item is + // one swept balance, one closed order/swap/dispenser escrow, or one transferred + // ownership. + SWEEP_BASE: 5000, + SWEEP_PER_ITEM: 100, + CALLBACK_BASE: 5000, + CALLBACK_PER_RECIPIENT: 100, // BET (parimutuel betting, spec decision F): feed creation is duration- // metered like ORDER/SWAP/DISPENSER expiration (same free window via // UNIFIED_EXPIRATION_FEE_FREE_DAYS) but under its OWN per-day key so the diff --git a/src/coins/consensus_pin.js b/src/coins/consensus_pin.js index 50ce2d0..53a2113 100644 --- a/src/coins/consensus_pin.js +++ b/src/coins/consensus_pin.js @@ -66,16 +66,25 @@ module.exports = { // so the public testnet announces with zero pre-announcement test actions. // Same one-wave rule as every regeneration above. Regtest and mainnet are // untouched and were re-verified as unchanged by this edit. + // REGENERATED 2026-09-01: GAS_SCHEDULE gains SWEEP_BASE, + // SWEEP_PER_ITEM, CALLBACK_BASE and CALLBACK_PER_RECIPIENT, the unified prices + // SWEEP and CALLBACK move onto at the UNIFIED_FEES_SWEEP_CALLBACK flag day (both + // networks UNARMED; regtest genesis-active). GAS_SCHEDULE is hashed whole by + // consensusSubset(), so ADDING a key moves every hash even while the flag that + // reads it is unarmed, and the same one-wave rollout rule as every regeneration + // above applies in full: every service bundling these must ship the new values + // together, and a straggler fail-closes on verifyConsensusPin() at boot rather + // than forking. Mainnet stays null (Phase 6 arms it). testnet: { - BTC: 'f6589c6b88dc930db05998070ef0b73743f58623a0d23fbc30fdb158c49d1427', - LTC: '9faf066a1470be2486d8a2cd121548ca02de1397d0678a2ab8dc0e712ebfa8fd', - DOGE: '2991d7e7caf2b212de959dd5831ac1477e0b13da95ac1ed8c2b43e2704732439', + BTC: 'd3c66a4fb288b2666a2a4fad85200bbeac162bb36fed8a3eddcfc7b2d4d48070', + LTC: 'ae94a951a838e64f9c36e503b978d9b9ad5ea74f7b443465baaabca8f675ea0d', + DOGE: 'b90aec4381b0ad32caba078706c8fb244cbe267390e41668fa063d9e64fb60e6', }, regtest: { - BTC: '24e6a363e5a36285574dea357328a997fdee5762ef812d8947eacf69c51afc24', - LTC: '5ad03b383d873d309640e75dfefa2787a5806cb8a84ee46f4cc7fb25ca7f808b', - DOGE: '019220a461e34c99fcf5cbf107673f13d3f2a57d2a20e16a0323ed44c81edd11', + BTC: '29976bd33cad1842320c57acdc849250646adea765f70a0ae5dad3f201f7d5d7', + LTC: 'bca62db9f59a6f7566620b086380c10fffac08dabe99f00a4fcc7cd038e46146', + DOGE: '816632e9f6647e726042282c37789ae8d924e8d4a1b2995ddde8d6a54a0bba54', }, }, }; From 6eb593ae4ac4443d0ddbe44d835ce088727ef60b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 3 Sep 2026 17:28:43 -0700 Subject: [PATCH 04/10] test(fixtures): run the containerised regtest node on Bitcoin Core 31.1 Matches the version the node installer now pins. The integration tier passes unchanged against it; the fixtures already set the non-standard relay options this tier depends on, and none of them changed in 31. --- test/e2e/fixtures/docker-compose.test.yml | 2 +- test/integration/fixtures/docker-compose.test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/fixtures/docker-compose.test.yml b/test/e2e/fixtures/docker-compose.test.yml index ffb20ca..738f272 100644 --- a/test/e2e/fixtures/docker-compose.test.yml +++ b/test/e2e/fixtures/docker-compose.test.yml @@ -28,7 +28,7 @@ name: xchain-decoder-e2e services: bitcoind-test: - image: bitcoin/bitcoin:28.1 + image: bitcoin/bitcoin:31.1 command: - bitcoind - -regtest diff --git a/test/integration/fixtures/docker-compose.test.yml b/test/integration/fixtures/docker-compose.test.yml index 1d03725..bed92c6 100644 --- a/test/integration/fixtures/docker-compose.test.yml +++ b/test/integration/fixtures/docker-compose.test.yml @@ -23,7 +23,7 @@ name: xchain-decoder-it services: bitcoind-test: - image: bitcoin/bitcoin:28.1 + image: bitcoin/bitcoin:31.1 command: - bitcoind - -regtest From bd5160d7a19488a03f3af070347e1fe286558aba Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 4 Sep 2026 08:11:10 -0700 Subject: [PATCH 05/10] fix(decode): mirror the indexer batch weight budget and correct stale comments One wave of the review round on the xchain-platform board. Every change was re-derived from the code rather than applied from the finding recommended option, and each carries a control that reproduces the original failure. Review findings: 6391 6532 6543 --- src/XChainDecoder.js | 7 +- src/batchSubCommandCapture.js | 97 ++++++++++++-- src/protocol/indexerBatchLimits.js | 28 +++++ test/fixtures/action-manifest.json | 2 +- test/tools/sync-batch-limits.js | 63 ++++++++++ test/unit/batchLimitsVendoring.test.js | 167 ++++++++++++++++++++++++- test/unit/dispenserSafeDepth.test.js | 4 +- 7 files changed, 353 insertions(+), 15 deletions(-) diff --git a/src/XChainDecoder.js b/src/XChainDecoder.js index 4854829..cf7a001 100644 --- a/src/XChainDecoder.js +++ b/src/XChainDecoder.js @@ -102,15 +102,16 @@ const SYNCED_THRESHOLD = 3 //Maximum blocks behind to be synced // 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 -// DOGE = 120 (xchain-utxo-tracker DEFAULT_UNDO_BLOCKS: BTC 12 / LTC 48 / DOGE 120); -// the previous flat 100 sat BELOW DOGE's window. Invariant: SAFE_DEPTH >= +// 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 // 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. // 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 (DOGE undo window) + 6 margin +const DISPENSER_EXPIRE_SAFE_DEPTH = 126 // 120 (deepest undo window, LTC and DOGE) + 6 margin // 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 // indexer's DISPENSER_CLOSE fires. Reintroducing a closing mirror would need that pinned diff --git a/src/batchSubCommandCapture.js b/src/batchSubCommandCapture.js index c76bcce..ad88264 100644 --- a/src/batchSubCommandCapture.js +++ b/src/batchSubCommandCapture.js @@ -51,7 +51,10 @@ const ACTION_ALIASES = require('./actionAliases.js') const { COMMAND_LIMIT, ACTION_LIMITS, GATED_ACTION_LIMITS, - CHILD_ISSUE_KEY } = require('./protocol/indexerBatchLimits.js') + CHILD_ISSUE_KEY, + WEIGHT_BUDGET, + COMMAND_WEIGHTS, + COST_WEIGHTING_ACTIVATION } = require('./protocol/indexerBatchLimits.js') // The BATCH FORMAT versions the indexer registers (xchain-indexer/src/actions/batch.js // `this.formats`, which today holds only 0 = 'VERSION|COMMAND'). A BATCH whose FORMAT is @@ -174,10 +177,11 @@ function subCommandActionName(command){ // verdict this file can reach on its own evidence. // // The rest of the class is now closed as far as it is provable, in hasProvablyRejectedBatch -// below: the nested BATCH, the per-ACTION caps and the 250-command cap, against the indexer's -// cap tables vendored canonically in src/protocol/indexerBatchLimits.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. +// 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 +// deliberately, for the reason this paragraph gives: a vendored name LIST is not closed under +// registry growth, so a stale one under-captures. // // A '' name is reachable two ways and both are covered, because both are what // `split('|')[0]` yields: an EMPTY element (a trailing ';', a ';;', or the whole command @@ -399,10 +403,18 @@ function maxIdenticalMintTicks(ticks){ // * THE AGGREGATE GAS PRE-CHECK ('invalid: GAS (insufficient)'). Same reason: it reads the // SOURCE's balances and the token set from the indexer database. // +// A FOURTH cause, the BATCH_COST_WEIGHTING weight budget, IS mirrored, and unlike the caps +// above it is gated on its own vendored instant rather than assumed on (see +// isBatchCostWeightingActive). Its one deliberate under-estimate, the DEPLOY discount, is +// argued at subCommandCostWeight. +// // Order of the checks is irrelevant to the verdict (any one of them means "rejected"), so // this does NOT reproduce the indexer's error precedence, which decides only WHICH string a // rejected batch reports. -function hasProvablyRejectedBatch(subCommands, aliases){ +// +// `consensusNetwork` and `blockTime` are OPTIONAL and default to "the weight budget is not +// provably active", so every caller written before the budget existed keeps today's verdicts. +function hasProvablyRejectedBatch(subCommands, aliases, consensusNetwork, blockTime){ if (!Array.isArray(subCommands)) return false // The global command cap, counted over the raw ';'-split list with empty elements // included - the same list, and the same counting rule, the indexer caps. @@ -427,9 +439,74 @@ function hasProvablyRejectedBatch(subCommands, aliases){ : (tally.get(action) || 0) if (count > caps[action]) return true } + + // The weighted budget, which the indexer applies INSTEAD of the flat count at/after + // BATCH_COST_WEIGHTING. The count cap above stays a sound pre-filter either way, because + // every weight is >= 1 and the budget is the same number. + if (isBatchCostWeightingActive(consensusNetwork, blockTime) && + batchCostWeight(subCommands, aliases) > WEIGHT_BUDGET) return true + return false } +// Is the indexer's BATCH_COST_WEIGHTING weight budget in force at this block? +// +// Its own vendored per-network instant, NOT the ordering argument the caps lean on. That +// argument is specific to BATCH_ISSUANCE_LIMITS, whose instant the decoder's capture gate is +// required to sit at or after; the weighting flag has no such relationship and today it is +// the counter-example, with mainnet capture ARMED and the weighting instant still on the +// house sentinel. An absent or DISARMED (null) entry is inactive at every block time, which +// leaves today's over-capture in place rather than inventing a suppression rule. +function isBatchCostWeightingActive(consensusNetwork, blockTime){ + const activation = COST_WEIGHTING_ACTIVATION[consensusNetwork] + if (typeof activation !== 'number') return false + const t = Number(blockTime) + if (!Number.isFinite(t)) return false + return t >= activation +} + +// The cost weight of ONE sub-command: a strict LOWER BOUND on the indexer's subCommandWeight. +// +// The bound is the whole design, because the directions are not symmetric. Charging MORE than +// the indexer pushes the sum over the budget for a batch the indexer really dispatches, which +// suppresses capture and loses a settlement output; charging LESS only leaves today's +// over-capture open for that shape. +// +// So DEPLOY is deliberately UNDER-charged at the default 1 rather than its table weight of +// 30: the indexer discounts a format-4 chunk carrier back to 1, and this module does not read +// FORMAT versions. DEPLOY is capped at 1 per BATCH by GATED_ACTION_LIMITS, so the whole +// under-estimate is bounded at 29 of the 250 budget. Every other weighted ACTION +// (AIRDROP/DIVIDEND/EXECUTE/XEXEC) is charged unconditionally by the indexer, so the table +// value is exact there. +// +// Alias expansion matches the indexer's, which normalizes the name before weighing; the +// module header's ordering argument covers that BATCH_SUBACTION_NORMALIZATION is on wherever +// capture runs. A name the table does not carry weighs 1, and hasOwnProperty keeps +// `constructor`/`__proto__` off the prototype chain. +function subCommandCostWeight(command, aliases){ + const rawName = subCommandActionName(command) + if (rawName === null) return 1 + const action = expandAliasName(rawName, aliases) + if (action === 'DEPLOY') return 1 + if (!Object.prototype.hasOwnProperty.call(COMMAND_WEIGHTS, action)) return 1 + const weight = COMMAND_WEIGHTS[action] + return (Number.isInteger(weight) && weight >= 1) ? weight : 1 +} + +// Total cost weight of a BATCH: the sum of subCommandCostWeight over the raw ';'-split list, +// empty elements included, exactly the list the indexer weighs. Never throws (a crash here +// would take down block decoding); an unweighable list falls back to 0, which is "not +// provably rejected". +function batchCostWeight(subCommands, aliases){ + try { + let total = 0 + for (const command of subCommands) total += subCommandCostWeight(command, aliases) + return total + } catch (e) { + return 0 + } +} + // The list of action strings the output-capture decision should be taken over. // // Below the gate, and for every transaction that is not a BATCH, this is exactly @@ -465,7 +542,7 @@ function captureCommands(decodedData, consensusNetwork, blockTime){ return [decodedData] const subCommands = batchSubCommands(decodedData) if (subCommands === null) return [decodedData] - if (hasProvablyRejectedBatch(subCommands, ACTION_ALIASES)) return [] + if (hasProvablyRejectedBatch(subCommands, ACTION_ALIASES, consensusNetwork, blockTime)) return [] return subCommands.map(command => expandSubCommandAlias(command, ACTION_ALIASES)) } @@ -532,6 +609,9 @@ module.exports = { subCommandActionName, hasProvablyRejectedSubCommand, hasProvablyRejectedBatch, + isBatchCostWeightingActive, + subCommandCostWeight, + batchCostWeight, expandSubCommandAlias, expandAliasName, isNumeric, @@ -545,4 +625,7 @@ module.exports = { ACTION_LIMITS, GATED_ACTION_LIMITS, CHILD_ISSUE_KEY, + WEIGHT_BUDGET, + COMMAND_WEIGHTS, + COST_WEIGHTING_ACTIVATION, } diff --git a/src/protocol/indexerBatchLimits.js b/src/protocol/indexerBatchLimits.js index 4752b3f..59730c4 100644 --- a/src/protocol/indexerBatchLimits.js +++ b/src/protocol/indexerBatchLimits.js @@ -57,9 +57,37 @@ const GATED_ACTION_LIMITS = { // BATCH_ISSUANCE_LIMITS, so child issuance is exempt from the top-level ISSUE cap. const CHILD_ISSUE_KEY = "ISSUE.CHILD"; +// Weighted per-BATCH cost budget (indexer: this.weightBudget), which REPLACES the flat +// command cap at/after BATCH_COST_WEIGHTING. Breached => 'invalid: COMMAND (limit)', the +// same string, whole batch. +const WEIGHT_BUDGET = 250; + +// Per-ACTION cost weights (indexer: this.commandWeights). An ACTION absent from this table +// weighs the DEFAULT of 1, which is every ordinary action. +const COMMAND_WEIGHTS = { + "AIRDROP": 25, + "DEPLOY": 30, + "DIVIDEND": 25, + "EXECUTE": 30, + "XEXEC": 30, +}; + +// 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. +const COST_WEIGHTING_ACTIVATION = { + "mainnet": 9999999999, + "testnet": 0, + "regtest": 0, +}; + module.exports = { COMMAND_LIMIT, ACTION_LIMITS, GATED_ACTION_LIMITS, CHILD_ISSUE_KEY, + WEIGHT_BUDGET, + COMMAND_WEIGHTS, + COST_WEIGHTING_ACTIVATION, }; diff --git a/test/fixtures/action-manifest.json b/test/fixtures/action-manifest.json index 93e02ab..ad21bd2 100644 --- a/test/fixtures/action-manifest.json +++ b/test/fixtures/action-manifest.json @@ -11,7 +11,7 @@ }, "categories": { "wire-user": "user-encodable on-chain action: decoded + indexed + SDK-encodable", - "validator": "validator-broadcast on-chain action: decoded + indexed but NOT user-encodable (ANCHOR/ATTEST/NODEPROOF/SLASH)", + "validator": "validator-broadcast on-chain action: decoded + indexed but NOT user-encodable (ANCHOR/ATTEST/NODEPROOF/ROLLCALL/SLASH)", "mirror-injected": "indexer-injected from the hub mirror, NOT chain-decoded (XCALL/XEXEC/CROSS_SETTLE)", "lifecycle": "system-generated sub-action, never a decoded wire tx (matches/expiries/dispense)", "explorer-legacy-render": "render-only in the explorer (legacy order/dispenser cancel+edit views); no decoder/indexer twin" diff --git a/test/tools/sync-batch-limits.js b/test/tools/sync-batch-limits.js index fabef80..abf1845 100644 --- a/test/tools/sync-batch-limits.js +++ b/test/tools/sync-batch-limits.js @@ -43,6 +43,15 @@ * answered; the ordering, its block-index gates and its consensus-version gate are asserted * in batchLimitsVendoring.test.js instead. * + * 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. + * * Lives under test/ rather than bin/ because it is a maintenance tool for the conformance * suite that consumes it: batchLimitsVendoring.test.js requires deriveFromSibling() and * renderModule() from HERE, so the check and the fix can never implement two different ideas @@ -84,9 +93,44 @@ function deriveFromSibling(){ ACTION_LIMITS: sortedCopy(batch.actionLimits), GATED_ACTION_LIMITS: sortedCopy(batch.gatedActionLimits), CHILD_ISSUE_KEY: batch.childIssueKey, + WEIGHT_BUDGET: batch.weightBudget, + COMMAND_WEIGHTS: sortedCopy(batch.commandWeights), + COST_WEIGHTING_ACTIVATION: costWeightingActivation(), + }; +} + +// The BATCH_COST_WEIGHTING per-network activation instants, read off a REAL ProtocolChanges +// instance. The registration carries all three networks in one object, so one read gives the +// whole map whatever NETWORK the instance was built for. Unlike BATCH_ISSUANCE_LIMITS this +// one IS written into the vendored module: see the header for why no ordering invariant +// excuses it. +function costWeightingChange(){ + const ProtocolChanges = require(INDEXER_CHANGES); + const changes = new ProtocolChanges({ + config: { NETWORK: 'regtest' }, + decoderDb: { getBlockTime: async () => 0 }, + }); + return { change: changes.changes['BATCH_COST_WEIGHTING'], consensusVersion: changes.version }; +} + +// The per-network instant map the vendored module carries. +function costWeightingActivation(){ + const { change } = costWeightingChange(); + // A flag the sibling does not register at all reads as DISARMED everywhere, which leaves + // today's over-capture in place rather than inventing a suppression rule. + if (!change) return { mainnet: null, testnet: null, regtest: null }; + return { + mainnet: instantOf(change.mainnet_time), + testnet: instantOf(change.testnet_time), + regtest: instantOf(change.regtest_time), }; } +// A registered instant, or null when the field is not a finite number (DISARMED). +function instantOf(value){ + return Number.isFinite(Number(value)) ? Number(value) : null; +} + // Key order is normalized so a reordering in the sibling cannot show up as drift, and so the // rendered file is stable across regenerations. function sortedCopy(table){ @@ -171,11 +215,29 @@ const GATED_ACTION_LIMITS = ${renderTable(derived.GATED_ACTION_LIMITS, 0)}; // BATCH_ISSUANCE_LIMITS, so child issuance is exempt from the top-level ISSUE cap. const CHILD_ISSUE_KEY = ${literal(derived.CHILD_ISSUE_KEY)}; +// Weighted per-BATCH cost budget (indexer: this.weightBudget), which REPLACES the flat +// command cap at/after BATCH_COST_WEIGHTING. Breached => 'invalid: COMMAND (limit)', the +// same string, whole batch. +const WEIGHT_BUDGET = ${literal(derived.WEIGHT_BUDGET)}; + +// Per-ACTION cost weights (indexer: this.commandWeights). An ACTION absent from this table +// weighs the DEFAULT of 1, which is every ordinary action. +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. +const COST_WEIGHTING_ACTIVATION = ${renderTable(derived.COST_WEIGHTING_ACTIVATION, 0)}; + module.exports = { COMMAND_LIMIT, ACTION_LIMITS, GATED_ACTION_LIMITS, CHILD_ISSUE_KEY, + WEIGHT_BUDGET, + COMMAND_WEIGHTS, + COST_WEIGHTING_ACTIVATION, }; `; } @@ -211,5 +273,6 @@ module.exports = { VENDORED, deriveFromSibling, issuanceLimitsChange, + costWeightingChange, renderModule, }; diff --git a/test/unit/batchLimitsVendoring.test.js b/test/unit/batchLimitsVendoring.test.js index c1ab878..cee8889 100644 --- a/test/unit/batchLimitsVendoring.test.js +++ b/test/unit/batchLimitsVendoring.test.js @@ -42,6 +42,9 @@ const path = require('path'); const VENDORED_MODULE = require('../../src/protocol/indexerBatchLimits.js'); const { BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION, hasProvablyRejectedBatch, + captureCommands, + batchCostWeight, + subCommandCostWeight, subCommandLimitKey, subCommandTick, CHILD_ISSUE_KEY } = require('../../src/batchSubCommandCapture.js'); @@ -84,10 +87,13 @@ function realBatch(opts) { util.addAddressTicker = () => {}; util.detectFeePaymentMode = () => 'native'; + // Network and block time are overridable so the weight-budget cases can drive the SAME + // handler where BATCH_COST_WEIGHTING is armed and where it is not. + const blockTime = (opts.blockTime === undefined) ? T0 : opts.blockTime; const changes = new ProtocolChanges({ - config: { NETWORK: 'regtest' }, + config: { NETWORK: opts.network || 'regtest' }, util: util, - decoderDb: { getBlockTime: async () => T0 }, + decoderDb: { getBlockTime: async () => blockTime }, }); const ids = opts.tickIds || new Map(); @@ -195,6 +201,31 @@ describe('BATCH limit vendoring and cross-repo conformance', function () { assert.deepStrictEqual(VENDORED_MODULE.GATED_ACTION_LIMITS, derived.GATED_ACTION_LIMITS); }); + it('carries the live weight budget, weight table and activation instants', function () { + // A retune of either number in the sibling moves indexer verdicts, and before this + // pin the decoder had no copy of them at all, so the retune was invisible here. + if (!siblingOrSkip(this, sync.INDEXER_BATCH)) return; + const derived = sync.deriveFromSibling(); + assert.strictEqual(VENDORED_MODULE.WEIGHT_BUDGET, derived.WEIGHT_BUDGET); + assert.deepStrictEqual(VENDORED_MODULE.COMMAND_WEIGHTS, derived.COMMAND_WEIGHTS); + assert.deepStrictEqual(VENDORED_MODULE.COST_WEIGHTING_ACTIVATION, + derived.COST_WEIGHTING_ACTIVATION); + }); + + it('keeps every weight an integer >= 1, which is what makes the count cap a sound pre-filter', function () { + // The decoder still checks the raw count first. That is exact rather than + // conservative only while no weight can be below 1. + assert.ok(Number.isInteger(VENDORED_MODULE.WEIGHT_BUDGET) && + VENDORED_MODULE.WEIGHT_BUDGET > 0); + for (const action of Object.keys(VENDORED_MODULE.COMMAND_WEIGHTS)) { + const weight = VENDORED_MODULE.COMMAND_WEIGHTS[action]; + assert.ok(Number.isInteger(weight) && weight >= 1, + action + ' weighs ' + weight + '; a weight below 1 would let a batch whose ' + + 'raw count exceeds the budget still weigh in under it, and the count ' + + 'pre-filter would start rejecting batches the indexer runs'); + } + }); + it('keeps the ungated and gated caps in the tables they came from', function () { // Placement is not cosmetic: everything in ACTION_LIMITS binds in BOTH flag // states, so mirroring it needs no flag reasoning at all, while everything in @@ -263,11 +294,143 @@ describe('BATCH limit vendoring and cross-repo conformance', function () { 'cap and the DEPLOY cap would be enforced here and nowhere else'); } }); + + it('registers BATCH_COST_WEIGHTING with no block-index threshold, so a TIME mirror is sound', function () { + // The decoder mirrors this flag on block TIME alone. isEnabled ANDs a block-index + // leg onto that, so a non-zero threshold could hold the flag off past its instant + // while the decoder already applied the budget: suppression where the indexer + // dispatches, the money-bearing direction. + if (!siblingOrSkip(this, sync.INDEXER_CHANGES)) return; + const { change } = sync.costWeightingChange(); + assert.ok(change, 'BATCH_COST_WEIGHTING must be registered in the sibling'); + for (const network of ['mainnet', 'testnet', 'regtest']) + assert.strictEqual(change[network + '_block'], 0, + network + ' BATCH_COST_WEIGHTING grew a block-index threshold that the ' + + 'vendored instant map cannot express'); + }); + + it('registers it at or below the indexer compiled consensus version', function () { + // Same AND: the version leg could hold the flag off after its instant. + if (!siblingOrSkip(this, sync.INDEXER_CHANGES)) return; + const { change, consensusVersion } = sync.costWeightingChange(); + const current = consensusVersion.split('.').map(Number); + const at = [change.version_major, change.version_minor, change.version_revision]; + const ordered = (at[0] !== current[0]) ? at[0] < current[0] + : (at[1] !== current[1]) ? at[1] < current[1] + : at[2] <= current[2]; + assert.ok(ordered, + 'BATCH_COST_WEIGHTING is registered at ' + at.join('.') + ' but the indexer ' + + 'compiles ' + consensusVersion + ': the version leg of isEnabled would hold ' + + 'the flag off while the decoder applied the budget'); + }); + + it('carries the weighting instants the sibling registers, per network', function () { + if (!siblingOrSkip(this, sync.INDEXER_CHANGES)) return; + const { change } = sync.costWeightingChange(); + for (const network of ['mainnet', 'testnet', 'regtest']) + assert.strictEqual(VENDORED_MODULE.COST_WEIGHTING_ACTIVATION[network], + change[network + '_time'], + network + ': the vendored weighting instant drifted from the sibling. ' + + 'EARLIER here than there means the decoder suppresses capture for batches ' + + 'the indexer still dispatches'); + }); + + it('does NOT assume the weighting flag is on wherever capture is, which is why it is gated', function () { + // The counter-example that killed the ordering shortcut, pinned so it stays a + // counter-example: mainnet capture is armed and mainnet weighting is not. An + // ungated budget would suppress mainnet capture today. + const captureGate = BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION.mainnet; + const weightGate = VENDORED_MODULE.COST_WEIGHTING_ACTIVATION.mainnet; + if (captureGate === null || typeof weightGate !== 'number') return; + assert.ok(captureGate < weightGate, + 'mainnet capture (' + captureGate + ') is no longer earlier than mainnet ' + + 'weighting (' + weightGate + '); re-read isBatchCostWeightingActive before ' + + 'relying on the gate, and re-derive whether the budget may now be unconditional'); + }); }); // ------------------------------------------------------------------------------------- describe('tier 3: driven against the REAL indexer Batch handler', function () { + // The weight budget, driven on BOTH sides of its own flag. Every wire here is under + // the 250-COUNT cap, so nothing in the pre-weighting rule set can explain a rejection: + // the only thing that moves is the summed weight. + const WEIGHT_VECTORS = [ + { name: '9x EXECUTE + SEND', weight: 271, + wire: 'BATCH|0|SEND|0|BTC|TICK|1|addr;' + + Array.from({ length: 9 }, () => 'EXECUTE|0|1|a').join(';') }, + { name: '11x AIRDROP', weight: 275, + wire: 'BATCH|0|' + + Array.from({ length: 11 }, () => 'AIRDROP|0|BTC|TICK|1|a').join(';') }, + ]; + const UNDER_BUDGET = [ + { name: '8x EXECUTE + SEND', weight: 241, + wire: 'BATCH|0|SEND|0|BTC|TICK|1|addr;' + + Array.from({ length: 8 }, () => 'EXECUTE|0|1|a').join(';') }, + { name: '10x AIRDROP', weight: 250, + wire: 'BATCH|0|' + + Array.from({ length: 10 }, () => 'AIRDROP|0|BTC|TICK|1|a').join(';') }, + ]; + const MAINNET_LIVE = 1800000000; // above mainnet capture, below the weighting sentinel + + it('suppresses an over-budget batch on regtest, where the handler rejects it whole', async function () { + if (!siblingOrSkip(this, sync.INDEXER_BATCH)) return; + for (const vector of WEIGHT_VECTORS) { + assert.strictEqual(subCommandsOf(vector.wire).length <= VENDORED_MODULE.COMMAND_LIMIT, + true, vector.name + ' must stay under the COUNT cap or it proves nothing'); + assert.strictEqual( + batchCostWeight(subCommandsOf(vector.wire), ACTION_ALIASES), vector.weight); + const status = await indexerStatus(vector.wire, { network: 'regtest', blockTime: 0 }); + assert.strictEqual(status, 'invalid: COMMAND (limit)', + vector.name + ': premise wrong, the real handler said ' + status); + assert.deepStrictEqual(captureCommands(vector.wire, 'regtest', 0), [], + vector.name + ' still captures on regtest; the weight budget is not mirrored'); + } + }); + + it('still captures an over-budget batch on MAINNET, where the flag is unarmed', async function () { + // The under-capture control, and the reason the rule is gated instead of + // unconditional. Pre-gate reasoning would have suppressed these. + if (!siblingOrSkip(this, sync.INDEXER_BATCH)) return; + for (const vector of WEIGHT_VECTORS) { + const status = await indexerStatus(vector.wire, + { network: 'mainnet', blockTime: MAINNET_LIVE }); + assert.strictEqual(status, 'valid', + vector.name + ': premise wrong, mainnet handler said ' + status); + const view = captureCommands(vector.wire, 'mainnet', MAINNET_LIVE); + assert.strictEqual(view.length, subCommandsOf(vector.wire).length, + 'UNDER-CAPTURE on mainnet: the mirror suppressed ' + vector.name + + ', which the real handler dispatches in full'); + } + }); + + it('leaves a batch AT the budget alone on both networks', async function () { + if (!siblingOrSkip(this, sync.INDEXER_BATCH)) return; + for (const vector of UNDER_BUDGET) { + assert.strictEqual( + batchCostWeight(subCommandsOf(vector.wire), ACTION_ALIASES), vector.weight); + assert.strictEqual(await indexerStatus(vector.wire, { network: 'regtest', blockTime: 0 }), + 'valid', vector.name + ': premise wrong on regtest'); + assert.strictEqual(captureCommands(vector.wire, 'regtest', 0).length, + subCommandsOf(vector.wire).length, + 'UNDER-CAPTURE: ' + vector.name + ' weighs exactly the budget and is valid'); + } + }); + + it('under-charges DEPLOY rather than guessing its format, which is the safe direction', async function () { + // The indexer charges DEPLOY 30 and discounts a format-4 chunk carrier to 1. This + // module reads no FORMAT, so it charges 1 for both: an UNDER-estimate bounded at 29 + // by the one-DEPLOY-per-batch cap. Charging 30 would suppress a batch carrying a + // chunk carrier the indexer runs. + if (!siblingOrSkip(this, sync.INDEXER_BATCH)) return; + assert.strictEqual(VENDORED_MODULE.COMMAND_WEIGHTS.DEPLOY, 30, + 'the sibling stopped weighting DEPLOY at 30; re-derive the discount argument'); + assert.strictEqual(subCommandCostWeight('DEPLOY|0|code', ACTION_ALIASES), 1); + assert.strictEqual(subCommandCostWeight('DEPLOY|4|chunk', ACTION_ALIASES), 1); + assert.strictEqual(VENDORED_MODULE.GATED_ACTION_LIMITS.DEPLOY, 1, + 'the per-batch DEPLOY cap is what bounds the under-estimate at 29'); + }); + it('agrees with it on every vector, and never suppresses a batch it accepts', async function () { if (!siblingOrSkip(this, sync.INDEXER_BATCH)) return; let mirrored = 0; diff --git a/test/unit/dispenserSafeDepth.test.js b/test/unit/dispenserSafeDepth.test.js index 74847cf..4e54493 100644 --- a/test/unit/dispenserSafeDepth.test.js +++ b/test/unit/dispenserSafeDepth.test.js @@ -32,7 +32,7 @@ const XChainDecoder = require('../../src/XChainDecoder.js'); // Baseline floor (always asserted, even without the sibling checkout). // Mirrors xchain-utxo-tracker/src/undo-blocks.js DEFAULT_UNDO_BLOCKS. -const DEEPEST_UNDO_WINDOW = 120; // DOGE (BTC 12 / LTC 48 / DOGE 120) +const DEEPEST_UNDO_WINDOW = 120; // LTC and DOGE (BTC 12 / LTC 120 / DOGE 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,7 +40,7 @@ const DEEPEST_UNDO_WINDOW = 120; // DOGE (BTC 12 / LTC 48 / 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 (DOGE = 120) + margin', 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} ` + From 6477473cf171260df6fedf95a1b592beb1fb9c57 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 4 Sep 2026 21:59:08 -0700 Subject: [PATCH 06/10] fix(decoder): a halt-marker write failure is no longer logged as persisted The gate could not see a failed halt-marker write, and the failure was logged as though it had persisted, so a halt believed recorded might not be. The write is now read back and honoured, with marker_write=attempting|unavailable replacing the typeof-derived marker_persisted, a REORG_HALT_MARKER outcome record and an operator-action line. Review round 7 finding #6855. Also carries review round 6's decoder work. --- package-lock.json | 329 ----------------------- package.json | 4 - src/BlockchainConnector.js | 21 +- src/XChainDecoder.js | 116 +++++++- src/api.js | 10 +- src/batchSubCommandCapture.js | 2 - src/db.js | 22 +- src/decoderMetrics.js | 2 +- src/sql/mempool_transactions.sql | 17 +- test/unit/auxpowReassembly.test.js | 43 +++ test/unit/decoderHaltDiagnostics.test.js | 119 +++++++- test/unit/mempoolIsolation.test.js | 52 ++++ test/unit/reorgHaltSurface.test.js | 54 ++++ test/unit/verifyReorgRetry.test.js | 5 +- 14 files changed, 436 insertions(+), 360 deletions(-) diff --git a/package-lock.json b/package-lock.json index 20107f1..caa9781 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,15 +17,11 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "ecpair": "2.1.0", - "encoding-down": "^7.1.0", "express": "^5.2.1", "express-json-rpc-router": "^1.4.0", "express-rate-limit": "^8.5.2", "helmet": "^8.2.0", - "leveldown": "^6.1.1", - "levelup": "^5.1.1", "mariadb": "3.5.3", - "memdown": "^6.1.1", "tiny-secp256k1": "2.2.4" }, "devDependencies": { @@ -1300,24 +1296,6 @@ "uuid": "bin/uuid" } }, - "node_modules/abstract-leveldown": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-7.2.0.tgz", - "integrity": "sha512-DnhQwcFEaYsvYDnACLZhMmCWd3rkOeEvglpa4q5i/5Jlm3UIsWaxVzuXvDLFCSCWRO3yy2/+V/G7FusFgejnfQ==", - "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", - "license": "MIT", - "dependencies": { - "buffer": "^6.0.3", - "catering": "^2.0.0", - "is-buffer": "^2.0.5", - "level-concat-iterator": "^3.0.0", - "level-supports": "^2.0.1", - "queue-microtask": "^1.2.3" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1514,26 +1492,6 @@ "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/baseline-browser-mapping": { "version": "2.11.5", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz", @@ -1762,30 +1720,6 @@ "bs58": "^5.0.0" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/bunyan": { "version": "1.8.15", "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.15.tgz", @@ -1935,15 +1869,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/catering": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/catering/-/catering-2.1.1.tgz", - "integrity": "sha512-K7Qy8O9p76sL3/3m7/zLKbRkyOlSZAgzEaLhyj2mXS8PsCud2Eo4hAb8aLtZqHh0QGqLcb9dlJSu6lHRVENm1w==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -2273,20 +2198,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deferred-leveldown": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/deferred-leveldown/-/deferred-leveldown-7.0.0.tgz", - "integrity": "sha512-QKN8NtuS3BC6m0B8vAnBls44tX1WXAFATUsJlruyAYbZpysWV3siH6o/i3g9DCHauzodksO60bdj5NazNbjCmg==", - "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", - "license": "MIT", - "dependencies": { - "abstract-leveldown": "^7.2.0", - "inherits": "^2.0.3" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -2461,22 +2372,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding-down": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/encoding-down/-/encoding-down-7.1.0.tgz", - "integrity": "sha512-ky47X5jP84ryk5EQmvedQzELwVJPjCgXDQZGeb9F6r4PdChByCGHTBrVcF3h8ynKVJ1wVbkxTsDC8zBROPypgQ==", - "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", - "license": "MIT", - "dependencies": { - "abstract-leveldown": "^7.2.0", - "inherits": "^2.0.3", - "level-codec": "^10.0.0", - "level-errors": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2926,12 +2821,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "license": "MIT" - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3314,26 +3203,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -3371,29 +3240,6 @@ "node": ">= 0.10" } }, - "node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -3701,98 +3547,6 @@ "node": ">=0.6.0" } }, - "node_modules/level-codec": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/level-codec/-/level-codec-10.0.0.tgz", - "integrity": "sha512-QW3VteVNAp6c/LuV6nDjg7XDXx9XHK4abmQarxZmlRSDyXYk20UdaJTSX6yzVvQ4i0JyWSB7jert0DsyD/kk6g==", - "deprecated": "Superseded by level-transcoder (https://github.com/Level/community#faq)", - "license": "MIT", - "dependencies": { - "buffer": "^6.0.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/level-concat-iterator": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/level-concat-iterator/-/level-concat-iterator-3.1.0.tgz", - "integrity": "sha512-BWRCMHBxbIqPxJ8vHOvKUsaO0v1sLYZtjN3K2iZJsRBYtp+ONsY6Jfi6hy9K3+zolgQRryhIn2NRZjZnWJ9NmQ==", - "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", - "license": "MIT", - "dependencies": { - "catering": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/level-errors": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/level-errors/-/level-errors-3.0.1.tgz", - "integrity": "sha512-tqTL2DxzPDzpwl0iV5+rBCv65HWbHp6eutluHNcVIftKZlQN//b6GEnZDM2CvGZvzGYMwyPtYppYnydBQd2SMQ==", - "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/level-iterator-stream": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/level-iterator-stream/-/level-iterator-stream-5.0.0.tgz", - "integrity": "sha512-wnb1+o+CVFUDdiSMR/ZymE2prPs3cjVLlXuDeSq9Zb8o032XrabGEXcTCsBxprAtseO3qvFeGzh6406z9sOTRA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/level-supports": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/level-supports/-/level-supports-2.1.0.tgz", - "integrity": "sha512-E486g1NCjW5cF78KGPrMDRBYzPuueMZ6VBXHT6gC7A8UYWGiM14fGgp+s/L1oFfDWSPV/+SFkYCmZ0SiESkRKA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/leveldown": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/leveldown/-/leveldown-6.1.1.tgz", - "integrity": "sha512-88c+E+Eizn4CkQOBHwqlCJaTNEjGpaEIikn1S+cINc5E9HEvJ77bqY4JY/HxT5u0caWqsc3P3DcFIKBI1vHt+A==", - "deprecated": "Superseded by classic-level (https://github.com/Level/community#faq)", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "abstract-leveldown": "^7.2.0", - "napi-macros": "~2.0.0", - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/levelup": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/levelup/-/levelup-5.1.1.tgz", - "integrity": "sha512-0mFCcHcEebOwsQuk00WJwjLI6oCjbBuEYdh/RaRqhjnyVlzqf41T1NnDtCedumZ56qyIh8euLFDqV1KfzTAVhg==", - "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", - "license": "MIT", - "dependencies": { - "catering": "^2.0.0", - "deferred-leveldown": "^7.0.0", - "level-errors": "^3.0.1", - "level-iterator-stream": "^5.0.0", - "level-supports": "^2.0.1", - "queue-microtask": "^1.2.3" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3893,12 +3647,6 @@ "yallist": "^3.0.2" } }, - "node_modules/ltgt": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz", - "integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==", - "license": "MIT" - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -3981,23 +3729,6 @@ "node": ">= 0.8" } }, - "node_modules/memdown": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/memdown/-/memdown-6.1.1.tgz", - "integrity": "sha512-vh2RiuVrn6Vv73088C1KzLwy9+hhRwoZsgddYqIoVuFFrcoc2Rt+lq/KrmkFn6ulko7AtQ0AvqtYid35exb38A==", - "deprecated": "Superseded by memory-level (https://github.com/Level/community#faq)", - "license": "MIT", - "dependencies": { - "abstract-leveldown": "^7.2.0", - "buffer": "^6.0.3", - "functional-red-black-tree": "^1.0.1", - "inherits": "^2.0.1", - "ltgt": "^2.2.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -4214,12 +3945,6 @@ "license": "MIT", "optional": true }, - "node_modules/napi-macros": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.0.0.tgz", - "integrity": "sha512-A0xLykHtARfueITVDernsAWdtIMbOJgKgcluwENp3AlsKN/PloyO10HtmoqnFAQAcxPkgZN7wdfPfEd0zNGxbg==", - "license": "MIT" - }, "node_modules/ncp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", @@ -4240,17 +3965,6 @@ "node": ">= 0.6" } }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-releases": { "version": "2.0.51", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", @@ -4571,26 +4285,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -4624,20 +4318,6 @@ "node": ">= 0.10" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -5135,15 +4815,6 @@ "node": ">= 0.8" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", diff --git a/package.json b/package.json index 1246606..64b9640 100644 --- a/package.json +++ b/package.json @@ -16,15 +16,11 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "ecpair": "2.1.0", - "encoding-down": "^7.1.0", "express": "^5.2.1", "express-json-rpc-router": "^1.4.0", "express-rate-limit": "^8.5.2", "helmet": "^8.2.0", - "leveldown": "^6.1.1", - "levelup": "^5.1.1", "mariadb": "3.5.3", - "memdown": "^6.1.1", "tiny-secp256k1": "2.2.4" }, "scripts": { diff --git a/src/BlockchainConnector.js b/src/BlockchainConnector.js index 50ae228..877ef6b 100644 --- a/src/BlockchainConnector.js +++ b/src/BlockchainConnector.js @@ -521,7 +521,26 @@ class BlockchainConnector { } return headerHex + encodeVarintHex(txHexes.length) + txHexes.join('') } catch (err) { - throw new Error("There were problems reassembling a block without auxpow. " + err.message) + // Carry the fault's identity out with the message. The three RPC fetches above + // sit INSIDE this try, so a transport fault (an ECONNRESET from a saturated + // Dogecoin 1.14 RPC queue, an ECONNABORTED timeout, a node restart) lands here + // beside a genuine content fault, and only error.code and the rpcCode/rpcMessage + // sanitizeRpcError attaches separate the two. _auxPowParseErrorCount never + // decays, so once a height has escalated to this path every later failure at + // that height arrives through this catch, which is precisely where an operator + // has to tell an unreachable node from a block whose bytes are unusable. + // Mirrors the cause attachment getBlockWithoutAuxPow makes above. + // + // Deliberately NOT tagged auxPowParseFailure: that flag is the only signal + // fetchBlockHex escalates on, and aiming a per-tx fan-out at a node that is + // merely unreachable is the failure the comment above getBlockWithoutAuxPow + // describes. Errors leaving these RPC helpers have already passed through + // sanitizeRpcError, which scrubs config.auth, the Authorization header and + // error.request in place, so attaching one as cause carries no credential. + const reassembleErr = new Error("There were problems reassembling a block without auxpow. " + err.message) + reassembleErr.cause = err + if (err && err.code !== undefined) reassembleErr.code = err.code + throw reassembleErr } } diff --git a/src/XChainDecoder.js b/src/XChainDecoder.js index cf7a001..c0171f6 100644 --- a/src/XChainDecoder.js +++ b/src/XChainDecoder.js @@ -418,6 +418,12 @@ class XChainDecoder { this.reorgHaltReason = null this.reorgHaltAt = null this.reorgHaltCheckedAt = 0 + // Whether a REORG_HALT row is known to be READABLE, as distinct from + // whether this decoder is halted. null = no halt has been raised or read + // yet; false = a halt exists in memory whose durable write could not be + // confirmed, which is the one state where a restart silently resumes the + // rollback and the bootstrap gate finds nothing to refuse on. + this.reorgHaltMarkerPersisted = null this._reorgHaltProbeInFlight = null } @@ -633,6 +639,11 @@ class XChainDecoder { this.reorgHaltReason = (marker && marker.reason) || null this.reorgHaltAt = (marker && marker.at) || null this.reorgHaltCheckedAt = now + // A marker this probe just READ is durable by observation, whatever the + // write that produced it reported. Raised here and never cleared here: + // finding no row is exactly the state an unconfirmed in-process halt + // leaves behind, so clearing on absence would erase the one signal. + if (this.reorgHalted) this.reorgHaltMarkerPersisted = true if (this.reorgHalted && !wasHalted){ console.error('XChainDecoder: LATENT REORG_HALT MARKER PRESENT - this decoder carries a durable ' + 'REORG_HALT row from an aborted rollback. It will keep parsing forward and look healthy, but ' + @@ -656,13 +667,17 @@ class XChainDecoder { // Cached view of the halt marker for health surfaces. `checked_at` is null until // the first successful probe, so a consumer can tell "not halted" apart from - // "never looked". + // "never looked". `marker_persisted` splits the halt from its evidence: null when + // no halt has been raised or seen, false when this process halted and could not + // confirm the durable row, true when a row is known readable. getReorgHaltStatus(){ return { halted: !!this.reorgHalted, reason: this.reorgHaltReason || null, at: this.reorgHaltAt || null, - checked_at: this.reorgHaltCheckedAt || null + checked_at: this.reorgHaltCheckedAt || null, + marker_persisted: (this.reorgHaltMarkerPersisted === null || this.reorgHaltMarkerPersisted === undefined) + ? null : !!this.reorgHaltMarkerPersisted } } @@ -1753,8 +1768,10 @@ class XChainDecoder { throw new Error(msg) } - // Persist the durable halt marker before an abort throws (best-effort: swallow - // write errors so a marker failure never masks the loud abort). Feature-detected. + // Persist the durable halt marker before an abort throws. Feature-detected, and + // non-throwing so a marker failure never masks the loud abort, but NOT silent: + // the outcome is honoured, published on the health surface and logged, because + // an unrecorded halt is the one state where a restart resumes the rollback. const haltReorg = async (reason) => { // Set the in-memory health state first: the durable write is best-effort, // but this decoder is halted either way and every health surface must say @@ -1777,9 +1794,12 @@ class XChainDecoder { network: this.consensusNetwork, reason: reason, depth: blocksDeleted.length, - marker_persisted: canPersist, + // 'attempting', not 'persisted': this record is emitted BEFORE the + // write, so it cannot know the outcome and must not claim one. The + // REORG_HALT_MARKER record below carries the real answer. + marker_write: canPersist ? 'attempting' : 'unavailable', // Spelled out rather than left for the reader to infer from the - // boolean: this is the one halt that /status and /live cannot + // field: this is the one halt that /status and /live cannot // report, because the marker they read is never written. detail: canPersist ? undefined : 'db.markReorgHalted is unavailable: the durable halt marker cannot be persisted, ' @@ -1788,11 +1808,60 @@ class XChainDecoder { }) } catch (_) { /* a diagnostic must never mask the abort it describes */ } - if (!canPersist) return + if (!canPersist) { + this.reorgHaltMarkerPersisted = false + return + } + + // Honour the write result. markReorgHalted confirms the row by read-back + // and returns false when it cannot; the catch below only ever fires for a + // connection or SELECT fault, because insertEvent eats the INSERT error. + // Retried ONCE and without a sleep: a failed insertEvent rolls the open + // block transaction back (db.js insertEvent -> endTransaction), so the + // second attempt runs on a freshly leased pooled connection, which is a + // materially different attempt rather than the same one repeated. No + // backoff, because this sits directly in front of the abort throw and a + // marker write must never delay the fault it is describing. + let persisted = false + let lastError = null + let attempts = 0 + while (attempts < 2 && !persisted){ + attempts++ + try { + persisted = (await this.db.markReorgHalted(reason)) === true + } catch (e) { + lastError = e + } + } + this.reorgHaltMarkerPersisted = persisted + + // The outcome record. Separate from the one above because the two answer + // different questions ("what halted, and why" vs "did the evidence land"), + // and because collapsing them would put the reason behind the write that + // may be the thing failing. try { - await this.db.markReorgHalted(reason) - } catch (e) { - console.error('verifyReorg: failed to persist REORG_HALT marker:', e) + getLogger().error('REORG_HALT_MARKER', { + coin: this.coinTick, + network: this.consensusNetwork, + marker_persisted: persisted, + attempts: attempts, + err: lastError ? (lastError.message || String(lastError)) : undefined + }) + } catch (_) { /* a diagnostic must never mask the abort it describes */ } + + if (!persisted){ + // The incident shape the bootstrap gate exists to stop: the process is + // about to exit, the restart policy recycles the container, the entry + // guard reads a marker that was never written, the decoder finishes the + // over-deep rollback, and the gate counts zero markers and publishes + // this database as known-good. Nothing durable records it, so this line + // is the only evidence and it has to name the required action. + console.error('verifyReorg: the durable REORG_HALT marker could NOT be persisted after ' + + attempts + ' attempt(s)' + + (lastError ? ' (' + (lastError.message || String(lastError)) + ')' : '') + + '. This database is NOT a valid bootstrap source: a restart will re-enter verifyReorg ' + + 'with a zeroed depth counter and silently resume the over-deep rollback. ' + + 'REQUIRED OPERATOR ACTION: full resync from a known-good snapshot.') } } @@ -3326,9 +3395,24 @@ class XChainDecoder { let mempoolStartTime = Date.now() this.mempoolBusy = true let rawMempool = [] + // Mempool size as the node reported it, held separately because + // deleteAndCompareTxsNotInList below empties and refills rawMempool in place. + let nodeMempoolCount = 0 try { let rawMempoolUnordered = await this.connector.getRawMempool() + // getrawmempool answers with an array of txids; rpcResult only guarantees the + // result member is present, never its type. Reject any other shape HERE, at the + // boundary, and let the catch below skip the poll: a malformed-but-iterable + // answer (a bare string from an RPC proxy or a trimmed body) dedups into + // per-character "txids", and deleteAndCompareTxsNotInList then anti-joins the + // stored table against that snapshot and deletes every pending row, blanking + // the published feed until a healthy poll refills it. Mirrors the shape check + // the verbose-block consumer makes in BlockchainConnector.getBlockReassembled. + if (!Array.isArray(rawMempoolUnordered)) { + throw new Error('getrawmempool did not return an array') + } + // Dedup + single O(n log n) sort. The old per-txid binary-insert // (bs + splice) was O(n^2) in mempool size every poll cycle, a CPU // hazard under a mempool flood. What the consumer needs is the DEDUP: @@ -3343,7 +3427,8 @@ class XChainDecoder { // Snapshot the node's total mempool size for the API's getmempool // method (deduped count, matching what this cycle actually processes). - this.nodeMempoolTxCount = rawMempool.length + nodeMempoolCount = rawMempool.length + this.nodeMempoolTxCount = nodeMempoolCount this.nodeMempoolUpdatedAt = Date.now() } catch (error) { @@ -3362,7 +3447,10 @@ class XChainDecoder { let deletedInfo = await this.mempoolDb.deleteAndCompareTxsNotInList(rawMempool) let deletedTransactionsCount = deletedInfo.transactionsDeleted - + // Read the length before the batch loop, while it still means "new arrivals": + // the call above truncated rawMempool down to the txids this node has not stored. + let newArrivalsCount = rawMempool.length + let i = 0 while (i < rawMempool.length) { let nextRawMempoolChunk = rawMempool.slice(i, i + MEMPOOL_BATCH_SIZE) @@ -3465,8 +3553,10 @@ class XChainDecoder { let mempoolEndTime = Date.now() let timeString = this.millisecondsToTimeString(mempoolEndTime - mempoolStartTime) + // nodeMempoolCount, not rawMempool.length: the db diff empties and refills + // rawMempool in place, so by here its length is the new-arrival count. console.log("Mempool updated!" - + " Transactions (" + rawMempool.length + " in mempool, " + validTransactionsCount + " valid, " + deletedTransactionsCount + " less) [" + timeString + "]") + + " Transactions (" + nodeMempoolCount + " in mempool, " + newArrivalsCount + " new, " + validTransactionsCount + " valid, " + deletedTransactionsCount + " less) [" + timeString + "]") } finally { // Always clear the busy flag, even if a DB or parse operation above threw. // Otherwise a single transient failure would leave mempool tracking frozen diff --git a/src/api.js b/src/api.js index b4179f6..37d7b66 100644 --- a/src/api.js +++ b/src/api.js @@ -515,7 +515,15 @@ async function startApi(){ running: decoderRunning, reorg_halted: reorgHalt.halted, reorg_halt_reason: reorgHalt.reason, - reorg_halted_at: reorgHalt.at + reorg_halted_at: reorgHalt.at, + // Ships beside the boolean, never without it. "Not halted" is only an answer + // if something looked, and the probe is fail-soft: its state starts at + // not-halted with checked_at null, so a decoder that has NEVER completed a + // probe publishes exactly what a clean one publishes. Consumers that gate on + // this body (xchain-node's BootstrapHealthGate falls back to GET /status when + // the JSON-RPC health surface is unavailable) can only tell those two apart + // if this route carries the timestamp the health method already carries. + reorg_halt_checked_at: reorgHalt.checked_at }) }) diff --git a/src/batchSubCommandCapture.js b/src/batchSubCommandCapture.js index ad88264..b469199 100644 --- a/src/batchSubCommandCapture.js +++ b/src/batchSubCommandCapture.js @@ -613,8 +613,6 @@ module.exports = { subCommandCostWeight, batchCostWeight, expandSubCommandAlias, - expandAliasName, - isNumeric, isLegacyActionFormat, subCommandTick, subCommandLimitKey, diff --git a/src/db.js b/src/db.js index 2ca87c5..16af433 100644 --- a/src/db.js +++ b/src/db.js @@ -2702,9 +2702,29 @@ class Database { // Called on every verifyReorg abort path BEFORE the throw, so a restart cannot // resume the over-deep rollback. Best-effort by design; the caller swallows any // error so a marker-write failure never masks the original loud abort. + // + // Returns TRUE only when a REORG_HALT row is readable afterwards, never merely + // "the INSERT reported no error". insertEvent swallows every write error and + // returns false, so the boolean it hands back is the only failure signal that + // exists here, and a caller that trusts it without a read-back is trusting a + // driver's ack for a row nobody has seen. That distinction is the whole point: + // this marker is the only thing standing between a restarted decoder and a + // silently resumed over-deep rollback, and every consumer of it (the entry + // guard, the health surfaces, the bootstrap gate) reads the ROW, not the ack. async markReorgHalted(reason){ if (await this.isReorgHalted()) return true - return this.insertEvent('REORG_HALT', { reason: reason, at: new Date().toISOString() }) + const written = await this.insertEvent('REORG_HALT', { reason: reason, at: new Date().toISOString() }) + // Anything other than a clean insert is a failure. DUPLICATED_TRANSACTION + // is truthy and would otherwise read as success, so the read-back below + // decides that case on the row rather than on the errno. + if (written === false) return false + try { + return await this.isReorgHalted() + } catch (_) { + // The write may well have landed, but nothing here can say so, and an + // unconfirmed marker must never report as a confirmed one. + return false + } } } diff --git a/src/decoderMetrics.js b/src/decoderMetrics.js index 4d70e55..967df24 100644 --- a/src/decoderMetrics.js +++ b/src/decoderMetrics.js @@ -124,4 +124,4 @@ function registerDecoderMetrics(registry, decoder) { return { gauges, counters, collector }; } -module.exports = { registerDecoderMetrics, DECODER_GAUGES, DECODER_COUNTERS }; +module.exports = { registerDecoderMetrics }; diff --git a/src/sql/mempool_transactions.sql b/src/sql/mempool_transactions.sql index 9260aa0..97723cc 100644 --- a/src/sql/mempool_transactions.sql +++ b/src/sql/mempool_transactions.sql @@ -16,9 +16,15 @@ DROP TABLE IF EXISTS mempool_transactions; CREATE TABLE mempool_transactions ( tx_hash VARCHAR(250), -- raw transaction hash (NOT an index_transactions id) source VARCHAR(120), -- raw source address (NOT an index_addresses id) - destination VARCHAR(120), -- raw destination address (NOT an index_addresses id) - amount BIGINT, -- BTC amount sent - fee BIGINT, -- BTC Fee paid (miners fee) + -- These three mirror the confirmed twin's shape (transactions.destination_id/amount/fee) + -- so a pending row and its confirmed row line up column for column, and like that twin + -- the decoder is not their authority. The single mempool writer + -- (XChainDecoder.updateMempool -> Database.insertMempoolTransaction) binds + -- parseTransaction's result, whose only success return hardcodes destination:null and + -- carries no amount key, plus a literal fee of 0. + destination VARCHAR(120), -- Not authoritative: always NULL (parseTransaction hardcodes destination:null). Typed to hold a raw address (NOT an index_addresses id) like source. A pending tx's destinations live inside the decoded ACTION string in data, which callers parse. + amount BIGINT, -- Not authoritative: always NULL (parseTransaction emits no amount, so the writer binds undefined). The indexer derives COIN_AMOUNT from transaction_outputs at confirmation. + fee BIGINT, -- Not authoritative: the writer binds a literal 0 (miner fee is not tracked here), mirroring transactions.fee. -- utf8mb4 per column, mirroring transactions.data: a pending row must accept exactly -- what its confirmed twin accepts, or a non-BMP ACTION fails the mempool INSERT with -- errno 1366 and the tx is skipped on every poll. The table default stays utf8mb3 so @@ -44,4 +50,9 @@ CREATE TABLE mempool_transactions ( -- block-confirmation processing; this transient table keeps the raw values verbatim. CREATE UNIQUE INDEX mempool_tx_hash ON mempool_transactions (tx_hash); CREATE INDEX mempool_source ON mempool_transactions (source); +-- mempool_destination covers a column the writer always binds NULL, so it holds exactly one +-- distinct value and selects nothing. It is kept so this table's index set stays a mirror of +-- the confirmed twin's; dropping it needs a dated mode=manual migration plus an operator +-- migrate run per node, since reconcileTableIndexes re-adds any index declared here. Consumers +-- must not filter on destination expecting rows: see xchain-explorer getDecoderMempoolRows. CREATE INDEX mempool_destination ON mempool_transactions (destination); diff --git a/test/unit/auxpowReassembly.test.js b/test/unit/auxpowReassembly.test.js index 9c1aae1..43ee03f 100644 --- a/test/unit/auxpowReassembly.test.js +++ b/test/unit/auxpowReassembly.test.js @@ -97,6 +97,49 @@ describe('malformed-AuxPoW block reassembly fallback', function () { }) await assert.rejects(() => connector.getBlockReassembled('hash'), /no raw tx for in-block txid/) }) + + // The three RPC fetches sit inside the try, so a transport fault is wrapped by the + // same catch that wraps a content fault. Once _auxPowParseErrorCount has escalated a + // height into this path it never decays, so every later failure at that height comes + // through here, and error.code is the only thing separating "the node is unreachable" + // from "this block's bytes are unusable" in the operator log. + it('preserves error.code and the original error as cause on a transport fault', async function () { + const transportErr = new Error('socket hang up') + transportErr.code = 'ECONNRESET' + const connector = makeConnector({ + getBlockHeader: async () => { throw transportErr }, + }) + await assert.rejects( + () => connector.getBlockReassembled('hash'), + (err) => { + assert.strictEqual(err.code, 'ECONNRESET', 'error.code must survive the wrap') + assert.strictEqual(err.cause, transportErr, 'the original error must travel as cause') + assert.match(err.message, /^There were problems reassembling a block without auxpow\. /, + 'the message prefix must stay byte-identical for existing log greps') + assert.strictEqual(err.auxPowParseFailure, undefined, + 'a transport fault must never carry the escalation tag') + return true + } + ) + }) + + // A content fault raised inside the try has no .code, so the wrapper must not invent + // one, and must still stay untagged: escalation is getBlockWithoutAuxPow's to signal. + it('wraps a content fault with a cause and no invented code', async function () { + const connector = makeConnector({ + getBlockHeader: async () => HEADER_HEX, + getBlockVerbose: async () => ({ tx: 'not-an-array' }), + }) + await assert.rejects( + () => connector.getBlockReassembled('hash'), + (err) => { + assert.strictEqual(err.code, undefined, 'no code exists to copy, so none may be set') + assert.match(err.cause.message, /verbose getblock returned no tx array/) + assert.strictEqual(err.auxPowParseFailure, undefined) + return true + } + ) + }) }) describe('BlockchainConnector.probeTxIndex', function () { diff --git a/test/unit/decoderHaltDiagnostics.test.js b/test/unit/decoderHaltDiagnostics.test.js index f691faf..385bf1e 100644 --- a/test/unit/decoderHaltDiagnostics.test.js +++ b/test/unit/decoderHaltDiagnostics.test.js @@ -48,8 +48,11 @@ function installSink() { }) } +// Match the EVENT, not a substring of it. formatTextLine renders +// ` [] k=v ...`, so REORG_HALT_MARKER contains +// REORG_HALT and a bare includes() would fold the two records into one count. function linesFor(event) { - return sink.lines.filter((l) => l.includes(event)) + return sink.lines.filter((l) => l.includes('] ' + event + ' ') || l.endsWith('] ' + event)) } function makeDecoder() { @@ -96,21 +99,89 @@ describe('REORG_HALT: a halt the marker cannot record still leaves a record', fu '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_persisted=false'), + 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 - const decoder = haltingDecoder({ markReorgHalted: async (r) => { marked = r } }) + // 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_persisted=true'), halts[0]) + 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 () { + const errors = [] + const realError = console.error + console.error = (...a) => { errors.push(a.map(String).join(' ')) } + let attempts = 0 + try { + 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) + } finally { + console.error = realError + } + const critical = errors.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(errors)) + 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 () { @@ -288,3 +359,43 @@ describe('db: a failed temp-table drop stops being silent', function () { assert.ok(warned[0].includes('lost connection to server'), warned[0]) }) }) + +// api.js registers GET /status inside startApi(), which builds a real decoder and +// opens a listening socket, so the route is not reachable from a unit test. The +// contract is pinned at the source instead, the same way this repo pins the +// getmempool JSON-RPC method (test/unit/mempoolApiSurface.test.js). +// +// What is pinned: reorg_halted never ships without reorg_halt_checked_at. +// xchain-node's BootstrapHealthGate refuses a payload that owns the boolean and +// carries a null/absent timestamp, because a decoder whose fail-soft marker probe +// has NEVER completed publishes exactly what a clean one publishes. That gate probes +// the JSON-RPC health surface first and falls back to GET /status, so the boolean +// must never appear alone in either body. +describe('api.js GET /status halt surface (source pin)', function () { + const fs = require('fs') + const path = require('path') + const src = fs.readFileSync(path.join(__dirname, '../../src/api.js'), 'utf8') + + function statusRouteBody() { + const at = src.indexOf("app.get('/status'") + assert.ok(at > -1, 'GET /status route missing from api.js') + return src.slice(at, at + 3000) + } + + it('publishes reorg_halt_checked_at beside reorg_halted', function () { + const body = statusRouteBody() + assert.ok(/reorg_halted:\s+reorgHalt\.halted/.test(body), + 'GET /status no longer publishes reorg_halted') + assert.ok(/reorg_halt_checked_at:\s*reorgHalt\.checked_at/.test(body), + 'GET /status publishes reorg_halted without the probe timestamp its consumers ' + + 'need to tell "clean" apart from "never looked"') + }) + + it('spells the key exactly as the JSON-RPC health surface does', function () { + // The gate reads one key name across both surfaces; a near-miss spelling on + // the fallback body reads as an absent timestamp and refuses every decoder. + const occurrences = src.match(/reorg_halt_checked_at/g) || [] + assert.ok(occurrences.length >= 2, + 'reorg_halt_checked_at must appear on both the health result and GET /status') + }) +}) diff --git a/test/unit/mempoolIsolation.test.js b/test/unit/mempoolIsolation.test.js index 613a330..98f34fa 100644 --- a/test/unit/mempoolIsolation.test.js +++ b/test/unit/mempoolIsolation.test.js @@ -100,4 +100,56 @@ describe('updateMempool DB isolation', function () { assert.ok(!calls.db.includes('endTransaction'), 'mempool failure must not roll back the block db') assert.strictEqual(decoder.mempoolBusy, false, 'the busy flag must be cleared after the cycle') }) + + // getrawmempool answers with an array of txids, and the JSON-RPC unwrap only checks that a + // result member is present. A malformed-but-iterable answer is the destructive one: a bare + // string dedups into per-character "txids", and deleteAndCompareTxsNotInList anti-joins the + // stored table against that snapshot and deletes every pending row. + it('a non-array getrawmempool answer skips the poll instead of reaching the delete', async () => { + const { decoder, calls } = buildDecoder(true) + decoder.connector.getRawMempool = async () => 'aabbcc' + await decoder.updateMempool() + assert.deepStrictEqual(calls.mempoolDb, [], 'a shape fault must not reach the mempool DB at all') + assert.deepStrictEqual(calls.db, []) + assert.strictEqual(decoder.mempoolBusy, false, 'the busy flag must be cleared so the next poll runs') + }) + + it('an empty node mempool still reaches the delete, so departed txs are pruned', async () => { + // [] is a legitimate answer, not a shape fault: the anti-join is exactly what prunes the + // stored rows when the node's mempool has drained. + const { decoder } = buildDecoder(true) + let received = null + decoder.mempoolDb.deleteAndCompareTxsNotInList = async (list) => { + received = list.slice(); return { transactionsDeleted: 0 } + } + decoder.connector.getRawMempool = async () => [] + await decoder.updateMempool() + assert.deepStrictEqual(received, [], 'an empty snapshot must still be handed to the diff') + }) + + // deleteAndCompareTxsNotInList empties and refills the caller's array in place, leaving it + // holding only the new arrivals, so the cycle summary must not read that array for the + // mempool size: in steady state that reads 0 against a full table, which an operator takes + // for "mempool tracking has stopped". + it('the cycle summary reports the node mempool size, not the post-diff new arrivals', async () => { + const { decoder } = buildDecoder(true) + decoder.connector.getRawMempool = async () => ['ccc', 'bbb', 'aaa'] + decoder.connector.getRawTransactions = async () => [] + // Steady state: every txid the node reported is already stored, so the diff empties the list. + decoder.mempoolDb.deleteAndCompareTxsNotInList = async (list) => { + list.length = 0; return { transactionsDeleted: 0 } + } + const lines = [] + const realLog = console.log + console.log = (...args) => { lines.push(args.join(' ')) } + try { + await decoder.updateMempool() + } finally { + console.log = realLog + } + const summary = lines.find((line) => line.startsWith('Mempool updated!')) + assert.ok(summary, 'the cycle must emit its summary line') + assert.match(summary, /3 in mempool/, 'the summary must report the size the node reported') + assert.match(summary, /0 new/, 'the post-diff length is the new-arrival count and must be labelled so') + }) }) diff --git a/test/unit/reorgHaltSurface.test.js b/test/unit/reorgHaltSurface.test.js index 9e82fde..ba21e51 100644 --- a/test/unit/reorgHaltSurface.test.js +++ b/test/unit/reorgHaltSurface.test.js @@ -213,3 +213,57 @@ describe('Database.getReorgHaltMarker', function () { assert.strictEqual(marker.at, '2026-07-26 03:30:31') }) }) + +// The marker is the only thing standing between a restarted decoder and a silently +// resumed over-deep rollback, and every consumer of it reads the ROW: the entry +// guard, the health surfaces, and xchain-node's BootstrapHealthGate, which counts +// `events WHERE code='REORG_HALT'` before publishing a database as a bootstrap +// source. insertEvent swallows every write error and returns false, so a caller that +// trusts its ack without reading the row back certifies a marker nobody has seen. +describe('Database.markReorgHalted reports the row, not the ack', function () { + + function stubMarkerDb({ alreadyHalted = false, insertResult = true, readBack = true, readBackThrows = false } = {}) { + const db = new Database('127.0.0.1', 3306, 'test_db', 'u', 'p') + const calls = { insert: 0, probes: 0 } + db.isReorgHalted = async () => { + calls.probes++ + if (calls.probes === 1) return alreadyHalted + if (readBackThrows) throw new Error('connection lost during read-back') + return readBack + } + db.insertEvent = async () => { calls.insert++; return insertResult } + return { db, calls } + } + + it('returns true when the row is readable after the insert', async function () { + const { db, calls } = stubMarkerDb() + assert.strictEqual(await db.markReorgHalted('over-deep'), true) + assert.strictEqual(calls.insert, 1) + assert.strictEqual(calls.probes, 2, 'the write must be confirmed by a read-back') + }) + + it('returns false when insertEvent swallowed the write error', async function () { + const { db } = stubMarkerDb({ insertResult: false }) + assert.strictEqual(await db.markReorgHalted('over-deep'), false) + }) + + // The case the old contract got wrong: insertEvent answers truthy and no row + // exists (the transaction was rolled back under it, or the errno was 1062 and + // DUPLICATED_TRANSACTION came back, which is truthy and is not a written row). + it('returns false when the insert claims success but no row is readable', async function () { + const { db } = stubMarkerDb({ readBack: false }) + assert.strictEqual(await db.markReorgHalted('over-deep'), false, + 'an unconfirmed marker must never report as a confirmed one') + }) + + it('returns false rather than throwing when the read-back itself fails', async function () { + const { db } = stubMarkerDb({ readBackThrows: true }) + assert.strictEqual(await db.markReorgHalted('over-deep'), false) + }) + + it('stays idempotent: an existing marker is true without a second write', async function () { + const { db, calls } = stubMarkerDb({ alreadyHalted: true }) + assert.strictEqual(await db.markReorgHalted('over-deep'), true) + assert.strictEqual(calls.insert, 0) + }) +}) diff --git a/test/unit/verifyReorgRetry.test.js b/test/unit/verifyReorgRetry.test.js index bea3e9a..56c8212 100644 --- a/test/unit/verifyReorgRetry.test.js +++ b/test/unit/verifyReorgRetry.test.js @@ -203,7 +203,10 @@ describe('XChainDecoder.verifyReorg durable halt (restart-mid-reorg)', function deleteBlockByIndex: async (h) => { deleted.push(h); dbState.top = h - 1 }, insertEvent: async () => true, isReorgHalted: async () => store.halted, - markReorgHalted: async () => { store.halted = true } + // Answers the db contract (true = a REORG_HALT row is now readable) rather + // than shrugging with undefined: haltReorg honours this value, and a stub + // that shrugs models a decoder whose marker write silently failed. + markReorgHalted: async () => { store.halted = true; return true } } return { decoder, deleted } } From dfa6e875587e20f048ab2bb2187b48d9f15219e8 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 5 Sep 2026 22:51:24 -0700 Subject: [PATCH 07/10] fix(decoder): halt-marker durability and per-chain segwit capability Review-round fixes. The halt-marker contract was stated as an absolute in two places and implemented as best-effort in three, so a restart could clear a halt the operator set. The guard now refuses on an unreadable marker rather than treating it as absent. Suite: 1482 passing, 0 failing. --- src/XChainDecoder.js | 80 +++++++- src/db.js | 67 +++++++ test/unit/parseTransaction.test.js | 83 ++++++++ test/unit/reorgDepthAcrossRestart.test.js | 229 ++++++++++++++++++++++ 4 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 test/unit/reorgDepthAcrossRestart.test.js diff --git a/src/XChainDecoder.js b/src/XChainDecoder.js index c0171f6..0640916 100644 --- a/src/XChainDecoder.js +++ b/src/XChainDecoder.js @@ -1471,9 +1471,37 @@ class XChainDecoder { // P2WSH chunk carrier: same shape as P2SH, chunks in the witness. } else if (dataWithoutObfuscation.subarray(MAGIC_WORD.length).equals(P2WSH_BUFFER)){ p2shFundingTxId = firstInputTxId // commit tx carrying any native-coin fee output + // A chain that declares no segwit has no witness carrier, so refuse + // to read payload out of a witness stack there instead of trusting + // upstream node validation to keep one from ever arriving. Same + // per-chain capability gate the taproot envelope lane already carries + // (envelopeRecognitionHeight), which this older lane never got. + // + // `=== false`, never a falsy test: supportsSegwit is declared only on + // the non-segwit coin (src/coins/DOGE.js), so it is undefined on + // BTC/LTC and `!this.network.supportsSegwit` would disable the whole + // P2WSH lane on the chains that DO use it and change how already + // indexed history decodes. + // + // Placed inside the branch body rather than in the `else if` + // condition, and after p2shFundingTxId is set, on purpose. Folding it + // into the condition would fall through to the trailing `else`, which + // appends the marker remainder as raw payload; clearing the funding + // txid would drop the commit's native-fee attribution. Both are + // behaviour changes on a live chain, and this is a capability gate. + // Against chain-realistic input it is a strict no-op: a non-segwit + // transaction carries no witness stack, so every input already failed + // the shape check below and nextDataBuffer already stayed empty. for (let txInputIndex=0;txInputIndex < transaction.ins.length;txInputIndex++){ let nextInput = transaction.ins[txInputIndex] try { + // Per-chain capability gate (see above). `continue`, not + // `break`: this branch sits inside the enclosing OUTPUT loop, + // so breaking here would stop scanning the transaction's + // remaining outputs. Same idiom and same meaning as the + // witness-shape check on the next line: this input carries no + // payload for us. + if (this.network.supportsSegwit === false) continue if (!nextInput["witness"] || nextInput["witness"].length < 3 || !Buffer.isBuffer(nextInput["witness"][2])) continue let decodedRedeemScript = bitcoin.script.decompile(nextInput["witness"][2]) if (!decodedRedeemScript || decodedRedeemScript.length < 1 || !Buffer.isBuffer(decodedRedeemScript[0])) continue @@ -1768,6 +1796,46 @@ class XChainDecoder { throw new Error(msg) } + // Depth already rolled back and not yet re-synced, carried across restarts. + // + // The guard above depends on a marker written on the ABORT path, which is + // exactly when the database may be the thing failing: markReorgHalted is + // best-effort, so two failed writes leave the halt recorded nowhere and this + // entry guard sees a clean database. The counter below does not have that + // hole, because deleteBlockByIndex commits each block's REORG marker inside + // the same transaction as the delete: whatever else fails, the evidence of a + // completed delete is durable. Counting the marked heights above the current + // tip therefore reconstructs the depth of an interrupted rollback, and the + // ceiling holds across a restart with no successful abort-time write. + // + // Fail-closed: an unreadable count is retried, and a persistent fault throws + // out of verifyReorg BEFORE any delete. Deliberately NOT a haltReorg - like + // the walk's read-fault catch below, a read fault is infrastructure, and a + // durable REORG_HALT would block every later reorg until an operator cleared + // it. Feature-detected so the minimal-mock verifyReorg tests stay unaffected. + let priorDepth = 0 + if (typeof this.db.countReorgDeletesAboveTip === 'function'){ + let seedErr = null + for (let attempt = 1; attempt <= 3; attempt++){ + try { + priorDepth = await this.db.countReorgDeletesAboveTip() + seedErr = null + break + } catch (err){ + seedErr = err + console.error(`reorg: could not read the prior rollback depth (attempt ${attempt}/3)`, err) + if (attempt < 3) await this.sleep(3000) + } + } + if (seedErr){ + const msg = 'verifyReorg: the prior rollback depth could not be read, so the dispenser ' + + 'safe-depth ceiling cannot be enforced across a restart. Refusing to delete any block: ' + + (seedErr.message || String(seedErr)) + console.error(msg) + throw new Error(msg) + } + } + // Persist the durable halt marker before an abort throws. Feature-detected, and // non-throwing so a marker failure never masks the loud abort, but NOT silent: // the outcome is honoured, published on the health surface and logged, because @@ -1875,11 +1943,19 @@ class XChainDecoder { // strictly safer than a silently corrupt DB: stop and require an // operator-driven resync. Called BEFORE each delete attempt (outside // the per-block retry try/catch, so the throw is not retried away). + // + // The ceiling is measured over priorDepth + this run's deletes, because the + // dispenser purge window is a property of the DATABASE, not of one process: + // 100 blocks deleted before a restart and 100 after are 200 blocks past the + // tip either way, and counting only the current invocation is what let a + // restart finish an aborted over-deep rollback. const assertWithinSafeDepth = async (lastBlockIndex) => { - if (blocksDeleted.length >= DISPENSER_EXPIRE_SAFE_DEPTH){ + if (priorDepth + blocksDeleted.length >= DISPENSER_EXPIRE_SAFE_DEPTH){ const msg = "verifyReorg: reorg depth exceeds the dispenser safe-depth window " + "(DISPENSER_EXPIRE_SAFE_DEPTH=" + DISPENSER_EXPIRE_SAFE_DEPTH + "). Already rolled back " - + blocksDeleted.length + " blocks; soft-expired dispenser rows for block height " + + (priorDepth + blocksDeleted.length) + " blocks (" + blocksDeleted.length + + " in this run, resumed from " + priorDepth + " already deleted above the tip); " + + "soft-expired dispenser rows for block height " + lastBlockIndex + " and below have already been hard-purged, so continuing would " + "silently lose money-bearing dispenser state. Aborting. Recovery: perform a full " + "resync from a known-good snapshot." diff --git a/src/db.js b/src/db.js index 16af433..12d6021 100644 --- a/src/db.js +++ b/src/db.js @@ -2663,6 +2663,73 @@ class Database { } } + // How many distinct block heights above the current tip have already been + // rolled back and not yet re-synced. + // + // This is the restart-durable half of the safe-depth ceiling. The REORG_HALT + // marker above is best-effort by construction: markReorgHalted runs on the + // abort path, so a DB fault at exactly that moment leaves the halt recorded + // nowhere, and a restarted decoder re-entered verifyReorg with a zeroed depth + // counter and finished the over-deep rollback. The evidence this method reads + // cannot be lost that way, because deleteBlockByIndex commits the REORG marker + // INSIDE the same transaction as the block delete: a deleted block and its + // 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 + // (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){ + // 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() + // Interpolated, not bound: LIMIT placeholders are not used anywhere else in + // this file, so the bound is range-checked here instead and the SQL stays the + // plain shape the rest of the module uses. The value is internal, never + // operator input, and the guard is what makes that literal safe. + const limit = Number(scanLimit) + if (!Number.isInteger(limit) || limit < 1 || limit > 1000000) + throw new Error('countReorgDeletesAboveTip: refusing an out-of-range scan limit: ' + scanLimit) + const query = `SELECT id, data FROM events WHERE code = 'REORG' ORDER BY id DESC LIMIT ${limit};` + let connection = await this.getConnection() + const ownLease = (this.transactionConnection == null) + try { + const rows = await connection.query(query) + if (!Array.isArray(rows)) + throw new Error('countReorgDeletesAboveTip: the REORG marker scan returned no readable rows') + const heightsAboveTip = new Set() + for (const row of rows){ + let payload + try { + payload = (typeof row.data === 'string') ? JSON.parse(row.data) : row.data + } catch (err){ + throw new Error('countReorgDeletesAboveTip: REORG marker id ' + row.id + + ' has an unreadable payload, so the rollback depth cannot be bounded: ' + err.message) + } + // Both marker shapes are arrays of {block_index, block_hash} (one entry + // per row since M-12, several on older rows); anything else means this + // is not the marker whose depth we are counting. + if (!Array.isArray(payload)) + throw new Error('countReorgDeletesAboveTip: REORG marker id ' + row.id + + ' is not the expected array payload, so the rollback depth cannot be bounded') + for (const entry of payload){ + const height = Number(entry && entry.block_index) + if (!Number.isFinite(height)) + throw new Error('countReorgDeletesAboveTip: REORG marker id ' + row.id + + ' carries a non-numeric block_index, so the rollback depth cannot be bounded') + if (height > tip) heightsAboveTip.add(height) + } + } + return heightsAboveTip.size + } finally { + if (ownLease){ + await connection.release() + } + } + } + // Read the durable halt marker WITH its detail. isReorgHalted() above // answers the one question verifyReorg asks (may I roll back?) and deliberately // stays a bare existence probe on the hot reorg path. Operator-facing surfaces diff --git a/test/unit/parseTransaction.test.js b/test/unit/parseTransaction.test.js index 847f6b6..c857f8b 100644 --- a/test/unit/parseTransaction.test.js +++ b/test/unit/parseTransaction.test.js @@ -629,3 +629,86 @@ describe('XChainDecoder#getSourceFromOutput()', () => { assert.ok(decoder.connector.getRawTransaction.calledTwice) }) }) + +// Per-chain capability gate on the P2WSH witness carrier. +// +// The branch recognized the XCHNp2wsh marker and read payload out of +// transaction.ins[i].witness[2] on any chain, consulting only the witness stack's +// SHAPE. On a chain that declares no segwit the lane relied entirely on upstream +// node validation to keep witness data from ever arriving, while the sibling +// taproot envelope lane has carried an explicit per-chain gate all along. +// +// The BTC case is the control that makes the DOGE case mean something: the same +// bytes, the same stubs, the same helper, and the only difference is the chain. +// Without it a blanket disable of the whole P2WSH lane would look identical. +describe('XChainDecoder#parseTransaction() P2WSH per-chain segwit gate', () => { + + const WITNESS_PAYLOAD = Buffer.from('witness-carrier-payload') + + function decoderFor(network) { + const decoder = new XChainDecoder( + network, null, null, null, null, null, + '127.0.0.1', 18443, 'rpc', 'rpc', false + ) + decoder.db = { isThereADispenserForAddress: sinon.stub().resolves(false) } + decoder.connector = { getRawTransaction: sinon.stub().rejects(new Error('mocked')) } + decoder.getSourceFromOutput = sinon.stub().resolves(null) + // The chunk lanes set p2shFundingTxId, which drives a commit fetch this + // test is not about. Stubbed on BOTH decoders so the only difference + // between them stays the chain. + sinon.stub(decoder, 'findFundingFeeOutputs').resolves([]) + sinon.stub(decoder, 'removeObfuscation').resolves( + Buffer.concat([Buffer.from('XCHN'), Buffer.from('p2wsh')]) + ) + return decoder + } + + // One well-formed witness carrier: a 3-element stack whose third element + // decompiles to a single payload push, which is exactly what the extraction + // path reads. Byte-identical for both chains. + function witnessCarrierTx() { + const tx = new bitcoin.Transaction() + tx.version = 2 + tx.addInput(PREV_HASH, 1) + tx.ins[0].witness = [ + Buffer.alloc(72, 0x30), + Buffer.alloc(33, 0x02), + // The extraction reads decompile(witness[2])[0] as this input's chunk, + // and the reassembled chunks are themselves decompiled as the action + // stream, so the carried chunk is a COMPILED push inside one more push. + bitcoin.script.compile([bitcoin.script.compile([WITNESS_PAYLOAD])]) + ] + tx.addOutput(bitcoin.script.compile([bitcoin.opcodes.OP_RETURN, Buffer.alloc(8, 0xAB)]), 0) + addP2PKHOutput(tx) + return tx + } + + afterEach(() => { + sinon.restore() + }) + + it('a segwit chain still extracts the witness payload (control)', async () => { + const decoder = decoderFor('bitcoin-regtest') + const result = await decoder.parseTransaction(witnessCarrierTx()) + + assert.ok(result, 'the witness carrier must still produce an action on a segwit chain') + assert.strictEqual(result.data.toString('utf-8'), WITNESS_PAYLOAD.toString('utf-8')) + }) + + it('a non-segwit chain extracts nothing from the same bytes and does not throw', async () => { + const decoder = decoderFor('dogecoin-regtest') + const result = await decoder.parseTransaction(witnessCarrierTx()) + + const extracted = result && result.data ? result.data : Buffer.alloc(0) + assert.strictEqual(extracted.length, 0, + 'a chain declaring supportsSegwit:false must read no payload out of a witness stack') + }) + + it('the gate is chain capability, not a parse error: the non-segwit chain records none', async () => { + const decoder = decoderFor('dogecoin-regtest') + const before = decoder.parseErrors + await decoder.parseTransaction(witnessCarrierTx()) + assert.strictEqual(decoder.parseErrors, before, + 'skipping an impossible carrier is not a malformed-transaction event') + }) +}) diff --git a/test/unit/reorgDepthAcrossRestart.test.js b/test/unit/reorgDepthAcrossRestart.test.js new file mode 100644 index 0000000..0a89734 --- /dev/null +++ b/test/unit/reorgDepthAcrossRestart.test.js @@ -0,0 +1,229 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// The safe-depth ceiling must survive a restart that lost the halt marker. +// +// verifyReorg's abort writes a durable REORG_HALT row, and its entry guard reads +// it. That marker is written on the ABORT path, which is exactly the moment the +// database may be the thing failing: markReorgHalted gets two attempts and then +// gives up, recording the halt only in memory and in logs. A restarted decoder +// then found a clean entry guard, started blocksDeleted empty, and finished the +// over-deep rollback past the dispenser purge window. +// +// These pin the second, restart-durable leg: the depth is reconstructed from the +// REORG markers deleteBlockByIndex commits inside each delete's own transaction, +// which no abort-time write failure can lose. + +'use strict' + +const assert = require('assert') +const sinon = require('sinon') +const XChainDecoder = require('../../src/XChainDecoder') +const Database = require('../../src/db.js') + +const SAFE_DEPTH = 126 +const NODE_TIP = 100 + +function makeDecoder() { + return new XChainDecoder( + 'bitcoin-regtest', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null + ) +} + +// A decoder holding blocks far above the node tip, so verifyReorg takes its +// above-tip delete branch and rolls back one block per pass. `db` overrides let +// each case state only the restart evidence it is about. +function restartedDecoder(db) { + const decoder = makeDecoder() + let height = 300 + const deleted = [] + decoder.db = Object.assign({ + getLastBlockIndex: async () => height, + getBlockByIndex: async (i) => (i < 0 ? null : { block_index: i, block_hash: 'aa'.repeat(32) }), + deleteBlockByIndex: async (i) => { deleted.push(i); height -= 1; return true }, + // A restart that LOST the halt marker: the entry guard sees a clean database, + // which is the whole premise of the hazard. + isReorgHalted: async () => false, + markReorgHalted: async () => true + }, db) + decoder.connector = { rpcErrors: 0 } + // The seed read retries with a 3s sleep; no test may pay for that in wall time. + decoder.sleep = async () => {} + return { decoder, deleted } +} + +describe('verifyReorg: the safe-depth ceiling survives a lost halt marker', function () { + + it('refuses the FIRST delete when the committed REORG markers already reach the ceiling', async function () { + const { decoder, deleted } = restartedDecoder({ + countReorgDeletesAboveTip: async () => SAFE_DEPTH + }) + + await assert.rejects(() => decoder.verifyReorg(NODE_TIP), /safe-depth/) + + assert.strictEqual(deleted.length, 0, + 'a rollback already at the ceiling must not delete one more block after a restart') + assert.strictEqual(decoder.getReorgHaltStatus().halted, true, + 'the decoder must publish the halt on its health surface') + }) + + it('spends only the depth that is left, then aborts', async function () { + const { decoder, deleted } = restartedDecoder({ + countReorgDeletesAboveTip: async () => SAFE_DEPTH - 3 + }) + + await assert.rejects(() => decoder.verifyReorg(NODE_TIP), /safe-depth/) + + assert.strictEqual(deleted.length, 3, + 'the remaining budget is the ceiling minus what was already deleted above the tip') + }) + + it('names both halves of the depth in the abort message, so the operator sees the resume', async function () { + const { decoder } = restartedDecoder({ + countReorgDeletesAboveTip: async () => SAFE_DEPTH - 2 + }) + + await assert.rejects(() => decoder.verifyReorg(NODE_TIP), (err) => { + assert.match(err.message, /Already rolled back 126 blocks/) + assert.match(err.message, /2 in this run/) + assert.match(err.message, /resumed from 124 already deleted above the tip/) + return true + }) + }) + + // The pre-fix behaviour, kept explicit: with the marker lost and no durable + // depth evidence, the run would delete 126 more blocks on top of whatever the + // aborted one had already taken. + it('without the durable count, a restart spends a whole fresh budget', async function () { + const { decoder, deleted } = restartedDecoder({}) // no countReorgDeletesAboveTip + + await assert.rejects(() => decoder.verifyReorg(NODE_TIP), /safe-depth/) + + assert.strictEqual(deleted.length, SAFE_DEPTH, + 'this is the resume the durable count exists to stop') + }) + + it('deletes nothing at all when the prior depth cannot be read', async function () { + let attempts = 0 + const { decoder, deleted } = restartedDecoder({ + countReorgDeletesAboveTip: async () => { attempts++; throw new Error('connection lost') } + }) + + await assert.rejects(() => decoder.verifyReorg(NODE_TIP), + /prior rollback depth could not be read/) + + assert.strictEqual(attempts, 3, 'a transient read fault is retried before the refusal') + assert.strictEqual(deleted.length, 0, + 'an unknown depth must never be treated as a zero depth') + // Deliberately NOT a durable halt: a read fault is infrastructure, and a + // REORG_HALT row would block every later reorg until an operator cleared it. + assert.strictEqual(decoder.getReorgHaltStatus().halted, false) + }) + + it('recovers when the read fault is transient', async function () { + let attempts = 0 + const { decoder, deleted } = restartedDecoder({ + countReorgDeletesAboveTip: async () => { + if (++attempts < 3) throw new Error('connection lost') + return SAFE_DEPTH - 1 + } + }) + + await assert.rejects(() => decoder.verifyReorg(NODE_TIP), /safe-depth/) + assert.strictEqual(deleted.length, 1) + }) + + it('still refuses on the halt marker when one DID survive, before counting anything', async function () { + let counted = false + const { decoder, deleted } = restartedDecoder({ + isReorgHalted: async () => true, + countReorgDeletesAboveTip: async () => { counted = true; return 0 } + }) + + await assert.rejects(() => decoder.verifyReorg(NODE_TIP), /HALTED from a prior over-deep reorg abort/) + assert.strictEqual(counted, false, 'the cheap durable guard still runs first') + assert.strictEqual(deleted.length, 0) + }) +}) + +describe('Database#countReorgDeletesAboveTip()', function () { + + afterEach(() => sinon.restore()) + + // Answers the two queries the method makes, in order: the tip, then the scan. + function dbWith(tipRows, eventRows) { + const db = new Database('127.0.0.1', 3306, 'xchain_btc_mainnet', 'u', 'p') + const query = sinon.stub().callsFake(async (sql) => { + if (/MAX\(block_index\)/.test(sql)) return tipRows + if (/code = 'REORG'/.test(sql)) return eventRows + throw new Error('unexpected query: ' + sql) + }) + db.pool = { getConnection: sinon.stub().resolves({ query, release: sinon.stub().resolves() }) } + return { db, query } + } + + const marker = (height) => ({ id: height, data: JSON.stringify([{ block_index: height, block_hash: 'bb' }]) }) + + it('counts only the marked heights above the current tip', async function () { + const { db } = dbWith([{ max_height: 200n }], [marker(203), marker(202), marker(201), marker(199)]) + assert.strictEqual(await db.countReorgDeletesAboveTip(), 3) + }) + + it('counts a height once even when it was deleted, re-synced and deleted again', async function () { + const { db } = dbWith([{ max_height: 200n }], [marker(201), marker(201), marker(202)]) + assert.strictEqual(await db.countReorgDeletesAboveTip(), 2) + }) + + it('returns zero on a database with no REORG markers at all', async function () { + const { db } = dbWith([{ max_height: 200n }], []) + assert.strictEqual(await db.countReorgDeletesAboveTip(), 0) + }) + + it('handles the pre-M-12 multi-entry payload shape', async function () { + const rows = [{ id: 9, data: JSON.stringify([{ block_index: 201 }, { block_index: 202 }, { block_index: 199 }]) }] + const { db } = dbWith([{ max_height: 200n }], rows) + assert.strictEqual(await db.countReorgDeletesAboveTip(), 2) + }) + + // "We could not tell" must never arrive at verifyReorg as "no prior rollback". + it('THROWS on an unparseable marker payload', async function () { + const { db } = dbWith([{ max_height: 200n }], [{ id: 7, data: '{not json' }]) + await assert.rejects(() => db.countReorgDeletesAboveTip(), /unreadable payload/) + }) + + it('THROWS on a marker payload that is not the expected array', async function () { + const { db } = dbWith([{ max_height: 200n }], [{ id: 7, data: JSON.stringify({ block_index: 201 }) }]) + await assert.rejects(() => db.countReorgDeletesAboveTip(), /not the expected array payload/) + }) + + it('THROWS on a non-numeric block_index', async function () { + const { db } = dbWith([{ max_height: 200n }], [{ id: 7, data: JSON.stringify([{ block_index: 'tip' }]) }]) + await assert.rejects(() => db.countReorgDeletesAboveTip(), /non-numeric block_index/) + }) + + it('bounds the scan, and refuses a nonsense bound rather than emitting it as SQL', async function () { + const { db, query } = dbWith([{ max_height: 200n }], []) + await db.countReorgDeletesAboveTip(250) + const scan = query.getCalls().map(c => String(c.args[0])).find(s => /code = 'REORG'/.test(s)) + assert.match(scan, /ORDER BY id DESC LIMIT 250;/) + + await assert.rejects(() => db.countReorgDeletesAboveTip('5; DROP TABLE blocks'), + /out-of-range scan limit/) + await assert.rejects(() => db.countReorgDeletesAboveTip(0), /out-of-range scan limit/) + }) + + 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 () => {} + const query = sinon.stub().rejects(new Error('connection lost')) + db.pool = { getConnection: sinon.stub().resolves({ query, release: sinon.stub().resolves() }) } + await assert.rejects(() => db.countReorgDeletesAboveTip(), /getLastBlockIndex failed/) + }) +}) From 741b6da0a92cd8ecc48a93ea1c8050a4f9a6d3fe Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 6 Sep 2026 09:04:59 -0700 Subject: [PATCH 08/10] fix(observability): release the response body so a stalled collector cannot pin the socket _post cleared its abort timer when the response HEADERS arrived and never released res.body, so a collector that answers 200 and then stalls held the socket open with nothing bounding it. Measured against a stalling collector, the socket was still open at three seconds and the batch was counted as shipped. That is worse than a leak, because the timeout was configured and did nothing: at a 400ms ship timeout the timer cleared at 22ms, so the stall outlived its only bound and the shipper reported success. The body is now cancelled inside the same then, within the abort timer's window, rather than after finally has cleared it. The stream is cancelled and never read, and a cancel on an already-errored body is swallowed. Every existing test injected a transport and bypassed the real fetch path, which is why this survived. The new case drives _post itself. Vendored copy, written by the hub sync script and never hand-edited. Parity is gated in the hub. --- src/observability/logShipper.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/observability/logShipper.js b/src/observability/logShipper.js index 656141a..dae9542 100644 --- a/src/observability/logShipper.js +++ b/src/observability/logShipper.js @@ -187,6 +187,15 @@ function readLogEnv(env = process.env) { }; } +// fetch() settles on the response HEADERS, so the body is still an open stream +// holding its socket while the abort timer is cleared. Release it inside that +// window by cancel, not read; a cancel on an errored body is not a ship failure. +function releaseBody(res) { + const stream = res && res.body; + if (!stream || typeof stream.cancel !== 'function') return Promise.resolve(); + return Promise.resolve(stream.cancel()).catch(() => {}); +} + class LogShipper { /** * @param {object} opts @@ -331,9 +340,9 @@ class LogShipper { const headers = { 'Content-Type': 'application/x-ndjson' }; if (token) headers.Authorization = `Bearer ${token}`; return fetch(url, { method: 'POST', headers, body, signal: controller.signal }) - .then((res) => { + .then((res) => releaseBody(res).then(() => { if (!res.ok) throw new Error(`collector responded ${res.status}`); - }) + })) .finally(() => clearTimeout(timer)); } From dc64f257acc9c601359061a5738f35699d2e762f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 6 Sep 2026 10:55:47 -0700 Subject: [PATCH 09/10] fix(decoder): expiry stops dropping payments inside the cancellation grace period A payment arriving during the cancellation grace period was dropped by expiry, even though the window exists precisely so a payment in flight when a cancel lands is still honoured. Gated, because it changes whether a payment settles and therefore moves credits. Mainnet ships UNARMED on the house sentinel and naming the activation instant is a separate operator act; testnet and regtest run from genesis, so both sides of the gate are exercised and historical replay stays byte-identical. --- src/XChainDecoder.js | 12 +- src/db.js | 31 +- src/dispenserCancelGrace.js | 100 ++++++ src/protocol/constants.js | 54 +++ test/unit/dispenserCancelGrace.test.js | 338 ++++++++++++++++++ .../dispenserCancelGraceActivation.test.js | 196 ++++++++++ 6 files changed, 726 insertions(+), 5 deletions(-) create mode 100644 src/dispenserCancelGrace.js create mode 100644 test/unit/dispenserCancelGrace.test.js create mode 100644 test/unit/dispenserCancelGraceActivation.test.js diff --git a/src/XChainDecoder.js b/src/XChainDecoder.js index 0640916..808ec5f 100644 --- a/src/XChainDecoder.js +++ b/src/XChainDecoder.js @@ -30,6 +30,7 @@ const CryptoNetworks = require('./CryptoNetworks') const XChainBlockDecoder = require('./XChainBlockDecoder') const { isOracleFeeCaptureActive, isOracleFeeSetCaptureActive, oracleAddressFromCreate, isCompactedOracleAddress, V0_GIVE_COIN_INDEX, V0_GET_COIN_INDEX, V0_GET_ADDRESS_INDEX, V0_REQUIRED_FIELD_COUNT, ORACLE_ADDRESS_INDEX, V0_EXPIRATION_INDEX, V2_EXPIRATION_INDEX } = require('./oracleFeeOutput') const { isDispenserExpiryRealignActive } = require('./dispenserExpiryRealign') +const { cancelGraceFloor } = require('./dispenserCancelGrace') const { captureCommands, collapseDispenserRegistrations, isBatchSubCommandCaptureActive } = require('./batchSubCommandCapture') const { chainTierMismatch, chainFieldMissing, chainGenesisMismatch, chainGenesisUnpinned } = require('./chainIdentity') // REORG_HALT rides getLogger() rather than this.logError, because a patched @@ -2771,7 +2772,16 @@ class XChainDecoder { // null signals the query failed: decoding the block against an empty set // would silently drop every dispense output on this instance only, so // retry the block instead. - let openDispenserAddresses = await this.db.getAllOpenDispenserAddresses() + // + // CANCELLATION GRACE (at/above DISPENSER_CANCEL_GRACE_ACTIVATION): the floor + // widens the set by dispensers whose expiration is inside the indexer's + // cancellation grace period, which the indexer keeps fillable for an hour past + // a cancel while the decoder's soft-expire knows nothing about cancels. Below + // the gate the floor is null and the set is the unwidened one, so a + // from-genesis re-decode reproduces what the fleet wrote. The floor derives + // only from this block's header time, so every honest node loads the same set. + let openDispenserAddresses = await this.db.getAllOpenDispenserAddresses( + cancelGraceFloor(this.consensusNetwork, block.timestamp)) if (openDispenserAddresses == null){ console.error(`Could not load open dispenser addresses for block ${nextBlockHeight}; retrying block`) await this.db.endTransaction() diff --git a/src/db.js b/src/db.js index 12d6021..3a582a9 100644 --- a/src/db.js +++ b/src/db.js @@ -2540,16 +2540,39 @@ class Database { // distinguishable, because decoding a block against a silently-empty set would // drop every dispense output on this instance only (instance-dependent block // contents). The block loop retries the block on null. - async getAllOpenDispenserAddresses(){ + // + // CANCELLATION GRACE. `graceFloor` is the oldest expiration still eligible for capture, + // computed by dispenserCancelGrace.cancelGraceFloor from the block's own header time, and + // null below DISPENSER_CANCEL_GRACE_ACTIVATION. A finite floor admits rows the soft-expire + // has already stamped whose expiration is no older than it, which is how the decoder keeps + // capturing payments to a dispenser the indexer holds fillable through its cancellation + // grace period. It widens THIS query and nothing else: the expiry mark, the extend mirror, + // the oracle-address resolution and the hard purge keep their timing, so the divergence + // stays in the over-capture direction the advisory contract above calls safe. Rationale and + // the reason the MARK must not move instead: src/dispenserCancelGrace.js. + async getAllOpenDispenserAddresses(graceFloor){ let db = await this.getConnection(); - let query = - `SELECT ia.address AS address + // Strict number test, not Number(): `Number(null)` is 0, which would arm a floor of + // 1970 on the null cancelGraceFloor returns below the gate and widen the capture set + // on an unarmed network. Fail closed on anything that is not already a finite number. + const floor = graceFloor + const graceActive = (typeof floor === 'number') && Number.isFinite(floor) + // Two literal statements rather than one composed string: the below-gate query must + // stay exactly the text the fleet has been running, so a re-decode of pre-flag-day + // history cannot drift on a formatting edit. + let query = graceActive + ? `SELECT ia.address AS address + FROM dispensers op + LEFT JOIN index_addresses ia ON ia.id = op.address_id + WHERE op.expired_block_index IS NULL + OR op.expiration >= ?` + : `SELECT ia.address AS address FROM dispensers op LEFT JOIN index_addresses ia ON ia.id = op.address_id WHERE op.expired_block_index IS NULL` let addresses = new Set() try { - let rows = await db.query(query); + let rows = graceActive ? await db.query(query, [floor]) : await db.query(query); for (let row of rows){ if (row["address"] != null) addresses.add(row["address"]) diff --git a/src/dispenserCancelGrace.js b/src/dispenserCancelGrace.js new file mode 100644 index 0000000..a80d307 --- /dev/null +++ b/src/dispenserCancelGrace.js @@ -0,0 +1,100 @@ +/********************************************************************* + * + * Copyright © 2025-2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC - https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * XChain Decoder - dispenser cancellation grace window on payment capture + * + * The indexer keeps a CANCELLED dispenser fillable past its own expiration. Its expiration + * pass skips `cancelling` rows (xchain-indexer/src/db.js getExpiredItems, `s2.status='open'`), + * findMatchingDispensers still matches `status IN ('open','cancelling')`, and DISPENSER_CLOSE + * fires only at the cancel's block time plus DISPENSER_CLOSE_DELAY (3600s). The decoder + * mirrors no cancel at all, deliberately, so it soft-expires that dispenser at its raw + * expiration and drops the address from the block loop's payment-capture set. + * + * Cancel a funded dispenser shortly before its expiration and the two lifecycles diverge in + * the money-bearing direction: the indexer still settles fills, the decoder captures no + * output, and because the indexer only ever sees outputs the decoder persisted to + * transaction_outputs, the buyer's native coin reaches the seller with no DISPENSE record and + * no inventory release. + * + * A blanket grace window closes it by construction. A valid cancel always PRECEDES the + * dispenser's own expiration, so `expiration + grace` always covers `cancel_time + close + * delay`, with no cancel parsing, no BATCH sub-command gate dependency, and no cancel-target + * resolution (the guess the advisory contract in db.js retired). + * + * WHAT THE GRACE MOVES, AND WHAT IT MUST NOT. The widening applies to the CAPTURE SET only: + * getAllOpenDispenserAddresses admits a row whose expiration is no older than the floor this + * module computes. The soft-expire predicate, the expiry MARK, the extend mirror, the + * oracle-address resolution and the hard purge keep their current timing. That confines the + * change to the over-capture direction the advisory contract calls safe, because capture is a + * Set membership test that the indexer arbitrates afterwards. Delaying the MARK instead would + * reach getOpenDispenserOracleAddressBySource, whose below-gate `ORDER BY ... LIMIT 1` would + * then rank a dead row first; oracle-fee capture is a single-address EQUALITY test, so a wrong + * pick captures NOTHING. That is the under-capture direction, a second funds-loss path rather + * than a fix. Widen the capture set, never the mark. + * + * Changing which outputs are persisted is consensus-affecting, so it rides a flag-day in the + * same shape as dispenserExpiryRealign: this module holds the one pure decision. + * + ********************************************************************/ + +'use strict'; + +const { DISPENSER_CANCEL_GRACE_ACTIVATION } = require('./protocol/constants.js') + +// Seconds a soft-expired dispenser stays an eligible payment destination at/above the gate. +// +// Pinned to the indexer's DISPENSER_CLOSE_DELAY (xchain-indexer/src/config.js). The invariant +// is GRACE >= CLOSE_DELAY: the indexer stops matching a cancelled dispenser at cancel time +// plus its close delay, and the cancel precedes the expiration, so a grace of at least the +// close delay covers every block in which the indexer can still settle a fill. Equal, not +// larger, because every extra second is capture the indexer discards. dispenserCancelGrace +// tests read the indexer's value directly, so retuning it there fails this suite until this +// constant follows. +const DISPENSER_CANCEL_GRACE_SECONDS = 3600 + +// Is the grace window in force for a block at `blockTime` on this network? +// +// Fails CLOSED twice over, since either failure mode would widen the persisted output set on +// a chain whose fleet has not armed the change (a fork): +// * an unrecognized network name reads as "no grace", not "no gate"; +// * a null (DISARMED) entry means the network's maintainers have not ratified an instant +// yet, and stays inactive at every block time rather than defaulting to genesis-on. +// +// Comparison is `blockTime >= activation`, the same >= semantics the indexer's +// protocol_changes gates and the sibling dispenser gates use. +function isDispenserCancelGraceActive(consensusNetwork, blockTime){ + const activation = DISPENSER_CANCEL_GRACE_ACTIVATION[consensusNetwork] + if (typeof activation !== 'number') return false + const t = Number(blockTime) + if (!Number.isFinite(t)) return false + return t >= activation +} + +// The capture floor for a block: the oldest expiration still eligible for payment capture. +// +// Returns null below the gate, which getAllOpenDispenserAddresses reads as "keep the +// unwidened set", so a from-genesis re-decode of pre-flag-day history reproduces the output +// set the fleet wrote live, byte for byte. Above it the floor is a pure function of the +// block's own header time, so two honest nodes load the identical capture set. +function cancelGraceFloor(consensusNetwork, blockTime){ + if (!isDispenserCancelGraceActive(consensusNetwork, blockTime)) return null + return Number(blockTime) - DISPENSER_CANCEL_GRACE_SECONDS +} + +module.exports = { + DISPENSER_CANCEL_GRACE_ACTIVATION, + DISPENSER_CANCEL_GRACE_SECONDS, + isDispenserCancelGraceActive, + cancelGraceFloor, +} diff --git a/src/protocol/constants.js b/src/protocol/constants.js index 5c8d842..e2a5d26 100644 --- a/src/protocol/constants.js +++ b/src/protocol/constants.js @@ -427,6 +427,59 @@ const DISPENSER_EXPIRY_REALIGN_ACTIVATION = { regtest: 0, }; +// DISPENSER_CANCEL_GRACE_ACTIVATION (dispenser cancellation grace capture): the flag-day +// at/above which the DECODER keeps a just-expired dispenser in the block loop's payment +// CAPTURE SET for a grace window past its expiration. Keyed on BLOCK TIME with the same >= +// semantics as DISPENSER_EXPIRY_REALIGN_ACTIVATION, because dispensers settle on BTC, LTC +// and DOGE, whose heights diverge. +// +// WHY IT EXISTS: the indexer keeps a CANCELLED dispenser fillable past its own expiration. +// It excludes `cancelling` rows from its expiration pass (xchain-indexer/src/db.js +// getExpiredItems, `s2.status='open'`), keeps them matchable through +// `status IN ('open','cancelling')` in findMatchingDispensers, and closes only at the +// cancel's block time plus DISPENSER_CLOSE_DELAY (3600s). The decoder mirrors no cancel at +// all, by design, so it soft-expires that dispenser at its raw expiration and drops the +// address from the capture set. Cancel a funded dispenser shortly before its expiration and +// a window opens: the indexer still settles fills, the decoder captures no output, and the +// buyer's native coin reaches the seller with no DISPENSE record and no inventory release. +// +// At/above the gate the CAPTURE SET alone widens: a row whose expiration is no older than +// the grace window stays an eligible payment destination even once the soft-expire has +// stamped it. The soft-expire itself, the expiry MARK, the extend mirror, the oracle-address +// resolution and the hard purge all keep their current timing, which confines the change to +// the over-capture direction the decoder's advisory contract (xchain-decoder/src/db.js, +// above extendOpenDispenserExpirationBySource) calls safe. Delaying the MARK instead reaches +// the legacy single-pick oracle resolution, whose ORDER BY ... LIMIT 1 then ranks a dead row +// first and captures nothing at all: the under-capture direction, a second money-bearing +// defect rather than a fix. Widen the capture set, never the mark. +// +// CONSENSUS-AFFECTING: it changes the set of outputs persisted to transaction_outputs, so an +// ungated widening breaks from-genesis byte-identity and forks validators. The unwidened +// capture set therefore stays live BELOW the gate, and a re-decode of pre-flag-day history +// reproduces exactly what the fleet wrote. +// +// null means DISARMED (never active), the fail-closed default: mainnet keeps the unwidened +// capture set until that network's maintainers ratify 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. +// +// 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 block whose header +// time passes a cancelled dispenser's expiration. +// +// Vendored byte-equal into xchain-decoder/src/protocol/constants.js; the conformance suite +// keeps the two copies in lockstep. +const DISPENSER_CANCEL_GRACE_ACTIVATION = { + mainnet: null, // DISARMED: awaiting the operator's ratified per-network instant + // ARMED AT GENESIS (instant 0 = always in force), matching the sibling + // DISPENSER_EXPIRY_REALIGN_ACTIVATION under the pre-launch ruling that every feature must + // be ACTIVE on testnet. This gate closes a defect that spends a payer's native coin and + // gives nothing back, so a public testnet WILL hit it. Safe at 0 because testnet + // decoder/indexer state is REBUILT from the chain before launch. + testnet: 0, + regtest: 0, +}; + // BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION (output capture AND open-dispenser registration // through a BATCH): the flag-day at/above which the DECODER reads a BATCH's SUB-COMMANDS // instead of only its top-level ACTION name, both when deciding which native-coin outputs to @@ -619,6 +672,7 @@ module.exports = { ORACLE_FEE_OUTPUT_ACTIVATION, ORACLE_FEE_SET_CAPTURE_ACTIVATION, DISPENSER_EXPIRY_REALIGN_ACTIVATION, + DISPENSER_CANCEL_GRACE_ACTIVATION, BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION, ENVELOPE_MAX_PAYLOAD, ENVELOPE_RECOGNITION_ACTIVATION, diff --git a/test/unit/dispenserCancelGrace.test.js b/test/unit/dispenserCancelGrace.test.js new file mode 100644 index 0000000..8aca4e1 --- /dev/null +++ b/test/unit/dispenserCancelGrace.test.js @@ -0,0 +1,338 @@ +'use strict'; + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Dispenser CANCELLATION GRACE: payment capture must outlast the indexer's fill window. +// +// The two services disagree about when a CANCELLED dispenser stops taking money, and the +// disagreement runs in the money-bearing direction. The indexer excludes `cancelling` rows +// from its expiration pass, keeps matching `status IN ('open','cancelling')`, and closes only +// at cancel time + DISPENSER_CLOSE_DELAY (3600s). The decoder mirrors no cancel at all, so it +// soft-expires the row at its raw expiration and drops the address from the block loop's +// capture set. Cancel a funded dispenser shortly before its expiration and the indexer keeps +// settling fills while the decoder captures nothing, so the buyer's native coin reaches the +// seller with no DISPENSE record and no inventory release. +// +// The invariant this suite drives, over a sweep of block times rather than one lucky point: +// for every block in which the INDEXER would still settle a fill, +// the DECODER's capture set contains the dispenser's address. +// +// SENSITIVITY: the sweep and the single-block case FAIL against a decoder whose capture set +// is `expired_block_index IS NULL` alone, which is the behavior below the flag-day and the +// behavior this suite exists to change. Below-the-gate assertions pin that older behavior in +// place, so the green side is a gate flip and not a rewritten expectation. + +const assert = require('assert') +const sinon = require('sinon') + +const XChainDecoder = require('../../src/XChainDecoder') +const Database = require('../../src/db.js') +const { DISPENSER_CANCEL_GRACE_SECONDS, + cancelGraceFloor } = require('../../src/dispenserCancelGrace') + +const PREV_WIRE = Buffer.from( + '00112233445566778899aabbccddeeff0123456789abcdeffedcba9876543210', + 'hex' +) +// The reorg check compares a block's prevHash, byte-reversed to display order, against the +// stored hash of the block below it. +const PREV_HASH = Buffer.from(PREV_WIRE).reverse().toString('hex') + +const ADDR = 'bcrt1qgracedispenser' +const EXPIRATION = 1700000000 // the dispenser's own expiry instant +const CANCEL_AT = EXPIRATION - 600 // cancelled 10 minutes before it +// The indexer's cancel close-delay, named here to express "a block the indexer would still +// settle a fill in". The decoder carries its own copy as DISPENSER_CANCEL_GRACE_SECONDS, and +// dispenserCancelGraceActivation.test.js pins the two together against the indexer's config. +const INDEXER_CLOSE_DELAY = 3600 + +// The indexer's rule for a CANCELLED dispenser: expiration does not close it (getExpiredItems +// skips `cancelling` rows), findMatchingDispensers still matches it, and DISPENSER_CLOSE fires +// at cancel time + close delay. +function indexerStillSettlesFill(blockTime){ + return blockTime < CANCEL_AT + INDEXER_CLOSE_DELAY +} + +// A faithful in-memory model of the decoder `dispensers` table, mirroring the db.js SQL for +// the two methods this fix touches. Every capture load is recorded with the floor it was +// given, so a test can assert on the exact set the block loop received. +class DispenserModel { + constructor(){ + this.rows = [] + this.captureLoads = [] + } + async insertDispenser(){ return true } + async extendOpenDispenserExpirationBySource(){ return true } + async getOpenDispenserOracleAddressBySource(){ return null } + async getOpenDispenserOracleAddressesBySource(){ return [] } + async purgeExpiredDispensers(){ return true } + // Mirrors deleteOpenDispensers: stamp open rows whose expiration < minExpiration. + async deleteOpenDispensers(blockIndex, minExpiration){ + for (const r of this.rows) + if (r.expiredBlockIndex === null && r.expiration < Number(minExpiration)) + r.expiredBlockIndex = blockIndex + return true + } + // Mirrors getAllOpenDispenserAddresses: + // WHERE expired_block_index IS NULL (no floor) + // WHERE expired_block_index IS NULL OR expiration >= ? (floor bound) + async getAllOpenDispenserAddresses(graceFloor){ + // Same strict number test as db.js: `Number(null)` is 0, which would silently arm a + // 1970 floor below the gate. + const floor = graceFloor + const graceActive = (typeof floor === 'number') && Number.isFinite(floor) + const set = new Set(this.rows + .filter(r => r.expiredBlockIndex === null || (graceActive && r.expiration >= floor)) + .map(r => r.address)) + this.captureLoads.push({ floor: graceActive ? floor : null, set }) + return set + } +} + +function fakeTx(id){ + return { getId: () => id, outs: [] } +} + +// An inert parseTransaction result: this suite exercises the block loop's CAPTURE-SET +// plumbing, not the decode path. +function inertParseResult(){ + return { + data: Buffer.alloc(0), source: null, destination: null, amount: 0, + dispenseOutputs: [], paymentOutputs: [], compiledDataLength: 0, rawData: null, + } +} + +// Drive the real block loop over two blocks on `consensusNetwork`: +// block 0 at `expireAt` - the decoder's own soft-expire stamps the dispenser here; +// block 1 at `payAt` - the payment block whose capture set the test asserts on. +// Nothing is pre-stamped by hand: the stamp under test is written by the production +// deleteOpenDispensers call site. +function runTwoBlocks(consensusNetwork, expireAt, payAt, model){ + const decoder = new XChainDecoder( + 'bitcoin-regtest', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null + ) + // The gate reads consensusNetwork, so set it directly rather than routing a mainnet name + // through the chain-identity machinery this suite does not exercise. + decoder.consensusNetwork = consensusNetwork + decoder.startBlockIndex = 0 + decoder.sleep = async () => {} + + const timesByHeight = { 0: expireAt, 1: payAt } + const setsSeenByParse = [] + decoder.parseTransaction = async (tx, openDispenserAddresses) => { + setsSeenByParse.push(openDispenserAddresses) + return inertParseResult() + } + + decoder.connector = { + getBlockchainInfo: async () => ({ verificationprogress: 1, blocks: 1 }), + getBlockHash: async (height) => 'height:' + height, + getBlock: async (hash) => hash, + } + + let commits = 0 + decoder.db = { + createDatabase: async () => true, + verifyDatabase: async () => true, + verifyTables: async () => true, + runMigrations: async () => ({ applied: [], pending: [] }), + getLastBlockIndex: async () => -1, + getLastTxIndex: async () => 0, + // Block 1 runs the reorg check against block 0's stored hash. Both blocks carry the + // same PREV_WIRE, so answering with that value keeps the chain contiguous and the loop + // out of its reorg branch, which this suite does not exercise. + getBlockByIndex: async () => ({ block_hash: PREV_HASH }), + beginTransaction: async () => {}, + endTransaction: async () => {}, + commitTransaction: async () => { if (++commits >= 2) decoder.stopFlag = true; return true }, + insertBlock: async () => true, + insertEvent: async () => true, + insertTransaction: async () => true, + insertTransactionOutput: async () => true, + POISON_ROW: 2, + DUPLICATED_TRANSACTION: 1, + insertDispenser: (d) => model.insertDispenser(d), + extendOpenDispenserExpirationBySource: (s, e, b) => model.extendOpenDispenserExpirationBySource(s, e, b), + deleteOpenDispensers: (b, m) => model.deleteOpenDispensers(b, m), + purgeExpiredDispensers: (h) => model.purgeExpiredDispensers(h), + getAllOpenDispenserAddresses: (f) => model.getAllOpenDispenserAddresses(f), + getOpenDispenserOracleAddressBySource: (s) => model.getOpenDispenserOracleAddressBySource(s), + getOpenDispenserOracleAddressesBySource: (s) => model.getOpenDispenserOracleAddressesBySource(s), + } + + decoder.xchainBlockDecoder = { + blockFromHex: (hex) => ({ + prevHash: Buffer.from(PREV_WIRE), + timestamp: timesByHeight[Number(String(hex).split(':')[1])], + transactions: [fakeTx('tx-at-' + String(hex))], + }) + } + + return decoder.start().then(() => ({ setsSeenByParse })) +} + +// One funded dispenser, cancelled shortly before its expiration, as the decoder holds it: +// the decoder mirrors no cancel, so the row carries only its own expiration. +function fundedCancelledDispenser(){ + const model = new DispenserModel() + model.rows.push({ address: ADDR, expiration: EXPIRATION, expiredBlockIndex: null }) + return model +} + +describe('dispenser cancellation grace: decoder capture outlasts the indexer fill window', function () { + this.timeout(0) + + it('captures a payment made after expiry while the indexer still settles fills', async () => { + // The finding's named failure mode, driven end to end. The dispenser is cancelled at + // EXPIRATION - 600, so the indexer keeps settling until EXPIRATION + 3000. A payment + // 30 minutes past the expiration lands squarely inside that window. + const payAt = EXPIRATION + 1800 + assert.ok(indexerStillSettlesFill(payAt), + 'the probe block must be one the indexer would still settle a fill in') + + const model = fundedCancelledDispenser() + const { setsSeenByParse } = await runTwoBlocks('regtest', EXPIRATION + 1, payAt, model) + + // The production soft-expire really did stamp the row on the earlier block, so the + // grace clause is what carries it, not an unexpired row. + assert.strictEqual(model.rows[0].expiredBlockIndex, 0, + 'block 0 must have soft-expired the dispenser, or this test proves nothing') + + assert.strictEqual(model.captureLoads.length, 2) + const payLoad = model.captureLoads[1] + assert.strictEqual(payLoad.floor, payAt - DISPENSER_CANCEL_GRACE_SECONDS, + 'the block loop must pass the grace floor derived from this block header time') + assert.ok(payLoad.set.has(ADDR), + 'a payment inside the indexer fill window must still be captured by the decoder') + + // The set the loop handed parseTransaction is the same object, so capture really runs + // against the widened set rather than a copy made for the assertion. + assert.strictEqual(setsSeenByParse[1], payLoad.set) + }) + + it('keeps the unwidened capture set below the flag-day (the other side of the gate)', async () => { + // Same blocks, same model, DISARMED network. This is the behavior the fleet runs today + // and the behavior a from-genesis re-decode of pre-flag-day history must reproduce. + const payAt = EXPIRATION + 1800 + const model = fundedCancelledDispenser() + await runTwoBlocks('mainnet', EXPIRATION + 1, payAt, model) + + assert.strictEqual(model.rows[0].expiredBlockIndex, 0) + const payLoad = model.captureLoads[1] + assert.strictEqual(payLoad.floor, null, + 'below the gate the block loop must pass no floor at all') + assert.ok(!payLoad.set.has(ADDR), + 'below the gate the expired dispenser stays out of the capture set') + }) + + it('closes capture once the indexer can no longer settle a fill', async () => { + // The grace is a window, not an amnesty: past expiration + grace the address leaves the + // capture set, and by then the indexer stopped matching the dispenser long ago. + const payAt = EXPIRATION + DISPENSER_CANCEL_GRACE_SECONDS + 1 + assert.ok(!indexerStillSettlesFill(payAt), + 'the probe block must be one the indexer has already closed') + + const model = fundedCancelledDispenser() + await runTwoBlocks('regtest', EXPIRATION + 1, payAt, model) + + assert.ok(!model.captureLoads[1].set.has(ADDR), + 'past the grace window the dispenser leaves the capture set') + }) + + it('covers every block of the indexer fill window, swept at five-minute steps', async () => { + // The invariant, not a lucky point. Walk the payment block from the expiration out past + // the grace and assert the implication in both directions at each step. + let insideWindowBlocks = 0 + for (let payAt = EXPIRATION + 1; payAt <= EXPIRATION + 4500; payAt += 300){ + const model = fundedCancelledDispenser() + await runTwoBlocks('regtest', EXPIRATION + 1, payAt, model) + const captured = model.captureLoads[model.captureLoads.length - 1].set.has(ADDR) + + if (indexerStillSettlesFill(payAt)){ + insideWindowBlocks++ + assert.ok(captured, + `block time ${payAt}: the indexer still settles fills here, so the decoder ` + + 'must still capture payments to the dispenser') + } + // Outside the indexer's window capture is merely allowed to continue to the end of + // the grace: over-capture is the direction the advisory contract calls safe, and + // the indexer drops the surplus. + if (payAt > EXPIRATION + DISPENSER_CANCEL_GRACE_SECONDS) + assert.ok(!captured, `block time ${payAt}: capture must end with the grace window`) + } + // Guard against a vacuous sweep: an arithmetic slip that made the window empty would + // otherwise pass every assertion above. + assert.ok(insideWindowBlocks >= 8, + `the sweep must cross at least 8 blocks inside the indexer fill window, saw ${insideWindowBlocks}`) + }) +}) + +describe('Database#getAllOpenDispenserAddresses() grace floor', function () { + afterEach(() => sinon.restore()) + + function makeDb(){ return new Database('127.0.0.1', 3306, 'xchain_btc_regtest', 'u', 'p') } + function withConn(queryStub){ + const conn = { + query: queryStub, release: sinon.stub().resolves(), + beginTransaction: sinon.stub().resolves(), commit: sinon.stub().resolves(), + rollback: sinon.stub().resolves(), + } + return { pool: { getConnection: sinon.stub().resolves(conn) } } + } + + it('runs the unwidened predicate and binds nothing when no floor is given', async () => { + const db = makeDb() + const q = sinon.stub().resolves([{ address: ADDR }]) + db.pool = withConn(q).pool + await db.getAllOpenDispenserAddresses() + const [sql, params] = q.firstCall.args + assert.ok(/expired_block_index IS NULL/.test(sql)) + assert.ok(!/expiration >= \?/.test(sql), + 'the below-gate query must not carry the grace clause') + assert.strictEqual(params, undefined, 'the below-gate query must bind no parameter') + }) + + it('adds the grace clause and binds the floor when one is given', async () => { + const db = makeDb() + const q = sinon.stub().resolves([{ address: ADDR }]) + db.pool = withConn(q).pool + const floor = cancelGraceFloor('regtest', EXPIRATION + 1800) + await db.getAllOpenDispenserAddresses(floor) + const [sql, params] = q.firstCall.args + assert.ok(/expired_block_index IS NULL\s*\n\s*OR op\.expiration >= \?/.test(sql), + 'the above-gate query must admit rows whose expiration is no older than the floor') + assert.deepStrictEqual(params, [EXPIRATION + 1800 - DISPENSER_CANCEL_GRACE_SECONDS]) + }) + + it('treats a null or non-finite floor as no grace at all', async () => { + // cancelGraceFloor returns null below the gate, so this is the fail-closed path that + // keeps an unarmed network on the legacy capture set. + for (const floor of [null, undefined, NaN, 'soon']){ + const db = makeDb() + const q = sinon.stub().resolves([]) + db.pool = withConn(q).pool + await db.getAllOpenDispenserAddresses(floor) + const [sql, params] = q.firstCall.args + assert.ok(!/expiration >= \?/.test(sql), `floor ${String(floor)} must not widen the query`) + assert.strictEqual(params, undefined) + } + }) + + it('still returns null on a query fault, with or without a floor', async () => { + // A failed read and an empty set must stay distinguishable; the grace path must not + // quietly become an empty-set success. + for (const floor of [null, EXPIRATION]){ + const db = makeDb() + db.pool = withConn(sinon.stub().rejects(new Error('fail'))).pool + assert.strictEqual(await db.getAllOpenDispenserAddresses(floor), null) + } + }) +}) diff --git a/test/unit/dispenserCancelGraceActivation.test.js b/test/unit/dispenserCancelGraceActivation.test.js new file mode 100644 index 0000000..600a503 --- /dev/null +++ b/test/unit/dispenserCancelGraceActivation.test.js @@ -0,0 +1,196 @@ +'use strict'; + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// DISPENSER_CANCEL_GRACE_ACTIVATION drift guard, plus the two cross-repo invariants the +// grace constant depends on. +// +// The gate widens the block loop's payment-capture address set by a grace window past a +// dispenser's expiration, which changes the set of outputs persisted to transaction_outputs. +// That is consensus-affecting in both directions: +// * arming it in the PAST rewrites agreed history, so a from-genesis re-decode stops +// matching what the fleet wrote live; +// * arming it on a network whose decoders are not all running the value forks the fleet at +// the first block that passes a cancelled dispenser's expiration. +// So mainnet is DISARMED (null) until the operator ratifies an instant, and the helper fails +// closed on anything that is not a number. +// +// Two tiers, so a one-sided edit fails somewhere no matter which checkout is present: +// 1. PIN - the vendored map has the disarmed/genesis-on shape, in this repo alone. +// 2. DOCS - it is value-identical to the canonical map in +// xchain-documentation/protocol/constants.js. +// Tier 2 skips when the sibling checkout is absent (standalone deploy); set +// XCHAIN_REQUIRE_SIBLINGS=1 in CI so a missing sibling hard-fails instead of green-by-skip. + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const { DISPENSER_CANCEL_GRACE_ACTIVATION, + DISPENSER_CANCEL_GRACE_SECONDS, + isDispenserCancelGraceActive, + cancelGraceFloor } = require('../../src/dispenserCancelGrace.js'); +const XChainDecoder = require('../../src/XChainDecoder.js'); + +const DOCS_CONSTANTS = process.env.XCHAIN_DOCS_DIR + ? path.join(process.env.XCHAIN_DOCS_DIR, 'protocol', 'constants.js') + : path.join(__dirname, '..', '..', '..', 'xchain-documentation', 'protocol', 'constants.js'); +const INDEXER_DIR = process.env.XCHAIN_INDEXER_DIR || + path.join(__dirname, '..', '..', '..', 'xchain-indexer'); +const INDEXER_CONFIG = path.join(INDEXER_DIR, 'src', 'config.js'); +const REQUIRE_SIBLINGS = process.env.XCHAIN_REQUIRE_SIBLINGS === '1'; + +function siblingOrSkip(ctx, file){ + if (fs.existsSync(file)) return true; + if (REQUIRE_SIBLINGS) + throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but sibling not found: ' + file); + ctx.skip(); + return false; +} + +// The indexer pins DISPENSER_CLOSE_DELAY as a literal in a config builder that wants a live +// environment, so read the assignment out of the source rather than executing the module. +function indexerCloseDelay(){ + const src = fs.readFileSync(INDEXER_CONFIG, 'utf8'); + const m = src.match(/config\['DISPENSER_CLOSE_DELAY'\]\s*=\s*(\d+)\s*;/); + assert.ok(m, 'xchain-indexer/src/config.js must assign a numeric DISPENSER_CLOSE_DELAY'); + return Number(m[1]); +} + +describe('DISPENSER_CANCEL_GRACE_ACTIVATION conformance', function () { + + it('keeps mainnet DISARMED, with testnet and regtest genesis-on', function () { + // Teeth for the ratification requirement: a number on MAINNET means someone armed a + // consensus boundary without the operator's ratified instant. + assert.strictEqual(DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet, null); + assert.strictEqual(DISPENSER_CANCEL_GRACE_ACTIVATION.testnet, 0); + assert.strictEqual(DISPENSER_CANCEL_GRACE_ACTIVATION.regtest, 0); + }); + + it('is value-identical to the canonical map in xchain-documentation', function () { + if (!siblingOrSkip(this, DOCS_CONSTANTS)) return; + const canon = require(DOCS_CONSTANTS).DISPENSER_CANCEL_GRACE_ACTIVATION; + assert.ok(canon && typeof canon === 'object', + 'xchain-documentation/protocol/constants.js must export DISPENSER_CANCEL_GRACE_ACTIVATION'); + assert.deepStrictEqual( + { mainnet: DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet, + testnet: DISPENSER_CANCEL_GRACE_ACTIVATION.testnet, + regtest: DISPENSER_CANCEL_GRACE_ACTIVATION.regtest }, + { mainnet: canon.mainnet, testnet: canon.testnet, regtest: canon.regtest }, + 'the vendored map drifted from the canonical one; a one-sided flag-day edit forks ' + + 'the decoder fleet at the first block that passes a cancelled dispenser expiration'); + }); + + it('a DISARMED network is inactive at every block time, including absurd ones', function () { + // A `time >= null` coercion would read 0 and arm mainnet from genesis, which is the + // failure this pins. + assert.strictEqual(isDispenserCancelGraceActive('mainnet', 0), false); + assert.strictEqual(isDispenserCancelGraceActive('mainnet', 1786060800), false); + assert.strictEqual(isDispenserCancelGraceActive('mainnet', 4000000000), false); + assert.strictEqual(cancelGraceFloor('mainnet', 4000000000), null); + }); + + it('testnet and regtest are active from genesis', function () { + assert.strictEqual(isDispenserCancelGraceActive('testnet', 0), true); + assert.strictEqual(isDispenserCancelGraceActive('regtest', 1700000000), true); + }); + + it('fails closed on an unrecognized network name', function () { + // An unknown network must read as "no grace", never as "no gate": the latter would + // widen the persisted output set on an unarmed chain. + assert.strictEqual(isDispenserCancelGraceActive('signet', 4000000000), false); + assert.strictEqual(isDispenserCancelGraceActive(undefined, 4000000000), false); + assert.strictEqual(isDispenserCancelGraceActive('', 4000000000), false); + assert.strictEqual(cancelGraceFloor('signet', 4000000000), null); + }); + + it('fails closed on a non-finite block time', function () { + assert.strictEqual(isDispenserCancelGraceActive('regtest', NaN), false); + assert.strictEqual(isDispenserCancelGraceActive('regtest', undefined), false); + assert.strictEqual(isDispenserCancelGraceActive('regtest', 'not-a-time'), false); + assert.strictEqual(cancelGraceFloor('regtest', NaN), null); + }); + + it('flips exactly at the armed instant once a network IS armed (>= semantics)', function () { + // The map is disarmed today, so arm a network in place for the length of this test and + // drive the REAL helper (the module reads the map per call, so the mutation is + // visible). This pins the boundary the operator will ratify onto: >=, so the block AT + // the instant already carries the grace, matching every protocol_changes gate. + const ARMED = 1789430400; + const saved = DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet; + DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet = ARMED; + try { + assert.strictEqual(isDispenserCancelGraceActive('mainnet', ARMED - 1), false, + 'the block below the instant keeps the unwidened capture set'); + assert.strictEqual(cancelGraceFloor('mainnet', ARMED - 1), null); + assert.strictEqual(isDispenserCancelGraceActive('mainnet', ARMED), true, + 'the block AT the instant already carries the grace'); + assert.strictEqual(cancelGraceFloor('mainnet', ARMED), + ARMED - DISPENSER_CANCEL_GRACE_SECONDS); + assert.strictEqual(isDispenserCancelGraceActive('mainnet', ARMED + 1), true); + } finally { + DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet = saved; + } + assert.strictEqual(isDispenserCancelGraceActive('mainnet', ARMED), false, + 'the map must be back to DISARMED after the probe'); + }); + + it('the floor is exactly one grace window below the block time', function () { + // The floor is the whole consensus decision: two nodes reading the same header time + // must load the same capture set, so it is a pure subtraction and nothing else. + assert.strictEqual(cancelGraceFloor('regtest', 1700000000), + 1700000000 - DISPENSER_CANCEL_GRACE_SECONDS); + assert.strictEqual(cancelGraceFloor('regtest', 0), -DISPENSER_CANCEL_GRACE_SECONDS); + }); +}); + +describe('DISPENSER_CANCEL_GRACE_SECONDS cross-repo invariants', function () { + + it('covers the indexer cancellation grace period', function () { + // The invariant the whole fix rests on. The indexer stops matching a cancelled + // dispenser at cancel time + DISPENSER_CLOSE_DELAY, and a valid cancel always precedes + // the dispenser's own expiration, so a grace of at least the close delay covers every + // block in which the indexer can still settle a fill. A shorter grace reopens the + // funds-loss window silently, because the arithmetic keeps working. + if (!siblingOrSkip(this, INDEXER_CONFIG)) return; + const closeDelay = indexerCloseDelay(); + assert.ok( + DISPENSER_CANCEL_GRACE_SECONDS >= closeDelay, + `DISPENSER_CANCEL_GRACE_SECONDS (${DISPENSER_CANCEL_GRACE_SECONDS}) must be >= the ` + + `indexer DISPENSER_CLOSE_DELAY (${closeDelay}); the indexer was retuned without ` + + 'following it in src/dispenserCancelGrace.js' + ); + }); + + it('holds the hand-pinned close delay the constant is set from', function () { + // Baseline that fires even without the sibling checkout: the constant is pinned EQUAL + // to the indexer's delay, not merely above it, because every extra second is capture + // the indexer discards. + assert.strictEqual(DISPENSER_CANCEL_GRACE_SECONDS, 3600); + }); + + it('the hard purge cannot reclaim a row that is still inside the grace window', function () { + // purgeExpiredDispensers hard-deletes a row 126 blocks (DISPENSER_EXPIRE_SAFE_DEPTH) + // after the block that stamped it. A row purged while still inside its grace window + // would drop out of the widened capture set early, so the depth has to outlast the + // grace on the FASTEST chain the platform decodes. Purge is keyed on block HEIGHT, so + // even a burst that outran this stays deterministic across nodes; the margin is what + // keeps the grace from being cosmetic on DOGE. + const FASTEST_TARGET_SPACING = 60; // DOGE (BTC 600 / LTC 150 / DOGE 60) + const purgeSpan = XChainDecoder.DISPENSER_EXPIRE_SAFE_DEPTH * FASTEST_TARGET_SPACING; + assert.ok( + purgeSpan > DISPENSER_CANCEL_GRACE_SECONDS, + `the purge span (${purgeSpan}s at target spacing) must exceed the grace window ` + + `(${DISPENSER_CANCEL_GRACE_SECONDS}s), or a cancelled dispenser is hard-deleted ` + + 'before the indexer stops matching it' + ); + }); +}); From 22d95b9a9fbcf784f2db62e6d5bf932836b2f8c8 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 4 Sep 2026 06:56:51 -0700 Subject: [PATCH 10/10] release: v0.15.0 --- CHANGELOG.md | 6 ++++++ README.md | 6 +++--- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ba2e9d..188b498 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.15.0] - 2026-09-07 + +### Changed +- The vendored coin registry is resynced from the hub. +- mariadb moved off the cleartext-credential advisory range with the floor pinned in the dependency gate. + ## [0.12.0] - 2026-08-30 ### Added diff --git a/README.md b/README.md index 73c98ca..0871276 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ # XChain Platform Decoder

- Version - Tests + Version + Tests Node License

@@ -114,7 +114,7 @@ defaults hold on an unconfigured box: | `npm run migrate` | Apply pending database migrations (auto + manual; `--file ` scopes to specific migration(s)) | | `npm run ci` | The full no-external-services gate: unit, security, smoke, regression, chaos, and a 100-iteration fuzz pass (about a minute) | | `npm run test:smoke` | Smoke tests (58 tests, no external services) | -| `npm run test:unit` | Unit tests (1,447 tests, no external services) | +| `npm run test:unit` | Unit tests (1,450 tests, no external services) | | `npm run test:security` | Security tests (83 tests, no external services) | | `npm run test:integration` | Integration tests (30 tests; brings up its own throwaway regtest node and MariaDB, requires Docker) | | `npm run test:e2e` | End-to-end tests (72 tests; brings up its own throwaway regtest node and MariaDB on separate ports, requires Docker) | diff --git a/package-lock.json b/package-lock.json index caa9781..eaf29cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-decoder", - "version": "0.12.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-decoder", - "version": "0.12.0", + "version": "0.15.0", "license": "AGPL-3.0-or-later", "dependencies": { "axios": "^1.18.1", diff --git a/package.json b/package.json index 64b9640..709ced7 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.12.0", + "version": "0.15.0", "license": "AGPL-3.0-or-later", "repository": { "type": "git",