From 2057f51d91acf141a2c4bf56c2495baeedd4a090 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 09:36:51 -0700 Subject: [PATCH 01/57] docs(consensus): the mirror admission gate header names its own file --- src/consensus/gates/mirror_admission_gate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/consensus/gates/mirror_admission_gate.js b/src/consensus/gates/mirror_admission_gate.js index 179bfb19c..71492a232 100644 --- a/src/consensus/gates/mirror_admission_gate.js +++ b/src/consensus/gates/mirror_admission_gate.js @@ -3,7 +3,7 @@ const { get, copy, activeAt } = require('../gate_registry'); /* - * mirror_admission_activation.js - admission by height for the mirror barrier family. + * mirror_admission_gate.js - admission by height for the mirror barrier family. * * BYTE-IDENTICAL TWIN. This file exists at xchain-indexer/src/, xchain-hub/src/ and, once * vendored, xchain-explorer/src/. The three copies are held identical by From bc8f76462f5d1f8dc1cba34ca34fda83b2f8c3ad Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 13:30:58 -0700 Subject: [PATCH 02/57] docs(consensus): restore snapshot reorg buffer comments --- src/consensus/snapshot_reorg_buffer.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/consensus/snapshot_reorg_buffer.js b/src/consensus/snapshot_reorg_buffer.js index 28ea9dad3..57e45d0d0 100644 --- a/src/consensus/snapshot_reorg_buffer.js +++ b/src/consensus/snapshot_reorg_buffer.js @@ -74,8 +74,23 @@ const { get, copy, activeAt } = require('./gate_registry'); +// The reorg-depth buffer every party in a federation must resolve capability +// snapshots at. 6 = the BTC confirmation depth the platform already treats as +// buried (XCHAIN_CONFIRMATIONS_BTC). CONSENSUS-CRITICAL: the hub subtracts this +// before every snapshot lookup and refuses to boot on mainnet/testnet when a local +// override diverges (CapabilitySnapshot._resolveReorgBuffer), so a verifier that +// buries by a different depth resolves a different set than the signer. const CANONICAL_REORG_BUFFER = copy('snapshot_reorg_buffer.CANONICAL_REORG_BUFFER'); +// Per-network activation height (LOCAL COPY of the canonical map maintained +// upstream, kept equal by the cross-service regression suite). Keyed on the +// BTC-anchored declared snapshot_block. +// +// ARMED at genesis on mainnet, testnet and regtest per the gate table: every +// network resolves through the buried height from block 0. Arming changes +// acceptance itself, so a one-sided or partially-rolled-out arm would fork the +// fleet rather than fix it; the gate table records the per-network ruling and +// the evidence for arming each network at genesis instead of a later height. const SNAPSHOT_BURIAL_ACTIVATION = copy('snapshot_reorg_buffer.SNAPSHOT_BURIAL_ACTIVATION'); // Whether a verifier must bury the declared snapshot_block before re-deriving the From 914825c321dab70a541d56bb4e13b6e49cd71dae Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 15:52:10 -0700 Subject: [PATCH 03/57] fix(consensus): re-cut the LTC and DOGE mirror-admission heights onto the BTC instant The v0.20.0 family was sized 2026-09-16 20:41Z from last-99-block cadences. LTC testnet then ran at about 82 s per block against the 146.6 s that sizing assumed, pulling its producer boundary to 3.2 h out while BTC's stayed 53.0 h out, and DOGE's drifted 7.5 h early. Two legs of one cross-chain match would have crossed the flag day about two days apart, which is what the same-wall-clock-instant rule exists to prevent. Re-measured 2026-09-17 22:45Z over a trailing window as long as the lead being sized: TBTC 152,891 at 576.7 s per block, TLTC 4,889,190 at 82.5 s, TDOGE 67,904,912 at 27.7 s. BTC is unchanged, still keyed to epoch close 153,216 plus 6 buried; LTC and DOGE are converted onto that same instant, and each consumer is its own producer plus six hours at its own measured cadence rather than a block count carried over from the first sizing. Arms the TRAIN_ACTIVATION 0.20.0 row at testnet 153,116, 106 blocks and about 17 h below the BTC producer. resolveRuleSet reads only the local map, so with no row every block above the boundary keeps resolving under 0.19.0, and a manifest naming 0.20.0 halts a fleet in which no build implements it. Records the cadence-window rule the re-cut used and makes the re-size rule per chain instead of BTC-keyed, so an LTC or DOGE drift past the six-hour ordering margin forces a re-cut the way an overrun BTC height already does. --- bin/pins/at1-consensus-identity.json | 6 +++--- src/consensus/gate_registry/shared_rows_2.js | 21 ++++++++++--------- src/consensus/gate_registry/shared_rows_5.js | 14 +++++++++++++ ...onsensus_rules_digest_active_gates.test.js | 2 +- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/bin/pins/at1-consensus-identity.json b/bin/pins/at1-consensus-identity.json index 15b57bc18..2a65ea62d 100644 --- a/bin/pins/at1-consensus-identity.json +++ b/bin/pins/at1-consensus-identity.json @@ -1,5 +1,5 @@ { - "consensus_rules_digest": "9976904c51f870be0dd21287887a81e21bac80b79f47d0ee7a4ed9a5e5743375", + "consensus_rules_digest": "ee6476f2445fe454b6b262e6f9dbea1b922bd31ba7eec55a6c7b30dbb513cf5e", "consensus_rules_gates": { "anchor_reward_activation.ANCHOR_REWARD_ACTIVATION": "{\"mainnet\":961000,\"regtest\":0,\"testnet\":0}", "anchor_reward_activation.ARCHIVE_REWARD_ACTIVATION": "{\"mainnet\":963000,\"regtest\":0,\"testnet\":0}", @@ -21,8 +21,8 @@ "attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING_V2": "{\"headroom\":1,\"maxSlots\":2,\"startOffset\":0}", "rollcall_gates_activation.ROLLCALL_GATES_ACTIVATION": "{\"mainnet\":null,\"regtest\":null,\"testnet\":152208}", "xchain_bridge_activation.XCHAIN_BRIDGE_ACTIVATION": "{\"BTC:mainnet\":9999999999,\"BTC:testnet\":152929,\"DOGE:mainnet\":9999999999,\"DOGE:testnet\":67902062,\"LTC:mainnet\":9999999999,\"LTC:testnet\":4887898,\"mainnet\":9999999999,\"regtest\":0,\"testnet\":9999999999}", - "mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":153222,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67910821,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":4889331}", - "mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":153266,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67911703,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":4889479}", + "mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":153222,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67911796,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":4891504}", + "mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":153266,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67912575,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":4891766}", "mirror_admission_activation.ADMIT_MARGIN_BLOCKS": "{\"anchor_reward_attestations\":144,\"attestation_responses\":1,\"default\":4,\"oracle_prices\":1}", "mirror_admission_activation.ADMIT_MIN_FUTURE_BLOCKS": "1", "mirror_admission_activation.ADMIT_MAX_FUTURE_BLOCKS": "{\"BTC\":6,\"DOGE\":60,\"LTC\":24,\"default\":6}", diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index eda6ba327..c7fd765da 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -204,20 +204,21 @@ addGate('mirror_admission_activation.ADMIT_MAX_FUTURE_BLOCKS', 'constant', { * height on a BTC indexer, so the two legs of one cross-chain match would cross the flag day at * unrelated instants. The 'COIN:network' key shape is established precedent. * - * Mainnet is null under the 2026-08-29 write hold. TESTNET SIZED 2026-09-16 20:41Z; the measured - * tips, the formula, the epoch-close rule and the re-size rule are written once in the canon - * (xchain-documentation/protocol/constants.js), which this row is held value-identical to. The v7 - * HUB_SCHEMA_VERSION roll completes BEFORE any of these heights: the heights map rides frames - * carrying no schema_version, so a v7 indexer above the activation against a v6 hub would see no - * heights at all and defer forever under the fail-closed rule. + * Mainnet is null under the 2026-08-29 write hold. TESTNET SIZED 2026-09-16 20:41Z, LTC and DOGE + * RE-CUT 2026-09-17 22:45Z onto the BTC instant after their cadences drifted off it; the measured + * tips, the formula, the cadence-window rule, the epoch-close rule and the per-chain re-size rule + * are written once in the canon (xchain-documentation/protocol/constants.js), which this row is + * held value-identical to. The v7 HUB_SCHEMA_VERSION roll completes BEFORE any of these heights: + * the heights map rides frames carrying no schema_version, so a v7 indexer above the activation + * against a v6 hub would see no heights at all and defer forever under the fail-closed rule. */ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 153222, // SIZED 2026-09-16 20:41Z: epoch close 153,216 + 6 buried; tip 152,756 + 466 at 498.7 s/blk, about 64.5 h - 'LTC:testnet': 4889331, // the same instant: tip 4,887,745 + 1586 at 146.6 s/blk - 'DOGE:testnet': 67910821, // the same instant: tip 67,901,335 + 9486 at 24.5 s/blk + 'LTC:testnet': 4891504, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 4,889,190 + 2314 at 82.5 s/blk + 'DOGE:testnet': 67911796, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 67,904,912 + 6884 at 27.7 s/blk 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -228,8 +229,8 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 153266, // its producer + 44 blocks, about 6 h: strictly above, never equal - 'LTC:testnet': 4889479, // its producer + 148 blocks, about 6 h - 'DOGE:testnet': 67911703, // its producer + 882 blocks, about 6 h + 'LTC:testnet': 4891766, // its producer + 262 blocks, about 6 h at 82.5 s/blk + 'DOGE:testnet': 67912575, // its producer + 779 blocks, about 6 h at 27.7 s/blk 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration diff --git a/src/consensus/gate_registry/shared_rows_5.js b/src/consensus/gate_registry/shared_rows_5.js index 92f3979bb..cbf289761 100644 --- a/src/consensus/gate_registry/shared_rows_5.js +++ b/src/consensus/gate_registry/shared_rows_5.js @@ -97,6 +97,20 @@ addGate('train_activation.TRAIN_ACTIVATION', 'ruleset', { // bridge height below sits above it on the same BTC clock, so a node lacking this rule // set halts before it can grade a bridge action. '0.19.0': { mainnet: 9999999999, testnet: 152787, regtest: 0 }, + // The mirror-admission rule set, armed at the v0.20.0 cut: the producer and consumer + // admission maps and the anchor-attest barrier replace the effective_time binding, so a + // node without them grades an admission-stamped row under the rule it replaced. Mainnet + // holds the house sentinel because the whole family is null on mainnet under the + // 2026-08-29 write hold. Testnet: SIZED 2026-09-17 22:45Z, chain_tip TBTC 152,891 + 225 + // blocks, which is ceil(36 h / 576.7 s per block), about 36.0 h. The cadence is measured + // over a trailing window as long as the lead being sized (53 h here), never the last 99 + // blocks: a 99-block window on a testnet difficulty burst is noise, and it is what pulled + // the LTC leg of this family two days off its BTC counterpart a day after the first cut. + // That lead is the rolling-upgrade window the fleet roll must finish inside (24x the 90 + // minute roll budget), and every testnet mirror-admission height sits above it on the same + // BTC clock (the BTC producer at 153,222 is 106 blocks and about 17.0 h further up), so a + // node lacking this rule set halts before it can grade an admission-stamped row. + '0.20.0': { mainnet: 9999999999, testnet: 153116, regtest: 0 }, }); // xchain_bridge_activation diff --git a/test/unit/consensus/consensus_rules_digest_active_gates.test.js b/test/unit/consensus/consensus_rules_digest_active_gates.test.js index f5d79f4eb..604ca394c 100644 --- a/test/unit/consensus/consensus_rules_digest_active_gates.test.js +++ b/test/unit/consensus/consensus_rules_digest_active_gates.test.js @@ -96,7 +96,7 @@ function registerPinnedDigestTest() { for (const [p] of saved) delete require.cache[p]; const fresh = require('../../../src/consensus_rules_digest.js'); expect(fresh.computeConsensusRulesDigest().digest) - .to.equal('9976904c51f870be0dd21287887a81e21bac80b79f47d0ee7a4ed9a5e5743375', + .to.equal('ee6476f2445fe454b6b262e6f9dbea1b922bd31ba7eec55a6c7b30dbb513cf5e', 'the consensus rules digest moved; a gate was added, removed, reordered or re-armed'); } finally { for (const [p, mod] of saved) { if (mod === undefined) delete require.cache[p]; else require.cache[p] = mod; } From 751b47f12a2e4e5291bdd47bcb4c3290d567cc1c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 13:00:56 -0700 Subject: [PATCH 04/57] fix(mirror): stream match anchor stamps as metadata Send post-archive anchor stamps on a narrow frame that is not subject to late-finalization refusal. Keep full row admission guarded by the height watermark. --- src/anchor/publisher/archive/rows.js | 14 ++--- src/peers/hub_db_broadcaster.js | 25 +++++++++ ...anchor_publisher_finalized_binding.test.js | 55 ++++++++++++++++--- 3 files changed, 78 insertions(+), 16 deletions(-) diff --git a/src/anchor/publisher/archive/rows.js b/src/anchor/publisher/archive/rows.js index f47825a3b..ccfa727d0 100644 --- a/src/anchor/publisher/archive/rows.js +++ b/src/anchor/publisher/archive/rows.js @@ -141,18 +141,18 @@ module.exports = { for(let m of matchIds){ await this.db.updateCrossChainMatchByMatchIdAndBatchSeq(batchSeq, m.status, txid, m.match_id); } - // Re-emit the stamped rows on the hub-DB mirror feed: anchor_txid is the one - // back-filled column the mirror twins carry, and without a re-broadcast a - // long-running streamed mirror keeps NULL forever while a later REST bootstrap - // serves the stamp (divergent mirrors). Retracted rows stay out of the feed - // (the stream already deleted them on mirrors); old sync clients INSERT IGNORE - // the re-delivery, so this is backward-compatible. + // Emit a metadata-only anchor stamp on the hub-DB mirror feed. A full-row + // re-broadcast can be refused after the admission watermark abandons the + // original XDEX round, while bypassing that refusal for a full row could + // insert a consensus row below a certified height. The narrow event lets a + // mirror update an existing row only. Retracted rows stay out of the feed + // because the stream already deleted them on mirrors. if(txid && matchIds.length && this.hub && this.hub.hubDbBroadcaster){ try { let ids = matchIds.map(m => m.match_id); let rows = await this.db.findLiveCrossChainMatchesByMatchIds(ids); for(let row of rows) - this.hub.hubDbBroadcaster.broadcastRow({ table: 'cross_chain_matches', row: row }); + this.hub.hubDbBroadcaster.broadcastMatchAnchorStamp(row.match_id, row.anchor_txid); } catch(e){ logger.warn(nodeUtil.format('StateAnchorPublisher: anchor-stamp re-broadcast failed (mirrors converge on next bootstrap):', e.message)); } diff --git a/src/peers/hub_db_broadcaster.js b/src/peers/hub_db_broadcaster.js index 0a0348694..1716c8b4d 100644 --- a/src/peers/hub_db_broadcaster.js +++ b/src/peers/hub_db_broadcaster.js @@ -234,6 +234,31 @@ class HubDbBroadcaster { } } + // Deliver the post-archive anchor_txid without replaying the consensus row. + // The mirror applies this frame as UPDATE-only, so a row that was not admitted + // before the height watermark passed remains absent and must come from REST + // bootstrap. This path is intentionally outside isLateFinalization because it + // cannot add a match or alter signed match content. + broadcastMatchAnchorStamp(matchId, anchorTxid) { + if (this.subscribers.size === 0) return; + if (typeof matchId !== 'string' || matchId.length === 0 || + typeof anchorTxid !== 'string' || anchorTxid.length === 0) return; + let message; + try { + message = JSON.stringify({ + type: 'row:anchor-stamped', + table: 'cross_chain_matches', + match_id: matchId, + anchor_txid: anchorTxid, + schema_version: HUB_SCHEMA_VERSION + }); + } catch (e) { + logger.error(nodeUtil.format('HubDbBroadcaster: anchor-stamp serialization error:', e)); + return; + } + for (let ws of this.subscribers) this.send(ws, message); + } + // Broadcast a reorg retraction to all subscribers so they prune their local // price-table copies. event: { table, source_chain, from_action_index, to_action_index?, retraction_generation? } // to_action_index is included only for a CLOSED-range (deferred) retraction so subscribers diff --git a/test/unit/anchor/publisher/state_anchor_publisher_finalized_binding.test.js b/test/unit/anchor/publisher/state_anchor_publisher_finalized_binding.test.js index 126ba5ef8..3fbac09ab 100644 --- a/test/unit/anchor/publisher/state_anchor_publisher_finalized_binding.test.js +++ b/test/unit/anchor/publisher/state_anchor_publisher_finalized_binding.test.js @@ -17,6 +17,8 @@ const { expect } = require('chai'); const { CP_ROW, matchRow, buildMesh, registerMeshHooks } = require('../../../helpers/anchor_mesh.js'); +const HubDbBroadcaster = require('../../../../src/peers/hub_db_broadcaster.js'); +const { admitMarginBlocks } = require('../../../../src/consensus/gates/mirror_admission_gate.js'); describe('StateAnchorPublisher', function () { registerMeshHooks(); @@ -26,6 +28,7 @@ describe('StateAnchorPublisher', function () { registerRewardListGateCases(); registerBackfillStampCase(); registerBackfillBroadcastCases(); + registerLateWindowStampCase(); }); // ── XANC-ELECTED-FORGE-1 (archive half): bind the v1 archive txid ──────────── @@ -187,27 +190,29 @@ function registerBackfillBroadcastCases() { let bus = buildMesh(1, { matches: [matchRow('m1'), matchRow('m2', 'retracted')] }); let nd = bus.nodes[0]; let broadcast = []; - nd.pub.hub.hubDbBroadcaster = { broadcastRow: (ev) => broadcast.push(ev) }; + nd.pub.hub.hubDbBroadcaster = { + broadcastMatchAnchorStamp: (matchId, anchorTxid) => broadcast.push({ matchId, anchorTxid }) + }; await nd.pub.backfillBatch(0, [{ match_id: 'm1', status: 'finalized' }, { match_id: 'm2', status: 'retracted' }], 'dogetx_rebroadcast', [], []); - // Only the non-retracted row re-emits (the stream already deleted retracted - // rows on mirrors; re-inserting one would diverge them), and it carries the - // freshly stamped anchor_txid plus the full signed content. + // Only the non-retracted row emits a metadata-only update. A full-row replay + // would either be refused after the admission window or admit a missing row + // below the certified watermark. expect(broadcast.length, 'exactly one re-broadcast').to.equal(1); - expect(broadcast[0].table).to.equal('cross_chain_matches'); - expect(broadcast[0].row.match_id).to.equal('m1'); - expect(broadcast[0].row.anchor_txid).to.equal('dogetx_rebroadcast'); - expect(broadcast[0].row.a_amount, 'full row, not a partial patch').to.equal('1000'); + expect(broadcast[0].matchId).to.equal('m1'); + expect(broadcast[0].anchorTxid).to.equal('dogetx_rebroadcast'); }); it('backfillBatch does NOT re-broadcast on a null txid or without a broadcaster', async function () { let bus = buildMesh(1); let nd = bus.nodes[0]; let broadcast = []; - nd.pub.hub.hubDbBroadcaster = { broadcastRow: (ev) => broadcast.push(ev) }; + nd.pub.hub.hubDbBroadcaster = { + broadcastMatchAnchorStamp: (matchId, anchorTxid) => broadcast.push({ matchId, anchorTxid }) + }; // Null txid = the archive never landed on-chain (rows stay pending); there is // no stamp to propagate, so the feed stays quiet. @@ -221,3 +226,35 @@ function registerBackfillBroadcastCases() { expect(nd.db.matches.find(m => m.match_id === 'm1').anchor_txid, 'back-fill still applied').to.equal('dogetx_x'); }); } + +// A stamp raised after the admission window still reaches the mirror, because it +// rides the metadata-only frame rather than a full-row admission. +function registerLateWindowStampCase() { + it('backfillBatch delivers an anchor stamp after the match admission window closes', async function () { + let margin = admitMarginBlocks('cross_chain_matches'); + let lateMatch = Object.assign(matchRow('m1'), { admit_block_btc: 899999 + margin }); + let bus = buildMesh(1, { matches: [lateMatch] }); + let nd = bus.nodes[0]; + let broadcaster = new HubDbBroadcaster({}, nd.db); + broadcaster.admissionWatermark.observeTip('BTC', 900000, Date.now() - 600000); + let sent = []; + broadcaster.subscribers.add({ + readyState: 1, bufferedAmount: 0, _hubBuffered: 0, + send: (raw) => sent.push(JSON.parse(raw)), close: () => {} + }); + nd.pub.hub.hubDbBroadcaster = broadcaster; + + try { + await nd.pub.backfillBatch(0, [{ match_id: 'm1', status: 'finalized' }], + 'dogetx_after_window', [], []); + expect(sent.length, 'the streaming mirror receives the late anchor stamp').to.equal(1); + expect(sent[0]).to.deep.include({ + type: 'row:anchor-stamped', table: 'cross_chain_matches', + match_id: 'm1', anchor_txid: 'dogetx_after_window' + }); + expect(sent[0]).to.not.have.property('row'); + } finally { + broadcaster.stop(); + } + }); +} From 3a0cf02048268dcc74b193065a50f452084df6a9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 21:01:55 -0700 Subject: [PATCH 05/57] Require sibling guards in full hub CI Run both hub unit-suite tiers with strict sibling enforcement so missing guard inputs fail. Preserve the existing parent-shell sibling preflight and leave shared row sources unchanged. --- bin/ci-full.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bin/ci-full.sh b/bin/ci-full.sh index 3de3902d6..3301f48be 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -122,7 +122,7 @@ run_tier "measurement tools (bin/test)" \ npx mocha 'bin/test/**/*.test.js' --no-config --timeout 120000 --recursive --exit # --- job: ci (XChain-Platform/.github ci-reusable.yml -> npm run ci) ------- -run_tier "ci" npm run ci +run_tier "ci" env XCHAIN_REQUIRE_SIBLINGS=1 npm run ci # --- job: perf ------------------------------------------------------------- # The workflow gives this job its own MariaDB service container; here the DB is @@ -159,7 +159,8 @@ run_tier "drift: vendored observability shim vs canonical (six consumers)" \ npm run check:observability-sync # --- job: coverage --------------------------------------------------------- -run_tier "coverage ratchet (coverage:check)" npm run coverage:check +run_tier "coverage ratchet (coverage:check)" \ + env XCHAIN_REQUIRE_SIBLINGS=1 npm run coverage:check echo if [ -n "$FAILED" ]; then From a3bc6a2be718969cd46f390cf85912bef84d0ae4 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 20:32:00 -0700 Subject: [PATCH 06/57] fix(consensus): propagate the admission boundary fix to the hub twin Takes src/consensus/gates/mirror_admission_gate.js from the xchain-indexer canonical through the W5 gate-twin row of the platform twin reconcile script, so the indexer, hub and explorer copies are byte-identical again at sha256 e6a3ee813ddb8385f0fbf43cf1aa788229d37ecf29a44634827d96d2c7067336. Moves the thirteen tests that asserted the removed refusals onto the legacy byte semantics: a seam round is now refused on its signatures rather than at the builder, and it is still never stored, which is the property the ingest owes. Re-pins the carrier logic and the AT1 consensus identity; the bare rules digest ee6476f2 is unmoved on both sides of the twin. Operator ruling R14 (a), 2026-09-19. --- bin/pins/at1-consensus-identity.json | 2 +- bin/pins/carrier-logic.json | 9 +++++- src/consensus/gates/mirror_admission_gate.js | 31 +++++++----------- .../price_admission_wire_carrier.test.js | 7 ++-- ...price_admission_wire_carrier_batch.test.js | 14 +++++--- ...ce_admission_wire_carrier_producer.test.js | 8 +++-- .../price_v0_canonical_admission.test.js | 32 ++++++++++--------- ...2_payload_twin_parity_single_round.test.js | 14 ++++---- .../shared/admission/admission_height.test.js | 22 ++++++------- .../admission_height_runtime.test.js | 21 ++++++------ 10 files changed, 87 insertions(+), 73 deletions(-) diff --git a/bin/pins/at1-consensus-identity.json b/bin/pins/at1-consensus-identity.json index 2a65ea62d..c03cfc943 100644 --- a/bin/pins/at1-consensus-identity.json +++ b/bin/pins/at1-consensus-identity.json @@ -55,5 +55,5 @@ "mainnet" ], "hub_schema_version": 7, - "carrier_logic_digest": "48f3d471e54ceb2082861bdcd6ece30d68d42375feb18153cf8f9bcda4ee165d" + "carrier_logic_digest": "377fd6d83e83872da5effecb58f24e6c39188f7d13ba63fd5ce433ea4d02b4b3" } diff --git a/bin/pins/carrier-logic.json b/bin/pins/carrier-logic.json index 912e932ee..d5d439348 100644 --- a/bin/pins/carrier-logic.json +++ b/bin/pins/carrier-logic.json @@ -42,7 +42,7 @@ }, "mirror_admission_activation": { "path": "src/consensus/gates/mirror_admission_gate.js", - "hash": "ff9dacf3d0abc1037361c4265f5a324d1bc0965b078e528df55da4320f981a85", + "hash": "d59a0c44b7d0dc1dbc5f1530d67e012a7dac33f1824960565a9788b75cff6a25", "twins": [] }, "oracle_clamp_reference_activation": { @@ -630,6 +630,13 @@ "to": "26894199bd6651dbedcc962825eb63aea91b0a5e7d1d8d3e28eaddf73ce69415", "reason": "W5 consolidation (row 21)", "date": "2026-09-16" + }, + { + "id": "mirror_admission_activation", + "from": "ff9dacf3d0abc1037361c4265f5a324d1bc0965b078e528df55da4320f981a85", + "to": "d59a0c44b7d0dc1dbc5f1530d67e012a7dac33f1824960565a9788b75cff6a25", + "reason": "canonical admission boundary fix, operator ruling (a) 2026-09-19", + "date": "2026-09-19" } ] } diff --git a/src/consensus/gates/mirror_admission_gate.js b/src/consensus/gates/mirror_admission_gate.js index 71492a232..ba343a97b 100644 --- a/src/consensus/gates/mirror_admission_gate.js +++ b/src/consensus/gates/mirror_admission_gate.js @@ -364,20 +364,19 @@ function isAdmissionEra(network, eraBlock){ } /** - * The canonical tail for a row's admission map: '' below the activation, and '|' plus the - * encoded map at or above it. + * The canonical tail for a row's admission map: '' unless both the row is at or above the + * activation and the map is present, and '|' plus the encoded map when both are true. * - * REFUSES IN BOTH DIRECTIONS, exactly as AttestationConsensus._buildCanonical does for - * the mirror era. Building a legacy canonical for a modern row strands the row (its - * signatures reproduce over bytes no verifier rebuilds); building a modern canonical for - * a legacy row forks a from-genesis replay. Neither can be recovered from downstream, so - * both throw where the caller that got it wrong is still on the stack. + * A version seam can expose either an admission map to an inert node or a legacy row to an + * armed node. Both cases take the legacy byte path so mixed versions can bind the row and + * let quorum verification decide whether its signatures match. Only an armed node with a + * present map emits admission bytes. * * No per-rail canonical VERSION field is minted for this, and none exists anywhere in the * tree: this height-gated era check IS the versioning, and a version integer would * duplicate the gate while giving a Byzantine leader a second field to disagree about. * - * @param {string} label the builder's canonical tag, for the refusal message + * @param {string} label the builder's canonical tag, retained for the stable caller API * @param {string} network the row's network, half the activation key * @param {number} eraBlock the ROW's own BTC block * @param {object|null} map the row's admission map, or null for a legacy row @@ -390,22 +389,16 @@ function admissionCanonicalField(label, network, eraBlock, map){ /** * The admission map as a canonical VALUE rather than a pipe-appended tail: null below the - * activation, the encoded map at or above it, with the same two refusals as the field - * form. This is the spelling a JSON-shaped canonical (the PRICE batch) carries under its - * own key, where a '|' tail would be a byte inside a string rather than a delimiter. One - * era gate for both spellings, so the two carriers can never disagree about which era a - * row is in. + * activation or when the map is absent, and the encoded map when the era is armed and the + * map is present. This is the spelling a JSON-shaped canonical (the PRICE batch) carries + * under its own key, where a '|' tail would be a byte inside a string rather than a + * delimiter. One era gate for both spellings keeps their boundary semantics identical. */ function admissionCanonicalValue(label, network, eraBlock, map){ let era = isAdmissionEra(network, eraBlock); let has = (map !== null && map !== undefined); - if(era && !has) - throw new Error(label + ': admission-era row at block ' + String(eraBlock) + ' on ' + String(network) + - ' has no admit_blocks; refusing to build a legacy canonical'); - if(!era && has) - throw new Error(label + ': legacy-era row at block ' + String(eraBlock) + ' on ' + String(network) + - ' was handed admit_blocks ' + JSON.stringify(map) + '; refusing to build an admission-era canonical'); if(!era) return null; + if(!has) return null; return encodeAdmitBlocks(map); } diff --git a/test/unit/oracle/price/price_admission_wire_carrier.test.js b/test/unit/oracle/price/price_admission_wire_carrier.test.js index ed8bdd4a1..11afccc0f 100644 --- a/test/unit/oracle/price/price_admission_wire_carrier.test.js +++ b/test/unit/oracle/price/price_admission_wire_carrier.test.js @@ -282,13 +282,16 @@ function registerEndToEndThroughThe3Tests4() { expect(result.accepted).to.equal(true, 'reason: ' + result.reason); }); - it('REFUSES the identical round when the RPC drops the map', async function () { + it('still does not accept the identical round when the RPC drops the map', async function () { + // The round is rebuilt over legacy bytes instead of being refused at the builder, + // so it fails on its signatures. Not accepted either way, which is the property + // the carrier exists to hold. const controller = await bootApi({ priceAggregator: agg }); const params = signedRound(MAP); delete params.admit_blocks; // the pre-row behaviour, exactly const result = await controller.pushpriceround(params); expect(result.accepted).to.equal(false); - expect(result.reason).to.match(/refusing to build a legacy canonical/); + expect(result.reason).to.not.match(/refusing to build a legacy canonical/); }); it('refuses a map edited in flight, so the carrier cannot be used to rewrite one', async function () { diff --git a/test/unit/oracle/price/price_admission_wire_carrier_batch.test.js b/test/unit/oracle/price/price_admission_wire_carrier_batch.test.js index 1dce7543c..513ad5947 100644 --- a/test/unit/oracle/price/price_admission_wire_carrier_batch.test.js +++ b/test/unit/oracle/price/price_admission_wire_carrier_batch.test.js @@ -245,18 +245,22 @@ function registerThePriceBatchRailCarries2Tests1() { expect(result.rejected).to.equal(2); }); - it('refuses an era batch whose rounds carry NO map, whole, storing nothing', async function () { + it('takes the legacy byte path for an era batch whose rounds carry NO map, storing nothing', async function () { + // A version seam is not a fault. The batch is rebuilt over legacy bytes and quorum + // verification decides it, so it is refused on its signatures rather than on an era + // complaint. Nothing lands either way, which is the property that still matters. const result = await aggOn(NETWORK).receiveValidatedBatch('BTC', batchOn(ADMIT_AT + 1)); expect(result.accepted).to.equal(false); - expect(result.reason).to.match(/admission map does not match the round's era/); + expect(result.reason).to.not.match(/admission map does not match the round's era/); expect(result.stored).to.equal(0); - expect(result.rejected).to.equal(2); }); - it('refuses a LEGACY batch that was handed a map, the other direction', async function () { + it('takes the legacy byte path for a LEGACY batch that was handed a map, the other direction', async function () { + // An activation-inert consumer treats admit_blocks as absent even when a newer + // producer supplied it, so the map is ignored and the signatures decide. const result = await aggOn(NETWORK).receiveValidatedBatch('BTC', batchOn(LEGACY_AT, [MAP5, MAP6])); expect(result.accepted).to.equal(false); - expect(result.reason).to.match(/admission map does not match the round's era/); + expect(result.reason).to.not.match(/admission map does not match the round's era/); }); it('refuses a map the encoder cannot spell rather than throwing on it', async function () { diff --git a/test/unit/oracle/price/price_admission_wire_carrier_producer.test.js b/test/unit/oracle/price/price_admission_wire_carrier_producer.test.js index 244081221..b7e345c65 100644 --- a/test/unit/oracle/price/price_admission_wire_carrier_producer.test.js +++ b/test/unit/oracle/price/price_admission_wire_carrier_producer.test.js @@ -251,9 +251,11 @@ function registerTheBatchProducerCarriesEach2Tests1() { expect(legacy.join('|')).to.not.match(/BTC:/); }); - it('refuses to emit a wire for an era round with no map, so nothing its verifiers would refuse is ever spent on', function () { - expect(() => pub.buildPriceBatchBody(5, 6, ADMIT_AT + 1, rounds(ADMIT_AT), SIGS)) - .to.throw(/has no admit_blocks; refusing to build a legacy canonical/); + it('emits a LEGACY wire for an era round with no map, leaving the verifiers to decide it', function () { + // An armed producer with no map in hand spells the legacy bytes rather than refusing, + // so a mixed-version fleet can still bind the round. + const body = pub.buildPriceBatchBody(5, 6, ADMIT_AT + 1, rounds(ADMIT_AT), SIGS); + expect(body).to.not.match(/BTC:/); }); it('splits a window at the activation, so no batch straddles it', function () { diff --git a/test/unit/oracle/price/price_v0_canonical_admission.test.js b/test/unit/oracle/price/price_v0_canonical_admission.test.js index 782937cd1..305a250ff 100644 --- a/test/unit/oracle/price/price_v0_canonical_admission.test.js +++ b/test/unit/oracle/price/price_v0_canonical_admission.test.js @@ -173,16 +173,16 @@ function builtBy(height, map, network) { expect(b.producer.endsWith('}')).to.equal(true); } - function allThreeRefuseAMapOnTest7() { - const b = three(LEGACY_AT, admitMap()); - for (const [name, build] of [['producer', b.producer], ['ingest', b.ingest], ['indexer', b.indexer]]) - expect(build, name).to.throw(/refusing to build an admission-era canonical/); + function allThreeIgnoreAMapOnTest7() { + // An inert consumer treats a supplied map as absent, so the legacy bytes stand. + const plain = builtBy(LEGACY_AT, undefined), b = builtBy(LEGACY_AT, admitMap()); + for (const k of ['producer', 'ingest', 'indexer']) assert.strictEqual(b[k], plain[k], k); } function belowTheActivationTheLegacyBytesSuite4() { it('all three emit the identical canonical, with no admission field', allThreeEmitTheIdenticalCanonicalTest5); it('a mainnet round is legacy at a height far above the armed regtest one', aMainnetRoundIsLegacyAtTest6); - it('all three REFUSE a map on a legacy round rather than signing bytes no era reads', allThreeRefuseAMapOnTest7); + it('all three IGNORE a map on a legacy round and sign the untouched legacy bytes', allThreeIgnoreAMapOnTest7); } registerbelowTheActivationTheLegacyBytes3 = function registerSuite() { @@ -245,12 +245,13 @@ function builtBy(height, map, network) { assert.strictEqual(b.indexer, b.producer); } - function allThreeRefuseARoundWithTest14() { - for (const missing of [null, undefined]) { - const b = three(ADMIT_AT, missing); - for (const [name, build] of [['producer', b.producer], ['ingest', b.ingest], ['indexer', b.indexer]]) - expect(build, name + ' with ' + String(missing)).to.throw(/refusing to build a legacy canonical/); - } + function allThreeUseLegacyBytesWhenTest14() { + // Armed with no map in hand a consumer binds the legacy bytes rather than refusing. + // The with-map build is compared alongside: equal spellings would pass vacuously. + const withMap = builtBy(ADMIT_AT, admitMap()); + for (const b of [builtBy(ADMIT_AT, null), builtBy(ADMIT_AT, undefined)]) + for (const k of ['producer', 'ingest', 'indexer']) + assert.strictEqual(b[k].endsWith('}') && b[k] !== withMap[k], true, k + ' did not use legacy bytes'); } function allThreeRefuseAMapTheTest15() { @@ -267,7 +268,7 @@ function builtBy(height, map, network) { it('appends the field after the JSON body and inside the EQUIV wrapper', appendsTheFieldAfterTheJsonTest11); it('the map insertion order never reaches the bytes, on any of the three', theMapInsertionOrderNeverReachesTest12); it('one changed height changes the signed bytes on all three, and they still agree', oneChangedHeightChangesTheSignedTest13); - it('all three REFUSE a round with no map, rather than signing legacy bytes above the era', allThreeRefuseARoundWithTest14); + it('all three sign the legacy bytes for a round with no map above the era', allThreeUseLegacyBytesWhenTest14); it('all three refuse a map the encoding cannot spell injectively', allThreeRefuseAMapTheTest15); } @@ -326,12 +327,13 @@ function builtBy(height, map, network) { expect(result.reason).to.match(/admission map unusable/); } - async function rejectsAnAdmissionEraRoundThatTest20() { + async function doesNotAcceptAnAdmissionEraRoundTest20() { + // Rebuilt over legacy bytes rather than refused at the builder: signatures decide. const r = round(); delete r.admit_blocks; const result = await agg.receiveValidatedRound('BTC', r); expect(result.accepted).to.equal(false); - expect(result.reason).to.match(/refusing to build a legacy canonical/); + expect(result.reason).to.not.match(/refusing to build a legacy canonical/); } async function refusesTheSignaturesWhenThePushedTest21() { @@ -374,7 +376,7 @@ function builtBy(height, map, network) { afterEach(function () { sinon.restore(); }); it('accepts a round whose signatures cover the admission-era canonical', acceptsARoundWhoseSignaturesCoverTest18); it('rejects, and does not throw, when the pushed map is unspellable', rejectsAndDoesNotThrowWhenTest19); - it('rejects an admission-era round that carries no map at all', rejectsAnAdmissionEraRoundThatTest20); + it('does not accept an admission-era round that carries no map at all', doesNotAcceptAnAdmissionEraRoundTest20); it('refuses the signatures when the pushed map is not the one that was signed', refusesTheSignaturesWhenThePushedTest21); } diff --git a/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js b/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js index 92e6aab0a..027c7215c 100644 --- a/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js +++ b/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js @@ -247,15 +247,17 @@ function loadIndexerTwin(ctx) { assert.strictEqual(armed.indexer.buildPriceBatchPayload(FIRST, LAST, LEGACY_AT, legacy, NETWORK), fromProducer); } - function everyTwinRefusesInBothDirectionsTest17() { + function everyTwinUsesLegacyBytesTest17() { + // Both directions of the version seam take the legacy byte path. An armed twin with + // no map emits no admission field, and an inert twin handed one ignores it. let twins = [['producer', (r, a) => armed.producer.buildPriceBatchPayload(FIRST, LAST, a, r)], ['ingest', (r, a) => armed.ingest.buildPriceBatchPayload(FIRST, LAST, a, r)]]; if (armed.indexer) twins.push(['indexer', (r, a) => armed.indexer.buildPriceBatchPayload(FIRST, LAST, a, r, NETWORK)]); for (const [name, build] of twins) { - assert.throws(() => build(rounds(ADMIT_AT), ADMIT_AT + 1), /has no admit_blocks; refusing to build a legacy canonical/, - name + ' built legacy bytes for an era round'); - assert.throws(() => build(rounds(LEGACY_AT - 1, MAPS), LEGACY_AT), /was handed admit_blocks .*; refusing to build an admission-era canonical/, - name + ' built era bytes for a legacy round'); + assert.strictEqual(/admit/.test(build(rounds(ADMIT_AT), ADMIT_AT + 1)), false, + name + ' emitted admission bytes for an era round with no map'); + assert.strictEqual(/admit/.test(build(rounds(LEGACY_AT - 1, MAPS), LEGACY_AT)), false, + name + ' emitted admission bytes for a legacy round handed a map'); } } @@ -271,7 +273,7 @@ function loadIndexerTwin(ctx) { function theBatchCanonicalCarriesOneAdmissionSuite14() { it('the hub twins agree, and the indexer verifier with them, in the admission era', theHubTwinsAgreeAndTheTest15); it('below the activation the bytes are the pre-admission form exactly, on all twins', belowTheActivationTheBytesAreTest16); - it('every twin refuses in both directions: an era round with no map, a legacy round with one', everyTwinRefusesInBothDirectionsTest17); + it('every twin uses legacy bytes in both directions: an era round with no map, a legacy round with one', everyTwinUsesLegacyBytesTest17); it('the map is keyed on EACH round\'s own anchor: a straddling window carries it on the era round only', theMapIsKeyedOnEachTest18); } diff --git a/test/unit/shared/admission/admission_height.test.js b/test/unit/shared/admission/admission_height.test.js index 9940b7a6e..cbb1167f4 100644 --- a/test/unit/shared/admission/admission_height.test.js +++ b/test/unit/shared/admission/admission_height.test.js @@ -353,11 +353,11 @@ describe('admission_height: the era gate, INERT', () => { expect(armed.ah.admissionCanonicalField('XTEST', REGTEST, 1000, null)).to.equal(''); }); - it('refuses to build an ADMISSION canonical for a legacy-era row', () => { - expect(() => armed.ah.admissionCanonicalField('XTEST', 'mainnet', 1000, { BTC: 1004 })) - .to.throw(/refusing to build an admission-era canonical/); - expect(() => armed.ah.admissionCanonicalField('XTEST', REGTEST, 1000, { BTC: 1004 })) - .to.throw(/refusing to build an admission-era canonical/); + it('builds NO field for a legacy-era row that was handed a map, ignoring it', () => { + // An activation-inert consumer treats admit_blocks as absent even when a newer producer + // supplied it, so the legacy bytes stay byte for byte what every peer rebuilds. + expect(armed.ah.admissionCanonicalField('XTEST', 'mainnet', 1000, { BTC: 1004 })).to.equal(''); + expect(armed.ah.admissionCanonicalField('XTEST', REGTEST, 1000, { BTC: 1004 })).to.equal(''); }); }); @@ -378,13 +378,11 @@ describe('admission_height: the era gate, ARMED at a regtest height', () => { expect(armed.ah.isAdmissionEra('testnet', ERA_AT)).to.equal(false); }); - it('refuses to build a LEGACY canonical for an admission-era row', () => { - // The branch under test is the refusal, and it is the one that strands a row: a - // modern row signed over pre-admission bytes reproduces for no verifier in the fleet. - expect(() => armed.ah.admissionCanonicalField('XTEST', REGTEST, ERA_AT, null)) - .to.throw(/refusing to build a legacy canonical/); - expect(() => armed.ah.admissionCanonicalField('XTEST', REGTEST, ERA_AT, undefined)) - .to.throw(/refusing to build a legacy canonical/); + it('builds NO field for an admission-era row with no map, taking the legacy path', () => { + // Only an armed consumer WITH a present map emits admission bytes. Armed with nothing + // in hand it spells the legacy bytes and lets quorum verification decide. + expect(armed.ah.admissionCanonicalField('XTEST', REGTEST, ERA_AT, null)).to.equal(''); + expect(armed.ah.admissionCanonicalField('XTEST', REGTEST, ERA_AT, undefined)).to.equal(''); }); it('appends the field after a single pipe when the era is active', () => { diff --git a/test/unit/shared/admission/admission_height_runtime.test.js b/test/unit/shared/admission/admission_height_runtime.test.js index e968e3816..2e0920bf7 100644 --- a/test/unit/shared/admission/admission_height_runtime.test.js +++ b/test/unit/shared/admission/admission_height_runtime.test.js @@ -162,9 +162,10 @@ describe('at and above the activation', () => { before(() => { armed = withAdmissionActivation(ERA_AT); }); after(() => { armed.restore(); }); - it('the match builder refuses to build LEGACY bytes for an admission-era row', () => { - expect(() => armed.DEX.call({}, matchRow(), 0)) - .to.throw(/CrossChainDex.*refusing to build a legacy canonical/); + it('the match builder builds LEGACY bytes for an admission-era row with no map', () => { + // Armed with no map in hand, the builder spells the legacy bytes. + let raw = armed.DEX.call({}, matchRow(), 0); + expect(raw).to.not.match(/BTC:/); }); it('an admission-era match canonical ends with the ASCII-ordered map', () => { @@ -211,14 +212,16 @@ describe('below the activation', () => { expect(raw.split('|').pop()).to.equal('0'); // b_filled_before, the old last field }); - it('the match builder refuses an admission map on a legacy-era row', () => { - expect(() => inert.DEX.call({}, matchRow({ network: 'mainnet', admit_blocks: { BTC: 1004, DOGE: 2004 } }), 0)) - .to.throw(/CrossChainDex.*refusing to build an admission-era canonical/); + it('the match builder ignores an admission map on a legacy-era row', () => { + // Inert means absent, whatever a newer producer put on the row. + let raw = inert.DEX.call({}, matchRow({ network: 'mainnet', admit_blocks: { BTC: 1004, DOGE: 2004 } }), 0); + expect(raw).to.not.match(/BTC:/); }); - it('the policy builder refuses an admission map on a legacy-era row', () => { - expect(() => inert.BRIDGE.call({}, policyRow({ network: 'mainnet', admit_block_btc: 1004 }), 0)) - .to.throw(/CrossChainPolicy.*refusing to build an admission-era canonical/); + it('the policy builder ignores an admission map on a legacy-era row', () => { + // The policy carrier reads the same era gate as the match carrier. + let raw = inert.BRIDGE.call({}, policyRow({ network: 'mainnet', admit_block_btc: 1004 }), 0); + expect(raw).to.not.match(/BTC:/); }); }); } From 6dfd0f1c45fe0ebd752989bf59517568a6a42fa0 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 20:57:38 -0700 Subject: [PATCH 07/57] Revert "fix(consensus): propagate the admission boundary fix to the hub twin" This reverts a3bc6a2be718969cd46f390cf85912bef84d0ae4. The file is one byte twin in three repos and its two counterparts could not land beside it: the xchain-indexer and xchain-explorer gates both refuse on SNAP-2, whose bundled lockfile snapshot of xchain-vm still records 0.19.0 while that sibling's develop now declares 0.20.0. That is the v0.20.x develop levelling still in flight, not this change, and refreshing those lockfiles belongs to the train rather than to a consensus fix. Measured: with this commit on develop and the two counterparts still at their old bytes, the hub suite goes from 1 pre-existing failure to 4, the three new ones being the cross-repo byte-twin canonical tests. Leaving it would red hub develop for every pusher, so the half is withdrawn until all three can land together. The prepared commits are preserved on land/xc2564-twin in each repo. Operator ruling R14 (a), 2026-09-19. --- bin/pins/at1-consensus-identity.json | 2 +- bin/pins/carrier-logic.json | 9 +----- src/consensus/gates/mirror_admission_gate.js | 31 +++++++++++------- .../price_admission_wire_carrier.test.js | 7 ++-- ...price_admission_wire_carrier_batch.test.js | 14 +++----- ...ce_admission_wire_carrier_producer.test.js | 8 ++--- .../price_v0_canonical_admission.test.js | 32 +++++++++---------- ...2_payload_twin_parity_single_round.test.js | 14 ++++---- .../shared/admission/admission_height.test.js | 22 +++++++------ .../admission_height_runtime.test.js | 21 ++++++------ 10 files changed, 73 insertions(+), 87 deletions(-) diff --git a/bin/pins/at1-consensus-identity.json b/bin/pins/at1-consensus-identity.json index c03cfc943..2a65ea62d 100644 --- a/bin/pins/at1-consensus-identity.json +++ b/bin/pins/at1-consensus-identity.json @@ -55,5 +55,5 @@ "mainnet" ], "hub_schema_version": 7, - "carrier_logic_digest": "377fd6d83e83872da5effecb58f24e6c39188f7d13ba63fd5ce433ea4d02b4b3" + "carrier_logic_digest": "48f3d471e54ceb2082861bdcd6ece30d68d42375feb18153cf8f9bcda4ee165d" } diff --git a/bin/pins/carrier-logic.json b/bin/pins/carrier-logic.json index d5d439348..912e932ee 100644 --- a/bin/pins/carrier-logic.json +++ b/bin/pins/carrier-logic.json @@ -42,7 +42,7 @@ }, "mirror_admission_activation": { "path": "src/consensus/gates/mirror_admission_gate.js", - "hash": "d59a0c44b7d0dc1dbc5f1530d67e012a7dac33f1824960565a9788b75cff6a25", + "hash": "ff9dacf3d0abc1037361c4265f5a324d1bc0965b078e528df55da4320f981a85", "twins": [] }, "oracle_clamp_reference_activation": { @@ -630,13 +630,6 @@ "to": "26894199bd6651dbedcc962825eb63aea91b0a5e7d1d8d3e28eaddf73ce69415", "reason": "W5 consolidation (row 21)", "date": "2026-09-16" - }, - { - "id": "mirror_admission_activation", - "from": "ff9dacf3d0abc1037361c4265f5a324d1bc0965b078e528df55da4320f981a85", - "to": "d59a0c44b7d0dc1dbc5f1530d67e012a7dac33f1824960565a9788b75cff6a25", - "reason": "canonical admission boundary fix, operator ruling (a) 2026-09-19", - "date": "2026-09-19" } ] } diff --git a/src/consensus/gates/mirror_admission_gate.js b/src/consensus/gates/mirror_admission_gate.js index ba343a97b..71492a232 100644 --- a/src/consensus/gates/mirror_admission_gate.js +++ b/src/consensus/gates/mirror_admission_gate.js @@ -364,19 +364,20 @@ function isAdmissionEra(network, eraBlock){ } /** - * The canonical tail for a row's admission map: '' unless both the row is at or above the - * activation and the map is present, and '|' plus the encoded map when both are true. + * The canonical tail for a row's admission map: '' below the activation, and '|' plus the + * encoded map at or above it. * - * A version seam can expose either an admission map to an inert node or a legacy row to an - * armed node. Both cases take the legacy byte path so mixed versions can bind the row and - * let quorum verification decide whether its signatures match. Only an armed node with a - * present map emits admission bytes. + * REFUSES IN BOTH DIRECTIONS, exactly as AttestationConsensus._buildCanonical does for + * the mirror era. Building a legacy canonical for a modern row strands the row (its + * signatures reproduce over bytes no verifier rebuilds); building a modern canonical for + * a legacy row forks a from-genesis replay. Neither can be recovered from downstream, so + * both throw where the caller that got it wrong is still on the stack. * * No per-rail canonical VERSION field is minted for this, and none exists anywhere in the * tree: this height-gated era check IS the versioning, and a version integer would * duplicate the gate while giving a Byzantine leader a second field to disagree about. * - * @param {string} label the builder's canonical tag, retained for the stable caller API + * @param {string} label the builder's canonical tag, for the refusal message * @param {string} network the row's network, half the activation key * @param {number} eraBlock the ROW's own BTC block * @param {object|null} map the row's admission map, or null for a legacy row @@ -389,16 +390,22 @@ function admissionCanonicalField(label, network, eraBlock, map){ /** * The admission map as a canonical VALUE rather than a pipe-appended tail: null below the - * activation or when the map is absent, and the encoded map when the era is armed and the - * map is present. This is the spelling a JSON-shaped canonical (the PRICE batch) carries - * under its own key, where a '|' tail would be a byte inside a string rather than a - * delimiter. One era gate for both spellings keeps their boundary semantics identical. + * activation, the encoded map at or above it, with the same two refusals as the field + * form. This is the spelling a JSON-shaped canonical (the PRICE batch) carries under its + * own key, where a '|' tail would be a byte inside a string rather than a delimiter. One + * era gate for both spellings, so the two carriers can never disagree about which era a + * row is in. */ function admissionCanonicalValue(label, network, eraBlock, map){ let era = isAdmissionEra(network, eraBlock); let has = (map !== null && map !== undefined); + if(era && !has) + throw new Error(label + ': admission-era row at block ' + String(eraBlock) + ' on ' + String(network) + + ' has no admit_blocks; refusing to build a legacy canonical'); + if(!era && has) + throw new Error(label + ': legacy-era row at block ' + String(eraBlock) + ' on ' + String(network) + + ' was handed admit_blocks ' + JSON.stringify(map) + '; refusing to build an admission-era canonical'); if(!era) return null; - if(!has) return null; return encodeAdmitBlocks(map); } diff --git a/test/unit/oracle/price/price_admission_wire_carrier.test.js b/test/unit/oracle/price/price_admission_wire_carrier.test.js index 11afccc0f..ed8bdd4a1 100644 --- a/test/unit/oracle/price/price_admission_wire_carrier.test.js +++ b/test/unit/oracle/price/price_admission_wire_carrier.test.js @@ -282,16 +282,13 @@ function registerEndToEndThroughThe3Tests4() { expect(result.accepted).to.equal(true, 'reason: ' + result.reason); }); - it('still does not accept the identical round when the RPC drops the map', async function () { - // The round is rebuilt over legacy bytes instead of being refused at the builder, - // so it fails on its signatures. Not accepted either way, which is the property - // the carrier exists to hold. + it('REFUSES the identical round when the RPC drops the map', async function () { const controller = await bootApi({ priceAggregator: agg }); const params = signedRound(MAP); delete params.admit_blocks; // the pre-row behaviour, exactly const result = await controller.pushpriceround(params); expect(result.accepted).to.equal(false); - expect(result.reason).to.not.match(/refusing to build a legacy canonical/); + expect(result.reason).to.match(/refusing to build a legacy canonical/); }); it('refuses a map edited in flight, so the carrier cannot be used to rewrite one', async function () { diff --git a/test/unit/oracle/price/price_admission_wire_carrier_batch.test.js b/test/unit/oracle/price/price_admission_wire_carrier_batch.test.js index 513ad5947..1dce7543c 100644 --- a/test/unit/oracle/price/price_admission_wire_carrier_batch.test.js +++ b/test/unit/oracle/price/price_admission_wire_carrier_batch.test.js @@ -245,22 +245,18 @@ function registerThePriceBatchRailCarries2Tests1() { expect(result.rejected).to.equal(2); }); - it('takes the legacy byte path for an era batch whose rounds carry NO map, storing nothing', async function () { - // A version seam is not a fault. The batch is rebuilt over legacy bytes and quorum - // verification decides it, so it is refused on its signatures rather than on an era - // complaint. Nothing lands either way, which is the property that still matters. + it('refuses an era batch whose rounds carry NO map, whole, storing nothing', async function () { const result = await aggOn(NETWORK).receiveValidatedBatch('BTC', batchOn(ADMIT_AT + 1)); expect(result.accepted).to.equal(false); - expect(result.reason).to.not.match(/admission map does not match the round's era/); + expect(result.reason).to.match(/admission map does not match the round's era/); expect(result.stored).to.equal(0); + expect(result.rejected).to.equal(2); }); - it('takes the legacy byte path for a LEGACY batch that was handed a map, the other direction', async function () { - // An activation-inert consumer treats admit_blocks as absent even when a newer - // producer supplied it, so the map is ignored and the signatures decide. + it('refuses a LEGACY batch that was handed a map, the other direction', async function () { const result = await aggOn(NETWORK).receiveValidatedBatch('BTC', batchOn(LEGACY_AT, [MAP5, MAP6])); expect(result.accepted).to.equal(false); - expect(result.reason).to.not.match(/admission map does not match the round's era/); + expect(result.reason).to.match(/admission map does not match the round's era/); }); it('refuses a map the encoder cannot spell rather than throwing on it', async function () { diff --git a/test/unit/oracle/price/price_admission_wire_carrier_producer.test.js b/test/unit/oracle/price/price_admission_wire_carrier_producer.test.js index b7e345c65..244081221 100644 --- a/test/unit/oracle/price/price_admission_wire_carrier_producer.test.js +++ b/test/unit/oracle/price/price_admission_wire_carrier_producer.test.js @@ -251,11 +251,9 @@ function registerTheBatchProducerCarriesEach2Tests1() { expect(legacy.join('|')).to.not.match(/BTC:/); }); - it('emits a LEGACY wire for an era round with no map, leaving the verifiers to decide it', function () { - // An armed producer with no map in hand spells the legacy bytes rather than refusing, - // so a mixed-version fleet can still bind the round. - const body = pub.buildPriceBatchBody(5, 6, ADMIT_AT + 1, rounds(ADMIT_AT), SIGS); - expect(body).to.not.match(/BTC:/); + it('refuses to emit a wire for an era round with no map, so nothing its verifiers would refuse is ever spent on', function () { + expect(() => pub.buildPriceBatchBody(5, 6, ADMIT_AT + 1, rounds(ADMIT_AT), SIGS)) + .to.throw(/has no admit_blocks; refusing to build a legacy canonical/); }); it('splits a window at the activation, so no batch straddles it', function () { diff --git a/test/unit/oracle/price/price_v0_canonical_admission.test.js b/test/unit/oracle/price/price_v0_canonical_admission.test.js index 305a250ff..782937cd1 100644 --- a/test/unit/oracle/price/price_v0_canonical_admission.test.js +++ b/test/unit/oracle/price/price_v0_canonical_admission.test.js @@ -173,16 +173,16 @@ function builtBy(height, map, network) { expect(b.producer.endsWith('}')).to.equal(true); } - function allThreeIgnoreAMapOnTest7() { - // An inert consumer treats a supplied map as absent, so the legacy bytes stand. - const plain = builtBy(LEGACY_AT, undefined), b = builtBy(LEGACY_AT, admitMap()); - for (const k of ['producer', 'ingest', 'indexer']) assert.strictEqual(b[k], plain[k], k); + function allThreeRefuseAMapOnTest7() { + const b = three(LEGACY_AT, admitMap()); + for (const [name, build] of [['producer', b.producer], ['ingest', b.ingest], ['indexer', b.indexer]]) + expect(build, name).to.throw(/refusing to build an admission-era canonical/); } function belowTheActivationTheLegacyBytesSuite4() { it('all three emit the identical canonical, with no admission field', allThreeEmitTheIdenticalCanonicalTest5); it('a mainnet round is legacy at a height far above the armed regtest one', aMainnetRoundIsLegacyAtTest6); - it('all three IGNORE a map on a legacy round and sign the untouched legacy bytes', allThreeIgnoreAMapOnTest7); + it('all three REFUSE a map on a legacy round rather than signing bytes no era reads', allThreeRefuseAMapOnTest7); } registerbelowTheActivationTheLegacyBytes3 = function registerSuite() { @@ -245,13 +245,12 @@ function builtBy(height, map, network) { assert.strictEqual(b.indexer, b.producer); } - function allThreeUseLegacyBytesWhenTest14() { - // Armed with no map in hand a consumer binds the legacy bytes rather than refusing. - // The with-map build is compared alongside: equal spellings would pass vacuously. - const withMap = builtBy(ADMIT_AT, admitMap()); - for (const b of [builtBy(ADMIT_AT, null), builtBy(ADMIT_AT, undefined)]) - for (const k of ['producer', 'ingest', 'indexer']) - assert.strictEqual(b[k].endsWith('}') && b[k] !== withMap[k], true, k + ' did not use legacy bytes'); + function allThreeRefuseARoundWithTest14() { + for (const missing of [null, undefined]) { + const b = three(ADMIT_AT, missing); + for (const [name, build] of [['producer', b.producer], ['ingest', b.ingest], ['indexer', b.indexer]]) + expect(build, name + ' with ' + String(missing)).to.throw(/refusing to build a legacy canonical/); + } } function allThreeRefuseAMapTheTest15() { @@ -268,7 +267,7 @@ function builtBy(height, map, network) { it('appends the field after the JSON body and inside the EQUIV wrapper', appendsTheFieldAfterTheJsonTest11); it('the map insertion order never reaches the bytes, on any of the three', theMapInsertionOrderNeverReachesTest12); it('one changed height changes the signed bytes on all three, and they still agree', oneChangedHeightChangesTheSignedTest13); - it('all three sign the legacy bytes for a round with no map above the era', allThreeUseLegacyBytesWhenTest14); + it('all three REFUSE a round with no map, rather than signing legacy bytes above the era', allThreeRefuseARoundWithTest14); it('all three refuse a map the encoding cannot spell injectively', allThreeRefuseAMapTheTest15); } @@ -327,13 +326,12 @@ function builtBy(height, map, network) { expect(result.reason).to.match(/admission map unusable/); } - async function doesNotAcceptAnAdmissionEraRoundTest20() { - // Rebuilt over legacy bytes rather than refused at the builder: signatures decide. + async function rejectsAnAdmissionEraRoundThatTest20() { const r = round(); delete r.admit_blocks; const result = await agg.receiveValidatedRound('BTC', r); expect(result.accepted).to.equal(false); - expect(result.reason).to.not.match(/refusing to build a legacy canonical/); + expect(result.reason).to.match(/refusing to build a legacy canonical/); } async function refusesTheSignaturesWhenThePushedTest21() { @@ -376,7 +374,7 @@ function builtBy(height, map, network) { afterEach(function () { sinon.restore(); }); it('accepts a round whose signatures cover the admission-era canonical', acceptsARoundWhoseSignaturesCoverTest18); it('rejects, and does not throw, when the pushed map is unspellable', rejectsAndDoesNotThrowWhenTest19); - it('does not accept an admission-era round that carries no map at all', doesNotAcceptAnAdmissionEraRoundTest20); + it('rejects an admission-era round that carries no map at all', rejectsAnAdmissionEraRoundThatTest20); it('refuses the signatures when the pushed map is not the one that was signed', refusesTheSignaturesWhenThePushedTest21); } diff --git a/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js b/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js index 027c7215c..92e6aab0a 100644 --- a/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js +++ b/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js @@ -247,17 +247,15 @@ function loadIndexerTwin(ctx) { assert.strictEqual(armed.indexer.buildPriceBatchPayload(FIRST, LAST, LEGACY_AT, legacy, NETWORK), fromProducer); } - function everyTwinUsesLegacyBytesTest17() { - // Both directions of the version seam take the legacy byte path. An armed twin with - // no map emits no admission field, and an inert twin handed one ignores it. + function everyTwinRefusesInBothDirectionsTest17() { let twins = [['producer', (r, a) => armed.producer.buildPriceBatchPayload(FIRST, LAST, a, r)], ['ingest', (r, a) => armed.ingest.buildPriceBatchPayload(FIRST, LAST, a, r)]]; if (armed.indexer) twins.push(['indexer', (r, a) => armed.indexer.buildPriceBatchPayload(FIRST, LAST, a, r, NETWORK)]); for (const [name, build] of twins) { - assert.strictEqual(/admit/.test(build(rounds(ADMIT_AT), ADMIT_AT + 1)), false, - name + ' emitted admission bytes for an era round with no map'); - assert.strictEqual(/admit/.test(build(rounds(LEGACY_AT - 1, MAPS), LEGACY_AT)), false, - name + ' emitted admission bytes for a legacy round handed a map'); + assert.throws(() => build(rounds(ADMIT_AT), ADMIT_AT + 1), /has no admit_blocks; refusing to build a legacy canonical/, + name + ' built legacy bytes for an era round'); + assert.throws(() => build(rounds(LEGACY_AT - 1, MAPS), LEGACY_AT), /was handed admit_blocks .*; refusing to build an admission-era canonical/, + name + ' built era bytes for a legacy round'); } } @@ -273,7 +271,7 @@ function loadIndexerTwin(ctx) { function theBatchCanonicalCarriesOneAdmissionSuite14() { it('the hub twins agree, and the indexer verifier with them, in the admission era', theHubTwinsAgreeAndTheTest15); it('below the activation the bytes are the pre-admission form exactly, on all twins', belowTheActivationTheBytesAreTest16); - it('every twin uses legacy bytes in both directions: an era round with no map, a legacy round with one', everyTwinUsesLegacyBytesTest17); + it('every twin refuses in both directions: an era round with no map, a legacy round with one', everyTwinRefusesInBothDirectionsTest17); it('the map is keyed on EACH round\'s own anchor: a straddling window carries it on the era round only', theMapIsKeyedOnEachTest18); } diff --git a/test/unit/shared/admission/admission_height.test.js b/test/unit/shared/admission/admission_height.test.js index cbb1167f4..9940b7a6e 100644 --- a/test/unit/shared/admission/admission_height.test.js +++ b/test/unit/shared/admission/admission_height.test.js @@ -353,11 +353,11 @@ describe('admission_height: the era gate, INERT', () => { expect(armed.ah.admissionCanonicalField('XTEST', REGTEST, 1000, null)).to.equal(''); }); - it('builds NO field for a legacy-era row that was handed a map, ignoring it', () => { - // An activation-inert consumer treats admit_blocks as absent even when a newer producer - // supplied it, so the legacy bytes stay byte for byte what every peer rebuilds. - expect(armed.ah.admissionCanonicalField('XTEST', 'mainnet', 1000, { BTC: 1004 })).to.equal(''); - expect(armed.ah.admissionCanonicalField('XTEST', REGTEST, 1000, { BTC: 1004 })).to.equal(''); + it('refuses to build an ADMISSION canonical for a legacy-era row', () => { + expect(() => armed.ah.admissionCanonicalField('XTEST', 'mainnet', 1000, { BTC: 1004 })) + .to.throw(/refusing to build an admission-era canonical/); + expect(() => armed.ah.admissionCanonicalField('XTEST', REGTEST, 1000, { BTC: 1004 })) + .to.throw(/refusing to build an admission-era canonical/); }); }); @@ -378,11 +378,13 @@ describe('admission_height: the era gate, ARMED at a regtest height', () => { expect(armed.ah.isAdmissionEra('testnet', ERA_AT)).to.equal(false); }); - it('builds NO field for an admission-era row with no map, taking the legacy path', () => { - // Only an armed consumer WITH a present map emits admission bytes. Armed with nothing - // in hand it spells the legacy bytes and lets quorum verification decide. - expect(armed.ah.admissionCanonicalField('XTEST', REGTEST, ERA_AT, null)).to.equal(''); - expect(armed.ah.admissionCanonicalField('XTEST', REGTEST, ERA_AT, undefined)).to.equal(''); + it('refuses to build a LEGACY canonical for an admission-era row', () => { + // The branch under test is the refusal, and it is the one that strands a row: a + // modern row signed over pre-admission bytes reproduces for no verifier in the fleet. + expect(() => armed.ah.admissionCanonicalField('XTEST', REGTEST, ERA_AT, null)) + .to.throw(/refusing to build a legacy canonical/); + expect(() => armed.ah.admissionCanonicalField('XTEST', REGTEST, ERA_AT, undefined)) + .to.throw(/refusing to build a legacy canonical/); }); it('appends the field after a single pipe when the era is active', () => { diff --git a/test/unit/shared/admission/admission_height_runtime.test.js b/test/unit/shared/admission/admission_height_runtime.test.js index 2e0920bf7..e968e3816 100644 --- a/test/unit/shared/admission/admission_height_runtime.test.js +++ b/test/unit/shared/admission/admission_height_runtime.test.js @@ -162,10 +162,9 @@ describe('at and above the activation', () => { before(() => { armed = withAdmissionActivation(ERA_AT); }); after(() => { armed.restore(); }); - it('the match builder builds LEGACY bytes for an admission-era row with no map', () => { - // Armed with no map in hand, the builder spells the legacy bytes. - let raw = armed.DEX.call({}, matchRow(), 0); - expect(raw).to.not.match(/BTC:/); + it('the match builder refuses to build LEGACY bytes for an admission-era row', () => { + expect(() => armed.DEX.call({}, matchRow(), 0)) + .to.throw(/CrossChainDex.*refusing to build a legacy canonical/); }); it('an admission-era match canonical ends with the ASCII-ordered map', () => { @@ -212,16 +211,14 @@ describe('below the activation', () => { expect(raw.split('|').pop()).to.equal('0'); // b_filled_before, the old last field }); - it('the match builder ignores an admission map on a legacy-era row', () => { - // Inert means absent, whatever a newer producer put on the row. - let raw = inert.DEX.call({}, matchRow({ network: 'mainnet', admit_blocks: { BTC: 1004, DOGE: 2004 } }), 0); - expect(raw).to.not.match(/BTC:/); + it('the match builder refuses an admission map on a legacy-era row', () => { + expect(() => inert.DEX.call({}, matchRow({ network: 'mainnet', admit_blocks: { BTC: 1004, DOGE: 2004 } }), 0)) + .to.throw(/CrossChainDex.*refusing to build an admission-era canonical/); }); - it('the policy builder ignores an admission map on a legacy-era row', () => { - // The policy carrier reads the same era gate as the match carrier. - let raw = inert.BRIDGE.call({}, policyRow({ network: 'mainnet', admit_block_btc: 1004 }), 0); - expect(raw).to.not.match(/BTC:/); + it('the policy builder refuses an admission map on a legacy-era row', () => { + expect(() => inert.BRIDGE.call({}, policyRow({ network: 'mainnet', admit_block_btc: 1004 }), 0)) + .to.throw(/CrossChainPolicy.*refusing to build an admission-era canonical/); }); }); } From 66e63dea8e96ce94106b561d72e02100a4a6af37 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 19 Sep 2026 10:28:20 -0700 Subject: [PATCH 08/57] fix(protocol): ship LTC:testnet mirror admission as null under dq4 (a) The armed LTC:testnet producer/consumer heights (4891504/4891766) are already behind the live TLTC tip. Ruling dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train and arms on a later train. Sets both heights to null in this repo's shared_rows_2.js twin, with the same replacement comment used across all six carriers, and re-pins the consensus rules digest and the AT1 consensus identity file to match, since those two values move with the arming they hash. The paired hardcoded digest in xchain-indexer's own suite moves in the same edit there. --- bin/pins/at1-consensus-identity.json | 6 +++--- src/consensus/gate_registry/shared_rows_2.js | 4 ++-- .../consensus/consensus_rules_digest_active_gates.test.js | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bin/pins/at1-consensus-identity.json b/bin/pins/at1-consensus-identity.json index 2a65ea62d..3a5064a25 100644 --- a/bin/pins/at1-consensus-identity.json +++ b/bin/pins/at1-consensus-identity.json @@ -1,5 +1,5 @@ { - "consensus_rules_digest": "ee6476f2445fe454b6b262e6f9dbea1b922bd31ba7eec55a6c7b30dbb513cf5e", + "consensus_rules_digest": "01169736d617db9ddab9654879fbaac802c1c7351d40e23fdff9eb7700ef2fea", "consensus_rules_gates": { "anchor_reward_activation.ANCHOR_REWARD_ACTIVATION": "{\"mainnet\":961000,\"regtest\":0,\"testnet\":0}", "anchor_reward_activation.ARCHIVE_REWARD_ACTIVATION": "{\"mainnet\":963000,\"regtest\":0,\"testnet\":0}", @@ -21,8 +21,8 @@ "attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING_V2": "{\"headroom\":1,\"maxSlots\":2,\"startOffset\":0}", "rollcall_gates_activation.ROLLCALL_GATES_ACTIVATION": "{\"mainnet\":null,\"regtest\":null,\"testnet\":152208}", "xchain_bridge_activation.XCHAIN_BRIDGE_ACTIVATION": "{\"BTC:mainnet\":9999999999,\"BTC:testnet\":152929,\"DOGE:mainnet\":9999999999,\"DOGE:testnet\":67902062,\"LTC:mainnet\":9999999999,\"LTC:testnet\":4887898,\"mainnet\":9999999999,\"regtest\":0,\"testnet\":9999999999}", - "mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":153222,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67911796,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":4891504}", - "mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":153266,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67912575,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":4891766}", + "mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":153222,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67911796,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":null}", + "mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":153266,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67912575,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":null}", "mirror_admission_activation.ADMIT_MARGIN_BLOCKS": "{\"anchor_reward_attestations\":144,\"attestation_responses\":1,\"default\":4,\"oracle_prices\":1}", "mirror_admission_activation.ADMIT_MIN_FUTURE_BLOCKS": "1", "mirror_admission_activation.ADMIT_MAX_FUTURE_BLOCKS": "{\"BTC\":6,\"DOGE\":60,\"LTC\":24,\"default\":6}", diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index c7fd765da..b935b053f 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -217,7 +217,7 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 153222, // SIZED 2026-09-16 20:41Z: epoch close 153,216 + 6 buried; tip 152,756 + 466 at 498.7 s/blk, about 64.5 h - 'LTC:testnet': 4891504, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 4,889,190 + 2314 at 82.5 s/blk + 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train 'DOGE:testnet': 67911796, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 67,904,912 + 6884 at 27.7 s/blk 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -229,7 +229,7 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 153266, // its producer + 44 blocks, about 6 h: strictly above, never equal - 'LTC:testnet': 4891766, // its producer + 262 blocks, about 6 h at 82.5 s/blk + 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train 'DOGE:testnet': 67912575, // its producer + 779 blocks, about 6 h at 27.7 s/blk 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration diff --git a/test/unit/consensus/consensus_rules_digest_active_gates.test.js b/test/unit/consensus/consensus_rules_digest_active_gates.test.js index 604ca394c..580f0a5e2 100644 --- a/test/unit/consensus/consensus_rules_digest_active_gates.test.js +++ b/test/unit/consensus/consensus_rules_digest_active_gates.test.js @@ -80,7 +80,7 @@ function registerPinnedDigestTest() { // the environment: a venue process launched armed has a different, equally correct digest, // and a pin that moved with a drill lever would be a test of the launcher. The pinned value // is the fleet's: every shipped process reads the unarmed maps. - it('digests to the pinned value, which moved when the v0.19.0 cut armed the bridge on testnet (and again at the 16:33Z ladder re-cut)', function () { + it('digests to the pinned value, which moved when the v0.19.0 cut armed the bridge on testnet (and again at the 16:33Z ladder re-cut, and again when LTC:testnet mirror admission shipped null under dq4 (a))', function () { // Every gate module that still has a logic file, not just the admission one: the // family's arming lever is shared, so the anchor-attest gate resolves from the same // variable and a cached copy of it would keep a drill's heights in the digest after @@ -96,7 +96,7 @@ function registerPinnedDigestTest() { for (const [p] of saved) delete require.cache[p]; const fresh = require('../../../src/consensus_rules_digest.js'); expect(fresh.computeConsensusRulesDigest().digest) - .to.equal('ee6476f2445fe454b6b262e6f9dbea1b922bd31ba7eec55a6c7b30dbb513cf5e', + .to.equal('01169736d617db9ddab9654879fbaac802c1c7351d40e23fdff9eb7700ef2fea', 'the consensus rules digest moved; a gate was added, removed, reordered or re-armed'); } finally { for (const [p, mod] of saved) { if (mod === undefined) delete require.cache[p]; else require.cache[p] = mod; } From 2d0ff72a9784434a0ab25abfda1e9e7e78fad953 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 19 Sep 2026 11:53:05 -0700 Subject: [PATCH 09/57] fix(consensus): re-slide v0.20.1 BTC and DOGE activation heights Propagate the canonical v0.20.1 patch-train reslide into the hub's own copy of the shared consensus registry: BTC train height 153221, BTC mirror producer/consumer 153300/153328 (producer also carries ANCHOR_ATTEST_BARRIER_ACTIVATION), DOGE mirror producer/consumer 67916857/67917706. LTC:testnet stays null under dq4 (a) and is untouched. Re-pin bin/pins/at1-consensus-identity.json (consensus_rules_digest and consensus_rules_gates) to match, and update the two test fixtures that hardcoded the superseded BTC heights (153222/153266) so the regression suite tracks the reslide instead of failing on stale numbers. --- bin/pins/at1-consensus-identity.json | 8 ++++---- src/consensus/gate_registry/shared_rows_1.js | 10 +++++----- src/consensus/gate_registry/shared_rows_2.js | 8 ++++---- src/consensus/gate_registry/shared_rows_5.js | 12 +++++++++--- .../anchor_reward_activation_parity.test.js | 6 +++--- .../consensus_rules_digest_active_gates.test.js | 2 +- test/unit/consensus/gate_registry.test.js | 2 +- 7 files changed, 27 insertions(+), 21 deletions(-) diff --git a/bin/pins/at1-consensus-identity.json b/bin/pins/at1-consensus-identity.json index 3a5064a25..481dc911f 100644 --- a/bin/pins/at1-consensus-identity.json +++ b/bin/pins/at1-consensus-identity.json @@ -1,5 +1,5 @@ { - "consensus_rules_digest": "01169736d617db9ddab9654879fbaac802c1c7351d40e23fdff9eb7700ef2fea", + "consensus_rules_digest": "b57eb9a867a8d3c914dc100b445fccb217c54efc8a7dd671a0ad176c83cebd4c", "consensus_rules_gates": { "anchor_reward_activation.ANCHOR_REWARD_ACTIVATION": "{\"mainnet\":961000,\"regtest\":0,\"testnet\":0}", "anchor_reward_activation.ARCHIVE_REWARD_ACTIVATION": "{\"mainnet\":963000,\"regtest\":0,\"testnet\":0}", @@ -21,12 +21,12 @@ "attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING_V2": "{\"headroom\":1,\"maxSlots\":2,\"startOffset\":0}", "rollcall_gates_activation.ROLLCALL_GATES_ACTIVATION": "{\"mainnet\":null,\"regtest\":null,\"testnet\":152208}", "xchain_bridge_activation.XCHAIN_BRIDGE_ACTIVATION": "{\"BTC:mainnet\":9999999999,\"BTC:testnet\":152929,\"DOGE:mainnet\":9999999999,\"DOGE:testnet\":67902062,\"LTC:mainnet\":9999999999,\"LTC:testnet\":4887898,\"mainnet\":9999999999,\"regtest\":0,\"testnet\":9999999999}", - "mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":153222,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67911796,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":null}", - "mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":153266,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67912575,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":null}", + "mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":153300,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67916857,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":null}", + "mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":153328,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67917706,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":null}", "mirror_admission_activation.ADMIT_MARGIN_BLOCKS": "{\"anchor_reward_attestations\":144,\"attestation_responses\":1,\"default\":4,\"oracle_prices\":1}", "mirror_admission_activation.ADMIT_MIN_FUTURE_BLOCKS": "1", "mirror_admission_activation.ADMIT_MAX_FUTURE_BLOCKS": "{\"BTC\":6,\"DOGE\":60,\"LTC\":24,\"default\":6}", - "anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION": "{\"mainnet\":null,\"regtest\":null,\"testnet\":153266}", + "anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION": "{\"mainnet\":null,\"regtest\":null,\"testnet\":153328}", "anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S": "64800", "mirror_admission_activation.CHAIN_CODE_RE": "{}", "mirror_admission_activation.CANONICAL_HEIGHT_RE": "{}" diff --git a/src/consensus/gate_registry/shared_rows_1.js b/src/consensus/gate_registry/shared_rows_1.js index bf311be06..742a811c0 100644 --- a/src/consensus/gate_registry/shared_rows_1.js +++ b/src/consensus/gate_registry/shared_rows_1.js @@ -295,11 +295,11 @@ addGate('anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S', 'constant', 6 // that window. addGate('anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION', 'height', { mainnet: null, // INERT under the 2026-08-29 mainnet write hold - // SIZED 2026-09-16 20:41Z, on the BTC clock because this member is BTC-only: the same - // instant as the family's BTC CONSUMER height, so the one member that keeps BOTH - // certificates gains them together rather than carrying a lone extra rule for 6 h. - // Above the same roll and the same epoch close; the canon carries the measurement. - testnet: 153266, + // SIZED 2026-09-16 20:41Z, RE-SLID 2026-09-19, on the BTC clock because this member is + // BTC-only: the same instant as the family's BTC CONSUMER height, so the one member that + // keeps BOTH certificates gains them together rather than carrying a lone extra rule for + // 6 h. The canon carries the measurement. + testnet: 153328, regtest: UNPINNED, // shares the family's arming seam so one venue lever arms both }); diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index b935b053f..c245769cf 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -216,9 +216,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 153222, // SIZED 2026-09-16 20:41Z: epoch close 153,216 + 6 buried; tip 152,756 + 466 at 498.7 s/blk, about 64.5 h + 'BTC:testnet': 153300, // RE-SLID 2026-09-19: train 153,221 + 79 blocks (17 h at 781.078553 s/blk), the v0.20.1 patch reslide 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67911796, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 67,904,912 + 6884 at 27.7 s/blk + 'DOGE:testnet': 67916857, // RE-SLID 2026-09-19: tip 67,911,061 + 5796 blocks (41 h at 25.469118 s/blk, the same instant as the BTC producer), the v0.20.1 patch reslide 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -228,9 +228,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 153266, // its producer + 44 blocks, about 6 h: strictly above, never equal + 'BTC:testnet': 153328, // RE-SLID 2026-09-19: its producer + 28 blocks (6 h at 781.078553 s/blk), strictly above, never equal 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67912575, // its producer + 779 blocks, about 6 h at 27.7 s/blk + 'DOGE:testnet': 67917706, // RE-SLID 2026-09-19: its producer + 849 blocks (6 h at 25.469118 s/blk) 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration diff --git a/src/consensus/gate_registry/shared_rows_5.js b/src/consensus/gate_registry/shared_rows_5.js index cbf289761..291fc4fe7 100644 --- a/src/consensus/gate_registry/shared_rows_5.js +++ b/src/consensus/gate_registry/shared_rows_5.js @@ -108,9 +108,15 @@ addGate('train_activation.TRAIN_ACTIVATION', 'ruleset', { // the LTC leg of this family two days off its BTC counterpart a day after the first cut. // That lead is the rolling-upgrade window the fleet roll must finish inside (24x the 90 // minute roll budget), and every testnet mirror-admission height sits above it on the same - // BTC clock (the BTC producer at 153,222 is 106 blocks and about 17.0 h further up), so a - // node lacking this rule set halts before it can grade an admission-stamped row. - '0.20.0': { mainnet: 9999999999, testnet: 153116, regtest: 0 }, + // BTC clock, so a node lacking this rule set halts before it can grade an admission-stamped + // row. + // RE-SLID 2026-09-19 for the v0.20.1 patch train: margin is 24 h measured from the freeze, + // converted at each coin's own measured cadence, not five days. Chain_tip TBTC 153,110 + 111 + // blocks, ceil(24 h / 781.078553 s per block, least-squares bound over the trailing 114-block + // window, about 25.2 h span, at least as long as the lead). The mirror-admission family + // below re-slides onto the same instant plus its own 17 h and 6 h offsets. LTC stays null + // under dq4 (a) and is untouched by this reslide. + '0.20.0': { mainnet: 9999999999, testnet: 153221, regtest: 0 }, }); // xchain_bridge_activation diff --git a/test/unit/activations/anchor_reward_activation_parity.test.js b/test/unit/activations/anchor_reward_activation_parity.test.js index d91fd8e7d..f75cc4fae 100644 --- a/test/unit/activations/anchor_reward_activation_parity.test.js +++ b/test/unit/activations/anchor_reward_activation_parity.test.js @@ -174,10 +174,10 @@ it('holds an INERT network inert at height 0 and at a huge height', function () function registerBarrierCutTests() { it('arms the testnet barrier horizon exactly at its sized height, never a block early', function () { expect(local.ANCHOR_ATTEST_BARRIER_ACTIVATION.testnet, - 'not vacuous: the sized testnet height this case is about must still be in the map').to.equal(153266); - expect(local.isAnchorAttestBarrierHorizonActive('testnet', 153265), + 'not vacuous: the sized testnet height this case is about must still be in the map').to.equal(153328); + expect(local.isAnchorAttestBarrierHorizonActive('testnet', 153327), 'the horizon armed a block BELOW its sized height').to.equal(false); - expect(local.isAnchorAttestBarrierHorizonActive('testnet', 153266), + expect(local.isAnchorAttestBarrierHorizonActive('testnet', 153328), 'the horizon is not armed AT its own sized height; the comparison must be >=, not >').to.equal(true); // The same `0 >= threshold` direction the inert case guards, but on a LIVE height: a // guard keyed on null alone would arm genesis here and no inert case would catch it. diff --git a/test/unit/consensus/consensus_rules_digest_active_gates.test.js b/test/unit/consensus/consensus_rules_digest_active_gates.test.js index 580f0a5e2..80fa9bb64 100644 --- a/test/unit/consensus/consensus_rules_digest_active_gates.test.js +++ b/test/unit/consensus/consensus_rules_digest_active_gates.test.js @@ -96,7 +96,7 @@ function registerPinnedDigestTest() { for (const [p] of saved) delete require.cache[p]; const fresh = require('../../../src/consensus_rules_digest.js'); expect(fresh.computeConsensusRulesDigest().digest) - .to.equal('01169736d617db9ddab9654879fbaac802c1c7351d40e23fdff9eb7700ef2fea', + .to.equal('b57eb9a867a8d3c914dc100b445fccb217c54efc8a7dd671a0ad176c83cebd4c', 'the consensus rules digest moved; a gate was added, removed, reordered or re-armed'); } finally { for (const [p, mod] of saved) { if (mod === undefined) delete require.cache[p]; else require.cache[p] = mod; } diff --git a/test/unit/consensus/gate_registry.test.js b/test/unit/consensus/gate_registry.test.js index f0810a810..1db5e5633 100644 --- a/test/unit/consensus/gate_registry.test.js +++ b/test/unit/consensus/gate_registry.test.js @@ -302,7 +302,7 @@ describe('src/consensus/gate_registry.js: regtest arming from the environment', bootedWith('XC_MIRROR_ADMISSION_ACTIVATION', '150', (fresh) => { const producer = fresh.get(ADMISSION); for (const k of ['BTC:regtest', 'LTC:regtest', 'DOGE:regtest']) expect(producer[k], k).to.equal(150); - expect(producer['BTC:testnet']).to.equal(153222); + expect(producer['BTC:testnet']).to.equal(153300); expect(fresh.copy(BARRIER).regtest).to.equal(150); expect(fresh.get(ROLLCALL).regtest, 'the roll-call lever is a different variable').to.equal(null); }); From 1ffdb103e44c5d712512ca7d2251756953e7f20e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 08:38:15 -0700 Subject: [PATCH 10/57] feat(oracle): configure derivation tick budget --- src/oracle/price_aggregator/derived_capabilities.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/oracle/price_aggregator/derived_capabilities.js b/src/oracle/price_aggregator/derived_capabilities.js index 97173dcf6..2d2893148 100644 --- a/src/oracle/price_aggregator/derived_capabilities.js +++ b/src/oracle/price_aggregator/derived_capabilities.js @@ -20,6 +20,8 @@ * ********************************************************************/ +const { positiveIntConfig } = require('../../lib/config_int.js'); + // ── Derived capability snapshots ──────────────────────────────────────────── // Every capability the consensus path persists into capability_snapshots, and so // every capability a chain-only hub must derive for itself. Row 45 built this pass @@ -82,7 +84,11 @@ const PRICE_CAP_DERIVE_INTERVAL_S = 60; // widen this budget. The pass walks newest-first and covers ALL capabilities at a height // before it steps back one, so the tip (what a node following the chain needs next) is // fully covered first and the backfill trails behind it over the following ticks. -const PRICE_CAP_DERIVE_MAX_PER_TICK = 64; +// HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK accepts a positive integer RPC-unit budget; +// an unset or invalid value retains the 64-unit default. +const PRICE_CAP_DERIVE_MAX_PER_TICK = positiveIntConfig( + process.env.HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK, 64, + 'HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK'); module.exports = { DERIVED_CAPABILITIES, CAPABILITY_CONSENSUS_ENGINES, From a5d696529c56320f3d0a4f2c72c54c575fb7b955 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 09:37:06 -0700 Subject: [PATCH 11/57] fix(attestation): bound marker hydration SQL --- src/attestation/batch_publisher/markers.js | 23 +++++++++++++--------- src/db/attestation.js | 11 +++++++++++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/attestation/batch_publisher/markers.js b/src/attestation/batch_publisher/markers.js index ffcf32ff0..4e2b4b8fe 100644 --- a/src/attestation/batch_publisher/markers.js +++ b/src/attestation/batch_publisher/markers.js @@ -66,38 +66,43 @@ module.exports = { async hydrateMarkers(nowSec){ let db = this.hubDb(); if(!db || typeof db.doQuery !== 'function') return; - let rows = await db.findAttestPublishedBatchesByNetworkAndStatus(this.network, 'intent'); let current = this.windowStartFor(Number.isFinite(nowSec) ? Number(nowSec) : this.nowSeconds()); let floor = this.catchupFloorWindow(current); + let rows = await db.findAttestPublishedBatchesByNetworkAndStatusSince( + this.network, 'intent', floor); + let agedRows = await db.getAttestPublishedBatchesCountByNetworkAndStatusBefore( + this.network, 'intent', floor); + let agedSummary = (agedRows && agedRows[0]) || {}; - let live = [], aged = []; + let live = []; for(let r of (rows || [])){ let start = Number(r.window_start); // A row whose window_start does not read as a number cannot be matched against // any window the sweep proposes, so it is neither quarantined nor counted. if(!Number.isFinite(start)) continue; - (start >= floor ? live : aged).push(start); + if(start >= floor) live.push(start); } live.sort((a, b) => a - b); for(let start of live) this._quarantined.add(start); - this.reportHydratedMarkers(live, aged, current); + this.reportHydratedMarkers(live, agedSummary, current); }, // Two kinds of marker at two severities. A window inside the horizon is an action item // and names its age, because a count with no age leaves a four-day-old marker reading // like a fresh one; a window below it is a record and must not read as an alarm. - reportHydratedMarkers(live, aged, currentWindow){ + reportHydratedMarkers(live, agedSummary, currentWindow){ if(live.length > 0) logger.error('AttestationBatchPublisher: ' + live.length + ' window(s) carry a ' + 'publish-intent marker with no outcome; they are NOT re-published automatically. ' + 'Operator: verify each on chain and replay by hand if absent. Windows: ' + live.map(s => s + ' (' + this.markerAge(s, currentWindow) + ')').join(', ') + '.'); - if(aged.length > 0) - logger.info('AttestationBatchPublisher: ' + aged.length + ' publish-intent marker(s) lie below ' + + let agedCount = Number(agedSummary.count) || 0; + if(agedCount > 0) + logger.info('AttestationBatchPublisher: ' + agedCount + ' publish-intent marker(s) lie below ' + 'the ' + MAX_CATCHUP_WINDOWS + '-window catch-up horizon and are NOT an action item: no ' + 'sweep can propose those windows again. Oldest ' + - this.markerAge(Math.min.apply(null, aged), currentWindow) + ', newest ' + - this.markerAge(Math.max.apply(null, aged), currentWindow) + '.'); + this.markerAge(agedSummary.oldest, currentWindow) + ', newest ' + + this.markerAge(agedSummary.newest, currentWindow) + '.'); }, // A marker's age in the two units that decide what to do with it: windows closed diff --git a/src/db/attestation.js b/src/db/attestation.js index d840553a0..6fc296b38 100644 --- a/src/db/attestation.js +++ b/src/db/attestation.js @@ -116,6 +116,17 @@ module.exports = { return this.doQuery('SELECT window_start FROM attest_published_batches WHERE network = ? AND status = ?', [network, status]); }, + // Reads rows from attest_published_batches at or above a window floor. + async findAttestPublishedBatchesByNetworkAndStatusSince(network, status, sinceWindowStart) { + return this.doQuery('SELECT window_start FROM attest_published_batches WHERE network = ? AND status = ? AND window_start >= ?', [network, status, sinceWindowStart]); + }, + + // Summarizes attest_published_batches below a window floor without returning + // the historical rows. MIN and MAX preserve the age range in the operator log. + async getAttestPublishedBatchesCountByNetworkAndStatusBefore(network, status, beforeWindowStart) { + return this.doQuery('SELECT COUNT(*) AS count, MIN(window_start) AS oldest, MAX(window_start) AS newest FROM attest_published_batches WHERE network = ? AND status = ? AND window_start < ?', [network, status, beforeWindowStart]); + }, + // Reads rows from attest_published_requests. // Moved here from src/attestation/publisher.js:616. async findAttestPublishedRequestsByRequestId(rid) { From 77a682d907e6d5062ea289eeee24fee5e105a110 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 10:27:36 -0700 Subject: [PATCH 12/57] fix(attestation): preserve bounded marker hydration --- src/attestation/batch_publisher/markers.js | 5 +++++ src/db/attestation.js | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/attestation/batch_publisher/markers.js b/src/attestation/batch_publisher/markers.js index 4e2b4b8fe..e002167e2 100644 --- a/src/attestation/batch_publisher/markers.js +++ b/src/attestation/batch_publisher/markers.js @@ -73,6 +73,11 @@ module.exports = { let agedRows = await db.getAttestPublishedBatchesCountByNetworkAndStatusBefore( this.network, 'intent', floor); let agedSummary = (agedRows && agedRows[0]) || {}; + if(!Number.isFinite(Number(agedSummary.count))){ + let oldest = Number(agedSummary.oldest); + agedSummary.count = agedSummary.oldest !== null && + agedSummary.oldest !== undefined && Number.isFinite(oldest) && oldest < floor ? 1 : 0; + } let live = []; for(let r of (rows || [])){ diff --git a/src/db/attestation.js b/src/db/attestation.js index 6fc296b38..096a7905e 100644 --- a/src/db/attestation.js +++ b/src/db/attestation.js @@ -124,7 +124,7 @@ module.exports = { // Summarizes attest_published_batches below a window floor without returning // the historical rows. MIN and MAX preserve the age range in the operator log. async getAttestPublishedBatchesCountByNetworkAndStatusBefore(network, status, beforeWindowStart) { - return this.doQuery('SELECT COUNT(*) AS count, MIN(window_start) AS oldest, MAX(window_start) AS newest FROM attest_published_batches WHERE network = ? AND status = ? AND window_start < ?', [network, status, beforeWindowStart]); + return this.doQuery('SELECT MIN(window_start) AS oldest, MAX(window_start) AS newest, COUNT(*) AS count FROM attest_published_batches WHERE network = ? AND status = ? AND window_start < ?', [network, status, beforeWindowStart]); }, // Reads rows from attest_published_requests. From c88bd37d1d22ea80b9f7156e6fe58593daed9374 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 10:32:51 -0700 Subject: [PATCH 13/57] fix(oracle): route derive budget through config --- src/oracle/price_aggregator/derived_capabilities.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/oracle/price_aggregator/derived_capabilities.js b/src/oracle/price_aggregator/derived_capabilities.js index 2d2893148..5444502f1 100644 --- a/src/oracle/price_aggregator/derived_capabilities.js +++ b/src/oracle/price_aggregator/derived_capabilities.js @@ -20,6 +20,7 @@ * ********************************************************************/ +const hubConfig = require('../../config.js'); const { positiveIntConfig } = require('../../lib/config_int.js'); // ── Derived capability snapshots ──────────────────────────────────────────── @@ -87,7 +88,7 @@ const PRICE_CAP_DERIVE_INTERVAL_S = 60; // HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK accepts a positive integer RPC-unit budget; // an unset or invalid value retains the 64-unit default. const PRICE_CAP_DERIVE_MAX_PER_TICK = positiveIntConfig( - process.env.HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK, 64, + hubConfig.env().HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK, 64, 'HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK'); module.exports = { From 3fe086c40793529d417fbca66d9633cdb2c5d0aa Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 15:35:26 -0700 Subject: [PATCH 14/57] test(oracle): rename PRICE v0 batch parity suite --- bin/pins/at1-suite-titles.json | 24 +++++----- .../aggregator/price_aggregator_batch.test.js | 7 +-- .../price_aggregator_batch_activation.test.js | 7 +-- .../price_aggregator_batch_boundaries.test.js | 7 +-- .../price_aggregator_batch_publisher.test.js | 7 +-- .../price_aggregator_batch_retraction.test.js | 7 +-- .../price_aggregator_batch_snapshots.test.js | 7 +-- .../price_aggregator_batch_validation.test.js | 7 +-- ...> price_v0_batch_canonical_parity.test.js} | 47 ++++++++++--------- 9 files changed, 64 insertions(+), 56 deletions(-) rename test/unit/oracle/price/{price_v2_payload_twin_parity.test.js => price_v0_batch_canonical_parity.test.js} (84%) diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index a77aaa7ca..eacc25681 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -1363,15 +1363,6 @@ "e5a52ef95e935f53002f3f6a19037a22b54ca1b84cb9992030d82bdf00cf0325", "6c5adea75dbab254c4617ef8432e0f54b654bea928e43045848c57f1270cc4bf" ], - "247daec60db604ec": [ - "790d33b706a4d861b11e2babc8fc013869a72cc0e2257fdb5e2853e50f224ff5", - "ea5bb9ecbea9d4ee6103ebc21439ec93cc5931488bf8391ebe09261f876d4c36", - "96c6aaf15072d51c011cb1eb3fb40130f4f000fa83f22e8cf4e655e49919e006", - "5fa294212ce85473365e4c7083a7aa4c2cf756fec55a1c21d29208fd0f5a514f", - "94bdf42183dea70f4ed78a4177f0435ac64c6c0a339f962829e4716e93854385", - "8a953a352a75b6d0fed2c5aa4c08d93aa0d5299d2d4376b1dd5b2ff80bb4840b", - "f1cf5f9d20412c1e1bde8fa62b397e7dd5963f779a8f26fdbb5d7e66109b9db4" - ], "24a2552ee5e2627c": [ "581e0eb1c6087dd597caa1b626ee3d2bf4f31759c7cef2abc2b7babedb0acec5", "a34547f2ee4f1fb534ae4928d97c8e7cf426898ab6208f4a35a213d6951896ac", @@ -8804,6 +8795,15 @@ "7af9fce537c9dfea9527bbe2fe3b89d9df8045444e7ea77b3fe233185da29754", "f88a9439387d34c4125d9a15306290a0fcefad9404606798986ce2cca98f62b5" ], + "f7bbb4514212d6f3": [ + "d49bfd35be6446b731f4e829348fbf91507e2c0de88534b534dec19a1551e4de", + "e37529ab23c20969256b4ce679cd69ee8e2e310e6e99c20add14fd6cd4af6c1d", + "9dfe8307d9051964bfd7040786fa1f64d6d3b89f6d331a469e5846d9d9f58d25", + "2ef917540e197747b07cc70e835949934359b23af1880a834166a2253643898b", + "b57c69ad8ddaa61ec0cd5eae6968fcbf9347c996bb6e0abfff38dbb165d96c3d", + "3f3e628ad8fe6308403564408b2f72ad22552cf296384aef39110dff2458bb3a", + "6a8d80e71f9b5b27d793b946d8004ec5549023915a9c4e55e08880d2550b4c1d" + ], "f848101de0f26a45": [ "aa72839c5ce957458accfdd1ed69a78aae4bdc722a874c6da9640fa6c01699f2", "b50da41333f64911824a0cf1e23253031fa64ff4fade89d3defb67032f9e7708", @@ -9527,7 +9527,7 @@ "test/unit/oracle/price/price_capability_derivation_rows.test.js": "35eb18802fe53bd9", "test/unit/oracle/price/price_capability_derivation_topology.test.js": "a525401f113821d1", "test/unit/oracle/price/price_v0_canonical_admission.test.js": "c6ad10cbe91a98b9", - "test/unit/oracle/price/price_v2_payload_twin_parity.test.js": "247daec60db604ec", + "test/unit/oracle/price/price_v0_batch_canonical_parity.test.js": "f7bbb4514212d6f3", "test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js": "09a26ce652e78b52", "test/unit/oracle/price/push_price_batch.test.js": "a1d7bb616c893e9e", "test/unit/oracle/publisher/batch/oracle_publisher_batch_01_the_separate_durable_buffer_file.test.js": "91662f70f43b9692", @@ -10201,7 +10201,7 @@ "test/unit/oracle/price/price_capability_derivation_rows.test.js": "35eb18802fe53bd9", "test/unit/oracle/price/price_capability_derivation_topology.test.js": "a525401f113821d1", "test/unit/oracle/price/price_v0_canonical_admission.test.js": "c6ad10cbe91a98b9", - "test/unit/oracle/price/price_v2_payload_twin_parity.test.js": "247daec60db604ec", + "test/unit/oracle/price/price_v0_batch_canonical_parity.test.js": "f7bbb4514212d6f3", "test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js": "09a26ce652e78b52", "test/unit/oracle/price/push_price_batch.test.js": "a1d7bb616c893e9e", "test/unit/oracle/publisher/batch/oracle_publisher_batch_01_the_separate_durable_buffer_file.test.js": "91662f70f43b9692", @@ -10903,7 +10903,7 @@ "test/unit/oracle/price/price_capability_derivation_rows.test.js": "35eb18802fe53bd9", "test/unit/oracle/price/price_capability_derivation_topology.test.js": "a525401f113821d1", "test/unit/oracle/price/price_v0_canonical_admission.test.js": "c6ad10cbe91a98b9", - "test/unit/oracle/price/price_v2_payload_twin_parity.test.js": "247daec60db604ec", + "test/unit/oracle/price/price_v0_batch_canonical_parity.test.js": "f7bbb4514212d6f3", "test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js": "09a26ce652e78b52", "test/unit/oracle/price/push_price_batch.test.js": "a1d7bb616c893e9e", "test/unit/oracle/publisher/batch/oracle_publisher_batch_01_the_separate_durable_buffer_file.test.js": "91662f70f43b9692", diff --git a/test/unit/oracle/price/aggregator/price_aggregator_batch.test.js b/test/unit/oracle/price/aggregator/price_aggregator_batch.test.js index e9a6adda5..fb9de29f0 100644 --- a/test/unit/oracle/price/aggregator/price_aggregator_batch.test.js +++ b/test/unit/oracle/price/aggregator/price_aggregator_batch.test.js @@ -13,9 +13,10 @@ // PRICE v0 (batch) ingest: PriceAggregator.receiveValidatedBatch, spec // spec section 5.7, decisions D8, D13, D14, D23, D28. // -// The canonical payload itself is pinned elsewhere (priceV2PayloadTwinParity.test.js -// asserts buildPriceBatchPayload is byte-identical to the indexer and OracleConsensus -// twins), so these tests sign whatever that builder emits and pin what INGEST does +// The canonical payload itself is pinned elsewhere (price_v0_batch_canonical_parity.test.js +// asserts all three buildPriceBatchPayload implementations are byte-identical, including +// the OracleConsensus and PriceAggregator production twins), so these tests sign whatever +// that builder emits and pin what INGEST does // with a batch: per-round dedupe, column semantics, the block_time-keyed pair flag // day, the WS mirror re-emit, the reorg fence and the publisher marker clear. diff --git a/test/unit/oracle/price/aggregator/price_aggregator_batch_activation.test.js b/test/unit/oracle/price/aggregator/price_aggregator_batch_activation.test.js index 86385fdc7..824427b76 100644 --- a/test/unit/oracle/price/aggregator/price_aggregator_batch_activation.test.js +++ b/test/unit/oracle/price/aggregator/price_aggregator_batch_activation.test.js @@ -13,9 +13,10 @@ // PRICE v0 (batch) ingest: PriceAggregator.receiveValidatedBatch, spec // spec section 5.7, decisions D8, D13, D14, D23, D28. // -// The canonical payload itself is pinned elsewhere (priceV2PayloadTwinParity.test.js -// asserts buildPriceBatchPayload is byte-identical to the indexer and OracleConsensus -// twins), so these tests sign whatever that builder emits and pin what INGEST does +// The canonical payload itself is pinned elsewhere (price_v0_batch_canonical_parity.test.js +// asserts all three buildPriceBatchPayload implementations are byte-identical, including +// the OracleConsensus and PriceAggregator production twins), so these tests sign whatever +// that builder emits and pin what INGEST does // with a batch: per-round dedupe, column semantics, the block_time-keyed pair flag // day, the WS mirror re-emit, the reorg fence and the publisher marker clear. diff --git a/test/unit/oracle/price/aggregator/price_aggregator_batch_boundaries.test.js b/test/unit/oracle/price/aggregator/price_aggregator_batch_boundaries.test.js index 1a92a8c43..efeedcdb7 100644 --- a/test/unit/oracle/price/aggregator/price_aggregator_batch_boundaries.test.js +++ b/test/unit/oracle/price/aggregator/price_aggregator_batch_boundaries.test.js @@ -13,9 +13,10 @@ // PRICE v0 (batch) ingest: PriceAggregator.receiveValidatedBatch, spec // spec section 5.7, decisions D8, D13, D14, D23, D28. // -// The canonical payload itself is pinned elsewhere (priceV2PayloadTwinParity.test.js -// asserts buildPriceBatchPayload is byte-identical to the indexer and OracleConsensus -// twins), so these tests sign whatever that builder emits and pin what INGEST does +// The canonical payload itself is pinned elsewhere (price_v0_batch_canonical_parity.test.js +// asserts all three buildPriceBatchPayload implementations are byte-identical, including +// the OracleConsensus and PriceAggregator production twins), so these tests sign whatever +// that builder emits and pin what INGEST does // with a batch: per-round dedupe, column semantics, the block_time-keyed pair flag // day, the WS mirror re-emit, the reorg fence and the publisher marker clear. diff --git a/test/unit/oracle/price/aggregator/price_aggregator_batch_publisher.test.js b/test/unit/oracle/price/aggregator/price_aggregator_batch_publisher.test.js index 479fb2294..379843a50 100644 --- a/test/unit/oracle/price/aggregator/price_aggregator_batch_publisher.test.js +++ b/test/unit/oracle/price/aggregator/price_aggregator_batch_publisher.test.js @@ -13,9 +13,10 @@ // PRICE v0 (batch) ingest: PriceAggregator.receiveValidatedBatch, spec // spec section 5.7, decisions D8, D13, D14, D23, D28. // -// The canonical payload itself is pinned elsewhere (priceV2PayloadTwinParity.test.js -// asserts buildPriceBatchPayload is byte-identical to the indexer and OracleConsensus -// twins), so these tests sign whatever that builder emits and pin what INGEST does +// The canonical payload itself is pinned elsewhere (price_v0_batch_canonical_parity.test.js +// asserts all three buildPriceBatchPayload implementations are byte-identical, including +// the OracleConsensus and PriceAggregator production twins), so these tests sign whatever +// that builder emits and pin what INGEST does // with a batch: per-round dedupe, column semantics, the block_time-keyed pair flag // day, the WS mirror re-emit, the reorg fence and the publisher marker clear. diff --git a/test/unit/oracle/price/aggregator/price_aggregator_batch_retraction.test.js b/test/unit/oracle/price/aggregator/price_aggregator_batch_retraction.test.js index e1530436d..16b71462e 100644 --- a/test/unit/oracle/price/aggregator/price_aggregator_batch_retraction.test.js +++ b/test/unit/oracle/price/aggregator/price_aggregator_batch_retraction.test.js @@ -13,9 +13,10 @@ // PRICE v0 (batch) ingest: PriceAggregator.receiveValidatedBatch, spec // spec section 5.7, decisions D8, D13, D14, D23, D28. // -// The canonical payload itself is pinned elsewhere (priceV2PayloadTwinParity.test.js -// asserts buildPriceBatchPayload is byte-identical to the indexer and OracleConsensus -// twins), so these tests sign whatever that builder emits and pin what INGEST does +// The canonical payload itself is pinned elsewhere (price_v0_batch_canonical_parity.test.js +// asserts all three buildPriceBatchPayload implementations are byte-identical, including +// the OracleConsensus and PriceAggregator production twins), so these tests sign whatever +// that builder emits and pin what INGEST does // with a batch: per-round dedupe, column semantics, the block_time-keyed pair flag // day, the WS mirror re-emit, the reorg fence and the publisher marker clear. diff --git a/test/unit/oracle/price/aggregator/price_aggregator_batch_snapshots.test.js b/test/unit/oracle/price/aggregator/price_aggregator_batch_snapshots.test.js index 3a781cd0e..cc8052aa6 100644 --- a/test/unit/oracle/price/aggregator/price_aggregator_batch_snapshots.test.js +++ b/test/unit/oracle/price/aggregator/price_aggregator_batch_snapshots.test.js @@ -13,9 +13,10 @@ // PRICE v0 (batch) ingest: PriceAggregator.receiveValidatedBatch, spec // spec section 5.7, decisions D8, D13, D14, D23, D28. // -// The canonical payload itself is pinned elsewhere (priceV2PayloadTwinParity.test.js -// asserts buildPriceBatchPayload is byte-identical to the indexer and OracleConsensus -// twins), so these tests sign whatever that builder emits and pin what INGEST does +// The canonical payload itself is pinned elsewhere (price_v0_batch_canonical_parity.test.js +// asserts all three buildPriceBatchPayload implementations are byte-identical, including +// the OracleConsensus and PriceAggregator production twins), so these tests sign whatever +// that builder emits and pin what INGEST does // with a batch: per-round dedupe, column semantics, the block_time-keyed pair flag // day, the WS mirror re-emit, the reorg fence and the publisher marker clear. diff --git a/test/unit/oracle/price/aggregator/price_aggregator_batch_validation.test.js b/test/unit/oracle/price/aggregator/price_aggregator_batch_validation.test.js index f067e5141..13b97b5c0 100644 --- a/test/unit/oracle/price/aggregator/price_aggregator_batch_validation.test.js +++ b/test/unit/oracle/price/aggregator/price_aggregator_batch_validation.test.js @@ -13,9 +13,10 @@ // PRICE v0 (batch) ingest: PriceAggregator.receiveValidatedBatch, spec // spec section 5.7, decisions D8, D13, D14, D23, D28. // -// The canonical payload itself is pinned elsewhere (priceV2PayloadTwinParity.test.js -// asserts buildPriceBatchPayload is byte-identical to the indexer and OracleConsensus -// twins), so these tests sign whatever that builder emits and pin what INGEST does +// The canonical payload itself is pinned elsewhere (price_v0_batch_canonical_parity.test.js +// asserts all three buildPriceBatchPayload implementations are byte-identical, including +// the OracleConsensus and PriceAggregator production twins), so these tests sign whatever +// that builder emits and pin what INGEST does // with a batch: per-round dedupe, column semantics, the block_time-keyed pair flag // day, the WS mirror re-emit, the reorg fence and the publisher marker clear. diff --git a/test/unit/oracle/price/price_v2_payload_twin_parity.test.js b/test/unit/oracle/price/price_v0_batch_canonical_parity.test.js similarity index 84% rename from test/unit/oracle/price/price_v2_payload_twin_parity.test.js rename to test/unit/oracle/price/price_v0_batch_canonical_parity.test.js index 30f8a37c1..0c4540e14 100644 --- a/test/unit/oracle/price/price_v2_payload_twin_parity.test.js +++ b/test/unit/oracle/price/price_v0_batch_canonical_parity.test.js @@ -11,15 +11,16 @@ * contact legal@dankest.llc. * ********************************************************************** - * priceV2PayloadTwinParity: the PRICE v0 canonical is written three times, and the - * hub owns two of them. OracleConsensus.buildPriceBatchPayload SIGNS; anything + * priceV0BatchCanonicalParity: the PRICE v0 canonical is written by the two hub + * production twins and an indexer verifier. OracleConsensus.buildPriceBatchPayload + * SIGNS; anything * PriceAggregator.buildPriceBatchPayload or xchain-indexer ed25519.buildPriceBatchPayload * builds differently is a batch the federation cannot verify, so the price rail stops * and the native-fee / XCHAIN-USD path stops with it. This suite asserts byte equality * on a batch built to exercise every normalization the builders own (round order, pair * order, integer spelling, the coinPair/pair spelling split). * - * The indexer twin is resolved by monorepo-relative path: a standalone hub checkout + * The indexer verifier is resolved by monorepo-relative path: a standalone hub checkout * skips that comparison (unless XCHAIN_REQUIRE_SIBLINGS=1) and still compares the hub's * own two copies against each other. * @@ -82,7 +83,7 @@ function hubTwins() { }; } -function loadIndexerTwin(ctx) { +function loadIndexerVerifier(ctx) { try { return require('../../../../../xchain-indexer/src/consensus/ed25519.js'); } catch (e) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') @@ -145,12 +146,12 @@ function loadIndexerTwin(ctx) { assert.strictEqual(eq.isEquivHeaderActive(1, 'mainnet'), false, 'the gate v0 would have failed here'); } - let registeragainstTheIndexerVerifierTwin6; + let registerAgainstTheIndexerVerifier6; { - function allThreeTwinsEmitTheIdenticalTest8() { - let ed25519 = loadIndexerTwin(this); + function allThreeBuildersEmitTheIdenticalTest8() { + let ed25519 = loadIndexerVerifier(this); if (!ed25519) return; let fromIndexer = ed25519.buildPriceBatchPayload(FIRST, LAST, ANCHOR, batch()); @@ -160,8 +161,8 @@ function loadIndexerTwin(ctx) { 'PriceAggregator (hub ingest verifier) diverged from the indexer verifier'); } - function allThreeNormalizeCallerOrderingToTest9() { - let ed25519 = loadIndexerTwin(this); + function allThreeBuildersNormalizeCallerOrderingToTest9() { + let ed25519 = loadIndexerVerifier(this); if (!ed25519) return; let expected = ed25519.buildPriceBatchPayload(FIRST, LAST, ANCHOR, batch()); @@ -174,39 +175,39 @@ function loadIndexerTwin(ctx) { } } - function allThreeSpellCoinpairAndPairTest10() { - let ed25519 = loadIndexerTwin(this); + function allThreeBuildersSpellCoinpairAndPairTest10() { + let ed25519 = loadIndexerVerifier(this); if (!ed25519) return; let rounds = [{ round: 7, timestamp: 100, btcBlockHeight: 5, pairs: [{ coinPair: 'BTC/USD', price: '1' }] }]; - let twin = [{ round: 7, timestamp: 100, btcBlockHeight: 5, pairs: [{ pair: 'BTC/USD', price: 1 }] }]; + let alternate = [{ round: 7, timestamp: 100, btcBlockHeight: 5, pairs: [{ pair: 'BTC/USD', price: 1 }] }]; let expected = ed25519.buildPriceBatchPayload(7, 7, 5, rounds); - assert.strictEqual(ed25519.buildPriceBatchPayload(7, 7, 5, twin), expected, 'indexer verifier'); - assert.strictEqual(hub.producer.buildPriceBatchPayload(7, 7, 5, twin), expected, 'hub producer'); + assert.strictEqual(ed25519.buildPriceBatchPayload(7, 7, 5, alternate), expected, 'indexer verifier'); + assert.strictEqual(hub.producer.buildPriceBatchPayload(7, 7, 5, alternate), expected, 'hub producer'); assert.strictEqual(hub.ingest.buildPriceBatchPayload(7, 7, 5, rounds), expected, 'hub ingest'); } - function againstTheIndexerVerifierTwinSuite7() { - it('all three twins emit the identical canonical for one batch', allThreeTwinsEmitTheIdenticalTest8); - it('all three normalize caller ordering to the same bytes', allThreeNormalizeCallerOrderingToTest9); - it('all three spell coinPair and pair to the same bytes', allThreeSpellCoinpairAndPairTest10); + function againstTheIndexerVerifierSuite7() { + it('all three builders emit the identical canonical for one batch', allThreeBuildersEmitTheIdenticalTest8); + it('all three builders normalize caller ordering to the same bytes', allThreeBuildersNormalizeCallerOrderingToTest9); + it('all three builders spell coinPair and pair to the same bytes', allThreeBuildersSpellCoinpairAndPairTest10); } - registeragainstTheIndexerVerifierTwin6 = function registerSuite() { - describe('against the indexer verifier twin', againstTheIndexerVerifierTwinSuite7); + registerAgainstTheIndexerVerifier6 = function registerSuite() { + describe('against the indexer verifier', againstTheIndexerVerifierSuite7); }; } - function priceV0CanonicalThreeWayTwinSuite1() { + function priceV0BatchCanonicalParitySuite1() { before(function () { hub = hubTwins(); }); it('the producer emits the pinned key order, ascending rounds and sorted pairs', theProducerEmitsThePinnedKeyTest2); it('the hub twins agree with each other, byte for byte', theHubTwinsAgreeWithEachTest3); it('the hub twins are caller-order independent', theHubTwinsAreCallerOrderTest4); it('the hub twins wrap in the EQUIV header unconditionally, with no activation gate', theHubTwinsWrapInTheTest5); - registeragainstTheIndexerVerifierTwin6(); + registerAgainstTheIndexerVerifier6(); } - describe('PRICE v0 canonical: three-way twin parity', priceV0CanonicalThreeWayTwinSuite1); + describe('PRICE v0 batch canonical parity', priceV0BatchCanonicalParitySuite1); } From d62710b378dc4825d6d4a4090d208d6685dd85f2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 15:35:46 -0700 Subject: [PATCH 15/57] Add proposal-scoped admission tip memoization --- src/cross_chain/pbft/votes.js | 43 ++++---- src/hub/chain_tips.js | 25 ++++- test/helpers/armAdmission.js | 27 +++++ .../price_v0_canonical_admission.test.js | 60 +++++------ ...2_payload_twin_parity_single_round.test.js | 47 +++------ .../follower_admission_bound.test.js | 42 ++------ ...follower_admission_bound_consensus.test.js | 99 +++++++------------ 7 files changed, 154 insertions(+), 189 deletions(-) create mode 100644 test/helpers/armAdmission.js diff --git a/src/cross_chain/pbft/votes.js b/src/cross_chain/pbft/votes.js index da387721f..e3d2f8d81 100644 --- a/src/cross_chain/pbft/votes.js +++ b/src/cross_chain/pbft/votes.js @@ -33,29 +33,28 @@ module.exports = { if(!proposal) return; let { rid, pending, view, row, canonical } = proposal; - // INDEPENDENT confirmation: re-derive + validate against our own view of - // the underlying data. This (not byte-equality with our locally pre-built - // row) is the gate against a Byzantine leader. - let ok = false; - try { ok = await this.engine.validateProposedMatch(row); } - catch(e){ ok = false; } - if(!ok){ - logger.warn('CrossChainDexConsensus: PROPOSE ' + rid.substring(0,16) + '... failed local validation; not signing'); - return; - } + const validateAndBound = async () => { + // INDEPENDENT confirmation: re-derive + validate against our own view of + // the underlying data. This (not byte-equality with our locally pre-built + // row) is the gate against a Byzantine leader. + let ok = false; + try { ok = await this.engine.validateProposedMatch(row); } + catch(e){ ok = false; } + if(!ok){ + logger.warn('CrossChainDexConsensus: PROPOSE ' + rid.substring(0,16) + '... failed local validation; not signing'); + return false; + } - // The per-chain follower bound on the row's ADMISSION MAP (C38, BF6), applied here - // because this is the one PROPOSE handler every engine on this consensus shares. - // An engine opts in by answering admissionScope(row) with the row's table and read - // set; one that does not is on the legacy effective_time rule and is unchanged. - // - // Deliberately a SECOND application for CrossChainCallEngine, which also holds the - // bound inside its own validateProposedMatch. The engine gate binds every caller of - // validateProposedMatch (its own tests, the e2e legs, any future caller) and this - // one binds every engine on the shared path, so neither can be removed by work on - // the other. The cost is one getlatestblock per reading chain on a path that - // already makes at least two indexer round trips per proposal. - if(!(await this.admissionBoundHolds(row, rid))) return; + // The per-chain follower bound on the row's ADMISSION MAP (C38, BF6), applied + // here because this is the one PROPOSE handler every engine shares. XCALL also + // applies the bound inside validateProposedMatch, so the proposal-scoped memo + // makes both gates reuse one tip promise per reading chain. + return this.admissionBoundHolds(row, rid); + }; + const checked = this.hub && typeof this.hub.withAdmissionTipMemo === 'function' + ? await this.hub.withAdmissionTipMemo(validateAndBound) + : await validateAndBound(); + if(!checked) return; // Every await of the round sits in this function, so adopting the leader's row and // writing the leader's and our own signature are one synchronous step. diff --git a/src/hub/chain_tips.js b/src/hub/chain_tips.js index 08a014501..bf1024c43 100644 --- a/src/hub/chain_tips.js +++ b/src/hub/chain_tips.js @@ -26,9 +26,14 @@ const { blockIntervalS } = require('../lib/relay_margin.js'); const { DEFAULT_ORACLE_ROUND_INTERVAL_MS } = require('../constants.js'); const hubConfig = require('../config'); const nodeUtil = require('node:util'); +const { AsyncLocalStorage } = require('node:async_hooks'); const { getLogger } = require('../observability'); const logger = getLogger(); +// One store per asynchronous proposal flow. The WeakMap keeps separate hub instances +// isolated even when tests or embedded callers deliberately use more than one in a scope. +const admissionTipMemo = new AsyncLocalStorage(); + class ChainTips { // Resolve the latest BTC block index: first hub.db.getChainTip, populated by the @@ -159,6 +164,13 @@ class ChainTips { // fast chains. Overridable per deployment, never per call. static get ADMISSION_TIP_STALL_BLOCKS(){ return 6; } + // Share admission-tip reads only within one proposal. Nested users join the current + // scope, while the next top-level proposal starts with an empty memo. + withAdmissionTipMemo(fn){ + if(admissionTipMemo.getStore()) return fn(); + return admissionTipMemo.run(new WeakMap(), fn); + } + async resolveAdmissionTip(coin){ let c = admissionHeight.normalizeChain(coin); if(c === null){ @@ -253,7 +265,18 @@ class ChainTips { if(c === null){ out[String(raw)] = null; continue; } if(want.indexOf(c) === -1) want.push(c); } - let tips = await Promise.all(want.map((c) => this.resolveAdmissionTip(c).catch(() => null))); + let scope = admissionTipMemo.getStore(); + let memo = null; + if(scope){ + memo = scope.get(this); + if(!memo){ memo = new Map(); scope.set(this, memo); } + } + let tips = await Promise.all(want.map((c) => { + if(!memo) return this.resolveAdmissionTip(c).catch(() => null); + if(!memo.has(c)) + memo.set(c, Promise.resolve().then(() => this.resolveAdmissionTip(c)).catch(() => null)); + return memo.get(c); + })); want.forEach((c, i) => { out[c] = tips[i]; }); return out; } diff --git a/test/helpers/armAdmission.js b/test/helpers/armAdmission.js new file mode 100644 index 000000000..146dd2d90 --- /dev/null +++ b/test/helpers/armAdmission.js @@ -0,0 +1,27 @@ +'use strict'; + +// Load a set of modules under an admission activation without leaking either the +// require-cache entries or the environment override into the rest of the test run. +// moduleIds are require.resolve() results from the caller so relative paths keep the +// caller's resolution context. Loaded exports remain usable after their cache entries +// are restored because they retain the dependencies they closed over at load time. +function armAdmission(height, moduleIds, load) { + const paths = [...new Set(moduleIds)]; + const saved = paths.map(path => [path, require.cache[path]]); + const savedEnv = process.env.XC_MIRROR_ADMISSION_ACTIVATION; + + try { + for (const path of paths) delete require.cache[path]; + process.env.XC_MIRROR_ADMISSION_ACTIVATION = String(height); + return load(); + } finally { + for (const [path, mod] of saved) { + if (mod === undefined) delete require.cache[path]; + else require.cache[path] = mod; + } + if (savedEnv === undefined) delete process.env.XC_MIRROR_ADMISSION_ACTIVATION; + else process.env.XC_MIRROR_ADMISSION_ACTIVATION = savedEnv; + } +} + +module.exports = armAdmission; diff --git a/test/unit/oracle/price/price_v0_canonical_admission.test.js b/test/unit/oracle/price/price_v0_canonical_admission.test.js index 782937cd1..1ee02f76a 100644 --- a/test/unit/oracle/price/price_v0_canonical_admission.test.js +++ b/test/unit/oracle/price/price_v0_canonical_admission.test.js @@ -36,6 +36,7 @@ const crypto = require('crypto'); const sinon = require('sinon'); const { expect } = require('chai'); const { createMockHub } = require('../../../helpers/mockHub'); +const armAdmission = require('../../../helpers/armAdmission'); const eq = require('../../../../src/consensus/equivocation_header.js'); // The regtest producer activation this suite arms, keyed on the ROUND's own BTC anchor. One // armed process therefore drives both eras: a round below this height is a pre-activation round and @@ -81,43 +82,26 @@ function armTwins() { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('PRICE v0 admission parity cannot run: xchain-indexer sibling missing (' + e.message + ')'); } - const paths = hubPaths.concat(indexerPaths || []); - const saved = paths.map(p => [p, require.cache[p]]); - const savedEnv = process.env.XC_MIRROR_ADMISSION_ACTIVATION; - for (const p of paths) delete require.cache[p]; - process.env.XC_MIRROR_ADMISSION_ACTIVATION = String(ADMIT_AT); - - const OracleConsensus = require('../../../../src/oracle/consensus.js'); - const PriceAggregator = require('../../../../src/oracle/price_aggregator.js'); - const act = require('../../../../src/consensus/gates/mirror_admission_gate.js'); - const indexer = indexerPaths ? require('../../../../../xchain-indexer/src/consensus/ed25519.js') : null; - - // Put the process back exactly as it was found. The instances built below keep the - // armed modules they closed over, so the rest of the run still sees the inert tree it - // was written against: arming is scoped to this file and not to the process. - function restore() { - for (const [p, mod] of saved) { - if (mod === undefined) delete require.cache[p]; else require.cache[p] = mod; - } - if (savedEnv === undefined) delete process.env.XC_MIRROR_ADMISSION_ACTIVATION; - else process.env.XC_MIRROR_ADMISSION_ACTIVATION = savedEnv; - } - - function hubOn(network) { return { db: null, network: network, getPeerManager: () => ({}) }; } - - return { - act: act, - indexer: indexer, - producer: new OracleConsensus(hubOn(NETWORK), {}), - ingest: new PriceAggregator(hubOn(NETWORK)), - // Second pair on an INERT network, to drive the era key: a mainnet round is a pre-activation - // round at every height, including heights far above the armed regtest threshold. - inertProducer: new OracleConsensus(hubOn('mainnet'), {}), - inertIngest: new PriceAggregator(hubOn('mainnet')), - OracleConsensus: OracleConsensus, - PriceAggregator: PriceAggregator, - restore: restore - }; + return armAdmission(ADMIT_AT, hubPaths.concat(indexerPaths || []), () => { + const OracleConsensus = require('../../../../src/oracle/consensus.js'); + const PriceAggregator = require('../../../../src/oracle/price_aggregator.js'); + const act = require('../../../../src/consensus/gates/mirror_admission_gate.js'); + const indexer = indexerPaths ? require('../../../../../xchain-indexer/src/consensus/ed25519.js') : null; + function hubOn(network) { return { db: null, network: network, getPeerManager: () => ({}) }; } + + return { + act: act, + indexer: indexer, + producer: new OracleConsensus(hubOn(NETWORK), {}), + ingest: new PriceAggregator(hubOn(NETWORK)), + // Second pair on an INERT network, to drive the era key: a mainnet round is a pre-activation + // round at every height, including heights far above the armed regtest threshold. + inertProducer: new OracleConsensus(hubOn('mainnet'), {}), + inertIngest: new PriceAggregator(hubOn('mainnet')), + OracleConsensus: OracleConsensus, + PriceAggregator: PriceAggregator + }; + }); } // The three builders on one round. `network` picks which pair of hub instances answers, @@ -386,7 +370,7 @@ function builtBy(height, map, network) { function priceV0CanonicalTheAdmissionFieldSuite1() { before(function () { armed = armTwins(); }); - after(function () { if (armed) armed.restore(); armed = null; }); + after(function () { armed = null; }); it('is ARMED for this suite, so neither era case is vacuous', isArmedForThisSuiteSoTest2); registerbelowTheActivationTheLegacyBytes3(); registeratAndAboveTheActivationThe8(); diff --git a/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js b/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js index 92e6aab0a..8c3b20b28 100644 --- a/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js +++ b/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js @@ -33,6 +33,7 @@ const assert = require('assert'); const OracleConsensus = require('../../../../src/oracle/consensus.js'); const PriceAggregator = require('../../../../src/oracle/price_aggregator.js'); +const armAdmission = require('../../../helpers/armAdmission'); const eq = require('../../../../src/consensus/equivocation_header.js'); const ANCHOR = 912345; // equals the last round's own anchor, per the wire format @@ -145,37 +146,19 @@ function loadIndexerTwin(ctx) { throw new Error('PRICE v0 single-round parity cannot run: xchain-indexer sibling missing (' + e.message + ')'); } - const paths = hubPaths.concat(indexerPaths || []); - const saved = paths.map(p => [p, require.cache[p]]); - const savedEnv = process.env.XC_MIRROR_ADMISSION_ACTIVATION; - for (const p of paths) delete require.cache[p]; - process.env.XC_MIRROR_ADMISSION_ACTIVATION = String(ADMIT_AT); - - const ArmedOracleConsensus = require('../../../../src/oracle/consensus.js'); - const ArmedPriceAggregator = require('../../../../src/oracle/price_aggregator.js'); - const act = require('../../../../src/consensus/gates/mirror_admission_gate.js'); - const indexer = indexerPaths ? require('../../../../../xchain-indexer/src/consensus/ed25519.js') : null; - - // Put the process back exactly as it was found. The instances built below keep the - // armed modules they closed over, so the batch describe above and every other file in - // the run still see the inert tree they were written against: the arming is scoped to - // this describe and never to the process. - function restore() { - for (const [p, mod] of saved) { - if (mod === undefined) delete require.cache[p]; else require.cache[p] = mod; - } - if (savedEnv === undefined) delete process.env.XC_MIRROR_ADMISSION_ACTIVATION; - else process.env.XC_MIRROR_ADMISSION_ACTIVATION = savedEnv; - } - - const stubHub = { db: null, network: NETWORK, getPeerManager: () => ({}) }; - return { - act: act, - indexer: indexer, - producer: new ArmedOracleConsensus(stubHub, {}), - ingest: new ArmedPriceAggregator(stubHub), - restore: restore - }; + return armAdmission(ADMIT_AT, hubPaths.concat(indexerPaths || []), () => { + const ArmedOracleConsensus = require('../../../../src/oracle/consensus.js'); + const ArmedPriceAggregator = require('../../../../src/oracle/price_aggregator.js'); + const act = require('../../../../src/consensus/gates/mirror_admission_gate.js'); + const indexer = indexerPaths ? require('../../../../../xchain-indexer/src/consensus/ed25519.js') : null; + const stubHub = { db: null, network: NETWORK, getPeerManager: () => ({}) }; + return { + act: act, + indexer: indexer, + producer: new ArmedOracleConsensus(stubHub, {}), + ingest: new ArmedPriceAggregator(stubHub) + }; + }); } let armed = null; @@ -283,7 +266,7 @@ function loadIndexerTwin(ctx) { function priceV0SingleRoundCanonicalThreeSuite11() { before(function () { armed = armTwins(); }); - after(function () { if (armed) armed.restore(); armed = null; }); + after(function () { armed = null; }); it('is ARMED, so neither era block below is the other one in disguise', isArmedSoNeitherEraBlockTest12); for (const era of [ { name: 'below the activation, where the round is legacy', height: LEGACY_AT, map: () => undefined, tail: null }, diff --git a/test/unit/shared/admission/follower_admission_bound.test.js b/test/unit/shared/admission/follower_admission_bound.test.js index 2d64c6a77..3956ddfcd 100644 --- a/test/unit/shared/admission/follower_admission_bound.test.js +++ b/test/unit/shared/admission/follower_admission_bound.test.js @@ -13,12 +13,9 @@ ********************************************************************** * followerAdmissionBound: the CALL SITES of the per-chain follower bound (BF6, C38). * - * admissionHeight.test.js drives checkAdmitBlocks as a function. This file drives the two - * places a live hub actually reaches it, because a bound with no call site refuses nothing: - * - * 1. CrossChainCallEngine.validateProposedMatch, the XCALL engine's own follower gate; - * 2. CrossChainDexConsensus.handlePropose, the ONE proposal handler every engine on that - * consensus shares, for any engine that declares an admissionScope. + * admissionHeight.test.js drives checkAdmitBlocks as a function. This file drives + * CrossChainCallEngine.validateProposedMatch, the XCALL engine's own follower gate. + * The shared consensus call site is covered in follower_admission_bound_consensus.test.js. * * Both must refuse in the same directions, and both must resolve the FOLLOWER'S OWN tips * rather than trusting the map the leader sent. The refusals driven here are the ones BF6 @@ -39,6 +36,7 @@ const { expect } = require('chai'); const sinon = require('sinon'); const crypto = require('crypto'); const ValidatorIdentity = require('../../../../src/validators/identity.js'); +const armAdmission = require('../../../helpers/armAdmission'); const sha256 = (s) => crypto.createHash('sha256').update(String(s), 'utf8').digest('hex'); @@ -60,34 +58,11 @@ const ARMED_MODULES = [ '../../../../src/cross_chain/call_engine.js' ]; -// Purge, arm, re-require, and hand back a restore() that puts the process back byte-exact. -// The objects built from the armed modules keep them by closure, so the rest of the run -// still sees the inert tree it was written against. -function armAdmission() { - const paths = ARMED_MODULES.map(m => require.resolve(m)); - const saved = paths.map(p => [p, require.cache[p]]); - const savedEnv = process.env.XC_MIRROR_ADMISSION_ACTIVATION; - for (const p of paths) delete require.cache[p]; - process.env.XC_MIRROR_ADMISSION_ACTIVATION = String(ADMIT_AT); - - const ah = require('../../../../src/lib/admission_height.js'); - const CrossChainCallEngine = require('../../../../src/cross_chain/call_engine.js'); - - function restore() { - for (const [p, mod] of saved) { - if (mod === undefined) delete require.cache[p]; else require.cache[p] = mod; - } - if (savedEnv === undefined) delete process.env.XC_MIRROR_ADMISSION_ACTIVATION; - else process.env.XC_MIRROR_ADMISSION_ACTIVATION = savedEnv; - } - return { ah, CrossChainCallEngine, restore }; -} - // ───────────────────────────────────────────────────────────────────────────── // 1. CrossChainCallEngine.validateProposedMatch // ───────────────────────────────────────────────────────────────────────────── -let armedAh, CallEngine, restore; +let armedAh, CallEngine; let tipCalls; // tips: chain code -> the height this hub's own indexer reports, or null for a chain @@ -269,12 +244,13 @@ function registerLegacyCallAdmissionTest() { describe('follower admission bound: CrossChainCallEngine.validateProposedMatch', function () { before(function () { - const armed = armAdmission(); + const armed = armAdmission(ADMIT_AT, ARMED_MODULES.map(m => require.resolve(m)), () => ({ + ah: require('../../../../src/lib/admission_height.js'), + CrossChainCallEngine: require('../../../../src/cross_chain/call_engine.js') + })); armedAh = armed.ah; CallEngine = armed.CrossChainCallEngine; - restore = armed.restore; }); - after(function () { restore(); }); afterEach(function () { sinon.restore(); }); registerCallAdmissionAcceptTests(); diff --git a/test/unit/shared/admission/follower_admission_bound_consensus.test.js b/test/unit/shared/admission/follower_admission_bound_consensus.test.js index c8d97efa4..16b73f91c 100644 --- a/test/unit/shared/admission/follower_admission_bound_consensus.test.js +++ b/test/unit/shared/admission/follower_admission_bound_consensus.test.js @@ -11,82 +11,34 @@ * contact legal@dankest.llc. * ********************************************************************** - * followerAdmissionBound: the CALL SITES of the per-chain follower bound (BF6, C38). + * followerAdmissionBound: the shared consensus call site of the per-chain follower + * bound (BF6, C38). * - * admissionHeight.test.js drives checkAdmitBlocks as a function. This file drives the two - * places a live hub actually reaches it, because a bound with no call site refuses nothing: - * - * 1. CrossChainCallEngine.validateProposedMatch, the XCALL engine's own follower gate; - * 2. CrossChainDexConsensus.handlePropose, the ONE proposal handler every engine on that - * consensus shares, for any engine that declares an admissionScope. + * admissionHeight.test.js drives checkAdmitBlocks as a function. This file drives + * CrossChainDexConsensus.handlePropose, the one proposal handler every engine on that + * consensus shares, for any engine that declares an admissionScope. * * Both must refuse in the same directions, and both must resolve the FOLLOWER'S OWN tips * rather than trusting the map the leader sent. The refusals driven here are the ones BF6 * names: a map that omits a reading chain, a height past the chain's own forward window, a * height at or behind our tip, and no usable own tip at all (a dead indexer, a frozen * decoder, or a hub with no resolver) which is a REFUSAL and never a pass. - * - * THE SUITE ARMS ITSELF, in the shape priceV0CanonicalAdmission.test.js established: the - * activation resolves at module load, so the engine half purges the twin, the admission seam - * and both classes from the require cache, sets the regtest height, re-requires, and restores - * every entry and the variable afterwards. A default run that only drove the inert tree would - * report the whole file as vacuous green, since below the activation there is no map to bound. ********************************************************************/ 'use strict'; const { expect } = require('chai'); -const sinon = require('sinon'); const crypto = require('crypto'); const ValidatorIdentity = require('../../../../src/validators/identity.js'); +const ChainTips = require('../../../../src/hub/chain_tips.js'); const sha256 = (s) => crypto.createHash('sha256').update(String(s), 'utf8').digest('hex'); -// The regtest producer activation this suite arms. Rows below it are legacy rows in the -// same armed process, which is how the "no map, no tip read" case below is driven. -const ADMIT_AT = 1000; const ERA_BLOCK = 2000; -const LEGACY_BLK = 150; const OWN_BTC = 900000; const OWN_DOGE = 5000000; -const CALL_ID = 'c'.repeat(64); - -const ARMED_MODULES = [ - '../../../../src/consensus/gates/mirror_admission_gate.js', - '../../../../src/lib/admission_height.js', - '../../../../src/cross_chain/dex_consensus.js', - '../../../../src/cross_chain/call_engine.js' -]; - -// Purge, arm, re-require, and hand back a restore() that puts the process back byte-exact. -// The objects built from the armed modules keep them by closure, so the rest of the run -// still sees the inert tree it was written against. -function armAdmission() { - const paths = ARMED_MODULES.map(m => require.resolve(m)); - const saved = paths.map(p => [p, require.cache[p]]); - const savedEnv = process.env.XC_MIRROR_ADMISSION_ACTIVATION; - for (const p of paths) delete require.cache[p]; - process.env.XC_MIRROR_ADMISSION_ACTIVATION = String(ADMIT_AT); - - const ah = require('../../../../src/lib/admission_height.js'); - const CrossChainCallEngine = require('../../../../src/cross_chain/call_engine.js'); - - function restore() { - for (const [p, mod] of saved) { - if (mod === undefined) delete require.cache[p]; else require.cache[p] = mod; - } - if (savedEnv === undefined) delete process.env.XC_MIRROR_ADMISSION_ACTIVATION; - else process.env.XC_MIRROR_ADMISSION_ACTIVATION = savedEnv; - } - return { ah, CrossChainCallEngine, restore }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// 1. CrossChainCallEngine.validateProposedMatch -// ───────────────────────────────────────────────────────────────────────────── - // No arming here on purpose: the consensus never asks the era gate. It asks the ENGINE // for a scope, which is the only thing that can know the row's table and read set, and // then bounds whatever map the row carries. Driving it with a stub engine is what proves @@ -112,12 +64,12 @@ const ah = require('../../../../src/lib/admission_height.js' // A round id whose leader is the OTHER identity, so handlePropose runs for real // instead of the follower being its own leader. - function ridLedBy(pub) { + function ridLedBy(pub, excluded) { const sorted = VALIDATORS.map(v => v.pubkey).sort(); for (let n = 0; n < 512; n++) { const rid = sha256('round-' + n).slice(0, 64); const mInt = parseInt(rid.slice(0, 8), 16) || 0; - if (sorted[mInt % sorted.length] === pub) return rid; + if (rid !== excluded && sorted[mInt % sorted.length] === pub) return rid; } throw new Error('no round id in the search space is led by the requested validator'); } @@ -145,7 +97,11 @@ const ah = require('../../../../src/lib/admission_height.js' capSnapshot: null, canonicalMatch: canonicalMatch, persistCapabilitySnapshot: async () => {}, - validateProposedMatch: async () => true + validateProposedMatch: async () => { + if(opts.validationReadSet) + await engine.hub.resolveAdmissionTips(opts.validationReadSet); + return true; + } }; if (opts.scope !== false) { engine.admissionScope = opts.scope || ((row) => ({ @@ -154,18 +110,19 @@ const ah = require('../../../../src/lib/admission_height.js' })); } if (opts.tips !== undefined) { - engine.hub.resolveAdmissionTips = async (chains) => { - let out = {}; - for (const c of chains) out[c] = Object.prototype.hasOwnProperty.call(opts.tips, c) ? opts.tips[c] : null; - return out; + engine.hub.resolveAdmissionTip = async (chain) => { + if(opts.tipReads) opts.tipReads.push(chain); + return Object.prototype.hasOwnProperty.call(opts.tips, chain) ? opts.tips[chain] : null; }; + engine.hub.resolveAdmissionTips = ChainTips.prototype.resolveAdmissionTips; + engine.hub.withAdmissionTipMemo = ChainTips.prototype.withAdmissionTipMemo; } return new CrossChainDexConsensus(engine); } // Seed the round, hand the follower a validly signed leader PROPOSE, and report whether // it took the leader's signature (which is what "signed and moved to PREPARE" means). - async function offerPropose(consensus, row, rid) { + async function offerPropose(consensus, row, rid, stop) { await consensus.propose(rid, { row, snapshot: { validators: VALIDATORS, count: 2 } }); const canonical = canonicalMatch(row); await consensus.handlePropose({ @@ -175,11 +132,27 @@ const ah = require('../../../../src/lib/admission_height.js' const pending = consensus.pending.get(rid); const took = !!(pending && pending.signatures.has(LEADER_PUB)); if (pending && pending.timer) clearTimeout(pending.timer); - await consensus.stop(); + if(stop !== false) await consensus.stop(); return took; } function registerConsensusAdmissionBoundTests() { + it('reads each chain once inside one proposal and reads both afresh for the next proposal', async function () { + const reads = []; + const firstRid = ridLedBy(LEADER_PUB); + const secondRid = ridLedBy(LEADER_PUB, firstRid); + const c = makeFollower({ + tips: { BTC: OWN_BTC, DOGE: OWN_DOGE }, + validationReadSet: ['BTC', 'DOGE'], + tipReads: reads + }); + + expect(await offerPropose(c, matchRow(firstRid, honestColumns()), firstRid, false)).to.equal(true); + expect(reads).to.deep.equal(['BTC', 'DOGE']); + expect(await offerPropose(c, matchRow(secondRid, honestColumns()), secondRid)).to.equal(true); + expect(reads).to.deep.equal(['BTC', 'DOGE', 'BTC', 'DOGE']); + }); + it('signs a proposal whose map holds against this follower\'s own tips', async function () { const rid = ridLedBy(LEADER_PUB); const c = makeFollower({ tips: { BTC: OWN_BTC, DOGE: OWN_DOGE } }); From e2a63a573082408b25f81b72d26c5ae503c6f883 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 15:44:01 -0700 Subject: [PATCH 16/57] test(attestation): pass canonical widening separately --- ...ponsibleset_canonical_vector_conformance_regression.test.js | 3 ++- ..._round_007_computeresponsible_over_the_same_vectors.test.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/test/unit/attestation/round/attestation_round_006_computeresponsibleset_canonical_vector_conformance_regression.test.js b/test/unit/attestation/round/attestation_round_006_computeresponsibleset_canonical_vector_conformance_regression.test.js index 46678e6a6..0873e40bb 100644 --- a/test/unit/attestation/round/attestation_round_006_computeresponsibleset_canonical_vector_conformance_regression.test.js +++ b/test/unit/attestation/round/attestation_round_006_computeresponsibleset_canonical_vector_conformance_regression.test.js @@ -135,7 +135,8 @@ describe('AttestationRound', function () { beforeEach(hookAt3853); afterEach(hoo let hub = makeHub(); let ar = new AttestationRound(hub, makeProviderRegistry()); let got = ar - .computeResponsibleSet(c.validators, c.requestId, c.redundancy, c.weighted, c.minStake) + .computeResponsibleSet(c.validators, c.requestId, + c.redundancy - (c.widen ?? 0), c.weighted, c.minStake, c.widen) .map(v => v.pubkey); expect(got).to.deep.equal(c.expected); }); diff --git a/test/unit/attestation/round/attestation_round_007_computeresponsible_over_the_same_vectors.test.js b/test/unit/attestation/round/attestation_round_007_computeresponsible_over_the_same_vectors.test.js index 68a5ee865..abf04c3d7 100644 --- a/test/unit/attestation/round/attestation_round_007_computeresponsible_over_the_same_vectors.test.js +++ b/test/unit/attestation/round/attestation_round_007_computeresponsible_over_the_same_vectors.test.js @@ -159,7 +159,8 @@ describe('AttestationRound', function () { beforeEach(hookAt3853); afterEach(hoo 'attest-vec-' + process.pid + '-' + Math.floor(Math.random() * 1e9) + '.jsonl'); // Block 100: below the mainnet SWQ anchor (961000) and above the // regtest one (0), so the vector's `weighted` flag alone picks the branch. - let got = await pub.computeResponsible(c.requestId, 100, c.redundancy, 'http_get'); + let got = await pub.computeResponsible(c.requestId, 100, + c.redundancy - (c.widen ?? 0), 'http_get', c.widen); expect(got).to.deep.equal(c.expected.length ? c.expected : null); }); }); }); }); }); From 61e6bc6f6da1964069fd1d19c22eabad8f15f0e1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 15:45:03 -0700 Subject: [PATCH 17/57] Purge regtest relics on BTC chain identity change --- src/api/rpc/feed.js | 8 ++ src/db/capability_snapshots.js | 5 + src/db/cross_chain.js | 10 ++ .../chain_tip_chain_id_purge.test.js | 97 +++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 test/unit/cross_chain/chain_tip_chain_id_purge.test.js diff --git a/src/api/rpc/feed.js b/src/api/rpc/feed.js index 21672a179..d6a9c5009 100644 --- a/src/api/rpc/feed.js +++ b/src/api/rpc/feed.js @@ -65,6 +65,14 @@ function chainTipFeedRpc(ctx) { chainId = chain_id; } try { + if (coin === 'BTC' && network === 'regtest' && chainId !== undefined) { + let storedTip = await hub.db.getChainTip(coin, network); + if (storedTip && storedTip.chainId && storedTip.chainId !== chainId) { + await hub.db.deleteCrossChainMatchesByNetwork(network); + await hub.db.deleteCrossChainCallsByNetwork(network); + await hub.db.deleteAllCapabilitySnapshots(); + } + } await hub.db.setChainTip(coin, network, height, time, chainId); return {status: "success"}; } catch (err) { diff --git a/src/db/capability_snapshots.js b/src/db/capability_snapshots.js index d77b7ff14..0420e67e1 100644 --- a/src/db/capability_snapshots.js +++ b/src/db/capability_snapshots.js @@ -31,6 +31,11 @@ const TABLE = 'capability_snapshots'; const COLUMNS = '(snapshot_block, capability, signing_pubkey, amount, source, btc_chain_id)'; module.exports = { + // The table has no network column, so a regtest chain-instance change clears it whole. + async deleteAllCapabilitySnapshots() { + return this.doQuery('DELETE FROM capability_snapshots'); + }, + // Reads rows from capability_snapshots. // Moved here from src/api.js:2116. async findCapabilitySnapshotsById(since, limit) { diff --git a/src/db/cross_chain.js b/src/db/cross_chain.js index 4b2f897c0..4dfaed40e 100644 --- a/src/db/cross_chain.js +++ b/src/db/cross_chain.js @@ -88,6 +88,16 @@ const CROSS_CHAIN_MATCH_COLUMNS = ['match_id','snapshot_block','network', 'admit_block_btc','admit_block_ltc','admit_block_doge']; module.exports = { + // Deletes rows tied to one network after a regtest chain instance changes. + async deleteCrossChainMatchesByNetwork(network) { + return this.doQuery('DELETE FROM cross_chain_matches WHERE network = ?', [network]); + }, + + // Deletes rows tied to one network after a regtest chain instance changes. + async deleteCrossChainCallsByNetwork(network) { + return this.doQuery('DELETE FROM cross_chain_calls WHERE network = ?', [network]); + }, + // Reads rows from cross_chain_calls. // Moved here from src/anchor/publisher.js:2491. async findCrossChainCallsByBatchSeq(maxBatch) { diff --git a/test/unit/cross_chain/chain_tip_chain_id_purge.test.js b/test/unit/cross_chain/chain_tip_chain_id_purge.test.js new file mode 100644 index 000000000..8be5b10a3 --- /dev/null +++ b/test/unit/cross_chain/chain_tip_chain_id_purge.test.js @@ -0,0 +1,97 @@ +'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 + +const sinon = require('sinon'); +const { expect } = require('chai'); +const { buildFeedRpc } = require('../../../src/api/rpc/feed'); +const crossChainDb = require('../../../src/db/cross_chain'); +const capabilityDb = require('../../../src/db/capability_snapshots'); + +const OLD_ID = '01'.repeat(32); +const NEW_ID = '02'.repeat(32); + +function makeDb(storedId = OLD_ID) { + let order = []; + return { + order, + getChainTip: sinon.stub().callsFake(async () => { + order.push('get'); + return { chainId: storedId }; + }), + deleteCrossChainMatchesByNetwork: sinon.stub().callsFake(async () => { order.push('matches'); }), + deleteCrossChainCallsByNetwork: sinon.stub().callsFake(async () => { order.push('calls'); }), + deleteAllCapabilitySnapshots: sinon.stub().callsFake(async () => { order.push('snapshots'); }), + setChainTip: sinon.stub().callsFake(async () => { order.push('set'); }) + }; +} + +async function push(db, overrides = {}) { + const params = { + coin: 'BTC', network: 'regtest', block_height: 42, + block_time: 1700000000, chain_id: NEW_ID, + ...overrides + }; + return buildFeedRpc({ hub: { db } }).pushchaintip(params); +} + +describe('regtest chain-tip identity purge', function () { + it('uses scoped SQL for cross-chain rows and an unfiltered snapshot delete', async function () { + const doQuery = sinon.stub().resolves(); + await crossChainDb.deleteCrossChainMatchesByNetwork.call({ doQuery }, 'regtest'); + await crossChainDb.deleteCrossChainCallsByNetwork.call({ doQuery }, 'regtest'); + await capabilityDb.deleteAllCapabilitySnapshots.call({ doQuery }); + + expect(doQuery.getCall(0).args).to.deep.equal([ + 'DELETE FROM cross_chain_matches WHERE network = ?', ['regtest'] + ]); + expect(doQuery.getCall(1).args).to.deep.equal([ + 'DELETE FROM cross_chain_calls WHERE network = ?', ['regtest'] + ]); + expect(doQuery.getCall(2).args).to.deep.equal(['DELETE FROM capability_snapshots']); + }); + + it('purges all three tables before replacing a changed BTC regtest tip', async function () { + const db = makeDb(); + expect(await push(db)).to.deep.equal({ status: 'success' }); + expect(db.order).to.deep.equal(['get', 'matches', 'calls', 'snapshots', 'set']); + expect(db.deleteCrossChainMatchesByNetwork.calledWithExactly('regtest')).to.equal(true); + expect(db.deleteCrossChainCallsByNetwork.calledWithExactly('regtest')).to.equal(true); + expect(db.deleteAllCapabilitySnapshots.calledOnceWithExactly()).to.equal(true); + }); + + for (const failedMethod of [ + 'getChainTip', + 'deleteCrossChainMatchesByNetwork', + 'deleteCrossChainCallsByNetwork', + 'deleteAllCapabilitySnapshots' + ]) { + it('does not update the tip when ' + failedMethod + ' fails', async function () { + const db = makeDb(); + db[failedMethod].rejects(new Error(failedMethod + ' failed')); + const result = await push(db); + expect(result).to.deep.equal({ error: failedMethod + ' failed' }); + expect(db.setChainTip.called).to.equal(false); + }); + } + + const noOps = [ + ['no stored identity', null, {}], + ['an unchanged identity', NEW_ID, {}], + ['a non-BTC tip', OLD_ID, { coin: 'DOGE' }], + ['a non-regtest tip', OLD_ID, { network: 'mainnet' }] + ]; + for (const [label, storedId, overrides] of noOps) { + it('does not purge for ' + label, async function () { + const db = makeDb(storedId); + expect(await push(db, overrides)).to.deep.equal({ status: 'success' }); + expect(db.deleteCrossChainMatchesByNetwork.called).to.equal(false); + expect(db.deleteCrossChainCallsByNetwork.called).to.equal(false); + expect(db.deleteAllCapabilitySnapshots.called).to.equal(false); + expect(db.setChainTip.calledOnce).to.equal(true); + }); + } +}); From cafa6e4f335782f105ad34fd5691312fa662002e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 15:57:26 -0700 Subject: [PATCH 18/57] Learn hub network from pushed config --- src/api/rpc/system.js | 1 + src/hub/config_params.js | 26 ++++ .../api/system_updateconfig_network.test.js | 132 ++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 test/unit/api/system_updateconfig_network.test.js diff --git a/src/api/rpc/system.js b/src/api/rpc/system.js index 126813b26..10794e529 100644 --- a/src/api/rpc/system.js +++ b/src/api/rpc/system.js @@ -47,6 +47,7 @@ function systemReads(ctx) { async updateconfig({config}){ try { + hub.learnNetworkFromConfig(config); await hub.addParametersFromJson(config); return {status: "success"}; } catch (err) { diff --git a/src/hub/config_params.js b/src/hub/config_params.js index efc252b53..03c56202f 100644 --- a/src/hub/config_params.js +++ b/src/hub/config_params.js @@ -42,6 +42,11 @@ const logger = getLogger(); const PARAMETER_LIST = ["host", "port", "service_port", "db_host", "db_port", "name", "user", "pass", "self_sync", "hub_url"]; const OPERATIONAL_PARAMS = new Set(["GAS_PRICE", "ACTIVATION_DELAY_BLOCKS", "EXPIRATION_FEE_PER_DAY"]); const JSON_BLOB_PARAMS = new Set(["GAS_SCHEDULE", "STAKING"]); +const HUB_NETWORKS = new Set(["mainnet", "testnet", "regtest"]); + +function isConfigObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} // The string-valued parameters of one (coin, network, module): the transport // keys every module carries, then the operational ones. @@ -119,6 +124,27 @@ function collectJsonBlobRows(rows, moduleLevel, nextCoin, nextNetwork, nextModul class ConfigParams { + // Infer only when every object-valued network slot names the same recognized + // network. Preserve explicit settings and refuse ambiguous input. + learnNetworkFromConfig(json){ + if(this.network) return this.network; + if(!isConfigObject(json)) return this.network; + + let learnedNetwork = null; + for(const coinLevel of Object.values(json)){ + if(!isConfigObject(coinLevel)) continue; + for(const [network, networkLevel] of Object.entries(coinLevel)){ + if(!isConfigObject(networkLevel)) continue; + if(!HUB_NETWORKS.has(network)) return this.network; + if(learnedNetwork !== null && learnedNetwork !== network) return this.network; + learnedNetwork = network; + } + } + + if(learnedNetwork !== null) this.network = learnedNetwork; + return this.network; + } + async addParametersFromJson(json){ if(this.consensus){ await this.consensus.propose(json); diff --git a/test/unit/api/system_updateconfig_network.test.js b/test/unit/api/system_updateconfig_network.test.js new file mode 100644 index 000000000..9144ccd30 --- /dev/null +++ b/test/unit/api/system_updateconfig_network.test.js @@ -0,0 +1,132 @@ +'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. + +const sinon = require('sinon'); +const { expect } = require('chai'); +const ConfigParams = require('../../../src/hub/config_params.js'); +const { buildSystemRpc } = require('../../../src/api/rpc/system.js'); + +function configFor(network, coin) { + return { + [coin || 'bitcoin']: { + [network]: {} + } + }; +} + +function emptyHub() { + const hub = new ConfigParams(); + hub.network = ''; + return hub; +} + +describe('system updateconfig network learning', function () { + afterEach(function () { + sinon.restore(); + }); + + describe('ConfigParams.learnNetworkFromConfig', function () { + it('adopts a recognized network shared by every coin', function () { + const hub = emptyHub(); + const config = Object.assign( + configFor('testnet', 'bitcoin'), + configFor('testnet', 'dogecoin'), + configFor('testnet', 'litecoin') + ); + + expect(hub.learnNetworkFromConfig(config)).to.equal('testnet'); + expect(hub.network).to.equal('testnet'); + }); + + it('can adopt each recognized network', function () { + for(const network of ['mainnet', 'testnet', 'regtest']){ + const hub = emptyHub(); + hub.learnNetworkFromConfig(configFor(network)); + expect(hub.network).to.equal(network); + } + }); + + it('ignores non-object second-level metadata when the object slots agree', function () { + const hub = emptyHub(); + const config = configFor('regtest'); + config.bitcoin.label = 'local development'; + config.bitcoin.disabled = false; + config.bitcoin.optional = null; + + hub.learnNetworkFromConfig(config); + expect(hub.network).to.equal('regtest'); + }); + + it('leaves the network empty for an empty or non-object config', function () { + for(const config of [{}, null, [], 'regtest']){ + const hub = emptyHub(); + hub.learnNetworkFromConfig(config); + expect(hub.network).to.equal(''); + } + }); + + it('refuses an unknown object-valued network key', function () { + const hub = emptyHub(); + hub.learnNetworkFromConfig(configFor('signet')); + expect(hub.network).to.equal(''); + }); + + it('refuses a recognized network when another coin has an unknown network', function () { + const hub = emptyHub(); + const config = Object.assign( + configFor('mainnet', 'bitcoin'), + configFor('production', 'dogecoin') + ); + + hub.learnNetworkFromConfig(config); + expect(hub.network).to.equal(''); + }); + + it('refuses mixed recognized networks', function () { + const hub = emptyHub(); + const config = Object.assign( + configFor('mainnet', 'bitcoin'), + configFor('testnet', 'dogecoin') + ); + + hub.learnNetworkFromConfig(config); + expect(hub.network).to.equal(''); + }); + + it('preserves an already configured network', function () { + const hub = emptyHub(); + hub.network = 'mainnet'; + + expect(hub.learnNetworkFromConfig(configFor('regtest'))).to.equal('mainnet'); + expect(hub.network).to.equal('mainnet'); + }); + }); + + it('learns the network before proposing the config', async function () { + const hub = emptyHub(); + hub.consensus = { + propose: sinon.stub().callsFake(async function () { + expect(hub.network).to.equal('regtest'); + }) + }; + const learn = sinon.spy(hub, 'learnNetworkFromConfig'); + const add = sinon.spy(hub, 'addParametersFromJson'); + const config = configFor('regtest'); + + expect(await buildSystemRpc({ hub }).updateconfig({ config })) + .to.deep.equal({ status: 'success' }); + expect(learn.calledOnceWithExactly(config)).to.equal(true); + expect(add.calledOnceWithExactly(config)).to.equal(true); + expect(learn.calledBefore(add)).to.equal(true); + expect(hub.consensus.propose.calledOnceWithExactly(config)).to.equal(true); + }); +}); From 5333f019102beec8efb0c86e9852951816f70fe3 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 18:30:08 -0700 Subject: [PATCH 19/57] Land rows L002-95 and L002-96 from cx-l002-95 and cx-l002-96 L002-95 records the durable coverage floor so an absent marker can be told apart from a publisher that ran without outcome. L002-96 scopes the quarantinedWindows statistic to the catch-up horizon and documents it. One commit rather than two merges: the lane branches' own comments narrated the change and the comment-style gate refuses those added lines wherever they sit in the pushed range. --- CHANGELOG.md | 3 ++ src/attestation/batch_publisher.js | 13 ++++- src/attestation/batch_publisher/markers.js | 21 ++++++-- ..._publisher_003_publishing_a_window.test.js | 54 ++++++++++++++++++- ...ch_publisher_011_the_landed_marker.test.js | 45 ++++++++++++++++ 5 files changed, 131 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 316d4fdbf..9c2a7c33a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- The attestation batch publisher's `quarantinedWindows` statistic now counts only quarantines inside the current catch-up horizon. Dashboards keyed to the previous all-history count may see a one-time decrease after upgrade. + ## [0.20.0] - 2026-09-17 ### Changed diff --git a/src/attestation/batch_publisher.js b/src/attestation/batch_publisher.js index b556e693f..b6495b7d1 100644 --- a/src/attestation/batch_publisher.js +++ b/src/attestation/batch_publisher.js @@ -283,11 +283,22 @@ class AttestationBatchPublisher { } getStats(){ + let currentWindow = this.windowStartFor(this.nowSeconds()); + let quarantineFloor = this.catchupFloorWindow(currentWindow); + let quarantinedWindows = 0; + for(let start of this._quarantined){ + let windowStart = Number(start); + if(Number.isFinite(windowStart) && windowStart >= quarantineFloor && + windowStart < currentWindow) quarantinedWindows++; + } return Object.assign({}, this.stats, { windowSeconds: this.windowS, enabled: this.enabled, armed: this.isArmedNetwork(), - quarantinedWindows: this._quarantined.size, + // The number of quarantines a sweep can still reach, not every quarantine + // retained by this process. A dashboard that counts whole retained history + // will read a lower number here, by design. + quarantinedWindows: quarantinedWindows, // Windows an earlier sweep walked past that are being caught up now. A count // that keeps rising is a publish path failing, not a busy federation. coverageGapWindows: this._coverageGaps.size, diff --git a/src/attestation/batch_publisher/markers.js b/src/attestation/batch_publisher/markers.js index e002167e2..581a366d1 100644 --- a/src/attestation/batch_publisher/markers.js +++ b/src/attestation/batch_publisher/markers.js @@ -48,7 +48,10 @@ module.exports = { let db = this.hubDb(); if(!db || typeof db.doQuery !== 'function') return null; let rows = await db.findAttestPublishedBatchesByNetwork(this.network, windowStart); - return (rows && rows.length) ? rows[0] : null; + let marker = (rows && rows.length) ? rows[0] : null; + // `tracking` records when this hub began owing coverage; it is not an outcome + // for that window. Returning it as absent keeps the ordinary retry path live. + return marker && String(marker.status) === 'tracking' ? null : marker; }, // The oldest window a marker can still matter for: pendingWindows builds candidates @@ -90,6 +93,14 @@ module.exports = { live.sort((a, b) => a - b); for(let start of live) this._quarantined.add(start); this.reportHydratedMarkers(live, agedSummary, current); + + // Absence on its own cannot separate "the publisher has never run" from "the + // publisher ran but produced no outcome". Mark the in-progress window as the + // durable coverage floor so a later table read can tell them apart. The insert + // is deliberately a no-op on an existing terminal row. + if(live.length === 0 && (Number(agedSummary.count) || 0) === 0) + await db.setAttestPublishedBatchByNetwork( + this.network, current, this.windowEndFor(current), null, 0, 'tracking'); }, // Two kinds of marker at two severities. A window inside the horizon is an action item @@ -119,11 +130,15 @@ module.exports = { return windows + ' window(s) / ' + formatAge(secs) + ' old'; }, - // Idempotent: an existing row for the window is left exactly as it is, so a replay - // can never downgrade a `sent` or `landed` marker back to an intent. + // A tracking row becomes the pre-send intent; an existing outcome row is left + // exactly as it is, so a replay cannot downgrade `sent` or `landed` to intent. async recordIntent(window, batchKey){ let db = this.hubDb(); if(!db || typeof db.doQuery !== 'function') return; + // A tracking row licenses no spend and must become the real pre-send marker. + // Delete it under a status guard before the insert. A concurrent landed row is + // untouched, and the intent insert's duplicate-key no-op preserves it. + await db.deleteAttestPublishedBatch(this.network, window.window_start, 'tracking'); await db.setAttestPublishedBatchByNetwork(this.network, window.window_start, window.window_end, batchKey, window.row_count, 'intent'); }, diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js index 7b4790ea4..d2b918600 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js @@ -38,6 +38,7 @@ const AttestationBatchPublisher = require('../../../../../src/attestation/batch_ const ValidatorIdentity = require('../../../../../src/validators/identity.js'); const abw = require('../../../../../src/lib/attest_batch_wire.js'); const { isNeverSentError, isAmbiguousSendError } = require('../../../../../src/lib/idempotent_broadcast.js'); +const { MAX_CATCHUP_WINDOWS } = require('../../../../../src/attestation/batch_publisher/constants.js'); const { DB_METHODS } = require('../../../../helpers/mockHub.js'); const WINDOW_S = 10; // regtest override; the whole suite closes windows in seconds @@ -109,6 +110,15 @@ function updateMarker(markers, args){ function doDbQuery(responses, markers, sql, args){ if(/FROM attestation_responses/i.test(sql)) return selectResponses(responses, sql, args); if(/^DELETE FROM attest_published_batches/i.test(sql)) return deleteMarker(markers, args); + if(/SELECT MIN\(window_start\).*window_start < \?/i.test(sql)){ + let [network, status, before] = args; + let starts = markers.filter(m => m.network === network && m.status === status && + Number(m.window_start) < Number(before)) + .map(m => Number(m.window_start)); + return [{ oldest: starts.length ? Math.min.apply(null, starts) : null, + newest: starts.length ? Math.max.apply(null, starts) : null, + count: starts.length }]; + } // The floor read. BOTH aggregates are answered from the same row set, so a // publisher that went back to flooring on the newest marker reads a real // value here rather than an undefined the test would silently coerce. @@ -117,8 +127,14 @@ function doDbQuery(responses, markers, sql, args){ let newest = markers.reduce((m, r) => Math.max(m, Number(r.window_start)), 0); return [{ oldest: Number.isFinite(oldest) ? oldest : null, newest: newest || null }]; } + if(/SELECT window_start FROM attest_published_batches.*window_start >= \?/i.test(sql)){ + return markers.filter(m => m.network === args[0] && m.status === args[1] && + Number(m.window_start) >= Number(args[2])) + .map(m => ({ window_start: m.window_start })); + } if(/SELECT window_start FROM attest_published_batches/i.test(sql)){ - return markers.filter(m => m.status === args[1]).map(m => ({ window_start: m.window_start })); + return markers.filter(m => m.network === args[0] && m.status === args[1]) + .map(m => ({ window_start: m.window_start })); } if(/FROM attest_published_batches WHERE network = \? AND window_start = \?/i.test(sql)){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); @@ -298,6 +314,42 @@ describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); aft // ------------------------------------------------------------ publishing + // The set is deliberately report-once process memory, so entries remain after + // their windows age out. The public statistic is narrower: it counts only the + // quarantines a bounded sweep can still reach. +describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('publishing a window', function () { it('drops quarantines from the statistic when they leave the catch-up horizon', async function () { + let hub = makeHub({ dir: dir }); + let now = 200 * WINDOW_S; + let oldestReachable = now - MAX_CATCHUP_WINDOWS * WINDOW_S; + let newestReachable = now - WINDOW_S; + hub.db.markers.push( + { network: 'regtest', window_start: oldestReachable, status: 'intent' }, + { network: 'regtest', window_start: newestReachable, status: 'intent' } + ); + let p = makePublisher(hub); + p.nowSeconds = () => now; + + let realError = console.error; + try { + console.error = () => {}; + await p.hydrateMarkers(); + } finally { + console.error = realError; + } + + expect(p._quarantined.size, 'both reachable markers stay in report-once memory').to.equal(2); + expect(p.getStats().quarantinedWindows, + 'the boundary window is still inside the horizon').to.equal(2); + + p.nowSeconds = () => now + WINDOW_S; + expect(p._quarantined.size, 'aging does not erase report-once memory').to.equal(2); + expect(p.getStats().quarantinedWindows, + 'the marker below the moving horizon is not actionable').to.equal(1); + }); }); }); + +// ------------------------------------------------------------ publishing + + // A federation that shares one Bitcoin indexer has one hub with a chain_tips // row and N-1 without (testnet 2026-09-07: four of five validators). Every diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_011_the_landed_marker.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_011_the_landed_marker.test.js index 88141edb3..6d0089c0f 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_011_the_landed_marker.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_011_the_landed_marker.test.js @@ -242,6 +242,51 @@ const hookAt10827 = function () { }; // ------------------------------------------------------------ landing +describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('the landed marker', function () { it('records the window from which an otherwise empty marker history is tracking coverage', async function () { + let hub = makeHub({ dir: dir }); + let p = makePublisher(hub); + let now = 200 * WINDOW_S; + + await p.hydrateMarkers(now); + + expect(hub.db.markers).to.deep.equal([{ + network: 'regtest', window_start: now, window_end: now + WINDOW_S, + batch_key: null, row_count: 0, status: 'tracking' + }]); + expect(await p.getMarker(now), + 'tracking is evidence of participation, not a completed window').to.equal(null); + }); }); }); + +describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('the landed marker', function () { it('turns the coverage-tracking row into a landed outcome', async function () { + let hub = makeHub({ dir: dir }); + let p = makePublisher(hub); + let now = 200 * WINDOW_S; + + await p.hydrateMarkers(now); + await p.recordLandedWindow(now, now + WINDOW_S, 'dogetxid', 0); + + expect(hub.db.markers).to.have.length(1); + expect(hub.db.marker(now)).to.include({ + window_start: now, window_end: now + WINDOW_S, + row_count: 0, status: 'landed' + }); + }); }); }); + +describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('the landed marker', function () { it('turns the coverage-tracking row into a publish intent before sending', async function () { + let hub = makeHub({ dir: dir }); + let p = makePublisher(hub); + let now = 200 * WINDOW_S; + let window = { window_start: now, window_end: now + WINDOW_S, row_count: 2 }; + + await p.hydrateMarkers(now); + await p.recordIntent(window, 'batchkey'); + + expect(hub.db.markers).to.deep.equal([{ + network: 'regtest', window_start: now, window_end: now + WINDOW_S, + batch_key: 'batchkey', row_count: 2, status: 'intent' + }]); + }); }); }); + describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('the landed marker', function () { it('stops a window the federation has already landed from being published', async function () { let hub = makeHub({ dir: dir }); let p = makePublisher(hub); From dfaf6ced71acbd76c27765b1ce03bce870c93231 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 18:33:16 -0700 Subject: [PATCH 20/57] test(attestation): move the catch-up horizon case to its own file The publishing-window file passed the 400-line ceiling when the horizon case landed. Same harness, same assertions, new numbered sibling. --- ..._publisher_003_publishing_a_window.test.js | 41 --- ..._publisher_013_the_catchup_horizon.test.js | 301 ++++++++++++++++++ 2 files changed, 301 insertions(+), 41 deletions(-) create mode 100644 test/unit/attestation/publisher/batch/attestation_batch_publisher_013_the_catchup_horizon.test.js diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js index d2b918600..329acff0f 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js @@ -313,47 +313,6 @@ describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); aft // ------------------------------------------------------------ publishing - - // The set is deliberately report-once process memory, so entries remain after - // their windows age out. The public statistic is narrower: it counts only the - // quarantines a bounded sweep can still reach. -describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('publishing a window', function () { it('drops quarantines from the statistic when they leave the catch-up horizon', async function () { - let hub = makeHub({ dir: dir }); - let now = 200 * WINDOW_S; - let oldestReachable = now - MAX_CATCHUP_WINDOWS * WINDOW_S; - let newestReachable = now - WINDOW_S; - hub.db.markers.push( - { network: 'regtest', window_start: oldestReachable, status: 'intent' }, - { network: 'regtest', window_start: newestReachable, status: 'intent' } - ); - let p = makePublisher(hub); - p.nowSeconds = () => now; - - let realError = console.error; - try { - console.error = () => {}; - await p.hydrateMarkers(); - } finally { - console.error = realError; - } - - expect(p._quarantined.size, 'both reachable markers stay in report-once memory').to.equal(2); - expect(p.getStats().quarantinedWindows, - 'the boundary window is still inside the horizon').to.equal(2); - - p.nowSeconds = () => now + WINDOW_S; - expect(p._quarantined.size, 'aging does not erase report-once memory').to.equal(2); - expect(p.getStats().quarantinedWindows, - 'the marker below the moving horizon is not actionable').to.equal(1); - }); }); }); - -// ------------------------------------------------------------ publishing - - - - // A federation that shares one Bitcoin indexer has one hub with a chain_tips - // row and N-1 without (testnet 2026-09-07: four of five validators). Every - // one of them polls that indexer for requests, and the poll reports the tip, // so a hub with no pushed row anchors on the tip its own round observed. describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('publishing a window', function () { it('anchors on the tip the attestation poll observed when no chain tip was pushed', async function () { let hub = makeHub({ dir: dir }); diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_013_the_catchup_horizon.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_013_the_catchup_horizon.test.js new file mode 100644 index 000000000..a6a7c5b8d --- /dev/null +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_013_the_catchup_horizon.test.js @@ -0,0 +1,301 @@ +/********************************************************************* + * + * 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 Hub - AttestationBatchPublisher unit tests (the ATTEST response-mirror + * design, §6.2). + * + * The cases here are the ones a reading of the diff cannot settle: that the window + * is on the unix hour and not on the process's own start, that an EMPTY window still + * publishes a coverage head, that an over-cap window dead-letters loudly rather than + * truncating itself, that a restart cannot pay for a window twice, and that a window + * whose quorum was unavailable is retried with the SAME bytes rather than a new + * proposal. The DB is a small in-memory pair of tables rather than call-counting + * stubs, because "the second publisher saw the first one's marker" is exactly the + * assertion a canned stub cannot fail. + * + ********************************************************************/ + +'use strict'; + +const os = require('os'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { expect } = require('chai'); + +const AttestationBatchPublisher = require('../../../../../src/attestation/batch_publisher.js'); +const ValidatorIdentity = require('../../../../../src/validators/identity.js'); +const abw = require('../../../../../src/lib/attest_batch_wire.js'); +const { isNeverSentError, isAmbiguousSendError } = require('../../../../../src/lib/idempotent_broadcast.js'); +const { MAX_CATCHUP_WINDOWS } = require('../../../../../src/attestation/batch_publisher/constants.js'); +const { DB_METHODS } = require('../../../../helpers/mockHub.js'); + +const WINDOW_S = 10; // regtest override; the whole suite closes windows in seconds +const ANCHOR = 941234; + +// --------------------------------------------------------------------------- +// Two in-memory tables with just enough SQL surface for the publisher's reads and +// writes, plus the chain-tip accessor it resolves its anchor from. +// --------------------------------------------------------------------------- +function selectResponses(responses, sql, args){ + // Membership is read off the column the statement actually names, so a + // publisher that went back to the audit column selects nothing here. + let column = /WHERE network = \? AND ([a-z_]+) >= \?/i.exec(sql)[1]; + let [network, from, to, limit] = args; + return responses + .filter(r => r.network === network && r[column] >= from && r[column] < to) + .sort((a, b) => (a.request_block_index - b.request_block_index) || + (a.request_action_index - b.request_action_index) || + (a.request_id < b.request_id ? -1 : 1)) + .slice(0, limit) + .map(r => Object.assign({}, r)); +} + +function deleteMarker(markers, args){ + // Read BEFORE the marker SELECT below, whose predicate this statement shares: + // matched the other way round, a withdraw would silently read as a lookup and + // the row it was meant to remove would survive. + let [network, windowStart, fromStatus] = args; + // The status predicate is honoured, because the whole safety argument for + // the withdraw is that it can only remove an intent-only row: a fake that + // deleted unconditionally could not fail the case that matters. + let idx = markers.findIndex(m => m.network === network && + Number(m.window_start) === Number(windowStart) && + m.status === fromStatus); + if(idx < 0) return { affectedRows: 0 }; + markers.splice(idx, 1); + return { affectedRows: 1 }; +} + +function insertMarker(markers, sql, args){ + let cols = sql.substring(sql.indexOf('(') + 1, sql.indexOf(')')).split(',').map(s => s.trim()); + let row = {}; + cols.forEach((c, i) => { row[c] = args[i]; }); + let existing = markers.find(m => Number(m.window_start) === Number(row.window_start)); + if(existing){ + // Only the landed/dead-letter statements carry an updating clause; the + // intent statement's is a deliberate no-op, and the fake honours that + // difference so a replay cannot downgrade a marker here either. + if(/status = VALUES\(status\)/i.test(sql)){ + existing.status = row.status; + existing.row_count = row.row_count; + } + return { affectedRows: 0 }; + } + markers.push(row); + return { affectedRows: 1 }; +} + +function updateMarker(markers, args){ + let [status, txid, rowCount, network, windowStart, fromStatus] = args; + let found = markers.find(m => m.network === network && + Number(m.window_start) === Number(windowStart) && + m.status === fromStatus); + if(!found) return { affectedRows: 0 }; + Object.assign(found, { status, txid, row_count: rowCount }); + return { affectedRows: 1 }; +} + +function doDbQuery(responses, markers, sql, args){ + if(/FROM attestation_responses/i.test(sql)) return selectResponses(responses, sql, args); + if(/^DELETE FROM attest_published_batches/i.test(sql)) return deleteMarker(markers, args); + if(/SELECT MIN\(window_start\).*window_start < \?/i.test(sql)){ + let [network, status, before] = args; + let starts = markers.filter(m => m.network === network && m.status === status && + Number(m.window_start) < Number(before)) + .map(m => Number(m.window_start)); + return [{ oldest: starts.length ? Math.min.apply(null, starts) : null, + newest: starts.length ? Math.max.apply(null, starts) : null, + count: starts.length }]; + } + // The floor read. BOTH aggregates are answered from the same row set, so a + // publisher that went back to flooring on the newest marker reads a real + // value here rather than an undefined the test would silently coerce. + if(/SELECT MIN\(window_start\)/i.test(sql)){ + let oldest = markers.reduce((m, r) => Math.min(m, Number(r.window_start)), Infinity); + let newest = markers.reduce((m, r) => Math.max(m, Number(r.window_start)), 0); + return [{ oldest: Number.isFinite(oldest) ? oldest : null, newest: newest || null }]; + } + if(/SELECT window_start FROM attest_published_batches.*window_start >= \?/i.test(sql)){ + return markers.filter(m => m.network === args[0] && m.status === args[1] && + Number(m.window_start) >= Number(args[2])) + .map(m => ({ window_start: m.window_start })); + } + if(/SELECT window_start FROM attest_published_batches/i.test(sql)){ + return markers.filter(m => m.network === args[0] && m.status === args[1]) + .map(m => ({ window_start: m.window_start })); + } + if(/FROM attest_published_batches WHERE network = \? AND window_start = \?/i.test(sql)){ + let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); + return found ? [Object.assign({}, found)] : []; + } + if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); + throw new Error('unexpected statement: ' + sql); +} + +function makeDb(){ + let responses = []; + let markers = []; + let tip = { blockHeight: ANCHOR, blockTime: 1 }; + return { ...DB_METHODS, + responses, markers, + setTip(h){ tip = (h === null) ? null : { blockHeight: h, blockTime: 1 }; }, + marker(windowStart){ return markers.find(m => Number(m.window_start) === Number(windowStart)) || null; }, + async getChainTip(){ return tip; }, + async doQuery(sql, args){ + return doDbQuery(responses, markers, sql, args); + } + }; +} + +// `effective_time` is what places a row in a window, so every case sets it. The +// default `finalized_at` is deliberately absurd: it belongs to no window any test +// publishes, so a publisher that partitioned on the audit column would find an empty +// window everywhere in this file rather than a subtly different one. +function makeRow(overrides){ + let rid = crypto.randomBytes(32).toString('hex'); + return Object.assign({ + network: 'regtest', + request_id: rid, + request_action_index: 4400, + request_block_index: 120, + provider_id: 'http_get', + status: 'ok', + response_payload: '{"ok":true}', + response_hash: crypto.createHash('sha256').update('body').digest('hex'), + meta: '200', + effective_time: 1780000120, + signer_pubkeys: '[]', + signatures: '[]', + widen: 0, + finalized_at: 7 + }, overrides || {}); +} + +// A capability snapshot of `n` validators, the first of which is this hub. Weights +// are deliberately uneven so a two-of-three signer set clears the strict 2/3 stake +// bar, which equal weights would sit exactly on and fail. +function makeSnapshot(privkeys){ + let weights = ['100', '100', '10', '10', '10']; + return { + validators: privkeys.map((pk, i) => ({ + pubkey: new ValidatorIdentity(pk).getPubkeyHex().toLowerCase(), + weight: weights[i] || '10', + amount: weights[i] || '10', + source: 'src' + i + })), + count: privkeys.length + }; +} + +function makeHub(opts){ + opts = opts || {}; + let db = opts.db || makeDb(); + let ids = opts.identities || [ValidatorIdentity.generate()]; + let identity = new ValidatorIdentity(ids[0].privkeyHex); + let snapshot = makeSnapshot(ids.map(i => i.privkeyHex)); + return { + network: opts.network || 'regtest', + db: db, + p2pConfig: Object.assign({ + ATTEST_BATCH_WINDOW_S_OVERRIDE: String(WINDOW_S), + ATTEST_BATCH_BUFFER_PATH: path.join(opts.dir, 'attest-batch-buffer.jsonl'), + ATTEST_BATCH_SPEND_STATE_PATH: path.join(opts.dir, 'spend-state.json'), + ORACLE_BATCH_SIGN_TIMEOUT_MS: '40' + }, opts.cfg || {}), + getIdentity: () => identity, + capabilitySnapshot: opts.capabilitySnapshot || { + async getWeightSnapshot(){ return snapshot; }, + async getSnapshot(){ return snapshot; } + }, + peerManager: opts.peerManager || null, + _identity: identity, + _snapshot: snapshot + }; +} + +// A publisher wired to a capturing broadcaster. Returns both so a test can read the +// wires that actually went out. +function makePublisher(hub){ + let p = new AttestationBatchPublisher(hub); + let sent = []; + p.setBroadcastHook(async (payload) => { + sent.push(payload); + return { txid: 'tx' + sent.length }; + }); + p.wires = sent; + return p; +} + +function decodeHead(wire){ + let params = wire.split('|').slice(1); + let head = abw.parseAttestBatchHead(params); + if(!head.ok) throw new Error('head did not parse: ' + head.status); + return head; +} + +{ +let dir; + +const hookAt10719 = function () { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'attest-batch-')); + }; + +const hookAt10827 = function () { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best effort */ } + }; + + + // The set is deliberately report-once process memory, so entries remain after + // their windows age out. The public statistic is narrower: it counts only the + // quarantines a bounded sweep can still reach. +describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('publishing a window', function () { it('drops quarantines from the statistic when they leave the catch-up horizon', async function () { + let hub = makeHub({ dir: dir }); + let now = 200 * WINDOW_S; + let oldestReachable = now - MAX_CATCHUP_WINDOWS * WINDOW_S; + let newestReachable = now - WINDOW_S; + hub.db.markers.push( + { network: 'regtest', window_start: oldestReachable, status: 'intent' }, + { network: 'regtest', window_start: newestReachable, status: 'intent' } + ); + let p = makePublisher(hub); + p.nowSeconds = () => now; + + let realError = console.error; + try { + console.error = () => {}; + await p.hydrateMarkers(); + } finally { + console.error = realError; + } + + expect(p._quarantined.size, 'both reachable markers stay in report-once memory').to.equal(2); + expect(p.getStats().quarantinedWindows, + 'the boundary window is still inside the horizon').to.equal(2); + + p.nowSeconds = () => now + WINDOW_S; + expect(p._quarantined.size, 'aging does not erase report-once memory').to.equal(2); + expect(p.getStats().quarantinedWindows, + 'the marker below the moving horizon is not actionable').to.equal(1); + }); }); }); + +// ------------------------------------------------------------ publishing + + + + // A federation that shares one Bitcoin indexer has one hub with a chain_tips + // row and N-1 without (testnet 2026-09-07: four of five validators). Every + // one of them polls that indexer for requests, and the poll reports the tip, +} From d2686354f85bf187e7b1b8e1b1307a63028d833d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 18:48:51 -0700 Subject: [PATCH 21/57] fix(anchor): clamp admission trail TTLs --- src/anchor/publisher/state.js | 15 ++++++-- .../publisher/admission_ttl_clamp.test.js | 35 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 test/unit/anchor/publisher/admission_ttl_clamp.test.js diff --git a/src/anchor/publisher/state.js b/src/anchor/publisher/state.js index 96b02a1a7..edab7be65 100644 --- a/src/anchor/publisher/state.js +++ b/src/anchor/publisher/state.js @@ -26,6 +26,13 @@ const coins = require('../../coins'); const hubConfig = require('../../config'); const { DEFAULT_ANCHOR_MARKER_RETENTION_MS } = require('./constants.js'); +// The anchor-attest arrival budget counts both the durable-intent hold and the +// deferred announcement/reward-attestation queue. Its measured 18 h envelope +// allocates 6 h to each TTL, so the shared default is also the maximum. Keeping +// the clamp at the two config reads prevents an operator override from silently +// moving the height watermark's worst-case trail past that fixed budget. +const MAX_ANCHOR_ATTEST_TTL_MS = 6 * 60 * 60 * 1000; + module.exports = { initRoundState(){ @@ -220,7 +227,9 @@ module.exports = { // announcement queues. this._deferredRewardAttest = new Map(); this.announceRetryMs = parseInt(hubConfig.ANCHOR_ANNOUNCE_RETRY_MS || cfg.ANCHOR_ANNOUNCE_RETRY_MS || '300000'); // 5 min - this.announceRetryTtlMs = parseInt(hubConfig.ANCHOR_ANNOUNCE_RETRY_TTL_MS || cfg.ANCHOR_ANNOUNCE_RETRY_TTL_MS || '21600000'); // 6 h, ~6x the 60-conf DOGE window + this.announceRetryTtlMs = Math.min(parseInt(hubConfig.ANCHOR_ANNOUNCE_RETRY_TTL_MS || + cfg.ANCHOR_ANNOUNCE_RETRY_TTL_MS || '21600000'), + MAX_ANCHOR_ATTEST_TTL_MS); // 6 h, ~6x the 60-conf DOGE window this.announceQueueMax = parseInt(hubConfig.ANCHOR_ANNOUNCE_QUEUE_MAX || cfg.ANCHOR_ANNOUNCE_QUEUE_MAX || '500'); this._deferTimer = null; this._rankWakeTimer = null; // failover wake, see rankWakeMs @@ -232,7 +241,9 @@ module.exports = { // same reasoning as announceRetryTtlMs above: ~6x the 60-conf DOGE window, past // which a send that never relayed is not coming back and holding the row costs // more than re-broadcasting it. - this.anchorIntentTtlMs = parseInt(hubConfig.ANCHOR_INTENT_TTL_MS || cfg.ANCHOR_INTENT_TTL_MS || '21600000'); // 6 h + this.anchorIntentTtlMs = Math.min(parseInt(hubConfig.ANCHOR_INTENT_TTL_MS || + cfg.ANCHOR_INTENT_TTL_MS || '21600000'), + MAX_ANCHOR_ATTEST_TTL_MS); // 6 h // Retention window for the two durable anchor marker tables. Both appended one // row per DOGE-spending broadcast and never removed one, so they grew for the // life of the deployment while their oracle_published_rounds sibling was swept. diff --git a/test/unit/anchor/publisher/admission_ttl_clamp.test.js b/test/unit/anchor/publisher/admission_ttl_clamp.test.js new file mode 100644 index 000000000..e70720fdf --- /dev/null +++ b/test/unit/anchor/publisher/admission_ttl_clamp.test.js @@ -0,0 +1,35 @@ +'use strict'; + +// Copyright (c) 2025-2026 Dankest, LLC +// SPDX-License-Identifier: AGPL-3.0-or-later + +const { expect } = require('chai'); +const StateAnchorPublisher = require('../../../../src/anchor/publisher'); +const { + ANCHOR_ATTEST_ARRIVAL_MARGIN_S +} = require('../../../../src/consensus/gates/anchor_reward_gate.js'); + +const SIX_HOURS_MS = 6 * 60 * 60 * 1000; +const CHECKPOINT_AGE_MS = 6 * 10 * 60 * 1000; +const RAW_STAMP_SKEW_MS = 2 * 60 * 60 * 1000; + +describe('anchor publisher admission TTL clamps', function () { + it('does not let raised TTLs push the anchor-attest trail past its arrival budget', function () { + const raisedTtlMs = 24 * 60 * 60 * 1000; + const pub = new StateAnchorPublisher({ + db: {}, + p2pConfig: { + DOGE_ADDRESS: 'Dpub1', + ANCHOR_ANNOUNCE_RETRY_TTL_MS: String(raisedTtlMs), + ANCHOR_INTENT_TTL_MS: String(raisedTtlMs) + } + }); + + expect(pub.announceRetryTtlMs).to.equal(SIX_HOURS_MS); + expect(pub.anchorIntentTtlMs).to.equal(SIX_HOURS_MS); + + const worstCaseTrailMs = CHECKPOINT_AGE_MS + pub.anchorIntentTtlMs + + pub.announceRetryTtlMs + RAW_STAMP_SKEW_MS; + expect(worstCaseTrailMs).to.be.at.most(ANCHOR_ATTEST_ARRIVAL_MARGIN_S * 1000); + }); +}); From 73f9c57871fe0da14614d564645bf1fd8579c1ca Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 08:38:15 -0700 Subject: [PATCH 22/57] feat(oracle): configure derivation tick budget --- src/oracle/price_aggregator/derived_capabilities.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/oracle/price_aggregator/derived_capabilities.js b/src/oracle/price_aggregator/derived_capabilities.js index 97173dcf6..2d2893148 100644 --- a/src/oracle/price_aggregator/derived_capabilities.js +++ b/src/oracle/price_aggregator/derived_capabilities.js @@ -20,6 +20,8 @@ * ********************************************************************/ +const { positiveIntConfig } = require('../../lib/config_int.js'); + // ── Derived capability snapshots ──────────────────────────────────────────── // Every capability the consensus path persists into capability_snapshots, and so // every capability a chain-only hub must derive for itself. Row 45 built this pass @@ -82,7 +84,11 @@ const PRICE_CAP_DERIVE_INTERVAL_S = 60; // widen this budget. The pass walks newest-first and covers ALL capabilities at a height // before it steps back one, so the tip (what a node following the chain needs next) is // fully covered first and the backfill trails behind it over the following ticks. -const PRICE_CAP_DERIVE_MAX_PER_TICK = 64; +// HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK accepts a positive integer RPC-unit budget; +// an unset or invalid value retains the 64-unit default. +const PRICE_CAP_DERIVE_MAX_PER_TICK = positiveIntConfig( + process.env.HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK, 64, + 'HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK'); module.exports = { DERIVED_CAPABILITIES, CAPABILITY_CONSENSUS_ENGINES, From 21502c203cb8460fcc597fd341cff4fd3b1515d7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 10:27:36 -0700 Subject: [PATCH 23/57] fix(attestation): preserve bounded marker hydration --- src/attestation/batch_publisher/markers.js | 5 +++++ src/db/attestation.js | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/attestation/batch_publisher/markers.js b/src/attestation/batch_publisher/markers.js index 4e2b4b8fe..e002167e2 100644 --- a/src/attestation/batch_publisher/markers.js +++ b/src/attestation/batch_publisher/markers.js @@ -73,6 +73,11 @@ module.exports = { let agedRows = await db.getAttestPublishedBatchesCountByNetworkAndStatusBefore( this.network, 'intent', floor); let agedSummary = (agedRows && agedRows[0]) || {}; + if(!Number.isFinite(Number(agedSummary.count))){ + let oldest = Number(agedSummary.oldest); + agedSummary.count = agedSummary.oldest !== null && + agedSummary.oldest !== undefined && Number.isFinite(oldest) && oldest < floor ? 1 : 0; + } let live = []; for(let r of (rows || [])){ diff --git a/src/db/attestation.js b/src/db/attestation.js index 6fc296b38..096a7905e 100644 --- a/src/db/attestation.js +++ b/src/db/attestation.js @@ -124,7 +124,7 @@ module.exports = { // Summarizes attest_published_batches below a window floor without returning // the historical rows. MIN and MAX preserve the age range in the operator log. async getAttestPublishedBatchesCountByNetworkAndStatusBefore(network, status, beforeWindowStart) { - return this.doQuery('SELECT COUNT(*) AS count, MIN(window_start) AS oldest, MAX(window_start) AS newest FROM attest_published_batches WHERE network = ? AND status = ? AND window_start < ?', [network, status, beforeWindowStart]); + return this.doQuery('SELECT MIN(window_start) AS oldest, MAX(window_start) AS newest, COUNT(*) AS count FROM attest_published_batches WHERE network = ? AND status = ? AND window_start < ?', [network, status, beforeWindowStart]); }, // Reads rows from attest_published_requests. From 666e2f33d336d37bfcca8d0d2b76851714d68c4d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 10:32:51 -0700 Subject: [PATCH 24/57] fix(oracle): route derive budget through config --- src/oracle/price_aggregator/derived_capabilities.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/oracle/price_aggregator/derived_capabilities.js b/src/oracle/price_aggregator/derived_capabilities.js index 2d2893148..5444502f1 100644 --- a/src/oracle/price_aggregator/derived_capabilities.js +++ b/src/oracle/price_aggregator/derived_capabilities.js @@ -20,6 +20,7 @@ * ********************************************************************/ +const hubConfig = require('../../config.js'); const { positiveIntConfig } = require('../../lib/config_int.js'); // ── Derived capability snapshots ──────────────────────────────────────────── @@ -87,7 +88,7 @@ const PRICE_CAP_DERIVE_INTERVAL_S = 60; // HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK accepts a positive integer RPC-unit budget; // an unset or invalid value retains the 64-unit default. const PRICE_CAP_DERIVE_MAX_PER_TICK = positiveIntConfig( - process.env.HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK, 64, + hubConfig.env().HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK, 64, 'HUB_PRICE_CAPABILITY_DERIVE_MAX_PER_TICK'); module.exports = { From 91851900dd07b76131100610f629952558e4b8ec Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 18:30:08 -0700 Subject: [PATCH 25/57] Land rows L002-95 and L002-96 from cx-l002-95 and cx-l002-96 L002-95 records the durable coverage floor so an absent marker can be told apart from a publisher that ran without outcome. L002-96 scopes the quarantinedWindows statistic to the catch-up horizon and documents it. One commit rather than two merges: the lane branches' own comments narrated the change and the comment-style gate refuses those added lines wherever they sit in the pushed range. --- CHANGELOG.md | 3 ++ src/attestation/batch_publisher.js | 13 ++++- src/attestation/batch_publisher/markers.js | 21 ++++++-- ..._publisher_003_publishing_a_window.test.js | 54 ++++++++++++++++++- ...ch_publisher_011_the_landed_marker.test.js | 45 ++++++++++++++++ 5 files changed, 131 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 316d4fdbf..9c2a7c33a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- The attestation batch publisher's `quarantinedWindows` statistic now counts only quarantines inside the current catch-up horizon. Dashboards keyed to the previous all-history count may see a one-time decrease after upgrade. + ## [0.20.0] - 2026-09-17 ### Changed diff --git a/src/attestation/batch_publisher.js b/src/attestation/batch_publisher.js index b556e693f..b6495b7d1 100644 --- a/src/attestation/batch_publisher.js +++ b/src/attestation/batch_publisher.js @@ -283,11 +283,22 @@ class AttestationBatchPublisher { } getStats(){ + let currentWindow = this.windowStartFor(this.nowSeconds()); + let quarantineFloor = this.catchupFloorWindow(currentWindow); + let quarantinedWindows = 0; + for(let start of this._quarantined){ + let windowStart = Number(start); + if(Number.isFinite(windowStart) && windowStart >= quarantineFloor && + windowStart < currentWindow) quarantinedWindows++; + } return Object.assign({}, this.stats, { windowSeconds: this.windowS, enabled: this.enabled, armed: this.isArmedNetwork(), - quarantinedWindows: this._quarantined.size, + // The number of quarantines a sweep can still reach, not every quarantine + // retained by this process. A dashboard that counts whole retained history + // will read a lower number here, by design. + quarantinedWindows: quarantinedWindows, // Windows an earlier sweep walked past that are being caught up now. A count // that keeps rising is a publish path failing, not a busy federation. coverageGapWindows: this._coverageGaps.size, diff --git a/src/attestation/batch_publisher/markers.js b/src/attestation/batch_publisher/markers.js index e002167e2..581a366d1 100644 --- a/src/attestation/batch_publisher/markers.js +++ b/src/attestation/batch_publisher/markers.js @@ -48,7 +48,10 @@ module.exports = { let db = this.hubDb(); if(!db || typeof db.doQuery !== 'function') return null; let rows = await db.findAttestPublishedBatchesByNetwork(this.network, windowStart); - return (rows && rows.length) ? rows[0] : null; + let marker = (rows && rows.length) ? rows[0] : null; + // `tracking` records when this hub began owing coverage; it is not an outcome + // for that window. Returning it as absent keeps the ordinary retry path live. + return marker && String(marker.status) === 'tracking' ? null : marker; }, // The oldest window a marker can still matter for: pendingWindows builds candidates @@ -90,6 +93,14 @@ module.exports = { live.sort((a, b) => a - b); for(let start of live) this._quarantined.add(start); this.reportHydratedMarkers(live, agedSummary, current); + + // Absence on its own cannot separate "the publisher has never run" from "the + // publisher ran but produced no outcome". Mark the in-progress window as the + // durable coverage floor so a later table read can tell them apart. The insert + // is deliberately a no-op on an existing terminal row. + if(live.length === 0 && (Number(agedSummary.count) || 0) === 0) + await db.setAttestPublishedBatchByNetwork( + this.network, current, this.windowEndFor(current), null, 0, 'tracking'); }, // Two kinds of marker at two severities. A window inside the horizon is an action item @@ -119,11 +130,15 @@ module.exports = { return windows + ' window(s) / ' + formatAge(secs) + ' old'; }, - // Idempotent: an existing row for the window is left exactly as it is, so a replay - // can never downgrade a `sent` or `landed` marker back to an intent. + // A tracking row becomes the pre-send intent; an existing outcome row is left + // exactly as it is, so a replay cannot downgrade `sent` or `landed` to intent. async recordIntent(window, batchKey){ let db = this.hubDb(); if(!db || typeof db.doQuery !== 'function') return; + // A tracking row licenses no spend and must become the real pre-send marker. + // Delete it under a status guard before the insert. A concurrent landed row is + // untouched, and the intent insert's duplicate-key no-op preserves it. + await db.deleteAttestPublishedBatch(this.network, window.window_start, 'tracking'); await db.setAttestPublishedBatchByNetwork(this.network, window.window_start, window.window_end, batchKey, window.row_count, 'intent'); }, diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js index 7b4790ea4..d2b918600 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js @@ -38,6 +38,7 @@ const AttestationBatchPublisher = require('../../../../../src/attestation/batch_ const ValidatorIdentity = require('../../../../../src/validators/identity.js'); const abw = require('../../../../../src/lib/attest_batch_wire.js'); const { isNeverSentError, isAmbiguousSendError } = require('../../../../../src/lib/idempotent_broadcast.js'); +const { MAX_CATCHUP_WINDOWS } = require('../../../../../src/attestation/batch_publisher/constants.js'); const { DB_METHODS } = require('../../../../helpers/mockHub.js'); const WINDOW_S = 10; // regtest override; the whole suite closes windows in seconds @@ -109,6 +110,15 @@ function updateMarker(markers, args){ function doDbQuery(responses, markers, sql, args){ if(/FROM attestation_responses/i.test(sql)) return selectResponses(responses, sql, args); if(/^DELETE FROM attest_published_batches/i.test(sql)) return deleteMarker(markers, args); + if(/SELECT MIN\(window_start\).*window_start < \?/i.test(sql)){ + let [network, status, before] = args; + let starts = markers.filter(m => m.network === network && m.status === status && + Number(m.window_start) < Number(before)) + .map(m => Number(m.window_start)); + return [{ oldest: starts.length ? Math.min.apply(null, starts) : null, + newest: starts.length ? Math.max.apply(null, starts) : null, + count: starts.length }]; + } // The floor read. BOTH aggregates are answered from the same row set, so a // publisher that went back to flooring on the newest marker reads a real // value here rather than an undefined the test would silently coerce. @@ -117,8 +127,14 @@ function doDbQuery(responses, markers, sql, args){ let newest = markers.reduce((m, r) => Math.max(m, Number(r.window_start)), 0); return [{ oldest: Number.isFinite(oldest) ? oldest : null, newest: newest || null }]; } + if(/SELECT window_start FROM attest_published_batches.*window_start >= \?/i.test(sql)){ + return markers.filter(m => m.network === args[0] && m.status === args[1] && + Number(m.window_start) >= Number(args[2])) + .map(m => ({ window_start: m.window_start })); + } if(/SELECT window_start FROM attest_published_batches/i.test(sql)){ - return markers.filter(m => m.status === args[1]).map(m => ({ window_start: m.window_start })); + return markers.filter(m => m.network === args[0] && m.status === args[1]) + .map(m => ({ window_start: m.window_start })); } if(/FROM attest_published_batches WHERE network = \? AND window_start = \?/i.test(sql)){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); @@ -298,6 +314,42 @@ describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); aft // ------------------------------------------------------------ publishing + // The set is deliberately report-once process memory, so entries remain after + // their windows age out. The public statistic is narrower: it counts only the + // quarantines a bounded sweep can still reach. +describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('publishing a window', function () { it('drops quarantines from the statistic when they leave the catch-up horizon', async function () { + let hub = makeHub({ dir: dir }); + let now = 200 * WINDOW_S; + let oldestReachable = now - MAX_CATCHUP_WINDOWS * WINDOW_S; + let newestReachable = now - WINDOW_S; + hub.db.markers.push( + { network: 'regtest', window_start: oldestReachable, status: 'intent' }, + { network: 'regtest', window_start: newestReachable, status: 'intent' } + ); + let p = makePublisher(hub); + p.nowSeconds = () => now; + + let realError = console.error; + try { + console.error = () => {}; + await p.hydrateMarkers(); + } finally { + console.error = realError; + } + + expect(p._quarantined.size, 'both reachable markers stay in report-once memory').to.equal(2); + expect(p.getStats().quarantinedWindows, + 'the boundary window is still inside the horizon').to.equal(2); + + p.nowSeconds = () => now + WINDOW_S; + expect(p._quarantined.size, 'aging does not erase report-once memory').to.equal(2); + expect(p.getStats().quarantinedWindows, + 'the marker below the moving horizon is not actionable').to.equal(1); + }); }); }); + +// ------------------------------------------------------------ publishing + + // A federation that shares one Bitcoin indexer has one hub with a chain_tips // row and N-1 without (testnet 2026-09-07: four of five validators). Every diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_011_the_landed_marker.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_011_the_landed_marker.test.js index 88141edb3..6d0089c0f 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_011_the_landed_marker.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_011_the_landed_marker.test.js @@ -242,6 +242,51 @@ const hookAt10827 = function () { }; // ------------------------------------------------------------ landing +describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('the landed marker', function () { it('records the window from which an otherwise empty marker history is tracking coverage', async function () { + let hub = makeHub({ dir: dir }); + let p = makePublisher(hub); + let now = 200 * WINDOW_S; + + await p.hydrateMarkers(now); + + expect(hub.db.markers).to.deep.equal([{ + network: 'regtest', window_start: now, window_end: now + WINDOW_S, + batch_key: null, row_count: 0, status: 'tracking' + }]); + expect(await p.getMarker(now), + 'tracking is evidence of participation, not a completed window').to.equal(null); + }); }); }); + +describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('the landed marker', function () { it('turns the coverage-tracking row into a landed outcome', async function () { + let hub = makeHub({ dir: dir }); + let p = makePublisher(hub); + let now = 200 * WINDOW_S; + + await p.hydrateMarkers(now); + await p.recordLandedWindow(now, now + WINDOW_S, 'dogetxid', 0); + + expect(hub.db.markers).to.have.length(1); + expect(hub.db.marker(now)).to.include({ + window_start: now, window_end: now + WINDOW_S, + row_count: 0, status: 'landed' + }); + }); }); }); + +describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('the landed marker', function () { it('turns the coverage-tracking row into a publish intent before sending', async function () { + let hub = makeHub({ dir: dir }); + let p = makePublisher(hub); + let now = 200 * WINDOW_S; + let window = { window_start: now, window_end: now + WINDOW_S, row_count: 2 }; + + await p.hydrateMarkers(now); + await p.recordIntent(window, 'batchkey'); + + expect(hub.db.markers).to.deep.equal([{ + network: 'regtest', window_start: now, window_end: now + WINDOW_S, + batch_key: 'batchkey', row_count: 2, status: 'intent' + }]); + }); }); }); + describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('the landed marker', function () { it('stops a window the federation has already landed from being published', async function () { let hub = makeHub({ dir: dir }); let p = makePublisher(hub); From 3657c994db8ad3cc98bd0947d933ba9e8492c78f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 18:33:16 -0700 Subject: [PATCH 26/57] test(attestation): move the catch-up horizon case to its own file The publishing-window file passed the 400-line ceiling when the horizon case landed. Same harness, same assertions, new numbered sibling. --- ..._publisher_003_publishing_a_window.test.js | 41 --- ..._publisher_013_the_catchup_horizon.test.js | 301 ++++++++++++++++++ 2 files changed, 301 insertions(+), 41 deletions(-) create mode 100644 test/unit/attestation/publisher/batch/attestation_batch_publisher_013_the_catchup_horizon.test.js diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js index d2b918600..329acff0f 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js @@ -313,47 +313,6 @@ describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); aft // ------------------------------------------------------------ publishing - - // The set is deliberately report-once process memory, so entries remain after - // their windows age out. The public statistic is narrower: it counts only the - // quarantines a bounded sweep can still reach. -describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('publishing a window', function () { it('drops quarantines from the statistic when they leave the catch-up horizon', async function () { - let hub = makeHub({ dir: dir }); - let now = 200 * WINDOW_S; - let oldestReachable = now - MAX_CATCHUP_WINDOWS * WINDOW_S; - let newestReachable = now - WINDOW_S; - hub.db.markers.push( - { network: 'regtest', window_start: oldestReachable, status: 'intent' }, - { network: 'regtest', window_start: newestReachable, status: 'intent' } - ); - let p = makePublisher(hub); - p.nowSeconds = () => now; - - let realError = console.error; - try { - console.error = () => {}; - await p.hydrateMarkers(); - } finally { - console.error = realError; - } - - expect(p._quarantined.size, 'both reachable markers stay in report-once memory').to.equal(2); - expect(p.getStats().quarantinedWindows, - 'the boundary window is still inside the horizon').to.equal(2); - - p.nowSeconds = () => now + WINDOW_S; - expect(p._quarantined.size, 'aging does not erase report-once memory').to.equal(2); - expect(p.getStats().quarantinedWindows, - 'the marker below the moving horizon is not actionable').to.equal(1); - }); }); }); - -// ------------------------------------------------------------ publishing - - - - // A federation that shares one Bitcoin indexer has one hub with a chain_tips - // row and N-1 without (testnet 2026-09-07: four of five validators). Every - // one of them polls that indexer for requests, and the poll reports the tip, // so a hub with no pushed row anchors on the tip its own round observed. describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('publishing a window', function () { it('anchors on the tip the attestation poll observed when no chain tip was pushed', async function () { let hub = makeHub({ dir: dir }); diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_013_the_catchup_horizon.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_013_the_catchup_horizon.test.js new file mode 100644 index 000000000..a6a7c5b8d --- /dev/null +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_013_the_catchup_horizon.test.js @@ -0,0 +1,301 @@ +/********************************************************************* + * + * 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 Hub - AttestationBatchPublisher unit tests (the ATTEST response-mirror + * design, §6.2). + * + * The cases here are the ones a reading of the diff cannot settle: that the window + * is on the unix hour and not on the process's own start, that an EMPTY window still + * publishes a coverage head, that an over-cap window dead-letters loudly rather than + * truncating itself, that a restart cannot pay for a window twice, and that a window + * whose quorum was unavailable is retried with the SAME bytes rather than a new + * proposal. The DB is a small in-memory pair of tables rather than call-counting + * stubs, because "the second publisher saw the first one's marker" is exactly the + * assertion a canned stub cannot fail. + * + ********************************************************************/ + +'use strict'; + +const os = require('os'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { expect } = require('chai'); + +const AttestationBatchPublisher = require('../../../../../src/attestation/batch_publisher.js'); +const ValidatorIdentity = require('../../../../../src/validators/identity.js'); +const abw = require('../../../../../src/lib/attest_batch_wire.js'); +const { isNeverSentError, isAmbiguousSendError } = require('../../../../../src/lib/idempotent_broadcast.js'); +const { MAX_CATCHUP_WINDOWS } = require('../../../../../src/attestation/batch_publisher/constants.js'); +const { DB_METHODS } = require('../../../../helpers/mockHub.js'); + +const WINDOW_S = 10; // regtest override; the whole suite closes windows in seconds +const ANCHOR = 941234; + +// --------------------------------------------------------------------------- +// Two in-memory tables with just enough SQL surface for the publisher's reads and +// writes, plus the chain-tip accessor it resolves its anchor from. +// --------------------------------------------------------------------------- +function selectResponses(responses, sql, args){ + // Membership is read off the column the statement actually names, so a + // publisher that went back to the audit column selects nothing here. + let column = /WHERE network = \? AND ([a-z_]+) >= \?/i.exec(sql)[1]; + let [network, from, to, limit] = args; + return responses + .filter(r => r.network === network && r[column] >= from && r[column] < to) + .sort((a, b) => (a.request_block_index - b.request_block_index) || + (a.request_action_index - b.request_action_index) || + (a.request_id < b.request_id ? -1 : 1)) + .slice(0, limit) + .map(r => Object.assign({}, r)); +} + +function deleteMarker(markers, args){ + // Read BEFORE the marker SELECT below, whose predicate this statement shares: + // matched the other way round, a withdraw would silently read as a lookup and + // the row it was meant to remove would survive. + let [network, windowStart, fromStatus] = args; + // The status predicate is honoured, because the whole safety argument for + // the withdraw is that it can only remove an intent-only row: a fake that + // deleted unconditionally could not fail the case that matters. + let idx = markers.findIndex(m => m.network === network && + Number(m.window_start) === Number(windowStart) && + m.status === fromStatus); + if(idx < 0) return { affectedRows: 0 }; + markers.splice(idx, 1); + return { affectedRows: 1 }; +} + +function insertMarker(markers, sql, args){ + let cols = sql.substring(sql.indexOf('(') + 1, sql.indexOf(')')).split(',').map(s => s.trim()); + let row = {}; + cols.forEach((c, i) => { row[c] = args[i]; }); + let existing = markers.find(m => Number(m.window_start) === Number(row.window_start)); + if(existing){ + // Only the landed/dead-letter statements carry an updating clause; the + // intent statement's is a deliberate no-op, and the fake honours that + // difference so a replay cannot downgrade a marker here either. + if(/status = VALUES\(status\)/i.test(sql)){ + existing.status = row.status; + existing.row_count = row.row_count; + } + return { affectedRows: 0 }; + } + markers.push(row); + return { affectedRows: 1 }; +} + +function updateMarker(markers, args){ + let [status, txid, rowCount, network, windowStart, fromStatus] = args; + let found = markers.find(m => m.network === network && + Number(m.window_start) === Number(windowStart) && + m.status === fromStatus); + if(!found) return { affectedRows: 0 }; + Object.assign(found, { status, txid, row_count: rowCount }); + return { affectedRows: 1 }; +} + +function doDbQuery(responses, markers, sql, args){ + if(/FROM attestation_responses/i.test(sql)) return selectResponses(responses, sql, args); + if(/^DELETE FROM attest_published_batches/i.test(sql)) return deleteMarker(markers, args); + if(/SELECT MIN\(window_start\).*window_start < \?/i.test(sql)){ + let [network, status, before] = args; + let starts = markers.filter(m => m.network === network && m.status === status && + Number(m.window_start) < Number(before)) + .map(m => Number(m.window_start)); + return [{ oldest: starts.length ? Math.min.apply(null, starts) : null, + newest: starts.length ? Math.max.apply(null, starts) : null, + count: starts.length }]; + } + // The floor read. BOTH aggregates are answered from the same row set, so a + // publisher that went back to flooring on the newest marker reads a real + // value here rather than an undefined the test would silently coerce. + if(/SELECT MIN\(window_start\)/i.test(sql)){ + let oldest = markers.reduce((m, r) => Math.min(m, Number(r.window_start)), Infinity); + let newest = markers.reduce((m, r) => Math.max(m, Number(r.window_start)), 0); + return [{ oldest: Number.isFinite(oldest) ? oldest : null, newest: newest || null }]; + } + if(/SELECT window_start FROM attest_published_batches.*window_start >= \?/i.test(sql)){ + return markers.filter(m => m.network === args[0] && m.status === args[1] && + Number(m.window_start) >= Number(args[2])) + .map(m => ({ window_start: m.window_start })); + } + if(/SELECT window_start FROM attest_published_batches/i.test(sql)){ + return markers.filter(m => m.network === args[0] && m.status === args[1]) + .map(m => ({ window_start: m.window_start })); + } + if(/FROM attest_published_batches WHERE network = \? AND window_start = \?/i.test(sql)){ + let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); + return found ? [Object.assign({}, found)] : []; + } + if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); + throw new Error('unexpected statement: ' + sql); +} + +function makeDb(){ + let responses = []; + let markers = []; + let tip = { blockHeight: ANCHOR, blockTime: 1 }; + return { ...DB_METHODS, + responses, markers, + setTip(h){ tip = (h === null) ? null : { blockHeight: h, blockTime: 1 }; }, + marker(windowStart){ return markers.find(m => Number(m.window_start) === Number(windowStart)) || null; }, + async getChainTip(){ return tip; }, + async doQuery(sql, args){ + return doDbQuery(responses, markers, sql, args); + } + }; +} + +// `effective_time` is what places a row in a window, so every case sets it. The +// default `finalized_at` is deliberately absurd: it belongs to no window any test +// publishes, so a publisher that partitioned on the audit column would find an empty +// window everywhere in this file rather than a subtly different one. +function makeRow(overrides){ + let rid = crypto.randomBytes(32).toString('hex'); + return Object.assign({ + network: 'regtest', + request_id: rid, + request_action_index: 4400, + request_block_index: 120, + provider_id: 'http_get', + status: 'ok', + response_payload: '{"ok":true}', + response_hash: crypto.createHash('sha256').update('body').digest('hex'), + meta: '200', + effective_time: 1780000120, + signer_pubkeys: '[]', + signatures: '[]', + widen: 0, + finalized_at: 7 + }, overrides || {}); +} + +// A capability snapshot of `n` validators, the first of which is this hub. Weights +// are deliberately uneven so a two-of-three signer set clears the strict 2/3 stake +// bar, which equal weights would sit exactly on and fail. +function makeSnapshot(privkeys){ + let weights = ['100', '100', '10', '10', '10']; + return { + validators: privkeys.map((pk, i) => ({ + pubkey: new ValidatorIdentity(pk).getPubkeyHex().toLowerCase(), + weight: weights[i] || '10', + amount: weights[i] || '10', + source: 'src' + i + })), + count: privkeys.length + }; +} + +function makeHub(opts){ + opts = opts || {}; + let db = opts.db || makeDb(); + let ids = opts.identities || [ValidatorIdentity.generate()]; + let identity = new ValidatorIdentity(ids[0].privkeyHex); + let snapshot = makeSnapshot(ids.map(i => i.privkeyHex)); + return { + network: opts.network || 'regtest', + db: db, + p2pConfig: Object.assign({ + ATTEST_BATCH_WINDOW_S_OVERRIDE: String(WINDOW_S), + ATTEST_BATCH_BUFFER_PATH: path.join(opts.dir, 'attest-batch-buffer.jsonl'), + ATTEST_BATCH_SPEND_STATE_PATH: path.join(opts.dir, 'spend-state.json'), + ORACLE_BATCH_SIGN_TIMEOUT_MS: '40' + }, opts.cfg || {}), + getIdentity: () => identity, + capabilitySnapshot: opts.capabilitySnapshot || { + async getWeightSnapshot(){ return snapshot; }, + async getSnapshot(){ return snapshot; } + }, + peerManager: opts.peerManager || null, + _identity: identity, + _snapshot: snapshot + }; +} + +// A publisher wired to a capturing broadcaster. Returns both so a test can read the +// wires that actually went out. +function makePublisher(hub){ + let p = new AttestationBatchPublisher(hub); + let sent = []; + p.setBroadcastHook(async (payload) => { + sent.push(payload); + return { txid: 'tx' + sent.length }; + }); + p.wires = sent; + return p; +} + +function decodeHead(wire){ + let params = wire.split('|').slice(1); + let head = abw.parseAttestBatchHead(params); + if(!head.ok) throw new Error('head did not parse: ' + head.status); + return head; +} + +{ +let dir; + +const hookAt10719 = function () { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'attest-batch-')); + }; + +const hookAt10827 = function () { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best effort */ } + }; + + + // The set is deliberately report-once process memory, so entries remain after + // their windows age out. The public statistic is narrower: it counts only the + // quarantines a bounded sweep can still reach. +describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); afterEach(hookAt10827); describe('publishing a window', function () { it('drops quarantines from the statistic when they leave the catch-up horizon', async function () { + let hub = makeHub({ dir: dir }); + let now = 200 * WINDOW_S; + let oldestReachable = now - MAX_CATCHUP_WINDOWS * WINDOW_S; + let newestReachable = now - WINDOW_S; + hub.db.markers.push( + { network: 'regtest', window_start: oldestReachable, status: 'intent' }, + { network: 'regtest', window_start: newestReachable, status: 'intent' } + ); + let p = makePublisher(hub); + p.nowSeconds = () => now; + + let realError = console.error; + try { + console.error = () => {}; + await p.hydrateMarkers(); + } finally { + console.error = realError; + } + + expect(p._quarantined.size, 'both reachable markers stay in report-once memory').to.equal(2); + expect(p.getStats().quarantinedWindows, + 'the boundary window is still inside the horizon').to.equal(2); + + p.nowSeconds = () => now + WINDOW_S; + expect(p._quarantined.size, 'aging does not erase report-once memory').to.equal(2); + expect(p.getStats().quarantinedWindows, + 'the marker below the moving horizon is not actionable').to.equal(1); + }); }); }); + +// ------------------------------------------------------------ publishing + + + + // A federation that shares one Bitcoin indexer has one hub with a chain_tips + // row and N-1 without (testnet 2026-09-07: four of five validators). Every + // one of them polls that indexer for requests, and the poll reports the tip, +} From 98ac25644d25250d171b9435c91534c48944e5ea Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 19:03:10 -0700 Subject: [PATCH 27/57] Re-vendor the attest batch wire twin after admit_block_btc xchain-indexer 3d5c9e52 added admit_block_btc to ATTEST_BATCH_ROW_FIELDS without re-vendoring the hub half, so the byte-identity parity test was red for every pusher. Copy the indexer part over, leaving the two files byte-identical again. --- src/lib/attest_batch_wire/constants.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/attest_batch_wire/constants.js b/src/lib/attest_batch_wire/constants.js index 8fb6364df..8d57f7722 100644 --- a/src/lib/attest_batch_wire/constants.js +++ b/src/lib/attest_batch_wire/constants.js @@ -115,6 +115,7 @@ const ATTEST_BATCH_ROW_FIELDS = [ 'response_hash', 'meta', 'effective_time', + 'admit_block_btc', 'signer_pubkeys', 'signatures', 'widen' From b6d78aa0149b57ba9a3a7f6a0ccb979af2b825ab Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 22:31:52 -0700 Subject: [PATCH 28/57] Revert "Land row L011-52 from cx-l011-52" This reverts commit bac00882d3e7cefad2d338cdab22d941d4f2e687, reversing changes made to ff00268cb697db80f602c4f01fa4c1841489fea5. --- src/api/rpc/system.js | 1 - src/hub/config_params.js | 26 ---- .../api/system_updateconfig_network.test.js | 132 ------------------ 3 files changed, 159 deletions(-) delete mode 100644 test/unit/api/system_updateconfig_network.test.js diff --git a/src/api/rpc/system.js b/src/api/rpc/system.js index 10794e529..126813b26 100644 --- a/src/api/rpc/system.js +++ b/src/api/rpc/system.js @@ -47,7 +47,6 @@ function systemReads(ctx) { async updateconfig({config}){ try { - hub.learnNetworkFromConfig(config); await hub.addParametersFromJson(config); return {status: "success"}; } catch (err) { diff --git a/src/hub/config_params.js b/src/hub/config_params.js index 03c56202f..efc252b53 100644 --- a/src/hub/config_params.js +++ b/src/hub/config_params.js @@ -42,11 +42,6 @@ const logger = getLogger(); const PARAMETER_LIST = ["host", "port", "service_port", "db_host", "db_port", "name", "user", "pass", "self_sync", "hub_url"]; const OPERATIONAL_PARAMS = new Set(["GAS_PRICE", "ACTIVATION_DELAY_BLOCKS", "EXPIRATION_FEE_PER_DAY"]); const JSON_BLOB_PARAMS = new Set(["GAS_SCHEDULE", "STAKING"]); -const HUB_NETWORKS = new Set(["mainnet", "testnet", "regtest"]); - -function isConfigObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} // The string-valued parameters of one (coin, network, module): the transport // keys every module carries, then the operational ones. @@ -124,27 +119,6 @@ function collectJsonBlobRows(rows, moduleLevel, nextCoin, nextNetwork, nextModul class ConfigParams { - // Infer only when every object-valued network slot names the same recognized - // network. Preserve explicit settings and refuse ambiguous input. - learnNetworkFromConfig(json){ - if(this.network) return this.network; - if(!isConfigObject(json)) return this.network; - - let learnedNetwork = null; - for(const coinLevel of Object.values(json)){ - if(!isConfigObject(coinLevel)) continue; - for(const [network, networkLevel] of Object.entries(coinLevel)){ - if(!isConfigObject(networkLevel)) continue; - if(!HUB_NETWORKS.has(network)) return this.network; - if(learnedNetwork !== null && learnedNetwork !== network) return this.network; - learnedNetwork = network; - } - } - - if(learnedNetwork !== null) this.network = learnedNetwork; - return this.network; - } - async addParametersFromJson(json){ if(this.consensus){ await this.consensus.propose(json); diff --git a/test/unit/api/system_updateconfig_network.test.js b/test/unit/api/system_updateconfig_network.test.js deleted file mode 100644 index 9144ccd30..000000000 --- a/test/unit/api/system_updateconfig_network.test.js +++ /dev/null @@ -1,132 +0,0 @@ -'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. - -const sinon = require('sinon'); -const { expect } = require('chai'); -const ConfigParams = require('../../../src/hub/config_params.js'); -const { buildSystemRpc } = require('../../../src/api/rpc/system.js'); - -function configFor(network, coin) { - return { - [coin || 'bitcoin']: { - [network]: {} - } - }; -} - -function emptyHub() { - const hub = new ConfigParams(); - hub.network = ''; - return hub; -} - -describe('system updateconfig network learning', function () { - afterEach(function () { - sinon.restore(); - }); - - describe('ConfigParams.learnNetworkFromConfig', function () { - it('adopts a recognized network shared by every coin', function () { - const hub = emptyHub(); - const config = Object.assign( - configFor('testnet', 'bitcoin'), - configFor('testnet', 'dogecoin'), - configFor('testnet', 'litecoin') - ); - - expect(hub.learnNetworkFromConfig(config)).to.equal('testnet'); - expect(hub.network).to.equal('testnet'); - }); - - it('can adopt each recognized network', function () { - for(const network of ['mainnet', 'testnet', 'regtest']){ - const hub = emptyHub(); - hub.learnNetworkFromConfig(configFor(network)); - expect(hub.network).to.equal(network); - } - }); - - it('ignores non-object second-level metadata when the object slots agree', function () { - const hub = emptyHub(); - const config = configFor('regtest'); - config.bitcoin.label = 'local development'; - config.bitcoin.disabled = false; - config.bitcoin.optional = null; - - hub.learnNetworkFromConfig(config); - expect(hub.network).to.equal('regtest'); - }); - - it('leaves the network empty for an empty or non-object config', function () { - for(const config of [{}, null, [], 'regtest']){ - const hub = emptyHub(); - hub.learnNetworkFromConfig(config); - expect(hub.network).to.equal(''); - } - }); - - it('refuses an unknown object-valued network key', function () { - const hub = emptyHub(); - hub.learnNetworkFromConfig(configFor('signet')); - expect(hub.network).to.equal(''); - }); - - it('refuses a recognized network when another coin has an unknown network', function () { - const hub = emptyHub(); - const config = Object.assign( - configFor('mainnet', 'bitcoin'), - configFor('production', 'dogecoin') - ); - - hub.learnNetworkFromConfig(config); - expect(hub.network).to.equal(''); - }); - - it('refuses mixed recognized networks', function () { - const hub = emptyHub(); - const config = Object.assign( - configFor('mainnet', 'bitcoin'), - configFor('testnet', 'dogecoin') - ); - - hub.learnNetworkFromConfig(config); - expect(hub.network).to.equal(''); - }); - - it('preserves an already configured network', function () { - const hub = emptyHub(); - hub.network = 'mainnet'; - - expect(hub.learnNetworkFromConfig(configFor('regtest'))).to.equal('mainnet'); - expect(hub.network).to.equal('mainnet'); - }); - }); - - it('learns the network before proposing the config', async function () { - const hub = emptyHub(); - hub.consensus = { - propose: sinon.stub().callsFake(async function () { - expect(hub.network).to.equal('regtest'); - }) - }; - const learn = sinon.spy(hub, 'learnNetworkFromConfig'); - const add = sinon.spy(hub, 'addParametersFromJson'); - const config = configFor('regtest'); - - expect(await buildSystemRpc({ hub }).updateconfig({ config })) - .to.deep.equal({ status: 'success' }); - expect(learn.calledOnceWithExactly(config)).to.equal(true); - expect(add.calledOnceWithExactly(config)).to.equal(true); - expect(learn.calledBefore(add)).to.equal(true); - expect(hub.consensus.propose.calledOnceWithExactly(config)).to.equal(true); - }); -}); From bfea780edf7cbaab90a43b8d5ada566f27ab4c70 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 22:34:28 -0700 Subject: [PATCH 29/57] Learn hub network from pushed config --- src/api/rpc/system.js | 1 + src/hub/config_params.js | 26 ++++ .../api/system_updateconfig_network.test.js | 143 ++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 test/unit/api/system_updateconfig_network.test.js diff --git a/src/api/rpc/system.js b/src/api/rpc/system.js index 126813b26..10794e529 100644 --- a/src/api/rpc/system.js +++ b/src/api/rpc/system.js @@ -47,6 +47,7 @@ function systemReads(ctx) { async updateconfig({config}){ try { + hub.learnNetworkFromConfig(config); await hub.addParametersFromJson(config); return {status: "success"}; } catch (err) { diff --git a/src/hub/config_params.js b/src/hub/config_params.js index efc252b53..03c56202f 100644 --- a/src/hub/config_params.js +++ b/src/hub/config_params.js @@ -42,6 +42,11 @@ const logger = getLogger(); const PARAMETER_LIST = ["host", "port", "service_port", "db_host", "db_port", "name", "user", "pass", "self_sync", "hub_url"]; const OPERATIONAL_PARAMS = new Set(["GAS_PRICE", "ACTIVATION_DELAY_BLOCKS", "EXPIRATION_FEE_PER_DAY"]); const JSON_BLOB_PARAMS = new Set(["GAS_SCHEDULE", "STAKING"]); +const HUB_NETWORKS = new Set(["mainnet", "testnet", "regtest"]); + +function isConfigObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} // The string-valued parameters of one (coin, network, module): the transport // keys every module carries, then the operational ones. @@ -119,6 +124,27 @@ function collectJsonBlobRows(rows, moduleLevel, nextCoin, nextNetwork, nextModul class ConfigParams { + // Infer only when every object-valued network slot names the same recognized + // network. Preserve explicit settings and refuse ambiguous input. + learnNetworkFromConfig(json){ + if(this.network) return this.network; + if(!isConfigObject(json)) return this.network; + + let learnedNetwork = null; + for(const coinLevel of Object.values(json)){ + if(!isConfigObject(coinLevel)) continue; + for(const [network, networkLevel] of Object.entries(coinLevel)){ + if(!isConfigObject(networkLevel)) continue; + if(!HUB_NETWORKS.has(network)) return this.network; + if(learnedNetwork !== null && learnedNetwork !== network) return this.network; + learnedNetwork = network; + } + } + + if(learnedNetwork !== null) this.network = learnedNetwork; + return this.network; + } + async addParametersFromJson(json){ if(this.consensus){ await this.consensus.propose(json); diff --git a/test/unit/api/system_updateconfig_network.test.js b/test/unit/api/system_updateconfig_network.test.js new file mode 100644 index 000000000..322b17c72 --- /dev/null +++ b/test/unit/api/system_updateconfig_network.test.js @@ -0,0 +1,143 @@ +'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. + +const sinon = require('sinon'); +const { expect } = require('chai'); +const ConfigParams = require('../../../src/hub/config_params.js'); +const { buildSystemRpc } = require('../../../src/api/rpc/system.js'); + +function configFor(network, coin) { + return { + [coin || 'bitcoin']: { + [network]: {} + } + }; +} + +function emptyHub() { + const hub = new ConfigParams(); + hub.network = ''; + return hub; +} + +function restoreSinon() { + sinon.restore(); +} + +function testSharedNetworkAdoption() { + const hub = emptyHub(); + const config = Object.assign( + configFor('testnet', 'bitcoin'), + configFor('testnet', 'dogecoin'), + configFor('testnet', 'litecoin') + ); + + expect(hub.learnNetworkFromConfig(config)).to.equal('testnet'); + expect(hub.network).to.equal('testnet'); +} + +function testEveryRecognizedNetwork() { + for(const network of ['mainnet', 'testnet', 'regtest']){ + const hub = emptyHub(); + hub.learnNetworkFromConfig(configFor(network)); + expect(hub.network).to.equal(network); + } +} + +function testNonObjectMetadata() { + const hub = emptyHub(); + const config = configFor('regtest'); + config.bitcoin.label = 'local development'; + config.bitcoin.disabled = false; + config.bitcoin.optional = null; + + hub.learnNetworkFromConfig(config); + expect(hub.network).to.equal('regtest'); +} + +function testEmptyOrNonObjectConfig() { + for(const config of [{}, null, [], 'regtest']){ + const hub = emptyHub(); + hub.learnNetworkFromConfig(config); + expect(hub.network).to.equal(''); + } +} + +function testUnknownNetworkRefusal() { + const hub = emptyHub(); + hub.learnNetworkFromConfig(configFor('signet')); + expect(hub.network).to.equal(''); +} + +function testRecognizedAndUnknownRefusal() { + const hub = emptyHub(); + const config = Object.assign( + configFor('mainnet', 'bitcoin'), + configFor('production', 'dogecoin') + ); + + hub.learnNetworkFromConfig(config); + expect(hub.network).to.equal(''); +} + +function testMixedNetworkRefusal() { + const hub = emptyHub(); + const config = Object.assign( + configFor('mainnet', 'bitcoin'), + configFor('testnet', 'dogecoin') + ); + + hub.learnNetworkFromConfig(config); + expect(hub.network).to.equal(''); +} + +function testConfiguredNetworkPreservation() { + const hub = emptyHub(); + hub.network = 'mainnet'; + + expect(hub.learnNetworkFromConfig(configFor('regtest'))).to.equal('mainnet'); + expect(hub.network).to.equal('mainnet'); +} + +async function testLearnBeforeProposal() { + const hub = emptyHub(); + hub.consensus = { + propose: sinon.stub().callsFake(async function assertLearnedNetwork() { + expect(hub.network).to.equal('regtest'); + }) + }; + const learn = sinon.spy(hub, 'learnNetworkFromConfig'); + const add = sinon.spy(hub, 'addParametersFromJson'); + const config = configFor('regtest'); + + expect(await buildSystemRpc({ hub }).updateconfig({ config })) + .to.deep.equal({ status: 'success' }); + expect(learn.calledOnceWithExactly(config)).to.equal(true); + expect(add.calledOnceWithExactly(config)).to.equal(true); + expect(learn.calledBefore(add)).to.equal(true); + expect(hub.consensus.propose.calledOnceWithExactly(config)).to.equal(true); +} + +function registerNetworkLearningTests() { + afterEach(restoreSinon); + it('adopts a recognized network shared by every coin', testSharedNetworkAdoption); + it('can adopt each recognized network', testEveryRecognizedNetwork); + it('ignores non-object metadata when object slots agree', testNonObjectMetadata); + it('leaves the network empty for empty or non-object config', testEmptyOrNonObjectConfig); + it('refuses an unknown object-valued network key', testUnknownNetworkRefusal); + it('refuses a recognized network paired with an unknown network', testRecognizedAndUnknownRefusal); + it('refuses mixed recognized networks', testMixedNetworkRefusal); + it('preserves an already configured network', testConfiguredNetworkPreservation); + it('learns the network before proposing the config', testLearnBeforeProposal); +} + +describe('system updateconfig network learning', registerNetworkLearningTests); From c408f2689689ede7077722e5b60b78fcc75f0beb Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 10:29:31 -0700 Subject: [PATCH 30/57] fix(consensus): re-land the mirror admission boundary correction in the hub twin Byte-identical to the indexer's canonical gate. The first propagation (a3bc6a2b) was reverted by 6dfd0f1c while an xchain-vm lockfile mismatch blocked the other legs; both lockfiles now read 0.20.0, so the twin, its suites and both pins land with the indexer leg. Suite: 6578 passing, 0 failing. --- bin/pins/at1-consensus-identity.json | 2 +- bin/pins/carrier-logic.json | 9 +++++- src/consensus/gates/mirror_admission_gate.js | 31 +++++++----------- .../price_admission_wire_carrier.test.js | 7 ++-- ...price_admission_wire_carrier_batch.test.js | 14 +++++--- ...ce_admission_wire_carrier_producer.test.js | 8 +++-- .../price_v0_canonical_admission.test.js | 32 ++++++++++--------- ...2_payload_twin_parity_single_round.test.js | 14 ++++---- .../shared/admission/admission_height.test.js | 22 ++++++------- .../admission_height_runtime.test.js | 21 ++++++------ 10 files changed, 87 insertions(+), 73 deletions(-) diff --git a/bin/pins/at1-consensus-identity.json b/bin/pins/at1-consensus-identity.json index 481dc911f..dd1f1eff3 100644 --- a/bin/pins/at1-consensus-identity.json +++ b/bin/pins/at1-consensus-identity.json @@ -55,5 +55,5 @@ "mainnet" ], "hub_schema_version": 7, - "carrier_logic_digest": "48f3d471e54ceb2082861bdcd6ece30d68d42375feb18153cf8f9bcda4ee165d" + "carrier_logic_digest": "377fd6d83e83872da5effecb58f24e6c39188f7d13ba63fd5ce433ea4d02b4b3" } diff --git a/bin/pins/carrier-logic.json b/bin/pins/carrier-logic.json index 912e932ee..d5d439348 100644 --- a/bin/pins/carrier-logic.json +++ b/bin/pins/carrier-logic.json @@ -42,7 +42,7 @@ }, "mirror_admission_activation": { "path": "src/consensus/gates/mirror_admission_gate.js", - "hash": "ff9dacf3d0abc1037361c4265f5a324d1bc0965b078e528df55da4320f981a85", + "hash": "d59a0c44b7d0dc1dbc5f1530d67e012a7dac33f1824960565a9788b75cff6a25", "twins": [] }, "oracle_clamp_reference_activation": { @@ -630,6 +630,13 @@ "to": "26894199bd6651dbedcc962825eb63aea91b0a5e7d1d8d3e28eaddf73ce69415", "reason": "W5 consolidation (row 21)", "date": "2026-09-16" + }, + { + "id": "mirror_admission_activation", + "from": "ff9dacf3d0abc1037361c4265f5a324d1bc0965b078e528df55da4320f981a85", + "to": "d59a0c44b7d0dc1dbc5f1530d67e012a7dac33f1824960565a9788b75cff6a25", + "reason": "canonical admission boundary fix, operator ruling (a) 2026-09-19", + "date": "2026-09-19" } ] } diff --git a/src/consensus/gates/mirror_admission_gate.js b/src/consensus/gates/mirror_admission_gate.js index 71492a232..ba343a97b 100644 --- a/src/consensus/gates/mirror_admission_gate.js +++ b/src/consensus/gates/mirror_admission_gate.js @@ -364,20 +364,19 @@ function isAdmissionEra(network, eraBlock){ } /** - * The canonical tail for a row's admission map: '' below the activation, and '|' plus the - * encoded map at or above it. + * The canonical tail for a row's admission map: '' unless both the row is at or above the + * activation and the map is present, and '|' plus the encoded map when both are true. * - * REFUSES IN BOTH DIRECTIONS, exactly as AttestationConsensus._buildCanonical does for - * the mirror era. Building a legacy canonical for a modern row strands the row (its - * signatures reproduce over bytes no verifier rebuilds); building a modern canonical for - * a legacy row forks a from-genesis replay. Neither can be recovered from downstream, so - * both throw where the caller that got it wrong is still on the stack. + * A version seam can expose either an admission map to an inert node or a legacy row to an + * armed node. Both cases take the legacy byte path so mixed versions can bind the row and + * let quorum verification decide whether its signatures match. Only an armed node with a + * present map emits admission bytes. * * No per-rail canonical VERSION field is minted for this, and none exists anywhere in the * tree: this height-gated era check IS the versioning, and a version integer would * duplicate the gate while giving a Byzantine leader a second field to disagree about. * - * @param {string} label the builder's canonical tag, for the refusal message + * @param {string} label the builder's canonical tag, retained for the stable caller API * @param {string} network the row's network, half the activation key * @param {number} eraBlock the ROW's own BTC block * @param {object|null} map the row's admission map, or null for a legacy row @@ -390,22 +389,16 @@ function admissionCanonicalField(label, network, eraBlock, map){ /** * The admission map as a canonical VALUE rather than a pipe-appended tail: null below the - * activation, the encoded map at or above it, with the same two refusals as the field - * form. This is the spelling a JSON-shaped canonical (the PRICE batch) carries under its - * own key, where a '|' tail would be a byte inside a string rather than a delimiter. One - * era gate for both spellings, so the two carriers can never disagree about which era a - * row is in. + * activation or when the map is absent, and the encoded map when the era is armed and the + * map is present. This is the spelling a JSON-shaped canonical (the PRICE batch) carries + * under its own key, where a '|' tail would be a byte inside a string rather than a + * delimiter. One era gate for both spellings keeps their boundary semantics identical. */ function admissionCanonicalValue(label, network, eraBlock, map){ let era = isAdmissionEra(network, eraBlock); let has = (map !== null && map !== undefined); - if(era && !has) - throw new Error(label + ': admission-era row at block ' + String(eraBlock) + ' on ' + String(network) + - ' has no admit_blocks; refusing to build a legacy canonical'); - if(!era && has) - throw new Error(label + ': legacy-era row at block ' + String(eraBlock) + ' on ' + String(network) + - ' was handed admit_blocks ' + JSON.stringify(map) + '; refusing to build an admission-era canonical'); if(!era) return null; + if(!has) return null; return encodeAdmitBlocks(map); } diff --git a/test/unit/oracle/price/price_admission_wire_carrier.test.js b/test/unit/oracle/price/price_admission_wire_carrier.test.js index ed8bdd4a1..11afccc0f 100644 --- a/test/unit/oracle/price/price_admission_wire_carrier.test.js +++ b/test/unit/oracle/price/price_admission_wire_carrier.test.js @@ -282,13 +282,16 @@ function registerEndToEndThroughThe3Tests4() { expect(result.accepted).to.equal(true, 'reason: ' + result.reason); }); - it('REFUSES the identical round when the RPC drops the map', async function () { + it('still does not accept the identical round when the RPC drops the map', async function () { + // The round is rebuilt over legacy bytes instead of being refused at the builder, + // so it fails on its signatures. Not accepted either way, which is the property + // the carrier exists to hold. const controller = await bootApi({ priceAggregator: agg }); const params = signedRound(MAP); delete params.admit_blocks; // the pre-row behaviour, exactly const result = await controller.pushpriceround(params); expect(result.accepted).to.equal(false); - expect(result.reason).to.match(/refusing to build a legacy canonical/); + expect(result.reason).to.not.match(/refusing to build a legacy canonical/); }); it('refuses a map edited in flight, so the carrier cannot be used to rewrite one', async function () { diff --git a/test/unit/oracle/price/price_admission_wire_carrier_batch.test.js b/test/unit/oracle/price/price_admission_wire_carrier_batch.test.js index 1dce7543c..513ad5947 100644 --- a/test/unit/oracle/price/price_admission_wire_carrier_batch.test.js +++ b/test/unit/oracle/price/price_admission_wire_carrier_batch.test.js @@ -245,18 +245,22 @@ function registerThePriceBatchRailCarries2Tests1() { expect(result.rejected).to.equal(2); }); - it('refuses an era batch whose rounds carry NO map, whole, storing nothing', async function () { + it('takes the legacy byte path for an era batch whose rounds carry NO map, storing nothing', async function () { + // A version seam is not a fault. The batch is rebuilt over legacy bytes and quorum + // verification decides it, so it is refused on its signatures rather than on an era + // complaint. Nothing lands either way, which is the property that still matters. const result = await aggOn(NETWORK).receiveValidatedBatch('BTC', batchOn(ADMIT_AT + 1)); expect(result.accepted).to.equal(false); - expect(result.reason).to.match(/admission map does not match the round's era/); + expect(result.reason).to.not.match(/admission map does not match the round's era/); expect(result.stored).to.equal(0); - expect(result.rejected).to.equal(2); }); - it('refuses a LEGACY batch that was handed a map, the other direction', async function () { + it('takes the legacy byte path for a LEGACY batch that was handed a map, the other direction', async function () { + // An activation-inert consumer treats admit_blocks as absent even when a newer + // producer supplied it, so the map is ignored and the signatures decide. const result = await aggOn(NETWORK).receiveValidatedBatch('BTC', batchOn(LEGACY_AT, [MAP5, MAP6])); expect(result.accepted).to.equal(false); - expect(result.reason).to.match(/admission map does not match the round's era/); + expect(result.reason).to.not.match(/admission map does not match the round's era/); }); it('refuses a map the encoder cannot spell rather than throwing on it', async function () { diff --git a/test/unit/oracle/price/price_admission_wire_carrier_producer.test.js b/test/unit/oracle/price/price_admission_wire_carrier_producer.test.js index 244081221..b7e345c65 100644 --- a/test/unit/oracle/price/price_admission_wire_carrier_producer.test.js +++ b/test/unit/oracle/price/price_admission_wire_carrier_producer.test.js @@ -251,9 +251,11 @@ function registerTheBatchProducerCarriesEach2Tests1() { expect(legacy.join('|')).to.not.match(/BTC:/); }); - it('refuses to emit a wire for an era round with no map, so nothing its verifiers would refuse is ever spent on', function () { - expect(() => pub.buildPriceBatchBody(5, 6, ADMIT_AT + 1, rounds(ADMIT_AT), SIGS)) - .to.throw(/has no admit_blocks; refusing to build a legacy canonical/); + it('emits a LEGACY wire for an era round with no map, leaving the verifiers to decide it', function () { + // An armed producer with no map in hand spells the legacy bytes rather than refusing, + // so a mixed-version fleet can still bind the round. + const body = pub.buildPriceBatchBody(5, 6, ADMIT_AT + 1, rounds(ADMIT_AT), SIGS); + expect(body).to.not.match(/BTC:/); }); it('splits a window at the activation, so no batch straddles it', function () { diff --git a/test/unit/oracle/price/price_v0_canonical_admission.test.js b/test/unit/oracle/price/price_v0_canonical_admission.test.js index 1ee02f76a..e14d0db26 100644 --- a/test/unit/oracle/price/price_v0_canonical_admission.test.js +++ b/test/unit/oracle/price/price_v0_canonical_admission.test.js @@ -157,16 +157,16 @@ function builtBy(height, map, network) { expect(b.producer.endsWith('}')).to.equal(true); } - function allThreeRefuseAMapOnTest7() { - const b = three(LEGACY_AT, admitMap()); - for (const [name, build] of [['producer', b.producer], ['ingest', b.ingest], ['indexer', b.indexer]]) - expect(build, name).to.throw(/refusing to build an admission-era canonical/); + function allThreeIgnoreAMapOnTest7() { + // An inert consumer treats a supplied map as absent, so the legacy bytes stand. + const plain = builtBy(LEGACY_AT, undefined), b = builtBy(LEGACY_AT, admitMap()); + for (const k of ['producer', 'ingest', 'indexer']) assert.strictEqual(b[k], plain[k], k); } function belowTheActivationTheLegacyBytesSuite4() { it('all three emit the identical canonical, with no admission field', allThreeEmitTheIdenticalCanonicalTest5); it('a mainnet round is legacy at a height far above the armed regtest one', aMainnetRoundIsLegacyAtTest6); - it('all three REFUSE a map on a legacy round rather than signing bytes no era reads', allThreeRefuseAMapOnTest7); + it('all three IGNORE a map on a legacy round and sign the untouched legacy bytes', allThreeIgnoreAMapOnTest7); } registerbelowTheActivationTheLegacyBytes3 = function registerSuite() { @@ -229,12 +229,13 @@ function builtBy(height, map, network) { assert.strictEqual(b.indexer, b.producer); } - function allThreeRefuseARoundWithTest14() { - for (const missing of [null, undefined]) { - const b = three(ADMIT_AT, missing); - for (const [name, build] of [['producer', b.producer], ['ingest', b.ingest], ['indexer', b.indexer]]) - expect(build, name + ' with ' + String(missing)).to.throw(/refusing to build a legacy canonical/); - } + function allThreeUseLegacyBytesWhenTest14() { + // Armed with no map in hand a consumer binds the legacy bytes rather than refusing. + // The with-map build is compared alongside: equal spellings would pass vacuously. + const withMap = builtBy(ADMIT_AT, admitMap()); + for (const b of [builtBy(ADMIT_AT, null), builtBy(ADMIT_AT, undefined)]) + for (const k of ['producer', 'ingest', 'indexer']) + assert.strictEqual(b[k].endsWith('}') && b[k] !== withMap[k], true, k + ' did not use legacy bytes'); } function allThreeRefuseAMapTheTest15() { @@ -251,7 +252,7 @@ function builtBy(height, map, network) { it('appends the field after the JSON body and inside the EQUIV wrapper', appendsTheFieldAfterTheJsonTest11); it('the map insertion order never reaches the bytes, on any of the three', theMapInsertionOrderNeverReachesTest12); it('one changed height changes the signed bytes on all three, and they still agree', oneChangedHeightChangesTheSignedTest13); - it('all three REFUSE a round with no map, rather than signing legacy bytes above the era', allThreeRefuseARoundWithTest14); + it('all three sign the legacy bytes for a round with no map above the era', allThreeUseLegacyBytesWhenTest14); it('all three refuse a map the encoding cannot spell injectively', allThreeRefuseAMapTheTest15); } @@ -310,12 +311,13 @@ function builtBy(height, map, network) { expect(result.reason).to.match(/admission map unusable/); } - async function rejectsAnAdmissionEraRoundThatTest20() { + async function doesNotAcceptAnAdmissionEraRoundTest20() { + // Rebuilt over legacy bytes rather than refused at the builder: signatures decide. const r = round(); delete r.admit_blocks; const result = await agg.receiveValidatedRound('BTC', r); expect(result.accepted).to.equal(false); - expect(result.reason).to.match(/refusing to build a legacy canonical/); + expect(result.reason).to.not.match(/refusing to build a legacy canonical/); } async function refusesTheSignaturesWhenThePushedTest21() { @@ -358,7 +360,7 @@ function builtBy(height, map, network) { afterEach(function () { sinon.restore(); }); it('accepts a round whose signatures cover the admission-era canonical', acceptsARoundWhoseSignaturesCoverTest18); it('rejects, and does not throw, when the pushed map is unspellable', rejectsAndDoesNotThrowWhenTest19); - it('rejects an admission-era round that carries no map at all', rejectsAnAdmissionEraRoundThatTest20); + it('does not accept an admission-era round that carries no map at all', doesNotAcceptAnAdmissionEraRoundTest20); it('refuses the signatures when the pushed map is not the one that was signed', refusesTheSignaturesWhenThePushedTest21); } diff --git a/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js b/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js index 8c3b20b28..10f6ed50d 100644 --- a/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js +++ b/test/unit/oracle/price/price_v2_payload_twin_parity_single_round.test.js @@ -230,15 +230,17 @@ function loadIndexerTwin(ctx) { assert.strictEqual(armed.indexer.buildPriceBatchPayload(FIRST, LAST, LEGACY_AT, legacy, NETWORK), fromProducer); } - function everyTwinRefusesInBothDirectionsTest17() { + function everyTwinUsesLegacyBytesTest17() { + // Both directions of the version seam take the legacy byte path. An armed twin with + // no map emits no admission field, and an inert twin handed one ignores it. let twins = [['producer', (r, a) => armed.producer.buildPriceBatchPayload(FIRST, LAST, a, r)], ['ingest', (r, a) => armed.ingest.buildPriceBatchPayload(FIRST, LAST, a, r)]]; if (armed.indexer) twins.push(['indexer', (r, a) => armed.indexer.buildPriceBatchPayload(FIRST, LAST, a, r, NETWORK)]); for (const [name, build] of twins) { - assert.throws(() => build(rounds(ADMIT_AT), ADMIT_AT + 1), /has no admit_blocks; refusing to build a legacy canonical/, - name + ' built legacy bytes for an era round'); - assert.throws(() => build(rounds(LEGACY_AT - 1, MAPS), LEGACY_AT), /was handed admit_blocks .*; refusing to build an admission-era canonical/, - name + ' built era bytes for a legacy round'); + assert.strictEqual(/admit/.test(build(rounds(ADMIT_AT), ADMIT_AT + 1)), false, + name + ' emitted admission bytes for an era round with no map'); + assert.strictEqual(/admit/.test(build(rounds(LEGACY_AT - 1, MAPS), LEGACY_AT)), false, + name + ' emitted admission bytes for a legacy round handed a map'); } } @@ -254,7 +256,7 @@ function loadIndexerTwin(ctx) { function theBatchCanonicalCarriesOneAdmissionSuite14() { it('the hub twins agree, and the indexer verifier with them, in the admission era', theHubTwinsAgreeAndTheTest15); it('below the activation the bytes are the pre-admission form exactly, on all twins', belowTheActivationTheBytesAreTest16); - it('every twin refuses in both directions: an era round with no map, a legacy round with one', everyTwinRefusesInBothDirectionsTest17); + it('every twin uses legacy bytes in both directions: an era round with no map, a legacy round with one', everyTwinUsesLegacyBytesTest17); it('the map is keyed on EACH round\'s own anchor: a straddling window carries it on the era round only', theMapIsKeyedOnEachTest18); } diff --git a/test/unit/shared/admission/admission_height.test.js b/test/unit/shared/admission/admission_height.test.js index 9940b7a6e..cbb1167f4 100644 --- a/test/unit/shared/admission/admission_height.test.js +++ b/test/unit/shared/admission/admission_height.test.js @@ -353,11 +353,11 @@ describe('admission_height: the era gate, INERT', () => { expect(armed.ah.admissionCanonicalField('XTEST', REGTEST, 1000, null)).to.equal(''); }); - it('refuses to build an ADMISSION canonical for a legacy-era row', () => { - expect(() => armed.ah.admissionCanonicalField('XTEST', 'mainnet', 1000, { BTC: 1004 })) - .to.throw(/refusing to build an admission-era canonical/); - expect(() => armed.ah.admissionCanonicalField('XTEST', REGTEST, 1000, { BTC: 1004 })) - .to.throw(/refusing to build an admission-era canonical/); + it('builds NO field for a legacy-era row that was handed a map, ignoring it', () => { + // An activation-inert consumer treats admit_blocks as absent even when a newer producer + // supplied it, so the legacy bytes stay byte for byte what every peer rebuilds. + expect(armed.ah.admissionCanonicalField('XTEST', 'mainnet', 1000, { BTC: 1004 })).to.equal(''); + expect(armed.ah.admissionCanonicalField('XTEST', REGTEST, 1000, { BTC: 1004 })).to.equal(''); }); }); @@ -378,13 +378,11 @@ describe('admission_height: the era gate, ARMED at a regtest height', () => { expect(armed.ah.isAdmissionEra('testnet', ERA_AT)).to.equal(false); }); - it('refuses to build a LEGACY canonical for an admission-era row', () => { - // The branch under test is the refusal, and it is the one that strands a row: a - // modern row signed over pre-admission bytes reproduces for no verifier in the fleet. - expect(() => armed.ah.admissionCanonicalField('XTEST', REGTEST, ERA_AT, null)) - .to.throw(/refusing to build a legacy canonical/); - expect(() => armed.ah.admissionCanonicalField('XTEST', REGTEST, ERA_AT, undefined)) - .to.throw(/refusing to build a legacy canonical/); + it('builds NO field for an admission-era row with no map, taking the legacy path', () => { + // Only an armed consumer WITH a present map emits admission bytes. Armed with nothing + // in hand it spells the legacy bytes and lets quorum verification decide. + expect(armed.ah.admissionCanonicalField('XTEST', REGTEST, ERA_AT, null)).to.equal(''); + expect(armed.ah.admissionCanonicalField('XTEST', REGTEST, ERA_AT, undefined)).to.equal(''); }); it('appends the field after a single pipe when the era is active', () => { diff --git a/test/unit/shared/admission/admission_height_runtime.test.js b/test/unit/shared/admission/admission_height_runtime.test.js index e968e3816..2e0920bf7 100644 --- a/test/unit/shared/admission/admission_height_runtime.test.js +++ b/test/unit/shared/admission/admission_height_runtime.test.js @@ -162,9 +162,10 @@ describe('at and above the activation', () => { before(() => { armed = withAdmissionActivation(ERA_AT); }); after(() => { armed.restore(); }); - it('the match builder refuses to build LEGACY bytes for an admission-era row', () => { - expect(() => armed.DEX.call({}, matchRow(), 0)) - .to.throw(/CrossChainDex.*refusing to build a legacy canonical/); + it('the match builder builds LEGACY bytes for an admission-era row with no map', () => { + // Armed with no map in hand, the builder spells the legacy bytes. + let raw = armed.DEX.call({}, matchRow(), 0); + expect(raw).to.not.match(/BTC:/); }); it('an admission-era match canonical ends with the ASCII-ordered map', () => { @@ -211,14 +212,16 @@ describe('below the activation', () => { expect(raw.split('|').pop()).to.equal('0'); // b_filled_before, the old last field }); - it('the match builder refuses an admission map on a legacy-era row', () => { - expect(() => inert.DEX.call({}, matchRow({ network: 'mainnet', admit_blocks: { BTC: 1004, DOGE: 2004 } }), 0)) - .to.throw(/CrossChainDex.*refusing to build an admission-era canonical/); + it('the match builder ignores an admission map on a legacy-era row', () => { + // Inert means absent, whatever a newer producer put on the row. + let raw = inert.DEX.call({}, matchRow({ network: 'mainnet', admit_blocks: { BTC: 1004, DOGE: 2004 } }), 0); + expect(raw).to.not.match(/BTC:/); }); - it('the policy builder refuses an admission map on a legacy-era row', () => { - expect(() => inert.BRIDGE.call({}, policyRow({ network: 'mainnet', admit_block_btc: 1004 }), 0)) - .to.throw(/CrossChainPolicy.*refusing to build an admission-era canonical/); + it('the policy builder ignores an admission map on a legacy-era row', () => { + // The policy carrier reads the same era gate as the match carrier. + let raw = inert.BRIDGE.call({}, policyRow({ network: 'mainnet', admit_block_btc: 1004 }), 0); + expect(raw).to.not.match(/BTC:/); }); }); } From 28c0acd7b4eff9c241d78e15007170db09df685e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 13:40:08 -0700 Subject: [PATCH 31/57] fix(tooling): consensus-identity rejects an unknown flag with exit 2 --- bin/consensus-identity.js | 3 +++ test/unit/repo_guards/consensus_identity_cli.test.js | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/bin/consensus-identity.js b/bin/consensus-identity.js index a68c8bb9a..4156d5fb0 100644 --- a/bin/consensus-identity.js +++ b/bin/consensus-identity.js @@ -225,6 +225,9 @@ function parseArgs(argv) { else if (argv[i] === '--out') { opts.out = path.resolve(argv[i + 1]); i += 1; } else if (argv[i] === '--compare') { opts.compare = path.resolve(argv[i + 1]); i += 1; } else if (argv[i] === '--help' || argv[i] === '-h') opts.help = true; + // A misspelt flag would otherwise run as the default reading and exit 0, + // which a caller checking --assert-no-absent would take as a pass. + else throw new Error(`unknown flag ${argv[i]}`); } return opts; } diff --git a/test/unit/repo_guards/consensus_identity_cli.test.js b/test/unit/repo_guards/consensus_identity_cli.test.js index 6a9aecdca..9de9dc659 100644 --- a/test/unit/repo_guards/consensus_identity_cli.test.js +++ b/test/unit/repo_guards/consensus_identity_cli.test.js @@ -45,4 +45,11 @@ describe('bin/consensus-identity.js: top-level error handling', function () { // one-liner carries none, so this is the line that tells the two apart. expect(res.stderr).to.not.match(/^\s+at /m); }); + + it('exits 2 naming an unknown flag instead of ignoring it', function () { + const res = run(['--assert-no-absnet']); + expect(res.status).to.equal(2); + expect(res.stderr).to.match(/^consensus-identity: unknown flag --assert-no-absnet/); + expect(res.stdout).to.equal(''); + }); }); From 6eda949d989b2db25474b3d1a0c449615eb2d9c8 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 14:14:12 -0700 Subject: [PATCH 32/57] build(hub): nest ajv 6 overrides under eslint and @eslint/eslintrc so eslint loads --- package-lock.json | 75 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 8 ++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index e988f9e80..2493cb32e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -703,6 +703,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "9.39.5", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", @@ -2536,6 +2560,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -2784,6 +2832,13 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", @@ -4377,6 +4432,16 @@ "resolve": "^1.11.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", @@ -5219,6 +5284,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/package.json b/package.json index f8e75526c..2fc761e6d 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,13 @@ "ajv": "^8.17.2", "decimal.js": "10.4.3", "js-yaml": "^4.3.1", - "form-data": "^4.0.6" + "form-data": "^4.0.6", + "eslint": { + "ajv": "^6.14.0" + }, + "@eslint/eslintrc": { + "ajv": "^6.14.0" + } }, "engines": { "node": ">=22.12.0" From 65655777d8e9613fe12a8ab48135069772571cdc Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 15:08:08 -0700 Subject: [PATCH 33/57] fix(attestation): claim a batch window atomically before broadcasting --- src/attestation/batch_publisher/window.js | 33 ++- ...isher_014_one_broadcast_per_window.test.js | 197 ++++++++++++++++++ 2 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 test/unit/attestation/publisher/batch/attestation_batch_publisher_014_one_broadcast_per_window.test.js diff --git a/src/attestation/batch_publisher/window.js b/src/attestation/batch_publisher/window.js index 2108c8605..5d4c597a8 100644 --- a/src/attestation/batch_publisher/window.js +++ b/src/attestation/batch_publisher/window.js @@ -211,7 +211,38 @@ module.exports = { return false; } - return await this.broadcastWindow(window, batchKey, encoded); + // Claim at broadcastWindow's existing pre-send intent point. The unique + // (network, window_start) marker is the cross-publisher mutex: only the INSERT + // that creates it may send. A marker read followed by a send is not sufficient, + // because several validators can all complete that read before any one sends. + let claimed = Object.create(this); + claimed.recordIntent = async (candidate, key) => { + if(!(await this.claimWindowForBroadcast(candidate, key))){ + let error = new Error('window already has a durable publication claim'); + error.code = 'ATTEST_BATCH_WINDOW_CLAIMED'; + throw error; + } + }; + return await this.broadcastWindow.call(claimed, window, batchKey, encoded); + }, + + // Atomically acquire the durable pre-send marker. setAttestPublishedBatchByNetwork + // uses INSERT with a duplicate-key no-op, so affectedRows separates the sole + // creator from every competing publisher. This runs inside broadcastWindow after + // its pipeline, balance and spend reservations have passed, preserving the rule + // that a window unable to attempt a send leaves no intent marker. + async claimWindowForBroadcast(window, batchKey){ + let db = this.hubDb(); + if(!db || typeof db.doQuery !== 'function') + throw new Error('no hub DB for durable batch-window claim'); + + // A tracking row is a coverage floor, not a publication claim. The guarded + // delete cannot remove an intent or outcome installed by a competing path. + await db.deleteAttestPublishedBatch(this.network, window.window_start, 'tracking'); + let result = await db.setAttestPublishedBatchByNetwork( + this.network, window.window_start, window.window_end, + batchKey, window.row_count, 'intent'); + return !!(result && Number(result.affectedRows) === 1); }, // ------------------------------------------------------------ the mirror read diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_014_one_broadcast_per_window.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_014_one_broadcast_per_window.test.js new file mode 100644 index 000000000..5b12ec922 --- /dev/null +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_014_one_broadcast_per_window.test.js @@ -0,0 +1,197 @@ +/********************************************************************* + * + * 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 Hub - one durable claim and one broadcast per ATTEST batch window. + * + ********************************************************************/ + +'use strict'; + +const os = require('os'); +const fs = require('fs'); +const path = require('path'); +const { expect } = require('chai'); + +const AttestationBatchPublisher = require('../../../../../src/attestation/batch_publisher.js'); +const ValidatorIdentity = require('../../../../../src/validators/identity.js'); +const abw = require('../../../../../src/lib/attest_batch_wire.js'); +const { DB_METHODS } = require('../../../../helpers/mockHub.js'); + +const WINDOW_S = 10; +const ANCHOR = 941234; + +// A shared durable marker table. INSERT executes synchronously before the returned +// promise resolves, matching the unique-key decision made by MariaDB. +function makeDb(){ + let markers = []; + let insertAttempts = 0; + return { ...DB_METHODS, + markers, + get insertAttempts(){ return insertAttempts; }, + marker(windowStart){ + return markers.find(m => Number(m.window_start) === Number(windowStart)) || null; + }, + async getChainTip(){ return { blockHeight: ANCHOR, blockTime: 1 }; }, + async doQuery(sql, args){ + if(/^DELETE FROM attest_published_batches/i.test(sql)){ + let [network, windowStart, fromStatus] = args; + let i = markers.findIndex(m => m.network === network && + Number(m.window_start) === Number(windowStart) && m.status === fromStatus); + if(i < 0) return { affectedRows: 0 }; + markers.splice(i, 1); + return { affectedRows: 1 }; + } + if(/^INSERT INTO attest_published_batches/i.test(sql)){ + insertAttempts++; + let [network, windowStart, windowEnd, batchKey, rowCount, status] = args; + let existing = markers.find(m => m.network === network && + Number(m.window_start) === Number(windowStart)); + if(existing) return { affectedRows: 0 }; + markers.push({ network, window_start: windowStart, window_end: windowEnd, + batch_key: batchKey, row_count: rowCount, status, txid: null }); + return { affectedRows: 1 }; + } + if(/^UPDATE attest_published_batches SET status/i.test(sql)){ + let [status, txid, rowCount, network, windowStart, fromStatus] = args; + let marker = markers.find(m => m.network === network && + Number(m.window_start) === Number(windowStart) && m.status === fromStatus); + if(!marker) return { affectedRows: 0 }; + Object.assign(marker, { status, txid, row_count: rowCount }); + return { affectedRows: 1 }; + } + if(/FROM attest_published_batches WHERE network = \? AND window_start = \?/i.test(sql)){ + let marker = markers.find(m => m.network === args[0] && + Number(m.window_start) === Number(args[1])); + return marker ? [Object.assign({}, marker)] : []; + } + throw new Error('unexpected statement: ' + sql); + } + }; +} + +function makeHub(db, identity, dir, index){ + let pubkey = identity.getPubkeyHex().toLowerCase(); + let snapshot = { + validators: [{ pubkey, weight: '100', amount: '100', source: 'validator-' + index }], + count: 1 + }; + return { + network: 'regtest', + db, + p2pConfig: { + ATTEST_BATCH_WINDOW_S_OVERRIDE: String(WINDOW_S), + ATTEST_BATCH_BUFFER_PATH: path.join(dir, 'buffer-' + index + '.jsonl'), + ATTEST_BATCH_SPEND_STATE_PATH: path.join(dir, 'spend-' + index + '.json'), + ORACLE_BATCH_SIGN_TIMEOUT_MS: '40' + }, + getIdentity: () => identity, + capabilitySnapshot: { + async getWeightSnapshot(){ return snapshot; }, + async getSnapshot(){ return snapshot; } + }, + peerManager: null + }; +} + +function makePublisher(hub){ + let publisher = new AttestationBatchPublisher(hub); + let wires = []; + publisher.setBroadcastHook(async payload => { + wires.push(payload); + return { txid: 'tx-' + wires.length }; + }); + publisher.wires = wires; + return publisher; +} + +function emptyWindow(){ + let windowStart = 199 * WINDOW_S; + return { + network: 'regtest', + window_start: windowStart, + window_end: windowStart + WINDOW_S, + row_count: 0, + btc_block_height: ANCHOR, + rows: [] + }; +} + +// Hold every publisher after signing so they reach the durable claim as peers, +// rather than letting the first finish before the others start. +function holdAtClaim(publishers){ + let arrived = 0; + let release; + let gate = new Promise(resolve => { release = resolve; }); + for(let publisher of publishers){ + let collect = publisher.collectBatchSignatures.bind(publisher); + publisher.collectBatchSignatures = async (window, batchKey) => { + let signed = await collect(window, batchKey); + arrived++; + if(arrived === publishers.length) release(); + await gate; + return signed; + }; + } +} + +function makeThreePublishers(db, dir){ + return [1, 2, 3].map(index => { + let identity = new ValidatorIdentity(ValidatorIdentity.generate().privkeyHex); + return makePublisher(makeHub(db, identity, dir, index)); + }); +} + +describe('AttestationBatchPublisher', function () { + let dir; + + beforeEach(function () { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'attest-one-window-')); + }); + + afterEach(function () { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best effort */ } + }); + + describe('one broadcast per window', function () { + it('lets one of three validator publishers claim and broadcast the shared window', async function () { + let db = makeDb(); + let publishers = makeThreePublishers(db, dir); + expect(new Set(publishers.map(p => p.identity.getPubkeyHex())).size).to.equal(3); + holdAtClaim(publishers); + + let window = emptyWindow(); + let batchKey = abw.computeBatchKey(window); + let results = await Promise.all(publishers.map(p => + p.signAndBroadcastWindow(Object.assign({}, window, { rows: [] }), batchKey))); + + expect(results.filter(Boolean).length).to.equal(1); + expect(publishers.reduce((n, p) => n + p.wires.length, 0)).to.equal(1); + expect(db.insertAttempts).to.equal(3); + expect(db.markers).to.have.length(1); + expect(db.marker(window.window_start).status).to.equal('sent'); + }); + + it('does not claim a window when the broadcast pipeline cannot attempt a send', async function () { + let db = makeDb(); + let identity = new ValidatorIdentity(ValidatorIdentity.generate().privkeyHex); + let publisher = new AttestationBatchPublisher(makeHub(db, identity, dir, 1)); + let window = emptyWindow(); + + expect(await publisher.signAndBroadcastWindow( + window, abw.computeBatchKey(window))).to.equal(false); + expect(db.insertAttempts).to.equal(0); + expect(db.marker(window.window_start)).to.equal(null); + }); + }); +}); From 003c337411ef5233bb84e56a2902ddda249faf02 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 19:39:53 -0700 Subject: [PATCH 34/57] fix(anchor): name TTL envelope clamps --- src/anchor/publisher/state.js | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/anchor/publisher/state.js b/src/anchor/publisher/state.js index edab7be65..be4d33b42 100644 --- a/src/anchor/publisher/state.js +++ b/src/anchor/publisher/state.js @@ -25,6 +25,8 @@ const coins = require('../../coins'); const hubConfig = require('../../config'); const { DEFAULT_ANCHOR_MARKER_RETENTION_MS } = require('./constants.js'); +const { getLogger } = require('../../observability'); +const logger = getLogger(); // The anchor-attest arrival budget counts both the durable-intent hold and the // deferred announcement/reward-attestation queue. Its measured 18 h envelope @@ -32,6 +34,18 @@ const { DEFAULT_ANCHOR_MARKER_RETENTION_MS } = require('./constants.js'); // the clamp at the two config reads prevents an operator override from silently // moving the height watermark's worst-case trail past that fixed budget. const MAX_ANCHOR_ATTEST_TTL_MS = 6 * 60 * 60 * 1000; +const ANCHOR_ATTEST_TTL_CLAMP_REASON = 'ANCHOR_ATTEST_WRITE_LAG_ENVELOPE'; + +function anchorAttestTtlMs(raw, name) { + let ttlMs = parseInt(raw, 10); + if(ttlMs > MAX_ANCHOR_ATTEST_TTL_MS){ + logger.warn('config: ' + name + '=' + ttlMs + 'ms exceeds the ' + + MAX_ANCHOR_ATTEST_TTL_MS + 'ms anchor-attest write-lag allocation; ' + + 'clamping at boot (reason=' + ANCHOR_ATTEST_TTL_CLAMP_REASON + ').'); + return MAX_ANCHOR_ATTEST_TTL_MS; + } + return ttlMs; +} module.exports = { @@ -227,9 +241,10 @@ module.exports = { // announcement queues. this._deferredRewardAttest = new Map(); this.announceRetryMs = parseInt(hubConfig.ANCHOR_ANNOUNCE_RETRY_MS || cfg.ANCHOR_ANNOUNCE_RETRY_MS || '300000'); // 5 min - this.announceRetryTtlMs = Math.min(parseInt(hubConfig.ANCHOR_ANNOUNCE_RETRY_TTL_MS || - cfg.ANCHOR_ANNOUNCE_RETRY_TTL_MS || '21600000'), - MAX_ANCHOR_ATTEST_TTL_MS); // 6 h, ~6x the 60-conf DOGE window + this.announceRetryTtlMs = anchorAttestTtlMs( + hubConfig.ANCHOR_ANNOUNCE_RETRY_TTL_MS || + cfg.ANCHOR_ANNOUNCE_RETRY_TTL_MS || String(MAX_ANCHOR_ATTEST_TTL_MS), + 'ANCHOR_ANNOUNCE_RETRY_TTL_MS'); // 6 h, ~6x the 60-conf DOGE window this.announceQueueMax = parseInt(hubConfig.ANCHOR_ANNOUNCE_QUEUE_MAX || cfg.ANCHOR_ANNOUNCE_QUEUE_MAX || '500'); this._deferTimer = null; this._rankWakeTimer = null; // failover wake, see rankWakeMs @@ -241,9 +256,10 @@ module.exports = { // same reasoning as announceRetryTtlMs above: ~6x the 60-conf DOGE window, past // which a send that never relayed is not coming back and holding the row costs // more than re-broadcasting it. - this.anchorIntentTtlMs = Math.min(parseInt(hubConfig.ANCHOR_INTENT_TTL_MS || - cfg.ANCHOR_INTENT_TTL_MS || '21600000'), - MAX_ANCHOR_ATTEST_TTL_MS); // 6 h + this.anchorIntentTtlMs = anchorAttestTtlMs( + hubConfig.ANCHOR_INTENT_TTL_MS || + cfg.ANCHOR_INTENT_TTL_MS || String(MAX_ANCHOR_ATTEST_TTL_MS), + 'ANCHOR_INTENT_TTL_MS'); // 6 h // Retention window for the two durable anchor marker tables. Both appended one // row per DOGE-spending broadcast and never removed one, so they grew for the // life of the deployment while their oracle_published_rounds sibling was swept. From b647105ab61f4827b0cd741247d22be2135ed5ac Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 19:48:53 -0700 Subject: [PATCH 35/57] fix(health): report stale admission tips --- src/api/rpc/system.js | 92 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/src/api/rpc/system.js b/src/api/rpc/system.js index 10794e529..55a0311f4 100644 --- a/src/api/rpc/system.js +++ b/src/api/rpc/system.js @@ -23,6 +23,11 @@ const configRedaction = require('../config_redaction.js'); const { DEFAULT_ORACLE_ROUND_INTERVAL_MS } = require('../../constants'); +const { + ADMIT_COLUMN_CHAINS, + ADMIT_MARGIN_BLOCKS, + isMirrorAdmissionProducerActive +} = require('../../consensus/gates/mirror_admission_gate.js'); const { validateChain } = require('../validate'); function buildSystemRpc(ctx) { @@ -185,11 +190,21 @@ function healthRpc(ctx) { ? hub.capabilitySnapshot.monitor.snapshot() : null; if (consensusInput && consensusInput.alerting) healthy = false; + // Admission-height production has a hard per-chain precondition: above + // its activation the hub cannot finalize a mirrored row unless every + // chain in that row's read set has a fresh decoder tip. Probe the full + // federation set here so an operator sees the dependency before a round + // pays for it. The resolver applies the same freshness gate as the + // producer and returns null rather than guessing a height. + let admissionTips = await probeAdmissionTips(hub, p2pConfig, DB_PROBE_TIMEOUT_MS); + if (admissionTips && !admissionTips.healthy) healthy = false; + let published = publisherStats(hub); if(!healthy) res.status(503); return healthBody(hub, configFetchCounters, published, { - healthy, dbOk, dbCircuit, oracleAgeS, oracleStale, oracleThresholdS, consensusInput + healthy, dbOk, dbCircuit, oracleAgeS, oracleStale, oracleThresholdS, + consensusInput, admissionTips }); }, }; @@ -240,6 +255,77 @@ async function probeOracleFreshness(hub, hubConfig, dbOk, p2pConfig, DB_PROBE_TI return { oracleAgeS, oracleStale, oracleThresholdS }; } +// The same per-chain read the producer uses, bounded so /health cannot hang behind +// an indexer. A stale resolver result is null; the last observation is retained only +// as diagnosis and is never promoted back into a usable admission height. +async function probeAdmissionTips(hub, p2pConfig, timeoutMs) { + if (!p2pConfig || !hub || typeof hub.resolveAdmissionTips !== 'function') return null; + + let resolved = {}; + try { + resolved = await Promise.race([ + hub.resolveAdmissionTips(ADMIT_COLUMN_CHAINS.slice()), + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)) + ]) || {}; + } catch (_) { + resolved = {}; + } + + let chains = {}; + let missing = []; + for (let chain of ADMIT_COLUMN_CHAINS) { + let tip = usableAdmissionHeight(resolved[chain]); + let previous = lastAdmissionTip(hub, chain); + let stale = false; + if (tip === null && previous !== null) { + stale = typeof hub.admissionTipFresh === 'function' + ? !hub.admissionTipFresh(chain, previous.height) + : true; + } + if (tip === null) missing.push(chain); + chains[chain] = { + height: tip === null && previous !== null ? previous.height : tip, + observed_at_ms: previous === null ? null : previous.at_ms, + age_s: previous === null || previous.at_ms === null + ? null : Math.max(0, Math.floor((Date.now() - previous.at_ms) / 1000)), + fresh: tip !== null, + stale, + reason: tip !== null ? null : (stale + ? 'admission tip for ' + chain + ' is stale' + : 'no fresh admission tip for ' + chain) + }; + } + + // Signed admission rows use their own BTC anchor for the producer flag day. + // A stale last observation is sufficient to establish that the node has crossed + // the activation, but is diagnostic only and remains unusable above. + let btcHeight = chains.BTC && chains.BTC.height; + let producerActive = usableAdmissionHeight(btcHeight) !== null && + isMirrorAdmissionProducerActive('BTC', hub.network, btcHeight); + return { + producer_active: producerActive, + healthy: !producerActive || missing.length === 0, + reason: producerActive && missing.length > 0 + ? 'no fresh admission tip for ' + missing.join(', ') + '; refusing to finalize admission-era rows' + : null, + admit_margin_blocks: ADMIT_MARGIN_BLOCKS, + chains + }; +} + +function usableAdmissionHeight(value) { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function lastAdmissionTip(hub, chain) { + let seen = hub && hub._admissionTipSeen; + if (!seen || typeof seen.get !== 'function') return null; + let value = seen.get(chain); + if (!value) return null; + let height = usableAdmissionHeight(value.height); + return height === null ? null : { height, at_ms: Number(value.atMs) || null }; +} + // The anchor, attestation and relay stats, read before the status code is set. function publisherStats(hub) { let anchorStats = hub.stateAnchorPublisher ? hub.stateAnchorPublisher.getAnchorStats() : null; @@ -256,7 +342,8 @@ function publisherStats(hub) { // The /health response body; every section past config_fetch is telemetry only. function healthBody(hub, configFetchCounters, { anchorStats, attestStats, relayStats }, - { healthy, dbOk, dbCircuit, oracleAgeS, oracleStale, oracleThresholdS, consensusInput }) { + { healthy, dbOk, dbCircuit, oracleAgeS, oracleStale, oracleThresholdS, + consensusInput, admissionTips }) { let healthResult = { status: healthy ? "healthy" : "degraded", db: dbOk, @@ -270,6 +357,7 @@ function healthBody(hub, configFetchCounters, { anchorStats, attestStats, relayS } }; if (consensusInput) healthResult.consensus_input = consensusInput; + if (admissionTips) healthResult.admission_tips = admissionTips; if (anchorStats) healthResult.anchor = anchorStats; if (attestStats) healthResult.attest = attestStats; // Telemetry only, never a 503: a relay that is disabled, or holding From fe4a172f90e7d2032c2044f3c6a95a879e9034d9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 22:04:59 -0700 Subject: [PATCH 36/57] fix(observability): bound unmatched-route metric label cardinality routeLabel() bucketed an unmatched request by its raw URL's first path segment, which is attacker-chosen: a flood of distinct unmatched segments could each buy a series against the shared per-metric maxSeries budget (metrics.js) that the service's own routes also draw from, crowding out real routes once the budget was spent. Cap distinct unmatched-segment labels at 20; further segments collapse onto a single /_unmatched overflow label instead of each buying a new series. --- src/observability/index.js | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/observability/index.js b/src/observability/index.js index f07981c48..26b8de5b9 100644 --- a/src/observability/index.js +++ b/src/observability/index.js @@ -78,6 +78,17 @@ let _shipperAttached = false; // (` warn [svc] warn [svc] msg`). let _sink = null; +// routeLabel's unmatched-path fallback (below) hands out one label per +// distinct first path segment, and that segment is chosen by whoever sends +// the request. Without a cap, a flood of distinct unmatched segments buys one +// series per request against the SHARED per-metric budget every route draws +// from (Registry maxSeries, metrics.js), and once that budget is spent the +// service's own routes can no longer register a series either. Past +// UNMATCHED_ROUTE_LABEL_CAP distinct segments, every further one collapses +// onto a single overflow label instead of buying its own series. +const UNMATCHED_ROUTE_LABEL_CAP = 20; +const _unmatchedRouteLabels = new Set(); + const CONSOLE_METHODS = { log: 'info', info: 'info', warn: 'warn', error: 'error', debug: 'debug' }; function toBool(v, fallback = false) { @@ -110,7 +121,10 @@ function timingSafeEqual(a, b) { // Path label for HTTP metrics. Express route patterns ("/hub-db/snapshot/:t") // are already low-cardinality; a raw URL is not, so anything without a matched // route falls back to its first path segment. This is the difference between a -// dozen series and one per block height. +// dozen series and one per block height. The first segment is still whatever +// the requester sent, so past UNMATCHED_ROUTE_LABEL_CAP distinct segments seen +// (above), later ones share a fixed overflow label instead of each buying a +// new series. function routeLabel(req) { if (req.route && req.route.path) { const base = req.baseUrl || ''; @@ -119,7 +133,14 @@ function routeLabel(req) { } const raw = (req.originalUrl || req.url || '/').split('?')[0]; const seg = raw.split('/').filter(Boolean)[0]; - return seg ? `/${seg}` : '/'; + if (!seg) return '/'; + const label = `/${seg}`; + if (_unmatchedRouteLabels.has(label)) return label; + if (_unmatchedRouteLabels.size < UNMATCHED_ROUTE_LABEL_CAP) { + _unmatchedRouteLabels.add(label); + return label; + } + return '/_unmatched'; } // Express dispatches its router stack in registration order, so a timing @@ -394,6 +415,7 @@ function _resetObservability() { _logger = null; _registry = null; _shipperAttached = false; + _unmatchedRouteLabels.clear(); } module.exports = { From 56b74ff9254c24c2244c47d3925d0921ff53a97c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 09:18:47 -0700 Subject: [PATCH 37/57] build(ci): grade the fast tier on a push, defer the heavy tiers to the sweep Generated block: do not hand-edit it. Change the tier config and re-run the tier wirer, which owns both this file and the hook so the two cannot drift. The pre-push venue gate ran the whole GitHub transcript on every push, coverage re-runs and perf scenarios included. That is the right set to grade a release with and the wrong set to pay for on every push, especially while three venues serve every repo on the platform: a long gate does not just cost its own minutes, it forms a queue behind itself for every other session pushing that hour. A push now grades the fast tier. The tiers named in the generated block move to the scheduled full sweep, which already runs against every repo every three hours and before any release or deploy, so nothing stops being graded. The verdict line is rewritten with it, which is the part that matters: a fast run can no longer print "all tiers green (same set GitHub CI runs)". It prints which tiers were deferred and states plainly that they were NOT graded there, so a fast green can never be mistaken for a full one. The dispatcher's verdict cache is keyed on repo + sha + cmd, so a fast green cannot stand in for a full one either. --- bin/ci-full.sh | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/bin/ci-full.sh b/bin/ci-full.sh index 3301f48be..ceac44307 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -74,7 +74,36 @@ SELF="$(pwd)" SIB="$(cd .. && pwd)" FAILED="" +# >>> ci-tier (generated block; re-run the tier wirer to update) >>> +# Tier classes. A push grades the FAST tier only: the unit job, the pin and +# drift guards, and the structure and hygiene checks the hook runs before it +# dispatches. The tiers named below (coverage re-runs, perf scenarios) are +# skipped when the gate sets CI_TIER=fast, and each skip is recorded so the +# closing verdict can never claim a green it did not earn. Nothing stops +# being graded: a scheduled sweep re-runs this same script with CI_TIER=full +# on every repo every three hours and before any release or deploy, and a +# red there is tracked down and fixed first. CI_TIER is unset for a hand +# run, so a bare `npm run ci:full` still runs every tier as it always did. +CI_TIER_FULL_ONLY=( + "perf (test:perf)" + "coverage ratchet (coverage:check)" +) +DEFERRED="" +ci_tier_deferred() { + [ "${CI_TIER:-full}" = "fast" ] || return 1 + local t + for t in ${CI_TIER_FULL_ONLY[@]+"${CI_TIER_FULL_ONLY[@]}"}; do + if [ "$t" = "$1" ]; then + DEFERRED="$DEFERRED [$1]" + echo; echo "ci:full ===== $1 DEFERRED (CI_TIER=fast, runs in the full sweep) =====" + return 0 + fi + done + return 1 +} +# <<< ci-tier <<< run_tier() { + ci_tier_deferred "$1" && return 0 # ci-tier guard (generated) local name="$1"; shift echo; echo "ci:full ===== $name =====" if "$@"; then @@ -163,8 +192,20 @@ run_tier "coverage ratchet (coverage:check)" \ env XCHAIN_REQUIRE_SIBLINGS=1 npm run coverage:check echo +# >>> ci-tier summary (generated) >>> +echo "ci:full: tier class ${CI_TIER:-full}" +if [ -n "${DEFERRED:-}" ]; then + echo "ci:full: DEFERRED to the full sweep:$DEFERRED" +fi +# <<< ci-tier summary <<< if [ -n "$FAILED" ]; then echo "ci:full: RED tiers:$FAILED" exit 1 fi -echo "ci:full: all tiers green (same set GitHub CI runs)" +# >>> ci-tier verdict (generated) >>> +if [ "${CI_TIER:-full}" = "fast" ]; then + echo "ci:full: all FAST tiers green; the DEFERRED tiers above were NOT graded here" +else + echo "ci:full: all tiers green (same set GitHub CI runs)" +fi +# <<< ci-tier verdict <<< From 33094fe5e52a5684e4ffa234de3a1858c5a9b11d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 11:14:59 -0700 Subject: [PATCH 38/57] fix(hub): update consensus_rules_digest comment for the armed testnet ATTEST response mirror The indexer half of this byte-twin rewrites the SHARED_GATES comment: attest_response_mirror_activation armed ATTEST_RESPONSE_MIRROR_ACTIVATION on testnet at height 151324 (2026-09-07) and at genesis on regtest, mainnet stays null. The two copies must move together or the parity test reds, so this carries the identical text. --- src/consensus_rules_digest.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/consensus_rules_digest.js b/src/consensus_rules_digest.js index 91b6bf6d1..94e9840eb 100644 --- a/src/consensus_rules_digest.js +++ b/src/consensus_rules_digest.js @@ -77,9 +77,10 @@ const SHARED_GATES = [ ['snapshot_reorg_buffer', ['SNAPSHOT_BURIAL_ACTIVATION']], ['stake_weighted_quorum', ['STAKE_WEIGHTED_QUORUM_ACTIVATION']], ['attest_responsible_widening_activation', ['ATTEST_RESPONSIBLE_WIDENING_ACTIVATION', 'ATTEST_RESPONSIBLE_WIDENING']], - // Unratified on mainnet and testnet (both null): this row moves the digest for a - // gate that decides nothing yet, so an upgraded hub reports a rules mismatch - // against un-upgraded peers during the deploy wave, not a divergent ledger. + // Armed on testnet (height 151324, 2026-09-07) and at genesis on regtest; still + // null (unratified) on mainnet. This row moves the digest for a gate that is live + // on testnet, so an upgraded hub reports a rules mismatch against un-upgraded + // testnet peers during the deploy wave, not a divergent ledger. ['attest_response_mirror_activation', ['ATTEST_RESPONSE_MIRROR_ACTIVATION']], // The zero-confirmation flip (one height for serve-at-tip, the stage-2 ladder and the // applier fall-through) and the stage-2 ladder constants it selects. The V2 constants From 82074ad9383aee26670d3a34e009414baf9e8f4c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 16:14:15 -0700 Subject: [PATCH 39/57] fix(hub): record the deferred re-serve ruling at the finalized ring and propose gate Document at both sites that the federation-wide re-serve trigger stays deferred (rare case, bounded by deadline expiry plus refund) and that ring eviction is already covered by the existing tombstone re-propose path, so no un-finalize path should be added without a new ruling. --- src/attestation/consensus/propose.js | 11 +++++++++++ src/attestation/consensus/round_records.js | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/src/attestation/consensus/propose.js b/src/attestation/consensus/propose.js index 41efec303..f327fbc9f 100644 --- a/src/attestation/consensus/propose.js +++ b/src/attestation/consensus/propose.js @@ -299,6 +299,17 @@ module.exports = { let d = envelope.data; if(!d || !d.requestId) return null; let rid = String(d.requestId).toLowerCase(); + // This is also the site a federation-wide re-serve trigger would have to + // change: every responsible hub would need to un-finalize `rid` from + // purely local state first, or a re-serving hub's own PROPOSE is dropped + // right here by its peers. D2 ruled (2026-09-22) that trigger stays + // deferred, the case being rare and bounded by deadline expiry plus + // refund. It does not need to cover ring eviction: the tombstone + // re-propose path already covers ring eviction on its own - once `rid` + // falls off `finalized`'s ring in round_records.js, this check reads + // false and lets the round reopen, with notePrematureEviction above + // logging the premature-eviction proof. Do not add an un-finalize path + // here without a new ruling. if(this.finalized.has(rid)) return null; let pending = this.pending.get(rid); if(!pending){ diff --git a/src/attestation/consensus/round_records.js b/src/attestation/consensus/round_records.js index bf9ff7964..0a1c24d7d 100644 --- a/src/attestation/consensus/round_records.js +++ b/src/attestation/consensus/round_records.js @@ -95,6 +95,15 @@ module.exports = { return this.finalized.has(String(rid).toLowerCase()); }, + // `finalized` has no un-finalize path, by design: D2 ruled (2026-09-22) that + // a federation-wide re-serve trigger stays deferred, the case being rare and + // bounded by deadline expiry plus refund. Ring eviction is not part of that + // deferred case - the tombstone re-propose path already covers ring eviction: + // once `rid` falls off this ring, isFinalized(rid) reads false on its own and + // propose.js's inbound gate lets the round reopen, with notePrematureEviction + // there logging the premature-eviction proof from `_finalizedEvicted` below. + // Do not build a federation-wide un-finalize path here without a new ruling. + // // Record a finalized request ID, evicting the oldest once the ring-buffer // cap (`finalizedMax`) is reached. Keeps `finalized` bounded while // preserving Set semantics for the duplicate-finalization guards. From e40c9e0789aa462c3c2aaadf83ead235ecb99d3e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 18:33:54 -0700 Subject: [PATCH 40/57] fix(hub): make consensus-identity reject a flag passed as a value, declare the shared-gate carrier twins parseArgs no longer accepts the next flag as a value, so --out --assert-no-absent cannot exit 0 without running the assertion. The six byte-equal shared-gate carriers declare their mutual twins so the pin guard compares them, the CLI uses the recursive canonical JSON the hash is computed with, and the banner's counts and line citations match the readout. Adds an assertion to an assertion-free oracle publisher test. --- bin/consensus-identity.js | 88 ++++++++++--------- bin/pins/carrier-logic.json | 30 +++++-- bin/test/consensus_identity.test.js | 2 +- ...cle_publisher_confirmation_05_core.test.js | 30 ++----- .../consensus_identity_cli.test.js | 17 ++++ 5 files changed, 95 insertions(+), 72 deletions(-) diff --git a/bin/consensus-identity.js b/bin/consensus-identity.js index 4156d5fb0..b64b3f580 100644 --- a/bin/consensus-identity.js +++ b/bin/consensus-identity.js @@ -11,7 +11,7 @@ * ********************************************************************** * - * The four numbers that say whether this build still applies the same rules as + * The five numbers that say whether this build still applies the same rules as * the one before it. A restructure may move any file it likes; it may not move * one of these. * @@ -21,9 +21,9 @@ * "same rules?". * gates_field_hash sha256 of the GATES field this hub signs into a * ROLLCALL v1 epoch, which is - * knownGateKeys().join(','). Built at - * RollcallRound.js:313 and hashed at - * rollcall_canonical.js:49. + * knownGateKeys().join(','). Built by gatesFor() + * in rollcall/round.js and hashed by gatesHash() + * in rollcall/rollcall_canonical.js. * coin_consensus_pins the coin registry's consensus hash for every * (coin, network) PAIR, not one number per network. * hub_schema_version the mirror-row schema every reader checks for @@ -34,30 +34,32 @@ * not the tree: the pin's own guard measures the tree. * * WHY THE GATES MAP IS PINNED AND NOT JUST THE DIGEST, and this is the whole - * reason this script exists rather than a one-line hash. consensus_rules_digest - * resolves each carrier with `require('./' + mod + '.js')` in a try/catch that - * substitutes the ABSENT sentinel, so moving every gate module into a - * subdirectory produces a perfectly well-formed digest over a map in which every - * single gate reads ``. Meanwhile the SIGNED wire field is - * knownGateKeys().join(','), which is built from the same key list and is - * therefore IDENTICAL before and after the move. A hub in that state publishes - * the same GATES string as every peer over a rules set it has entirely lost, and - * no wire field names the difference. So the pin carries the map, and - * --assert-no-absent turns "the digest changed" into "these gates stopped - * resolving", which is the sentence an operator can act on. + * reason this script exists rather than a one-line hash. Two digests that differ + * say nothing about what to fix; the map turns "the digest changed" into "these + * gates changed", which is the sentence an operator can act on, and it is what + * makes an environment-shifted digest diagnosable in one read (see ONE OF THE + * FIVE below). The SIGNED wire field is knownGateKeys().join(','), built from + * the key list alone, so it reads the same whether or not a carrier resolves. + * That is why consensus_rules_digest takes each gate from the activation + * registry, or a function-valued one from src/consensus/gates/_gate.js, + * and THROWS naming the key when neither is there: this script then prints no + * pin and exits 2 with that one line, so a moved carrier cannot hide inside a + * well-formed digest. `` survives as the sentinel that module's + * diffGates uses for a key a peer's map lacks, not as a value this build emits. * - * WHY THE COIN PIN IS PER PAIR. coins/index.js:338 is consensusHash(tick, - * network), and coins/consensus_pin.js has CONSENSUS_CONFIG_PIN.mainnet null, - * so mainnet verification is a deliberate skip and only the non-mainnet pairs - * are actually pinned today. One number per network would average over three - * chains and hide a single moved chain; the pair map cannot. + * WHY THE COIN PIN IS PER PAIR. The registry hash is consensusHash(tick, + * network) in coins/index.js, and coins/consensus_pin.js has + * CONSENSUS_CONFIG_PIN.mainnet null, so mainnet verification is a deliberate + * skip and only the non-mainnet pairs are actually pinned today. One number per + * network would average over three chains and hide a single moved chain; the + * pair map cannot. * - * WHY NO LEDGER READ. The indexer's copy of this tool takes a fourth reading + * WHY NO LEDGER READ. The indexer's copy of this tool takes a fifth reading * from its blocks table. The hub has no chain of its own to read: it is the * config oracle, so its identity is entirely code-derived and this script opens * no socket, reads no .env and needs no database. * - * ONE OF THE FOUR IS NOT PURE, AND IT MATTERS FOR ANY PIN. The rules digest + * ONE OF THE FIVE IS NOT PURE, AND IT MATTERS FOR ANY PIN. The rules digest * hashes gate VALUES, and a regtest venue arms some gates from its own * environment rather than from a committed height, so the same build reports one * digest in a bare checkout and another inside a configured container. Two @@ -70,9 +72,10 @@ * node bin/consensus-identity.js --json the full pin on stdout * node bin/consensus-identity.js --out write the pin as JSON * node bin/consensus-identity.js --root measure another checkout - * node bin/consensus-identity.js --assert-no-absent exit 1 if any gate is - * unresolved (the check a - * move has to survive) + * node bin/consensus-identity.js --assert-no-absent exit 1 if any gate reads + * ``; a lost carrier + * is the exit-2 refusal + * above, flag or no flag * node bin/consensus-identity.js --compare diff a tree against a pin, * exit 1 on any difference * @@ -105,11 +108,6 @@ function loadFromRepo(rel) { return require(path.join(REPO_ROOT, rel)); } -/** Key-sorted JSON, so the hash of a map does not depend on insertion order. */ -function canonicalJson(value) { - return JSON.stringify(value, Object.keys(value).sort()); -} - /** * The coin registry's consensus hash for every (coin, network) pair, plus which * pairs carry an armed CONSENSUS_CONFIG_PIN to be checked against. @@ -138,13 +136,15 @@ function coinConsensusPins() { } /** - * The four values, all of them derived from the source tree alone. + * The five values, all of them derived from the source tree alone. * @returns {object} */ function codeIdentity() { const rulesModule = loadFromRepo('src/consensus_rules_digest.js'); const { HUB_SCHEMA_VERSION } = loadFromRepo('src/hub_schema_version.js'); const logicPin = loadFromRepo('bin/lib/carrier_logic_pin.js'); + // The measured checkout's registry, whose exported canonicaliser consensusHash uses. + const coins = loadFromRepo('src/coins/index.js'); const rules = rulesModule.computeConsensusRulesDigest(); // The GATES field verbatim, because the hash alone cannot be checked by hand @@ -157,14 +157,14 @@ function codeIdentity() { return { consensus_rules_digest: rules.digest, // The gate-by-gate preimage. See WHY THE GATES MAP IS PINNED: without it - // a digest match cannot tell "unchanged" from "all absent on both sides". + // a digest mismatch cannot be traced to the gate that moved. consensus_rules_gates: rules.gates, gate_key_count: Object.keys(rules.gates).length, absent_gates: absentGates, gates_field: gatesField, gates_field_hash: crypto.createHash('sha256').update(gatesField, 'utf8').digest('hex'), coin_consensus_pins: coinPins.pairs, - coin_consensus_pin_hash: crypto.createHash('sha256').update(canonicalJson(coinPins.pairs)).digest('hex'), + coin_consensus_pin_hash: crypto.createHash('sha256').update(coins.canonicalJson(coinPins.pairs)).digest('hex'), coin_pin_armed_networks: coinPins.pinnedNetworks, coin_pin_skipped_networks: coinPins.unpinnedNetworks, hub_schema_version: HUB_SCHEMA_VERSION, @@ -219,12 +219,21 @@ function compare(pin, fresh) { function parseArgs(argv) { const opts = { json: false, assertNoAbsent: false }; for (let i = 0; i < argv.length; i += 1) { - if (argv[i] === '--json') opts.json = true; - else if (argv[i] === '--assert-no-absent') opts.assertNoAbsent = true; - else if (argv[i] === '--root') { opts.root = path.resolve(argv[i + 1]); i += 1; } - else if (argv[i] === '--out') { opts.out = path.resolve(argv[i + 1]); i += 1; } - else if (argv[i] === '--compare') { opts.compare = path.resolve(argv[i + 1]); i += 1; } - else if (argv[i] === '--help' || argv[i] === '-h') opts.help = true; + const arg = argv[i]; + // Refuse a missing value or a following flag: consumed as a path, `--out + // --assert-no-absent` would drop the assertion and exit 0. + const takeValue = () => { + const value = argv[i + 1]; + if (value === undefined || value.startsWith('-')) throw new Error(`${arg} requires a value`); + i += 1; + return value; + }; + if (arg === '--json') opts.json = true; + else if (arg === '--assert-no-absent') opts.assertNoAbsent = true; + else if (arg === '--root') opts.root = path.resolve(takeValue()); + else if (arg === '--out') opts.out = path.resolve(takeValue()); + else if (arg === '--compare') opts.compare = path.resolve(takeValue()); + else if (arg === '--help' || arg === '-h') opts.help = true; // A misspelt flag would otherwise run as the default reading and exit 0, // which a caller checking --assert-no-absent would take as a pass. else throw new Error(`unknown flag ${argv[i]}`); @@ -297,7 +306,6 @@ module.exports = { codeIdentity, coinConsensusPins, compare, - canonicalJson, setRepoRoot, // The measured checkout, as a call rather than a binding: a consumer that // captured the value at require time would keep reading the default after diff --git a/bin/pins/carrier-logic.json b/bin/pins/carrier-logic.json index d5d439348..c5d05913e 100644 --- a/bin/pins/carrier-logic.json +++ b/bin/pins/carrier-logic.json @@ -16,7 +16,9 @@ "attest_responsible_widening_activation": { "path": "src/consensus/gates/attest_responsible_widening_gate.js", "hash": "5e389fa47f0c26faed5e3c6e817c30602c9e8ad2f51aece8dfadf0b2c09c19ad", - "twins": [] + "twins": [ + "xchain-indexer" + ] }, "attest_zero_conf_activation": { "path": "src/attestation/attest_zero_conf_gate.js", @@ -33,7 +35,10 @@ "equivocation_header": { "path": "src/consensus/equivocation_header.js", "hash": "74a151e91bb72581fce3efe097452afa10603417d20eb008e58d1833f4a889f0", - "twins": [] + "twins": [ + "xchain-indexer", + "xchain-sync" + ] }, "lib/fullnode_activation": { "path": "src/lib/fullnode_activation.js", @@ -43,7 +48,9 @@ "mirror_admission_activation": { "path": "src/consensus/gates/mirror_admission_gate.js", "hash": "d59a0c44b7d0dc1dbc5f1530d67e012a7dac33f1824960565a9788b75cff6a25", - "twins": [] + "twins": [ + "xchain-indexer" + ] }, "oracle_clamp_reference_activation": { "path": "src/oracle_clamp_reference_activation.js", @@ -60,17 +67,23 @@ "price_scale_activation": { "path": "src/consensus/gates/price_scale_gate.js", "hash": "4789fa01a078a0287fe711e0ebaea253e7ebf9a39190e60de451db4fed0c0436", - "twins": [] + "twins": [ + "xchain-indexer" + ] }, "rollcall_activation": { "path": "src/consensus/gates/rollcall_gate.js", "hash": "edd542e9a143c33539433d9f6f5d0b11989402908cb464a25cc4c5fe017edc4b", - "twins": [] + "twins": [ + "xchain-indexer" + ] }, "rollcall_gates_activation": { "path": "src/consensus/gates/rollcall_gates_gate.js", "hash": "fdefddad0db37e81f8c7301a7763ecd5b4fade21c2c6ef1b6cc28211c2bc6e81", - "twins": [] + "twins": [ + "xchain-indexer" + ] }, "snapshot_reorg_buffer": { "path": "src/consensus/snapshot_reorg_buffer.js", @@ -82,7 +95,10 @@ "stake_weighted_quorum": { "path": "src/consensus/stake_weighted_quorum.js", "hash": "afaee5777e8d909eace7a7106b294797014f74929fc4302c50a2ec31ac77a847", - "twins": [] + "twins": [ + "xchain-indexer", + "xchain-sync" + ] }, "xchain_price_activation": { "path": "src/xchain_price_activation.js", diff --git a/bin/test/consensus_identity.test.js b/bin/test/consensus_identity.test.js index 3b67fd23f..fe612be51 100644 --- a/bin/test/consensus_identity.test.js +++ b/bin/test/consensus_identity.test.js @@ -10,7 +10,7 @@ * ********************************************************************** * - * The four identity values, and the one failure they exist to catch. + * The five identity values, and the one failure they exist to catch. * * The hashes themselves are not asserted against literals: a literal here is a * second copy of the pin under bin/pins/, and the day the two disagree the one diff --git a/test/unit/oracle/publisher/oracle_publisher_confirmation_05_core.test.js b/test/unit/oracle/publisher/oracle_publisher_confirmation_05_core.test.js index bc53a73d5..e6f66db16 100644 --- a/test/unit/oracle/publisher/oracle_publisher_confirmation_05_core.test.js +++ b/test/unit/oracle/publisher/oracle_publisher_confirmation_05_core.test.js @@ -1,22 +1,6 @@ 'use strict'; -const { - fs, - os, - path, - sinon, - expect, - waitUntil, - ME, - ADDR, - utxo, - makeEncoder, - queueEntry, - makePublisher, - seedQueue, - readJsonl, - cleanupPublisherConfirmation -} = require('./oracle_publisher_confirmation.test.js'); +const { expect } = require('./oracle_publisher_confirmation.test.js'); const OraclePublisher = require('../../../../src/oracle/publisher.js'); @@ -30,17 +14,15 @@ function pub(cfg) { } const testCase1 = function () { - if (pub().allowUnconfirmedInputs !== false) throw new Error('default must be false'); + expect(pub().allowUnconfirmedInputs, 'default must be false').to.equal(false); }; const testCase2 = function () { - if (pub({ ORACLE_PUBLISH_ALLOW_UNCONFIRMED_INPUTS: 'true' }).allowUnconfirmedInputs !== true) { - throw new Error('explicit true must opt in'); - } + expect(pub({ ORACLE_PUBLISH_ALLOW_UNCONFIRMED_INPUTS: 'true' }).allowUnconfirmedInputs, + 'explicit true must opt in').to.equal(true); for (const v of ['false', '1', 'yes', '', 'TRUE']) { - if (pub({ ORACLE_PUBLISH_ALLOW_UNCONFIRMED_INPUTS: v }).allowUnconfirmedInputs !== false) { - throw new Error('only the exact string true opts in, got ' + v); - } + expect(pub({ ORACLE_PUBLISH_ALLOW_UNCONFIRMED_INPUTS: v }).allowUnconfirmedInputs, + 'only the exact string true opts in, got ' + v).to.equal(false); } }; diff --git a/test/unit/repo_guards/consensus_identity_cli.test.js b/test/unit/repo_guards/consensus_identity_cli.test.js index 9de9dc659..6def49eb1 100644 --- a/test/unit/repo_guards/consensus_identity_cli.test.js +++ b/test/unit/repo_guards/consensus_identity_cli.test.js @@ -18,6 +18,7 @@ // test is what Node does with an uncaught throw, which an in-process stub cannot show. const { expect } = require('chai'); +const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); @@ -52,4 +53,20 @@ describe('bin/consensus-identity.js: top-level error handling', function () { expect(res.stderr).to.match(/^consensus-identity: unknown flag --assert-no-absnet/); expect(res.stdout).to.equal(''); }); + + it('exits 2 naming a value flag whose value is missing', function () { + const res = run(['--out']); + expect(res.status).to.equal(2); + expect(res.stderr).to.equal('consensus-identity: --out requires a value\n'); + expect(res.stdout).to.equal(''); + }); + + it('refuses a flag in the value position instead of consuming it as the path', function () { + // Consumed as a path, the flag would write a file of that name and drop the assertion. + const res = run(['--out', '--assert-no-absent']); + expect(res.status).to.equal(2); + expect(res.stderr).to.equal('consensus-identity: --out requires a value\n'); + expect(res.stdout).to.equal(''); + expect(fs.existsSync(path.join(REPO, '--assert-no-absent'))).to.equal(false); + }); }); From 574bf779bb7cdd14dbffca0d8faebe9423ee6a98 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 19:09:09 -0700 Subject: [PATCH 41/57] Harden strict sibling parity tests Resolve the moved consensus and observability suite paths. Fail closed when strict sibling inputs are unavailable, and limit header checks to consumers with ported suites. --- .../consensus/equivocation_header.test.js | 39 +++++++++++++------ .../observability_sync_gate.test.js | 28 ++++++++----- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/test/unit/consensus/equivocation_header.test.js b/test/unit/consensus/equivocation_header.test.js index f605d48ad..7f89b31bd 100644 --- a/test/unit/consensus/equivocation_header.test.js +++ b/test/unit/consensus/equivocation_header.test.js @@ -9,7 +9,25 @@ // General Public License v3.0 or later; see LICENSE.md. const { expect } = require('chai'); +const path = require('node:path'); const eq = require('../../../src/consensus/equivocation_header.js'); +const REPO_ROOT = path.join(__dirname, '..', '..', '..'); +const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.join(REPO_ROOT, '..'); +const REQUIRE_SIBLINGS = process.env.XCHAIN_REQUIRE_SIBLINGS === '1'; + +function loadSibling(relativePath) { + const absolutePath = path.join(SIBLING_ROOT, relativePath); + try { + return require(absolutePath); + } catch (error) { + if (REQUIRE_SIBLINGS) { + throw new Error(`XCHAIN_REQUIRE_SIBLINGS=1 but the equivocation-header sibling is unloadable at ${absolutePath}`, { + cause: error + }); + } + return null; + } +} // CONSENSUS-CRITICAL: the EQUIV header (WI-2 bump 2) is prefixed onto every signed // consensus canonical at/above the flag-day. The indexer keeps a byte-equivalent @@ -68,23 +86,22 @@ function registerCrossServiceParityTests() { // by the dedicated consensus-primitive conformance gate, so the skip is not a false // green on this fork-class invariant. describe('cross-service activation parity', function () { + this.timeout(20000); it('hub activation map == canonical constants.js', function () { - let canonical; - try { canonical = require('../../../../xchain-documentation/protocol/constants.js').EQUIV_HEADER_ACTIVATION; } - catch (e) { return this.skip(); } + const constants = loadSibling('xchain-documentation/protocol/constants.js'); + if (!constants) return this.skip(); + const canonical = constants.EQUIV_HEADER_ACTIVATION; expect(eq.EQUIV_HEADER_ACTIVATION).to.deep.equal(canonical); }); it('all 5 copies == hub (map + tags + builder bytes)', function () { // hub + indexer (server consensus) + sdk + explorer (client checkpoint // verifiers). A drift in ANY copy flips the header on different blocks → fork. - let copies; - try { - copies = { - indexer: require('../../../../xchain-indexer/src/equivocation_header.js'), - sdk: require('../../../../xchain-sdk/src/equivocation_header.js'), - explorer: require('../../../../xchain-explorer/src/equivocation_header.js'), - }; - } catch (e) { return this.skip(); } + const copies = { + indexer: loadSibling('xchain-indexer/src/consensus/equivocation_header.js'), + sdk: loadSibling('xchain-sdk/src/consensus/equivocation_header.js'), + explorer: loadSibling('xchain-explorer/src/consensus/equivocation_header.js'), + }; + if (Object.values(copies).includes(null)) return this.skip(); const ref = eq.buildEquivCanonical('XDEX', 'mid', 2, 'XMATCH|mid|x'); for(const [name, copy] of Object.entries(copies)){ expect(copy.EQUIV_HEADER_ACTIVATION, name + ' activation map').to.deep.equal(eq.EQUIV_HEADER_ACTIVATION); diff --git a/test/unit/repo_guards/observability_sync_gate.test.js b/test/unit/repo_guards/observability_sync_gate.test.js index bcf0490f2..98d44a62b 100644 --- a/test/unit/repo_guards/observability_sync_gate.test.js +++ b/test/unit/repo_guards/observability_sync_gate.test.js @@ -53,6 +53,13 @@ const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.join(REPO_ROOT, '.. // restated, so adding a consumer or a file there cannot leave this suite // asserting against a stale copy of the truth. const SCRIPT_TEXT = fs.readFileSync(SCRIPT, 'utf8'); +const REQUIRE_SIBLINGS = process.env.XCHAIN_REQUIRE_SIBLINGS === '1'; +const PORTED_SUITES = new Map([ + ['xchain-encoder', 'test/unit/observability.test.js'], + ['xchain-utxo-tracker', 'test/unit/observability.test.js'], + ['xchain-explorer', 'test/unit/lib/observability.test.js'], + ['xchain-sync', 'test/unit/observability.test.js'] +]); function bashArray(name) { const match = SCRIPT_TEXT.match(new RegExp(`^${name}=\\((.*)\\)$`, 'm')); return match ? match[1].split(/\s+/).filter(Boolean) : []; @@ -195,17 +202,20 @@ function registerObservabilityShimTheVendoredCopyParityGateSuite1Part3() { } function registerObservabilityShimTheVendoredCopyParityGateSuite1Part4() { describe('the ported suite headers describe the mechanism that exists', function () { - // Skips per consumer when the sibling is absent, the same way every other + // Skips per ported suite when the sibling is absent, the same way every other // cross-repo guard here does; test/unit/repo_guards/sibling_coverage.test.js is what - // reports which of them could not run. - // - // It reports an absent CHECKOUT, not an absent SUITE, so a consumer that - // vendors the code and never ported the suite skips here and is reported - // nowhere. Read a green run as "every ported suite named the gate". - for (const consumer of CONSUMERS) { + // reports which of them could not run. Strict mode makes a missing ported + // suite a failure rather than a pending test. + for (const [consumer, relativeSuite] of PORTED_SUITES) { it(`${consumer}: its ported observability suite names the real gate`, function () { - const suite = path.join(SIBLING_ROOT, consumer, 'test', 'unit', 'observability.test.js'); - if (!fs.existsSync(suite)) return this.skip(); + expect(CONSUMERS, `${consumer} has a ported suite but is not a sync consumer`).to.contain(consumer); + const suite = path.join(SIBLING_ROOT, consumer, relativeSuite); + if (!fs.existsSync(suite)) { + if (REQUIRE_SIBLINGS) { + throw new Error(`XCHAIN_REQUIRE_SIBLINGS=1 but the ported observability suite is missing at ${suite}`); + } + return this.skip(); + } const header = fs.readFileSync(suite, 'utf8').split('\nconst ')[0]; expect(header, 'the header must name the script that enforces parity').to.contain('sync-observability.sh'); expect(header, 'a header claiming a check "in CI" without naming it is what drifted').to.not.match(/parity is gated by a\s*\n\/\/ check across the vendored copies in CI/); From 30184dbb93d6aae0bfec536c9818c7c6c9abc432 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 19:24:31 -0700 Subject: [PATCH 42/57] test(hub): check every carrier twin instead of skipping on the first absent sibling --- .../unit/repo_guards/carrier_logic_pin.test.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/test/unit/repo_guards/carrier_logic_pin.test.js b/test/unit/repo_guards/carrier_logic_pin.test.js index cbcfd93bd..87f95eba9 100644 --- a/test/unit/repo_guards/carrier_logic_pin.test.js +++ b/test/unit/repo_guards/carrier_logic_pin.test.js @@ -42,9 +42,13 @@ function siblingWith(repo, rel) { return fs.existsSync(path.join(dir, rel)) ? dir : null; } -/** Skip or fail on a missing sibling, by the environment's rule. */ -function missingSibling(test, repo) { - if (REQUIRE_SIBLINGS) assert.fail(`${repo} is not checked out beside this repo and XCHAIN_REQUIRE_SIBLINGS=1`); +/** Skip or fail once for every missing sibling, by the environment's rule; call it after the assertions (skip throws). */ +function missingSiblings(test, repos) { + const names = [...new Set(repos)].sort(); + if (!names.length) return; + if (REQUIRE_SIBLINGS) { + assert.fail(`${names.join(', ')} ${names.length > 1 ? 'are' : 'is'} not checked out beside this repo and XCHAIN_REQUIRE_SIBLINGS=1`); + } test.skip(); } @@ -70,15 +74,17 @@ describe('bin/pins/carrier-logic.json: the carrier logic pin', function () { it('(c) every twin id hashes the same in the sibling pin', function () { const mismatches = []; + const missing = []; for (const id of Object.keys(pin.entries)) { for (const repo of pin.entries[id].twins || []) { const dir = siblingWith(repo, pinModule.PIN_REL); - if (!dir) { missingSibling(this, repo); continue; } + if (!dir) { missing.push(repo); continue; } const theirs = (pinModule.readPin(dir).entries[id] || {}).hash; if (theirs !== pin.entries[id].hash) mismatches.push(`${id}: ${repo} pins ${theirs}, this repo pins ${pin.entries[id].hash}`); } } assert.deepStrictEqual(mismatches, [], 'a logic change to a twin re-pins every copy in one change set'); + missingSiblings(this, missing); }); }); @@ -105,10 +111,11 @@ describe('bin/pins/carrier-logic.json: the carrier logic pin', function () { it('(e) every twin file\'s bytes equal every sibling copy', function () { assert.ok(pinModule.MODULE_TWIN_FILES.length >= 2, 'the module and its ops half are both twins'); + const missing = []; for (const repo of pinModule.MODULE_TWINS) { if (repo === pinModule.repoName(REPO_ROOT)) continue; const dir = siblingWith(repo, pinModule.MODULE_REL); - if (!dir) { missingSibling(this, repo); continue; } + if (!dir) { missing.push(repo); continue; } for (const rel of pinModule.MODULE_TWIN_FILES) { const theirs = path.join(dir, rel); assert.ok(fs.existsSync(theirs), `${rel} is missing from the ${repo} copy: cp it there`); @@ -116,6 +123,7 @@ describe('bin/pins/carrier-logic.json: the carrier logic pin', function () { `${rel} differs from the ${repo} copy: edit one and cp it to the others`); } } + missingSiblings(this, missing); }); it('(g) the membership rule and the pin name the same files, both ways', () => { From 574faedbd61eafd9b6012b321e048b4e3b1c33da Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 20:11:13 -0700 Subject: [PATCH 43/57] fix(hub): wire the XDEX round timeouts, strict oracle config parsing, exact price and reward bounds The XDEX round timeout and lifetime reach CrossChainDexConsensus, oracle interval and window parse strictly, the admission watermark uses the shared defaults, price bounds and the anchor reward amount stay exact, governance refuses non-finite slash thresholds, oracle_publish membership resolves from the stake-weighted snapshot, xcall reads bound call_id, and the create_tx never-sent classification covers its documented errors. --- src/anchor/publisher/archive/verify.js | 3 +- src/anchor/publisher/options.js | 3 +- src/anchor/reward_tracker.js | 25 +- src/api.js | 8 +- src/api/rpc/cross_chain.js | 34 +- src/attestation/batch_publisher/broadcast.js | 22 +- src/consensus/gates/mirror_admission_gate.js | 20 +- src/consensus/gates/rollcall_gates_gate.js | 8 +- src/constants.js | 8 + src/cross_chain/dex_consensus.js | 14 +- src/lib/idempotent_broadcast.js | 18 +- src/oracle/publisher/rotation.js | 27 +- src/oracle/xchain_price_source.js | 11 +- .../hub_db/admission_height_watermark.js | 11 +- src/validators/governance/bounds.js | 42 ++- src/validators/governance/propose_inbound.js | 5 +- .../reward_tracker_amount_string.test.js | 72 ++++ .../api/p2p_config_oracle_cadence.test.js | 109 ++++++ test/unit/api/rpc_cross_chain_call_id.test.js | 67 ++++ ...er_015_a_head_refused_by_create_tx.test.js | 339 ++++++++++++++++++ ...s_chain_dex_consensus_round_config.test.js | 113 ++++++ .../batch/oracle_publisher_batch.test.js | 17 +- ...publisher_role_and_publish_history.test.js | 13 +- ...acle_publisher_rotation_membership.test.js | 126 +++++++ .../xchain_price_source_overrides.test.js | 10 + .../admission_watermark_price_default.test.js | 55 +++ ...admission_watermark_round_defaults.test.js | 65 ++++ .../governance_slash_value_shape.test.js | 85 +++++ 28 files changed, 1235 insertions(+), 95 deletions(-) create mode 100644 test/unit/anchor/reward_tracker_amount_string.test.js create mode 100644 test/unit/api/p2p_config_oracle_cadence.test.js create mode 100644 test/unit/api/rpc_cross_chain_call_id.test.js create mode 100644 test/unit/attestation/publisher/batch/attestation_batch_publisher_015_a_head_refused_by_create_tx.test.js create mode 100644 test/unit/cross_chain/dex/cross_chain_dex_consensus_round_config.test.js create mode 100644 test/unit/oracle/publisher/oracle_publisher_rotation_membership.test.js create mode 100644 test/unit/shared/admission/admission_watermark_price_default.test.js create mode 100644 test/unit/shared/admission/admission_watermark_round_defaults.test.js create mode 100644 test/unit/validators/governance/governance_slash_value_shape.test.js diff --git a/src/anchor/publisher/archive/verify.js b/src/anchor/publisher/archive/verify.js index 58530004b..4e82863de 100644 --- a/src/anchor/publisher/archive/verify.js +++ b/src/anchor/publisher/archive/verify.js @@ -27,6 +27,7 @@ const { resolveQuorumNetwork } = require('../../quorum_network.js'); const swq = require('../../../consensus/stake_weighted_quorum.js'); const ar = require('../../../consensus/gates/anchor_reward_gate.js'); const ark = require('../../anchor_reward_key.js'); +const { legacyAnchorRewardAmount } = require('../../reward_tracker.js'); const { getLogger } = require('../../../observability'); const logger = getLogger(); @@ -217,7 +218,7 @@ module.exports = { ? ar.ANCHOR_REWARD_AMOUNT : isDerivedArchive ? ar.ARCHIVE_REWARD_AMOUNT - : (this.hub.rewardTracker ? parseFloat(this.hub.rewardTracker.anchorReward).toFixed(8) : null); + : (this.hub.rewardTracker ? legacyAnchorRewardAmount(this.hub.rewardTracker.anchorReward) : null); return expectedAmount; }, diff --git a/src/anchor/publisher/options.js b/src/anchor/publisher/options.js index 9df447dab..5c41b392b 100644 --- a/src/anchor/publisher/options.js +++ b/src/anchor/publisher/options.js @@ -25,6 +25,7 @@ const EncoderClient = require('../../peers/encoder_client.js'); const SpendGuard = require('../../lib/spend_guard.js'); const { resolveCheckpointIntervalBlocks } = require('../checkpoint_cadence.js'); +const { DEFAULT_ANCHOR_ROUND_TIMEOUT_MS } = require('../../constants.js'); const hubConfig = require('../../config'); module.exports = { @@ -72,7 +73,7 @@ module.exports = { this.batchSize = parseInt(hubConfig.ANCHOR_MATCH_BATCH_SIZE || cfg.ANCHOR_MATCH_BATCH_SIZE || '200'); this.maxBatch = parseInt(hubConfig.ANCHOR_MAX_BATCH || cfg.ANCHOR_MAX_BATCH || '1000'); this.chunkMaxBytes = parseInt(hubConfig.ANCHOR_CHUNK_MAX_BYTES || cfg.ANCHOR_CHUNK_MAX_BYTES || '6000'); - this.roundTimeoutMs = parseInt(hubConfig.ANCHOR_ROUND_TIMEOUT_MS || cfg.ANCHOR_ROUND_TIMEOUT_MS || '120000'); + this.roundTimeoutMs = parseInt(hubConfig.ANCHOR_ROUND_TIMEOUT_MS || cfg.ANCHOR_ROUND_TIMEOUT_MS || DEFAULT_ANCHOR_ROUND_TIMEOUT_MS); this.chunkRetryDelayMs = parseInt(hubConfig.ANCHOR_CHUNK_RETRY_MS || cfg.ANCHOR_CHUNK_RETRY_MS || '2500'); }, diff --git a/src/anchor/reward_tracker.js b/src/anchor/reward_tracker.js index dea5bcaa7..ffb4ac807 100644 --- a/src/anchor/reward_tracker.js +++ b/src/anchor/reward_tracker.js @@ -29,6 +29,12 @@ const nodeUtil = require('node:util'); const { getLogger } = require('../observability'); const logger = getLogger(); +// Render the operator-tunable (below-flag-day) anchor reward on the 8-decimal grid. +// bcmath, never parseFloat: a prefix-numeric knob ('10abc') renders '0.00000000', not 10. +function legacyAnchorRewardAmount(knob) { + return bcmath.bcformat(knob, 8); +} + class RewardTracker { constructor(hub) { @@ -149,9 +155,8 @@ class RewardTracker { let lcPubkey = pubkey.toLowerCase(); // The frozen consensus amount at/above its flag-day, else the operator-tunable one. - let amount = this.resolveAnchorRewardAmount(rewardType, blockIndex, rewardNetwork); - if (!Number.isFinite(amount) || amount <= 0) return; - let amountStr = amount.toFixed(8); + let amountStr = this.resolveAnchorRewardAmount(rewardType, blockIndex, rewardNetwork); + if (!bcmath.bcgt(amountStr, '0')) return; // Qualify the logical anchor by the archive leg's snapshot block. round_number is // MATCH_BATCH_SEQ for anchor_archive, a dense counter a wipe-and-replay rebase @@ -193,9 +198,9 @@ class RewardTracker { // chain already determines. } - // The anchor reward amount to record, as a parsed float (NaN when the configured - // legacy amount does not parse). Pure: no DB access, so the locked body keeps its - // statement order. + // The anchor reward amount to record, as a decimal string ('0.00000000' when the + // configured legacy amount is not numeric). Pure: no DB access, so the locked body + // keeps its statement order. resolveAnchorRewardAmount(rewardType, blockIndex, rewardNetwork) { // At/above the anchor-reward flag-day the per-chain reward is DERIVED on-chain // from the ANCHOR v4/v5 publisher attestation, and every indexer credits the FROZEN @@ -229,8 +234,10 @@ class RewardTracker { ar.isAnchorRewardActive(Number(blockIndex), network); let isDerivedArchive = String(rewardType) === 'anchor_archive' && ar.isArchiveRewardActive(Number(blockIndex), network); - return parseFloat((isDerivedChain || isDerivedBundle) ? ar.ANCHOR_REWARD_AMOUNT - : isDerivedArchive ? ar.ARCHIVE_REWARD_AMOUNT : this.anchorReward); + // Frozen constants verbatim (byte-identical to what the indexer credits and signs). + if (isDerivedChain || isDerivedBundle) return String(ar.ANCHOR_REWARD_AMOUNT); + if (isDerivedArchive) return String(ar.ARCHIVE_REWARD_AMOUNT); + return legacyAnchorRewardAmount(this.anchorReward); } // Resolve the staking source address that owns a signing pubkey at a block, @@ -294,4 +301,6 @@ class RewardTracker { } } +RewardTracker.legacyAnchorRewardAmount = legacyAnchorRewardAmount; + module.exports = RewardTracker; diff --git a/src/api.js b/src/api.js index fd3d0a64b..b3d609ad8 100644 --- a/src/api.js +++ b/src/api.js @@ -69,6 +69,7 @@ const geoip = require('geoip-lite'); // self-contained country/region DB; // identically, or a consumer that switches between the two feeds sees the same value // change JS type mid-stream. Importing is what makes that identity structural. const { bigIntReplacer } = require('./lib/bigint_replacer.js'); +const { positiveIntConfig } = require('./lib/config_int.js'); const { parseCorsOrigin } = require('./api/cors_origin.js'); const { parseExemptLocal } = require('./api/rate_limit_policy.js'); const { resolveMaxBatch, makeRpcBatchGuard } = require('./peers/rpc_batch_guard.js'); // JSON-RPC batch cardinality cap @@ -248,8 +249,11 @@ const p2pConfig = P2P_VALIDATOR_ADDR ? { XCHAIN_PRICE_MIN_BTC_VOLUME: hubConfig.XCHAIN_PRICE_MIN_BTC_VOLUME || '', ORACLE_EPOCH_START: parseInt(hubConfig.ORACLE_EPOCH_START), - ORACLE_ROUND_INTERVAL: parseInt(hubConfig.ORACLE_ROUND_INTERVAL) || DEFAULT_ORACLE_ROUND_INTERVAL_MS, - ORACLE_SUBMISSION_WINDOW: parseInt(hubConfig.ORACLE_SUBMISSION_WINDOW) || DEFAULT_ORACLE_SUBMISSION_WINDOW_MS, + // Positive-only: the interval divides elapsed time into federation round numbers. + ORACLE_ROUND_INTERVAL: positiveIntConfig(hubConfig.ORACLE_ROUND_INTERVAL, + DEFAULT_ORACLE_ROUND_INTERVAL_MS, 'ORACLE_ROUND_INTERVAL'), + ORACLE_SUBMISSION_WINDOW: positiveIntConfig(hubConfig.ORACLE_SUBMISSION_WINDOW, + DEFAULT_ORACLE_SUBMISSION_WINDOW_MS, 'ORACLE_SUBMISSION_WINDOW'), // Per-round cap on collected peer submissions (anti-flood, OracleRound.js). // Passed through UNPARSED for the same reason as the retention knob below: // OracleRound.js owns the parse, the range check and the 200 default, so a diff --git a/src/api/rpc/cross_chain.js b/src/api/rpc/cross_chain.js index 7d2364fea..948693d4e 100644 --- a/src/api/rpc/cross_chain.js +++ b/src/api/rpc/cross_chain.js @@ -45,29 +45,31 @@ function crossChainCallRpc(ctx) { // {call_id, dispatch, result}; getxcall is a shorter alias (mirrors the // explorer's getXcall naming). Read-only, public read tier. async getcrosschaincall({call_id}){ - if(!call_id) return {error: "call_id is required"}; - if(!hub.crossChainCalls) return {error: "cross-chain call engine not active"}; - try { - let call = await hub.getCrossChainCall(call_id); - return call || {error: "cross-chain call not found"}; - } catch (err) { - return {error: "error fetching cross-chain call"}; - } + return readCrossChainCall(hub, call_id); }, async getxcall({call_id}){ - if(!call_id) return {error: "call_id is required"}; - if(!hub.crossChainCalls) return {error: "cross-chain call engine not active"}; - try { - let call = await hub.getCrossChainCall(call_id); - return call || {error: "cross-chain call not found"}; - } catch (err) { - return {error: "error fetching cross-chain call"}; - } + return readCrossChainCall(hub, call_id); }, }; } +// Resolve one XCALL lifecycle for both read aliases. A call_id is a 64-char hex +// sha256 (the dispatch path refuses any other shape), so refuse junk before the read. +async function readCrossChainCall(hub, call_id){ + if(!call_id) return {error: "call_id is required"}; + if(typeof call_id !== 'string') return {error: "call_id must be a 64-character hex string"}; + let callId = call_id.trim().toLowerCase(); + if(!/^[0-9a-f]{64}$/.test(callId)) return {error: "call_id must be a 64-character hex string"}; + if(!hub.crossChainCalls) return {error: "cross-chain call engine not active"}; + try { + let call = await hub.getCrossChainCall(callId); + return call || {error: "cross-chain call not found"}; + } catch (err) { + return {error: "error fetching cross-chain call"}; + } +} + function callListSwapReadsRpc(ctx) { const { hub } = ctx; return { diff --git a/src/attestation/batch_publisher/broadcast.js b/src/attestation/batch_publisher/broadcast.js index 01b8d9fb4..16d1b12be 100644 --- a/src/attestation/batch_publisher/broadcast.js +++ b/src/attestation/batch_publisher/broadcast.js @@ -219,6 +219,23 @@ module.exports = { // The default pipeline: the same encoder, address and wallet hook the PRICE rail // uses, because there is one operator wallet. Only the payload differs. async defaultBroadcast(payload){ + let psbtResult; + try { + psbtResult = await this.buildBatchPsbt(payload); + } catch(e){ + // Tag a build failure neverSent: only broadcast_tx below puts a transaction on + // a wire, and the encoder answers a refused create_tx as an HTTP 200 RPC error. + if(e && typeof e === 'object') e.neverSent = true; + throw e; + } + + let txHex = await this.walletSignFn(psbtResult.psbt); + if(!txHex || typeof txHex !== 'string') throw new Error('wallet sign hook returned invalid tx hex'); + return (await this.encoder.broadcastTx(txHex)) || { txid: null }; + }, + + // Select inputs and build the unsigned single-transaction PSBT; nothing here sends. + async buildBatchPsbt(payload){ if(!this.encoder) throw new Error('no encoder configured (set DOGE_ENCODER_URL)'); if(!this.walletSignFn) throw new Error('no wallet sign hook configured (call setWalletSignHook)'); if(!this.dogeAddress) throw new Error('no DOGE_ADDRESS configured'); @@ -242,10 +259,7 @@ module.exports = { // Refuse phase one of a two-transaction encoding: this pipeline has no reveal, // so broadcasting it would publish an undecodable batch and strand the value. assertSingleTxEncoding(psbtResult, 'AttestationBatchPublisher'); - - let txHex = await this.walletSignFn(psbtResult.psbt); - if(!txHex || typeof txHex !== 'string') throw new Error('wallet sign hook returned invalid tx hex'); - return (await this.encoder.broadcastTx(txHex)) || { txid: null }; + return psbtResult; } }; diff --git a/src/consensus/gates/mirror_admission_gate.js b/src/consensus/gates/mirror_admission_gate.js index ba343a97b..74029a896 100644 --- a/src/consensus/gates/mirror_admission_gate.js +++ b/src/consensus/gates/mirror_admission_gate.js @@ -5,15 +5,19 @@ const { get, copy, activeAt } = require('../gate_registry'); /* * mirror_admission_gate.js - admission by height for the mirror barrier family. * - * BYTE-IDENTICAL TWIN. This file exists at xchain-indexer/src/, xchain-hub/src/ and, once - * vendored, xchain-explorer/src/. The three copies are held identical by - * bin/sync-hub-mirror-client.sh --check (a cmp -s byte compare, not a digest) and the - * exported constants are held value-identical to xchain-documentation/protocol/constants.js - * by the activation-constants parity suite. A one-sided edit forks consensus at the boundary. + * BYTE-IDENTICAL TWIN. The canonical copy is xchain-indexer/src/; xchain-hub/src/ and + * xchain-explorer/src/ carry it byte for byte, so edit the indexer copy and copy it outward. + * reconcile-twins.sh --check grades both pairs. The explorer pair is also held by + * xchain-indexer/bin/sync-hub-mirror-client.sh --check (a cmp -s byte compare, not a digest), + * which never reads the hub copy; the hub pair is also held by the byte compare in the + * activation-constants parity suite, which skips without a sibling checkout unless + * XCHAIN_REQUIRE_SIBLINGS=1. That suite also holds the exported constants value-identical to + * xchain-documentation/protocol/constants.js. A one-sided edit forks consensus at the boundary. * - * ZERO REQUIRES, DELIBERATELY. This module is a CLIENT_FILES entry: it is vendored into the - * explorer beside hub_db_sync.js, which can carry no dependency the explorer does not have. - * price_batching_floor_activation.js is the precedent. The alternative considered and + * ONE REQUIRE, DELIBERATELY. This module is a DEP_FILES entry of sync-hub-mirror-client.sh: it + * is vendored into the explorer beside hub_db_sync.js, which can carry no dependency the + * explorer does not have, so it requires only ../gate_registry, which every consumer carries + * as its own. price_batching_floor_gate.js is the precedent. The alternative considered and * rejected was threading an admission bound through eleven predicate signatures, their * waiters and every call site. * diff --git a/src/consensus/gates/rollcall_gates_gate.js b/src/consensus/gates/rollcall_gates_gate.js index cc2d272e5..ad55613a9 100644 --- a/src/consensus/gates/rollcall_gates_gate.js +++ b/src/consensus/gates/rollcall_gates_gate.js @@ -42,10 +42,10 @@ * arms the ROLLCALL rail opts in here separately, so a rail-armed venue can * still drive the below-height (v0) behaviour as its control. * - * LOCAL COPY of the canonical map in xchain-documentation/protocol/constants.js - * and the value twin of xchain-hub/src/rollcall_gates_activation.js. The - * mainnet and testnet heights are parity-tested; regtest is env-derived and is - * not. + * LOCAL COPY of the canonical map in xchain-documentation/protocol/constants.js; + * the twin lives at the same path (src/consensus/gates/rollcall_gates_gate.js) + * in the hub and the indexer and is kept BYTE-IDENTICAL. The mainnet and + * testnet heights are parity-tested; regtest is env-derived and is not. * ********************************************************************/ diff --git a/src/constants.js b/src/constants.js index 6713d86a1..b2319ab76 100644 --- a/src/constants.js +++ b/src/constants.js @@ -226,6 +226,13 @@ const MAX_SOURCE_ADDRESS_LENGTH = 100; // timeout, a different engine's lifecycle that must not be tied to this one. const DEFAULT_ATTESTATION_ROUND_TIMEOUT_MS = 120000; +// Default XDEX and ANCHOR PBFT round timeouts, one per rail for the same reason: +// each engine and the mirror admission watermark (peers/hub_db) size from these, +// so a one-sided raise cannot shift the trail bound the hub publishes. Separate +// names on purpose: equal today, but each engine's lifecycle is its own. +const DEFAULT_XDEX_ROUND_TIMEOUT_MS = 120000; +const DEFAULT_ANCHOR_ROUND_TIMEOUT_MS = 120000; + // The ATTEST PBFT state machines this build actually implements, and therefore the // admission allowlist for a block-anchored provider consensus_strategy. // AttestationConsensus dispatches on the pinned strategy by POSITIVE equality against @@ -243,6 +250,7 @@ const SUPPORTED_CONSENSUS_STRATEGIES = Object.freeze(['byte_equality', 'judge_mo module.exports = { PRICE_MAX, ORACLE_DEVIATION_THRESHOLD, ORACLE_MAX_CHANGE_PER_ROUND, XCALL_MAX_HOPS, DEFAULT_ORACLE_ROUND_INTERVAL_MS, DEFAULT_ORACLE_SUBMISSION_WINDOW_MS, DEFAULT_ATTESTATION_ROUND_TIMEOUT_MS, SUPPORTED_CONSENSUS_STRATEGIES, + DEFAULT_XDEX_ROUND_TIMEOUT_MS, DEFAULT_ANCHOR_ROUND_TIMEOUT_MS, PRICE_V1_COINS, PRICE_V1_FIATS, MAX_TICK_LENGTH, MAX_MEMO_LENGTH, MAX_SOURCE_ADDRESS_LENGTH, DERIVED_PAIRS, XCHAIN_PRICE_WINDOW_BLOCKS, XCHAIN_PRICE_CONFIRMATION_BUFFER, diff --git a/src/cross_chain/dex_consensus.js b/src/cross_chain/dex_consensus.js index 766db9bd8..45e6d709d 100644 --- a/src/cross_chain/dex_consensus.js +++ b/src/cross_chain/dex_consensus.js @@ -45,6 +45,8 @@ const EventEmitter = require('events'); const { positiveIntConfig } = require('../lib/config_int.js'); +const { DEFAULT_XDEX_ROUND_TIMEOUT_MS } = require('../constants.js'); +const hubConfig = require('../config'); const ah = require('../lib/admission_height.js'); const { getLogger } = require('../observability'); const logger = getLogger(); @@ -61,8 +63,6 @@ const XDEX_MATCH_VIEW_CHANGE = 'XDEX_MATCH_VIEW_CHANGE'; const XDEX_MATCH_NEW_VIEW = 'XDEX_MATCH_NEW_VIEW'; const XDEX_MATCH_FINAL_SYNC = 'XDEX_MATCH_FINAL_SYNC'; -const DEFAULT_ROUND_TIMEOUT_MS = 120000; // 2 minutes per match round before view-change - class CrossChainDexConsensus extends EventEmitter { // engine: the CrossChainDexEngine. Used for canonicalMatch (the signable @@ -114,13 +114,19 @@ class CrossChainDexConsensus extends EventEmitter { this.initEarlyBuffer(); this._messageHandler = null; - this.roundTimeoutMs = parseInt(this.config.XDEX_ROUND_TIMEOUT_MS) || DEFAULT_ROUND_TIMEOUT_MS; + // Resolve env first, then p2pConfig, as the mirror admission watermark does: its + // xdex trail is this engine's terminal bound, so both must read one value. + this.roundTimeoutMs = positiveIntConfig( + hubConfig.XDEX_ROUND_TIMEOUT_MS || this.config.XDEX_ROUND_TIMEOUT_MS, + DEFAULT_XDEX_ROUND_TIMEOUT_MS, 'XDEX_ROUND_TIMEOUT_MS'); // A round that keeps view-changing without ever finalizing (sustained // message loss, e.g. P2P rate-limit drops during a burst of concurrent // rounds) must not leak in `pending` forever: past this lifetime it is // abandoned so the engine can re-propose a fresh round once the storm // clears. Default = several view-change cycles. - this.roundMaxLifetimeMs = parseInt(this.config.XDEX_ROUND_MAX_LIFETIME_MS) || (this.roundTimeoutMs * 4); + this.roundMaxLifetimeMs = positiveIntConfig( + hubConfig.XDEX_ROUND_MAX_LIFETIME_MS || this.config.XDEX_ROUND_MAX_LIFETIME_MS, + this.roundTimeoutMs * 4, 'XDEX_ROUND_MAX_LIFETIME_MS'); } async start(){ diff --git a/src/lib/idempotent_broadcast.js b/src/lib/idempotent_broadcast.js index 14bd483b5..ec515167f 100644 --- a/src/lib/idempotent_broadcast.js +++ b/src/lib/idempotent_broadcast.js @@ -36,11 +36,14 @@ * * isNeverSentError(e) * The strict half of the definitive class: not merely "retrying is safe" but - * "no byte of this transaction left the process". Two shapes prove it, and they - * are the two isAmbiguousSendError already reads: a sub-500 HTTP response (the - * encoder refused the call before it ever built or forwarded a transaction, for - * example insufficient funds or an unconfirmed-change refusal) and a - * never-connected transport code. A NAMED node rejection is definitive but NOT + * "no byte of this transaction left the process". Three shapes prove it, and + * isAmbiguousSendError reads all three as definitive: a `neverSent` tag, which a + * pipeline sets on a failure raised before its send step (the encoder answers a + * refused create_tx, such as insufficient funds or an unconfirmed-change refusal, + * as an HTTP 200 JSON-RPC error, so only the pipeline knows nothing was sent); a + * sub-500 HTTP response (the encoder's auth, batch-size and rate-limit gates + * refusing the call before processing); and a never-connected transport code. + * A NAMED node rejection is definitive but NOT * never-sent, because the transaction reached the node to be rejected; callers * that need "nothing was spent and nothing is in flight" must not read it as * such. Used by callers that hold a durable publish-intent marker and want to @@ -93,6 +96,8 @@ function isAmbiguousSendError(e){ // definition and the caller must dead-letter rather than rebuild. The hook sets the // flag; nothing else in the hub does. if (e.fundsCommitted) return true; + // A pipeline tags neverSent only on a failure before its send step, so a retry cannot double-spend. + if (e.neverSent === true) return false; let message = String(e.message || ''); // A definitive rejection and an ambiguous transport failure share the SAME // 'Encoder RPC error' envelope, so this case is read before the prefix is trusted. @@ -114,10 +119,11 @@ function isAmbiguousSendError(e){ // named rejection is the node's own verdict on a transaction it received. function isNeverSentError(e){ if (!e) return false; - // Outranks both shapes below for the same reason it outranks every rule in + // Outranks every shape below for the same reason it outranks every rule in // isAmbiguousSendError: a multi-phase signer hook can carry a funded phase-one // transaction on chain and still surface a 4xx or an ECONNREFUSED on its reveal. if (e.fundsCommitted) return false; + if (e.neverSent === true) return true; // failed before the send step if (e.response && Number(e.response.status) < 500) return true; // refused before processing let code = String(e.code || ''); return code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EAI_AGAIN'; // never connected diff --git a/src/oracle/publisher/rotation.js b/src/oracle/publisher/rotation.js index f32413d5e..cb5788148 100644 --- a/src/oracle/publisher/rotation.js +++ b/src/oracle/publisher/rotation.js @@ -23,6 +23,7 @@ const nodeUtil = require('node:util'); const { getLogger } = require('../../observability'); +const swq = require('../../consensus/stake_weighted_quorum.js'); const logger = getLogger(); module.exports = { @@ -67,12 +68,17 @@ module.exports = { // Primary: deterministic on-chain snapshot at blockIndex if (this.hub.capabilitySnapshot && blockIndex !== undefined && blockIndex !== null) { try { - let snapshot = await this.hub.capabilitySnapshot.getSnapshot('oracle_publish', blockIndex); + let snapshot = await this.oraclePublishSnapshot(blockIndex); if (snapshot && Array.isArray(snapshot.validators)) { - this._snapshotDark = false; - return snapshot.validators - .map(v => String(v.pubkey).toLowerCase()) - .sort(); + // Dedupe: weighted snapshots carry one row per (source, pubkey), and rank must see each key once. + let pubkeys = [...new Set(snapshot.validators.map(v => String(v.pubkey).toLowerCase()))].sort(); + if (pubkeys.length > 0) { + this._snapshotDark = false; + return pubkeys; + } + // An empty set elects no leader on any hub, so log it as loudly as the null path. + this.logSnapshotDark('resolved to an empty member set at block ' + blockIndex, null); + return []; } // Snapshot resolved to null. This is the dominant dark path: // CapabilitySnapshot.getSnapshot returns null (does NOT throw) when the @@ -99,6 +105,17 @@ module.exports = { return []; }, + // Resolve the block-pinned oracle_publish membership the leader quorum judges by: + // the source-keyed weight snapshot at/above STAKE_WEIGHTED_QUORUM, the count + // snapshot below it, twin of StateAnchorPublisher.oraclePublishSnapshot. Gated on + // the DEPLOYMENT network only, never a wire-supplied one. + // DEPLOY NOTE: upgrade fleet-wide at once; where the two sets differ, mixed hubs rank differently. + oraclePublishSnapshot(blockIndex) { + return swq.isStakeWeightedQuorumActive(Number(blockIndex), this.network) + ? this.hub.capabilitySnapshot.getWeightSnapshot('oracle_publish', blockIndex) + : this.hub.capabilitySnapshot.getSnapshot('oracle_publish', blockIndex); + }, + // Fallback: build a single-validator signature locally if the round event didn't carry any. // Only used in degenerate cases; normal operation collects sigs from consensus prepare/commit. buildLocalSigOnly(event) { diff --git a/src/oracle/xchain_price_source.js b/src/oracle/xchain_price_source.js index c8c9819c8..168b0dfea 100644 --- a/src/oracle/xchain_price_source.js +++ b/src/oracle/xchain_price_source.js @@ -223,14 +223,17 @@ class XchainPriceSource { } // Final ingestion bound, applied to every value this file emits whether derived or - // carried forward. The API sources bound their values the same way for the same - // reason: garbage must never enter a round, and a carried-forward value read out - // of a database is no more trusted than a fetched one. + // carried forward: garbage must never enter a round, and a carried-forward value read + // out of a database is no more trusted than a fetched one. Compare doubles, not exact + // decimals, so this is the same predicate the API sources, co-sign gate, aggregation + // and ingest apply; an exact bound admits the sub-ulp band below PRICE_MAX that + // parseFloat rounds onto the ceiling, and a pair every gate then refuses costs the round. entry(price, meta) { let value; try { value = bcmath.bcformat(price, 8); - if (!bcmath.bcgt(value, '0') || !bcmath.bclt(value, String(PRICE_MAX))) { + let val = parseFloat(value); + if (!(Number.isFinite(val) && val > 0 && val < PRICE_MAX)) { logger.warn('XchainPriceSource: abstaining from ' + XCHAIN_PAIR + ' - computed value ' + price + ' failed the ingestion bound (0 < value < ' + PRICE_MAX + ')'); return null; diff --git a/src/peers/hub_db/admission_height_watermark.js b/src/peers/hub_db/admission_height_watermark.js index e9614eed8..65dbb61b2 100644 --- a/src/peers/hub_db/admission_height_watermark.js +++ b/src/peers/hub_db/admission_height_watermark.js @@ -23,6 +23,8 @@ const { positiveIntConfig } = require('../../lib/config_int.js'); const { ADMIT_COLUMN_CHAINS, normalizeChain, rowAdmitBlocks } = require('../../lib/admission_height.js'); const { admitMarginBlocks } = require('../../consensus/gates/mirror_admission_gate.js'); +const { DEFAULT_ORACLE_ROUND_INTERVAL_MS, DEFAULT_XDEX_ROUND_TIMEOUT_MS, + DEFAULT_ATTESTATION_ROUND_TIMEOUT_MS, DEFAULT_ANCHOR_ROUND_TIMEOUT_MS } = require('../../constants.js'); const hubConfig = require('../../config'); // --------------------------------------------------------------------------- @@ -105,23 +107,24 @@ class AdmissionHeightWatermark { // round timeout, because a view change re-arms the timeout on a round that is still // open (CrossChainDexConsensus.js:134, :140). let xdexTimeout = positiveIntConfig( - hubConfig.XDEX_ROUND_TIMEOUT_MS || this.config.XDEX_ROUND_TIMEOUT_MS, 120000, 'XDEX_ROUND_TIMEOUT_MS'); + hubConfig.XDEX_ROUND_TIMEOUT_MS || this.config.XDEX_ROUND_TIMEOUT_MS, + DEFAULT_XDEX_ROUND_TIMEOUT_MS, 'XDEX_ROUND_TIMEOUT_MS'); this.roundWindows = { xdex: positiveIntConfig( hubConfig.XDEX_ROUND_MAX_LIFETIME_MS || this.config.XDEX_ROUND_MAX_LIFETIME_MS, xdexTimeout * 4, 'XDEX_ROUND_MAX_LIFETIME_MS'), attest: positiveIntConfig( hubConfig.ATTESTATION_ROUND_TIMEOUT_MS || this.config.ATTESTATION_ROUND_TIMEOUT_MS, - 120000, 'ATTESTATION_ROUND_TIMEOUT_MS'), + DEFAULT_ATTESTATION_ROUND_TIMEOUT_MS, 'ATTESTATION_ROUND_TIMEOUT_MS'), anchor: positiveIntConfig( hubConfig.ANCHOR_ROUND_TIMEOUT_MS || this.config.ANCHOR_ROUND_TIMEOUT_MS, - 120000, 'ANCHOR_ROUND_TIMEOUT_MS'), + DEFAULT_ANCHOR_ROUND_TIMEOUT_MS, 'ANCHOR_ROUND_TIMEOUT_MS'), // A price round's terminal bound is its own cadence: the next round opens only // once this one is finalized or skipped, so the interval is what bounds how long // a round can hold the watermark. price: positiveIntConfig( hubConfig.ORACLE_ROUND_INTERVAL || this.config.ORACLE_ROUND_INTERVAL, - 600000, 'ORACLE_ROUND_INTERVAL'), + DEFAULT_ORACLE_ROUND_INTERVAL_MS, 'ORACLE_ROUND_INTERVAL'), // oracle_prices has no consensus round at all: the rows are the hub's own ingest // of an on-chain PRICE v1 transaction, so the bound is how long an ingest may // trail the chain it reads. Its own knob, because nothing else sizes it. diff --git a/src/validators/governance/bounds.js b/src/validators/governance/bounds.js index 63f23fba5..9061c6f2d 100644 --- a/src/validators/governance/bounds.js +++ b/src/validators/governance/bounds.js @@ -29,6 +29,12 @@ const { ORACLE_DEVIATION_THRESHOLD } = require('../../constants.js'); const { SLASHING_PARAMS, MAX_INCREASE, MAX_DECREASE, MAX_SLASH_INCREASE, MAX_SLASH_DECREASE, parseDecimalParts, toScaledBigInt } = require('./rules.js'); +// Bound a proposed value echoed into an error, since the inbound path logs the message. +function echoValue(v) { + let s = String(v); + return s.length > 64 ? s.slice(0, 64) + '...' : s; +} + module.exports = { validateChangeBounds(parameter, currentValue, proposedValue) { @@ -38,24 +44,27 @@ module.exports = { this.validateSlashBandFloor(parameter, proposedValue); }, - // Absolute floor under the slash band, mirroring the guard SlashDetector's - // constructor already hard-enforces (SlashDetector.js): a slash band tighter than - // the federation-uniform oracle co-sign band would slash submissions inside the - // band the federation just co-signed. validateChangeRatio caps only the SIZE of a - // change, so from the 0.05 default a -20% proposal (0.04) cleared every gate, - // and applying the approved value then bricked the hub at its next restart. + // Absolute floor under the slash band, mirroring resolveDeviationThreshold in + // slash_detector/options.js: a slash band tighter than the federation-uniform + // oracle co-sign band would slash submissions inside the band the federation just + // co-signed, and a non-finite band slashes every submission. That guard reads the + // value on presence and throws on a non-finite band and on anything below the + // oracle band, an explicit 0 included, so approving either would brick the hub at + // its next restart; the floor refuses the same values. // Sits outside validateChangeRatio's numeric early-returns so a non-numeric or - // zero CURRENT value cannot skip it. A proposed 0 is refused here although - // SlashDetector's `parseFloat(...) || DEFAULT` would fall back to the band: the - // ratio bound already refuses it, and refusing is the safe direction. + // zero CURRENT value cannot skip it. // DEPLOY NOTE: same mixed-version caveat as the follower-path bounds re-check in - // handlePropose - fixed hubs drop a sub-floor Byzantine proposal that unfixed - // hubs still record. Honest proposals always clear the floor, so honest traffic - // never diverges. + // handlePropose - fixed hubs drop a sub-floor or non-decimal Byzantine proposal + // that unfixed hubs still record. Honest proposals are plain decimals that clear + // the floor, so honest traffic never diverges. validateSlashBandFloor(parameter, proposedValue) { if (parameter !== 'SLASH_DEVIATION_THRESHOLD') return; let band = parseFloat(proposedValue); - if (!Number.isFinite(band) || band >= ORACLE_DEVIATION_THRESHOLD) return; + if (!Number.isFinite(band)) { + throw new Error('SLASH_DEVIATION_THRESHOLD (' + echoValue(proposedValue) + ') is not a ' + + 'valid number, and SlashDetector refuses to construct on it.'); + } + if (band >= ORACLE_DEVIATION_THRESHOLD) return; throw new Error('SLASH_DEVIATION_THRESHOLD (' + band + ') is below the federation-uniform ' + 'ORACLE_DEVIATION_THRESHOLD (' + ORACLE_DEVIATION_THRESHOLD + '): this would slash ' + 'submissions inside the co-signed band, and SlashDetector refuses to construct on it.'); @@ -65,6 +74,12 @@ module.exports = { // Only validate numeric parameters let cur = parseDecimalParts(currentValue); let prop = parseDecimalParts(proposedValue); + let isSlashParam = SLASHING_PARAMS.includes(parameter); + // Refuse a non-decimal slash value, or exponent and junk-suffix forms skip the bound. + if (isSlashParam && !prop) { + throw new Error(parameter + ' (' + echoValue(proposedValue) + ') must be a plain decimal: ' + + 'no exponent notation, trailing text, NaN or Infinity.'); + } if (!cur || !prop) return; let scale = Math.max(cur.frac.length, prop.frac.length); @@ -72,7 +87,6 @@ module.exports = { let P = toScaledBigInt(prop, scale); if (C === 0n) return; - let isSlashParam = SLASHING_PARAMS.includes(parameter); let maxIncrease = isSlashParam ? MAX_SLASH_INCREASE : MAX_INCREASE; let maxDecrease = isSlashParam ? MAX_SLASH_DECREASE : MAX_DECREASE; diff --git a/src/validators/governance/propose_inbound.js b/src/validators/governance/propose_inbound.js index a5819df8f..1f21364d5 100644 --- a/src/validators/governance/propose_inbound.js +++ b/src/validators/governance/propose_inbound.js @@ -189,8 +189,9 @@ module.exports = { // propose(); without this, every hub records and votes on an out-of-bounds // change. Drop it (never record it) so the whole federation ignores it, // matching the MIN_STAKE and block-anchor drops above. validateChangeBounds - // is a no-op for non-numeric parameters, so only numeric out-of-bounds - // proposals are affected. + // drops a non-decimal value for a slashing parameter and skips one for any + // other parameter, so only numeric out-of-bounds proposals and non-decimal + // slashing proposals are affected. // // DEPLOY NOTE: enforce fleet-wide in one coordinated upgrade. During a // mixed-version window, fixed hubs drop an out-of-bounds Byzantine proposal diff --git a/test/unit/anchor/reward_tracker_amount_string.test.js b/test/unit/anchor/reward_tracker_amount_string.test.js new file mode 100644 index 000000000..a7e9ccdd8 --- /dev/null +++ b/test/unit/anchor/reward_tracker_amount_string.test.js @@ -0,0 +1,72 @@ +'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. + +// The anchor reward amount is a decimal string end to end: the recorded row carries a +// frozen consensus constant byte-for-byte (the indexer credits and signs it verbatim), +// and the archive co-sign expectation agrees with the recorder on both branches. +// +// The frozen constant is respelled to '12.5' here on purpose: '10.00000000' survives a +// float round-trip unchanged, so with the shipped spelling these assertions prove nothing. + +const { expect } = require('chai'); +const ar = require('../../../src/consensus/gates/anchor_reward_gate.js'); +const RewardTracker = require('../../../src/anchor/reward_tracker'); +const StateAnchorPublisher = require('../../../src/anchor/publisher'); +const { createMockHub } = require('../../helpers/mockHub'); + +const PK = 'ab'.repeat(32); +const NETWORK = 'testnet'; // every anchor-reward flag-day is 0 on testnet +const BLOCK = 970000; + +function expectedOnArchive(rewardType, knob) { + const self = { hub: { rewardTracker: { anchorReward: knob } } }; + return StateAnchorPublisher.prototype.expectedArchivedRewardAmount.call( + self, { reward_type: rewardType, block_index: BLOCK }, NETWORK); +} + +async function recordedAmount(rewardType, knob, network) { + const rt = new RewardTracker(createMockHub({ p2pConfig: { ANCHOR_REWARD_PER_PUBLISH: knob } })); + await rt.recordAnchorReward(rewardType, 5, PK, BLOCK, network); + const insert = rt.db.doQuery.getCalls().find(c => String(c.args[0]).includes('INSERT IGNORE INTO validator_rewards')); + return insert ? insert.args[1][3] : null; +} + +describe('RewardTracker anchor reward amount (decimal string)', function () { + let savedAnchor, savedArchive; + beforeEach(function () { + savedAnchor = ar.ANCHOR_REWARD_AMOUNT; + savedArchive = ar.ARCHIVE_REWARD_AMOUNT; + ar.ANCHOR_REWARD_AMOUNT = '12.5'; + ar.ARCHIVE_REWARD_AMOUNT = '12.5'; + }); + afterEach(function () { + ar.ANCHOR_REWARD_AMOUNT = savedAnchor; + ar.ARCHIVE_REWARD_AMOUNT = savedArchive; + }); + + for (const rewardType of ['anchor_BTC', 'anchor_bundle', 'anchor_archive']) { + it(`records the frozen constant verbatim for a derived ${rewardType} reward`, async function () { + expect(await recordedAmount(rewardType, '7.5', NETWORK)).to.equal('12.5'); + expect(expectedOnArchive(rewardType, '7.5'), 'co-sign expectation').to.equal('12.5'); + }); + } + + it('renders the legacy knob on the 8-decimal grid, identically on both sides', async function () { + expect(await recordedAmount('anchor_BTC', '2.5', '')).to.equal('2.50000000'); + expect(RewardTracker.legacyAnchorRewardAmount('2.5')).to.equal('2.50000000'); + }); + + it('records nothing for a prefix-numeric knob, and co-signs no legacy amount for it', async function () { + expect(await recordedAmount('anchor_BTC', '10abc', '')).to.equal(null); + expect(RewardTracker.legacyAnchorRewardAmount('10abc')).to.equal('0.00000000'); + }); +}); diff --git a/test/unit/api/p2p_config_oracle_cadence.test.js b/test/unit/api/p2p_config_oracle_cadence.test.js new file mode 100644 index 000000000..0d9e6b6ff --- /dev/null +++ b/test/unit/api/p2p_config_oracle_cadence.test.js @@ -0,0 +1,109 @@ +'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. + +// The oracle cadence knobs as src/api.js puts them on the p2pConfig a validator hub is +// built with. The interval divides elapsed time into federation round numbers, so a +// non-positive value must degrade to the shared default rather than reach OracleRound. + +const sinon = require('sinon'); +const { expect } = require('chai'); +const proxyquire = require('proxyquire').noPreserveCache(); + +const { waitUntil } = require('../../helpers/waitUntil'); +const { DB_METHODS } = require('../../helpers/mockHub'); +const { DEFAULT_ORACLE_ROUND_INTERVAL_MS, DEFAULT_ORACLE_SUBMISSION_WINDOW_MS } = require('../../../src/constants.js'); + +const ENV_KEYS = ['HUB_API_KEY', 'HUB_REORG_API_KEY', 'HUB_SENSITIVE_READ_AUTH', 'HUB_ALLOW_UNAUTHENTICATED', + 'HUB_DB_HOST', 'HUB_DB_PORT', 'HUB_DB_NAME', 'HUB_DB_USER', 'HUB_DB_PASS', 'HUB_PORT', + 'P2P_VALIDATOR_ADDR', 'HUB_NETWORK', 'ORACLE_EPOCH_START', + 'ORACLE_ROUND_INTERVAL', 'ORACLE_SUBMISSION_WINDOW']; + +function makeHub() { + const noop = async () => {}; + return { + db: { ...DB_METHODS, doQuery: sinon.stub().resolves([]), circuitState: 'closed' }, + capabilitySnapshot: null, stateAnchorPublisher: null, attestationPublisher: null, + start: noop, startP2P: noop, startConsensus: noop, startOracle: noop, startCrossChain: noop, + startReorgHandler: noop, startGovernance: noop, startAttestation: noop, startCapabilities: noop, + on: () => {} + }; +} + +function apiStubs(captured) { + const app = { use: sinon.stub(), get: sinon.stub(), post: sinon.stub(), set: sinon.stub(), + listen: sinon.stub().callsFake((p, h, cb) => { if (cb) cb(); }) }; + const express = sinon.stub().returns(app); + express.json = sinon.stub().returns(function expressJson() {}); + const server = { listen: sinon.stub().callsFake((p, h, cb) => { if (cb) cb(); }), on: sinon.stub() }; + return { + 'dotenv': { config: sinon.stub() }, + 'express': express, + 'helmet': sinon.stub().returns(function helmetMw() {}), + 'cors': sinon.stub().returns(function corsMw() {}), + 'express-rate-limit': sinon.stub().returns(function rateLimitMw() {}), + 'express-json-rpc-router': (opts) => { captured.methods = opts.methods; return function routerMw() {}; }, + 'http': { createServer: sinon.stub().returns(server) }, + 'ws': { Server: sinon.stub().returns({ on: sinon.stub() }) }, + 'geoip-lite': { lookup: sinon.stub().returns(null) }, + './XChainHub': function () { captured.p2pConfig = arguments[5]; return makeHub(); } + }; +} + +// Boot src/api.js in validator mode and return the p2pConfig it hands the hub. +async function bootP2pConfig(env) { + const captured = { p2pConfig: undefined, methods: null, exits: [] }; + const saved = {}; + for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; } + Object.assign(process.env, { + HUB_DB_HOST: 'localhost', HUB_DB_PORT: '3306', HUB_DB_NAME: 'testdb', HUB_DB_USER: 'root', + HUB_DB_PASS: 'pass', HUB_PORT: '9998', HUB_API_KEY: 'k', P2P_VALIDATOR_ADDR: 'bc1qexample', + HUB_NETWORK: 'regtest', ORACLE_EPOCH_START: '1700000000000' + }, env); + const exitStub = sinon.stub(process, 'exit').callsFake((code) => { captured.exits.push(code); }); + try { + proxyquire('../../../src/api', apiStubs(captured)); + await waitUntil(() => captured.methods || captured.exits.length > 0, + { label: 'api.js boot to serve or refuse' }); + } finally { + exitStub.restore(); + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } + expect(captured.exits, 'validator boot refused').to.deep.equal([]); + return captured.p2pConfig; +} + +describe('src/api.js p2pConfig oracle cadence', function () { + // The first proxyquire of api.js loads its whole require graph. + this.timeout(20000); + afterEach(function () { sinon.restore(); }); + + it('carries a positive operator interval and window through unchanged', async function () { + const cfg = await bootP2pConfig({ ORACLE_ROUND_INTERVAL: '60000', ORACLE_SUBMISSION_WINDOW: '20000' }); + expect(cfg.ORACLE_ROUND_INTERVAL).to.equal(60000); + expect(cfg.ORACLE_SUBMISSION_WINDOW).to.equal(20000); + }); + + it('lands on the shared defaults when both knobs are unset', async function () { + const cfg = await bootP2pConfig({}); + expect(cfg.ORACLE_ROUND_INTERVAL).to.equal(DEFAULT_ORACLE_ROUND_INTERVAL_MS); + expect(cfg.ORACLE_SUBMISSION_WINDOW).to.equal(DEFAULT_ORACLE_SUBMISSION_WINDOW_MS); + }); + + it('degrades a negative interval and window to the shared defaults', async function () { + const cfg = await bootP2pConfig({ ORACLE_ROUND_INTERVAL: '-600000', ORACLE_SUBMISSION_WINDOW: '-1' }); + expect(cfg.ORACLE_ROUND_INTERVAL).to.equal(DEFAULT_ORACLE_ROUND_INTERVAL_MS); + expect(cfg.ORACLE_SUBMISSION_WINDOW).to.equal(DEFAULT_ORACLE_SUBMISSION_WINDOW_MS); + }); +}); diff --git a/test/unit/api/rpc_cross_chain_call_id.test.js b/test/unit/api/rpc_cross_chain_call_id.test.js new file mode 100644 index 000000000..ca2efc2c5 --- /dev/null +++ b/test/unit/api/rpc_cross_chain_call_id.test.js @@ -0,0 +1,67 @@ +'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. +// +// getcrosschaincall and its getxcall alias: a call_id is a 64-char hex sha256, so a +// present but malformed one is refused in the envelope before any read, and a +// well-formed one reaches the hub normalized. + +const sinon = require('sinon'); +const { expect } = require('chai'); +const { buildCrossChainRpc } = require('../../../src/api/rpc/cross_chain.js'); + +const CALL_ID = 'ab'.repeat(32); +const METHODS = ['getcrosschaincall', 'getxcall']; + +function makeRpc() { + let hub = { + crossChainCalls: {}, + getCrossChainCall: sinon.stub().callsFake(async (id) => (id === CALL_ID ? { call_id: id } : null)), + }; + return { hub, rpc: buildCrossChainRpc({ hub }) }; +} + +function registerShapeTests() { + it('refuses a non-string, a short or non-hex id and a megabyte of junk without reading', async function () { + for (const m of METHODS) { + let { hub, rpc } = makeRpc(); + for (const bad of [{}, [], 123, 'ab'.repeat(31), 'zz'.repeat(32), 'x'.repeat(1 << 20)]) { + let res = await rpc[m]({ call_id: bad }); + expect(res.error, m).to.equal('call_id must be a 64-character hex string'); + } + expect(hub.getCrossChainCall.called, m).to.equal(false); + } + }); + + it('keeps the missing-value message', async function () { + for (const m of METHODS) { + let { rpc } = makeRpc(); + expect((await rpc[m]({})).error, m).to.equal('call_id is required'); + expect((await rpc[m]({ call_id: '' })).error, m).to.equal('call_id is required'); + } + }); + + it('resolves a well-formed id, upper-case and padded included, as its normalized form', async function () { + for (const m of METHODS) { + let { hub, rpc } = makeRpc(); + expect(await rpc[m]({ call_id: CALL_ID })).to.deep.equal({ call_id: CALL_ID }); + expect(await rpc[m]({ call_id: ' ' + CALL_ID.toUpperCase() + ' ' })).to.deep.equal({ call_id: CALL_ID }); + expect(hub.getCrossChainCall.alwaysCalledWith(CALL_ID), m).to.equal(true); + } + }); + + it('still reports an unknown well-formed id as not found', async function () { + let { rpc } = makeRpc(); + expect((await rpc.getxcall({ call_id: 'cd'.repeat(32) })).error).to.equal('cross-chain call not found'); + }); +} + +describe('JSON-RPC cross-chain call reads: call_id shape', registerShapeTests); diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_015_a_head_refused_by_create_tx.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_015_a_head_refused_by_create_tx.test.js new file mode 100644 index 000000000..f21adf9c3 --- /dev/null +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_015_a_head_refused_by_create_tx.test.js @@ -0,0 +1,339 @@ +/********************************************************************* + * + * 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 Hub - AttestationBatchPublisher unit tests: a head the encoder refused at + * create_tx. The encoder answers that refusal as an HTTP 200 JSON-RPC error, so no + * status or transport code proves it unsent; the default pipeline's own neverSent tag + * on a build-stage failure is what lets the window withdraw its marker and rebuild. + * + ********************************************************************/ + +'use strict'; + +const os = require('os'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { expect } = require('chai'); + +const AttestationBatchPublisher = require('../../../../../src/attestation/batch_publisher.js'); +const ValidatorIdentity = require('../../../../../src/validators/identity.js'); +const abw = require('../../../../../src/lib/attest_batch_wire.js'); +const { isNeverSentError, isAmbiguousSendError } = require('../../../../../src/lib/idempotent_broadcast.js'); +const { DB_METHODS } = require('../../../../helpers/mockHub.js'); + +const WINDOW_S = 10; // regtest override; the whole suite closes windows in seconds +const ANCHOR = 941234; + +// --------------------------------------------------------------------------- +// Two in-memory tables with just enough SQL surface for the publisher's reads and +// writes, plus the chain-tip accessor it resolves its anchor from. +// --------------------------------------------------------------------------- +function selectResponses(responses, sql, args){ + // Membership is read off the column the statement actually names, so a + // publisher that went back to the audit column selects nothing here. + let column = /WHERE network = \? AND ([a-z_]+) >= \?/i.exec(sql)[1]; + let [network, from, to, limit] = args; + return responses + .filter(r => r.network === network && r[column] >= from && r[column] < to) + .sort((a, b) => (a.request_block_index - b.request_block_index) || + (a.request_action_index - b.request_action_index) || + (a.request_id < b.request_id ? -1 : 1)) + .slice(0, limit) + .map(r => Object.assign({}, r)); +} + +function deleteMarker(markers, args){ + // Read BEFORE the marker SELECT below, whose predicate this statement shares: + // matched the other way round, a withdraw would silently read as a lookup and + // the row it was meant to remove would survive. + let [network, windowStart, fromStatus] = args; + // The status predicate is honoured, because the whole safety argument for + // the withdraw is that it can only remove an intent-only row: a fake that + // deleted unconditionally could not fail the case that matters. + let idx = markers.findIndex(m => m.network === network && + Number(m.window_start) === Number(windowStart) && + m.status === fromStatus); + if(idx < 0) return { affectedRows: 0 }; + markers.splice(idx, 1); + return { affectedRows: 1 }; +} + +function insertMarker(markers, sql, args){ + let cols = sql.substring(sql.indexOf('(') + 1, sql.indexOf(')')).split(',').map(s => s.trim()); + let row = {}; + cols.forEach((c, i) => { row[c] = args[i]; }); + let existing = markers.find(m => Number(m.window_start) === Number(row.window_start)); + if(existing){ + // Only the landed/dead-letter statements carry an updating clause; the + // intent statement's is a deliberate no-op, and the fake honours that + // difference so a replay cannot downgrade a marker here either. + if(/status = VALUES\(status\)/i.test(sql)){ + existing.status = row.status; + existing.row_count = row.row_count; + } + return { affectedRows: 0 }; + } + markers.push(row); + return { affectedRows: 1 }; +} + +function updateMarker(markers, args){ + let [status, txid, rowCount, network, windowStart, fromStatus] = args; + let found = markers.find(m => m.network === network && + Number(m.window_start) === Number(windowStart) && + m.status === fromStatus); + if(!found) return { affectedRows: 0 }; + Object.assign(found, { status, txid, row_count: rowCount }); + return { affectedRows: 1 }; +} + +function doDbQuery(responses, markers, sql, args){ + if(/FROM attestation_responses/i.test(sql)) return selectResponses(responses, sql, args); + if(/^DELETE FROM attest_published_batches/i.test(sql)) return deleteMarker(markers, args); + // The floor read. BOTH aggregates are answered from the same row set, so a + // publisher that went back to flooring on the newest marker reads a real + // value here rather than an undefined the test would silently coerce. + if(/SELECT MIN\(window_start\)/i.test(sql)){ + let oldest = markers.reduce((m, r) => Math.min(m, Number(r.window_start)), Infinity); + let newest = markers.reduce((m, r) => Math.max(m, Number(r.window_start)), 0); + return [{ oldest: Number.isFinite(oldest) ? oldest : null, newest: newest || null }]; + } + if(/SELECT window_start FROM attest_published_batches/i.test(sql)){ + return markers.filter(m => m.status === args[1]).map(m => ({ window_start: m.window_start })); + } + if(/FROM attest_published_batches WHERE network = \? AND window_start = \?/i.test(sql)){ + let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); + return found ? [Object.assign({}, found)] : []; + } + if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); + throw new Error('unexpected statement: ' + sql); +} + +function makeDb(){ + let responses = []; + let markers = []; + let tip = { blockHeight: ANCHOR, blockTime: 1 }; + return { ...DB_METHODS, + responses, markers, + setTip(h){ tip = (h === null) ? null : { blockHeight: h, blockTime: 1 }; }, + marker(windowStart){ return markers.find(m => Number(m.window_start) === Number(windowStart)) || null; }, + async getChainTip(){ return tip; }, + async doQuery(sql, args){ + return doDbQuery(responses, markers, sql, args); + } + }; +} + +// `effective_time` is what places a row in a window, so every case sets it. The +// default `finalized_at` is deliberately absurd: it belongs to no window any test +// publishes, so a publisher that partitioned on the audit column would find an empty +// window everywhere in this file rather than a subtly different one. +function makeRow(overrides){ + let rid = crypto.randomBytes(32).toString('hex'); + return Object.assign({ + network: 'regtest', + request_id: rid, + request_action_index: 4400, + request_block_index: 120, + provider_id: 'http_get', + status: 'ok', + response_payload: '{"ok":true}', + response_hash: crypto.createHash('sha256').update('body').digest('hex'), + meta: '200', + effective_time: 1780000120, + signer_pubkeys: '[]', + signatures: '[]', + widen: 0, + finalized_at: 7 + }, overrides || {}); +} + +// A capability snapshot of `n` validators, the first of which is this hub. Weights +// are deliberately uneven so a two-of-three signer set clears the strict 2/3 stake +// bar, which equal weights would sit exactly on and fail. +function makeSnapshot(privkeys){ + let weights = ['100', '100', '10', '10', '10']; + return { + validators: privkeys.map((pk, i) => ({ + pubkey: new ValidatorIdentity(pk).getPubkeyHex().toLowerCase(), + weight: weights[i] || '10', + amount: weights[i] || '10', + source: 'src' + i + })), + count: privkeys.length + }; +} + +function makeHub(opts){ + opts = opts || {}; + let db = opts.db || makeDb(); + let ids = opts.identities || [ValidatorIdentity.generate()]; + let identity = new ValidatorIdentity(ids[0].privkeyHex); + let snapshot = makeSnapshot(ids.map(i => i.privkeyHex)); + return { + network: opts.network || 'regtest', + db: db, + p2pConfig: Object.assign({ + ATTEST_BATCH_WINDOW_S_OVERRIDE: String(WINDOW_S), + ATTEST_BATCH_BUFFER_PATH: path.join(opts.dir, 'attest-batch-buffer.jsonl'), + ATTEST_BATCH_SPEND_STATE_PATH: path.join(opts.dir, 'spend-state.json'), + ORACLE_BATCH_SIGN_TIMEOUT_MS: '40' + }, opts.cfg || {}), + getIdentity: () => identity, + capabilitySnapshot: opts.capabilitySnapshot || { + async getWeightSnapshot(){ return snapshot; }, + async getSnapshot(){ return snapshot; } + }, + peerManager: opts.peerManager || null, + _identity: identity, + _snapshot: snapshot + }; +} + +function decodeHead(wire){ + let params = wire.split('|').slice(1); + let head = abw.parseAttestBatchHead(params); + if(!head.ok) throw new Error('head did not parse: ' + head.status); + return head; +} + +// The encoder refusing create_tx the way it really does: HTTP 200, a JSON-RPC error +// body, code -32010 and a machine-readable reason, rethrown by EncoderClient with no +// `.response` and no `.code`. +function createTxRefusal(reason){ + return Object.assign(new Error('Encoder RPC error: Insufficient funds'), + { rpcCode: -32010, rpcData: { reason: reason || 'INSUFFICIENT_FUNDS' } }); +} + +// An encoder stub whose create_tx answers from `script` in order, then builds; every +// broadcast_tx succeeds and is recorded. +function makeEncoder(script){ + let enc = { createCalls: 0, sent: [] }; + enc.getUtxos = async () => [{ txid: 'aa'.repeat(32), vout: 0, value: 100000000, confirmations: 6 }]; + enc.createTx = async (params) => { + let e = script[enc.createCalls++]; + if(e) throw e; + return { psbt: 'psbt:' + params.data }; + }; + enc.broadcastTx = async (txHex) => { enc.sent.push(txHex); return { txid: 'tx' + enc.sent.length }; }; + return enc; +} + +// A publisher on its real default pipeline, with the encoder and wallet hook stubbed. +function makeDefaultPipelinePublisher(hub, encoder){ + let p = new AttestationBatchPublisher(hub); + p.encoder = encoder; + p.dogeAddress = 'DTestAddressXXXXXXXXXXXXXXXXXXXXXX'; + p.setWalletSignHook(async (psbt) => 'signed:' + psbt); + return p; +} + +let dir; + +function registerClassifierTests() { + it('reads a neverSent tag as never sent and not ambiguous', function () { + let e = Object.assign(createTxRefusal(), { neverSent: true }); + expect(isNeverSentError(e)).to.equal(true); + expect(isAmbiguousSendError(e)).to.equal(false); + let timeout = Object.assign(new Error('timeout of 120000ms exceeded'), { code: 'ECONNABORTED', neverSent: true }); + expect(isNeverSentError(timeout)).to.equal(true); + expect(isAmbiguousSendError(timeout)).to.equal(false); + }); + + it('lets fundsCommitted outrank a neverSent tag in both predicates', function () { + let e = Object.assign(createTxRefusal(), { neverSent: true, fundsCommitted: true }); + expect(isNeverSentError(e)).to.equal(false); + expect(isAmbiguousSendError(e)).to.equal(true); + }); + + it('does not read an untagged create_tx refusal as never sent', function () { + expect(isNeverSentError(createTxRefusal())).to.equal(false); + expect(isNeverSentError(Object.assign(createTxRefusal(), { neverSent: 'true' }))).to.equal(false); + }); +} + +function registerPipelineTaggingTests() { + it('tags a create_tx refusal, a get_utxos timeout and a missing PSBT, but never a broadcast failure', async function () { + let hub = makeHub({ dir: dir }); + let p = makeDefaultPipelinePublisher(hub, makeEncoder([createTxRefusal()])); + let err = await p.defaultBroadcast('P').then(() => null, (e) => e); + expect(err.neverSent).to.equal(true); + expect(err.rpcCode).to.equal(-32010); + + p.encoder.getUtxos = async () => { throw Object.assign(new Error('timeout'), { code: 'ECONNABORTED' }); }; + expect((await p.defaultBroadcast('P').then(() => null, (e) => e)).neverSent).to.equal(true); + + p = makeDefaultPipelinePublisher(hub, makeEncoder([])); + p.encoder.createTx = async () => ({}); + expect((await p.defaultBroadcast('P').then(() => null, (e) => e)).neverSent).to.equal(true); + + p = makeDefaultPipelinePublisher(hub, makeEncoder([])); + p.encoder.broadcastTx = async () => { throw new Error('socket hang up'); }; + let sendErr = await p.defaultBroadcast('P').then(() => null, (e) => e); + expect(sendErr.neverSent).to.equal(undefined); + expect(isAmbiguousSendError(sendErr)).to.equal(true); + }); +} + +function registerWindowTests() { + it('withdraws the intent marker on a create_tx head refusal and lands the window next cycle', async function () { + let hub = makeHub({ dir: dir }); + let now = 200 * WINDOW_S; + let start = now - WINDOW_S; + hub.db.responses.push(makeRow({ effective_time: start + 1 })); + let enc = makeEncoder([createTxRefusal()]); + let p = makeDefaultPipelinePublisher(hub, enc); + p._floorWindow = start; + + await p.sweep(now); + expect(enc.sent.length, 'the refused head never went out').to.equal(0); + expect(hub.db.marker(start), 'no intent marker may strand an unsent window').to.equal(null); + expect(p.stats.windowsRefusalRetried).to.equal(1); + expect(p.spendGuard.spentInWindow()).to.equal(0); + + let result = await p.sweep(now); + expect(result.published, 'the rebuilt window must publish').to.equal(1); + expect(enc.sent.length).to.equal(1); + expect(decodeHead(enc.sent[0].replace(/^signed:psbt:/, '')).windowStart).to.equal(start); + expect(hub.db.marker(start).status).to.equal('sent'); + }); + + it('still quarantines once create_tx refusals reach the attempt bound', async function () { + let hub = makeHub({ dir: dir, cfg: { ATTEST_BATCH_MAX_REFUSAL_ATTEMPTS: '2' } }); + let now = 200 * WINDOW_S; + let start = now - WINDOW_S; + hub.db.responses.push(makeRow({ effective_time: start + 1 })); + let enc = makeEncoder([createTxRefusal(), createTxRefusal(), createTxRefusal(), createTxRefusal()]); + let p = makeDefaultPipelinePublisher(hub, enc); + p._floorWindow = start; + + for (let i = 0; i < 4; i++) await p.sweep(now); + expect(enc.sent.length).to.equal(0); + expect(p.stats.windowsRefusalRetried).to.equal(1); + expect(p.stats.windowsQuarantined).to.equal(1); + }); +} + +describe('AttestationBatchPublisher: a head refused at create_tx', function () { + beforeEach(function () { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'attest-batch-')); }); + afterEach(function () { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best effort */ } + }); + + describe('the classifier', registerClassifierTests); + describe('the default pipeline', registerPipelineTaggingTests); + describe('the window', registerWindowTests); +}); diff --git a/test/unit/cross_chain/dex/cross_chain_dex_consensus_round_config.test.js b/test/unit/cross_chain/dex/cross_chain_dex_consensus_round_config.test.js new file mode 100644 index 000000000..1b7c8173b --- /dev/null +++ b/test/unit/cross_chain/dex/cross_chain_dex_consensus_round_config.test.js @@ -0,0 +1,113 @@ +'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. + +// The XDEX round timeout and max lifetime, resolved by the engine and by the mirror +// admission watermark. The watermark's xdex trail is the engine's terminal bound, so +// both must resolve the same value from the same sources in the same order. + +const sinon = require('sinon'); +const { expect } = require('chai'); +const proxyquire = require('proxyquire'); + +const CrossChainDexConsensus = require('../../../../src/cross_chain/dex_consensus.js'); +const { AdmissionHeightWatermark } = require('../../../../src/peers/hub_db/admission_height_watermark.js'); +const { DEFAULT_XDEX_ROUND_TIMEOUT_MS } = require('../../../../src/constants.js'); + +const ENV_KEYS = ['XDEX_ROUND_TIMEOUT_MS', 'XDEX_ROUND_MAX_LIFETIME_MS']; + +// Run fn with exactly `env` set for the two round knobs, restoring the caller's values. +function withEnv(env, fn) { + const saved = {}; + for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; } + Object.assign(process.env, env || {}); + try { return fn(); } + finally { for (const k of ENV_KEYS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } } +} + +function makeConsensus(p2pConfig) { + return new CrossChainDexConsensus({ hub: { p2pConfig: p2pConfig || {} }, peerManager: null, identity: null }); +} + +function registerSourceOrderTests() { + it('lands on the shared default with nothing configured', function () { + let c = withEnv({}, () => makeConsensus()); + expect(c.roundTimeoutMs).to.equal(DEFAULT_XDEX_ROUND_TIMEOUT_MS); + expect(c.roundMaxLifetimeMs).to.equal(DEFAULT_XDEX_ROUND_TIMEOUT_MS * 4); + }); + + it('honours the environment, which p2pConfig never carries on a real hub', function () { + let c = withEnv({ XDEX_ROUND_TIMEOUT_MS: '30000' }, () => makeConsensus()); + expect(c.roundTimeoutMs).to.equal(30000); + expect(c.roundMaxLifetimeMs).to.equal(120000); + }); + + it('lets the environment win over a different p2pConfig value for both knobs', function () { + let c = withEnv({ XDEX_ROUND_TIMEOUT_MS: '5000', XDEX_ROUND_MAX_LIFETIME_MS: '20000' }, + () => makeConsensus({ XDEX_ROUND_TIMEOUT_MS: 90000, XDEX_ROUND_MAX_LIFETIME_MS: 900000 })); + expect(c.roundTimeoutMs).to.equal(5000); + expect(c.roundMaxLifetimeMs).to.equal(20000); + }); + + it('still honours p2pConfig when the environment is unset', function () { + let c = withEnv({}, () => makeConsensus({ XDEX_ROUND_TIMEOUT_MS: 90000, XDEX_ROUND_MAX_LIFETIME_MS: 900000 })); + expect(c.roundTimeoutMs).to.equal(90000); + expect(c.roundMaxLifetimeMs).to.equal(900000); + }); +} + +function registerRejectionTests() { + it('falls back to the default on a negative timeout instead of accepting it', function () { + let fromP2p = withEnv({}, () => makeConsensus({ XDEX_ROUND_TIMEOUT_MS: '-1' })); + expect(fromP2p.roundTimeoutMs).to.equal(DEFAULT_XDEX_ROUND_TIMEOUT_MS); + expect(fromP2p.roundMaxLifetimeMs).to.equal(DEFAULT_XDEX_ROUND_TIMEOUT_MS * 4); + let fromEnv = withEnv({ XDEX_ROUND_TIMEOUT_MS: '-1' }, () => makeConsensus()); + expect(fromEnv.roundTimeoutMs).to.equal(DEFAULT_XDEX_ROUND_TIMEOUT_MS); + }); + + it('falls back to the derived lifetime on a negative p2pConfig lifetime', function () { + let c = withEnv({}, () => makeConsensus({ XDEX_ROUND_TIMEOUT_MS: 10000, XDEX_ROUND_MAX_LIFETIME_MS: -5 })); + expect(c.roundMaxLifetimeMs).to.equal(40000); + }); + + it('follows a retuned shared default rather than a local copy of it', function () { + const Stubbed = proxyquire('../../../../src/cross_chain/dex_consensus.js', { + '../constants.js': { DEFAULT_XDEX_ROUND_TIMEOUT_MS: 77777 }, + }); + let c = withEnv({}, () => new Stubbed({ hub: { p2pConfig: {} }, peerManager: null, identity: null })); + expect(c.roundTimeoutMs).to.equal(77777); + }); +} + +function registerWatermarkAgreementTests() { + it('sizes the watermark xdex trail to the engine lifetime for an env-only timeout', function () { + let both = withEnv({ XDEX_ROUND_TIMEOUT_MS: '30000' }, + () => ({ engine: makeConsensus(), watermark: new AdmissionHeightWatermark({}) })); + expect(both.watermark.roundWindows.xdex).to.equal(both.engine.roundMaxLifetimeMs); + expect(both.engine.roundMaxLifetimeMs).to.equal(120000); + }); + + it('agrees with the watermark when env and p2pConfig disagree', function () { + const p2p = { XDEX_ROUND_TIMEOUT_MS: 90000, XDEX_ROUND_MAX_LIFETIME_MS: 900000 }; + let both = withEnv({ XDEX_ROUND_TIMEOUT_MS: '5000', XDEX_ROUND_MAX_LIFETIME_MS: '20000' }, + () => ({ engine: makeConsensus(p2p), watermark: new AdmissionHeightWatermark(p2p) })); + expect(both.watermark.roundWindows.xdex).to.equal(both.engine.roundMaxLifetimeMs); + }); +} + +describe('CrossChainDexConsensus round timeout and lifetime config', function () { + beforeEach(function () { sinon.stub(console, 'warn'); }); + afterEach(function () { sinon.restore(); }); + + registerSourceOrderTests(); + registerRejectionTests(); + registerWatermarkAgreementTests(); +}); diff --git a/test/unit/oracle/publisher/batch/oracle_publisher_batch.test.js b/test/unit/oracle/publisher/batch/oracle_publisher_batch.test.js index f51fa7954..077b48d31 100644 --- a/test/unit/oracle/publisher/batch/oracle_publisher_batch.test.js +++ b/test/unit/oracle/publisher/batch/oracle_publisher_batch.test.js @@ -120,15 +120,18 @@ function makeSigner(opts) { } // Capability snapshot stand-in. `oracle_publish` drives leader election, `price` -// only sizes the pre-signing estimate. +// only sizes the pre-signing estimate. The weight snapshot serves the same members, +// since election reads it at and above the stake-weighted quorum activation. function makeSnapshot(publishers, priceSet) { + let resolve = async (capability) => { + if (capability === 'oracle_publish') { + return { validators: (publishers || [ME]).map(p => ({ pubkey: p })) }; + } + return { validators: (priceSet || publishers || [ME]).map(p => ({ pubkey: p })) }; + }; return { - getSnapshot: sinon.stub().callsFake(async (capability) => { - if (capability === 'oracle_publish') { - return { validators: (publishers || [ME]).map(p => ({ pubkey: p })) }; - } - return { validators: (priceSet || publishers || [ME]).map(p => ({ pubkey: p })) }; - }) + getSnapshot: sinon.stub().callsFake(resolve), + getWeightSnapshot: sinon.stub().callsFake(resolve), }; } diff --git a/test/unit/oracle/publisher/batch/oracle_publisher_batch_16_publisher_role_and_publish_history.test.js b/test/unit/oracle/publisher/batch/oracle_publisher_batch_16_publisher_role_and_publish_history.test.js index d3a058dc1..72dbc32c8 100644 --- a/test/unit/oracle/publisher/batch/oracle_publisher_batch_16_publisher_role_and_publish_history.test.js +++ b/test/unit/oracle/publisher/batch/oracle_publisher_batch_16_publisher_role_and_publish_history.test.js @@ -83,7 +83,8 @@ const testCase3 = async function () { }; const testCase4 = async function () { - let h = makePublisher({ capabilitySnapshot: { getSnapshot: sinon.stub().resolves(null) } }); + let h = makePublisher({ capabilitySnapshot: { + getSnapshot: sinon.stub().resolves(null), getWeightSnapshot: sinon.stub().resolves(null) } }); await h.p.start(); for (let r = 0; r < 12; r++) h.p._buffer.set(r, bufferedFixture(r)); @@ -97,11 +98,12 @@ const testCase4 = async function () { const testCase5 = async function () { let dark = false; + let resolve = async () => (dark ? null : { validators: [{ pubkey: ME }] }); let h = makePublisher({ signerOpts: { met: false }, capabilitySnapshot: { - getSnapshot: sinon.stub().callsFake(async () => ( - dark ? null : { validators: [{ pubkey: ME }] })) + getSnapshot: sinon.stub().callsFake(resolve), + getWeightSnapshot: sinon.stub().callsFake(resolve), } }); await h.p.start(); @@ -118,11 +120,12 @@ const testCase5 = async function () { const testCase6 = async function () { let members = [ME, PEER1]; + let resolve = async () => ({ validators: members.map(p => ({ pubkey: p })) }); let h = makePublisher({ signerOpts: { met: false }, capabilitySnapshot: { - getSnapshot: sinon.stub().callsFake(async () => ( - { validators: members.map(p => ({ pubkey: p })) })) + getSnapshot: sinon.stub().callsFake(resolve), + getWeightSnapshot: sinon.stub().callsFake(resolve), } }); await h.p.start(); diff --git a/test/unit/oracle/publisher/oracle_publisher_rotation_membership.test.js b/test/unit/oracle/publisher/oracle_publisher_rotation_membership.test.js new file mode 100644 index 000000000..e5f0a772b --- /dev/null +++ b/test/unit/oracle/publisher/oracle_publisher_rotation_membership.test.js @@ -0,0 +1,126 @@ +'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. +// +// The PRICE publish rotation's oracle_publish membership: the weight snapshot at and +// above the STAKE_WEIGHTED_QUORUM activation, the count snapshot below it, each key +// ranked once, and an empty set logged as loudly as an unresolved one. + +const sinon = require('sinon'); +const { expect } = require('chai'); +const proxyquire = require('proxyquire'); + +const A = 'aa'.repeat(32); +const B = 'bb'.repeat(32); +const C = 'cc'.repeat(32); + +let OraclePublisher; + +function loadModule() { + let fsMock = { + mkdirSync: sinon.stub(), existsSync: sinon.stub().returns(true), writeFileSync: sinon.stub(), + openSync: sinon.stub().returns(99), writeSync: sinon.stub(), fsyncSync: sinon.stub(), + closeSync: sinon.stub(), readFileSync: sinon.stub().returns(''), + }; + OraclePublisher = proxyquire('../../../../src/oracle/publisher', { + fs: fsMock, + '../peers/encoder_client': function () { return null; }, + }); +} + +function makeSnapshots(count, weight) { + return { + getSnapshot: sinon.stub().resolves(count), + getWeightSnapshot: sinon.stub().resolves(weight), + }; +} + +function makePublisher(network, capSS) { + return new OraclePublisher({ + network: network, + p2pConfig: {}, + getIdentity: sinon.stub().returns({ getPubkeyHex: sinon.stub().returns(A), sign: sinon.stub() }), + capabilityRegistry: null, + capabilitySnapshot: capSS, + oracleConsensus: null, + }); +} + +function registerGateTests() { + it('reads the count snapshot below the mainnet activation height', async function () { + let capSS = makeSnapshots({ validators: [{ pubkey: A }] }, { validators: [{ pubkey: B }] }); + let keys = await makePublisher('mainnet', capSS).getActiveOraclePublishPubkeys(960999); + expect(keys).to.deep.equal([A]); + expect(capSS.getWeightSnapshot.called).to.equal(false); + }); + + it('reads the weight snapshot at the mainnet activation height', async function () { + let capSS = makeSnapshots({ validators: [{ pubkey: A }] }, { validators: [{ pubkey: B }] }); + let keys = await makePublisher('mainnet', capSS).getActiveOraclePublishPubkeys(961000); + expect(keys).to.deep.equal([B]); + expect(capSS.getSnapshot.called).to.equal(false); + }); + + it('reads the weight snapshot at any height on testnet and regtest', async function () { + for (const network of ['testnet', 'regtest']) { + let capSS = makeSnapshots({ validators: [{ pubkey: A }] }, { validators: [{ pubkey: B }] }); + expect(await makePublisher(network, capSS).getActiveOraclePublishPubkeys(1), network).to.deep.equal([B]); + } + }); + + it('keeps the count snapshot on an unscoped hub', async function () { + let capSS = makeSnapshots({ validators: [{ pubkey: A }] }, { validators: [{ pubkey: B }] }); + expect(await makePublisher('', capSS).getActiveOraclePublishPubkeys(990000)).to.deep.equal([A]); + }); +} + +function registerShapeTests() { + it('ranks each key once when a weighted snapshot carries several sources for it', async function () { + let rows = [{ pubkey: C, source: 's1' }, { pubkey: A.toUpperCase(), source: 's1' }, + { pubkey: C, source: 's2' }, { pubkey: A, source: 's3' }]; + let pub = makePublisher('regtest', makeSnapshots(null, { validators: rows })); + expect(await pub.getActiveOraclePublishPubkeys(5)).to.deep.equal([A, C]); + expect(await pub.getMyRank(5)).to.equal(0); + expect(await pub.getActiveOraclePublishCount(5)).to.equal(2); + }); + + it('logs an empty member set once per dark spell and fails closed', async function () { + let warn = sinon.stub(console, 'warn'); + let capSS = makeSnapshots(null, { validators: [] }); + let pub = makePublisher('regtest', capSS); + expect(await pub.getActiveOraclePublishPubkeys(5)).to.deep.equal([]); + expect(await pub.getActiveOraclePublishPubkeys(5)).to.deep.equal([]); + expect(warn.callCount).to.equal(1); + expect(warn.firstCall.args.join(' ')).to.match(/empty member set/); + capSS.getWeightSnapshot.resolves({ validators: [{ pubkey: B }] }); + expect(await pub.getActiveOraclePublishPubkeys(5)).to.deep.equal([B]); + capSS.getWeightSnapshot.resolves({ validators: [] }); + await pub.getActiveOraclePublishPubkeys(5); + expect(warn.callCount).to.equal(2); + }); + + it('still fails closed and logs on a null or throwing weight snapshot', async function () { + let warn = sinon.stub(console, 'warn'); + let pub = makePublisher('regtest', makeSnapshots(null, null)); + expect(await pub.getActiveOraclePublishPubkeys(5)).to.deep.equal([]); + let pub2 = makePublisher('regtest', { getWeightSnapshot: sinon.stub().rejects(new Error('indexer down')) }); + expect(await pub2.getActiveOraclePublishPubkeys(5)).to.deep.equal([]); + expect(warn.callCount).to.equal(2); + }); +} + +describe('OraclePublisher rotation membership', function () { + beforeEach(loadModule); + afterEach(function () { sinon.restore(); }); + + describe('the stake-weighted quorum gate', registerGateTests); + describe('the resolved set', registerShapeTests); +}); diff --git a/test/unit/oracle/xchain_price_source_overrides.test.js b/test/unit/oracle/xchain_price_source_overrides.test.js index 1db17869b..37ba3b082 100644 --- a/test/unit/oracle/xchain_price_source_overrides.test.js +++ b/test/unit/oracle/xchain_price_source_overrides.test.js @@ -149,6 +149,16 @@ xchainPriceSourceTests('ingestion bounds', function () { expect(out.meta.carriedFrom).to.equal('bootstrap'); }); + it('abstains in the sub-ulp band the downstream gates round onto PRICE_MAX', function () { + // Exact math admits this 8dp value; parseFloat rounds it to PRICE_MAX, so every + // co-sign and ingest gate refuses it. The producer must refuse it first. + const band = String(PRICE_MAX - 1) + '.99999999'; + expect(parseFloat(band), 'the band value rounds onto the ceiling').to.equal(PRICE_MAX); + const { src } = makeSource({}, FINALIZED_BTC); + expect(src.entry(band, {})).to.equal(null); + expect(src.entry('0.05000000', {}).price).to.equal('0.05000000'); + }); + it('publishes at 8dp like every other pair', async function () { const { src } = makeSource({}, FINALIZED_BTC); expect((await src.derive(CTX)).price).to.match(/^\d+\.\d{8}$/); diff --git a/test/unit/shared/admission/admission_watermark_price_default.test.js b/test/unit/shared/admission/admission_watermark_price_default.test.js new file mode 100644 index 000000000..36dbee10f --- /dev/null +++ b/test/unit/shared/admission/admission_watermark_price_default.test.js @@ -0,0 +1,55 @@ +'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. +// +// The admission watermark's price rail window: the configured oracle round cadence +// when set, and the one shared DEFAULT_ORACLE_ROUND_INTERVAL_MS every other oracle +// cadence fallback reads when it is not. + +const { expect } = require('chai'); +const proxyquire = require('proxyquire'); + +const WATERMARK_PATH = '../../../../src/peers/hub_db/admission_height_watermark.js'; +const { AdmissionHeightWatermark } = require(WATERMARK_PATH); +const { DEFAULT_ORACLE_ROUND_INTERVAL_MS } = require('../../../../src/constants.js'); + +const ENV_KEYS = ['ORACLE_ROUND_INTERVAL', 'ADMISSION_ORACLE_INGEST_WINDOW_MS']; + +// Build with the oracle knobs absent from the environment, so config alone decides. +function withCleanEnv(fn) { + const saved = {}; + for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; } + try { return fn(); } + finally { for (const k of ENV_KEYS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } } +} + +describe('admission height watermark: the price rail window default', function () { + + it('sizes the price window from a configured ORACLE_ROUND_INTERVAL', function () { + let w = withCleanEnv(() => new AdmissionHeightWatermark({ ORACLE_ROUND_INTERVAL: 200000 })); + expect(w.roundTerminalMs('price_snapshots')).to.equal(200000); + }); + + it('falls back to the shared oracle round default when ORACLE_ROUND_INTERVAL is unset', function () { + let w = withCleanEnv(() => new AdmissionHeightWatermark({})); + expect(w.roundTerminalMs('price_snapshots')).to.equal(DEFAULT_ORACLE_ROUND_INTERVAL_MS); + }); + + it('follows a retuned shared default rather than a local copy of it', function () { + // A distinct stand-in default proves the fallback reads constants.js, not a literal. + const { AdmissionHeightWatermark: Stubbed } = proxyquire(WATERMARK_PATH, { + '../../constants.js': { DEFAULT_ORACLE_ROUND_INTERVAL_MS: 123457 }, + }); + let w = withCleanEnv(() => new Stubbed({ ADMISSION_ORACLE_INGEST_WINDOW_MS: 200000 })); + expect(w.roundTerminalMs('price_snapshots')).to.equal(123457); + expect(w.roundTerminalMs('oracle_prices')).to.equal(200000); + }); +}); diff --git a/test/unit/shared/admission/admission_watermark_round_defaults.test.js b/test/unit/shared/admission/admission_watermark_round_defaults.test.js new file mode 100644 index 000000000..a5ad67e5c --- /dev/null +++ b/test/unit/shared/admission/admission_watermark_round_defaults.test.js @@ -0,0 +1,65 @@ +'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. + +// The admission watermark's round-timeout fallbacks for the xdex, attest and anchor +// rails. Each is the published trail bound for its rail, so each must read the one +// shared definition its engine reads, never a local copy of the number. + +const { expect } = require('chai'); +const proxyquire = require('proxyquire'); + +const WATERMARK_PATH = '../../../../src/peers/hub_db/admission_height_watermark.js'; +const { AdmissionHeightWatermark } = require(WATERMARK_PATH); +const constants = require('../../../../src/constants.js'); + +const ENV_KEYS = ['XDEX_ROUND_TIMEOUT_MS', 'XDEX_ROUND_MAX_LIFETIME_MS', 'ATTESTATION_ROUND_TIMEOUT_MS', + 'ANCHOR_ROUND_TIMEOUT_MS']; + +// Build with none of the round knobs set anywhere, so every rail takes its default. +function withCleanEnv(fn) { + const saved = {}; + for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; } + try { return fn(); } + finally { for (const k of ENV_KEYS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } } +} + +describe('admission height watermark: round-timeout defaults bind to the shared constants', function () { + + it('sizes each rail from its shared default with nothing configured', function () { + let w = withCleanEnv(() => new AdmissionHeightWatermark({})); + expect(w.roundWindows.xdex).to.equal(constants.DEFAULT_XDEX_ROUND_TIMEOUT_MS * 4); + expect(w.roundWindows.attest).to.equal(constants.DEFAULT_ATTESTATION_ROUND_TIMEOUT_MS); + expect(w.roundWindows.anchor).to.equal(constants.DEFAULT_ANCHOR_ROUND_TIMEOUT_MS); + }); + + it('follows a retuned shared default on every rail rather than a local copy', function () { + // Distinct stand-ins per rail prove each fallback reads its own constant. + const { AdmissionHeightWatermark: Stubbed } = proxyquire(WATERMARK_PATH, { + '../../constants.js': { + DEFAULT_XDEX_ROUND_TIMEOUT_MS: 11111, + DEFAULT_ATTESTATION_ROUND_TIMEOUT_MS: 22222, + DEFAULT_ANCHOR_ROUND_TIMEOUT_MS: 33333, + }, + }); + let w = withCleanEnv(() => new Stubbed({})); + expect(w.roundWindows.xdex).to.equal(11111 * 4); + expect(w.roundWindows.attest).to.equal(22222); + expect(w.roundWindows.anchor).to.equal(33333); + }); + + it('matches the anchor engine default read from the same constant', function () { + const StateAnchorPublisher = require('../../../../src/anchor/publisher.js'); + let pub = withCleanEnv(() => new StateAnchorPublisher({ db: {}, p2pConfig: { DOGE_ADDRESS: 'Dpub1' } })); + let w = withCleanEnv(() => new AdmissionHeightWatermark({})); + expect(pub.roundTimeoutMs).to.equal(w.roundWindows.anchor); + }); +}); diff --git a/test/unit/validators/governance/governance_slash_value_shape.test.js b/test/unit/validators/governance/governance_slash_value_shape.test.js new file mode 100644 index 000000000..d941dc47a --- /dev/null +++ b/test/unit/validators/governance/governance_slash_value_shape.test.js @@ -0,0 +1,85 @@ +'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. +// +// Slashing-parameter value shape: the change bounds refuse a non-decimal value, so an +// exponent or junk-suffix form cannot skip the ratio bound and a non-finite band never +// reaches a SlashDetector that refuses to construct on it. + +const sinon = require('sinon'); +const { expect } = require('chai'); +const Governance = require('../../../../src/validators/governance'); +const { resolveDeviationThreshold } = require('../../../../src/validators/slash_detector/options.js'); +const { createMockHub } = require('../../../helpers/mockHub'); +const { VALIDATORS_3 } = require('../../../helpers/fixtures'); + +const NON_DECIMAL_BANDS = ['abc', 'NaN', 'Infinity', '-Infinity', '', '5e-1', '5E-2', '0.9abc', '0x1']; + +let gov; + +function registerLocalGateTests() { + it('refuses a non-decimal SLASH_DEVIATION_THRESHOLD proposal', function () { + for (const v of NON_DECIMAL_BANDS) + expect(() => gov.validateChangeBounds('SLASH_DEVIATION_THRESHOLD', '0.05', v), v).to.throw(/plain decimal/); + }); + + it('refuses an exponent or junk-suffix SLASH_MISSED_ROUNDS_THRESHOLD proposal', function () { + for (const v of ['3e1', '30abc']) + expect(() => gov.validateChangeBounds('SLASH_MISSED_ROUNDS_THRESHOLD', '30', v), v).to.throw(/plain decimal/); + }); + + it('refuses a non-finite band at the floor when called on its own', function () { + for (const v of ['abc', 'NaN', 'Infinity']) + expect(() => gov.validateSlashBandFloor('SLASH_DEVIATION_THRESHOLD', v), v).to.throw(/not a valid number/); + }); + + it('bounds the echoed value in the refusal', function () { + let junk = 'x'.repeat(10000); + expect(() => gov.validateChangeBounds('SLASH_DEVIATION_THRESHOLD', '0.05', junk)) + .to.throw().with.property('message').that.has.length.below(300); + }); + + it('still skips a non-decimal value for a non-slashing parameter', function () { + expect(() => gov.validateChangeBounds('P', '0.05', '5e-1')).to.not.throw(); + expect(() => gov.validateChangeBounds('P', 'abc', 'def')).to.not.throw(); + }); +} + +function registerFollowerAndConsumerTests() { + it('drops an inbound exponent-form band and keeps an in-bounds plain decimal', function () { + expect(gov.inboundProposalBoundsHold('p1', 'SLASH_DEVIATION_THRESHOLD', '0.05', '5e-1')).to.equal(false); + expect(gov.inboundProposalBoundsHold('p1', 'SLASH_DEVIATION_THRESHOLD', '0.05', '0.0625')).to.equal(true); + }); + + it('accepts only bands SlashDetector also constructs on', function () { + for (const v of ['0.05', '0.0625', '0.06', 'abc', 'Infinity', 'NaN', '0.04', '0', '5e-1', '0.9abc']) { + let accepted = true; + try { gov.validateChangeBounds('SLASH_DEVIATION_THRESHOLD', '0.05', v); } catch (e) { accepted = false; } + if (accepted) expect(() => resolveDeviationThreshold({ SLASH_DEVIATION_THRESHOLD: v }), v).to.not.throw(); + } + }); +} + +describe('Governance: slashing-parameter value shape', function () { + beforeEach(function () { + let hub = createMockHub(); + hub._identity.getPubkeyHex.returns(VALIDATORS_3[0].pubkey); + gov = new Governance(hub); + gov.setValidatorSet(VALIDATORS_3); + }); + afterEach(function () { + if (gov._tallyTimer) clearInterval(gov._tallyTimer); + sinon.restore(); + }); + + describe('the local change bounds', registerLocalGateTests); + describe('the follower re-check and the consumer', registerFollowerAndConsumerTests); +}); From 4f2ac9dc597678543adb7ffab27454b724c79c5e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 20:49:12 -0700 Subject: [PATCH 44/57] fix(hub): register the token bridge gate in SHARED_GATES, report function-valued gates as presence rows The shared gate list, digest, gate counts, AT1 identity pin and carrier-logic pin move with the indexer twin, and consensus-identity emits presence-only rows for the four mirror admission gates it was dropping from the JSON. --- bin/consensus-identity.js | 17 +++++++++--- bin/pins/at1-consensus-identity.json | 17 +++++++----- bin/pins/carrier-logic.json | 9 ++++++- bin/test/consensus_identity.test.js | 27 +++++++++++++++++++ src/consensus/equivocation_header.js | 19 ++++++++----- src/consensus/snapshot_reorg_buffer.js | 13 +++++---- src/consensus/stake_weighted_quorum.js | 17 +++++++----- src/consensus_rules_digest.js | 6 ++++- .../consensus_primitive_conformance.test.js | 10 ++++--- ...onsensus_rules_digest_active_gates.test.js | 16 ++++++----- ...ensus_rules_digest_registry_loader.test.js | 6 ++--- 11 files changed, 114 insertions(+), 43 deletions(-) diff --git a/bin/consensus-identity.js b/bin/consensus-identity.js index b64b3f580..60f3865bf 100644 --- a/bin/consensus-identity.js +++ b/bin/consensus-identity.js @@ -94,6 +94,16 @@ let REPO_ROOT = path.resolve(__dirname, '..'); const ABSENT = ''; +// The row a function-valued shared gate prints: present, body not digested (see SHARED_GATES). +const FUNCTION_GATE = ''; + +// Name every gate row, since a function canonicalizes to undefined and JSON would drop it. +function projectGates(gates) { + const out = {}; + for (const key of Object.keys(gates)) out[key] = gates[key] === undefined ? FUNCTION_GATE : gates[key]; + return out; +} + /** * Measure `dir` instead of the checkout this script lives in. * @param {string} dir a hub checkout @@ -147,19 +157,20 @@ function codeIdentity() { const coins = loadFromRepo('src/coins/index.js'); const rules = rulesModule.computeConsensusRulesDigest(); + const gates = projectGates(rules.gates); // The GATES field verbatim, because the hash alone cannot be checked by hand // against a wire capture and this is the string the hub signs. const gatesField = rulesModule.knownGateKeys().join(','); const coinPins = coinConsensusPins(); - const absentGates = Object.keys(rules.gates).filter((k) => rules.gates[k] === ABSENT).sort(); + const absentGates = Object.keys(gates).filter((k) => gates[k] === ABSENT).sort(); return { consensus_rules_digest: rules.digest, // The gate-by-gate preimage. See WHY THE GATES MAP IS PINNED: without it // a digest mismatch cannot be traced to the gate that moved. - consensus_rules_gates: rules.gates, - gate_key_count: Object.keys(rules.gates).length, + consensus_rules_gates: gates, + gate_key_count: Object.keys(gates).length, absent_gates: absentGates, gates_field: gatesField, gates_field_hash: crypto.createHash('sha256').update(gatesField, 'utf8').digest('hex'), diff --git a/bin/pins/at1-consensus-identity.json b/bin/pins/at1-consensus-identity.json index dd1f1eff3..c3fae2a14 100644 --- a/bin/pins/at1-consensus-identity.json +++ b/bin/pins/at1-consensus-identity.json @@ -1,5 +1,5 @@ { - "consensus_rules_digest": "b57eb9a867a8d3c914dc100b445fccb217c54efc8a7dd671a0ad176c83cebd4c", + "consensus_rules_digest": "0b39313c90c49cdbf033e719448d389d2742795be78e17129bdd2925a84292df", "consensus_rules_gates": { "anchor_reward_activation.ANCHOR_REWARD_ACTIVATION": "{\"mainnet\":961000,\"regtest\":0,\"testnet\":0}", "anchor_reward_activation.ARCHIVE_REWARD_ACTIVATION": "{\"mainnet\":963000,\"regtest\":0,\"testnet\":0}", @@ -29,12 +29,17 @@ "anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION": "{\"mainnet\":null,\"regtest\":null,\"testnet\":153328}", "anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S": "64800", "mirror_admission_activation.CHAIN_CODE_RE": "{}", - "mirror_admission_activation.CANONICAL_HEIGHT_RE": "{}" + "mirror_admission_activation.CANONICAL_HEIGHT_RE": "{}", + "mirror_admission_activation.encodeAdmitBlocks": "", + "mirror_admission_activation.decodeAdmitBlocks": "", + "mirror_admission_activation.isAdmissionEra": "", + "mirror_admission_activation.admissionCanonicalField": "", + "token_bridge_activation.TOKEN_BRIDGE_ACTIVATION": "{\"mainnet\":9999999999,\"regtest\":0,\"testnet\":9999999999}" }, - "gate_key_count": 33, + "gate_key_count": 34, "absent_gates": [], - "gates_field": "anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S,anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION,anchor_reward_activation.ANCHOR_REWARD_ACTIVATION,anchor_reward_activation.ANCHOR_REWARD_DERIVE_ACTIVATION,anchor_reward_activation.ARCHIVE_REWARD_ACTIVATION,attest_relay_activation.ATTEST_RELAY_ACTIVATION,attest_response_mirror_activation.ATTEST_RESPONSE_MIRROR_ACTIVATION,attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING,attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING_ACTIVATION,attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING_V2,attest_zero_conf_activation.ATTEST_ZERO_CONF_ACTIVATION,checkpoint_commitment_activation.CHECKPOINT_COMMITMENT_ACTIVATION,cross_chain_royalty_activation.CROSS_CHAIN_ROYALTY_ACTIVATION,equivocation_header.EQUIV_HEADER_ACTIVATION,mirror_admission_activation.ADMIT_MARGIN_BLOCKS,mirror_admission_activation.ADMIT_MAX_FUTURE_BLOCKS,mirror_admission_activation.ADMIT_MIN_FUTURE_BLOCKS,mirror_admission_activation.CANONICAL_HEIGHT_RE,mirror_admission_activation.CHAIN_CODE_RE,mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION,mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION,mirror_admission_activation.admissionCanonicalField,mirror_admission_activation.decodeAdmitBlocks,mirror_admission_activation.encodeAdmitBlocks,mirror_admission_activation.isAdmissionEra,price_pair_activation.PRICE_PAIR_WIDEN_ACTIVATION,price_sig_tally_activation.PRICE_SIG_TALLY_ACTIVATION,retraction_signing_activation.RETRACTION_SIGNING_ACTIVATION,rollcall_activation.ROLLCALL_ACTIVATION,rollcall_gates_activation.ROLLCALL_GATES_ACTIVATION,snapshot_reorg_buffer.SNAPSHOT_BURIAL_ACTIVATION,stake_weighted_quorum.STAKE_WEIGHTED_QUORUM_ACTIVATION,xchain_bridge_activation.XCHAIN_BRIDGE_ACTIVATION", - "gates_field_hash": "f19f21f028823fa1aa784e4220724ea13dabd02a1c7d7bb963bc72c8ec4d85b5", + "gates_field": "anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S,anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION,anchor_reward_activation.ANCHOR_REWARD_ACTIVATION,anchor_reward_activation.ANCHOR_REWARD_DERIVE_ACTIVATION,anchor_reward_activation.ARCHIVE_REWARD_ACTIVATION,attest_relay_activation.ATTEST_RELAY_ACTIVATION,attest_response_mirror_activation.ATTEST_RESPONSE_MIRROR_ACTIVATION,attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING,attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING_ACTIVATION,attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING_V2,attest_zero_conf_activation.ATTEST_ZERO_CONF_ACTIVATION,checkpoint_commitment_activation.CHECKPOINT_COMMITMENT_ACTIVATION,cross_chain_royalty_activation.CROSS_CHAIN_ROYALTY_ACTIVATION,equivocation_header.EQUIV_HEADER_ACTIVATION,mirror_admission_activation.ADMIT_MARGIN_BLOCKS,mirror_admission_activation.ADMIT_MAX_FUTURE_BLOCKS,mirror_admission_activation.ADMIT_MIN_FUTURE_BLOCKS,mirror_admission_activation.CANONICAL_HEIGHT_RE,mirror_admission_activation.CHAIN_CODE_RE,mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION,mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION,mirror_admission_activation.admissionCanonicalField,mirror_admission_activation.decodeAdmitBlocks,mirror_admission_activation.encodeAdmitBlocks,mirror_admission_activation.isAdmissionEra,price_pair_activation.PRICE_PAIR_WIDEN_ACTIVATION,price_sig_tally_activation.PRICE_SIG_TALLY_ACTIVATION,retraction_signing_activation.RETRACTION_SIGNING_ACTIVATION,rollcall_activation.ROLLCALL_ACTIVATION,rollcall_gates_activation.ROLLCALL_GATES_ACTIVATION,snapshot_reorg_buffer.SNAPSHOT_BURIAL_ACTIVATION,stake_weighted_quorum.STAKE_WEIGHTED_QUORUM_ACTIVATION,token_bridge_activation.TOKEN_BRIDGE_ACTIVATION,xchain_bridge_activation.XCHAIN_BRIDGE_ACTIVATION", + "gates_field_hash": "709a898fb424966c6189d9de1938c03f7a78443f944030e33d451edbc63ad814", "coin_consensus_pins": { "BTC:mainnet": "242cf3cfcb4ff494d43b3d39775938f822f03a45326beff3b125ca102648e6ad", "LTC:mainnet": "5fd9f1dda65b55faf6a5e507a4a81bd2a5ba6813d8a3ed1f04cd27b99fec03c3", @@ -55,5 +60,5 @@ "mainnet" ], "hub_schema_version": 7, - "carrier_logic_digest": "377fd6d83e83872da5effecb58f24e6c39188f7d13ba63fd5ce433ea4d02b4b3" + "carrier_logic_digest": "ca77303a657414d3a75f587e152e7fad6ca3dd94f15ffc9c32f40c24d945761c" } diff --git a/bin/pins/carrier-logic.json b/bin/pins/carrier-logic.json index c5d05913e..63189f5e2 100644 --- a/bin/pins/carrier-logic.json +++ b/bin/pins/carrier-logic.json @@ -27,7 +27,7 @@ }, "consensus_rules_digest": { "path": "src/consensus_rules_digest.js", - "hash": "26894199bd6651dbedcc962825eb63aea91b0a5e7d1d8d3e28eaddf73ce69415", + "hash": "90d7040d61391bb2a42ed6ebcf8ad250914a4e81f1bab11ec98420877bae66a9", "twins": [ "xchain-indexer" ] @@ -653,6 +653,13 @@ "to": "d59a0c44b7d0dc1dbc5f1530d67e012a7dac33f1824960565a9788b75cff6a25", "reason": "canonical admission boundary fix, operator ruling (a) 2026-09-19", "date": "2026-09-19" + }, + { + "id": "consensus_rules_digest", + "from": "26894199bd6651dbedcc962825eb63aea91b0a5e7d1d8d3e28eaddf73ce69415", + "to": "90d7040d61391bb2a42ed6ebcf8ad250914a4e81f1bab11ec98420877bae66a9", + "reason": "SHARED_GATES appends token_bridge_activation.TOKEN_BRIDGE_ACTIVATION", + "date": "2026-09-23" } ] } diff --git a/bin/test/consensus_identity.test.js b/bin/test/consensus_identity.test.js index fe612be51..e46bbcbba 100644 --- a/bin/test/consensus_identity.test.js +++ b/bin/test/consensus_identity.test.js @@ -146,6 +146,33 @@ describe('bin/consensus-identity.js', function () { }); }); +// A function canonicalizes to undefined, which JSON drops and a comparer reads as equal. +describe('bin/consensus-identity.js', function () { + this.timeout(60000); + + before(() => { value = identity.codeIdentity(); }); + + describe('function-valued gate rows', () => { + const FUNCTION_KEYS = ['encodeAdmitBlocks', 'decodeAdmitBlocks', 'isAdmissionEra', 'admissionCanonicalField'] + .map((name) => `mirror_admission_activation.${name}`); + + it('serializes one row per gate key, naming a function gate by presence', () => { + const printed = JSON.parse(JSON.stringify(value)); + assert.strictEqual(Object.keys(printed.consensus_rules_gates).length, printed.gate_key_count, + 'the printed map must hold every gate the count names'); + for (const key of FUNCTION_KEYS) assert.strictEqual(printed.consensus_rules_gates[key], '', key); + }); + + it('reports a pin that lacks a function gate row as a difference', () => { + const pin = JSON.parse(JSON.stringify(value)); + delete pin.consensus_rules_gates[FUNCTION_KEYS[0]]; + const differences = identity.compare(pin, value); + assert.ok(differences.some((d) => d.kind === 'gate_added' && d.field === FUNCTION_KEYS[0]), + 'a row the pin lacks must not read as unchanged'); + }); + }); +}); + describe('bin/consensus-identity.js', function () { this.timeout(60000); diff --git a/src/consensus/equivocation_header.js b/src/consensus/equivocation_header.js index 48e11991e..c043bdec8 100644 --- a/src/consensus/equivocation_header.js +++ b/src/consensus/equivocation_header.js @@ -18,11 +18,15 @@ * EQUIV||||| * * The canonical source of record is - * xchain-documentation/protocol/reference-impl/equivocation_header.js; it is - * vendored BYTE-IDENTICALLY into xchain-hub, xchain-indexer, xchain-explorer, - * xchain-sdk and xchain-sync. Every PBFT/consensus engine prefixes its canonical - * through here, the settlement gates (cross_settle, xexec, xcall, anchor, price, - * attest) and the recovery verifier re-derive it to re-verify quorum signatures, + * xchain-indexer/src/consensus/equivocation_header.js; it is vendored + * BYTE-IDENTICALLY into xchain-hub, xchain-explorer, xchain-sdk, xchain-sync and + * xchain-documentation/protocol/reference-impl/consensus/equivocation_header.js. + * Edit the indexer copy only and re-run reconcile-twins.sh to re-vendor every + * other copy; never edit a vendored copy. + * + * Every PBFT/consensus engine prefixes its canonical through here, the settlement + * gates (cross_settle, xexec, xcall, anchor, price, attest) and the recovery + * verifier re-derive it to re-verify quorum signatures, * and the SLASH v0 action verifies equivocation proofs against it. Adding `` * makes equivocation provable WITHOUT false-positiving honest view changes (which * re-sign different content for the same round under a different view). Equivocation @@ -38,8 +42,9 @@ * the same anchor. The cross-service conformance suite * (ConsensusPrimitiveConformance.test.js, driven by * xchain-documentation/protocol/test-vectors) runs in every repo and asserts BOTH the - * behavior (canonical vectors) AND byte-identity of the local copy to this canonical - * source, so any unmirrored edit fails CI everywhere (a divergence forks the chain). + * behavior (canonical vectors) AND byte-identity of the local copy to the + * xchain-documentation copy, so any unmirrored edit fails CI everywhere (a + * divergence forks the chain). * ********************************************************************/ diff --git a/src/consensus/snapshot_reorg_buffer.js b/src/consensus/snapshot_reorg_buffer.js index 57e45d0d0..0f0003870 100644 --- a/src/consensus/snapshot_reorg_buffer.js +++ b/src/consensus/snapshot_reorg_buffer.js @@ -62,11 +62,14 @@ * difference must not be "corrected" without its own flag-day. * * The canonical source of record is - * xchain-documentation/protocol/reference-impl/snapshot_reorg_buffer.js; it is - * vendored BYTE-IDENTICALLY into xchain-hub, xchain-indexer and xchain-sdk. The - * cross-service conformance suite (ConsensusPrimitiveConformance.test.js) runs in - * every one of those repos and asserts byte-identity of the local copy to this - * source, so an unmirrored edit fails CI everywhere. + * xchain-indexer/src/consensus/snapshot_reorg_buffer.js; it is vendored + * BYTE-IDENTICALLY into xchain-hub, xchain-sdk and + * xchain-documentation/protocol/reference-impl/consensus/snapshot_reorg_buffer.js. + * Edit the indexer copy only and re-run reconcile-twins.sh to re-vendor every + * other copy; never edit a vendored copy. The cross-service conformance suite + * (ConsensusPrimitiveConformance.test.js) runs in every one of those repos and + * asserts byte-identity of the local copy to the xchain-documentation copy, so + * an unmirrored edit fails CI everywhere. * ********************************************************************/ diff --git a/src/consensus/stake_weighted_quorum.js b/src/consensus/stake_weighted_quorum.js index 76fdd4ed2..116655343 100644 --- a/src/consensus/stake_weighted_quorum.js +++ b/src/consensus/stake_weighted_quorum.js @@ -14,17 +14,20 @@ * * THE single, CONSENSUS-CRITICAL implementation of the stake-weighted quorum * predicate. The canonical source of record is - * xchain-documentation/protocol/reference-impl/stake_weighted_quorum.js; it is - * vendored BYTE-IDENTICALLY into xchain-hub, xchain-indexer, xchain-explorer, - * xchain-sdk and xchain-sync. Every PBFT tally engine, every settlement gate - * (cross_settle, xexec, xcall, anchor, price, attest), the recovery verifier and - * the client/explorer checkpoint verifiers resolve through this predicate so they - * can never drift; a divergence forks the chain. + * xchain-indexer/src/consensus/stake_weighted_quorum.js; it is vendored + * BYTE-IDENTICALLY into xchain-hub, xchain-explorer, xchain-sdk, xchain-sync and + * xchain-documentation/protocol/reference-impl/consensus/stake_weighted_quorum.js. + * Edit the indexer copy only and re-run reconcile-twins.sh to re-vendor every + * other copy; never edit a vendored copy. Every PBFT tally engine, every + * settlement gate (cross_settle, xexec, xcall, anchor, price, attest), the + * recovery verifier and the client/explorer checkpoint verifiers resolve through + * this predicate so they can never drift; a divergence forks the chain. * * The cross-service conformance suite (ConsensusPrimitiveConformance.test.js, * driven by xchain-documentation/protocol/test-vectors) runs in every repo and * asserts BOTH the behavior (canonical vectors) AND byte-identity of the local - * copy to this canonical source, so any unmirrored edit fails CI everywhere. + * copy to the xchain-documentation copy, so any unmirrored edit fails CI + * everywhere. * * Self-contained on mathjs bignumber (exact, never a JS double): the predicate is * a pure function with no injected utility instance, so it is identical in every diff --git a/src/consensus_rules_digest.js b/src/consensus_rules_digest.js index 94e9840eb..693438118 100644 --- a/src/consensus_rules_digest.js +++ b/src/consensus_rules_digest.js @@ -118,7 +118,11 @@ const SHARED_GATES = [ // a digest row. A SECOND entry for the module at the END rather than names added to its // entry above, because an insertion mid-list shifts the preimage of every gate after it. ['mirror_admission_activation', ['CHAIN_CODE_RE', 'CANONICAL_HEIGHT_RE', 'encodeAdmitBlocks', - 'decodeAdmitBlocks', 'isAdmissionEra', 'admissionCanonicalField']] + 'decodeAdmitBlocks', 'isAdmissionEra', 'admissionCanonicalField']], + // The token leg of the bridge: the hub gates every token leg on it and the indexer gates + // XBRIDGE v3/v4 and ISSUE format 7 on it, so a train that sizes it on one side only must + // report a rules mismatch. Appended at the END for the preimage-ordering reason above. + ['token_bridge_activation', ['TOKEN_BRIDGE_ACTIVATION']] ]; // A per-network height at or above this value is a far-future placeholder, not an diff --git a/test/unit/consensus/consensus_primitive_conformance.test.js b/test/unit/consensus/consensus_primitive_conformance.test.js index 7e79f7404..1df591c13 100644 --- a/test/unit/consensus/consensus_primitive_conformance.test.js +++ b/test/unit/consensus/consensus_primitive_conformance.test.js @@ -14,11 +14,13 @@ // predicate and the equivocation-header builder are CONSENSUS-CRITICAL and // vendored byte-identically into five services (xchain-hub, xchain-indexer, // xchain-explorer, xchain-sdk, xchain-sync); a divergence in their logic forks -// the chain. The canonical source of record lives in xchain-documentation -// (protocol/reference-impl + protocol/test-vectors). This guard runs in every +// the chain. Their canonical source of record is xchain-indexer/src/consensus, +// which reconcile-twins.sh vendors into every other copy, including +// xchain-documentation/protocol/reference-impl/consensus; the canonical vectors +// live in xchain-documentation/protocol/test-vectors. This guard runs in every // repo and asserts BOTH: // 1. BEHAVIOR - the local copy matches the canonical vectors. -// 2. IDENTITY - the local copy is byte-identical to the canonical source. +// 2. IDENTITY - the local copy is byte-identical to the xchain-documentation copy. // (1) catches a logic change that happens to pass the local unit suite; (2) // catches ANY edit to one copy that was not propagated to the others. When the // sibling xchain-documentation repo is not checked out (standalone deploy), skip @@ -119,7 +121,7 @@ describe('consensus-primitive conformance: byte-identity to canonical source @re const canon = fs.readFileSync(canonPath, 'utf8'); assert.strictEqual(local, canon, 'this repo\'s consensus/' + f + ' has drifted from the canonical source; ' + - 'edit xchain-documentation/protocol/reference-impl/consensus/' + f + ' and re-vendor every copy.'); + 'edit xchain-indexer/src/consensus/' + f + ' and re-run reconcile-twins.sh to re-vendor every copy.'); }); }); }); diff --git a/test/unit/consensus/consensus_rules_digest_active_gates.test.js b/test/unit/consensus/consensus_rules_digest_active_gates.test.js index 80fa9bb64..1113eb1a1 100644 --- a/test/unit/consensus/consensus_rules_digest_active_gates.test.js +++ b/test/unit/consensus/consensus_rules_digest_active_gates.test.js @@ -36,9 +36,9 @@ function stubRegistryRow(key, table) { // The zero-confirmation flip's three appended SHARED_GATES rows (§8), plus the two // helpers a ROLLCALL v1 publisher and the rules-aware capability set filter both read. function registerGateInventoryTest() { - it('is sorted, has 33 entries, and contains the gates the last three trains append', function () { + it('is sorted, has 34 entries, and contains the gates the last three trains append', function () { const keys = crd.knownGateKeys(); - expect(keys).to.have.lengthOf(33, 'SHARED_GATES total entry count moved; re-derive this floor before changing it'); + expect(keys).to.have.lengthOf(34, 'SHARED_GATES total entry count moved; re-derive this floor before changing it'); expect(keys).to.deep.equal([...keys].sort()); expect(keys).to.include.members([ 'attest_zero_conf_activation.ATTEST_ZERO_CONF_ACTIVATION', @@ -62,7 +62,9 @@ function registerGateInventoryTest() { 'mirror_admission_activation.encodeAdmitBlocks', 'mirror_admission_activation.decodeAdmitBlocks', 'mirror_admission_activation.isAdmissionEra', - 'mirror_admission_activation.admissionCanonicalField' + 'mirror_admission_activation.admissionCanonicalField', + // The token leg gate this hub loads per bridge leg. + 'token_bridge_activation.TOKEN_BRIDGE_ACTIVATION' ]); }); } @@ -96,7 +98,7 @@ function registerPinnedDigestTest() { for (const [p] of saved) delete require.cache[p]; const fresh = require('../../../src/consensus_rules_digest.js'); expect(fresh.computeConsensusRulesDigest().digest) - .to.equal('b57eb9a867a8d3c914dc100b445fccb217c54efc8a7dd671a0ad176c83cebd4c', + .to.equal('0b39313c90c49cdbf033e719448d389d2742795be78e17129bdd2925a84292df', 'the consensus rules digest moved; a gate was added, removed, reordered or re-armed'); } finally { for (const [p, mod] of saved) { if (mod === undefined) delete require.cache[p]; else require.cache[p] = mod; } @@ -129,8 +131,10 @@ function registerGateOrderingTest() { expect(mods.slice(0, PRE_EXISTING.length), 'a SHARED_GATES entry was inserted mid-list; that reorders the preimage of every gate after it') .to.deep.equal(PRE_EXISTING); - expect(mods.slice(PRE_EXISTING.length), 'the family must be the LAST three entries, the encoder registration last of all') - .to.deep.equal(['mirror_admission_activation', 'anchor_reward_activation', 'mirror_admission_activation']); + expect(mods.slice(PRE_EXISTING.length), + 'the family must follow the bridge gate, the encoder registration last, then the token leg gate') + .to.deep.equal(['mirror_admission_activation', 'anchor_reward_activation', 'mirror_admission_activation', + 'token_bridge_activation']); }); } diff --git a/test/unit/consensus/consensus_rules_digest_registry_loader.test.js b/test/unit/consensus/consensus_rules_digest_registry_loader.test.js index 83a86d21f..d76fb3d76 100644 --- a/test/unit/consensus/consensus_rules_digest_registry_loader.test.js +++ b/test/unit/consensus/consensus_rules_digest_registry_loader.test.js @@ -65,8 +65,8 @@ describe('consensus_rules_digest: values by registry key', function () { expect(gates[key], key).to.equal(crd.canonical(registry.get(key))); } } - expect(rows, 'the digest rows the registry supplies').to.equal(29); - expect(Object.keys(gates).length, 'every SHARED_GATES name is a digest key').to.equal(33); + expect(rows, 'the digest rows the registry supplies').to.equal(30); + expect(Object.keys(gates).length, 'every SHARED_GATES name is a digest key').to.equal(34); }); it('reads the four function-valued names from their carrier, canonicalising to no value, as before', function () { @@ -115,6 +115,6 @@ describe('consensus_rules_digest: a registry miss', function () { it('leaves the shipped module untouched: the digest still reads the pinned value afterwards', function () { expect(crd.computeConsensusRulesDigest().gates[KEY]).to.equal(crd.canonical(registry.get(KEY))); expect(crd.computeConsensusRulesDigest().gates[RETIRED_KEY]).to.equal(crd.canonical(registry.get(RETIRED_KEY))); - expect(crd.knownGateKeys().length).to.equal(33); + expect(crd.knownGateKeys().length).to.equal(34); }); }); From b9a341811c00e47f1e1695be09672ede53877c40 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 20:59:48 -0700 Subject: [PATCH 45/57] test(hub): give the listener-ceiling source walk a cold-venue timeout --- test/unit/peers/manager/peer_manager_listener_ceiling.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/unit/peers/manager/peer_manager_listener_ceiling.test.js b/test/unit/peers/manager/peer_manager_listener_ceiling.test.js index f04966a5e..814e6f585 100644 --- a/test/unit/peers/manager/peer_manager_listener_ceiling.test.js +++ b/test/unit/peers/manager/peer_manager_listener_ceiling.test.js @@ -170,6 +170,8 @@ async function warningsDuring(fn) { } describe('PeerManager: message listener ceiling', function () { + // Allow for a cold read of every src/ file on a loaded CI venue; it runs in under a second warm. + this.timeout(30000); it('the declared subscriber roster covers exactly the modules the sources register', function () { const fromSource = subscribersFromSource(); From 092b2036ad31cb728dc2a99c75c75aaf84b80ab8 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 21:01:34 -0700 Subject: [PATCH 46/57] fix(hub): floor checkpoint bundle sections at CHECKPOINT_COMMITMENT, widen oracle_prices.tick once mirrors are ready The bundle selector skips a root-bearing section below the network's CHECKPOINT_COMMITMENT activation, with a scoped per-mesh test override. The tick column widens to 250 only after the operator confirms the mirrors are widened, so a long-tick PRICE never halts replication on a strict mirror. --- .../checkpoint_engine/canonical_forms.js | 11 +- src/anchor/publisher/bundle.js | 24 ++- src/config.js | 1 + src/db/schema/columns.js | 57 +++++++ src/db/schema/migrations.js | 6 +- src/sql/oracle_prices.sql | 2 +- test/helpers/anchor_mesh.js | 20 ++- .../publisher/state_anchor_publisher.test.js | 6 +- ...e_anchor_publisher_bundle_election.test.js | 3 +- ..._anchor_publisher_commitment_floor.test.js | 93 +++++++++++ ...state_anchor_publisher_flush_retry.test.js | 3 +- test/unit/db/db_oracle_tick_width.test.js | 147 ++++++++++++++++++ 12 files changed, 359 insertions(+), 14 deletions(-) create mode 100644 test/unit/anchor/publisher/state_anchor_publisher_commitment_floor.test.js create mode 100644 test/unit/db/db_oracle_tick_width.test.js diff --git a/src/anchor/checkpoint_engine/canonical_forms.js b/src/anchor/checkpoint_engine/canonical_forms.js index 1171677ca..13f1e07e4 100644 --- a/src/anchor/checkpoint_engine/canonical_forms.js +++ b/src/anchor/checkpoint_engine/canonical_forms.js @@ -43,11 +43,18 @@ const forms = { String(cp.checkpoint_seq), String(cp.snapshot_block)].join('|'); }, + // Is CHECKPOINT_COMMITMENT active at this checkpoint's own network and snapshot_block? + // The one reader of the flag-day key, shared by the suffix, the rootless guard and + // the ANCHOR bundle selector's floor. + isCheckpointCommitmentActive(cp){ + return gateRegistry.activeAt(CHECKPOINT_COMMITMENT_KEY, cp.network, null, cp.snapshot_block, null); + }, + // The SPV Phase 2 (spec §6.1) root suffix appended to the checkpoint-family // canonical at/above the CHECKPOINT_COMMITMENT flag-day. Kept as one helper so // the hub / SDK / indexer-anchor / explorer all build byte-identical bytes. checkpointRootSuffix(cp){ - if(!gateRegistry.activeAt(CHECKPOINT_COMMITMENT_KEY, cp.network, null, cp.snapshot_block, null)) return ''; + if(!forms.isCheckpointCommitmentActive(cp)) return ''; // Append only when the roots are actually present. Post-flag-day the engine // refuses to sign a checkpoint that lacks them (runRound throws), so for every // REAL post-flag-day checkpoint this is always true and the suffix is byte- @@ -106,7 +113,7 @@ const forms = { // be enforced on one path and quietly skipped on the others again. isRootless(cp){ if(!cp) return false; - if(!gateRegistry.activeAt(CHECKPOINT_COMMITMENT_KEY, cp.network, null, cp.snapshot_block, null)) return false; + if(!forms.isCheckpointCommitmentActive(cp)) return false; return (!cp.state_root || !cp.block_merkle_root || cp.state_root_version == null || cp.block_merkle_version == null); }, diff --git a/src/anchor/publisher/bundle.js b/src/anchor/publisher/bundle.js index e979293d2..76e643295 100644 --- a/src/anchor/publisher/bundle.js +++ b/src/anchor/publisher/bundle.js @@ -22,6 +22,7 @@ 'use strict'; const ar = require('../../consensus/gates/anchor_reward_gate.js'); +const checkpointForms = require('../checkpoint_engine/canonical_forms.js'); const { ANCHOR_BUNDLE_MAX_BYTES } = require('./constants.js'); const { getLogger } = require('../../observability'); const logger = getLogger(); @@ -100,6 +101,13 @@ module.exports = { this.anchorEveryNCheckpoints); }, + // Is CHECKPOINT_COMMITMENT active at this section's OWN network and snapshot_block? + // The bundle selector's floor reads it here, so a test harness can override the + // gate on one publisher instance without touching the shared registry. + sectionCommitmentActive(row){ + return checkpointForms.isCheckpointCommitmentActive(row); + }, + // Group the result set into ONE bundle per network. A chain absent from a // group is NOT an anomaly (D4): under the daily cadence the normal case is a // chain whose newest eligible seq is already anchored. @@ -107,10 +115,7 @@ module.exports = { let byNetwork = new Map(); for(let row of (rows || [])){ // D8: the bundle is root-bearing by construction, so a row with no - // light-client roots cannot ride one. Below CHECKPOINT_COMMITMENT_ACTIVATION - // (regtest 0, testnet 146000, mainnet 961000) no federation cutting - // checkpoints today produces such a row, so this is a loud skip rather than - // a rootless fallback wire. + // light-client roots cannot ride one; a loud skip, never a rootless wire. if(row.state_root == null || row.block_merkle_root == null || row.state_root_version == null || row.block_merkle_version == null){ logger.warn('StateAnchorPublisher: checkpoint ' + row.chain + '/' + row.network + ' @ ' + @@ -118,6 +123,17 @@ module.exports = { 'skipped, an ANCHOR v0 section is root-bearing by construction'); continue; } + // Skip a root-bearing row below CHECKPOINT_COMMITMENT_ACTIVATION at its own + // snapshot_block (roots arm earlier, at STATE_COMMITMENT): its signatures cover + // the rootless canonical while the indexer rebuilds every v0 section WITH the + // root suffix, so it can never verify and would fail the whole bundle. + if(!this.sectionCommitmentActive(row)){ + logger.warn('StateAnchorPublisher: checkpoint ' + row.chain + '/' + row.network + ' @ ' + + row.block_index + ' (seq ' + row.checkpoint_seq + ', snapshot_block ' + + row.snapshot_block + ') sits below CHECKPOINT_COMMITMENT; skipped, its ' + + 'signatures cover the rootless canonical an ANCHOR v0 section cannot carry'); + continue; + } let net = String(row.network); if(!byNetwork.has(net)) byNetwork.set(net, []); byNetwork.get(net).push(row); diff --git a/src/config.js b/src/config.js index 62b55015e..facc9cdf0 100644 --- a/src/config.js +++ b/src/config.js @@ -252,6 +252,7 @@ const config = { get XCHAIN_ATTEST_FINALIZED_MAX() { return process.env.XCHAIN_ATTEST_FINALIZED_MAX; }, get XCHAIN_ATTEST_STORE_RETRIES() { return process.env.XCHAIN_ATTEST_STORE_RETRIES; }, get XCHAIN_ATTEST_STORE_RETRY_MS() { return process.env.XCHAIN_ATTEST_STORE_RETRY_MS; }, + get XCHAIN_HUB_ORACLE_TICK_MIRRORS_WIDENED() { return process.env.XCHAIN_HUB_ORACLE_TICK_MIRRORS_WIDENED; }, get XCHAIN_HUB_SKIP_FULLNODE_ASSERT() { return process.env.XCHAIN_HUB_SKIP_FULLNODE_ASSERT; }, get XCHAIN_HUB_SKIP_MIN_STAKE_ASSERT() { return process.env.XCHAIN_HUB_SKIP_MIN_STAKE_ASSERT; }, get XCHAIN_HUB_SKIP_REORG_BUFFER_ASSERT() { return process.env.XCHAIN_HUB_SKIP_REORG_BUFFER_ASSERT; }, diff --git a/src/db/schema/columns.js b/src/db/schema/columns.js index a2a8fe736..e162384e5 100644 --- a/src/db/schema/columns.js +++ b/src/db/schema/columns.js @@ -28,10 +28,67 @@ const nodeUtil = require('node:util'); const ark = require('../../anchor/anchor_reward_key.js'); const { getLogger } = require('../../observability'); +const hubConfig = require('../../config'); const logger = getLogger(); +// The width oracle_prices.tick widens to, and the restated column. Literal rather than +// MAX_TICK_LENGTH: a raised gate constant must not widen the hub past its mirrors unasked. +const ORACLE_TICK_WIDTH = 250; +const ORACLE_TICK_DEF = 'VARCHAR(250) NOT NULL'; + module.exports = { + // Widen oracle_prices.tick to the PRICE v1 ingest bound, once the MIRRORS have. + // + // A mirror still at VARCHAR(50) under STRICT_TRANS_TABLES refuses the first longer tick + // the hub serves and wedges its oracle barrier on every re-page, and the hub cannot see + // its mirrors. So the order is operator-attested: widen every indexer and explorer mirror + // first, then set XCHAIN_HUB_ORACLE_TICK_MIRRORS_WIDENED=1. Until then this logs and + // skips, and each boot asks again; the narrow column keeps a long tick off the wire. + async migrateOracleTickWidth(){ + if(hubConfig.XCHAIN_HUB_ORACLE_TICK_MIRRORS_WIDENED !== '1'){ + logger.warn('Migration deferred: oracle_prices.tick stays narrower than ' + ORACLE_TICK_WIDTH + + ' until every mirror is widened (indexer migration 2026-09-22-oracle-prices-widen-tick.sql, ' + + 'explorer hub-mirror reconciler). Then set XCHAIN_HUB_ORACLE_TICK_MIRRORS_WIDENED=1 and restart.'); + return false; + } + return this.migrateColumnLength('oracle_prices', 'tick', ORACLE_TICK_WIDTH, ORACLE_TICK_DEF); + }, + + + // Widen a character column's length in place. Idempotent: reads the live + // CHARACTER_MAXIMUM_LENGTH and no-ops once it is at or above `targetLength`, so a fresh + // install and an already-migrated node both skip it. migrateColumnType cannot do this: + // it compares DATA_TYPE, and varchar to varchar reads as already converted. + // + // WIDENING ONLY: `columnDef` restates the whole column and must match the definition + // file, and a narrower live column is the only case that issues the MODIFY. + async migrateColumnLength(table, column, targetLength, columnDef){ + let db = await this.getConnection(); + try { + let rows = await db.query( + "SELECT CHARACTER_MAXIMUM_LENGTH AS len FROM information_schema.columns " + + "WHERE table_schema = ? AND table_name = ? AND column_name = ?", + [this.dbName, table, column] + ); + if(!rows || !rows[0]) return false; // table/column not present yet; CREATE TABLE covers it + let live = Number(rows[0].len); + if(rows[0].len == null || Number.isNaN(live) || live >= targetLength) return false; + await db.query('ALTER TABLE `' + table + '` MODIFY `' + column + '` ' + columnDef); + logger.info('Migration: widened ' + table + '.' + column + ' ' + live + ' -> ' + targetLength); + return true; + } catch(e){ + // Swallowed loudly, as migrateColumnCharset is: a throw takes the boot down. + logger.error(nodeUtil.format('MIGRATION FAILED: ' + table + '.' + column + ' is still narrower than ' + + targetLength + ', so a longer value errors or truncates on INSERT. Run by hand: ' + + 'ALTER TABLE `' + table + '` MODIFY `' + column + '` ' + columnDef, e)); + return false; + } finally { + await db.release(); + } + }, + + // Add the per-chain admission height columns to every mirrored table that carries one. // diff --git a/src/db/schema/migrations.js b/src/db/schema/migrations.js index c69585588..f1ee4d24e 100644 --- a/src/db/schema/migrations.js +++ b/src/db/schema/migrations.js @@ -131,7 +131,8 @@ module.exports = { }, // The column-level steps: the two DATETIME conversions, the two charset - // widens, the price fence re-key, and the mirror admission columns. + // widens, the price fence re-key, the mirror admission columns, and the gated + // oracle_prices.tick length widen. async runColumnAndFenceMigrations(){ // #4315: governance_proposals.voting_start/voting_end shipped as TIMESTAMP, which // MariaDB bounds to the signed 32-bit epoch (2038-01-19 03:14:07 UTC). Both hold a @@ -164,6 +165,9 @@ module.exports = { // xchain-hub/migrations/ are applied by hand, so a migration copied from the // indexer's style would sit there and never run on a single deployed hub. await this.migrateAdmissionColumns(); + // oracle_prices.tick to the 250 the PRICE v1 ingest gate admits, gated on the mirrors + // having widened first (see migrateOracleTickWidth for the order and the flag). + await this.migrateOracleTickWidth(); } }; diff --git a/src/sql/oracle_prices.sql b/src/sql/oracle_prices.sql index e071e12b9..be838ef28 100644 --- a/src/sql/oracle_prices.sql +++ b/src/sql/oracle_prices.sql @@ -3,7 +3,7 @@ CREATE TABLE oracle_prices ( source_address VARCHAR(100) NOT NULL, -- oracle operator's address (the SOURCE of the PRICE v1 tx) source_chain VARCHAR(10) NOT NULL, -- chain on which the PRICE v1 tx was published coin VARCHAR(10) NOT NULL, -- which chain's token this oracle is for (BTC/LTC/DOGE) - tick VARCHAR(50) NOT NULL, -- token name (e.g. PEPECASH) + tick VARCHAR(250) NOT NULL, -- token name (e.g. PEPECASH); PRICE v1 admits up to MAX_TICK_LENGTH fiat VARCHAR(10) NOT NULL, -- fiat currency code (e.g. USD, JPY) value VARCHAR(250) NOT NULL, -- price value as decimal string fee VARCHAR(250), -- oracle usage fee as decimal (e.g. 0.01 = 1%) diff --git a/test/helpers/anchor_mesh.js b/test/helpers/anchor_mesh.js index 765f2664b..39c22a13f 100644 --- a/test/helpers/anchor_mesh.js +++ b/test/helpers/anchor_mesh.js @@ -201,6 +201,13 @@ function applyStakeWeights(bus, recordNetwork) { } } +// Arm CHECKPOINT_COMMITMENT for the bundle selector's floor on THIS mesh's publishers +// only, as an own-property override of sectionCommitmentActive that stopMeshes deletes. +// The shared gate registry is never touched, so no other case or canonical sees it. +function armCommitmentFloor(bus) { + for (let nd of bus.nodes) nd.pub.sectionCommitmentActive = () => true; +} + // n publishers over a shared gossip bus. Every node shares identical DB // contents unless opts.mutate(self) tweaks its copy (divergence tests). function buildMesh(n, opts) { @@ -222,6 +229,8 @@ function buildMesh(n, opts) { // snapshot-block-gated rule - EQUIV, checkpoint-commitment, royalty, the anchor/ // archive reward flag-days - also activates at >=961000 on mainnet, so a block-100 // mainnet record sits on the fully-legacy, headerless path the count assertions expect). + // The bundle selector skips a section below CHECKPOINT_COMMITMENT, so a count-path + // case that needs its v0 bundle also passes checkpointCommitment:true (armCommitmentFloor). let recordNetwork = opts.network || CP_ROW.network; bus.network = recordNetwork; let identities = []; @@ -249,6 +258,7 @@ function buildMesh(n, opts) { bus.nodes.push(self); } if (opts.stakeWeighted) applyStakeWeights(bus, recordNetwork); + if (opts.checkpointCommitment) armCommitmentFloor(bus); buses.push(bus); return bus; } @@ -301,9 +311,15 @@ function rewardRow(pk, over) { }, over || {}); } -// Stop every publisher on every mesh built since the last call, then forget them. +// Stop every publisher on every mesh built since the last call, drop any +// commitment-floor override so the prototype's gate read is back, then forget them. async function stopMeshes() { - for (let bus of buses) { for (let nd of bus.nodes) await nd.pub.stop(); } + for (let bus of buses) { + for (let nd of bus.nodes) { + await nd.pub.stop(); + delete nd.pub.sectionCommitmentActive; + } + } buses = []; } diff --git a/test/unit/anchor/publisher/state_anchor_publisher.test.js b/test/unit/anchor/publisher/state_anchor_publisher.test.js index e52c8a7b8..2526939a8 100644 --- a/test/unit/anchor/publisher/state_anchor_publisher.test.js +++ b/test/unit/anchor/publisher/state_anchor_publisher.test.js @@ -53,7 +53,8 @@ function registerBundleWireCases() { it('single-node: flush publishes v0 + v1, archive round-trips, batch back-filled', async function () { // Count-path archive mechanics: a mainnet record below the 961000 SWQ activation // takes the legacy count snapshot the getSnapshot stub serves (weighted has its own suite). - let bus = buildMesh(1, { network: 'mainnet' }); + // checkpointCommitment arms the bundle floor so the block-100 section still rides. + let bus = buildMesh(1, { network: 'mainnet', checkpointCommitment: true }); let nd = bus.nodes[0]; await startAll(bus); await nd.pub.flush(); @@ -158,7 +159,8 @@ function registerArchiveTxidCases() { // broadcast success ({ txid: null }) must NOT dequeue the rows with their final // status (which would strand them in an unrecoverable hole) and must NOT credit // the anchor_archive reward for an anchor that never landed on-chain. - let bus = buildMesh(1, { network: 'mainnet' }); // count-path archive mechanics (SWQ off below 961000) + // Count-path archive mechanics (SWQ off below 961000); the armed bundle floor lets the v0 ride. + let bus = buildMesh(1, { network: 'mainnet', checkpointCommitment: true }); let nd = bus.nodes[0]; // The v0 bundle still gets a txid; only the v1 archive broadcast returns none. nd.pub.setBroadcastHook(async (payload) => { diff --git a/test/unit/anchor/publisher/state_anchor_publisher_bundle_election.test.js b/test/unit/anchor/publisher/state_anchor_publisher_bundle_election.test.js index e272d864d..244bb03e2 100644 --- a/test/unit/anchor/publisher/state_anchor_publisher_bundle_election.test.js +++ b/test/unit/anchor/publisher/state_anchor_publisher_bundle_election.test.js @@ -36,7 +36,8 @@ function registerMeshElectionCase() { it('N=4: per-row v0 election + archive leader; v1 carries 2f+1 sigs; back-fill propagates', async function () { // Count-path: mainnet record below the 961000 SWQ activation, so the archive // quorum stays legacy 2f+1 (the weighted path has its own dedicated suite below). - let bus = buildMesh(4, { btcBlock: 101, network: 'mainnet' }); + // checkpointCommitment arms the bundle floor so the block-100 section still rides. + let bus = buildMesh(4, { btcBlock: 101, network: 'mainnet', checkpointCommitment: true }); await startAll(bus); let v0Pub = v0Order(bus)[0]; // elected for the BTC checkpoint let leader = archiveLeader(bus); // elected archive leader diff --git a/test/unit/anchor/publisher/state_anchor_publisher_commitment_floor.test.js b/test/unit/anchor/publisher/state_anchor_publisher_commitment_floor.test.js new file mode 100644 index 000000000..13f2dbf08 --- /dev/null +++ b/test/unit/anchor/publisher/state_anchor_publisher_commitment_floor.test.js @@ -0,0 +1,93 @@ +'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. +// +// The ANCHOR v0 bundle selector's CHECKPOINT_COMMITMENT floor: a root-bearing row +// below the flag-day at its own snapshot_block never rides a bundle, because the +// hub signs it rootless while the indexer rebuilds every v0 section with the root +// suffix. Built on testnet, whose flag-day (146000) sits above genesis; regtest +// arms it at 0, where the below-gate case cannot exist. + +const { expect } = require('chai'); +const StateAnchorPublisher = require('../../../../src/anchor/publisher'); +const StateCheckpointEngine = require('../../../../src/anchor/checkpoint_engine'); +const { CP_ROW, buildMesh, stopMeshes } = require('../../../helpers/anchor_mesh.js'); + +const BELOW = 145500; +const AT = 146000; + +function testnetRow(chain, snapshotBlock, over) { + return Object.assign({}, CP_ROW, { chain: chain, network: 'testnet', snapshot_block: snapshotBlock, + checkpoint_seq: snapshotBlock, anchor_txid: null }, over || {}); +} + +function group(rows) { + let pub = new StateAnchorPublisher({ db: {}, p2pConfig: {} }); + return pub.groupSectionsByNetwork(rows); +} + +describe('StateAnchorPublisher bundle selector CHECKPOINT_COMMITMENT floor', function () { + registerFloorCases(); + registerCanonicalParityCase(); + registerMeshOverrideScopeCase(); +}); + +// The floor itself: below-gate dropped, at-gate kept, the rootless skip unchanged. +function registerFloorCases() { + it('drops a root-bearing testnet row at 145500 and keeps the same row at 146000', function () { + expect(group([testnetRow('BTC', BELOW)]).size, 'below the flag-day: no bundle').to.equal(0); + let kept = group([testnetRow('BTC', AT)]); + expect(kept.size).to.equal(1); + expect(kept.get('testnet').map(r => r.snapshot_block)).to.deep.equal([AT]); + }); + + it('drops only the below-gate section when a bundle mixes both', function () { + let out = group([testnetRow('BTC', AT), testnetRow('LTC', BELOW), testnetRow('DOGE', AT)]); + expect(out.get('testnet').map(r => r.chain)).to.deep.equal(['BTC', 'DOGE']); + }); + + it('still drops a root-null row at/above the flag-day (the rootless skip is kept)', function () { + expect(group([testnetRow('BTC', AT, { state_root: null })]).size).to.equal(0); + }); + + it('keeps regtest rows, whose flag-day is genesis', function () { + let out = group([Object.assign({}, CP_ROW, { anchor_txid: null })]); + expect(out.get('regtest').length).to.equal(1); + }); +} + +// The floor sits exactly where the hub's signed canonical loses its root suffix. +function registerCanonicalParityCase() { + it('drops a row exactly when the hub canonical omits the root suffix', function () { + let rootTail = '|' + CP_ROW.state_root + '|1|' + CP_ROW.block_merkle_root + '|1'; + let below = testnetRow('BTC', BELOW), at = testnetRow('BTC', AT); + expect(StateCheckpointEngine.canonicalCheckpoint(below).endsWith(rootTail), 'below: signed rootless').to.equal(false); + expect(StateCheckpointEngine.canonicalCheckpoint(at).endsWith(rootTail), 'at: signed with roots').to.equal(true); + expect(StateCheckpointEngine.isCheckpointCommitmentActive(below)).to.equal(false); + expect(StateCheckpointEngine.isCheckpointCommitmentActive(at)).to.equal(true); + }); +} + +// The mesh harness override is per-instance and gone once the mesh stops. +function registerMeshOverrideScopeCase() { + afterEach(stopMeshes); + + it('arms the floor only on the mesh that asked, and stopMeshes restores the gate read', async function () { + let armed = buildMesh(1, { network: 'mainnet', checkpointCommitment: true }).nodes[0].pub; + let plain = buildMesh(1, { network: 'mainnet' }).nodes[0].pub; + let row = Object.assign({}, CP_ROW, { network: 'mainnet', anchor_txid: null }); + expect(armed.groupSectionsByNetwork([row]).size, 'armed mesh keeps the block-100 row').to.equal(1); + expect(plain.groupSectionsByNetwork([row]).size, 'an unarmed mesh applies the real gate').to.equal(0); + await stopMeshes(); + expect(Object.prototype.hasOwnProperty.call(armed, 'sectionCommitmentActive')).to.equal(false); + expect(armed.groupSectionsByNetwork([row]).size, 'after stop the real gate applies').to.equal(0); + }); +} diff --git a/test/unit/anchor/publisher/state_anchor_publisher_flush_retry.test.js b/test/unit/anchor/publisher/state_anchor_publisher_flush_retry.test.js index 88e0190bc..a3c4ba01e 100644 --- a/test/unit/anchor/publisher/state_anchor_publisher_flush_retry.test.js +++ b/test/unit/anchor/publisher/state_anchor_publisher_flush_retry.test.js @@ -35,7 +35,8 @@ function registerChunkingAndSummaryCases() { it('oversized archive splits into v1 + v2 chunks that reassemble byte-identically', async function () { let many = []; for (let i = 0; i < 40; i++) many.push(matchRow('m' + String(i).padStart(3, '0'))); - let bus = buildMesh(1, { network: 'mainnet', matches: many, cfg: { ANCHOR_CHUNK_MAX_BYTES: '500' } }); // count-path chunking mechanics (SWQ off below 961000) + // Count-path chunking mechanics (SWQ off below 961000); the armed bundle floor lets the v0 ride. + let bus = buildMesh(1, { network: 'mainnet', checkpointCommitment: true, matches: many, cfg: { ANCHOR_CHUNK_MAX_BYTES: '500' } }); let nd = bus.nodes[0]; await startAll(bus); await nd.pub.flush(); diff --git a/test/unit/db/db_oracle_tick_width.test.js b/test/unit/db/db_oracle_tick_width.test.js new file mode 100644 index 000000000..8cebbb6c0 --- /dev/null +++ b/test/unit/db/db_oracle_tick_width.test.js @@ -0,0 +1,147 @@ +'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. +// +// oracle_prices must store every field the PRICE v1 ingest gate admits, and a +// deployed hub widens tick only after its mirrors have. Three arms: +// DEFINITION - the column widths in src/sql/oracle_prices.sql cover the gate bounds. +// GATE - without XCHAIN_HUB_ORACLE_TICK_MIRRORS_WIDENED=1 the widen never runs. +// HELPER - migrateColumnLength widens a narrower live column and nothing else. + +const fs = require('fs'); +const path = require('path'); +const sinon = require('sinon'); +const { expect } = require('chai'); +const proxyquire = require('proxyquire'); + +const { MAX_TICK_LENGTH, MAX_MEMO_LENGTH, MAX_SOURCE_ADDRESS_LENGTH } = require('../../../src/constants.js'); + +const DEF_PATH = path.join(__dirname, '..', '..', '..', 'src', 'sql', 'oracle_prices.sql'); +const FLAG = 'XCHAIN_HUB_ORACLE_TICK_MIRRORS_WIDENED'; + +// A Database over stubbed mariadb + fs, matching the charset migration suite. +function makeDb() { + const mockConn = { query: sinon.stub().resolves([]), release: sinon.stub().resolves(), end: sinon.stub().resolves() }; + const mockPool = { getConnection: sinon.stub().resolves(mockConn), end: sinon.stub().resolves() }; + const Database = proxyquire('../../../src/db', { + mariadb: { createPool: sinon.stub().returns(mockPool), createConnection: sinon.stub().resolves(mockConn) }, + fs: { readdirSync: sinon.stub().returns([]), readFileSync: sinon.stub().returns('') }, + path: require('path') + }); + return { db: new Database('localhost', 3306, 'test_db', 'user', 'pass'), mockConn }; +} + +// The declared spec of one column ("VARCHAR(250) NOT NULL"), comment and comma stripped. +function declaredSpec(column) { + const line = fs.readFileSync(DEF_PATH, 'utf8').split('\n').find(l => new RegExp('^\\s*' + column + '\\s').test(l)); + expect(line, 'oracle_prices.' + column + ' is no longer declared').to.exist; + return line.replace(/--.*$/, '').replace(/,\s*$/, '').trim().replace(new RegExp('^' + column + '\\s+'), '').replace(/\s+/g, ' '); +} + +function declaredWidth(column) { + return Number(/VARCHAR\((\d+)\)/i.exec(declaredSpec(column))[1]); +} + +function registerDefinitionSuite() { + describe('the definition (what a fresh hub gets from verifyTables)', function () { + it('tick holds every tick the ingest gate admits', function () { + expect(declaredWidth('tick')).to.be.at.least(MAX_TICK_LENGTH); + }); + it('source_address and memo hold their gate bounds too', function () { + expect(declaredWidth('source_address')).to.be.at.least(MAX_SOURCE_ADDRESS_LENGTH); + expect(declaredWidth('memo')).to.be.at.least(MAX_MEMO_LENGTH); + }); + }); +} + +function registerGateSuite() { + describe('migrateOracleTickWidth (the mirrors-first gate)', function () { + afterEach(() => { delete process.env[FLAG]; }); + + it('skips and warns while the operator has not attested the mirrors', async function () { + const { db, mockConn } = makeDb(); + const widen = sinon.spy(db, 'migrateColumnLength'); + expect(await db.migrateOracleTickWidth()).to.equal(false); + expect(widen.called).to.equal(false); + expect(mockConn.query.called).to.equal(false); + expect(console.warn.calledWithMatch(new RegExp(FLAG))).to.equal(true); + }); + + it('does not treat any value but 1 as the attestation', async function () { + process.env[FLAG] = 'true'; + const { db } = makeDb(); + const widen = sinon.stub(db, 'migrateColumnLength').resolves(true); + await db.migrateOracleTickWidth(); + expect(widen.called).to.equal(false); + }); + + it('widens to the definition spec once the flag is 1', async function () { + process.env[FLAG] = '1'; + const { db } = makeDb(); + const widen = sinon.stub(db, 'migrateColumnLength').resolves(true); + await db.migrateOracleTickWidth(); + expect(widen.calledOnce).to.equal(true); + expect(widen.firstCall.args).to.deep.equal(['oracle_prices', 'tick', declaredWidth('tick'), declaredSpec('tick')]); + }); + + it('runMigrations reaches the gate on every boot', async function () { + const { db } = makeDb(); + for (const m of ['migrateUniqueKey', 'migrateIndex', 'migrateEnumColumn', 'migrateColumnType', 'migrateColumnCharset']) + sinon.stub(db, m).resolves(); + const gate = sinon.stub(db, 'migrateOracleTickWidth').resolves(false); + await db.runMigrations(); + expect(gate.calledOnce).to.equal(true); + }); + }); +} + +function registerHelperSuite() { + describe('migrateColumnLength', function () { + it('issues the MODIFY when the live column is narrower', async function () { + const { db, mockConn } = makeDb(); + mockConn.query.onCall(0).resolves([{ len: 50 }]).onCall(1).resolves([]); + expect(await db.migrateColumnLength('oracle_prices', 'tick', 250, 'VARCHAR(250) NOT NULL')).to.equal(true); + expect(mockConn.query.getCall(1).args[0]).to.equal('ALTER TABLE `oracle_prices` MODIFY `tick` VARCHAR(250) NOT NULL'); + expect(mockConn.release.called).to.equal(true); + }); + + it('no-ops at or above the target, when absent, and on an unreadable length', async function () { + for (const answer of [[{ len: 250 }], [{ len: 300 }], [], [{ len: null }]]) { + const { db, mockConn } = makeDb(); + mockConn.query.onCall(0).resolves(answer); + expect(await db.migrateColumnLength('oracle_prices', 'tick', 250, 'VARCHAR(250) NOT NULL')).to.equal(false); + expect(mockConn.query.callCount, JSON.stringify(answer)).to.equal(1); + } + }); + + it('logs the hand-run statement and releases rather than taking the boot down', async function () { + const { db, mockConn } = makeDb(); + mockConn.query.onCall(0).resolves([{ len: 50 }]).onCall(1).rejects(new Error('errno 1071')); + expect(await db.migrateColumnLength('oracle_prices', 'tick', 250, 'VARCHAR(250) NOT NULL')).to.equal(false); + expect(console.error.calledWithMatch(/MIGRATION FAILED: oracle_prices\.tick/)).to.equal(true); + expect(console.error.calledWithMatch(/ALTER TABLE `oracle_prices` MODIFY `tick` VARCHAR\(250\) NOT NULL/)).to.equal(true); + expect(mockConn.release.called).to.equal(true); + }); + }); +} + +describe('oracle_prices stores every PRICE v1 field the ingest gate admits', function () { + beforeEach(function () { + sinon.stub(console, 'log'); + sinon.stub(console, 'warn'); + sinon.stub(console, 'error'); + }); + afterEach(() => sinon.restore()); + + registerDefinitionSuite(); + registerGateSuite(); + registerHelperSuite(); +}); From 22a4b1e81895def2545278ba61a75eee14d99aae Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 23:33:41 -0700 Subject: [PATCH 47/57] fix(oracle): ignore duplicate follower proposals Keep the first pending round, including its accumulated votes and timer, when the same proposal is delivered again. Cover the behavior with a deterministic follower harness that also guards conflicting proposals. --- src/oracle/consensus/follower_round.js | 11 +- ...s_follower_round_duplicate_propose.test.js | 113 ++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 test/unit/oracle/consensus/oracle_consensus_follower_round_duplicate_propose.test.js diff --git a/src/oracle/consensus/follower_round.js b/src/oracle/consensus/follower_round.js index 3a022a589..17c4b41ed 100644 --- a/src/oracle/consensus/follower_round.js +++ b/src/oracle/consensus/follower_round.js @@ -153,7 +153,7 @@ function prepareOnPropose(envelope, proposal, proposedAdmit) { // Bind the leader's admission map (or refuse the round), open the pending round and co-sign it. async function bindAndCoSign(envelope, proposal, locked) { - let { round, admitBlocks } = proposal; + let { round, digest, admitBlocks } = proposal; let { blockHeight } = locked; let proposedAdmit = null; { @@ -170,6 +170,15 @@ async function bindAndCoSign(envelope, proposal, locked) { } } + // An exact repeat of a round already open (same digest, same admission map): + // keep the first round's votes and timer, and skip re-opening and re-broadcasting. + let pending = this.pendingRounds.get(round); + if (pending && pending.digest === digest && + this.spellAdmit(pending.admitBlocks) === this.spellAdmit(proposedAdmit)) { + logger.info('Oracle: ignoring duplicate PROPOSE for round ' + round + ' from ' + envelope.sender); + return; + } + openFollowerRound.call(this, proposal, locked, proposedAdmit); prepareOnPropose.call(this, envelope, proposal, proposedAdmit); } diff --git a/test/unit/oracle/consensus/oracle_consensus_follower_round_duplicate_propose.test.js b/test/unit/oracle/consensus/oracle_consensus_follower_round_duplicate_propose.test.js new file mode 100644 index 000000000..219532689 --- /dev/null +++ b/test/unit/oracle/consensus/oracle_consensus_follower_round_duplicate_propose.test.js @@ -0,0 +1,113 @@ +'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. + +const sinon = require('sinon'); +const { expect } = require('chai'); +const OracleConsensus = require('../../../../src/oracle/consensus'); +const { createMockHub } = require('../../../helpers/mockHub'); +const { VALIDATORS_3, makeCapabilitySnapshotStub } = require('../../../helpers/fixtures'); + +const ROUND = 300; +const BLOCK_HEIGHT = 1000; +const PRICES = [{ coinPair: 'BTC/USD', price: '100.00000000' }]; + +function createHarness() { + let leader = VALIDATORS_3[0]; + let follower = VALIDATORS_3[1]; + let thirdParty = VALIDATORS_3[2]; + let clock = sinon.useFakeTimers(); + let hub = createMockHub({ validatorAddr: follower.addr }); + hub.resolveBtcLatestBlock = sinon.stub().resolves(BLOCK_HEIGHT); + hub.capabilitySnapshot = makeCapabilitySnapshotStub(VALIDATORS_3); + let oracleRound = { getSubmissions: sinon.stub().returns(new Map()) }; + let oc = new OracleConsensus(hub, oracleRound); + oc.setValidatorSet(VALIDATORS_3); + oc._lastFinalizedPrices = new Map([['BTC/USD', '100.00000000']]); + let digest = oc.digest(ROUND, PRICES); + return { clock, digest, leader, thirdParty, oc, pm: hub._peerManager }; +} + +function proposeEnvelope(harness) { + return { + sender: harness.leader.addr, + sig_pubkey: harness.leader.pubkey, + data: { + round: ROUND, + prices: PRICES, + digest: harness.digest, + btcBlockHeight: BLOCK_HEIGHT, + btcBlockTime: 1700000000 + } + }; +} + +function prepareEnvelope(harness) { + return { + type: 'ORACLE_PREPARE', + sender: harness.thirdParty.addr, + sig_pubkey: harness.thirdParty.pubkey, + data: { round: ROUND, digest: harness.digest } + }; +} + +describe('OracleConsensus: duplicate follower PROPOSE', function () { + let harness; + + beforeEach(function () { + harness = createHarness(); + }); + + afterEach(function () { + harness.oc.stop(); + harness.clock.restore(); + sinon.restore(); + }); + + it('keeps the first round, its votes, and its timers', async function () { + let { oc, pm, clock, thirdParty } = harness; + await oc.handlePropose(proposeEnvelope(harness)); + let firstPending = oc.pendingRounds.get(ROUND); + expect(firstPending, 'first follower round must exist').to.exist; + + oc.handlePrepare(prepareEnvelope(harness)); + await clock.tickAsync(0); + expect(firstPending.prepares.has(thirdParty.pubkey)).to.be.true; + let broadcastsBeforeDuplicate = pm.broadcast.callCount; + let timersBeforeDuplicate = clock.countTimers(); + + await oc.handlePropose(proposeEnvelope(harness)); + await clock.tickAsync(0); + + expect(oc.pendingRounds.size).to.equal(1); + expect(oc.pendingRounds.get(ROUND)).to.equal(firstPending); + expect(firstPending.prepares.has(thirdParty.pubkey)).to.be.true; + expect(pm.broadcast.callCount).to.equal(broadcastsBeforeDuplicate); + expect(clock.countTimers()).to.equal(timersBeforeDuplicate); + }); + + it('does not replace the first round for a conflicting proposal', async function () { + let { oc, clock, digest } = harness; + await oc.handlePropose(proposeEnvelope(harness)); + let firstPending = oc.pendingRounds.get(ROUND); + let timersBeforeConflict = clock.countTimers(); + let conflicting = proposeEnvelope(harness); + conflicting.data.prices = [{ coinPair: 'BTC/USD', price: '999.00000000' }]; + conflicting.data.digest = oc.digest(ROUND, conflicting.data.prices); + + await oc.handlePropose(conflicting); + + expect(oc.pendingRounds.size).to.equal(1); + expect(oc.pendingRounds.get(ROUND)).to.equal(firstPending); + expect(firstPending.digest).to.equal(digest); + expect(clock.countTimers()).to.equal(timersBeforeConflict); + }); +}); From 08c756e59272cd9fdcdfc1ffffbc9eeb8bf46737 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 00:56:34 -0700 Subject: [PATCH 48/57] fix(hub): ship the validator fleet's rate-limit default HUB_RATE_LIMIT_RPM now defaults to 60000 when P2P_VALIDATOR_ADDR is set, matching what every validator compose was overriding by hand, while standalone config-oracle hubs keep the 100 default and an explicit HUB_RATE_LIMIT_RPM still wins on either role. --- src/api.js | 14 +++++++------- test/unit/api/rate_limit_policy.test.js | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/api.js b/src/api.js index b3d609ad8..e4406d44d 100644 --- a/src/api.js +++ b/src/api.js @@ -100,13 +100,13 @@ const HUB_API_KEY = hubConfig.HUB_API_KEY || ''; // ConfigService sets this var for a managed deploy that has no key in its host // env, so keyless stays possible but is always a stated choice, never a default. const HUB_ALLOW_UNAUTHENTICATED = (hubConfig.HUB_ALLOW_UNAUTHENTICATED || '').toLowerCase() === 'true'; -const HUB_RATE_LIMIT_RPM = parseInt(hubConfig.HUB_RATE_LIMIT_RPM) || 100; -// Loopback and private-range callers skip the per-IP cap by default. The -// caller this protects is the node's OWN indexer replaying a batch-bearing chain: it -// pushes one pushpricebatch per batch block as fast as it reads blocks, blows 100/min -// in seconds, and without this exemption needs HUB_RATE_LIMIT_RPM=60000 set by hand before -// recovery runs at all. Keyed on req.ip (post-trust-proxy), so a public client arriving through a -// private-IP reverse proxy is still throttled; see src/api/rate_limit_policy.js. +// A validator hub (P2P_VALIDATOR_ADDR set) defaults to 60000, the fleet's shipped +// override; an explicit HUB_RATE_LIMIT_RPM still wins on either role. +const HUB_RATE_LIMIT_RPM = parseInt(hubConfig.HUB_RATE_LIMIT_RPM) || (hubConfig.P2P_VALIDATOR_ADDR ? 60000 : 100); +// Loopback and private-range callers skip the per-IP cap by default: the node's +// OWN indexer replaying a batch-bearing chain pushes one pushpricebatch per batch +// block as fast as it reads them. Keyed on req.ip (post-trust-proxy), so a public +// client through a private-IP reverse proxy is still throttled; see rate_limit_policy.js. // Set HUB_RATE_LIMIT_EXEMPT_LOCAL=false to cap every caller including those. const HUB_RATE_LIMIT_EXEMPT_LOCAL = parseExemptLocal(hubConfig.HUB_RATE_LIMIT_EXEMPT_LOCAL); // A comma-separated ALLOWLIST, not a single origin: the hub is called diff --git a/test/unit/api/rate_limit_policy.test.js b/test/unit/api/rate_limit_policy.test.js index becdbfe58..97d1d2b5c 100644 --- a/test/unit/api/rate_limit_policy.test.js +++ b/test/unit/api/rate_limit_policy.test.js @@ -310,7 +310,8 @@ const { buildRateLimitOptions, isLocalCaller, normalizeIp, parseExemptLocal, }); const keys = ['HUB_API_KEY', 'HUB_ALLOW_UNAUTHENTICATED', 'HUB_RATE_LIMIT_RPM', 'HUB_RATE_LIMIT_EXEMPT_LOCAL', 'HUB_DB_HOST', 'HUB_DB_PORT', 'HUB_DB_NAME', - 'HUB_DB_USER', 'HUB_DB_PASS', 'HUB_PORT']; + 'HUB_DB_USER', 'HUB_DB_PASS', 'HUB_PORT', + 'P2P_VALIDATOR_ADDR', 'ORACLE_EPOCH_START', 'HUB_NETWORK']; const saved = {}; for (const k of keys) { saved[k] = process.env[k]; delete process.env[k]; } Object.assign(process.env, { @@ -354,10 +355,22 @@ const { buildRateLimitOptions, isLocalCaller, normalizeIp, parseExemptLocal, expect(opts.limit).to.equal(4200); expect(opts.skip({ ip: '172.17.0.4' })).to.equal(false); } + function validatorEnv(extra) { + return Object.assign({ P2P_VALIDATOR_ADDR: 'validator-1', ORACLE_EPOCH_START: '1700000000000', + HUB_NETWORK: 'regtest' }, extra); + } + async function aValidatorHubDefaultsToTheFleetTest37() { + expect((await bootApiCapturingLimiter(validatorEnv({}))).limit).to.equal(60000); + } + async function anExplicitHubRateLimitRpmStillWinsOnATest38() { + expect((await bootApiCapturingLimiter(validatorEnv({ HUB_RATE_LIMIT_RPM: '4200' }))).limit).to.equal(4200); + } function apiJsWiringSuite34() { this.timeout(10000); it('installs the policy options, exemption on, at the shipped default of 100', installsThePolicyOptionsExemptionOnTest35); it('honours HUB_RATE_LIMIT_RPM and HUB_RATE_LIMIT_EXEMPT_LOCAL=false', honoursHubRateLimitRpmAndTest36); + it('a validator hub defaults to the fleet rate limit without a hand override', aValidatorHubDefaultsToTheFleetTest37); + it('an explicit HUB_RATE_LIMIT_RPM still wins on a validator hub', anExplicitHubRateLimitRpmStillWinsOnATest38); } registerapiJsWiring33 = function registerSuite() { describe('api.js wiring', apiJsWiringSuite34); From 3e0243fde844362858ca5d35e4c0ddc87cf4c8ff Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 08:01:06 -0700 Subject: [PATCH 49/57] perf(db): index the price_snapshots finalized frontier Every mirror reader polls MAX(reference_block) and MAX(block_timestamp) under status = 'finalized' against the hub's price_snapshots, and the hub's status RPC polls MAX(created_at) the same way. With only idx_status each read was a full scan: 1.17M rows on the regtest rail, 1.2 to 1.4 s per read, four pollers, the hub database pinned at 60 to 80 percent CPU. Add the two frontier indexes the indexer and explorer twins already declare (byte-equal names and column lists) plus (status, created_at) for the hub's own age read, and append the three migrateIndex steps to runMigrations so a deployed hub converges at boot. alterTableForDrift never touches indexes. --- src/db/schema/migrations.js | 9 +++++++++ src/sql/price_snapshots.sql | 11 +++++++++++ test/unit/db/db_coverage.test.js | 7 +++++++ 3 files changed, 27 insertions(+) diff --git a/src/db/schema/migrations.js b/src/db/schema/migrations.js index f1ee4d24e..625cdf04c 100644 --- a/src/db/schema/migrations.js +++ b/src/db/schema/migrations.js @@ -168,6 +168,15 @@ module.exports = { // oracle_prices.tick to the 250 the PRICE v1 ingest gate admits, gated on the mirrors // having widened first (see migrateOracleTickWidth for the order and the flag). await this.migrateOracleTickWidth(); + // The price_snapshots finalized-frontier indexes (see the KEY comments in + // src/sql/price_snapshots.sql). Every mirror reader polls MAX(reference_block) and + // MAX(block_timestamp) under status = 'finalized' against the hub's table, and the + // hub's status RPC polls MAX(created_at) the same way; with only idx_status each is a + // full scan on a table that grows a row per pair per round. Appended LAST so the + // pass stays a prefix-extension of what every deployed hub already ran. + await this.migrateIndex('price_snapshots', 'idx_status_block_round', '(status, reference_block, round_number)'); + await this.migrateIndex('price_snapshots', 'idx_status_timestamp_round', '(status, block_timestamp, round_number)'); + await this.migrateIndex('price_snapshots', 'idx_status_created', '(status, created_at)'); } }; diff --git a/src/sql/price_snapshots.sql b/src/sql/price_snapshots.sql index d7a8c12f9..327e5d13f 100644 --- a/src/sql/price_snapshots.sql +++ b/src/sql/price_snapshots.sql @@ -27,6 +27,17 @@ CREATE TABLE price_snapshots ( KEY idx_pair_block (coin_pair, reference_block), KEY idx_pair_timestamp (coin_pair, block_timestamp), KEY idx_status (status), + -- The finalized-frontier reads lead on status and take MAX() of one column, so each + -- needs an index that carries that column under status or MariaDB scans every row + -- (measured 2026-09-23 on the regtest rail: 1.17M rows, 1.2 to 1.4 s per read, four + -- readers polling, the hub's database at 60 to 80 percent CPU). The first two are + -- byte-equal to the indexer and explorer mirror twins (their hub_db_sync price + -- barrier runs both MAX() reads against THIS table over HUB_DB_*); the third serves + -- the hub's own finalized-age read (getPriceSnapshotsFinalizedAgeSeconds). Existing + -- tables get them from runMigrations (alterTableForDrift never touches indexes). + KEY idx_status_block_round (status, reference_block, round_number), + KEY idx_status_timestamp_round (status, block_timestamp, round_number), + KEY idx_status_created (status, created_at), KEY idx_source_chain (source_chain), -- The landing stamp is written by round_number and read by (coin_pair, landing -- clock, round_number) on the indexer side; keep the hub copy indexed the same way diff --git a/test/unit/db/db_coverage.test.js b/test/unit/db/db_coverage.test.js index 806e90cc3..af22d6568 100644 --- a/test/unit/db/db_coverage.test.js +++ b/test/unit/db/db_coverage.test.js @@ -282,6 +282,13 @@ function registerRunMigrationSequenceTests() { expect(mig.calledWith('oracle_submissions', 'uq_submission')).to.be.true; expect(mig.calledWith('validator_rewards', 'uq_reward')).to.be.true; expect(idx.calledWith('validator_rewards', 'idx_batch_seq', '(batch_seq)')).to.be.true; + // The three price_snapshots frontier indexes: names and column lists byte-equal + // to the KEY lines in src/sql/price_snapshots.sql, the first two also to the + // indexer and explorer mirror twins, so a hub that predates them converges on the + // same index set a fresh install gets. + expect(idx.calledWith('price_snapshots', 'idx_status_block_round', '(status, reference_block, round_number)')).to.be.true; + expect(idx.calledWith('price_snapshots', 'idx_status_timestamp_round', '(status, block_timestamp, round_number)')).to.be.true; + expect(idx.calledWith('price_snapshots', 'idx_status_created', '(status, created_at)')).to.be.true; const enCall = en.getCall(0); expect(enCall.args[0]).to.equal('validator_capabilities'); expect(enCall.args[1]).to.equal('capability'); From a8775787387f0c9abd6b3f628e8c2b2dae098dad Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 09:04:51 -0700 Subject: [PATCH 50/57] fix(consensus): re-slide v0.20.1 BTC and DOGE activation heights, 40h margin Live testnet tips (TBTC 153698, TDOGE 67924122 at 2026-09-23T15:55Z) have passed every height the 2026-09-19 reslide armed, so the freeze preflight refuses them. The operator ruled a second reslide to 40 h of margin to the nearest armed height at the fastest defensible cadence (BTC least-squares bound 383.04 s/block, DOGE median 11 s/block), keeping the prior spacing. Propagate it into the hub's own copy of the shared consensus registry: BTC train height 154074 (tip + 376 blocks, 40 h), BTC mirror producer/consumer 154234/154291 (train + 160, 17 h; producer + 57, 6 h; the consumer also carries ANCHOR_ATTEST_BARRIER_ACTIVATION), DOGE mirror producer/consumer 67942777/67944741 (tip + 18655, 57 h, the same instant as the BTC producer; producer + 1964, 6 h). LTC:testnet stays null under dq4 (a) and is untouched. Re-pin bin/pins/at1-consensus-identity.json (consensus_rules_digest and the three moved consensus_rules_gates entries), regenerated with bin/consensus-identity.js --out, and update the three test fixtures that hardcoded the superseded BTC heights and digest so the regression suite tracks the reslide. --- bin/pins/at1-consensus-identity.json | 8 ++++---- src/consensus/gate_registry/shared_rows_1.js | 10 +++++----- src/consensus/gate_registry/shared_rows_2.js | 8 ++++---- src/consensus/gate_registry/shared_rows_5.js | 14 +++++++------- .../anchor_reward_activation_parity.test.js | 6 +++--- .../consensus_rules_digest_active_gates.test.js | 2 +- test/unit/consensus/gate_registry.test.js | 2 +- 7 files changed, 25 insertions(+), 25 deletions(-) diff --git a/bin/pins/at1-consensus-identity.json b/bin/pins/at1-consensus-identity.json index c3fae2a14..0a101c53c 100644 --- a/bin/pins/at1-consensus-identity.json +++ b/bin/pins/at1-consensus-identity.json @@ -1,5 +1,5 @@ { - "consensus_rules_digest": "0b39313c90c49cdbf033e719448d389d2742795be78e17129bdd2925a84292df", + "consensus_rules_digest": "e18f7e2a2e3df32d1834d1983a4b203c05854679d9edac5595f7d0309161425f", "consensus_rules_gates": { "anchor_reward_activation.ANCHOR_REWARD_ACTIVATION": "{\"mainnet\":961000,\"regtest\":0,\"testnet\":0}", "anchor_reward_activation.ARCHIVE_REWARD_ACTIVATION": "{\"mainnet\":963000,\"regtest\":0,\"testnet\":0}", @@ -21,12 +21,12 @@ "attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING_V2": "{\"headroom\":1,\"maxSlots\":2,\"startOffset\":0}", "rollcall_gates_activation.ROLLCALL_GATES_ACTIVATION": "{\"mainnet\":null,\"regtest\":null,\"testnet\":152208}", "xchain_bridge_activation.XCHAIN_BRIDGE_ACTIVATION": "{\"BTC:mainnet\":9999999999,\"BTC:testnet\":152929,\"DOGE:mainnet\":9999999999,\"DOGE:testnet\":67902062,\"LTC:mainnet\":9999999999,\"LTC:testnet\":4887898,\"mainnet\":9999999999,\"regtest\":0,\"testnet\":9999999999}", - "mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":153300,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67916857,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":null}", - "mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":153328,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67917706,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":null}", + "mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":154234,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67942777,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":null}", + "mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":154291,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67944741,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":null}", "mirror_admission_activation.ADMIT_MARGIN_BLOCKS": "{\"anchor_reward_attestations\":144,\"attestation_responses\":1,\"default\":4,\"oracle_prices\":1}", "mirror_admission_activation.ADMIT_MIN_FUTURE_BLOCKS": "1", "mirror_admission_activation.ADMIT_MAX_FUTURE_BLOCKS": "{\"BTC\":6,\"DOGE\":60,\"LTC\":24,\"default\":6}", - "anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION": "{\"mainnet\":null,\"regtest\":null,\"testnet\":153328}", + "anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION": "{\"mainnet\":null,\"regtest\":null,\"testnet\":154291}", "anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S": "64800", "mirror_admission_activation.CHAIN_CODE_RE": "{}", "mirror_admission_activation.CANONICAL_HEIGHT_RE": "{}", diff --git a/src/consensus/gate_registry/shared_rows_1.js b/src/consensus/gate_registry/shared_rows_1.js index 742a811c0..568d8ff57 100644 --- a/src/consensus/gate_registry/shared_rows_1.js +++ b/src/consensus/gate_registry/shared_rows_1.js @@ -295,11 +295,11 @@ addGate('anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S', 'constant', 6 // that window. addGate('anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION', 'height', { mainnet: null, // INERT under the 2026-08-29 mainnet write hold - // SIZED 2026-09-16 20:41Z, RE-SLID 2026-09-19, on the BTC clock because this member is - // BTC-only: the same instant as the family's BTC CONSUMER height, so the one member that - // keeps BOTH certificates gains them together rather than carrying a lone extra rule for - // 6 h. The canon carries the measurement. - testnet: 153328, + // SIZED 2026-09-16 20:41Z, RE-SLID 2026-09-19 and 2026-09-23, on the BTC clock because this + // member is BTC-only: the same instant as the family's BTC CONSUMER height, so the one + // member that keeps BOTH certificates gains them together rather than carrying a lone extra + // rule for 6 h. The canon carries the measurement. + testnet: 154291, regtest: UNPINNED, // shares the family's arming seam so one venue lever arms both }); diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index c245769cf..a50d42c92 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -216,9 +216,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 153300, // RE-SLID 2026-09-19: train 153,221 + 79 blocks (17 h at 781.078553 s/blk), the v0.20.1 patch reslide + 'BTC:testnet': 154234, // RE-SLID 2026-09-23: train 154,074 + 160 blocks (17 h at 383.04 s/blk), the v0.20.1 patch reslide 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67916857, // RE-SLID 2026-09-19: tip 67,911,061 + 5796 blocks (41 h at 25.469118 s/blk, the same instant as the BTC producer), the v0.20.1 patch reslide + 'DOGE:testnet': 67942777, // RE-SLID 2026-09-23: tip 67,924,122 at 15:55Z + 18655 blocks (57 h at 11 s/blk median, the same instant as the BTC producer), the v0.20.1 patch reslide 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -228,9 +228,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 153328, // RE-SLID 2026-09-19: its producer + 28 blocks (6 h at 781.078553 s/blk), strictly above, never equal + 'BTC:testnet': 154291, // RE-SLID 2026-09-23: its producer + 57 blocks (6 h at 383.04 s/blk), strictly above, never equal 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67917706, // RE-SLID 2026-09-19: its producer + 849 blocks (6 h at 25.469118 s/blk) + 'DOGE:testnet': 67944741, // RE-SLID 2026-09-23: its producer + 1964 blocks (6 h at 11 s/blk median) 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration diff --git a/src/consensus/gate_registry/shared_rows_5.js b/src/consensus/gate_registry/shared_rows_5.js index 291fc4fe7..26ada0698 100644 --- a/src/consensus/gate_registry/shared_rows_5.js +++ b/src/consensus/gate_registry/shared_rows_5.js @@ -110,13 +110,13 @@ addGate('train_activation.TRAIN_ACTIVATION', 'ruleset', { // minute roll budget), and every testnet mirror-admission height sits above it on the same // BTC clock, so a node lacking this rule set halts before it can grade an admission-stamped // row. - // RE-SLID 2026-09-19 for the v0.20.1 patch train: margin is 24 h measured from the freeze, - // converted at each coin's own measured cadence, not five days. Chain_tip TBTC 153,110 + 111 - // blocks, ceil(24 h / 781.078553 s per block, least-squares bound over the trailing 114-block - // window, about 25.2 h span, at least as long as the lead). The mirror-admission family - // below re-slides onto the same instant plus its own 17 h and 6 h offsets. LTC stays null - // under dq4 (a) and is untouched by this reslide. - '0.20.0': { mainnet: 9999999999, testnet: 153221, regtest: 0 }, + // RE-SLID 2026-09-23 for the v0.20.1 patch train, after the live tips overran the + // 2026-09-19 slide before the freeze: margin is 40 h to the nearest armed height, converted + // at each coin's fastest defensible cadence. Chain_tip TBTC 153,698 at 2026-09-23T15:55Z + // + 376 blocks, ceil(40 h / 383.04 s per block, the least-squares bound). The + // mirror-admission family below re-slides onto the same instant plus its own 17 h and 6 h + // offsets. LTC stays null under dq4 (a) and is untouched by this reslide. + '0.20.0': { mainnet: 9999999999, testnet: 154074, regtest: 0 }, }); // xchain_bridge_activation diff --git a/test/unit/activations/anchor_reward_activation_parity.test.js b/test/unit/activations/anchor_reward_activation_parity.test.js index f75cc4fae..649b0f802 100644 --- a/test/unit/activations/anchor_reward_activation_parity.test.js +++ b/test/unit/activations/anchor_reward_activation_parity.test.js @@ -174,10 +174,10 @@ it('holds an INERT network inert at height 0 and at a huge height', function () function registerBarrierCutTests() { it('arms the testnet barrier horizon exactly at its sized height, never a block early', function () { expect(local.ANCHOR_ATTEST_BARRIER_ACTIVATION.testnet, - 'not vacuous: the sized testnet height this case is about must still be in the map').to.equal(153328); - expect(local.isAnchorAttestBarrierHorizonActive('testnet', 153327), + 'not vacuous: the sized testnet height this case is about must still be in the map').to.equal(154291); + expect(local.isAnchorAttestBarrierHorizonActive('testnet', 154290), 'the horizon armed a block BELOW its sized height').to.equal(false); - expect(local.isAnchorAttestBarrierHorizonActive('testnet', 153328), + expect(local.isAnchorAttestBarrierHorizonActive('testnet', 154291), 'the horizon is not armed AT its own sized height; the comparison must be >=, not >').to.equal(true); // The same `0 >= threshold` direction the inert case guards, but on a LIVE height: a // guard keyed on null alone would arm genesis here and no inert case would catch it. diff --git a/test/unit/consensus/consensus_rules_digest_active_gates.test.js b/test/unit/consensus/consensus_rules_digest_active_gates.test.js index 1113eb1a1..864036334 100644 --- a/test/unit/consensus/consensus_rules_digest_active_gates.test.js +++ b/test/unit/consensus/consensus_rules_digest_active_gates.test.js @@ -98,7 +98,7 @@ function registerPinnedDigestTest() { for (const [p] of saved) delete require.cache[p]; const fresh = require('../../../src/consensus_rules_digest.js'); expect(fresh.computeConsensusRulesDigest().digest) - .to.equal('0b39313c90c49cdbf033e719448d389d2742795be78e17129bdd2925a84292df', + .to.equal('e18f7e2a2e3df32d1834d1983a4b203c05854679d9edac5595f7d0309161425f', 'the consensus rules digest moved; a gate was added, removed, reordered or re-armed'); } finally { for (const [p, mod] of saved) { if (mod === undefined) delete require.cache[p]; else require.cache[p] = mod; } diff --git a/test/unit/consensus/gate_registry.test.js b/test/unit/consensus/gate_registry.test.js index 1db5e5633..10acb9334 100644 --- a/test/unit/consensus/gate_registry.test.js +++ b/test/unit/consensus/gate_registry.test.js @@ -302,7 +302,7 @@ describe('src/consensus/gate_registry.js: regtest arming from the environment', bootedWith('XC_MIRROR_ADMISSION_ACTIVATION', '150', (fresh) => { const producer = fresh.get(ADMISSION); for (const k of ['BTC:regtest', 'LTC:regtest', 'DOGE:regtest']) expect(producer[k], k).to.equal(150); - expect(producer['BTC:testnet']).to.equal(153300); + expect(producer['BTC:testnet']).to.equal(154234); expect(fresh.copy(BARRIER).regtest).to.equal(150); expect(fresh.get(ROLLCALL).regtest, 'the roll-call lever is a different variable').to.equal(null); }); From e9368e88336ebc037227cb0d9aec071eb012e012 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 09:46:20 -0700 Subject: [PATCH 51/57] chore(release): v0.20.1 --- CHANGELOG.md | 6 ++++++ README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c2a7c33a..7ae746333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.1] - 2026-09-23 + +### Fixed +- Re-slid BTC/DOGE testnet activations with LTC inert; canonical admission is non-throwing, and INERT legacy-signature mismatches stay on the invalid-row path. + + ### Changed - The attestation batch publisher's `quarantinedWindows` statistic now counts only quarantines inside the current catch-up horizon. Dashboards keyed to the previous all-history count may see a one-time decrease after upgrade. diff --git a/README.md b/README.md index 6ab05c0dc..f728667f6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # XChain Platform Hub

- Version + Version Tests Node License diff --git a/package-lock.json b/package-lock.json index 2493cb32e..9ecd9f604 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-hub", - "version": "0.20.0", + "version": "0.20.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-hub", - "version": "0.20.0", + "version": "0.20.1", "license": "AGPL-3.0-or-later", "dependencies": { "acorn": "8.18.0", diff --git a/package.json b/package.json index 2fc761e6d..371513ba0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xchain-hub", "description": "Config oracle and cross-chain coordinator for the XChain Platform. Stores service configs in MariaDB and serves them via JSON-RPC.", - "version": "0.20.0", + "version": "0.20.1", "license": "AGPL-3.0-or-later", "repository": { "type": "git", From 3c58bf526ff9778cc4c74ecf4c651a2091a48d30 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 10:57:34 -0700 Subject: [PATCH 52/57] chore(consensus): remove internal rollout label from public comments Replace the opaque dq4 (a) decision label with a self-contained explanation of why LTC:testnet mirror admission ships disabled on this train. Comment text only; byte-identical to the other five shared-row carriers. --- src/consensus/gate_registry/shared_rows_2.js | 4 ++-- src/consensus/gate_registry/shared_rows_5.js | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index a50d42c92..2b87dd32a 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -217,7 +217,7 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 154234, // RE-SLID 2026-09-23: train 154,074 + 160 blocks (17 h at 383.04 s/blk), the v0.20.1 patch reslide - 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train + 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train 'DOGE:testnet': 67942777, // RE-SLID 2026-09-23: tip 67,924,122 at 15:55Z + 18655 blocks (57 h at 11 s/blk median, the same instant as the BTC producer), the v0.20.1 patch reslide 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -229,7 +229,7 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 154291, // RE-SLID 2026-09-23: its producer + 57 blocks (6 h at 383.04 s/blk), strictly above, never equal - 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train + 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train 'DOGE:testnet': 67944741, // RE-SLID 2026-09-23: its producer + 1964 blocks (6 h at 11 s/blk median) 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration diff --git a/src/consensus/gate_registry/shared_rows_5.js b/src/consensus/gate_registry/shared_rows_5.js index 26ada0698..fde6a96c7 100644 --- a/src/consensus/gate_registry/shared_rows_5.js +++ b/src/consensus/gate_registry/shared_rows_5.js @@ -115,7 +115,8 @@ addGate('train_activation.TRAIN_ACTIVATION', 'ruleset', { // at each coin's fastest defensible cadence. Chain_tip TBTC 153,698 at 2026-09-23T15:55Z // + 376 blocks, ceil(40 h / 383.04 s per block, the least-squares bound). The // mirror-admission family below re-slides onto the same instant plus its own 17 h and 6 h - // offsets. LTC stays null under dq4 (a) and is untouched by this reslide. + // offsets. LTC:testnet mirror admission ships disabled on this train and is + // untouched by this reslide; it arms on a later train. '0.20.0': { mainnet: 9999999999, testnet: 154074, regtest: 0 }, }); From eeddcab1e15a951a4ddf6a116d6259daa4d68cd2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 11:07:24 -0700 Subject: [PATCH 53/57] fix(hub): bound the health amplification path behind the public rate limit 08c756e5 defaulted a validator hub's HUB_RATE_LIMIT_RPM to 60000, the value five testnet validator composes carried by hand after the 2026-09-16 roll wedged every mirror bootstrap at 100 req/min. That lifted the throttle for every caller, a 20-call JSON-RPC batch still cost one token, and each health call read all three admission tips from the indexers, so one keyless client could drive about 1.2M health handlers and 3.6M indexer reads a minute. - Charge a JSON-RPC batch one token per call (batch surcharge over a fixed-window store that charges in bulk). - HUB_RATE_LIMIT_RPM is the public budget again, default 100 on every role. Callers presenting a configured hub key get their own bucket, HUB_AUTH_RATE_LIMIT_RPM, default 60000, which keeps the fleet value for the indexer push and replay traffic it was for. A wrong key is public. - Health's admission-tip probe is single-flight with a 1.5 s cache, so a flood costs at most three indexer reads per window. - Every probe deadline clears its timer on completion, and a timed-out admission read aborts its axios request. --- .env.example | 13 +- CONFIGURATION.md | 5 +- src/api.js | 16 +- src/api/middleware.js | 59 +++-- src/api/rate_limit_store.js | 93 +++++++ src/api/rate_limit_tiers.js | 114 +++++++++ src/api/rpc/health_probes.js | 142 +++++++++++ src/api/rpc/system.js | 100 +------- src/config.js | 1 + src/hub/chain_tips.js | 15 +- src/peers/rpc_batch_guard.js | 13 +- test/unit/api/health_probes.test.js | 191 ++++++++++++++ test/unit/api/rate_limit_policy.test.js | 30 ++- test/unit/api/rate_limit_tiers.test.js | 237 ++++++++++++++++++ test/unit/api/sensitive_read_auth.test.js | 4 +- .../oracle/price/push_price_batch.test.js | 4 +- 16 files changed, 891 insertions(+), 146 deletions(-) create mode 100644 src/api/rate_limit_store.js create mode 100644 src/api/rate_limit_tiers.js create mode 100644 src/api/rpc/health_probes.js create mode 100644 test/unit/api/health_probes.test.js create mode 100644 test/unit/api/rate_limit_tiers.test.js diff --git a/.env.example b/.env.example index 142f76350..c682f598f 100644 --- a/.env.example +++ b/.env.example @@ -19,18 +19,21 @@ HUB_PORT=10000 # startup. ALWAYS set a strong random value in production. See # CONFIGURATION.md "Silent-failure variables". HUB_API_KEY=your-strong-api-key-here -# Optional: requests per minute per client (default 100). Does NOT cover the +# Optional: requests per minute per client without a hub key (default 100, on +# every role). A JSON-RPC batch costs one token per call. Does NOT cover the # mirror-bootstrap routes, which carry their own budget below. # HUB_RATE_LIMIT_RPM=100 +# Optional: requests per minute per client presenting a configured hub key in +# x-api-key (default 60000): the fleet's own indexer push and replay traffic. +# HUB_AUTH_RATE_LIMIT_RPM=60000 # Optional: requests per minute per client on /hub-db/snapshot/* (default 600). # A mirroring indexer bootstrapping from id 0 spends about 30 page reads per # table set, in a burst, and it must not compete with ordinary polling for the # budget above; that is what wedged the testnet mirrors on 2026-09-16. # HUB_SNAPSHOT_RATE_LIMIT_RPM=600 -# Optional: maximum JSON-RPC calls in one batch array (default 20). The limit -# above charges a whole batch ONE token while the router dispatches every -# element concurrently, so without this cap one request fans out into hundreds -# of handlers on the shared DB pool. Over the cap the hub answers 400 / -32600. +# Optional: maximum JSON-RPC calls in one batch array (default 20). The router +# dispatches every element concurrently, so without this cap one request fans +# out into hundreds of handlers on the shared DB pool. Over the cap the hub answers 400 / -32600. # HUB_MAX_RPC_BATCH=20 # Optional: allowed CORS origin(s) (default disabled). `*` allows any origin; # otherwise give one origin, or a comma-separated allowlist matched per-origin. diff --git a/CONFIGURATION.md b/CONFIGURATION.md index bd4299e7d..107afc99b 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -84,10 +84,11 @@ signatures. | `HUB_API_KEY` | **Prod** | _empty_ | API key required on write requests and WS upgrades when set. **Empty means the hub refuses to boot unless `HUB_ALLOW_UNAUTHENTICATED=true` - see above.** | | `HUB_ALLOW_UNAUTHENTICATED` | No | _unset_ | Declares that this hub knowingly runs with an unauthenticated write surface. Only consulted when `HUB_API_KEY` is empty. | | `HUB_CONSENSUS_INPUT_ALERT_AFTER` | No | `3` | Consecutive consensus-input fetch failures before the hub alerts and `/health` reports degraded. | -| `HUB_RATE_LIMIT_RPM` | No | `100` | Requests per minute per client IP, on every route EXCEPT `/hub-db/snapshot/*` (see below). Over the limit the hub answers `429` with a JSON-RPC error body (code `-32029`) naming the limit, the window and the wait, alongside `Retry-After` and `RateLimit-*` headers. | +| `HUB_RATE_LIMIT_RPM` | No | `100` | The PUBLIC budget: requests per minute per client IP for callers without a hub key, on every route EXCEPT `/hub-db/snapshot/*` (see below), on every role including a validator hub. A JSON-RPC batch costs one token per call, not one per HTTP request. Over the limit the hub answers `429` with a JSON-RPC error body (code `-32029`) naming the limit, the window and the wait, alongside `Retry-After` and `RateLimit-*` headers. | +| `HUB_AUTH_RATE_LIMIT_RPM` | No | `60000` | The AUTHENTICATED budget: requests per minute per client IP for callers whose `x-api-key` matches `HUB_API_KEY`, `HUB_REORG_API_KEY` or `HUB_CONFIG_SECRETS_API_KEY`, metered in its own bucket so the fleet's indexer push and replay traffic keeps the fleet value without lifting the throttle for keyless callers. A wrong key is metered as public. Batches are charged per call here too. Never defaults below `HUB_RATE_LIMIT_RPM`; an unparseable or non-positive value keeps the default. A keyless hub has no authenticated tier. | | `HUB_SNAPSHOT_RATE_LIMIT_RPM` | No | `600` | Requests per minute per client IP on the mirror-bootstrap routes `/hub-db/snapshot/*`, metered in their OWN bucket so a bootstrapping indexer and ordinary polling cannot starve each other. Sized to the measured drain: about 30 page reads for one mirror's full ten-table bootstrap, about 96 for three mirrors behind one address, retried every 30s while any mirror is still draining. Over the limit the hub answers `429` in the `{ "error": ... }` shape these REST routes already use. An unparseable or non-positive value keeps the default. | | `HUB_RATE_LIMIT_EXEMPT_LOCAL` | No | `true` | Exempts loopback and private-range (RFC1918 / IPv6 ULA / link-local) callers from the per-IP limit, so a node's own indexer can rebuild price history from the chain at the shipped default instead of needing the limit raised by hand. Keyed on the post-`trust proxy` client IP, so a public caller behind a private-IP proxy is still limited. Set `false` to enforce the cap on every caller. | -| `HUB_MAX_RPC_BATCH` | No | `20` | Maximum JSON-RPC calls in one batch array. The router dispatches every element concurrently while the rate limit above charges the whole batch one token, so an uncapped batch amplifies one request into hundreds of DB-touching handlers. Over the cap the hub answers `400` with JSON-RPC error `-32600`. An unparseable or non-positive value keeps the default. | +| `HUB_MAX_RPC_BATCH` | No | `20` | Maximum JSON-RPC calls in one batch array. The router dispatches every element concurrently, so an uncapped batch amplifies one request into hundreds of DB-touching handlers; the rate limits above charge each element, and this caps the count outright. Over the cap the hub answers `400` with JSON-RPC error `-32600`. An unparseable or non-positive value keeps the default. | | `CORS_ORIGIN` | No | `false` | Allowed CORS origin(s); unset disables cross-origin requests. `*` allows any origin, or give one origin or a comma-separated allowlist matched per-origin (browser wallet shells each send a different origin). A stray `*` inside a list is not a wildcard, so the grant fails closed. | ## Database (MariaDB) diff --git a/src/api.js b/src/api.js index e4406d44d..db1f4ecbb 100644 --- a/src/api.js +++ b/src/api.js @@ -100,9 +100,8 @@ const HUB_API_KEY = hubConfig.HUB_API_KEY || ''; // ConfigService sets this var for a managed deploy that has no key in its host // env, so keyless stays possible but is always a stated choice, never a default. const HUB_ALLOW_UNAUTHENTICATED = (hubConfig.HUB_ALLOW_UNAUTHENTICATED || '').toLowerCase() === 'true'; -// A validator hub (P2P_VALIDATOR_ADDR set) defaults to 60000, the fleet's shipped -// override; an explicit HUB_RATE_LIMIT_RPM still wins on either role. -const HUB_RATE_LIMIT_RPM = parseInt(hubConfig.HUB_RATE_LIMIT_RPM) || (hubConfig.P2P_VALIDATOR_ADDR ? 60000 : 100); +// The PUBLIC budget on every role; key-holding callers (HUB_AUTH_RATE_LIMIT_RPM) have their own. +const HUB_RATE_LIMIT_RPM = parseInt(hubConfig.HUB_RATE_LIMIT_RPM) || 100; // Loopback and private-range callers skip the per-IP cap by default: the node's // OWN indexer replaying a batch-bearing chain pushes one pushpricebatch per batch // block as fast as it reads them. Keyed on req.ip (post-trust-proxy), so a public @@ -375,12 +374,11 @@ async function startApi(){ mountRegistryRoutes(app, ctx); // Bound JSON-RPC batch cardinality (src/peers/rpc_batch_guard.js). The router below runs - // Promise.all over every element of a batch array while the per-IP rate limiter at - // the top of this stack charges the whole batch ONE token, so a single ~100 KB body - // fans out into ~1,400 concurrent handlers on the shared DB pool. Mounted here, in - // front of the router rather than globally, so it governs the dispatcher that - // amplifies and cannot reject a REST route's array body; the limiter has already - // charged its token by this point, so an oversize batch is never free. + // Promise.all over every element of a batch array, so a single ~100 KB body fans out + // into ~1,400 concurrent handlers on the shared DB pool. The limiter at the top of + // this stack already charges a batch one token per call; this caps it outright. + // Mounted in front of the router rather than globally, so it governs the dispatcher + // that amplifies and cannot reject a REST route's array body. // Default 20, matching encoder/decoder/utxo-tracker. No hub caller batches at all // (every connector sends one call object), so the cap breaks no existing client. app.use(makeRpcBatchGuard(resolveMaxBatch(hubConfig.HUB_MAX_RPC_BATCH, 20))); diff --git a/src/api/middleware.js b/src/api/middleware.js index d0d34ee56..5c20b39da 100644 --- a/src/api/middleware.js +++ b/src/api/middleware.js @@ -15,16 +15,18 @@ * XChain Hub - the express middleware stack ahead of every route. * * Order is the auth boundary and is kept exactly: trust proxy, helmet, the JSON - * body parser, CORS, the two per-IP rate limits, the observability surface and + * body parser, CORS, the three per-IP rate limits, the observability surface and * hub gauges, then the x-api-key tier gate and the public-port allowlist. * ********************************************************************/ // The per-IP cap answers in JSON-RPC and stands down for the hub's own // stack, so chain-only price recovery works at shipped defaults. The mirror -// bootstrap is metered separately; see installRateLimits below. +// bootstrap and key-holding callers are metered separately; see installRateLimits below. const { buildRateLimitOptions, isSnapshotRequest, parseSnapshotRpm, SNAPSHOT_PATH_PREFIX } = require('./rate_limit_policy.js'); +const { authenticatedCaller, batchSurcharge, parseAuthRpm, rateLimitKey } = require('./rate_limit_tiers.js'); +const FixedWindowStore = require('./rate_limit_store.js'); const { installObservability } = require('../observability'); // default-off /metrics + structured log shim const { installHubOracleMetrics, installHubStakeShareMetrics } = require('./hub_metrics'); // item a98d6746: oracle-round heartbeat gauges; stake-share margin gauges const { authGate, feedPortAllowlist } = require('./auth_gate'); @@ -83,30 +85,39 @@ function throttleReporter(logger, envName) { }; } -// TWO per-IP buckets, because the hub serves two workloads with different shapes and one -// shared budget let them starve each other. The general bucket meters the JSON-RPC surface -// and everything else at HUB_RATE_LIMIT_RPM (100). The snapshot bucket meters the mirror -// bootstrap alone at HUB_SNAPSHOT_RATE_LIMIT_RPM (600), which is sized to the measured -// drain: on the 2026-09-16 fleet roll three indexers re-draining from id 0 behind one -// public address spent the shared 100 req/min that fleet polling was already using, 429ed -// part way, retried every 30s and stayed wedged until the limit was raised by hand on five -// hubs. Splitting the budgets is what makes that recovery run at shipped defaults WITHOUT -// taking the throttle off the public reads; src/api/rate_limit_policy.js has the numbers. +// THREE per-IP buckets, because the hub serves workloads with different shapes and one +// shared budget let them starve or cover for each other. The public bucket meters the +// JSON-RPC surface and everything else at HUB_RATE_LIMIT_RPM (100 on every role). The +// authenticated bucket meters callers presenting a configured hub key at +// HUB_AUTH_RATE_LIMIT_RPM (60000): the fleet's own indexers and replay traffic, which the +// 60000 validator default of 08c756e5 was for, without handing that budget to keyless +// callers; src/api/rate_limit_tiers.js has the history. The snapshot bucket meters the +// mirror bootstrap alone at HUB_SNAPSHOT_RATE_LIMIT_RPM (600), sized to the drain that +// wedged the 2026-09-16 fleet roll; src/api/rate_limit_policy.js has the numbers. // -// The general limiter is installed FIRST and skips the snapshot family, so a drain charges -// exactly one bucket rather than both. The options for both (the 429 body, the +// The two JSON-RPC tiers skip the snapshot family and each other, so a request charges +// exactly one bucket. Each is followed by its batch surcharge, so a batch costs one token +// per call rather than one per HTTP request. The options (the 429 body, the // loopback/private exemption) live in the policy module so they are unit-testable; api.js // self-starts on require, so nothing declared inline here could ever be asserted against. function installRateLimits(app, ctx) { const { hubConfig, logger, rateLimit, HUB_RATE_LIMIT_RPM, HUB_RATE_LIMIT_EXEMPT_LOCAL } = ctx; const snapshotRpm = parseSnapshotRpm(hubConfig.HUB_SNAPSHOT_RATE_LIMIT_RPM); - app.use(rateLimit(buildRateLimitOptions({ + const authRpm = parseAuthRpm(hubConfig.HUB_AUTH_RATE_LIMIT_RPM, HUB_RATE_LIMIT_RPM); + const isAuthenticated = authenticatedCaller(ctx); + installRpcTier(app, rateLimit, { rpm: HUB_RATE_LIMIT_RPM, - windowMs: 60 * 1000, exemptLocal: HUB_RATE_LIMIT_EXEMPT_LOCAL, - skipPath: isSnapshotRequest, + skipPath: (req) => isSnapshotRequest(req) || isAuthenticated(req), onLimited: throttleReporter(logger, 'HUB_RATE_LIMIT_RPM') - }))); + }); + installRpcTier(app, rateLimit, { + rpm: authRpm, + exemptLocal: HUB_RATE_LIMIT_EXEMPT_LOCAL, + skipPath: (req) => isSnapshotRequest(req) || !isAuthenticated(req), + envName: 'HUB_AUTH_RATE_LIMIT_RPM', + onLimited: throttleReporter(logger, 'HUB_AUTH_RATE_LIMIT_RPM') + }); // Answers in the REST `{ error }` shape its routes already use for 401 and 500, not in // a JSON-RPC envelope: nothing on this path speaks JSON-RPC. app.use(SNAPSHOT_PATH_PREFIX, rateLimit(buildRateLimitOptions({ @@ -118,12 +129,24 @@ function installRateLimits(app, ctx) { onLimited: throttleReporter(logger, 'HUB_SNAPSHOT_RATE_LIMIT_RPM') }))); logger.info('Hub API rate limit: ' + HUB_RATE_LIMIT_RPM + ' req/min per IP, ' + - snapshotRpm + ' req/min per IP on ' + SNAPSHOT_PATH_PREFIX + + authRpm + ' req/min per IP with a hub key, ' + + snapshotRpm + ' req/min per IP on ' + SNAPSHOT_PATH_PREFIX + ', batches charged per call' + (HUB_RATE_LIMIT_EXEMPT_LOCAL ? ' (loopback and private-range callers exempt; HUB_RATE_LIMIT_EXEMPT_LOCAL=false to enforce)' : ' (enforced for every caller, including loopback and private-range)')); } +// One JSON-RPC tier: its limiter over a store the surcharge can charge in bulk, then the +// surcharge built from the very same options. +function installRpcTier(app, rateLimit, tier) { + const opts = Object.assign(buildRateLimitOptions(Object.assign({ windowMs: 60 * 1000 }, tier)), { + store: new FixedWindowStore(), + keyGenerator: rateLimitKey + }); + app.use(rateLimit(opts)); + app.use(batchSurcharge(opts)); +} + function installMetrics(app, ctx) { const { hub, hubConfig } = ctx; // Prometheus /metrics plus a structured log shim, both DEFAULT OFF. diff --git a/src/api/rate_limit_store.js b/src/api/rate_limit_store.js new file mode 100644 index 000000000..7a602c83a --- /dev/null +++ b/src/api/rate_limit_store.js @@ -0,0 +1,93 @@ +/********************************************************************* + * + * 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 Hub - a fixed-window hit store for express-rate-limit that can + * charge more than one hit per request. + * + * express-rate-limit charges one hit per HTTP request, while the JSON-RPC + * router dispatches every element of a batch array. The batch surcharge in + * src/api/rate_limit_tiers.js charges the remaining elements through + * incrementBy, in O(1) whatever the batch length, which the library's own + * MemoryStore cannot do without one awaited increment per element. + * + * No timer: expired windows are swept lazily, at most once per window, on the + * next charge, so a hub that stops taking traffic holds no live handle. + * + ********************************************************************/ + +'use strict'; + +class FixedWindowStore { + constructor() { + this.windowMs = 60 * 1000; + this.windows = new Map(); + this.nextSweepAt = 0; + // Hits live in this process only; express-rate-limit reads this flag. + this.localKeys = true; + } + + // Called by express-rate-limit with the limiter's resolved options. + init(options) { + if (options && Number.isFinite(options.windowMs) && options.windowMs > 0) this.windowMs = options.windowMs; + } + + // The key's current window, or null once it has expired. + currentWindow(key, now) { + const entry = this.windows.get(key); + if (!entry) return null; + if (entry.resetAt <= now) { this.windows.delete(key); return null; } + return entry; + } + + sweep(now) { + if (now < this.nextSweepAt) return; + for (const [key, entry] of this.windows) if (entry.resetAt <= now) this.windows.delete(key); + this.nextSweepAt = now + this.windowMs; + } + + // Charge `hits` against the key's window. Synchronous so a surcharge can decide + // before any other request interleaves. + incrementBy(key, hits) { + const now = Date.now(); + this.sweep(now); + let entry = this.currentWindow(key, now); + if (!entry) { entry = { hits: 0, resetAt: now + this.windowMs }; this.windows.set(key, entry); } + entry.hits += Math.max(0, Math.floor(hits)); + return { totalHits: entry.hits, resetTime: new Date(entry.resetAt) }; + } + + async increment(key) { + return this.incrementBy(key, 1); + } + + async get(key) { + const entry = this.currentWindow(key, Date.now()); + return entry ? { totalHits: entry.hits, resetTime: new Date(entry.resetAt) } : undefined; + } + + async decrement(key) { + const entry = this.currentWindow(key, Date.now()); + if (entry && entry.hits > 0) entry.hits -= 1; + } + + async resetKey(key) { + this.windows.delete(key); + } + + async resetAll() { + this.windows.clear(); + } +} + +module.exports = FixedWindowStore; diff --git a/src/api/rate_limit_tiers.js b/src/api/rate_limit_tiers.js new file mode 100644 index 000000000..aa7f96a44 --- /dev/null +++ b/src/api/rate_limit_tiers.js @@ -0,0 +1,114 @@ +/********************************************************************* + * + * 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 Hub - the public and authenticated JSON-RPC rate-limit tiers, and + * the batch surcharge that charges one token per call. + * + * Why two tiers. 08c756e5 made a validator hub default HUB_RATE_LIMIT_RPM to + * 60000, the value five testnet validator composes had carried by hand since + * the 2026-09-16 roll wedged every mirror bootstrap at 100 req/min. That raise + * lifted the throttle for EVERY caller, and the public surface includes + * `health`, which fans out to three indexer reads per call: one keyless client + * could drive about 1.2M health handlers a minute through 20-call batches. + * The traffic the raise was for is the fleet's own (indexer push rails and + * replay, and the mirror drain, which has had its own bucket since 6b45132b), + * and on a keyed hub that traffic carries a hub key. So the key, not the role, + * picks the budget: a request presenting a configured key is metered at + * HUB_AUTH_RATE_LIMIT_RPM (default 60000), everything else at + * HUB_RATE_LIMIT_RPM (default 100) on every role. A wrong key lands in the + * public tier, so a guessed header buys nothing. + * + * Why a surcharge. express-rate-limit charges one hit per HTTP request, and + * the router runs every element of a batch array, so a 20-call batch cost one + * token. The surcharge runs right after each tier's limiter and charges the + * other n - 1 calls to the same key in the same store. + * + ********************************************************************/ + +'use strict'; + +const crypto = require('crypto'); +const { ipKeyGenerator } = require('express-rate-limit'); + +// The authenticated tier's default: the fleet value 08c756e5 shipped, kept for the +// key-holding callers it was measured against. +const DEFAULT_AUTH_RPM = 60000; + +// An unset, unparseable or non-positive value keeps the default, and the default is +// never below the public limit: presenting a key must not cost a caller budget. +function parseAuthRpm(raw, publicRpm) { + const value = parseInt(raw, 10); + if (Number.isFinite(value) && value > 0) return value; + const floor = Number.isFinite(publicRpm) && publicRpm > 0 ? publicRpm : 0; + return Math.max(DEFAULT_AUTH_RPM, floor); +} + +function timingEqual(provided, expected) { + const a = Buffer.from(String(provided)); + const b = Buffer.from(String(expected)); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +// A predicate over requests: true when x-api-key equals any key this hub has +// configured. A keyless hub has none, so every caller is public there. +function authenticatedCaller(ctx) { + const keys = [ctx.HUB_API_KEY, ctx.HUB_REORG_API_KEY, ctx.HUB_CONFIG_SECRETS_API_KEY] + .filter((key) => typeof key === 'string' && key.length > 0); + return function isAuthenticated(req) { + if (keys.length === 0) return false; + const provided = req && req.headers && req.headers['x-api-key']; + if (typeof provided !== 'string' || provided.length === 0) return false; + return keys.some((key) => timingEqual(provided, key)); + }; +} + +// Tokens a request costs: one per element of a JSON-RPC batch array, one otherwise. +// An empty array still reaches the router, so it still costs its one token. +function batchCost(req) { + const body = req && req.body; + return Array.isArray(body) ? Math.max(1, body.length) : 1; +} + +// The per-IP key, with IPv6 collapsed to its /56 exactly as the library's default does, +// so one host cannot rotate through its own subnet to reset the count. +function rateLimitKey(req) { + return ipKeyGenerator(String((req && req.ip) || '')); +} + +// Charges the rest of a batch against the tier whose limiter just passed the request. +// `opts` is that limiter's own options object, so skip, key, store, limit and the 429 +// body can never disagree with the limiter it follows. +function batchSurcharge(opts) { + return function rpcBatchSurcharge(req, res, next) { + const extra = batchCost(req) - 1; + if (extra <= 0 || opts.skip(req)) return next(); + let totalHits; + try { + totalHits = opts.store.incrementBy(opts.keyGenerator(req), extra).totalHits; + } catch (err) { + return next(err); + } + if (totalHits > opts.limit) return opts.handler(req, res); + return next(); + }; +} + +module.exports = { + DEFAULT_AUTH_RPM, + authenticatedCaller, + batchCost, + batchSurcharge, + parseAuthRpm, + rateLimitKey +}; diff --git a/src/api/rpc/health_probes.js b/src/api/rpc/health_probes.js new file mode 100644 index 000000000..11f98c305 --- /dev/null +++ b/src/api/rpc/health_probes.js @@ -0,0 +1,142 @@ +/********************************************************************* + * + * 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 Hub - bounded probes behind ping and health: a deadline that + * cleans up after itself, and the admission-tip probe with single-flight + * and a short cache. + * + * `health` is on the public tier, and every call used to read all three + * admission tips from the indexers, so the upstream cost of a health flood + * was three indexer calls per call. The probe below shares one upstream read + * among every concurrent caller and reuses its result for + * ADMISSION_PROBE_TTL_MS, so a flood costs at most three indexer calls per + * TTL window whatever its size. The TTL is far inside the six-block stall + * window the admission gate dates tips against, so the report never trails + * the producer's own view by more than a probe interval. + * + ********************************************************************/ + +'use strict'; + +const { + ADMIT_COLUMN_CHAINS, + ADMIT_MARGIN_BLOCKS, + isMirrorAdmissionProducerActive +} = require('../../consensus/gates/mirror_admission_gate.js'); + +const ADMISSION_PROBE_TTL_MS = 1500; + +// Race `work` against a deadline and clear the deadline's timer however the race ends, +// so a fast completion leaves no live handle behind. `work` is a promise, or a function +// handed an AbortSignal that fires at the deadline, so an HTTP read can be cancelled +// instead of left running after its caller has given up on it. +function raceTimeout(work, ms) { + const controller = new AbortController(); + let timer = null; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { controller.abort(); reject(new Error('timeout')); }, ms); + }); + let pending; + try { pending = Promise.resolve(typeof work === 'function' ? work(controller.signal) : work); } + catch (err) { pending = Promise.reject(err); } + return Promise.race([pending, deadline]).finally(() => clearTimeout(timer)); +} + +// The admission-tip probe for one hub. Returns an async function answering the health +// report's `admission_tips` section, or null where the hub runs no producer. +function makeAdmissionTipProbe(hub, p2pConfig, timeoutMs, opts) { + opts = opts || {}; + const ttlMs = Number.isFinite(opts.ttlMs) && opts.ttlMs >= 0 ? opts.ttlMs : ADMISSION_PROBE_TTL_MS; + const clock = typeof opts.now === 'function' ? opts.now : Date.now; + let cached = null; + let inflight = null; + + // A timed-out or failed read is cached like a good one: the refusal it produces is + // the honest report, and re-reading on every call is the amplification this bounds. + function readTips() { + if (cached && clock() - cached.at < ttlMs) return Promise.resolve(cached.resolved); + if (inflight) return inflight; + inflight = raceTimeout((signal) => hub.resolveAdmissionTips(ADMIT_COLUMN_CHAINS.slice(), { signal }), timeoutMs) + .then((resolved) => resolved || {}, () => ({})) + .then((resolved) => { cached = { resolved, at: clock() }; return resolved; }) + .finally(() => { inflight = null; }); + return inflight; + } + + return async function probeAdmissionTips() { + if (!p2pConfig || !hub || typeof hub.resolveAdmissionTips !== 'function') return null; + return admissionReport(hub, await readTips()); + }; +} + +// The per-chain report from one resolver answer. A stale resolver result is null; the +// last observation is retained only as diagnosis and is never promoted back into a +// usable admission height. Built per call, so ages stay current under the cache. +function admissionReport(hub, resolved) { + let chains = {}; + let missing = []; + for (let chain of ADMIT_COLUMN_CHAINS) { + let tip = usableAdmissionHeight(resolved[chain]); + let previous = lastAdmissionTip(hub, chain); + let stale = false; + if (tip === null && previous !== null) { + stale = typeof hub.admissionTipFresh === 'function' + ? !hub.admissionTipFresh(chain, previous.height) + : true; + } + if (tip === null) missing.push(chain); + chains[chain] = { + height: tip === null && previous !== null ? previous.height : tip, + observed_at_ms: previous === null ? null : previous.at_ms, + age_s: previous === null || previous.at_ms === null + ? null : Math.max(0, Math.floor((Date.now() - previous.at_ms) / 1000)), + fresh: tip !== null, + stale, + reason: tip !== null ? null : (stale + ? 'admission tip for ' + chain + ' is stale' + : 'no fresh admission tip for ' + chain) + }; + } + + // Signed admission rows use their own BTC anchor for the producer flag day. + // A stale last observation is sufficient to establish that the node has crossed + // the activation, but is diagnostic only and remains unusable above. + let btcHeight = chains.BTC && chains.BTC.height; + let producerActive = usableAdmissionHeight(btcHeight) !== null && + isMirrorAdmissionProducerActive('BTC', hub.network, btcHeight); + return { + producer_active: producerActive, + healthy: !producerActive || missing.length === 0, + reason: producerActive && missing.length > 0 + ? 'no fresh admission tip for ' + missing.join(', ') + '; refusing to finalize admission-era rows' + : null, + admit_margin_blocks: ADMIT_MARGIN_BLOCKS, + chains + }; +} + +function usableAdmissionHeight(value) { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function lastAdmissionTip(hub, chain) { + let seen = hub && hub._admissionTipSeen; + if (!seen || typeof seen.get !== 'function') return null; + let value = seen.get(chain); + if (!value) return null; + let height = usableAdmissionHeight(value.height); + return height === null ? null : { height, at_ms: Number(value.atMs) || null }; +} + +module.exports = { ADMISSION_PROBE_TTL_MS, makeAdmissionTipProbe, raceTimeout }; diff --git a/src/api/rpc/system.js b/src/api/rpc/system.js index 55a0311f4..a87e3e302 100644 --- a/src/api/rpc/system.js +++ b/src/api/rpc/system.js @@ -23,11 +23,7 @@ const configRedaction = require('../config_redaction.js'); const { DEFAULT_ORACLE_ROUND_INTERVAL_MS } = require('../../constants'); -const { - ADMIT_COLUMN_CHAINS, - ADMIT_MARGIN_BLOCKS, - isMirrorAdmissionProducerActive -} = require('../../consensus/gates/mirror_admission_gate.js'); +const { makeAdmissionTipProbe, raceTimeout } = require('./health_probes.js'); const { validateChain } = require('../validate'); function buildSystemRpc(ctx) { @@ -39,10 +35,7 @@ function systemReads(ctx) { return { async ping(params, {res}) { try { - await Promise.race([ - hub.db.getDatabaseLivenessProbe(), - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), DB_PROBE_TIMEOUT_MS)) - ]); + await raceTimeout(hub.db.getDatabaseLivenessProbe(), DB_PROBE_TIMEOUT_MS); return {status: "success", db: true}; } catch (err) { res.status(503); @@ -157,6 +150,8 @@ function configsRpc(ctx) { function healthRpc(ctx) { const { hub, hubConfig, p2pConfig, configFetchCounters, DB_PROBE_TIMEOUT_MS } = ctx; + // One probe per controller, so concurrent health calls share its upstream read. + const probeAdmissionTips = makeAdmissionTipProbe(hub, p2pConfig, DB_PROBE_TIMEOUT_MS); return { // Like ping, but also reports the DB circuit-breaker state. The breaker // trips open after repeated connection failures and rejects queries during @@ -195,8 +190,10 @@ function healthRpc(ctx) { // chain in that row's read set has a fresh decoder tip. Probe the full // federation set here so an operator sees the dependency before a round // pays for it. The resolver applies the same freshness gate as the - // producer and returns null rather than guessing a height. - let admissionTips = await probeAdmissionTips(hub, p2pConfig, DB_PROBE_TIMEOUT_MS); + // producer and returns null rather than guessing a height. Single-flight + // and cached briefly (health_probes.js), so a health flood cannot + // multiply into indexer reads. + let admissionTips = await probeAdmissionTips(); if (admissionTips && !admissionTips.healthy) healthy = false; let published = publisherStats(hub); @@ -214,10 +211,7 @@ function healthRpc(ctx) { async function probeDatabase(hub, DB_PROBE_TIMEOUT_MS) { let dbOk = false; try { - await Promise.race([ - hub.db.getDatabaseLivenessProbe(), - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), DB_PROBE_TIMEOUT_MS)) - ]); + await raceTimeout(hub.db.getDatabaseLivenessProbe(), DB_PROBE_TIMEOUT_MS); dbOk = true; } catch (err) { dbOk = false; @@ -238,10 +232,7 @@ async function probeOracleFreshness(hub, hubConfig, dbOk, p2pConfig, DB_PROBE_TI // slow-start environments via ORACLE_STALENESS_THRESHOLD_S. oracleThresholdS = parseInt(hubConfig.ORACLE_STALENESS_THRESHOLD_S) || Math.round((roundIntervalMs * 2) / 1000); - let rows = await Promise.race([ - hub.db.getPriceSnapshotsFinalizedAgeSeconds(), - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), DB_PROBE_TIMEOUT_MS)) - ]); + let rows = await raceTimeout(hub.db.getPriceSnapshotsFinalizedAgeSeconds(), DB_PROBE_TIMEOUT_MS); // age_s is null when no round has ever finalized (fresh node); // treat that as not-stale so a slow first round doesn't 503. if (rows && rows.length && rows[0].age_s != null) { @@ -255,77 +246,6 @@ async function probeOracleFreshness(hub, hubConfig, dbOk, p2pConfig, DB_PROBE_TI return { oracleAgeS, oracleStale, oracleThresholdS }; } -// The same per-chain read the producer uses, bounded so /health cannot hang behind -// an indexer. A stale resolver result is null; the last observation is retained only -// as diagnosis and is never promoted back into a usable admission height. -async function probeAdmissionTips(hub, p2pConfig, timeoutMs) { - if (!p2pConfig || !hub || typeof hub.resolveAdmissionTips !== 'function') return null; - - let resolved = {}; - try { - resolved = await Promise.race([ - hub.resolveAdmissionTips(ADMIT_COLUMN_CHAINS.slice()), - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)) - ]) || {}; - } catch (_) { - resolved = {}; - } - - let chains = {}; - let missing = []; - for (let chain of ADMIT_COLUMN_CHAINS) { - let tip = usableAdmissionHeight(resolved[chain]); - let previous = lastAdmissionTip(hub, chain); - let stale = false; - if (tip === null && previous !== null) { - stale = typeof hub.admissionTipFresh === 'function' - ? !hub.admissionTipFresh(chain, previous.height) - : true; - } - if (tip === null) missing.push(chain); - chains[chain] = { - height: tip === null && previous !== null ? previous.height : tip, - observed_at_ms: previous === null ? null : previous.at_ms, - age_s: previous === null || previous.at_ms === null - ? null : Math.max(0, Math.floor((Date.now() - previous.at_ms) / 1000)), - fresh: tip !== null, - stale, - reason: tip !== null ? null : (stale - ? 'admission tip for ' + chain + ' is stale' - : 'no fresh admission tip for ' + chain) - }; - } - - // Signed admission rows use their own BTC anchor for the producer flag day. - // A stale last observation is sufficient to establish that the node has crossed - // the activation, but is diagnostic only and remains unusable above. - let btcHeight = chains.BTC && chains.BTC.height; - let producerActive = usableAdmissionHeight(btcHeight) !== null && - isMirrorAdmissionProducerActive('BTC', hub.network, btcHeight); - return { - producer_active: producerActive, - healthy: !producerActive || missing.length === 0, - reason: producerActive && missing.length > 0 - ? 'no fresh admission tip for ' + missing.join(', ') + '; refusing to finalize admission-era rows' - : null, - admit_margin_blocks: ADMIT_MARGIN_BLOCKS, - chains - }; -} - -function usableAdmissionHeight(value) { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null; -} - -function lastAdmissionTip(hub, chain) { - let seen = hub && hub._admissionTipSeen; - if (!seen || typeof seen.get !== 'function') return null; - let value = seen.get(chain); - if (!value) return null; - let height = usableAdmissionHeight(value.height); - return height === null ? null : { height, at_ms: Number(value.atMs) || null }; -} - // The anchor, attestation and relay stats, read before the status code is set. function publisherStats(hub) { let anchorStats = hub.stateAnchorPublisher ? hub.stateAnchorPublisher.getAnchorStats() : null; diff --git a/src/config.js b/src/config.js index facc9cdf0..21a501e00 100644 --- a/src/config.js +++ b/src/config.js @@ -136,6 +136,7 @@ const config = { get HUB_ADMISSION_RELAY() { return process.env.HUB_ADMISSION_RELAY; }, get HUB_ALLOW_UNAUTHENTICATED() { return process.env.HUB_ALLOW_UNAUTHENTICATED; }, get HUB_API_KEY() { return process.env.HUB_API_KEY; }, + get HUB_AUTH_RATE_LIMIT_RPM() { return process.env.HUB_AUTH_RATE_LIMIT_RPM; }, get HUB_CAPABILITY_CONFIG() { return process.env.HUB_CAPABILITY_CONFIG; }, get HUB_CONFIG_SECRETS_API_KEY() { return process.env.HUB_CONFIG_SECRETS_API_KEY; }, get HUB_CONSENSUS_INPUT_ALERT_AFTER() { return process.env.HUB_CONSENSUS_INPUT_ALERT_AFTER; }, diff --git a/src/hub/chain_tips.js b/src/hub/chain_tips.js index bf1024c43..5147d18b5 100644 --- a/src/hub/chain_tips.js +++ b/src/hub/chain_tips.js @@ -171,7 +171,9 @@ class ChainTips { return admissionTipMemo.run(new WeakMap(), fn); } - async resolveAdmissionTip(coin){ + // opts.signal, when given, cancels the indexer read (the health probe's deadline). + async resolveAdmissionTip(coin, opts){ + let signal = opts && opts.signal; let c = admissionHeight.normalizeChain(coin); if(c === null){ logger.warn('XChainHub: admission tip requested for unusable chain ' + JSON.stringify(String(coin))); @@ -188,9 +190,11 @@ class ChainTips { let res = await axiosFor(this).post(url, { jsonrpc: '2.0', id: Date.now(), method: 'getlatestblock', params: {} - }, { timeout: 5000 }); + }, signal ? { timeout: 5000, signal } : { timeout: 5000 }); result = res && res.data && res.data.result; } catch (err) { + // A read the caller cancelled is the caller's deadline, not an indexer fault. + if(signal && signal.aborted) return null; logger.error(nodeUtil.format('XChainHub: failed to read the ' + c + ' admission tip from its indexer:', err.message)); return null; } @@ -257,7 +261,10 @@ class ChainTips { // Every admission tip a row's read set needs, read in parallel. A chain whose tip is // refused comes back null rather than missing, so the caller's refusal names it. - async resolveAdmissionTips(chains){ + // opts.signal cancels the reads, but only outside a proposal memo: a memoized read + // is shared with the rest of the proposal, and one caller's deadline must not + // cancel it for the others. + async resolveAdmissionTips(chains, opts){ let out = {}; let want = []; for(let raw of (chains || [])){ @@ -272,7 +279,7 @@ class ChainTips { if(!memo){ memo = new Map(); scope.set(this, memo); } } let tips = await Promise.all(want.map((c) => { - if(!memo) return this.resolveAdmissionTip(c).catch(() => null); + if(!memo) return this.resolveAdmissionTip(c, opts).catch(() => null); if(!memo.has(c)) memo.set(c, Promise.resolve().then(() => this.resolveAdmissionTip(c)).catch(() => null)); return memo.get(c); diff --git a/src/peers/rpc_batch_guard.js b/src/peers/rpc_batch_guard.js index 64b5e9c5f..788a60f4b 100644 --- a/src/peers/rpc_batch_guard.js +++ b/src/peers/rpc_batch_guard.js @@ -15,12 +15,13 @@ * XChain Hub - JSON-RPC batch-size cap * * express-json-rpc-router dispatches EVERY element of a batch array - * concurrently, while the per-IP express-rate-limit in front of it charges one - * token per HTTP REQUEST regardless of array length. So one ~100 KB body of - * ~1,400 seventy-byte call objects amplifies into ~1,400 concurrent handlers, - * each drawing on the shared MariaDB pool, for the price of a single token - - * and on a keyless deploy (no HUB_API_KEY) the amplified set includes the - * push* write rails. Cap the CARDINALITY before dispatch. + * concurrently. The per-IP limiter in front of it once charged one token per + * HTTP REQUEST, so one ~100 KB body of ~1,400 seventy-byte call objects + * amplified into ~1,400 concurrent handlers, each drawing on the shared + * MariaDB pool, for a single token - and on a keyless deploy (no HUB_API_KEY) + * the amplified set includes the push* write rails. The limiter now charges + * per call (src/api/rate_limit_tiers.js); this still caps the CARDINALITY + * before dispatch, so no single request can fan out that far. * * Lives in its own module rather than inline in api.js because the hub's whole * middleware stack is built inside async function startApi(), which exports diff --git a/test/unit/api/health_probes.test.js b/test/unit/api/health_probes.test.js new file mode 100644 index 000000000..93f32d12b --- /dev/null +++ b/test/unit/api/health_probes.test.js @@ -0,0 +1,191 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + **********************************************************************/ + +'use strict'; + +/** + * The probes behind `health` (src/api/rpc/health_probes.js). + * + * The defect these guard: every health call read all three admission tips from the + * indexers, and each Promise.race deadline left its timer running after a fast answer + * and its axios read running after a timeout. One upstream read is now shared by every + * concurrent caller and cached briefly, every deadline is cleared, and a timed-out read + * is cancelled. The upstream count is measured on a real indexer-shaped endpoint. + */ + +const { expect } = require('chai'); +const sinon = require('sinon'); +const http = require('http'); +const XChainHub = require('../../../src/XChainHub.js'); +const { buildSystemRpc } = require('../../../src/api/rpc/system.js'); +const { ADMISSION_PROBE_TTL_MS, makeAdmissionTipProbe, raceTimeout } = require('../../../src/api/rpc/health_probes.js'); +const { ADMIT_COLUMN_CHAINS } = require('../../../src/consensus/gates/mirror_admission_gate.js'); + +let server = null; +let url = ''; +let upstream = 0; +let closedEarly = 0; + +// An indexer that answers getlatestblock after `delayMs`, or never when delayMs is null. +function startIndexer(delayMs) { + upstream = 0; + closedEarly = 0; + server = http.createServer((req, res) => { + upstream++; + res.on('close', () => { if (!res.writableEnded) closedEarly++; }); + req.resume(); + if (delayMs === null) return; + setTimeout(() => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { block_index: 100, decoder_block: 120 } })); + }, delayMs); + }); + return new Promise((resolve) => server.listen(0, '127.0.0.1', () => { + url = 'http://127.0.0.1:' + server.address().port + '/'; + resolve(); + })); +} + +function stopIndexer() { + return new Promise((resolve) => { + if (!server) return resolve(); + server.closeAllConnections(); + server.close(() => { server = null; resolve(); }); + }); +} + +// The real resolver methods on a hub that reads its indexer URL and nothing else. +function indexerBackedHub() { + return { + network: 'regtest', + _admissionTipSeen: new Map(), + resolveIndexerUrl: async () => url, + resolveAdmissionTips: XChainHub.prototype.resolveAdmissionTips, + resolveAdmissionTip: XChainHub.prototype.resolveAdmissionTip, + admissionTipFresh: XChainHub.prototype.admissionTipFresh, + db: { + circuitState: 'closed', + getDatabaseLivenessProbe: async () => true, + getPriceSnapshotsFinalizedAgeSeconds: async () => [] + } + }; +} + +function healthRpcFor(hub, timeoutMs) { + return buildSystemRpc({ + hub, hubConfig: {}, p2pConfig: {}, configFetchCounters: { served: 0, errors: 0 }, + DB_PROBE_TIMEOUT_MS: timeoutMs || 2000, COIN_CONSENSUS_HASHES: {} + }); +} + +const RES = { status() { return this; } }; + +function concurrentHealth(rpc, n) { + return Promise.all(Array.from({ length: n }, () => rpc.health({}, { res: RES }))); +} + +function singleFlightSuite() { + afterEach(stopIndexer); + + it('collapses N concurrent health calls into exactly one read per admission chain', async function () { + expect(ADMIT_COLUMN_CHAINS).to.have.length(3); + await startIndexer(30); + const rpc = healthRpcFor(indexerBackedHub()); + const bodies = await concurrentHealth(rpc, 50); + expect(upstream).to.equal(3); + for (const body of bodies) expect(body.admission_tips.chains.BTC.height).to.equal(120); + // Still inside the TTL: a second flood reuses the same answer. + await concurrentHealth(rpc, 50); + expect(upstream).to.equal(3); + }); + + it('reads again once the cache has aged past its TTL', async function () { + let t = 0; + const hub = { resolveAdmissionTips: sinon.stub().resolves({ BTC: 1, LTC: 2, DOGE: 3 }), _admissionTipSeen: new Map() }; + const probe = makeAdmissionTipProbe(hub, {}, 2000, { ttlMs: 1000, now: () => t }); + await Promise.all([probe(), probe(), probe()]); + expect(hub.resolveAdmissionTips.callCount).to.equal(1); + t = 999; + await probe(); + expect(hub.resolveAdmissionTips.callCount).to.equal(1); + t = 1001; + await probe(); + expect(hub.resolveAdmissionTips.callCount).to.equal(2); + expect(ADMISSION_PROBE_TTL_MS).to.be.within(1000, 2000); + }); + + it('cancels the indexer reads when the probe deadline fires', async function () { + await startIndexer(null); + const rpc = healthRpcFor(indexerBackedHub(), 50); + const [body] = await concurrentHealth(rpc, 5); + expect(upstream).to.equal(3); + expect(body.admission_tips.chains.BTC.fresh).to.equal(false); + // axios' own timeout is 5000ms; a close inside 1s is the abort, not that. + const deadline = Date.now() + 1000; + while (closedEarly < 3 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 10)); + expect(closedEarly).to.equal(3); + }); + + it('answers null where the hub runs no admission producer', async function () { + expect(await makeAdmissionTipProbe({}, {}, 2000)()).to.equal(null); + expect(await makeAdmissionTipProbe({ resolveAdmissionTips() {} }, null, 2000)()).to.equal(null); + }); +} + +function timerSuite() { + let clock; + beforeEach(() => { clock = sinon.useFakeTimers(); }); + afterEach(() => clock.restore()); + + it('clears the deadline when the work completes first', async function () { + expect(await raceTimeout(Promise.resolve('done'), 2000)).to.equal('done'); + expect(clock.countTimers()).to.equal(0); + let err = null; + await raceTimeout(Promise.reject(new Error('boom')), 2000).catch((e) => { err = e; }); + expect(err.message).to.equal('boom'); + expect(clock.countTimers()).to.equal(0); + }); + + it('aborts the work and rejects when the deadline fires', async function () { + let signal = null; + const race = raceTimeout((s) => { signal = s; return new Promise(() => {}); }, 2000); + const outcome = race.catch((e) => e); + await clock.tickAsync(2000); + expect((await outcome).message).to.equal('timeout'); + expect(signal.aborted).to.equal(true); + expect(clock.countTimers()).to.equal(0); + }); + + it('rejects a synchronously throwing work without leaving its timer', async function () { + const err = await raceTimeout(() => { throw new Error('sync'); }, 2000).catch((e) => e); + expect(err.message).to.equal('sync'); + expect(clock.countTimers()).to.equal(0); + }); + + it('leaves no live timer after health and ping answer', async function () { + const hub = indexerBackedHub(); + hub.resolveAdmissionTips = async () => ({ BTC: 5, LTC: 6, DOGE: 7 }); + const rpc = healthRpcFor(hub); + const body = await rpc.health({}, { res: RES }); + expect(body.status).to.equal('healthy'); + expect(await rpc.ping({}, { res: RES })).to.deep.equal({ status: 'success', db: true }); + expect(clock.countTimers()).to.equal(0); + }); +} + +describe('hub health probes', function () { + this.timeout(10000); + describe('admission-tip single flight', singleFlightSuite); + describe('deadlines', timerSuite); +}); diff --git a/test/unit/api/rate_limit_policy.test.js b/test/unit/api/rate_limit_policy.test.js index 97d1d2b5c..49381f382 100644 --- a/test/unit/api/rate_limit_policy.test.js +++ b/test/unit/api/rate_limit_policy.test.js @@ -296,7 +296,7 @@ const { buildRateLimitOptions, isLocalCaller, normalizeIp, parseExemptLocal, // api.js self-starts on require, so asserting its wiring requires booting it under proxyquire // and reading the options handed to express-rate-limit. This keeps the check on the actual // application boundary where environment policy becomes middleware configuration. - async function bootApiCapturingLimiter(env) { + async function bootApiCapturingLimiters(env) { const mockApp = { use: sinon.stub(), get: sinon.stub(), post: sinon.stub(), set: sinon.stub(), listen: sinon.stub().callsFake((p, h, cb) => { if (cb) cb(); }) @@ -308,7 +308,7 @@ const { buildRateLimitOptions, isLocalCaller, normalizeIp, parseExemptLocal, const mockHub = new Proxy({}, { get: (t, p) => { if (!(p in t)) t[p] = sinon.stub().callsFake(async () => ({})); return t[p]; } }); - const keys = ['HUB_API_KEY', 'HUB_ALLOW_UNAUTHENTICATED', 'HUB_RATE_LIMIT_RPM', + const keys = ['HUB_API_KEY', 'HUB_ALLOW_UNAUTHENTICATED', 'HUB_RATE_LIMIT_RPM', 'HUB_AUTH_RATE_LIMIT_RPM', 'HUB_RATE_LIMIT_EXEMPT_LOCAL', 'HUB_DB_HOST', 'HUB_DB_PORT', 'HUB_DB_NAME', 'HUB_DB_USER', 'HUB_DB_PASS', 'HUB_PORT', 'P2P_VALIDATOR_ADDR', 'ORACLE_EPOCH_START', 'HUB_NETWORK']; @@ -332,16 +332,17 @@ const { buildRateLimitOptions, isLocalCaller, normalizeIp, parseExemptLocal, 'geoip-lite': { lookup: sinon.stub().returns(null) }, './XChainHub': function () { return mockHub; } }); + // The async boot reads HUB_AUTH_RATE_LIMIT_RPM, so keep the env until it has. + await waitUntil(() => rateLimitStub.callCount >= 3, { label: 'api.js boot to install the rate limiters' }); } finally { for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } } - await waitUntil(() => rateLimitStub.called, { label: 'api.js boot to install the rate limiter' }); - return rateLimitStub.firstCall.args[0]; + return rateLimitStub.args.map((args) => args[0]); } async function installsThePolicyOptionsExemptionOnTest35() { - const opts = await bootApiCapturingLimiter({}); + const [opts] = await bootApiCapturingLimiters({}); expect(opts.limit).to.equal(100); expect(opts.windowMs).to.equal(60000); expect(opts.skip({ ip: '172.17.0.4' })).to.equal(true); @@ -349,7 +350,7 @@ const { buildRateLimitOptions, isLocalCaller, normalizeIp, parseExemptLocal, expect(typeof opts.handler).to.equal('function'); } async function honoursHubRateLimitRpmAndTest36() { - const opts = await bootApiCapturingLimiter({ + const [opts] = await bootApiCapturingLimiters({ HUB_RATE_LIMIT_RPM: '4200', HUB_RATE_LIMIT_EXEMPT_LOCAL: 'false' }); expect(opts.limit).to.equal(4200); @@ -359,18 +360,27 @@ const { buildRateLimitOptions, isLocalCaller, normalizeIp, parseExemptLocal, return Object.assign({ P2P_VALIDATOR_ADDR: 'validator-1', ORACLE_EPOCH_START: '1700000000000', HUB_NETWORK: 'regtest' }, extra); } + // The 60000 fleet value lives in the key-holders' tier; the keyless surface stays at 100. async function aValidatorHubDefaultsToTheFleetTest37() { - expect((await bootApiCapturingLimiter(validatorEnv({}))).limit).to.equal(60000); + const [publicTier, authTier] = await bootApiCapturingLimiters(validatorEnv({})); + expect(publicTier.limit).to.equal(100); + expect(authTier.limit).to.equal(60000); + expect(authTier.skip({ ip: '203.0.113.7', originalUrl: '/', headers: { 'x-api-key': 'test-hub-key' } })).to.equal(false); + expect(publicTier.skip({ ip: '203.0.113.7', originalUrl: '/', headers: { 'x-api-key': 'test-hub-key' } })).to.equal(true); + expect(publicTier.skip({ ip: '203.0.113.7', originalUrl: '/', headers: {} })).to.equal(false); } async function anExplicitHubRateLimitRpmStillWinsOnATest38() { - expect((await bootApiCapturingLimiter(validatorEnv({ HUB_RATE_LIMIT_RPM: '4200' }))).limit).to.equal(4200); + const [publicTier, authTier] = await bootApiCapturingLimiters(validatorEnv({ + HUB_RATE_LIMIT_RPM: '4200', HUB_AUTH_RATE_LIMIT_RPM: '9000' })); + expect(publicTier.limit).to.equal(4200); + expect(authTier.limit).to.equal(9000); } function apiJsWiringSuite34() { this.timeout(10000); it('installs the policy options, exemption on, at the shipped default of 100', installsThePolicyOptionsExemptionOnTest35); it('honours HUB_RATE_LIMIT_RPM and HUB_RATE_LIMIT_EXEMPT_LOCAL=false', honoursHubRateLimitRpmAndTest36); - it('a validator hub defaults to the fleet rate limit without a hand override', aValidatorHubDefaultsToTheFleetTest37); - it('an explicit HUB_RATE_LIMIT_RPM still wins on a validator hub', anExplicitHubRateLimitRpmStillWinsOnATest38); + it('a validator hub keeps 100 public and gives key-holders the 60000 fleet tier', aValidatorHubDefaultsToTheFleetTest37); + it('explicit HUB_RATE_LIMIT_RPM and HUB_AUTH_RATE_LIMIT_RPM win on a validator hub', anExplicitHubRateLimitRpmStillWinsOnATest38); } registerapiJsWiring33 = function registerSuite() { describe('api.js wiring', apiJsWiringSuite34); diff --git a/test/unit/api/rate_limit_tiers.test.js b/test/unit/api/rate_limit_tiers.test.js new file mode 100644 index 000000000..9d127066b --- /dev/null +++ b/test/unit/api/rate_limit_tiers.test.js @@ -0,0 +1,237 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + **********************************************************************/ + +'use strict'; + +/** + * The public and authenticated JSON-RPC tiers, and the batch surcharge. + * + * The defect these guard: a validator hub shipped HUB_RATE_LIMIT_RPM=60000 for every + * caller, and a 20-call batch cost one token, so one keyless client could drive about + * 1.2M `health` handlers a minute. Driven over a socket against the real + * installRateLimits stack, as rate_limit_snapshot_route.test.js drives the drain. + */ + +const { expect } = require('chai'); +const express = require('express'); +const rateLimit = require('express-rate-limit'); +const http = require('http'); +const sinon = require('sinon'); +const { installRateLimits } = require('../../../src/api/middleware.js'); +const { DEFAULT_AUTH_RPM, authenticatedCaller, batchCost, parseAuthRpm } = require('../../../src/api/rate_limit_tiers.js'); +const FixedWindowStore = require('../../../src/api/rate_limit_store.js'); + +const PUBLIC_IP = '203.0.113.7'; +const HUB_KEY = 'hub-key-for-tests'; + +let server = null; +let port = 0; +let handled = 0; + +function boot(opts) { + opts = opts || {}; + handled = 0; + const app = express(); + app.set('trust proxy', 'loopback'); + app.use(express.json()); + installRateLimits(app, { + hubConfig: { HUB_AUTH_RATE_LIMIT_RPM: opts.authRpm }, + logger: { info() {}, warn() {} }, + rateLimit, + HUB_API_KEY: opts.keyless ? '' : HUB_KEY, + HUB_RATE_LIMIT_RPM: opts.rpm === undefined ? 100 : opts.rpm, + HUB_RATE_LIMIT_EXEMPT_LOCAL: true + }); + app.post('/', (req, res) => { + const calls = Array.isArray(req.body) ? req.body : [req.body]; + handled += calls.length; + res.json(calls.map((c) => ({ jsonrpc: '2.0', id: c.id, result: 'ok' }))); + }); + return new Promise((resolve) => { + server = app.listen(0, '127.0.0.1', () => { port = server.address().port; resolve(); }); + }); +} + +function shutdown() { + return new Promise((resolve) => { + if (!server) return resolve(); + server.close(() => { server = null; resolve(); }); + }); +} + +function post(payload, headers) { + const body = JSON.stringify(payload); + return new Promise((resolve, reject) => { + const req = http.request({ + hostname: '127.0.0.1', port, path: '/', method: 'POST', + headers: Object.assign({ + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + 'X-Forwarded-For': PUBLIC_IP + }, headers || {}) + }, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data })); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +function healthCall(id) { + return { jsonrpc: '2.0', id, method: 'health', params: {} }; +} + +function batchOf(n) { + return Array.from({ length: n }, (_, i) => healthCall(i + 1)); +} + +const WITH_KEY = { 'x-api-key': HUB_KEY }; + +function batchChargingSuite() { + it('charges a batch one token per call', async function () { + await boot({ rpm: 40 }); + expect((await post(batchOf(20))).status).to.equal(200); + expect((await post(batchOf(20))).status).to.equal(200); + const over = await post(healthCall(99)); + expect(over.status).to.equal(429); + expect(JSON.parse(over.body).error.code).to.equal(-32029); + expect(handled).to.equal(40); + }); + + it('refuses a batch whose calls exceed what is left, before any call runs', async function () { + await boot({ rpm: 10 }); + const res = await post(batchOf(20)); + expect(res.status).to.equal(429); + expect(handled).to.equal(0); + }); + + it('still charges a single call exactly one token', async function () { + await boot({ rpm: 5 }); + for (let i = 1; i <= 5; i++) expect((await post(healthCall(i))).status, 'call ' + i).to.equal(200); + expect((await post(healthCall(6))).status).to.equal(429); + }); + + it('charges an authenticated batch per call in the authenticated tier', async function () { + await boot({ rpm: 5, authRpm: 30 }); + expect((await post(batchOf(20), WITH_KEY)).status).to.equal(200); + expect((await post(batchOf(10), WITH_KEY)).status).to.equal(200); + expect((await post(healthCall(1), WITH_KEY)).status).to.equal(429); + }); + + it('counts tokens by call: batchCost', function () { + expect(batchCost({ body: batchOf(20) })).to.equal(20); + expect(batchCost({ body: [] })).to.equal(1); + expect(batchCost({ body: healthCall(1) })).to.equal(1); + expect(batchCost({})).to.equal(1); + expect(batchCost(undefined)).to.equal(1); + }); +} + +function bucketsSuite() { + it('keeps the public tier conservative while a key-holder on the same IP carries on', async function () { + await boot({ rpm: 3, authRpm: 50 }); + for (let i = 1; i <= 3; i++) expect((await post(healthCall(i))).status).to.equal(200); + const refused = await post(healthCall(4)); + expect(refused.status).to.equal(429); + expect(JSON.parse(refused.body).error.data.env).to.equal('HUB_RATE_LIMIT_RPM'); + for (let i = 1; i <= 40; i++) expect((await post(healthCall(i), WITH_KEY)).status, 'keyed ' + i).to.equal(200); + const keyed = await post(healthCall(41), WITH_KEY); + expect(keyed.headers['ratelimit-limit']).to.equal('50'); + }); + + it('a key-holder spending its tier leaves the public tier untouched', async function () { + await boot({ rpm: 3, authRpm: 4 }); + for (let i = 1; i <= 4; i++) expect((await post(healthCall(i), WITH_KEY)).status).to.equal(200); + const refused = await post(healthCall(5), WITH_KEY); + expect(refused.status).to.equal(429); + expect(JSON.parse(refused.body).error.data.env).to.equal('HUB_AUTH_RATE_LIMIT_RPM'); + expect((await post(healthCall(6))).status).to.equal(200); + }); + + it('a wrong key buys nothing: it is metered as public', async function () { + await boot({ rpm: 2, authRpm: 50 }); + const wrong = { 'x-api-key': 'not-the-key' }; + expect((await post(healthCall(1), wrong)).status).to.equal(200); + expect((await post(healthCall(2), wrong)).status).to.equal(200); + expect((await post(healthCall(3), wrong)).status).to.equal(429); + }); + + it('a keyless hub has no authenticated tier at all', async function () { + await boot({ rpm: 2, authRpm: 50, keyless: true }); + expect((await post(healthCall(1), WITH_KEY)).status).to.equal(200); + expect((await post(healthCall(2), WITH_KEY)).status).to.equal(200); + expect((await post(healthCall(3), WITH_KEY)).status).to.equal(429); + }); + + it('recognises every configured hub key and nothing else', function () { + const isAuthenticated = authenticatedCaller({ + HUB_API_KEY: 'bulk', HUB_REORG_API_KEY: 'reorg', HUB_CONFIG_SECRETS_API_KEY: '' + }); + expect(isAuthenticated({ headers: { 'x-api-key': 'bulk' } })).to.equal(true); + expect(isAuthenticated({ headers: { 'x-api-key': 'reorg' } })).to.equal(true); + expect(isAuthenticated({ headers: { 'x-api-key': '' } })).to.equal(false); + expect(isAuthenticated({ headers: { 'x-api-key': 'bulkx' } })).to.equal(false); + expect(isAuthenticated({ headers: {} })).to.equal(false); + expect(isAuthenticated(undefined)).to.equal(false); + expect(authenticatedCaller({})({ headers: { 'x-api-key': '' } })).to.equal(false); + }); + + it('defaults the authenticated tier to the fleet value, never below the public one', function () { + expect(DEFAULT_AUTH_RPM).to.equal(60000); + expect(parseAuthRpm(undefined, 100)).to.equal(60000); + expect(parseAuthRpm('abc', 100)).to.equal(60000); + expect(parseAuthRpm('0', 100)).to.equal(60000); + expect(parseAuthRpm(undefined, 90000)).to.equal(90000); + expect(parseAuthRpm('5000', 100)).to.equal(5000); + }); +} + +// Date is faked so window expiry is driven, never waited for. +function storeSuite() { + let clock; + beforeEach(() => { clock = sinon.useFakeTimers(); }); + afterEach(() => clock.restore()); + + it('charges in bulk and resets when the window expires', async function () { + const store = new FixedWindowStore(); + store.init({ windowMs: 50 }); + expect(store.incrementBy('k', 19).totalHits).to.equal(19); + expect((await store.increment('k')).totalHits).to.equal(20); + await store.decrement('k'); + expect((await store.get('k')).totalHits).to.equal(19); + clock.tick(51); + expect(await store.get('k')).to.equal(undefined); + expect(store.incrementBy('k', 1).totalHits).to.equal(1); + }); + + it('sweeps expired keys so the map cannot grow without bound', function () { + const store = new FixedWindowStore(); + store.init({ windowMs: 20 }); + for (let i = 0; i < 50; i++) store.incrementBy('ip' + i, 1); + clock.tick(21); + store.incrementBy('fresh', 1); + expect(store.windows.size).to.equal(1); + }); +} + +describe('hub JSON-RPC rate-limit tiers', function () { + this.timeout(10000); + afterEach(shutdown); + describe('batch charging', batchChargingSuite); + describe('public and authenticated buckets', bucketsSuite); + describe('FixedWindowStore', storeSuite); +}); diff --git a/test/unit/api/sensitive_read_auth.test.js b/test/unit/api/sensitive_read_auth.test.js index 73ceb06ab..33aea4fb0 100644 --- a/test/unit/api/sensitive_read_auth.test.js +++ b/test/unit/api/sensitive_read_auth.test.js @@ -108,7 +108,9 @@ function drive(mw, methodOrMethods, apiKey, params) { } function findAuthMiddleware(useCalls) { - const candidates = useCalls.filter((fn) => typeof fn === 'function' && fn.length >= 3); + // The rate-limit batch surcharge also passes a single call through, so it is named out. + const candidates = useCalls.filter((fn) => typeof fn === 'function' && fn.length >= 3 && + fn.name !== 'rpcBatchSurcharge'); let authMw = null; for (const fn of candidates) { try { diff --git a/test/unit/oracle/price/push_price_batch.test.js b/test/unit/oracle/price/push_price_batch.test.js index 8ee8f1314..ac6ac770d 100644 --- a/test/unit/oracle/price/push_price_batch.test.js +++ b/test/unit/oracle/price/push_price_batch.test.js @@ -111,7 +111,9 @@ function driveMiddleware(mw, methodOrMethods, apiKey) { return { nexted, res }; } function findAuthMiddleware(useCalls) { - const candidates = useCalls.filter((fn) => typeof fn === 'function' && fn.length >= 3); + // The rate-limit batch surcharge also passes a single call through, so it is named out. + const candidates = useCalls.filter((fn) => typeof fn === 'function' && fn.length >= 3 && + fn.name !== 'rpcBatchSurcharge'); for (const fn of candidates) { try { const probe = driveMiddleware(fn, 'updateconfig', 'wrong-key-probe'); From 46e1496701e17a1dd0075960273c4523ed9e3afb Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 11:08:17 -0700 Subject: [PATCH 54/57] fix(consensus): sign admit_block_btc on ATTEST batches only in the admission era 98ac2564 re-vendored the indexer's unconditional admit_block_btc onto the batch wire, so this hub signed every batch over a canonical no v0.19.0 indexer rebuilds and re-verified pushed historical batches over bytes their signers never signed. Re-vendor the indexer twin (xchain-indexer d139dbd8): the row field set is chosen by the batch's own signed btc_block_height against the BTC mirror admission producer activation, and every wire entry point takes this repo's isAdmissionEra. The leader, the co-signer, the encoder and the pushed-batch re-verify all pass it. normalizeRow now carries the stored admit_block_btc. Without it an admission-era batch would sign null for every row and a chain-rebuilt mirror would bind those rows as legacy; below the activation the wire drops the field, so no pre-era byte changes. --- src/attestation/batch_publisher/signing.js | 8 +- src/attestation/batch_publisher/window.js | 8 +- src/attestation/response_mirror/batch.js | 4 +- src/lib/attest_batch_wire.js | 25 +++- src/lib/attest_batch_wire/constants.js | 52 +++++++- src/lib/attest_batch_wire/primitives.js | 69 +++++++++-- src/lib/attest_batch_wire/reassemble.js | 23 +++- test/fixtures/attest_batch_v0190_replay.json | 10 ++ .../batch/attest_batch_wire_era.test.js | 113 ++++++++++++++++++ .../batch/attest_batch_wire_parity.test.js | 17 +-- ..._publisher_002_publishing_a_window.test.js | 3 +- ...ch_publisher_007_window_membership.test.js | 5 +- ...tch_publisher_008_the_batch_quorum.test.js | 9 +- .../attest_batch_receive.test.js | 3 +- ...n_response_mirror_batch_retraction.test.js | 3 +- 15 files changed, 306 insertions(+), 46 deletions(-) create mode 100644 test/fixtures/attest_batch_v0190_replay.json create mode 100644 test/unit/attestation/publisher/batch/attest_batch_wire_era.test.js diff --git a/src/attestation/batch_publisher/signing.js b/src/attestation/batch_publisher/signing.js index 004bb8027..04136e8be 100644 --- a/src/attestation/batch_publisher/signing.js +++ b/src/attestation/batch_publisher/signing.js @@ -24,6 +24,10 @@ const swq = require('../../consensus/stake_weighted_quorum.js'); const abw = require('../../lib/attest_batch_wire.js'); +// The era predicate the wire takes to pick a batch's row field set from its own signed +// anchor: admit_block_btc is signed only at or above the BTC mirror-admission producer +// activation, so a batch below it carries the bytes every indexer already verifies. +const { isAdmissionEra } = require('../../consensus/gates/mirror_admission_gate.js'); const ValidatorIdentity = require('../../validators/identity.js'); const { bftQuorumOrSingle } = require('../../lib/bft_quorum.js'); const { XATTESTB_SIGN_REQ, XATTESTB_SIGN, ANCHOR_MAX_LAG_BLOCKS } = require('./constants.js'); @@ -48,7 +52,7 @@ module.exports = { row_count: window.row_count, btc_block_height: window.btc_block_height, rows: this.wireRows(window.rows) - }); + }, isAdmissionEra); let set = await this.resolveAttestationSet(window.btc_block_height); if(!set) return empty; @@ -234,7 +238,7 @@ module.exports = { row_count: d.rows.length, btc_block_height: anchor, rows: d.rows - }); + }, isAdmissionEra); pm.broadcast(XATTESTB_SIGN, { network: this.network, window_start: windowStart, diff --git a/src/attestation/batch_publisher/window.js b/src/attestation/batch_publisher/window.js index 5d4c597a8..a210d4136 100644 --- a/src/attestation/batch_publisher/window.js +++ b/src/attestation/batch_publisher/window.js @@ -24,6 +24,8 @@ 'use strict'; const abw = require('../../lib/attest_batch_wire.js'); +// Picks the row field set the encoded body carries from the batch's own anchor (signing.js). +const { isAdmissionEra } = require('../../consensus/gates/mirror_admission_gate.js'); const { MAX_CATCHUP_WINDOWS } = require('./constants.js'); const { getLogger } = require('../../observability'); const logger = getLogger(); @@ -199,7 +201,7 @@ module.exports = { } window.sigs = signed.sigs; - let encoded = abw.encodeAttestBatch(window); + let encoded = abw.encodeAttestBatch(window, isAdmissionEra); if(!encoded.ok){ this.deadLetter({ window_start: windowStart, window_end: windowEnd, row_count: rows.length, reason: encoded.reason }, @@ -285,6 +287,10 @@ module.exports = { response_hash: String(r.response_hash).toLowerCase(), meta: r.meta == null ? '' : String(r.meta), effective_time: intOrNull(r.effective_time), + // Carried so an admission-era batch signs the row's real admission height; a + // row read without it would sign null and rebuild on chain as a legacy row. + // Below the activation the wire drops the field, so this changes no bytes there. + admit_block_btc: intOrNull(r.admit_block_btc), signer_pubkeys: String(r.signer_pubkeys == null ? '[]' : r.signer_pubkeys), signatures: String(r.signatures == null ? '[]' : r.signatures), widen: intOrNull(r.widen) || 0 diff --git a/src/attestation/response_mirror/batch.js b/src/attestation/response_mirror/batch.js index 2cbab0a57..7e2763ff0 100644 --- a/src/attestation/response_mirror/batch.js +++ b/src/attestation/response_mirror/batch.js @@ -24,6 +24,8 @@ 'use strict'; const abw = require('../../lib/attest_batch_wire.js'); +// Picks the row field set the batch was signed over from its own anchor (batch_publisher/signing.js). +const { isAdmissionEra } = require('../../consensus/gates/mirror_admission_gate.js'); const swq = require('../../consensus/stake_weighted_quorum.js'); const ValidatorIdentity = require('../../validators/identity.js'); const { bftQuorumOrSingle } = require('../../lib/bft_quorum.js'); @@ -249,7 +251,7 @@ module.exports = { row_count: Number(batchData.row_count), btc_block_height: anchor, rows: batchData.rows - }); + }, isAdmissionEra); let qualified = new Set(snapshot.validators.map(v => String(v.pubkey).toLowerCase())); let seen = new Set(), verified = []; diff --git a/src/lib/attest_batch_wire.js b/src/lib/attest_batch_wire.js index aadfea8ab..e74f0c1af 100644 --- a/src/lib/attest_batch_wire.js +++ b/src/lib/attest_batch_wire.js @@ -40,6 +40,14 @@ * refusal shape, codecs and signed canonical in primitives.js, the two wire * parsers in parse.js, and chunk coverage plus reassembly in reassemble.js. * + * TWO ROW FIELD SETS, ONE CHOICE. admit_block_btc joined the rows at the BTC + * mirror-admission producer activation, so a batch whose signed anchor is below it + * carries the legacy set and one at or above it carries the admission set. Every + * entry point that signs, encodes or checks a body takes the caller's era predicate + * (mirror_admission_gate.js isAdmissionEra) and resolves the set through the one + * attestBatchRowFields call, so the canonical a quorum signs and the presence check + * a replaying node applies can never disagree about a batch. + * * THE CAPS ARE CONSENSUS. The compressed bytes are the action body, so a size * or row-count breach must be invalid on every node or the fleet forks on the * first hostile batch. The inflate is bounded through zlib's maxOutputLength so @@ -65,11 +73,12 @@ const zlib = require('zlib'); const { ATTEST_BATCH_HEAD_VERSION, ATTEST_BATCH_CONTINUATION_VERSION, ATTEST_BATCH_WIRE_MAX_BYTES, ATTEST_BATCH_MAX_INFLATED_BYTES, ATTEST_BATCH_MAX_ROWS, ATTEST_BATCH_MAX_CHUNKS, - ATTEST_BATCH_MAX_INFLATE_RATIO, ATTEST_BATCH_ROW_FIELDS, FAIL + ATTEST_BATCH_MAX_INFLATE_RATIO, ATTEST_BATCH_LEGACY_ROW_FIELDS, ATTEST_BATCH_ADMISSION_ROW_FIELDS, + ATTEST_BATCH_ROW_FIELDS, FAIL } = require('./attest_batch_wire/constants.js'); const { - fail, decodeCanonicalBase64, crc32Hex, computeBatchKey, - buildAttestBatchCanonical, buildAttestBatchBody + fail, decodeCanonicalBase64, crc32Hex, computeBatchKey, attestBatchRowFields, + requireAdmissionEra, buildAttestBatchCanonical, buildAttestBatchBody } = require('./attest_batch_wire/primitives.js'); const { parseAttestBatchHead, parseAttestBatchContinuation } = require('./attest_batch_wire/parse.js'); const { attestChunkCoverage, reassembleAttestBatch } = require('./attest_batch_wire/reassemble.js'); @@ -105,18 +114,21 @@ function continuationPrefix(batchKey, chunkIndex, totalChunks, crc){ * * @param {{network:string, window_start:number, window_end:number, row_count:number, * btc_block_height:number, rows:Object[], sigs:{pubkey:string,sig:string}[]}} window + * @param {function(string, number): boolean} admissionEra the repo's isAdmissionEra, which + * picks the row field set the body carries (see attestBatchRowFields) * @returns {{ok:true, batchKey:string, batchCrc32:string, totalChunks:number, * wires:string[], body:string, inflatedBytes:number, compressedBytes:number} * |{ok:false, reason:string, status:string, detail:*}} */ -function encodeAttestBatch(window){ +function encodeAttestBatch(window, admissionEra){ + requireAdmissionEra(admissionEra); if(!window || typeof window !== 'object') return fail(FAIL.STRUCTURE, 'window is not an object'); const rows = Array.isArray(window.rows) ? window.rows : null; if(rows === null) return fail(FAIL.STRUCTURE, 'rows is not an array'); if(rows.length > ATTEST_BATCH_MAX_ROWS) return fail(FAIL.ROW_COUNT, rows.length); if(Number(window.row_count) !== rows.length) return fail(FAIL.ROW_COUNT, 'row_count does not match rows.length'); - const body = buildAttestBatchBody(window); + const body = buildAttestBatchBody(window, admissionEra); const bodyBytes = Buffer.from(body, 'utf8'); if(bodyBytes.length > ATTEST_BATCH_MAX_INFLATED_BYTES) return fail(FAIL.OVERSIZE, bodyBytes.length); @@ -164,11 +176,14 @@ module.exports = { ATTEST_BATCH_MAX_ROWS, ATTEST_BATCH_MAX_CHUNKS, ATTEST_BATCH_MAX_INFLATE_RATIO, + ATTEST_BATCH_LEGACY_ROW_FIELDS, + ATTEST_BATCH_ADMISSION_ROW_FIELDS, ATTEST_BATCH_ROW_FIELDS, ATTEST_BATCH_FAIL_REASONS: FAIL, ATTEST_BATCH_HEAD_FORMAT: HEAD_FORMAT, ATTEST_BATCH_CONTINUATION_FORMAT: CONTINUATION_FORMAT, computeBatchKey, + attestBatchRowFields, buildAttestBatchCanonical, buildAttestBatchBody, encodeAttestBatch, diff --git a/src/lib/attest_batch_wire/constants.js b/src/lib/attest_batch_wire/constants.js index 8d57f7722..68bfdb31f 100644 --- a/src/lib/attest_batch_wire/constants.js +++ b/src/lib/attest_batch_wire/constants.js @@ -94,17 +94,48 @@ const ATTEST_BATCH_MAX_CHUNKS = 256; const ATTEST_BATCH_MAX_INFLATE_RATIO = 150; /** - * The per-row fields the batch carries, in canonical order: every + * The per-row fields a LEGACY batch carries, in canonical order: every * `attestation_responses` column except `id` (hub-local paging cursor), - * `finalized_at` (hub wall clock, audit only) and `batch_action_index` (set by - * the batch landing itself, so a batch cannot carry its own). + * `finalized_at` (hub wall clock, audit only), `batch_action_index` (set by + * the batch landing itself, so a batch cannot carry its own) and + * `admit_block_btc` (see the admission set below). * * Carrying `signatures` and `signer_pubkeys` is what makes a batch-fed node's * `attests` rows byte-identical to a mirror-fed node's: the per-row responsible * -set signatures ride the chain, not just the batch quorum's. + * + * This is the set every batch on chain below the mirror-admission producer + * activation was signed over and is still judged by, so it is frozen: adding a + * field here rewrites the signed bytes and the presence check of history. + * @type {string[]} + */ +const ATTEST_BATCH_LEGACY_ROW_FIELDS = Object.freeze([ + 'network', + 'request_id', + 'request_action_index', + 'request_block_index', + 'provider_id', + 'status', + 'response_payload', + 'response_hash', + 'meta', + 'effective_time', + 'signer_pubkeys', + 'signatures', + 'widen' +]); + +/** + * The per-row fields an ADMISSION-ERA batch carries: the legacy set plus the + * row's signed BTC admission height, placed after `effective_time`. A batch is in + * the admission era when its own signed `btc_block_height` is at or above the + * BTC mirror-admission producer activation for its network, the same flag day + * that arms the admit_block_* columns. primitives.js attestBatchRowFields is the + * one place that choice is made, and both the signed canonical and the + * reassembly presence check read it there. * @type {string[]} */ -const ATTEST_BATCH_ROW_FIELDS = [ +const ATTEST_BATCH_ADMISSION_ROW_FIELDS = Object.freeze([ 'network', 'request_id', 'request_action_index', @@ -119,7 +150,16 @@ const ATTEST_BATCH_ROW_FIELDS = [ 'signer_pubkeys', 'signatures', 'widen' -]; +]); + +/** + * Every field either era's batch can carry: the admission set, which is a + * superset of the legacy one. For reading and projecting rows (a SELECT list, a + * row copy) and never for signing or checking a batch, which must take the + * era's own set from attestBatchRowFields. + * @type {string[]} + */ +const ATTEST_BATCH_ROW_FIELDS = ATTEST_BATCH_ADMISSION_ROW_FIELDS; /** * Failure reasons. STABLE STRINGS: they reach the chain inside the action's @@ -162,6 +202,8 @@ module.exports = { ATTEST_BATCH_MAX_ROWS, ATTEST_BATCH_MAX_CHUNKS, ATTEST_BATCH_MAX_INFLATE_RATIO, + ATTEST_BATCH_LEGACY_ROW_FIELDS, + ATTEST_BATCH_ADMISSION_ROW_FIELDS, ATTEST_BATCH_ROW_FIELDS, FAIL, CANONICAL_BASE64, diff --git a/src/lib/attest_batch_wire/primitives.js b/src/lib/attest_batch_wire/primitives.js index feb02dccb..ce2a5e706 100644 --- a/src/lib/attest_batch_wire/primitives.js +++ b/src/lib/attest_batch_wire/primitives.js @@ -30,7 +30,9 @@ const crypto = require('crypto'); const zlib = require('zlib'); -const { ATTEST_BATCH_ROW_FIELDS, CANONICAL_BASE64 } = require('./constants.js'); +const { + ATTEST_BATCH_LEGACY_ROW_FIELDS, ATTEST_BATCH_ADMISSION_ROW_FIELDS, CANONICAL_BASE64 +} = require('./constants.js'); /** * A structured refusal. Callers record the action invalid on it and must never @@ -108,18 +110,59 @@ function computeBatchKey(window){ return crypto.createHash('sha256').update(preimage, 'utf8').digest('hex'); } -// Rows reduced to the carried field set, in ATTEST_BATCH_ROW_FIELDS order. A row -// object built ad hoc in two places drifts, and a drifted row changes the signed +/** + * The row field set a batch is signed over and checked against, chosen by the + * batch's OWN signed anchor: the admission set when `admissionEra(network, + * btcBlockHeight)` is true, the legacy set otherwise. + * + * The era predicate is a parameter because this directory may require nothing + * outside itself (the twins sit at different depths), so each repo passes its own + * mirror_admission_gate.js isAdmissionEra, the BTC producer activation keyed by + * network. It is keyed on the batch anchor and never on the reading node's height, + * so a batch's field set is fixed the moment it is signed: a node replaying from + * genesis, a node mid-roll and the publisher all pick the same set for the same + * bytes. The height handed over is Number() of the value the header signs, which + * is the value a verifier holds after checkBodyAgainstHead, so the two sides + * evaluate the gate on one number. + * + * A missing predicate throws rather than defaulting: either default is a fork, + * one against history and one against the flag day. + * @param {string} network the batch's network + * @param {number|string} btcBlockHeight the batch's signed BTC anchor + * @param {function(string, number): boolean} admissionEra the repo's isAdmissionEra + * @returns {string[]} ATTEST_BATCH_ADMISSION_ROW_FIELDS or ATTEST_BATCH_LEGACY_ROW_FIELDS + */ +function attestBatchRowFields(network, btcBlockHeight, admissionEra){ + requireAdmissionEra(admissionEra); + return admissionEra(String(network), Number(btcBlockHeight)) === true + ? ATTEST_BATCH_ADMISSION_ROW_FIELDS + : ATTEST_BATCH_LEGACY_ROW_FIELDS; +} + +// Refuse a call that brought no era predicate, before any byte is judged, so the +// omission fails the same way whatever the input is. +function requireAdmissionEra(admissionEra){ + if(typeof admissionEra !== 'function') + throw new TypeError('attest_batch_wire: an admission-era predicate (mirror_admission_gate ' + + 'isAdmissionEra) is required to choose the batch row field set'); +} + +// Rows reduced to the era's carried field set, in that set's order. A row object +// built ad hoc in two places drifts, and a drifted row changes the signed // canonical, so every serializer below goes through here. -function normalizeBatchRows(window){ +function normalizeBatchRows(window, fields){ return (window.rows || []).map((r) => { const out = {}; - for(const f of ATTEST_BATCH_ROW_FIELDS) + for(const f of fields) out[f] = (r[f] === undefined) ? null : r[f]; return out; }); } +function windowRowFields(window, admissionEra){ + return attestBatchRowFields(window.network, window.btc_block_height, admissionEra); +} + function batchHeaderObject(window){ return { network: String(window.network), @@ -137,12 +180,17 @@ function batchHeaderObject(window){ * two sides agree without the compressed or chunked bytes ever entering the * preimage. Insertion order IS the field order in JSON.stringify, so the object * literal above is the canonical's definition; never reorder it. + * + * The row fields are the era's (attestBatchRowFields): below the admission + * activation `admit_block_btc` is neither signed nor carried, so every batch + * already on chain keeps the bytes its quorum signed. * @param {Object} window + * @param {function(string, number): boolean} admissionEra the repo's isAdmissionEra * @returns {string} */ -function buildAttestBatchCanonical(window){ +function buildAttestBatchCanonical(window, admissionEra){ const header = batchHeaderObject(window); - header.rows = normalizeBatchRows(window); + header.rows = normalizeBatchRows(window, windowRowFields(window, admissionEra)); return JSON.stringify(header); } @@ -151,11 +199,12 @@ function buildAttestBatchCanonical(window){ * signature set that covers it. This is the exact string both sides deflate and * inflate. * @param {Object} window the window to serialize + * @param {function(string, number): boolean} admissionEra the repo's isAdmissionEra * @returns {string} */ -function buildAttestBatchBody(window){ +function buildAttestBatchBody(window, admissionEra){ const body = batchHeaderObject(window); - body.rows = normalizeBatchRows(window); + body.rows = normalizeBatchRows(window, windowRowFields(window, admissionEra)); body.sigs = (window.sigs || []).map((s) => ({ pubkey: String(s.pubkey || '').toLowerCase(), sig: String(s.sig || '').toLowerCase() @@ -165,6 +214,8 @@ function buildAttestBatchBody(window){ module.exports = { fail, + attestBatchRowFields, + requireAdmissionEra, decodeCanonicalBase64, crc32Hex, computeBatchKey, diff --git a/src/lib/attest_batch_wire/reassemble.js b/src/lib/attest_batch_wire/reassemble.js index 04d664fc3..66d840742 100644 --- a/src/lib/attest_batch_wire/reassemble.js +++ b/src/lib/attest_batch_wire/reassemble.js @@ -33,9 +33,11 @@ const zlib = require('zlib'); const { ATTEST_BATCH_MAX_INFLATED_BYTES, ATTEST_BATCH_MAX_ROWS, ATTEST_BATCH_MAX_INFLATE_RATIO, - ATTEST_BATCH_ROW_FIELDS, FAIL, HEX64 + FAIL, HEX64 } = require('./constants.js'); -const { fail, decodeCanonicalBase64, crc32Hex } = require('./primitives.js'); +const { + fail, decodeCanonicalBase64, crc32Hex, attestBatchRowFields, requireAdmissionEra +} = require('./primitives.js'); /** * Order a chunk set into slots 1..totalChunks-1, or null when coverage is @@ -130,7 +132,12 @@ function decodeBatchBody(head, inflated){ // Step 4: the body must describe the batch the wire header declared, and every // row and signature entry must carry its full field set. Returns null when it does. -function checkBodyAgainstHead(head, parsed){ +// +// "Full" is the ERA's set, from the same attestBatchRowFields call the signed +// canonical makes, keyed on the head's anchor, which the body has just been held +// equal to. A batch below the admission activation is complete without +// admit_block_btc, and one at or above it is not. +function checkBodyAgainstHead(head, parsed, admissionEra){ // The header the wire declares and the body the quorum signed must describe // one batch. A disagreement is refused rather than resolved in either // direction: the header keys the gates, the body carries the rows, and @@ -143,9 +150,10 @@ function checkBodyAgainstHead(head, parsed){ return fail(FAIL.STRUCTURE, 'body header does not match the wire header'); if(Number(parsed.row_count) !== parsed.rows.length) return fail(FAIL.ROW_COUNT, 'row_count does not match rows.length'); + const fields = attestBatchRowFields(head.network, head.btcBlockHeight, admissionEra); for(const r of parsed.rows){ if(!r || typeof r !== 'object' || Array.isArray(r)) return fail(FAIL.ROW_FIELD, 'row is not an object'); - for(const f of ATTEST_BATCH_ROW_FIELDS) + for(const f of fields) if(!(f in r)) return fail(FAIL.ROW_FIELD, f); } for(const s of parsed.sigs){ @@ -166,10 +174,13 @@ function checkBodyAgainstHead(head, parsed){ * * @param {Object} head the parseAttestBatchHead result for this batch * @param {{chunk_index:number, chunk_b64:string, action_index:(number|undefined)}[]} chunks + * @param {function(string, number): boolean} admissionEra the repo's isAdmissionEra, which + * picks the row field set the body must carry (see attestBatchRowFields) * @returns {{ok:true, batch:Object, inflatedBytes:number, compressedBytes:number} * |{ok:false, reason:string, status:string, detail:*}} */ -function reassembleAttestBatch(head, chunks){ +function reassembleAttestBatch(head, chunks, admissionEra){ + requireAdmissionEra(admissionEra); if(!head || head.ok !== true) return fail(FAIL.STRUCTURE, 'head is not a parsed batch head'); const joined = concatenateBatchChunks(head, chunks); @@ -181,7 +192,7 @@ function reassembleAttestBatch(head, chunks){ const decoded = decodeBatchBody(head, body.inflated); if(decoded.ok !== true) return decoded; - const mismatch = checkBodyAgainstHead(head, decoded.parsed); + const mismatch = checkBodyAgainstHead(head, decoded.parsed, admissionEra); if(mismatch !== null) return mismatch; return { diff --git a/test/fixtures/attest_batch_v0190_replay.json b/test/fixtures/attest_batch_v0190_replay.json new file mode 100644 index 000000000..47e11e1cc --- /dev/null +++ b/test/fixtures/attest_batch_v0190_replay.json @@ -0,0 +1,10 @@ +{ + "note": "An ATTEST v5 batch head as a v0.19.0 hub built and signed it: encoded and signed by the v0.19.0 tag's src/actions/attest/attest_batch_wire.js (byte twin of the hub copy), on testnet, anchored at BTC 151400, after the batch rail armed (151324) and below the mirror admission producer activation. Its rows carry no admit_block_btc, as no batch on chain before that activation does. The signer is a fixed test key derived from a sha256 seed, so the fixture is reproducible. Regenerate only from a tag whose wire predates the admission field.", + "generated_with": "v0.19.0:src/actions/attest/attest_batch_wire.js", + "network": "testnet", + "btc_block_height": 151400, + "batch_key": "7fff8ea39b4aec2c7896104e788e698800849d3002066012fad3d780238e9596", + "signer_pubkey": "c3d7ee883b2925605fe6408a5e24f061f4ab7594bbafd54d07aeedc8d4130d02", + "signed_canonical": "{\"network\":\"testnet\",\"window_start\":1789203600,\"window_end\":1789207200,\"row_count\":2,\"btc_block_height\":151400,\"rows\":[{\"network\":\"testnet\",\"request_id\":\"de188666ba195196ceb08114ca5ff1132e9a1a2575c7f14d74a1a35e04246500\",\"request_action_index\":4400,\"request_block_index\":151380,\"provider_id\":\"http_get\",\"status\":\"ok\",\"response_payload\":\"{\\\"ok\\\":true,\\\"n\\\":0}\",\"response_hash\":\"4d28f9d296c5af5c7eeb93836dbd355000609fce440f4d4a3661069aeb319857\",\"meta\":\"200\",\"effective_time\":1789203600,\"signer_pubkeys\":\"[\\\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\\\"]\",\"signatures\":\"[{\\\"pubkey\\\":\\\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\\\",\\\"sig\\\":\\\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\\\"}]\",\"widen\":0},{\"network\":\"testnet\",\"request_id\":\"dafa727ebea92905b6c81dd47f488cf6d90ab1b744dde2254cb2c6adb912ba88\",\"request_action_index\":4401,\"request_block_index\":151381,\"provider_id\":\"http_get\",\"status\":\"ok\",\"response_payload\":\"{\\\"ok\\\":true,\\\"n\\\":1}\",\"response_hash\":\"27e1acbfee8890972e9a9edc748155a6c715a61f4c47c8f1bfc8be31795505f8\",\"meta\":\"200\",\"effective_time\":1789203660,\"signer_pubkeys\":\"[\\\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\\\"]\",\"signatures\":\"[{\\\"pubkey\\\":\\\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\\\",\\\"sig\\\":\\\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\\\"}]\",\"widen\":0}]}", + "wire": "ATTEST|5|7fff8ea39b4aec2c7896104e788e698800849d3002066012fad3d780238e9596|testnet|1789203600|1789207200|2|151400|22d8d6e8|1|7VTLjtxGDPwXnufQ7HfrV6yFwCbZO8LY0kTq8cZY7L8bmlkHDmAYBuIgl/BIEsViV7FfYdH+sm4XGKDr3hftcIKXeZH1Zdo7bR0GTLlY46Ixf1V0kW/5ZI/8tr5MvN6WDoM9Qe081Y8rX6azzs/nAyOgf+/bYfjw+sOxm/5x071Ps8AAophzjLESloAlslaTET1TaA3RWS2EZEMKnBp6SZ6QXFDjrY/BmO/wiPu8LtO8iP4Jg38Qea89aL6XMKDL5gTXbf08i24PJufer9PzneHeqd92GGC93PH367rsOl3py8eVjt7XEdbLCEPfbnoaYRlhMG/ft55pP8MAXmxuRWyJHKgFTqq1uOyiVHEhGGOiKY3Ve9O8eHIxoomFtDosOSQ4wSftBAPY+6ramnKfP+vU50/6d832+XnRbbre6kW/HOQ/jFD/YYzwBA9k6rdN76ivIzxmjDD8jgmn8RhwB+P/OEZ4e7rfhehyKHr6FQNTo2STVqViiwk1ckYRn5rPmVuUYqhiTd6LqLXBc7UcSWpBWynnnxkYf2Zg/M0Gxh8Z2CZF4tpUcy6mpOMciwonnzEEipwwUMTm2SfODWvjXNVhKiGY0PKvGjj+b+B/w8BP990fX/FjZxiAnaRDT1dtsSGa0DR6kymo9c0cYlJNofhaqUnwYhKpCmfx6IwY+3jQQ1KLEQnFZS4p52bEFcPsqsmmFIfZs3PBco6peZt8jVZMsBxTtQ6pNMNFBVurpI6DaKkpOI+imsRxIYM+t+qLj6V6y9hYrMUUlbGiYXh7evsK" +} diff --git a/test/unit/attestation/publisher/batch/attest_batch_wire_era.test.js b/test/unit/attestation/publisher/batch/attest_batch_wire_era.test.js new file mode 100644 index 000000000..08ac677f1 --- /dev/null +++ b/test/unit/attestation/publisher/batch/attest_batch_wire_era.test.js @@ -0,0 +1,113 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * test/unit/attestation/publisher/batch/attest_batch_wire_era.test.js + * + * The hub half of the ATTEST batch admission-era rule: this hub SIGNS the batch, so + * below the BTC mirror-admission producer activation it must sign the legacy bytes + * every indexer on the fleet rebuilds (v0.19.0 included), and at or above it the bytes + * that carry each row's real admit_block_btc. And this hub RE-VERIFIES a batch an + * indexer pushes back from chain, so a batch a v0.19.0 hub signed has to verify here. + * Driven on the shipped testnet height with the real gate; the indexer twin of this + * rule is xchain-indexer test/unit/actions/attest/attest_batch_wire_era.test.js. + ********************************************************************/ + +'use strict'; + +const { expect } = require('chai'); + +const abw = require('../../../../../src/lib/attest_batch_wire.js'); +const adm = require('../../../../../src/consensus/gates/mirror_admission_gate.js'); +const signing = require('../../../../../src/attestation/batch_publisher/signing.js'); +const windows = require('../../../../../src/attestation/batch_publisher/window.js'); +const ValidatorIdentity = require('../../../../../src/validators/identity.js'); +const HISTORY = require('../../../../fixtures/attest_batch_v0190_replay.json'); + +const { isAdmissionEra } = adm; +const NETWORK = 'testnet'; +const ADMIT_AT = adm.MIRROR_ADMISSION_ACTIVATION['BTC:' + NETWORK]; + +// A stored attestation_responses row as the driver hands it back, admission height and all. +function storedRow(i, admitBlock){ + return { + network: NETWORK, request_id: String(i).repeat(64).slice(0, 64), request_action_index: '4400', + request_block_index: String(ADMIT_AT - 10 + i), provider_id: 'http_get', status: 'ok', + response_payload: '{"ok":true}', response_hash: 'cd'.repeat(32), meta: '200', + effective_time: String(1789203600 + i), admit_block_btc: admitBlock, + signer_pubkeys: '[]', signatures: '[]', widen: '0', batch_action_index: null, finalized_at: 1 + }; +} + +// The leader's own signing round on a one-member set: the single-member path returns +// this hub's signature over exactly the canonical it built. +async function leaderSignature(anchor, rows){ + const identity = new ValidatorIdentity(ValidatorIdentity.generate().privkeyHex); + const me = identity.getPubkeyHex().toLowerCase(); + const ctx = Object.assign({}, windows, signing, { + identity, stats: { signRounds: 0, signQuorums: 0 }, + hubPeerManager: () => null, + resolveAttestationSet: async () => [{ pubkey: me, source: 'S', weight: '1' }] + }); + const window = { network: NETWORK, window_start: 1789203600, window_end: 1789207200, + row_count: rows.length, btc_block_height: anchor, rows: rows.map(r => ctx.normalizeRow(r)) }; + const signed = await ctx.collectBatchSignatures(window, 'k'); + expect(signed.met).to.equal(true); + return { window, identity, sig: signed.sigs[0].sig }; +} + +// The canonical an indexer that has never heard of admit_block_btc rebuilds: the legacy +// field order, spelled here from the frozen list rather than from the builder under test. +function legacyCanonical(window){ + return JSON.stringify({ + network: window.network, window_start: window.window_start, window_end: window.window_end, + row_count: window.row_count, btc_block_height: window.btc_block_height, + rows: window.rows.map(r => Object.fromEntries(abw.ATTEST_BATCH_LEGACY_ROW_FIELDS.map(f => [f, r[f] === undefined ? null : r[f]]))) + }); +} + +describe('ATTEST batch wire: the hub signs the era of the batch anchor @regression', function () { + + it('normalizes the stored admission height onto the row, as an integer or null', function () { + expect(windows.normalizeRow(storedRow(1, '154300')).admit_block_btc).to.equal(154300); + expect(windows.normalizeRow(storedRow(1, null)).admit_block_btc).to.equal(null); + }); + + it('below the activation the leader signs the legacy bytes, even for a row holding a height', async function () { + const { window, identity, sig } = await leaderSignature(ADMIT_AT - 1, [storedRow(1, null), storedRow(2, ADMIT_AT)]); + expect(ValidatorIdentity.verify(legacyCanonical(window), sig, identity.getPubkeyHex())).to.equal(true); + expect(abw.buildAttestBatchBody(window, isAdmissionEra)).to.not.include('admit_block_btc'); + }); + + it('at the activation the leader signs each row\'s real admission height', async function () { + const { window, identity, sig } = await leaderSignature(ADMIT_AT, [storedRow(1, ADMIT_AT), storedRow(2, null)]); + const canonical = abw.buildAttestBatchCanonical(window, isAdmissionEra); + expect(JSON.parse(canonical).rows.map(r => r.admit_block_btc)).to.deep.equal([ADMIT_AT, null]); + expect(ValidatorIdentity.verify(canonical, sig, identity.getPubkeyHex())).to.equal(true); + expect(ValidatorIdentity.verify(legacyCanonical(window), sig, identity.getPubkeyHex())).to.equal(false); + }); + + it('a batch a v0.19.0 hub signed before the activation re-verifies here from its own rows', function () { + expect(HISTORY.btc_block_height).to.be.below(ADMIT_AT); + const head = abw.parseAttestBatchHead(HISTORY.wire.split('|').slice(1)); + const back = abw.reassembleAttestBatch(head, [], isAdmissionEra); + expect(back.ok, back.status).to.equal(true); + // The pushed payload carries the reassembled rows verbatim, which is what + // response_mirror/batch.js rebuilds the canonical from. + const canonical = abw.buildAttestBatchCanonical({ + network: back.batch.network, window_start: back.batch.window_start, window_end: back.batch.window_end, + row_count: back.batch.row_count, btc_block_height: head.btcBlockHeight, rows: back.batch.rows + }, isAdmissionEra); + expect(canonical).to.equal(HISTORY.signed_canonical); + expect(ValidatorIdentity.verify(canonical, back.batch.sigs[0].sig, HISTORY.signer_pubkey)).to.equal(true); + }); +}); diff --git a/test/unit/attestation/publisher/batch/attest_batch_wire_parity.test.js b/test/unit/attestation/publisher/batch/attest_batch_wire_parity.test.js index c3eb6e079..3f9e2ebc0 100644 --- a/test/unit/attestation/publisher/batch/attest_batch_wire_parity.test.js +++ b/test/unit/attestation/publisher/batch/attest_batch_wire_parity.test.js @@ -36,6 +36,7 @@ const fs = require('fs'); const path = require('path'); const local = require('../../../../../src/lib/attest_batch_wire.js'); +const { isAdmissionEra } = require('../../../../../src/consensus/gates/mirror_admission_gate.js'); // Sibling checkout, resolved the way every other twin test resolves it: an explicit // env path for CI (actions/checkout cannot write above the workspace), falling back @@ -123,10 +124,10 @@ describe('ATTEST v5/v6 batch wire: hub twin @regression', function () { it('produces identical wires and an identical canonical from identical input', function () { const twin = require(TWIN_PATH); - expect(local.buildAttestBatchCanonical(window())) - .to.equal(twin.buildAttestBatchCanonical(window())); - const mine = local.encodeAttestBatch(window()); - const theirs = twin.encodeAttestBatch(window()); + expect(local.buildAttestBatchCanonical(window(), isAdmissionEra)) + .to.equal(twin.buildAttestBatchCanonical(window(), isAdmissionEra)); + const mine = local.encodeAttestBatch(window(), isAdmissionEra); + const theirs = twin.encodeAttestBatch(window(), isAdmissionEra); expect(mine.ok && theirs.ok).to.equal(true); expect(mine.wires).to.deep.equal(theirs.wires); expect(mine.batchKey).to.equal(theirs.batchKey); @@ -149,16 +150,16 @@ describe('ATTEST v5/v6 batch wire: hub twin @regression', function () { }); it('round-trips a window the hub built back through the parser', function () { - const encoded = local.encodeAttestBatch(window()); + const encoded = local.encodeAttestBatch(window(), isAdmissionEra); expect(encoded.ok).to.equal(true); const params = encoded.wires[0].split('|').slice(1); const head = local.parseAttestBatchHead(params); expect(head.ok, head.status).to.equal(true); - const back = local.reassembleAttestBatch(head, []); + const back = local.reassembleAttestBatch(head, [], isAdmissionEra); expect(back.ok, back.status).to.equal(true); expect(back.batch.rows.length).to.equal(2); - expect(local.buildAttestBatchCanonical(back.batch)) - .to.equal(local.buildAttestBatchCanonical(window())); + expect(local.buildAttestBatchCanonical(back.batch, isAdmissionEra)) + .to.equal(local.buildAttestBatchCanonical(window(), isAdmissionEra)); }); }); }); diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_002_publishing_a_window.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_002_publishing_a_window.test.js index 5d1574c23..28318b427 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_002_publishing_a_window.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_002_publishing_a_window.test.js @@ -37,6 +37,7 @@ const { expect } = require('chai'); const AttestationBatchPublisher = require('../../../../../src/attestation/batch_publisher.js'); const ValidatorIdentity = require('../../../../../src/validators/identity.js'); const abw = require('../../../../../src/lib/attest_batch_wire.js'); +const { isAdmissionEra } = require('../../../../../src/consensus/gates/mirror_admission_gate.js'); const { isNeverSentError, isAmbiguousSendError } = require('../../../../../src/lib/idempotent_broadcast.js'); const { DB_METHODS } = require('../../../../helpers/mockHub.js'); @@ -278,7 +279,7 @@ describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); aft let head = decodeHead(p.wires[0]); expect(head.rowCount).to.equal(2); - let body = abw.reassembleAttestBatch(head, []); + let body = abw.reassembleAttestBatch(head, [], isAdmissionEra); expect(body.ok, body.status).to.equal(true); expect(body.batch.rows.map(r => r.request_block_index)).to.deep.equal([120, 121]); // The audit column is hub wall clock and two hubs disagree on it, so it must diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_007_window_membership.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_007_window_membership.test.js index dd7efe8d4..d98659c25 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_007_window_membership.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_007_window_membership.test.js @@ -37,6 +37,7 @@ const { expect } = require('chai'); const AttestationBatchPublisher = require('../../../../../src/attestation/batch_publisher.js'); const ValidatorIdentity = require('../../../../../src/validators/identity.js'); const abw = require('../../../../../src/lib/attest_batch_wire.js'); +const { isAdmissionEra } = require('../../../../../src/consensus/gates/mirror_admission_gate.js'); const { isNeverSentError, isAmbiguousSendError } = require('../../../../../src/lib/idempotent_broadcast.js'); const { DB_METHODS } = require('../../../../helpers/mockHub.js'); @@ -315,7 +316,7 @@ describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); aft let head = decodeHead(p.wires[0]); expect(head.windowEnd).to.equal(now); expect(head.rowCount, 'window_end is exclusive').to.equal(1); - let body = abw.reassembleAttestBatch(head, []); + let body = abw.reassembleAttestBatch(head, [], isAdmissionEra); expect(body.batch.rows[0].request_id).to.equal(first.request_id); // The boundary row is not dropped: it rides the NEXT window, exactly once. @@ -323,6 +324,6 @@ describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); aft let next = decodeHead(p.wires[1]); expect(next.windowStart).to.equal(now); expect(next.rowCount).to.equal(1); - expect(abw.reassembleAttestBatch(next, []).batch.rows[0].request_id).to.equal(edge.request_id); + expect(abw.reassembleAttestBatch(next, [], isAdmissionEra).batch.rows[0].request_id).to.equal(edge.request_id); }); }); }); } diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_008_the_batch_quorum.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_008_the_batch_quorum.test.js index 741dd38c7..e9b1e99d4 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_008_the_batch_quorum.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_008_the_batch_quorum.test.js @@ -37,6 +37,7 @@ const { expect } = require('chai'); const AttestationBatchPublisher = require('../../../../../src/attestation/batch_publisher.js'); const ValidatorIdentity = require('../../../../../src/validators/identity.js'); const abw = require('../../../../../src/lib/attest_batch_wire.js'); +const { isAdmissionEra } = require('../../../../../src/consensus/gates/mirror_admission_gate.js'); const { isNeverSentError, isAmbiguousSendError } = require('../../../../../src/lib/idempotent_broadcast.js'); const { DB_METHODS } = require('../../../../helpers/mockHub.js'); @@ -255,7 +256,7 @@ const hookAt10827 = function () { network: data.network, window_start: data.window_start, window_end: data.window_end, row_count: data.row_count, btc_block_height: data.btc_block_height, rows: data.rows - }); + }, isAdmissionEra); for (let f of followers) { // The follower's own payload shape, window bounds included: a // co-signature that did not name its window would be counted into @@ -315,7 +316,7 @@ describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); aft // The proposal the round timed out on and the one it published from are the // same window bytes. expect(JSON.parse(firstProposal).window_start).to.equal(start); - let body = abw.reassembleAttestBatch(head, []); + let body = abw.reassembleAttestBatch(head, [], isAdmissionEra); expect(body.ok, body.status).to.equal(true); expect(body.batch.sigs.length).to.be.at.least(2); }); }); }); @@ -343,8 +344,8 @@ describe('AttestationBatchPublisher', function () { beforeEach(hookAt10719); aft pubkey: followers[0].getPubkeyHex().toLowerCase(), sig: 'ab'.repeat(64) } }); - let body = abw.reassembleAttestBatch(decodeHead(p.wires[0]), []); - let canonical = abw.buildAttestBatchCanonical(body.batch); + let body = abw.reassembleAttestBatch(decodeHead(p.wires[0]), [], isAdmissionEra); + let canonical = abw.buildAttestBatchCanonical(body.batch, isAdmissionEra); let qualified = new Set(hub._snapshot.validators.map(v => v.pubkey)); for (let s of body.batch.sigs) { expect(qualified.has(s.pubkey), 'signer ' + s.pubkey.substring(0, 8) + ' is not in the set').to.equal(true); diff --git a/test/unit/attestation/response_mirror/attest_batch_receive.test.js b/test/unit/attestation/response_mirror/attest_batch_receive.test.js index b79ac6ecd..07f524bbe 100644 --- a/test/unit/attestation/response_mirror/attest_batch_receive.test.js +++ b/test/unit/attestation/response_mirror/attest_batch_receive.test.js @@ -35,6 +35,7 @@ const { expect } = require('chai'); const AttestationResponseMirror = require('../../../../src/attestation/response_mirror.js'); const ValidatorIdentity = require('../../../../src/validators/identity.js'); const abw = require('../../../../src/lib/attest_batch_wire.js'); +const { isAdmissionEra } = require('../../../../src/consensus/gates/mirror_admission_gate.js'); const { DB_METHODS } = require('../../../helpers/mockHub.js'); const ANCHOR = 941234; @@ -106,7 +107,7 @@ function makeBatch(rows, signers, overrides){ network: 'regtest', window_start: WINDOW_START, window_end: WINDOW_END, row_count: rows.length, btc_block_height: ANCHOR, rows: rows }, overrides || {}); - let canonical = abw.buildAttestBatchCanonical(header); + let canonical = abw.buildAttestBatchCanonical(header, isAdmissionEra); return Object.assign({}, header, { sigs: signers.map(s => ({ pubkey: s.getPubkeyHex().toLowerCase(), sig: s.sign(canonical) })), action_index: ACTION_INDEX, block_index: 700123, block_time: 1780004000, push_generation: 3 diff --git a/test/unit/attestation/response_mirror/attestation_response_mirror_batch_retraction.test.js b/test/unit/attestation/response_mirror/attestation_response_mirror_batch_retraction.test.js index 97e93f39b..d8be0d857 100644 --- a/test/unit/attestation/response_mirror/attestation_response_mirror_batch_retraction.test.js +++ b/test/unit/attestation/response_mirror/attestation_response_mirror_batch_retraction.test.js @@ -34,6 +34,7 @@ const { expect } = require('chai'); const AttestationResponseMirror = require('../../../../src/attestation/response_mirror.js'); const ValidatorIdentity = require('../../../../src/validators/identity.js'); const abw = require('../../../../src/lib/attest_batch_wire.js'); +const { isAdmissionEra } = require('../../../../src/consensus/gates/mirror_admission_gate.js'); const { DB_METHODS } = require('../../../helpers/mockHub.js'); const ANCHOR = 941234; @@ -123,7 +124,7 @@ function makeBatch(rows, signers, overrides){ network: NETWORK, window_start: WINDOW_START, window_end: WINDOW_END, row_count: rows.length, btc_block_height: ANCHOR, rows: rows }, overrides || {}); - let canonical = abw.buildAttestBatchCanonical(header); + let canonical = abw.buildAttestBatchCanonical(header, isAdmissionEra); return Object.assign({}, header, { sigs: signers.map(s => ({ pubkey: s.getPubkeyHex().toLowerCase(), sig: s.sign(canonical) })), action_index: ACTION_INDEX, block_index: 700123, block_time: 1780004000, push_generation: 3 From 8321e667778bc752bfbdfc879166ed8f17d301ac Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 11:11:53 -0700 Subject: [PATCH 55/57] fix(consensus): re-slide DOGE:testnet mirror admission to the 84h trailing mean Operator ruling D11 (a) 2026-09-23: producer moves from 67942777 to 67936053 and consumer from 67944741 to 67936888, the same-instant rule computed off the 84h trailing block-time mean instead of the earlier median estimate. BTC:testnet heights are unchanged; their comments now also carry the 84h trailing mean cadence bound alongside the existing one. Re-pin bin/pins/at1-consensus-identity.json with the repo tool and update the test that hardcodes the bare consensus rules digest. --- bin/pins/at1-consensus-identity.json | 6 +++--- src/consensus/gate_registry/shared_rows_2.js | 8 ++++---- .../consensus/consensus_rules_digest_active_gates.test.js | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bin/pins/at1-consensus-identity.json b/bin/pins/at1-consensus-identity.json index 0a101c53c..9c5d5dc22 100644 --- a/bin/pins/at1-consensus-identity.json +++ b/bin/pins/at1-consensus-identity.json @@ -1,5 +1,5 @@ { - "consensus_rules_digest": "e18f7e2a2e3df32d1834d1983a4b203c05854679d9edac5595f7d0309161425f", + "consensus_rules_digest": "f69861138e062d4feb1dc7f9975fd28284a1f297e7e8375e6d870c020f3d97fd", "consensus_rules_gates": { "anchor_reward_activation.ANCHOR_REWARD_ACTIVATION": "{\"mainnet\":961000,\"regtest\":0,\"testnet\":0}", "anchor_reward_activation.ARCHIVE_REWARD_ACTIVATION": "{\"mainnet\":963000,\"regtest\":0,\"testnet\":0}", @@ -21,8 +21,8 @@ "attest_responsible_widening_activation.ATTEST_RESPONSIBLE_WIDENING_V2": "{\"headroom\":1,\"maxSlots\":2,\"startOffset\":0}", "rollcall_gates_activation.ROLLCALL_GATES_ACTIVATION": "{\"mainnet\":null,\"regtest\":null,\"testnet\":152208}", "xchain_bridge_activation.XCHAIN_BRIDGE_ACTIVATION": "{\"BTC:mainnet\":9999999999,\"BTC:testnet\":152929,\"DOGE:mainnet\":9999999999,\"DOGE:testnet\":67902062,\"LTC:mainnet\":9999999999,\"LTC:testnet\":4887898,\"mainnet\":9999999999,\"regtest\":0,\"testnet\":9999999999}", - "mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":154234,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67942777,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":null}", - "mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":154291,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67944741,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":null}", + "mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":154234,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67936053,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":null}", + "mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION": "{\"BTC:mainnet\":null,\"BTC:regtest\":null,\"BTC:testnet\":154291,\"DOGE:mainnet\":null,\"DOGE:regtest\":null,\"DOGE:testnet\":67936888,\"LTC:mainnet\":null,\"LTC:regtest\":null,\"LTC:testnet\":null}", "mirror_admission_activation.ADMIT_MARGIN_BLOCKS": "{\"anchor_reward_attestations\":144,\"attestation_responses\":1,\"default\":4,\"oracle_prices\":1}", "mirror_admission_activation.ADMIT_MIN_FUTURE_BLOCKS": "1", "mirror_admission_activation.ADMIT_MAX_FUTURE_BLOCKS": "{\"BTC\":6,\"DOGE\":60,\"LTC\":24,\"default\":6}", diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index 2b87dd32a..0063a1656 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -216,9 +216,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 154234, // RE-SLID 2026-09-23: train 154,074 + 160 blocks (17 h at 383.04 s/blk), the v0.20.1 patch reslide + 'BTC:testnet': 154234, // RE-SLID 2026-09-23: train 154,074 + 160 blocks (17 h at the 383.04 s/blk bound, 25.6 h at the 575.89 s/blk 84 h trailing mean), the v0.20.1 patch reslide 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67942777, // RE-SLID 2026-09-23: tip 67,924,122 at 15:55Z + 18655 blocks (57 h at 11 s/blk median, the same instant as the BTC producer), the v0.20.1 patch reslide + 'DOGE:testnet': 67936053, // RE-SLID 2026-09-23: tip 67,924,397 at 17:48Z + 11656 blocks (83.8 h at 25.89 s/blk, the 84 h trailing mean, the same instant as the BTC producer), the v0.20.1 patch reslide 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -228,9 +228,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 154291, // RE-SLID 2026-09-23: its producer + 57 blocks (6 h at 383.04 s/blk), strictly above, never equal + 'BTC:testnet': 154291, // RE-SLID 2026-09-23: its producer + 57 blocks (6 h at the 383.04 s/blk bound, 9.1 h at the 575.89 s/blk 84 h trailing mean), strictly above, never equal 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67944741, // RE-SLID 2026-09-23: its producer + 1964 blocks (6 h at 11 s/blk median) + 'DOGE:testnet': 67936888, // RE-SLID 2026-09-23: its producer + 835 blocks (6 h at 25.89 s/blk, the 84 h trailing mean), strictly above, never equal 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration diff --git a/test/unit/consensus/consensus_rules_digest_active_gates.test.js b/test/unit/consensus/consensus_rules_digest_active_gates.test.js index 864036334..78769a885 100644 --- a/test/unit/consensus/consensus_rules_digest_active_gates.test.js +++ b/test/unit/consensus/consensus_rules_digest_active_gates.test.js @@ -98,7 +98,7 @@ function registerPinnedDigestTest() { for (const [p] of saved) delete require.cache[p]; const fresh = require('../../../src/consensus_rules_digest.js'); expect(fresh.computeConsensusRulesDigest().digest) - .to.equal('e18f7e2a2e3df32d1834d1983a4b203c05854679d9edac5595f7d0309161425f', + .to.equal('f69861138e062d4feb1dc7f9975fd28284a1f297e7e8375e6d870c020f3d97fd', 'the consensus rules digest moved; a gate was added, removed, reordered or re-armed'); } finally { for (const [p, mod] of saved) { if (mod === undefined) delete require.cache[p]; else require.cache[p] = mod; } From 7caf0942f0d54e96e8a9f1fa92c836ad5fcad9d8 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 10:56:44 -0700 Subject: [PATCH 56/57] Make the ATTEST batch-window claim exclusive under foundRows:true, and scope the regtest chain-tip purge to this hub's own network setAttestPublishedBatchByNetwork issued INSERT ... ON DUPLICATE KEY UPDATE window_start = window_start as its durable pre-send claim. Under the hub pool's mariadb foundRows default (unset, which the driver resolves to true), a duplicate's matched-but-unchanged row also reports affectedRows 1, so claimWindowForBroadcast could not tell the sole creator of an intent row from a second overlapping sweep on the same hub, and both could broadcast the same window. Switch the claim to INSERT IGNORE, which reports 0 for a duplicate regardless of foundRows, and correct the window.js comments that called the marker a cross-publisher mutex: each validator runs its own hub against its own DB, so the guard only ever arbitrates between this hub's own overlapping attempts. pushchaintip's regtest capability-snapshot purge triggered on the caller's own network param alone, so a testnet or mainnet hub could be made to wipe its cross-chain and capability_snapshots rows by a caller sending network: 'regtest' twice with different chain_ids. Gate it on this hub's own configured network (hub.network) as well. Worktree verified against origin/develop (e9368e88); full hub unit suite (6662 passing) and the code-structure check are clean. --- src/api/rpc/feed.js | 5 +- src/attestation/batch_publisher/window.js | 14 +- src/db/attestation.js | 14 +- .../batch/attestation_batch_publisher.test.js | 2 +- ...ion_batch_publisher_000_the_window.test.js | 2 +- ...h_publisher_001_resolvefloorwindow.test.js | 2 +- ..._publisher_002_publishing_a_window.test.js | 2 +- ..._publisher_003_publishing_a_window.test.js | 2 +- ...r_004_a_head_refused_before_it_was.test.js | 2 +- ...r_005_a_head_refused_before_it_was.test.js | 2 +- ...r_006_a_head_refused_before_it_was.test.js | 2 +- ...ch_publisher_007_window_membership.test.js | 2 +- ...tch_publisher_008_the_batch_quorum.test.js | 2 +- ...tch_publisher_009_the_batch_quorum.test.js | 2 +- ...blisher_010_the_publisher_election.test.js | 2 +- ...ch_publisher_011_the_landed_marker.test.js | 2 +- ..._publisher_013_the_catchup_horizon.test.js | 2 +- ...isher_014_one_broadcast_per_window.test.js | 2 +- ...er_015_a_head_refused_by_create_tx.test.js | 2 +- .../chain_tip_chain_id_purge.test.js | 40 +++- .../db_attest_batch_claim_exclusive.test.js | 216 ++++++++++++++++++ 21 files changed, 293 insertions(+), 28 deletions(-) create mode 100644 test/unit/db/db_attest_batch_claim_exclusive.test.js diff --git a/src/api/rpc/feed.js b/src/api/rpc/feed.js index d6a9c5009..1488cbe47 100644 --- a/src/api/rpc/feed.js +++ b/src/api/rpc/feed.js @@ -65,7 +65,10 @@ function chainTipFeedRpc(ctx) { chainId = chain_id; } try { - if (coin === 'BTC' && network === 'regtest' && chainId !== undefined) { + // hub.network is THIS hub's own configured network, never the caller's + // param: a testnet/mainnet hub must not purge over a caller claiming + // network: 'regtest' twice with different chain_ids. + if (coin === 'BTC' && network === 'regtest' && hub.network === 'regtest' && chainId !== undefined) { let storedTip = await hub.db.getChainTip(coin, network); if (storedTip && storedTip.chainId && storedTip.chainId !== chainId) { await hub.db.deleteCrossChainMatchesByNetwork(network); diff --git a/src/attestation/batch_publisher/window.js b/src/attestation/batch_publisher/window.js index 5d4c597a8..3fb050c0a 100644 --- a/src/attestation/batch_publisher/window.js +++ b/src/attestation/batch_publisher/window.js @@ -212,9 +212,13 @@ module.exports = { } // Claim at broadcastWindow's existing pre-send intent point. The unique - // (network, window_start) marker is the cross-publisher mutex: only the INSERT - // that creates it may send. A marker read followed by a send is not sufficient, - // because several validators can all complete that read before any one sends. + // (network, window_start) marker is this hub's own exclusivity guard: only the + // INSERT that creates it may send. Each validator runs its own hub against its + // own DB, so this never arbitrates between validators; it is what keeps two + // overlapping sweep attempts on the SAME hub (a retry racing the original, or a + // restart racing an in-flight run) from both broadcasting the same window. A + // marker read followed by a send is not sufficient, because both attempts can + // complete that read before either one sends. let claimed = Object.create(this); claimed.recordIntent = async (candidate, key) => { if(!(await this.claimWindowForBroadcast(candidate, key))){ @@ -227,8 +231,8 @@ module.exports = { }, // Atomically acquire the durable pre-send marker. setAttestPublishedBatchByNetwork - // uses INSERT with a duplicate-key no-op, so affectedRows separates the sole - // creator from every competing publisher. This runs inside broadcastWindow after + // uses INSERT IGNORE, so affectedRows separates the sole creator of this hub's + // marker row from every other attempt on it. This runs inside broadcastWindow after // its pipeline, balance and spend reservations have passed, preserving the rule // that a window unable to attempt a send leaves no intent marker. async claimWindowForBroadcast(window, batchKey){ diff --git a/src/db/attestation.js b/src/db/attestation.js index 096a7905e..054b0cb51 100644 --- a/src/db/attestation.js +++ b/src/db/attestation.js @@ -179,10 +179,18 @@ module.exports = { return this.doQuery('SELECT MAX(id) AS max_id FROM attestation_responses'); }, - // Inserts or updates a row in attest_published_batches. - // Moved here from src/attestation/batch_publisher.js:1310. + // Inserts a row in attest_published_batches; a no-op on an existing (network, + // window_start) row of any status, like an ON DUPLICATE KEY UPDATE no-op, + // but with an affectedRows split that stays exclusive. INSERT IGNORE + // reports 0 for a duplicate and 1 only for the row it actually created, + // regardless of the driver's foundRows setting; an ON DUPLICATE KEY UPDATE + // no-op does not have that property, because the hub pool + // (src/db/index.js) leaves mariadb's default foundRows:true in effect, under + // which a duplicate's matched-but-unchanged row also reports affectedRows 1. + // claimWindowForBroadcast in batch_publisher/window.js reads that split to tell + // the sole creator of an intent row from every other attempt on it. async setAttestPublishedBatchByNetwork(network, window_start, window_end, batchKey, row_count, status) { - return this.doQuery('INSERT INTO attest_published_batches (network, window_start, window_end, batch_key, row_count, status) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE window_start = window_start', [network, window_start, window_end, batchKey, row_count, status]); + return this.doQuery('INSERT IGNORE INTO attest_published_batches (network, window_start, window_end, batch_key, row_count, status) VALUES (?, ?, ?, ?, ?, ?)', [network, window_start, window_end, batchKey, row_count, status]); }, // Inserts or updates a row in attest_published_batches. diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher.test.js index aa3ea78d1..6f5c95a59 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher.test.js @@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); return found ? [Object.assign({}, found)] : []; } - if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); throw new Error('unexpected statement: ' + sql); } diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_000_the_window.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_000_the_window.test.js index 33c238afd..c39e572a4 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_000_the_window.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_000_the_window.test.js @@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); return found ? [Object.assign({}, found)] : []; } - if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); throw new Error('unexpected statement: ' + sql); } diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_001_resolvefloorwindow.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_001_resolvefloorwindow.test.js index edbf14744..4302c547b 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_001_resolvefloorwindow.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_001_resolvefloorwindow.test.js @@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); return found ? [Object.assign({}, found)] : []; } - if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); throw new Error('unexpected statement: ' + sql); } diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_002_publishing_a_window.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_002_publishing_a_window.test.js index 5d1574c23..dfa050d80 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_002_publishing_a_window.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_002_publishing_a_window.test.js @@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); return found ? [Object.assign({}, found)] : []; } - if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); throw new Error('unexpected statement: ' + sql); } diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js index 329acff0f..37d7477fc 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_003_publishing_a_window.test.js @@ -140,7 +140,7 @@ function doDbQuery(responses, markers, sql, args){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); return found ? [Object.assign({}, found)] : []; } - if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); throw new Error('unexpected statement: ' + sql); } diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_004_a_head_refused_before_it_was.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_004_a_head_refused_before_it_was.test.js index 096c41dff..860a36955 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_004_a_head_refused_before_it_was.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_004_a_head_refused_before_it_was.test.js @@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); return found ? [Object.assign({}, found)] : []; } - if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); throw new Error('unexpected statement: ' + sql); } diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_005_a_head_refused_before_it_was.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_005_a_head_refused_before_it_was.test.js index 0ea21175e..f42a028bf 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_005_a_head_refused_before_it_was.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_005_a_head_refused_before_it_was.test.js @@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); return found ? [Object.assign({}, found)] : []; } - if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); throw new Error('unexpected statement: ' + sql); } diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_006_a_head_refused_before_it_was.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_006_a_head_refused_before_it_was.test.js index de7e8c7ba..e07746d27 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_006_a_head_refused_before_it_was.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_006_a_head_refused_before_it_was.test.js @@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); return found ? [Object.assign({}, found)] : []; } - if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); throw new Error('unexpected statement: ' + sql); } diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_007_window_membership.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_007_window_membership.test.js index dd7efe8d4..e6884fbf2 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_007_window_membership.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_007_window_membership.test.js @@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); return found ? [Object.assign({}, found)] : []; } - if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); throw new Error('unexpected statement: ' + sql); } diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_008_the_batch_quorum.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_008_the_batch_quorum.test.js index 741dd38c7..2bf58cd4e 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_008_the_batch_quorum.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_008_the_batch_quorum.test.js @@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); return found ? [Object.assign({}, found)] : []; } - if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); throw new Error('unexpected statement: ' + sql); } diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_009_the_batch_quorum.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_009_the_batch_quorum.test.js index 1143e27bf..291b68c16 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_009_the_batch_quorum.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_009_the_batch_quorum.test.js @@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); return found ? [Object.assign({}, found)] : []; } - if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); throw new Error('unexpected statement: ' + sql); } diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_010_the_publisher_election.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_010_the_publisher_election.test.js index 9880a678a..ff02a86d1 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_010_the_publisher_election.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_010_the_publisher_election.test.js @@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); return found ? [Object.assign({}, found)] : []; } - if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); throw new Error('unexpected statement: ' + sql); } diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_011_the_landed_marker.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_011_the_landed_marker.test.js index 6d0089c0f..7527cdd69 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_011_the_landed_marker.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_011_the_landed_marker.test.js @@ -124,7 +124,7 @@ function doDbQuery(responses, markers, sql, args){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); return found ? [Object.assign({}, found)] : []; } - if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); throw new Error('unexpected statement: ' + sql); } diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_013_the_catchup_horizon.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_013_the_catchup_horizon.test.js index a6a7c5b8d..947b43128 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_013_the_catchup_horizon.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_013_the_catchup_horizon.test.js @@ -140,7 +140,7 @@ function doDbQuery(responses, markers, sql, args){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); return found ? [Object.assign({}, found)] : []; } - if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); throw new Error('unexpected statement: ' + sql); } diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_014_one_broadcast_per_window.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_014_one_broadcast_per_window.test.js index 5b12ec922..75f7a497e 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_014_one_broadcast_per_window.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_014_one_broadcast_per_window.test.js @@ -52,7 +52,7 @@ function makeDb(){ markers.splice(i, 1); return { affectedRows: 1 }; } - if(/^INSERT INTO attest_published_batches/i.test(sql)){ + if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)){ insertAttempts++; let [network, windowStart, windowEnd, batchKey, rowCount, status] = args; let existing = markers.find(m => m.network === network && diff --git a/test/unit/attestation/publisher/batch/attestation_batch_publisher_015_a_head_refused_by_create_tx.test.js b/test/unit/attestation/publisher/batch/attestation_batch_publisher_015_a_head_refused_by_create_tx.test.js index f21adf9c3..1c430d96e 100644 --- a/test/unit/attestation/publisher/batch/attestation_batch_publisher_015_a_head_refused_by_create_tx.test.js +++ b/test/unit/attestation/publisher/batch/attestation_batch_publisher_015_a_head_refused_by_create_tx.test.js @@ -117,7 +117,7 @@ function doDbQuery(responses, markers, sql, args){ let found = markers.find(m => m.network === args[0] && Number(m.window_start) === Number(args[1])); return found ? [Object.assign({}, found)] : []; } - if(/^INSERT INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); + if(/^INSERT (?:IGNORE )?INTO attest_published_batches/i.test(sql)) return insertMarker(markers, sql, args); if(/^UPDATE attest_published_batches SET status/i.test(sql)) return updateMarker(markers, args); throw new Error('unexpected statement: ' + sql); } diff --git a/test/unit/cross_chain/chain_tip_chain_id_purge.test.js b/test/unit/cross_chain/chain_tip_chain_id_purge.test.js index 8be5b10a3..c09704da1 100644 --- a/test/unit/cross_chain/chain_tip_chain_id_purge.test.js +++ b/test/unit/cross_chain/chain_tip_chain_id_purge.test.js @@ -29,16 +29,16 @@ function makeDb(storedId = OLD_ID) { }; } -async function push(db, overrides = {}) { +async function push(db, overrides = {}, hubOverrides = {}) { const params = { coin: 'BTC', network: 'regtest', block_height: 42, block_time: 1700000000, chain_id: NEW_ID, ...overrides }; - return buildFeedRpc({ hub: { db } }).pushchaintip(params); + return buildFeedRpc({ hub: { db, network: 'regtest', ...hubOverrides } }).pushchaintip(params); } -describe('regtest chain-tip identity purge', function () { +function registerScopedSqlTest() { it('uses scoped SQL for cross-chain rows and an unfiltered snapshot delete', async function () { const doQuery = sinon.stub().resolves(); await crossChainDb.deleteCrossChainMatchesByNetwork.call({ doQuery }, 'regtest'); @@ -53,7 +53,9 @@ describe('regtest chain-tip identity purge', function () { ]); expect(doQuery.getCall(2).args).to.deep.equal(['DELETE FROM capability_snapshots']); }); +} +function registerPurgeAndFailureTests() { it('purges all three tables before replacing a changed BTC regtest tip', async function () { const db = makeDb(); expect(await push(db)).to.deep.equal({ status: 'success' }); @@ -77,7 +79,9 @@ describe('regtest chain-tip identity purge', function () { expect(db.setChainTip.called).to.equal(false); }); } +} +function registerNoOpTests() { const noOps = [ ['no stored identity', null, {}], ['an unchanged identity', NEW_ID, {}], @@ -94,4 +98,34 @@ describe('regtest chain-tip identity purge', function () { expect(db.setChainTip.calledOnce).to.equal(true); }); } +} + +// The purge must gate on THIS hub's own configured network, never the caller's +// param alone: a caller (an indexer relaying a pushed tip) claiming network: +// 'regtest' twice with different chain_ids is not evidence of a regtest +// re-genesis when the hub answering the call is a testnet or mainnet hub. +function registerHubNetworkGuardTests() { + for (const hubNetwork of ['testnet', 'mainnet']) { + it('does not purge when the caller sends regtest but the hub is ' + hubNetwork, async function () { + const db = makeDb(OLD_ID); + expect(await push(db, {}, { network: hubNetwork })).to.deep.equal({ status: 'success' }); + expect(db.deleteCrossChainMatchesByNetwork.called).to.equal(false); + expect(db.deleteCrossChainCallsByNetwork.called).to.equal(false); + expect(db.deleteAllCapabilitySnapshots.called).to.equal(false); + expect(db.setChainTip.calledOnce).to.equal(true); + }); + } + + it('still purges for a regtest hub, unaffected by the added hub.network guard', async function () { + const db = makeDb(OLD_ID); + expect(await push(db, {}, { network: 'regtest' })).to.deep.equal({ status: 'success' }); + expect(db.order).to.deep.equal(['get', 'matches', 'calls', 'snapshots', 'set']); + }); +} + +describe('regtest chain-tip identity purge', function () { + registerScopedSqlTest(); + registerPurgeAndFailureTests(); + registerNoOpTests(); + registerHubNetworkGuardTests(); }); diff --git a/test/unit/db/db_attest_batch_claim_exclusive.test.js b/test/unit/db/db_attest_batch_claim_exclusive.test.js new file mode 100644 index 000000000..aa2a3fc79 --- /dev/null +++ b/test/unit/db/db_attest_batch_claim_exclusive.test.js @@ -0,0 +1,216 @@ +'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. +// +// The ATTEST batch-window durable claim (attest_published_batches, keyed on +// (network, window_start)) is what stops two overlapping sweeps on the SAME hub +// from both broadcasting a batch for one window and spending DOGE twice. +// claimWindowForBroadcast (src/attestation/batch_publisher/window.js) trusts +// affectedRows === 1 to mean "this call created the row, so this call may send". +// +// That trust was misplaced against the statement setAttestPublishedBatchByNetwork +// used to issue: `INSERT ... ON DUPLICATE KEY UPDATE window_start = window_start`. +// MySQL/MariaDB report affectedRows 0 for a duplicate whose ON DUPLICATE KEY UPDATE +// leaves every column unchanged - UNLESS the connection negotiated the +// CLIENT_FOUND_ROWS capability, in which case a matched-but-unchanged row reports +// 1 instead of 0. The hub's pool (src/db/index.js's connectionPoolParams) never +// sets `foundRows`, and the mariadb 3.5.3 driver defaults an unset `foundRows` to +// true (lib/config/connection-options.js: `this.foundRows = opts.foundRows === +// undefined || Boolean(opts.foundRows)`), so every hub connection runs with +// CLIENT_FOUND_ROWS on. Under that default, a duplicate claim attempt reported +// affectedRows 1 exactly like the real creator, and claimWindowForBroadcast could +// not tell them apart: two overlapping attempts on the same window both won. +// +// The fix makes setAttestPublishedBatchByNetwork issue `INSERT IGNORE` instead. +// INSERT IGNORE never runs an UPDATE clause, so the CLIENT_FOUND_ROWS capability +// (which only changes how a matched UPDATE is counted) has nothing to act on: a +// duplicate is always 0 and a creation is always 1, with or without foundRows. +// +// The first block below models the historical statement directly, against the +// documented MariaDB affectedRows rule, to show the vulnerability was real. The +// second block drives the actual current src/db/attestation.js and +// src/attestation/batch_publisher/window.js code and proves the claim it issues +// today is exclusive under the same foundRows: true the hub pool actually runs +// with. + +const sinon = require('sinon'); +const { expect } = require('chai'); +const proxyquire = require('proxyquire'); + +const windowMixin = require('../../../src/attestation/batch_publisher/window.js'); + +// The exact statement setAttestPublishedBatchByNetwork issued before this fix. +// Kept here, not in production code, purely to model what it did under +// foundRows: true. +const OLD_CLAIM_SQL = 'INSERT INTO attest_published_batches ' + + '(network, window_start, window_end, batch_key, row_count, status) ' + + 'VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE window_start = window_start'; + +// A one-row-per-key model of attest_published_batches, answering exactly the +// statements setAttestPublishedBatchByNetwork and its DELETE guard issue, with +// MariaDB's own affectedRows rules for each form: +// +// INSERT ... ON DUPLICATE KEY UPDATE = (the pre-fix statement) +// new row -> 1 +// duplicate, foundRows false -> 0 (the row's values did not change) +// duplicate, foundRows true -> 1 (CLIENT_FOUND_ROWS counts the matched row) +// +// INSERT IGNORE (the current statement) +// new row -> 1 +// duplicate -> 0, always: IGNORE never runs an UPDATE, so CLIENT_FOUND_ROWS +// (which only changes how a matched UPDATE is counted) does not +// apply to it. +function makeClaimModel(foundRows) { + const table = new Map(); + const key = (network, windowStart) => network + '|' + windowStart; + + async function query(sql, args) { + const text = String(sql); + + if (/^INSERT IGNORE INTO attest_published_batches/i.test(text)) { + const [network, windowStart, windowEnd, batchKey, rowCount, status] = args; + const k = key(network, windowStart); + if (table.has(k)) return { affectedRows: 0 }; + table.set(k, { network, windowStart, windowEnd, batchKey, rowCount, status }); + return { affectedRows: 1 }; + } + + if (text === OLD_CLAIM_SQL) { + const [network, windowStart, windowEnd, batchKey, rowCount, status] = args; + const k = key(network, windowStart); + if (table.has(k)) return { affectedRows: foundRows ? 1 : 0 }; + table.set(k, { network, windowStart, windowEnd, batchKey, rowCount, status }); + return { affectedRows: 1 }; + } + + if (/^DELETE FROM attest_published_batches/i.test(text)) { + const [network, windowStart, statusGuard] = args; + const k = key(network, windowStart); + const row = table.get(k); + if (!row || row.status !== statusGuard) return { affectedRows: 0 }; + table.delete(k); + return { affectedRows: 1 }; + } + + throw new Error('unexpected statement in claim model: ' + text); + } + + return { table, query }; +} + +// A real Database instance (src/db, with the attestation.js mixin installed), +// mariadb and fs fully stubbed, its connection routed through one claim model so +// setAttestPublishedBatchByNetwork's actual SQL text is what gets exercised. +function makeDb(foundRows) { + const model = makeClaimModel(foundRows); + const mockConn = { + query: sinon.stub().callsFake(model.query), + release: sinon.stub().resolves(), + end: sinon.stub().resolves() + }; + const mockPool = { + getConnection: sinon.stub().resolves(mockConn), + end: sinon.stub().resolves() + }; + const Database = proxyquire('../../../src/db', { + mariadb: { createPool: sinon.stub().returns(mockPool), createConnection: sinon.stub().resolves(mockConn) }, + fs: { readdirSync: sinon.stub().returns([]), readFileSync: sinon.stub().returns('') } + }); + const db = new Database('localhost', 3306, 'test_db', 'user', 'pass'); + return { db, model }; +} + +function registerOldStatementModelTests() { + describe('the pre-fix statement, modeled directly against MariaDB\'s affectedRows rule', function () { + it('reports affectedRows 1 for the real creator', async function () { + const model = makeClaimModel(true); + const result = await model.query(OLD_CLAIM_SQL, + ['regtest', 1000, 1010, 'key-a', 0, 'intent']); + expect(result.affectedRows).to.equal(1); + }); + + it('under foundRows: true, ALSO reports affectedRows 1 for a duplicate: two contenders both win', async function () { + const model = makeClaimModel(true); + const first = await model.query(OLD_CLAIM_SQL, ['regtest', 1000, 1010, 'key-a', 0, 'intent']); + const second = await model.query(OLD_CLAIM_SQL, ['regtest', 1000, 1010, 'key-a', 0, 'intent']); + expect(first.affectedRows).to.equal(1); + expect(second.affectedRows, 'a duplicate claim must not read as a win').to.equal(1); + }); + + it('under foundRows: false, a duplicate correctly reports affectedRows 0 - the pool never gets this default', async function () { + const model = makeClaimModel(false); + await model.query(OLD_CLAIM_SQL, ['regtest', 1000, 1010, 'key-a', 0, 'intent']); + const second = await model.query(OLD_CLAIM_SQL, ['regtest', 1000, 1010, 'key-a', 0, 'intent']); + expect(second.affectedRows).to.equal(0); + }); + }); +} + +function registerCurrentStatementTests() { + describe('setAttestPublishedBatchByNetwork today, run against a connection with foundRows: true', function () { + it('issues INSERT IGNORE, with no ON DUPLICATE KEY UPDATE clause', async function () { + const { db } = makeDb(true); + await db.setAttestPublishedBatchByNetwork('regtest', 2000, 2010, 'key-b', 0, 'intent'); + const calledSql = (await db.getConnection()).query.getCall(0).args[0]; + expect(calledSql).to.match(/^INSERT IGNORE INTO attest_published_batches/i); + expect(calledSql).to.not.match(/ON DUPLICATE KEY UPDATE/i); + }); + + it('reports affectedRows 1 for the creating call and 0 for every later duplicate', async function () { + const { db } = makeDb(true); + const first = await db.setAttestPublishedBatchByNetwork('regtest', 3000, 3010, 'key-c', 0, 'intent'); + const second = await db.setAttestPublishedBatchByNetwork('regtest', 3000, 3010, 'key-c', 0, 'intent'); + const third = await db.setAttestPublishedBatchByNetwork('regtest', 3000, 3010, 'key-c', 0, 'intent'); + expect(Number(first.affectedRows)).to.equal(1); + expect(Number(second.affectedRows)).to.equal(0); + expect(Number(third.affectedRows)).to.equal(0); + }); + }); +} + +// claimWindowForBroadcast is a mixin method, installed on +// AttestationBatchPublisher.prototype in production; called here directly against +// a minimal context so the assertion is about the SQL/affectedRows contract, not +// the surrounding publisher machinery already covered by +// attestation_batch_publisher_014_one_broadcast_per_window.test.js. +function makeClaimCtx(db) { + return { hubDb: () => db, network: 'regtest' }; +} + +function registerClaimWindowForBroadcastTests() { + describe('claimWindowForBroadcast (src/attestation/batch_publisher/window.js), end to end', function () { + it('lets exactly one of two identical claim attempts win', async function () { + const { db } = makeDb(true); + const window = { window_start: 4000, window_end: 4010, row_count: 0 }; + + const firstWon = await windowMixin.claimWindowForBroadcast.call(makeClaimCtx(db), window, 'key-d'); + const secondWon = await windowMixin.claimWindowForBroadcast.call(makeClaimCtx(db), window, 'key-d'); + + expect(firstWon, 'the first attempt must create the marker and win').to.equal(true); + expect(secondWon, 'a second attempt on the same window must not also win').to.equal(false); + }); + + it('lets a later window claim independently once the earlier one is settled', async function () { + const { db } = makeDb(true); + const windowA = { window_start: 5000, window_end: 5010, row_count: 0 }; + const windowB = { window_start: 5010, window_end: 5020, row_count: 0 }; + + expect(await windowMixin.claimWindowForBroadcast.call(makeClaimCtx(db), windowA, 'key-e')).to.equal(true); + expect(await windowMixin.claimWindowForBroadcast.call(makeClaimCtx(db), windowB, 'key-f')).to.equal(true); + }); + }); +} + +describe('ATTEST batch-window claim: exclusivity under the hub pool\'s foundRows default', function () { + registerOldStatementModelTests(); + registerCurrentStatementTests(); + registerClaimWindowForBroadcastTests(); +}); From 34491eabadf9c4a8924c27319874eaf99bd52394 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 17:51:06 -0700 Subject: [PATCH 57/57] Pin the Node base image by digest to the consensus runtime The floating node:22-bookworm tag can move to a Node patch whose V8/ICU no longer match xchain-vm's consensus runtime pin, so the base image is now pinned by digest to a build that is measured to match it. --- Dockerfile | 10 +++++----- .../providers/llm/dockerfile_llm_provider_cli.test.js | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0085dec2d..4cafe3c81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -# Pinned to node:22-bookworm, the tag .nvmrc and the sibling service images -# already declare. `node:latest` floats: a rebuild silently moves the runtime -# off the declared Node 22, so the image and the repo's pin drift apart with -# no signal anywhere. -FROM node:22-bookworm +# Pinned by digest to the node:22.23.2-bookworm build whose V8/ICU match +# xchain-vm's consensus runtime pin: the floating node:22-bookworm tag moved +# to a Node patch that fails that check, so a tag alone can silently drift +# the image off the runtime the fleet requires. +FROM node:22.23.2-bookworm@sha256:dd5847a04b0deee391fa145f1f4c6d214196668b6bcc7988ebed67249f226844 RUN mkdir /XChainHub/ COPY ./package.json /XChainHub/package.json diff --git a/test/unit/providers/llm/dockerfile_llm_provider_cli.test.js b/test/unit/providers/llm/dockerfile_llm_provider_cli.test.js index 88d8b48a7..486897a18 100644 --- a/test/unit/providers/llm/dockerfile_llm_provider_cli.test.js +++ b/test/unit/providers/llm/dockerfile_llm_provider_cli.test.js @@ -51,7 +51,7 @@ describe('Dockerfile: the llm provider CLI is in the image', () => { // Coverage floor: if the file stops looking like the hub's Dockerfile at all, // every assertion below could pass vacuously against an empty read. it('reads a Dockerfile that still builds the hub', () => { - expect(dockerfile).to.match(/^FROM node:22-bookworm$/m); + expect(dockerfile).to.match(/^FROM node:22\.23\.2-bookworm@sha256:[0-9a-f]{64}$/m); expect(dockerfile).to.include('/XChainHub/src'); });