From 297f863adf34e1f32b165bcc06595fc92289c549 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 13:30:58 -0700 Subject: [PATCH 01/30] 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 28ea9dad..57e45d0d 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 c3b68a719e36d8584400ab0283d5f0921d9c422e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 09:49:24 -0700 Subject: [PATCH 02/30] ci: sibling checkouts follow the pull request's base branch, keeping push, release and hotfix refs unchanged --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed0dd38e..e10a2232 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: uses: XChain-Platform/.github/.github/workflows/ci-reusable.yml@6f4d39ae85787fc31e90a31588d87610a2c33103 # pin: XChain-Platform/.github @ master 2026-08-14; bump deliberately with: # A hotfix or release PR compares against siblings at the same branch, not develop. - siblings-ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || '' }} + siblings-ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || github.base_ref || '' }} # Opt into the perf gate: the SDK's perf suite (Validator throughput) needs # no infra, so it runs cleanly as a post-gate step. First adopter of run-perf. run-perf: true @@ -48,7 +48,7 @@ jobs: # canonical sibling is read at the sibling release branch, because develop # keeps moving under the frozen train (the indexer handler map this gate # grades is re-pinned at every indexer develop landing). - ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || (github.ref == 'refs/heads/master' && 'master' || 'develop') }} + ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || github.base_ref || (github.ref == 'refs/heads/master' && 'master' || 'develop') }} ssh-key: ${{ secrets.XCHAIN_HUB_DEPLOY_KEY }} path: xchain-hub @@ -83,7 +83,7 @@ jobs: # canonical sibling is read at the sibling release branch, because develop # keeps moving under the frozen train (the indexer handler map this gate # grades is re-pinned at every indexer develop landing). - ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || (github.ref == 'refs/heads/master' && 'master' || 'develop') }} + ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || github.base_ref || (github.ref == 'refs/heads/master' && 'master' || 'develop') }} ssh-key: ${{ secrets.XCHAIN_EXPLORER_DEPLOY_KEY }} path: xchain-explorer @@ -117,7 +117,7 @@ jobs: # canonical sibling is read at the sibling release branch, because develop # keeps moving under the frozen train (the indexer handler map this gate # grades is re-pinned at every indexer develop landing). - ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || (github.ref == 'refs/heads/master' && 'master' || 'develop') }} + ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || github.base_ref || (github.ref == 'refs/heads/master' && 'master' || 'develop') }} ssh-key: ${{ secrets.XCHAIN_INDEXER_DEPLOY_KEY }} path: xchain-indexer @@ -150,7 +150,7 @@ jobs: - name: Check out declared sibling repositories uses: XChain-Platform/.github/actions/checkout-siblings@master # one definition for every call site; see the action for why this tracks master with: - ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || '' }} + ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || github.base_ref || '' }} - name: Use Node.js 22 uses: actions/setup-node@v4 From 4f601f0bb4bb40f3000c3e0c38b0641bad5786ff Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 15:52:10 -0700 Subject: [PATCH 03/30] 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. --- src/consensus/gate_registry/shared_rows_2.js | 21 ++++++++++---------- src/consensus/gate_registry/shared_rows_5.js | 14 +++++++++++++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index eda6ba32..c7fd765d 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -204,20 +204,21 @@ addGate('mirror_admission_activation.ADMIT_MAX_FUTURE_BLOCKS', 'constant', { * height on a BTC indexer, so the two legs of one cross-chain match would cross the flag day at * unrelated instants. The 'COIN:network' key shape is established precedent. * - * Mainnet is null under the 2026-08-29 write hold. TESTNET SIZED 2026-09-16 20:41Z; the measured - * tips, the formula, the epoch-close rule and the re-size rule are written once in the canon - * (xchain-documentation/protocol/constants.js), which this row is held value-identical to. The v7 - * HUB_SCHEMA_VERSION roll completes BEFORE any of these heights: the heights map rides frames - * carrying no schema_version, so a v7 indexer above the activation against a v6 hub would see no - * heights at all and defer forever under the fail-closed rule. + * Mainnet is null under the 2026-08-29 write hold. TESTNET SIZED 2026-09-16 20:41Z, LTC and DOGE + * RE-CUT 2026-09-17 22:45Z onto the BTC instant after their cadences drifted off it; the measured + * tips, the formula, the cadence-window rule, the epoch-close rule and the per-chain re-size rule + * are written once in the canon (xchain-documentation/protocol/constants.js), which this row is + * held value-identical to. The v7 HUB_SCHEMA_VERSION roll completes BEFORE any of these heights: + * the heights map rides frames carrying no schema_version, so a v7 indexer above the activation + * against a v6 hub would see no heights at all and defer forever under the fail-closed rule. */ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 153222, // SIZED 2026-09-16 20:41Z: epoch close 153,216 + 6 buried; tip 152,756 + 466 at 498.7 s/blk, about 64.5 h - 'LTC:testnet': 4889331, // the same instant: tip 4,887,745 + 1586 at 146.6 s/blk - 'DOGE:testnet': 67910821, // the same instant: tip 67,901,335 + 9486 at 24.5 s/blk + 'LTC:testnet': 4891504, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 4,889,190 + 2314 at 82.5 s/blk + 'DOGE:testnet': 67911796, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 67,904,912 + 6884 at 27.7 s/blk 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -228,8 +229,8 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 153266, // its producer + 44 blocks, about 6 h: strictly above, never equal - 'LTC:testnet': 4889479, // its producer + 148 blocks, about 6 h - 'DOGE:testnet': 67911703, // its producer + 882 blocks, about 6 h + 'LTC:testnet': 4891766, // its producer + 262 blocks, about 6 h at 82.5 s/blk + 'DOGE:testnet': 67912575, // its producer + 779 blocks, about 6 h at 27.7 s/blk 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration diff --git a/src/consensus/gate_registry/shared_rows_5.js b/src/consensus/gate_registry/shared_rows_5.js index 92f3979b..cbf28976 100644 --- a/src/consensus/gate_registry/shared_rows_5.js +++ b/src/consensus/gate_registry/shared_rows_5.js @@ -97,6 +97,20 @@ addGate('train_activation.TRAIN_ACTIVATION', 'ruleset', { // bridge height below sits above it on the same BTC clock, so a node lacking this rule // set halts before it can grade a bridge action. '0.19.0': { mainnet: 9999999999, testnet: 152787, regtest: 0 }, + // The mirror-admission rule set, armed at the v0.20.0 cut: the producer and consumer + // admission maps and the anchor-attest barrier replace the effective_time binding, so a + // node without them grades an admission-stamped row under the rule it replaced. Mainnet + // holds the house sentinel because the whole family is null on mainnet under the + // 2026-08-29 write hold. Testnet: SIZED 2026-09-17 22:45Z, chain_tip TBTC 152,891 + 225 + // blocks, which is ceil(36 h / 576.7 s per block), about 36.0 h. The cadence is measured + // over a trailing window as long as the lead being sized (53 h here), never the last 99 + // blocks: a 99-block window on a testnet difficulty burst is noise, and it is what pulled + // the LTC leg of this family two days off its BTC counterpart a day after the first cut. + // That lead is the rolling-upgrade window the fleet roll must finish inside (24x the 90 + // minute roll budget), and every testnet mirror-admission height sits above it on the same + // BTC clock (the BTC producer at 153,222 is 106 blocks and about 17.0 h further up), so a + // node lacking this rule set halts before it can grade an admission-stamped row. + '0.20.0': { mainnet: 9999999999, testnet: 153116, regtest: 0 }, }); // xchain_bridge_activation From 5d766e6c1ddf048f9caea608b71f803434833b3c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 21:01:48 -0700 Subject: [PATCH 04/30] test: resolve indexer parity handlers from the drift map Load BATCH and ISSUE parity handlers from existing mapped handler rows so directory refactors cannot silently skip the checks. Exercise all mapped handler source parts and restore 94 SDK-to-indexer parity cases to the live test count. --- test/helpers/indexer_action_handler.js | 48 +++++++++++++++++++ .../issue_format7_pre_change_pin.test.js | 21 ++------ test/unit/decoder/batch_grammar.test.js | 12 ++--- .../batch_builder_cost_weight.test.js | 12 ++--- ...lf_sibling_xchain_indexer_checkout.test.js | 11 ++--- ...04_weight_conformance_arbiter_half.test.js | 11 ++--- 6 files changed, 72 insertions(+), 43 deletions(-) create mode 100644 test/helpers/indexer_action_handler.js diff --git a/test/helpers/indexer_action_handler.js b/test/helpers/indexer_action_handler.js new file mode 100644 index 00000000..ffeb0a7a --- /dev/null +++ b/test/helpers/indexer_action_handler.js @@ -0,0 +1,48 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const { resolveIndexerRoot } = require('../../bin/preflight_indexer_root.js'); +const { listParts, parseMapRows } = require('../../bin/preflight_handler_dirs.js'); + +const MAP_PATH = path.join(__dirname, '..', '..', 'src', 'preflight', 'INDEXER-MAP.md'); + +function actionName(handler) { + const trimmed = handler.replace(/\/$/, ''); + return path.basename(trimmed, path.extname(trimmed)).toLowerCase(); +} + +function mappedActionRow(action) { + const wanted = String(action).toLowerCase(); + const rows = parseMapRows(fs.readFileSync(MAP_PATH, 'utf8')) + .filter((row) => actionName(row.handler) === wanted); + if (rows.length !== 1) + throw new Error(`expected one mapped indexer handler for ${action}, found ${rows.length}`); + return rows[0]; +} + +function resolveIndexerAction(action) { + const root = resolveIndexerRoot(); + if (!root) return null; + + const row = mappedActionRow(action); + const mappedPath = path.join(root, row.handler); + const entry = row.kind === 'directory' ? path.join(mappedPath, 'index.js') : mappedPath; + if (!fs.existsSync(entry)) + throw new Error(`mapped indexer handler entry is missing for ${action}: ${entry}`); + + const sources = row.kind === 'directory' + ? listParts(mappedPath).filter((name) => /\.(?:c|m)?js$/.test(name)) + .map((name) => path.join(mappedPath, name)) + : [mappedPath]; + return { root, entry, sources }; +} + +function loadIndexerAction(action) { + const resolved = resolveIndexerAction(action); + if (!resolved) return null; + return { ...resolved, Handler: require(resolved.entry) }; +} + +module.exports = { mappedActionRow, resolveIndexerAction, loadIndexerAction }; diff --git a/test/unit/actions/issue_format7_pre_change_pin.test.js b/test/unit/actions/issue_format7_pre_change_pin.test.js index 7bf0bee8..9b6276c1 100644 --- a/test/unit/actions/issue_format7_pre_change_pin.test.js +++ b/test/unit/actions/issue_format7_pre_change_pin.test.js @@ -38,8 +38,8 @@ ********************************************************************/ const { expect } = require('chai'); -const fs = require('fs'); const path = require('path'); +const { loadIndexerAction } = require('../../helpers/indexer_action_handler.js'); const config = require('../../../src/config.js'); const Utility = require('../../../src/utils/utility.js'); @@ -112,19 +112,6 @@ function makeActions() { // Same sibling resolution the golden suite uses: the unit tier checks out only // this repo, so the parser half degrades to a skip rather than a false red. -function resolveIndexerRoot() { - const candidates = [ - process.env.XCHAIN_INDEXER_PATH, - path.join(__dirname, '../..', '..', '..', 'xchain-indexer'), - ].filter(Boolean); - for (const root of candidates) { - if (fs.existsSync(path.join(root, 'src', 'utility.js')) && - fs.existsSync(path.join(root, 'src', 'actions', 'issue.js'))) - return root; - } - return null; -} - describe('ISSUE format 7 is purely additive (pre-change pin)', function () { describe('the SDK ISSUE format table for versions 0 to 6 is byte-identical to its pre-format-7 text', function () { @@ -158,18 +145,18 @@ describe('ISSUE format 7 is purely additive (pre-change pin)', function () { describe('ISSUE format 7 is purely additive (pre-change pin)', function () { describe('the sibling indexer parses an old-version ISSUE to the same values it always did', function () { - const indexerRoot = resolveIndexerRoot(); + const indexerAction = loadIndexerAction('issue'); let parse = null; before(function () { // Cross-repo require of the sibling handler and utility; synchronous, // so mocha's timer could only mis-attribute a slow load, never stop it. this.timeout(0); - if (!indexerRoot) { this.skip(); return; } + if (!indexerAction) { this.skip(); return; } + const { root: indexerRoot, Handler: Issue } = indexerAction; process.env.INDEXER_COIN = process.env.INDEXER_COIN || 'BTC'; process.env.INDEXER_NETWORK = process.env.INDEXER_NETWORK || 'regtest'; const IdxUtility = require(path.join(indexerRoot, 'src', 'utility.js')); - const Issue = require(path.join(indexerRoot, 'src', 'actions', 'issue.js')); const formats = new Issue({ config: {}, decoderDb: null, indexerDb: null, util: null, mapper: null }).formats; const util = new IdxUtility(); parse = function (wire) { diff --git a/test/unit/decoder/batch_grammar.test.js b/test/unit/decoder/batch_grammar.test.js index c20bf9e5..eba7a1e1 100644 --- a/test/unit/decoder/batch_grammar.test.js +++ b/test/unit/decoder/batch_grammar.test.js @@ -9,6 +9,7 @@ // findings. const { expect } = require('chai'); +const { resolveIndexerAction } = require('../../helpers/indexer_action_handler.js'); const { parse, BATCH_ACTION_LIMITS, @@ -155,14 +156,11 @@ describe('decoder.parse - BATCH sub-grammar', function () { describe('sibling conformance vs xchain-indexer batch.js', function () { const fs = require('fs'); - const path = require('path'); - const INDEXER = process.env.XCHAIN_INDEXER_PATH || - path.join(__dirname, '..', '..', '..', '..', 'xchain-indexer'); - const BATCH_SRC = path.join(INDEXER, 'src', 'actions', 'batch.js'); - before(function () { if (!fs.existsSync(BATCH_SRC)) this.skip(); }); + const batchHandler = resolveIndexerAction('batch'); + before(function () { if (!batchHandler) this.skip(); }); it('actionLimits values are byte-equal to the indexer source', function () { - const src = fs.readFileSync(BATCH_SRC, 'utf8'); + const src = batchHandler.sources.map((file) => fs.readFileSync(file, 'utf8')).join('\n'); for (const [action, limit] of Object.entries(BATCH_ACTION_LIMITS)) { // A gated cap lives in `gatedActionLimits` on purpose: writing it // into `actionLimits` would have applied it retroactively, so @@ -182,7 +180,7 @@ describe('decoder.parse - BATCH sub-grammar', function () { // what makes the merge in batch_limits.js a mirror rather than a // guess. `gatedActionLimits` capitalizes its A, so the pattern reads // only the ungated table by construction. - const src = fs.readFileSync(BATCH_SRC, 'utf8'); + const src = batchHandler.sources.map((file) => fs.readFileSync(file, 'utf8')).join('\n'); expect(/actionLimits\['DEPLOY'\]/.test(src)).to.equal(false); }); }); diff --git a/test/unit/protocol/batch_builder_cost_weight.test.js b/test/unit/protocol/batch_builder_cost_weight.test.js index 5e61f099..e9b5cb2b 100644 --- a/test/unit/protocol/batch_builder_cost_weight.test.js +++ b/test/unit/protocol/batch_builder_cost_weight.test.js @@ -38,8 +38,8 @@ ********************************************************************/ const { expect } = require('chai'); -const fs = require('fs'); const path = require('path'); +const { loadIndexerAction } = require('../../helpers/indexer_action_handler.js'); const XChainSDK = require('../../../src/XChainSDK.js'); const { @@ -96,16 +96,14 @@ const VECTORS = [ ]; function loadBatchHandler() { - const roots = [process.env.XCHAIN_INDEXER_PATH, - path.join(__dirname, '../..', '..', '..', 'xchain-indexer')].filter(Boolean); - const root = roots.find((r) => fs.existsSync(path.join(r, 'src', 'actions', 'batch.js'))); - if (!root) return null; + const resolved = loadIndexerAction('batch'); + if (!resolved) return null; + const { root, Handler: Batch } = resolved; process.env.INDEXER_COIN = process.env.INDEXER_COIN || 'BTC'; process.env.INDEXER_NETWORK = process.env.INDEXER_NETWORK || 'regtest'; - let Batch, IdxUtility, IdxConfig, ProtocolChanges; + let IdxUtility, IdxConfig, ProtocolChanges; try { - Batch = require(path.join(root, 'src', 'actions', 'batch.js')); IdxUtility = require(path.join(root, 'src', 'utility.js')); IdxConfig = require(path.join(root, 'src', 'config.js')); ProtocolChanges = require(path.join(root, 'src', 'protocol_changes.js')); diff --git a/test/unit/protocol/batch_limits_conformance.test/02_arbiter_half_sibling_xchain_indexer_checkout.test.js b/test/unit/protocol/batch_limits_conformance.test/02_arbiter_half_sibling_xchain_indexer_checkout.test.js index 71445636..2811699e 100644 --- a/test/unit/protocol/batch_limits_conformance.test/02_arbiter_half_sibling_xchain_indexer_checkout.test.js +++ b/test/unit/protocol/batch_limits_conformance.test/02_arbiter_half_sibling_xchain_indexer_checkout.test.js @@ -38,8 +38,8 @@ ********************************************************************/ const { expect } = require('chai'); -const fs = require('fs'); const path = require('path'); +const { loadIndexerAction } = require('../../../helpers/indexer_action_handler.js'); const { BATCH_ACTION_LIMITS, @@ -72,16 +72,15 @@ const TICKER_IDS = new Map([ const EXISTING_TICKER_IDS = new Set(TICKER_IDS.values()); function loadIndexer(context) { - const roots = [process.env.XCHAIN_INDEXER_PATH, - path.join(__dirname, '../..', '..', '..', '..', 'xchain-indexer')].filter(Boolean); - const root = roots.find((r) => fs.existsSync(path.join(r, 'src', 'actions', 'batch.js'))); - if (!root) return context.skip(); + const resolved = loadIndexerAction('batch'); + if (!resolved) return context.skip(); + const { root, Handler: Batch } = resolved; process.env.INDEXER_COIN = process.env.INDEXER_COIN || 'BTC'; process.env.INDEXER_NETWORK = process.env.INDEXER_NETWORK || 'regtest'; try { return { - Batch: require(path.join(root, 'src', 'actions', 'batch.js')), + Batch, IdxUtility: require(path.join(root, 'src', 'utility.js')), IdxConfig: require(path.join(root, 'src', 'config.js')), ProtocolChanges: require(path.join(root, 'src', 'protocol_changes.js')), diff --git a/test/unit/protocol/batch_limits_conformance.test/04_weight_conformance_arbiter_half.test.js b/test/unit/protocol/batch_limits_conformance.test/04_weight_conformance_arbiter_half.test.js index 3801ce99..a2d575b6 100644 --- a/test/unit/protocol/batch_limits_conformance.test/04_weight_conformance_arbiter_half.test.js +++ b/test/unit/protocol/batch_limits_conformance.test/04_weight_conformance_arbiter_half.test.js @@ -38,8 +38,8 @@ ********************************************************************/ const { expect } = require('chai'); -const fs = require('fs'); const path = require('path'); +const { loadIndexerAction } = require('../../../helpers/indexer_action_handler.js'); const { BATCH_COMMAND_WEIGHTS, @@ -54,16 +54,15 @@ const { } = require('./fixtures/weight_vectors.js'); function loadIndexer(context) { - const roots = [process.env.XCHAIN_INDEXER_PATH, - path.join(__dirname, '../..', '..', '..', '..', 'xchain-indexer')].filter(Boolean); - const root = roots.find((r) => fs.existsSync(path.join(r, 'src', 'actions', 'batch.js'))); - if (!root) return context.skip(); + const resolved = loadIndexerAction('batch'); + if (!resolved) return context.skip(); + const { root, Handler: Batch } = resolved; process.env.INDEXER_COIN = process.env.INDEXER_COIN || 'BTC'; process.env.INDEXER_NETWORK = process.env.INDEXER_NETWORK || 'regtest'; try { return { - Batch: require(path.join(root, 'src', 'actions', 'batch.js')), + Batch, IdxUtility: require(path.join(root, 'src', 'utility.js')), IdxConfig: require(path.join(root, 'src', 'config.js')), ProtocolChanges: require(path.join(root, 'src', 'protocol_changes.js')), From 1cdf119e615c78e0cd1d87f2d413dde090c18634 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 13:50:28 -0700 Subject: [PATCH 05/30] docs(ci): repair the sibling guard references in .ci-siblings Point the coverage citation at test/unit/tooling/sibling_coverage.test.js, which the feature-directory move left stale, and say plainly that ci-dispatch.sh is shared tooling on the pushing host rather than a script in this repo. Record its current fallback ladder: the pushed branch, then origin/master, origin/main, origin/HEAD, then local HEAD. --- .ci-siblings | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.ci-siblings b/.ci-siblings index 92bb3473..695b5433 100644 --- a/.ci-siblings +++ b/.ci-siblings @@ -5,13 +5,16 @@ # resolves with existsSync, calls this.skip(), and the gate still prints PASS, # so the run reports green having exercised none of them. Measured 2026-08-12, # that gap was ~36 guards (a partial CI checkout ran 3444 passing / 40 pending -# where a full local checkout runs 3515 / 4). See test/unit/sibling_coverage.test.js, -# which reports the surface this file leaves uncovered; the same gap applies -# to a sibling that is declared but missing. +# where a full local checkout runs 3515 / 4). See +# test/unit/tooling/sibling_coverage.test.js, which reports the surface this file +# leaves uncovered; the same gap applies to a sibling that is declared but missing. # -# ci-dispatch.sh ships each of these at its origin/master (falling back to local -# HEAD) and REFUSES the push if one is not a checkout beside this repo, so a -# declared sibling can never be silently absent. +# ci-dispatch.sh, the shared pre-push dispatcher (it lives in the CI tooling on +# the pushing host, outside this repo: there is no bin/ci-dispatch.sh here and +# never was), ships each of these at the pushed branch, falling back through +# origin/master, origin/main, origin/HEAD, then local HEAD. It REFUSES the push +# if one is not a checkout beside this repo, so a declared sibling can never be +# silently absent. # Drift gate (§8.5) over the mapped indexer handlers. xchain-indexer From fcf68015379b91e18117d10f628472c4542a5cf6 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 19 Sep 2026 11:01:12 -0700 Subject: [PATCH 06/30] fix(protocol): ship LTC:testnet mirror admission as null under dq4 (a) The armed LTC:testnet producer/consumer heights (4891504/4891766) are already behind the live TLTC tip. Ruling dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train and arms on a later train. Sets both heights to null in this repo's shared_rows_2.js twin, with the same replacement comment used across all six carriers. --- src/consensus/gate_registry/shared_rows_2.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index c7fd765d..b935b053 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -217,7 +217,7 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 153222, // SIZED 2026-09-16 20:41Z: epoch close 153,216 + 6 buried; tip 152,756 + 466 at 498.7 s/blk, about 64.5 h - 'LTC:testnet': 4891504, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 4,889,190 + 2314 at 82.5 s/blk + 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train 'DOGE:testnet': 67911796, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 67,904,912 + 6884 at 27.7 s/blk 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -229,7 +229,7 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 153266, // its producer + 44 blocks, about 6 h: strictly above, never equal - 'LTC:testnet': 4891766, // its producer + 262 blocks, about 6 h at 82.5 s/blk + 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train 'DOGE:testnet': 67912575, // its producer + 779 blocks, about 6 h at 27.7 s/blk 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration From 1e2dd5e025cf32a4b456512b4ca08485f9638eba Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 19 Sep 2026 12:49:07 -0700 Subject: [PATCH 07/30] ci(sdk): publish tagged releases to npm via Trusted Publishing Tag pushes matching v* build and publish both the SDK and the MCP server. Authentication is OIDC (id-token: write), so no npm token is stored in the repository. The version gate fails the run when either package.json disagrees with the tag, which stops a mistagged release before it reaches the registry. --- .github/workflows/publish.yml | 57 +++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..f41bdad5 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,57 @@ +name: Publish to npm + +on: + push: + tags: + - "v*" + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Use Node.js 22 + uses: actions/setup-node@v6 + with: + node-version: "22.22.3" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + + - name: Install an OIDC-capable npm CLI + run: npm install --global "npm@^11.5.1" + + - name: Verify release versions + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + node <<'NODE' + const packages = [ + require('./package.json'), + require('./mcp/package.json'), + ]; + const expectedVersion = process.env.RELEASE_TAG.replace(/^v/, ''); + + for (const pkg of packages) { + if (pkg.version !== expectedVersion) { + throw new Error(`${pkg.name} is ${pkg.version}, expected ${expectedVersion}`); + } + } + NODE + + - name: Install dependencies + run: npm ci + + - name: Test + run: npm test + + - name: Publish SDK + run: npm publish --access public + + - name: Publish MCP server + working-directory: mcp + run: npm publish --access public From 9608a908bc953c97b5b98007e936bdab840938a6 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 19 Sep 2026 12:03:04 -0700 Subject: [PATCH 08/30] fix(consensus): re-slide v0.20.1 BTC and DOGE activation heights Propagate the canonical v0.20.1 patch-train reslide into the sdk'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. --- 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 +++++++++--- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/consensus/gate_registry/shared_rows_1.js b/src/consensus/gate_registry/shared_rows_1.js index bf311be0..742a811c 100644 --- a/src/consensus/gate_registry/shared_rows_1.js +++ b/src/consensus/gate_registry/shared_rows_1.js @@ -295,11 +295,11 @@ addGate('anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S', 'constant', 6 // that window. addGate('anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION', 'height', { mainnet: null, // INERT under the 2026-08-29 mainnet write hold - // SIZED 2026-09-16 20:41Z, on the BTC clock because this member is BTC-only: the same - // instant as the family's BTC CONSUMER height, so the one member that keeps BOTH - // certificates gains them together rather than carrying a lone extra rule for 6 h. - // Above the same roll and the same epoch close; the canon carries the measurement. - testnet: 153266, + // SIZED 2026-09-16 20:41Z, RE-SLID 2026-09-19, on the BTC clock because this member is + // BTC-only: the same instant as the family's BTC CONSUMER height, so the one member that + // keeps BOTH certificates gains them together rather than carrying a lone extra rule for + // 6 h. The canon carries the measurement. + testnet: 153328, regtest: UNPINNED, // shares the family's arming seam so one venue lever arms both }); diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index b935b053..c245769c 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -216,9 +216,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 153222, // SIZED 2026-09-16 20:41Z: epoch close 153,216 + 6 buried; tip 152,756 + 466 at 498.7 s/blk, about 64.5 h + 'BTC:testnet': 153300, // RE-SLID 2026-09-19: train 153,221 + 79 blocks (17 h at 781.078553 s/blk), the v0.20.1 patch reslide 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67911796, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 67,904,912 + 6884 at 27.7 s/blk + 'DOGE:testnet': 67916857, // RE-SLID 2026-09-19: tip 67,911,061 + 5796 blocks (41 h at 25.469118 s/blk, the same instant as the BTC producer), the v0.20.1 patch reslide 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -228,9 +228,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 153266, // its producer + 44 blocks, about 6 h: strictly above, never equal + 'BTC:testnet': 153328, // RE-SLID 2026-09-19: its producer + 28 blocks (6 h at 781.078553 s/blk), strictly above, never equal 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67912575, // its producer + 779 blocks, about 6 h at 27.7 s/blk + 'DOGE:testnet': 67917706, // RE-SLID 2026-09-19: its producer + 849 blocks (6 h at 25.469118 s/blk) 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration diff --git a/src/consensus/gate_registry/shared_rows_5.js b/src/consensus/gate_registry/shared_rows_5.js index cbf28976..291fc4fe 100644 --- a/src/consensus/gate_registry/shared_rows_5.js +++ b/src/consensus/gate_registry/shared_rows_5.js @@ -108,9 +108,15 @@ addGate('train_activation.TRAIN_ACTIVATION', 'ruleset', { // the LTC leg of this family two days off its BTC counterpart a day after the first cut. // That lead is the rolling-upgrade window the fleet roll must finish inside (24x the 90 // minute roll budget), and every testnet mirror-admission height sits above it on the same - // BTC clock (the BTC producer at 153,222 is 106 blocks and about 17.0 h further up), so a - // node lacking this rule set halts before it can grade an admission-stamped row. - '0.20.0': { mainnet: 9999999999, testnet: 153116, regtest: 0 }, + // BTC clock, so a node lacking this rule set halts before it can grade an admission-stamped + // row. + // RE-SLID 2026-09-19 for the v0.20.1 patch train: margin is 24 h measured from the freeze, + // converted at each coin's own measured cadence, not five days. Chain_tip TBTC 153,110 + 111 + // blocks, ceil(24 h / 781.078553 s per block, least-squares bound over the trailing 114-block + // window, about 25.2 h span, at least as long as the lead). The mirror-admission family + // below re-slides onto the same instant plus its own 17 h and 6 h offsets. LTC stays null + // under dq4 (a) and is untouched by this reslide. + '0.20.0': { mainnet: 9999999999, testnet: 153221, regtest: 0 }, }); // xchain_bridge_activation From 244a70dd1933d296317f0ae0d2681a473b1d5c20 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 19 Sep 2026 13:05:26 -0700 Subject: [PATCH 09/30] fix(sdk): forward caller encoder options through the bridge workflows All five bridge recipes (bridgeLock, bridgeBurn, bridgeTokenLock, bridgeTokenBurn, setTokenBridgeability) passed a literal empty object as WalletSession.submit's encoder argument and handed the caller's whole opts object to the submission slot instead. A caller asking for a feePerKb or for unconfirmed inputs was silently ignored and the transaction went out at the default fee rate, with no error to say so. A submitBridge helper now splits opts.encoder out and forwards it as the encoder argument, leaving the remaining keys as submission options. An opts with no encoder key still yields the same empty object every recipe passed before, so no existing caller changes behaviour. --- src/actions/workflows/bridge.js | 44 ++++++++++++---- test/unit/actions/bridge_sdk.test.js | 76 ++++++++++++++++++++++++---- 2 files changed, 98 insertions(+), 22 deletions(-) diff --git a/src/actions/workflows/bridge.js b/src/actions/workflows/bridge.js index 8f54adbc..0d49614c 100644 --- a/src/actions/workflows/bridge.js +++ b/src/actions/workflows/bridge.js @@ -24,6 +24,23 @@ const Utility = require('../../utils/utility.js'); const coins = require('../../coins'); const Config = require('../../config.js'); +// Submit a bridge recipe, routing each half of the caller's opts to the layer +// that reads it. WalletSession.submit takes encoder options and submission +// options as SEPARATE arguments, so a recipe that hands its whole opts object +// to the third slot silently drops a feePerKb or an unconfirmed-input request: +// the encoder never sees them and the fee is whatever the default says. The +// `encoder` sub-object is lifted out and passed as the encoder argument; a +// caller who supplies no `encoder` key gets `{}` there, which is exactly the +// literal every recipe passed before, so nothing existing changes behaviour. +function submitBridge(workflows, wif, actionData, opts) { + let submitOpts = { ...opts }; + let encoderOpts = submitOpts.encoder || {}; + delete submitOpts.encoder; + + let session = workflows.sdk.session(wif, submitOpts); + return session.submit(actionData, encoderOpts, submitOpts); +} + module.exports = { // Open a market and immediately place the oracle's own opening context in a // single recipe is deliberately NOT offered: the oracle may not bet on its @@ -80,29 +97,32 @@ module.exports = { // // wif - WIF private key of the holder // params - { destCoin, destAddress, amount, memo } - // opts - submit options (network, waitForIndexer, timeout, ...) + // opts - submit options (network, waitForIndexer, timeout, maxFeeSats, ...) + // plus encoder: { feePerKb, unconfirmed, ... } // // Returns: async bridgeLock(wif, params, opts = {}) { let fields = this.bridgeParams(params); this.assertBridgeAddress('DEST_ADDRESS', fields.DEST_ADDRESS, fields.DEST_COIN, this.bridgeNetwork(opts)); - let session = this.sdk.session(wif, opts); - return session.submit({ action: 'XBRIDGE', params: Utility.withForcedVersion('0', fields) }, {}, opts); + return submitBridge(this, wif, + { action: 'XBRIDGE', params: Utility.withForcedVersion('0', fields) }, opts); }, // Burn bridged XCHAIN off BTC to release the BTC escrow (XBRIDGE v1, never on BTC). // // params - { btcAddress, amount, memo } + // opts - as bridgeLock, including encoder: { feePerKb, unconfirmed, ... } async bridgeBurn(wif, params, opts = {}) { let fields = this.bridgeParams(params); this.assertBridgeAddress('BTC_ADDRESS', fields.BTC_ADDRESS, 'BTC', this.bridgeNetwork(opts)); - let session = this.sdk.session(wif, opts); - return session.submit({ action: 'XBRIDGE', params: Utility.withForcedVersion('1', fields) }, {}, opts); + return submitBridge(this, wif, + { action: 'XBRIDGE', params: Utility.withForcedVersion('1', fields) }, opts); }, // Lock a general token on its origin chain (XBRIDGE v3). // // params - { tick, destCoin, destAddress, amount, memo } + // opts - as bridgeLock, including encoder: { feePerKb, unconfirmed, ... } // // TICK is the NATIVE name on this chain. A rooted name (BTC.FUFU) is the // bridged copy and is refused here rather than on chain, because v3 of a @@ -117,13 +137,14 @@ module.exports = { if (tick.indexOf('.') !== -1) throw new Error('XBRIDGE v3 cannot bridge "' + tick + '": a dotted tick is a subasset or a bridged copy, and neither is bridgeable in this milestone'); this.assertBridgeAddress('DEST_ADDRESS', fields.DEST_ADDRESS, fields.DEST_COIN, this.bridgeNetwork(opts)); - let session = this.sdk.session(wif, opts); - return session.submit({ action: 'XBRIDGE', params: Utility.withForcedVersion('3', fields) }, {}, opts); + return submitBridge(this, wif, + { action: 'XBRIDGE', params: Utility.withForcedVersion('3', fields) }, opts); }, // Burn a bridged token row back to its origin chain (XBRIDGE v4). // // params - { tick, originAddress, amount, memo } + // opts - as bridgeLock, including encoder: { feePerKb, unconfirmed, ... } // // The origin chain is the tick's own root (BTC.FUFU burns back to BTC), so // ORIGIN_ADDRESS is validated against THAT chain and never against the chain @@ -135,13 +156,14 @@ module.exports = { if (!parsed) throw new Error('XBRIDGE v4 needs a bridged tick of the form .; got "' + String(fields.TICK) + '"'); this.assertBridgeAddress('ORIGIN_ADDRESS', fields.ORIGIN_ADDRESS, parsed.origin, this.bridgeNetwork(opts)); - let session = this.sdk.session(wif, opts); - return session.submit({ action: 'XBRIDGE', params: Utility.withForcedVersion('4', fields) }, {}, opts); + return submitBridge(this, wif, + { action: 'XBRIDGE', params: Utility.withForcedVersion('4', fields) }, opts); }, // Set a token's bridgeability (ISSUE format 7). Owner only, no issuance fee. // // params - { tick, bridgeChains, minDepth, lockBridge, memo } + // opts - as bridgeLock, including encoder: { feePerKb, unconfirmed, ... } // // bridgeChains is a comma list of destination coins, or the '-' sentinel for // none. An EMPTY field means UNCHANGED on chain, so an empty string is refused @@ -159,8 +181,8 @@ module.exports = { throw new Error('ISSUE v7 BRIDGE_CHAINS names an unsupported coin: ' + chain); fields.BRIDGE_CHAINS = chains.map(c => c.toUpperCase()).join(','); } - let session = this.sdk.session(wif, opts); - return session.submit({ action: 'ISSUE', params: Utility.withForcedVersion('7', fields) }, {}, opts); + return submitBridge(this, wif, + { action: 'ISSUE', params: Utility.withForcedVersion('7', fields) }, opts); }, // Throw unless the chunked deploy's Phase-2 assembler fits the compiled-action diff --git a/test/unit/actions/bridge_sdk.test.js b/test/unit/actions/bridge_sdk.test.js index ec9cea20..d2115129 100644 --- a/test/unit/actions/bridge_sdk.test.js +++ b/test/unit/actions/bridge_sdk.test.js @@ -65,7 +65,10 @@ function stubWorkflows(network){ options: { network: network }, util: new Utility(), session: () => ({ - submit: async (actionData) => { calls.push(actionData); return { submitted: true }; } + submit: async (actionData, encoderOpts, submitOpts) => { + calls.push({ actionData, encoderOpts, submitOpts }); + return { submitted: true }; + } }) }; return { flows: new Workflows(sdk), calls: calls }; @@ -231,9 +234,9 @@ describe('bridge: workflow recipes', () => { let { flows, calls } = stubWorkflows('regtest'); await flows.bridgeLock('wif', { destCoin: 'BTC', destAddress: ADDR.BTC_REG_P2PKH, amount: '5', memo: 'go' }); assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0].action, 'XBRIDGE'); - assert.strictEqual(calls[0].params.VERSION, '0'); - assert.strictEqual(calls[0].params.DEST_ADDRESS, ADDR.BTC_REG_P2PKH); + assert.strictEqual(calls[0].actionData.action, 'XBRIDGE'); + assert.strictEqual(calls[0].actionData.params.VERSION, '0'); + assert.strictEqual(calls[0].actionData.params.DEST_ADDRESS, ADDR.BTC_REG_P2PKH); }); it('bridgeLock refuses a destination that is not valid on the destination chain', async () => { @@ -247,7 +250,7 @@ describe('bridge: workflow recipes', () => { it('bridgeBurn validates against BTC whatever chain it is broadcast on, and pins v1', async () => { let { flows, calls } = stubWorkflows('regtest'); await flows.bridgeBurn('wif', { btcAddress: ADDR.BTC_REG_P2PKH, amount: '2' }); - assert.strictEqual(calls[0].params.VERSION, '1'); + assert.strictEqual(calls[0].actionData.params.VERSION, '1'); await assert.rejects( () => flows.bridgeBurn('wif', { btcAddress: ADDR.DOGE_MAIN_P2PKH, amount: '2' }), /BTC_ADDRESS .* not a valid BTC regtest address/); @@ -256,7 +259,7 @@ describe('bridge: workflow recipes', () => { it('bridgeTokenLock pins v3 and refuses a dotted or missing tick', async () => { let { flows, calls } = stubWorkflows('regtest'); await flows.bridgeTokenLock('wif', { tick: 'FUFU', destCoin: 'BTC', destAddress: ADDR.BTC_REG_P2PKH, amount: '5' }); - assert.strictEqual(calls[0].params.VERSION, '3'); + assert.strictEqual(calls[0].actionData.params.VERSION, '3'); await assert.rejects(() => flows.bridgeTokenLock('wif', { tick: 'BTC.FUFU', destCoin: 'BTC', destAddress: ADDR.BTC_REG_P2PKH, amount: '5' }), /dotted tick/); await assert.rejects(() => flows.bridgeTokenLock('wif', { destCoin: 'BTC', destAddress: ADDR.BTC_REG_P2PKH, amount: '5' }), /requires a TICK/); }); @@ -264,7 +267,7 @@ describe('bridge: workflow recipes', () => { it('bridgeTokenBurn reads the origin chain out of the tick', async () => { let { flows, calls } = stubWorkflows('regtest'); await flows.bridgeTokenBurn('wif', { tick: 'BTC.FUFU', originAddress: ADDR.BTC_REG_P2PKH, amount: '2' }); - assert.strictEqual(calls[0].params.VERSION, '4'); + assert.strictEqual(calls[0].actionData.params.VERSION, '4'); // A BTC-shaped address is right for BTC.FUFU and wrong for DOGE.WOW: the // chain the address is checked against comes from the tick, not the caller. await assert.rejects( @@ -277,14 +280,65 @@ describe('bridge: workflow recipes', () => { }); +describe('bridge: workflow encoder options', () => { + + // A recipe that swallowed opts.encoder produced a transaction at the default + // fee rate no matter what the caller asked for, which is why the credited-lock + // recipe could not be driven at a chosen feePerKb. + it('forwards encoder and submission controls to their intended layers for every recipe', async () => { + let cases = [ + ['bridgeLock', { destCoin: 'BTC', destAddress: ADDR.BTC_REG_P2PKH, amount: '5' }], + ['bridgeBurn', { btcAddress: ADDR.BTC_REG_P2PKH, amount: '2' }], + ['bridgeTokenLock', { tick: 'FUFU', destCoin: 'BTC', destAddress: ADDR.BTC_REG_P2PKH, amount: '5' }], + ['bridgeTokenBurn', { tick: 'BTC.FUFU', originAddress: ADDR.BTC_REG_P2PKH, amount: '2' }], + ['setTokenBridgeability', { tick: 'FUFU', bridgeChains: 'DOGE' }] + ]; + + for (let [method, params] of cases) { + let { flows, calls } = stubWorkflows('regtest'); + await flows[method]('wif', params, { + network: 'regtest', + encoder: { feePerKb: 2000, unconfirmed: false }, + waitForIndexer: false, + maxFeeSats: 12000 + }); + + assert.strictEqual(calls.length, 1, method + ' must submit exactly once'); + assert.deepStrictEqual(calls[0].encoderOpts, + { feePerKb: 2000, unconfirmed: false }, method + ' encoder options'); + assert.deepStrictEqual(calls[0].submitOpts, { + network: 'regtest', + waitForIndexer: false, + maxFeeSats: 12000 + }, method + ' submission options'); + } + }); + + // The backward-compatibility case: an opts with no `encoder` key must reach + // the encoder as the same empty object every recipe passed before, and the + // encoder-shaped keys a caller misplaced at the top level stay where they are + // rather than being promoted into encoder input behind their back. + it('does not treat an encoder control placed only in submission options as encoder input', async () => { + let { flows, calls } = stubWorkflows('regtest'); + await flows.bridgeLock('wif', + { destCoin: 'BTC', destAddress: ADDR.BTC_REG_P2PKH, amount: '5' }, + { feePerKb: 9000, unconfirmed: true, maxFeeSats: 12000 }); + + assert.deepStrictEqual(calls[0].encoderOpts, {}); + assert.deepStrictEqual(calls[0].submitOpts, + { feePerKb: 9000, unconfirmed: true, maxFeeSats: 12000 }); + }); + +}); + describe('bridge: workflow recipes', () => { it('setTokenBridgeability pins ISSUE v7, upper-cases the chain list and refuses an unknown coin', async () => { let { flows, calls } = stubWorkflows('regtest'); await flows.setTokenBridgeability('wif', { tick: 'FUFU', bridgeChains: 'doge, ltc', minDepth: '3' }); - assert.strictEqual(calls[0].action, 'ISSUE'); - assert.strictEqual(calls[0].params.VERSION, '7'); - assert.strictEqual(calls[0].params.BRIDGE_CHAINS, 'DOGE,LTC'); + assert.strictEqual(calls[0].actionData.action, 'ISSUE'); + assert.strictEqual(calls[0].actionData.params.VERSION, '7'); + assert.strictEqual(calls[0].actionData.params.BRIDGE_CHAINS, 'DOGE,LTC'); await assert.rejects(() => flows.setTokenBridgeability('wif', { tick: 'FUFU', bridgeChains: 'XMR' }), /unsupported coin/); // '' means UNCHANGED on chain, so it can never be the way to clear a list. await assert.rejects(() => flows.setTokenBridgeability('wif', { tick: 'FUFU', bridgeChains: '' }), /comma list of coins or the "-" sentinel/); @@ -293,7 +347,7 @@ describe('bridge: workflow recipes', () => { it('setTokenBridgeability passes the "-" sentinel through untouched', async () => { let { flows, calls } = stubWorkflows('regtest'); await flows.setTokenBridgeability('wif', { tick: 'FUFU', bridgeChains: '-' }); - assert.strictEqual(calls[0].params.BRIDGE_CHAINS, '-'); + assert.strictEqual(calls[0].actionData.params.BRIDGE_CHAINS, '-'); }); it('refuses a caller-supplied VERSION that disagrees with the recipe', async () => { From 43368d9f2e4834b58242e84edcf94c4b075f408b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 11:14:53 -0700 Subject: [PATCH 10/30] fix(drift-gate): hash companion handler parts --- bin/check-preflight-drift.js | 3 +- bin/preflight_handler_dirs.js | 82 ++++++++++++++++++++++++++++++----- 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/bin/check-preflight-drift.js b/bin/check-preflight-drift.js index ddf5c33d..8246c4a4 100755 --- a/bin/check-preflight-drift.js +++ b/bin/check-preflight-drift.js @@ -71,7 +71,8 @@ const { indexerRootCandidates, describeIndexerCandidates, resolveIndexerRoot } = /* The table rows, each tagged 'file', 'directory' or 'malformed' by the shape of the * handler path it names. A directory row carries a trailing slash and is hashed over every - * file in the directory, so a handler split into parts cannot leave them unhashed. */ + * file in the directory. A file row also covers a same-named directory beside its entry, + * so either supported handler split shape includes every part in its pin. */ function parseMap(mapPath) { return handlerDirs.parseMapRows(fs.readFileSync(mapPath, 'utf8')); } diff --git a/bin/preflight_handler_dirs.js b/bin/preflight_handler_dirs.js index ea939b6c..1a7a4e77 100755 --- a/bin/preflight_handler_dirs.js +++ b/bin/preflight_handler_dirs.js @@ -19,7 +19,9 @@ * * - a map row may name a DIRECTORY (`src/actions//`, trailing slash), hashed over * every file in it, recursively, so editing, adding or removing any part moves the pin; - * - the fee walk reads every source file of a directory handler, not only index.js; + * - a FILE row also includes a same-named companion directory, since another valid split + * leaves `.js` as the entry and moves its imported logic into `/`; + * - the fee walk reads every source file owned by either split shape, not only its entry; * - a fee-quote literal is found exactly once across the indexer's src/, so moving it * out of the loader into a part neither loses it nor lets a second copy go unseen. * @@ -111,14 +113,44 @@ function hashDirectory(dirAbs) { return { digest: sha256(manifest), parts }; } +// A flat entry may keep its original path while its implementation moves into a directory +// beside it. In that shape the row still names the entry, but its pin must commit to both +// the entry and every companion part. Names are relative to src/actions/ so the manifest +// distinguishes the entry from its tree and detects part renames as well as byte changes. +function hashFileWithCompanionParts(fileAbs) { + const partsDir = fileAbs.replace(/\.js$/, ''); + let partsStat; + try { + partsStat = fs.lstatSync(partsDir); + } catch (e) { + if (e && e.code === 'ENOENT') return { digest: sha256(fs.readFileSync(fileAbs)) }; + throw e; + } + if (partsStat.isSymbolicLink()) { + throw new Error(`drift-gate: companion ${partsDir} is a symbolic link; handler parts must be in a plain directory.`); + } + if (!partsStat.isDirectory()) return { digest: sha256(fs.readFileSync(fileAbs)) }; + + const entryName = path.basename(fileAbs); + const partsName = path.basename(partsDir); + const parts = [{ name: entryName, sha256: sha256(fs.readFileSync(fileAbs)) }]; + for (const name of listParts(partsDir)) { + parts.push({ name: `${partsName}/${name}`, sha256: sha256(fs.readFileSync(path.join(partsDir, name))) }); + } + parts.sort((a, b) => Buffer.compare(Buffer.from(a.name, 'utf8'), Buffer.from(b.name, 'utf8'))); + const manifest = parts.map((p) => `${p.sha256} ${p.name}\n`).join(''); + return { digest: sha256(manifest), parts }; +} + function isDirectory(abs) { try { return fs.lstatSync(abs).isDirectory(); } catch (e) { return false; } } // Hash one mapped row against the checkout. Returns { actual, parts? } or { problem }. -// A flat row is hashed as the file's bytes, which is what every pin in the table already -// records, so directory support leaves the shipped map valid with no re-pin. +// A flat row with no companion directory is hashed as the file's bytes, preserving legacy +// pins. When a companion directory exists, the entry and its parts form one manifest and +// the row needs the deliberate re-pin that closes the old blind spot. // The problems a directory split introduces are named one by one below rather than folded // into a hash mismatch, because each has its own fix. @@ -129,7 +161,14 @@ function hashMappedRow(indexerRoot, row) { return { problem: `${row.handler}: not a mappable handler path; a row names src/actions/.js or the directory src/actions//` }; } if (row.kind === 'file') { - if (fs.existsSync(abs)) return { actual: sha256(fs.readFileSync(abs)) }; + if (fs.existsSync(abs)) { + try { + const { digest, parts } = hashFileWithCompanionParts(abs); + return { actual: digest, parts }; + } catch (e) { + return { problem: `${row.handler}: ${e && e.message ? e.message : String(e)}` }; + } + } // A flat row whose handler became a directory: the fix is a re-pin as a directory row. const dir = abs.replace(/\.js$/, ''); if (isDirectory(dir)) { @@ -144,7 +183,7 @@ function hashMappedRow(indexerRoot, row) { // require('./') resolves a flat .js ahead of /index.js, so with both // present the directory this would hash is not the code that runs. if (fs.existsSync(flat)) { - return { problem: `${row.handler}: a flat ${row.handler.replace(/\/$/, '.js')} beside it is what require() resolves first, so the directory is not the code that runs` }; + return { problem: `${row.handler}: a flat ${row.handler.replace(/\/$/, '.js')} beside it is what require() resolves first; map that entry file so its pin includes this companion parts directory` }; } try { const { digest, parts } = hashDirectory(abs); @@ -181,8 +220,8 @@ function stripCommentsAndStrings(src) { } // The handlers the fee walk reads, each with every source file that belongs to it. A -// handler is either src/actions/.js or, since the M3 feature-directory pass, -// src/actions//index.js. +// handler is src/actions/.js, optionally with parts in src/actions//, or a +// directory handler whose entry is src/actions//index.js. // A flat readdir of *.js alone silently drops every directory handler, and a dropped // handler reads as "charges no fee" rather than as a broken walk, so both shapes are @@ -196,11 +235,18 @@ function stripCommentsAndStrings(src) { // as a flat readdir drops a directory. function feeWalkHandlers(actionsDir, entries) { const handlers = []; + const flatNames = new Set(entries + .filter((e) => e.isFile() && e.name.endsWith('.js') && e.name !== 'index.js') + .map((e) => path.basename(e.name, '.js'))); for (const e of entries) { if (e.isDirectory()) { const dirAbs = path.join(actionsDir, e.name); const files = listParts(dirAbs, { allowEmpty: true }).filter((n) => JS_SOURCE.test(n)).map((n) => path.join(dirAbs, n)); - if (fs.existsSync(path.join(dirAbs, 'index.js'))) { + // A same-named flat entry owns this parts tree. It is added with the entry in + // the file branch below, including any index.js the tree happens to contain. + if (flatNames.has(e.name)) { + continue; + } else if (fs.existsSync(path.join(dirAbs, 'index.js'))) { handlers.push({ action: e.name, files }); // A directory with no index.js is not a handler, but if one of its files charges // a fee the walk cannot say for which action, so that fails CLOSED here rather @@ -210,7 +256,23 @@ function feeWalkHandlers(actionsDir, entries) { + 'calls createFeesObject. The fee walk cannot name the action that charges; fix the layout or the walk.'); } } else if (e.isFile() && e.name.endsWith('.js') && e.name !== 'index.js') { - handlers.push({ action: path.basename(e.name, '.js'), files: [path.join(actionsDir, e.name)] }); + const action = path.basename(e.name, '.js'); + const entry = path.join(actionsDir, e.name); + const partsDir = path.join(actionsDir, action); + const files = [entry]; + let partsStat; + try { partsStat = fs.lstatSync(partsDir); } catch (err) { + if (!err || err.code !== 'ENOENT') throw err; + } + if (partsStat && partsStat.isSymbolicLink()) { + throw new Error(`drift-gate: companion src/actions/${action}/ is a symbolic link; handler parts must be in a plain directory.`); + } + if (partsStat && partsStat.isDirectory()) { + files.push(...listParts(partsDir, { allowEmpty: true }) + .filter((n) => JS_SOURCE.test(n)) + .map((n) => path.join(partsDir, n))); + } + handlers.push({ action, files }); } } return handlers; @@ -290,6 +352,6 @@ function main(argv) { if (require.main === module) process.exit(main(process.argv.slice(2))); module.exports = { - parseMapRows, listParts, hashDirectory, hashMappedRow, compareRows, formatDrift, + parseMapRows, listParts, hashDirectory, hashFileWithCompanionParts, hashMappedRow, compareRows, formatDrift, stripCommentsAndStrings, feeWalkHandlers, declarationPattern, indexerLiteralReader, main, }; From 43c77f65b7429f52330f60f18b7fe70e2467b1c4 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 16:00:20 -0700 Subject: [PATCH 11/30] fix(hub): use public registry for keyless discovery --- src/XChainSDK/client_lifecycle.js | 2 +- src/clients/hub.js | 2 +- src/clients/hub/config_sync.js | 73 ++++++++++ .../hub.test/06_public_discovery.test.js | 135 ++++++++++++++++++ 4 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 test/unit/clients/hub.test/06_public_discovery.test.js diff --git a/src/XChainSDK/client_lifecycle.js b/src/XChainSDK/client_lifecycle.js index 7e95eeb5..b445dbcc 100644 --- a/src/XChainSDK/client_lifecycle.js +++ b/src/XChainSDK/client_lifecycle.js @@ -137,7 +137,7 @@ module.exports = { discover() { if (this._discovering) return this._discovering; this._discovering = (async () => { - await this.hub.getAllConfig(); + await this.hub.getDiscoveryConfig(); this.applyEndpoints(); this.startPollingOnce(); })(); diff --git a/src/clients/hub.js b/src/clients/hub.js index 28c64f61..c791fdb9 100644 --- a/src/clients/hub.js +++ b/src/clients/hub.js @@ -192,7 +192,7 @@ class HubConnector { if (this._pollTimer) return; this._pollTimer = setInterval(async () => { try { - await this.getAllConfig(); + await this.getDiscoveryConfig(); if (callback) callback(this.configs); } catch (err) { // Silently continue: hub unavailability during polling is non-fatal diff --git a/src/clients/hub/config_sync.js b/src/clients/hub/config_sync.js index db035db2..046bb147 100644 --- a/src/clients/hub/config_sync.js +++ b/src/clients/hub/config_sync.js @@ -31,6 +31,41 @@ const { const { getLogger } = require('../../observability/logger.js'); const log = getLogger('xchain-sdk:hub'); +const CHAIN_REGISTRY_PATH = '/api/v1/chain-registry'; + +function normalizeServiceDefaults(descriptor, service) { + let defaults = descriptor[service]; + if (!defaults || typeof defaults !== 'object' || + typeof defaults.defaultUrl !== 'string' || !defaults.defaultUrl) + throw new Error('chain registry descriptor ' + descriptor.id + ' has no ' + service + ' default URL'); + + let normalized = { host: defaults.defaultUrl }; + if (defaults.defaultPort !== undefined && defaults.defaultPort !== null) + normalized.port = defaults.defaultPort; + return normalized; +} + +function normalizeDiscoveryDescriptors(descriptors) { + if (!Array.isArray(descriptors)) throw new Error('chain registry response has no descriptors array'); + + let configs = {}; + for (const descriptor of descriptors) { + if (!descriptor || typeof descriptor !== 'object' || + typeof descriptor.coin !== 'string' || !descriptor.coin || + typeof descriptor.networkKind !== 'string' || !descriptor.networkKind) + throw new Error('chain registry contains an invalid descriptor'); + + let coin = descriptor.coin; + let network = descriptor.networkKind; + if (!configs[coin]) configs[coin] = {}; + configs[coin][network] = { + 'xchain-explorer': normalizeServiceDefaults(descriptor, 'explorer'), + 'xchain-encoder': normalizeServiceDefaults(descriptor, 'encoder') + }; + } + return configs; +} + async function fetchConfigResult(connector, url, cursorValid, sinceCursor, headers) { let result = await connector.postGetAllConfigs(url, sinceCursor, headers); if (result === undefined) return undefined; @@ -71,6 +106,44 @@ async function fetchConfigResult(connector, url, cursorValid, sinceCursor, heade } module.exports = { + // Public SDKs do not have the key required by getallconfigs. Discover from + // the public chain registry in that case, while keyed mesh clients retain + // the full JSON-RPC config path and its incremental polling behavior. + async getDiscoveryConfig() { + if (this.apiKey) return this.getAllConfig(); + + let lastError = null; + for (let i = 0; i < this.urls.length; i++) { + let idx = (this._lastGoodIdx + i) % this.urls.length; + let url = this.urls[idx]; + try { + // Hub URLs can carry a coin routing path such as /TBTC. The + // registry is mounted once at the origin, outside that route. + let registryUrl = new URL(CHAIN_REGISTRY_PATH, url).toString(); + let response = await axios.get(registryUrl, { + timeout: this.timeout, + ...agentOptsFor(registryUrl, this._pool) + }); + let configs = normalizeDiscoveryDescriptors(response.data && response.data.descriptors); + this._lastGoodIdx = idx; + this.configs = configs; + this.lastSeq = 0; + this.lastWatermark = 0; + this._watermarkEndpointIdx = null; + this.lastFetch = Date.now(); + return this.configs; + } catch (err) { + lastError = err; + } + } + + throw new SDKHubError( + 'HUB_UNAVAILABLE', + 'Failed to fetch discovery config from hub (tried ' + this.urls.length + ' endpoint(s)): ' + (lastError ? lastError.message : 'no result'), + { urls: this.urls, error: lastError ? lastError.message : 'no result' } + ); + }, + // Fetch all configs from the hub via JSON-RPC (tries each endpoint in order) async getAllConfig() { let lastError = null; diff --git a/test/unit/clients/hub.test/06_public_discovery.test.js b/test/unit/clients/hub.test/06_public_discovery.test.js new file mode 100644 index 00000000..4d324c1d --- /dev/null +++ b/test/unit/clients/hub.test/06_public_discovery.test.js @@ -0,0 +1,135 @@ +// 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'; + +const assert = require('assert'); +const nock = require('nock'); +const HubConnector = require('../../../../src/clients/hub.js'); +const XChainSDK = require('../../../../src/XChainSDK.js'); + +const HUB_BASE = 'http://localhost:10000'; +const REGISTRY_PATH = '/api/v1/chain-registry'; + +function descriptor(overrides = {}) { + return Object.assign({ + id: 'bitcoin-mainnet', + coin: 'bitcoin', + networkKind: 'mainnet', + explorer: { defaultUrl: 'https://discovered-explorer.test', defaultPort: 8443 }, + encoder: { defaultUrl: 'https://discovered-encoder.test/BTC', defaultPort: 9443 } + }, overrides); +} + +describe('public hub discovery', function () { + let savedHubApiKey; + + beforeEach(function () { + savedHubApiKey = process.env.HUB_API_KEY; + delete process.env.HUB_API_KEY; + }); + + afterEach(function () { + nock.cleanAll(); + if (savedHubApiKey === undefined) delete process.env.HUB_API_KEY; + else process.env.HUB_API_KEY = savedHubApiKey; + }); + + it('uses public GET discovery, updates clients and getHubConfig(), then polls the same public route', async function () { + let gets = 0; + let sawPoll; + let polled = new Promise(resolve => { sawPoll = resolve; }); + nock(HUB_BASE) + .get(REGISTRY_PATH) + .twice() + .reply(200, () => { + gets++; + if (gets === 2) sawPoll(); + return { schema_version: 1, descriptors: [descriptor()] }; + }); + let forbiddenPost = nock(HUB_BASE) + .post('/BTC', body => body && body.method === 'getallconfigs') + .reply(500, { error: 'public clients must not call getallconfigs' }); + + let sdk = new XChainSDK({ + network: 'bitcoin-mainnet', + hubUrl: HUB_BASE + '/BTC', + hubPollInterval: 10, + retry: false + }); + + try { + await sdk.discover(); + assert.strictEqual(sdk.explorer.baseUrl, 'https://discovered-explorer.test'); + assert.strictEqual(sdk.explorer.port, 8443); + assert.strictEqual(sdk.encoder.baseUrl, 'https://discovered-encoder.test/BTC'); + assert.strictEqual(sdk.encoder.port, 9443); + assert.deepStrictEqual(sdk.getHubConfig(), { + bitcoin: { + mainnet: { + 'xchain-explorer': { host: 'https://discovered-explorer.test', port: 8443 }, + 'xchain-encoder': { host: 'https://discovered-encoder.test/BTC', port: 9443 } + } + } + }); + assert.strictEqual(sdk._polling, true); + assert.ok(sdk.hub._pollTimer, 'discovery must reach polling'); + await Promise.race([ + polled, + new Promise((resolve, reject) => setTimeout(() => reject(new Error('poll did not use public discovery')), 500)) + ]); + assert.strictEqual(gets, 2); + assert.strictEqual(forbiddenPost.isDone(), false, 'keyless discovery must never POST getallconfigs'); + } finally { + sdk.stop(); + } + }); + + it('fails over between hub origins and reports HUB_UNAVAILABLE when all public origins fail', async function () { + nock(HUB_BASE).get(REGISTRY_PATH).replyWithError('first hub unavailable'); + nock('http://hub2.test:8001').get(REGISTRY_PATH) + .reply(200, { descriptors: [descriptor()] }); + + let hub = new HubConnector({ + hubValidators: [HUB_BASE + '/BTC', 'http://hub2.test:8001/TBTC'] + }); + let configs = await hub.getDiscoveryConfig(); + assert.ok(configs.bitcoin.mainnet); + assert.strictEqual(hub._lastGoodIdx, 1); + + nock('http://bad-one.test').get(REGISTRY_PATH).replyWithError('offline'); + nock('http://bad-two.test').get(REGISTRY_PATH).reply(200, { descriptors: null }); + let unavailable = new HubConnector({ + hubValidators: ['http://bad-one.test/BTC', 'http://bad-two.test/TBTC'] + }); + await assert.rejects( + unavailable.getDiscoveryConfig(), + err => err.name === 'SDKHubError' && err.code === 'HUB_UNAVAILABLE' && /2 endpoint/.test(err.message) + ); + }); + + it('retains keyed getallconfigs JSON-RPC discovery', async function () { + let publicGet = nock(HUB_BASE).get(REGISTRY_PATH) + .reply(500, { error: 'keyed clients should retain JSON-RPC' }); + let rpc = nock(HUB_BASE, { reqheaders: { 'x-api-key': 'operator-key' } }) + .post('/', body => body.method === 'getallconfigs' && body.params.since_updated_at === 0) + .reply(200, { + jsonrpc: '2.0', + result: { bitcoin: { mainnet: { 'xchain-encoder': { host: 'mesh-encoder.test' } } } }, + id: 1 + }); + + let hub = new HubConnector({ hubUrl: HUB_BASE, hubApiKey: 'operator-key' }); + let configs = await hub.getDiscoveryConfig(); + assert.strictEqual(configs.bitcoin.mainnet['xchain-encoder'].host, 'mesh-encoder.test'); + assert.strictEqual(rpc.isDone(), true); + assert.strictEqual(publicGet.isDone(), false); + }); +}); From 1a7fc1003e98bfd13328e1045937baa2f93df395 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 20 Sep 2026 20:39:18 -0700 Subject: [PATCH 12/30] Refactor public discovery test suite --- .../hub.test/06_public_discovery.test.js | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/test/unit/clients/hub.test/06_public_discovery.test.js b/test/unit/clients/hub.test/06_public_discovery.test.js index 4d324c1d..d5f86df2 100644 --- a/test/unit/clients/hub.test/06_public_discovery.test.js +++ b/test/unit/clients/hub.test/06_public_discovery.test.js @@ -17,6 +17,7 @@ const XChainSDK = require('../../../../src/XChainSDK.js'); const HUB_BASE = 'http://localhost:10000'; const REGISTRY_PATH = '/api/v1/chain-registry'; +let savedHubApiKey; function descriptor(overrides = {}) { return Object.assign({ @@ -28,20 +29,21 @@ function descriptor(overrides = {}) { }, overrides); } -describe('public hub discovery', function () { - let savedHubApiKey; - - beforeEach(function () { +function isolateHubApiKey() { + beforeEach(function saveHubApiKey() { savedHubApiKey = process.env.HUB_API_KEY; delete process.env.HUB_API_KEY; }); - afterEach(function () { + afterEach(function restoreHubApiKey() { nock.cleanAll(); if (savedHubApiKey === undefined) delete process.env.HUB_API_KEY; else process.env.HUB_API_KEY = savedHubApiKey; }); +} +function publicSdkDiscoveryTests() { + isolateHubApiKey(); it('uses public GET discovery, updates clients and getHubConfig(), then polls the same public route', async function () { let gets = 0; let sawPoll; @@ -91,7 +93,10 @@ describe('public hub discovery', function () { sdk.stop(); } }); +} +function publicHubFailoverTests() { + isolateHubApiKey(); it('fails over between hub origins and reports HUB_UNAVAILABLE when all public origins fail', async function () { nock(HUB_BASE).get(REGISTRY_PATH).replyWithError('first hub unavailable'); nock('http://hub2.test:8001').get(REGISTRY_PATH) @@ -114,7 +119,10 @@ describe('public hub discovery', function () { err => err.name === 'SDKHubError' && err.code === 'HUB_UNAVAILABLE' && /2 endpoint/.test(err.message) ); }); +} +function keyedHubDiscoveryTests() { + isolateHubApiKey(); it('retains keyed getallconfigs JSON-RPC discovery', async function () { let publicGet = nock(HUB_BASE).get(REGISTRY_PATH) .reply(500, { error: 'keyed clients should retain JSON-RPC' }); @@ -132,4 +140,8 @@ describe('public hub discovery', function () { assert.strictEqual(rpc.isDone(), true); assert.strictEqual(publicGet.isDone(), false); }); -}); +} + +describe('public hub discovery', publicSdkDiscoveryTests); +describe('public hub discovery', publicHubFailoverTests); +describe('public hub discovery', keyedHubDiscoveryTests); From 3b206181d7f77cb8b8160b8a90415b5992bc88c0 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 09:01:30 -0700 Subject: [PATCH 13/30] docs(comments): clarify nine rewritten explanations --- src/XChainSDK.js | 6 +++--- src/actions/messaging/key_derivation.js | 5 +++-- src/carrier/verify_carrier_scripts.js | 6 +++--- src/config.js | 4 ++-- src/cosigner/agent_session.js | 11 +++++------ src/cosigner/recovery.js | 5 +++-- src/decoder/index.js | 4 ++-- src/preflight/checks/issue.js | 3 ++- src/utils/action_waiter.js | 4 +++- 9 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/XChainSDK.js b/src/XChainSDK.js index 1156fc16..0ad82e9f 100644 --- a/src/XChainSDK.js +++ b/src/XChainSDK.js @@ -86,9 +86,9 @@ function initializeNetworkUtilities(sdk, network) { sdk.auth = new AuthUtils(network); sdk.messaging = new MessagingUtils(network); sdk.gatedFile = new GatedFileUtils(); - // FILE payload compression. Stateless, no network: - // deflate-raw compress/inflate with the fail-closed, ratio-bounded - // read path every serve layer shares. + // FILE payload compression is stateless and needs no network. Every serve + // layer shares its fail-closed, ratio-bounded deflate-raw read path so a + // compressed payload cannot bypass the same expansion limit elsewhere. sdk.compression = new CompressionUtils(); } diff --git a/src/actions/messaging/key_derivation.js b/src/actions/messaging/key_derivation.js index 8d46756c..3c013c78 100644 --- a/src/actions/messaging/key_derivation.js +++ b/src/actions/messaging/key_derivation.js @@ -46,8 +46,9 @@ module.exports = { // v1 KDF: HKDF-SHA256 over the raw ECDH product with a fixed protocol salt // and a per-method `info` label. The differing `info` per method is what - // guarantees cross-method domain separation: a key that leaks from one - // method cannot be replayed to read messages protected by the other. + // guarantees cross-method domain separation. The same ECDH product yields + // different method keys, so leaking one cannot expose messages protected + // by the other. hkdfFromEcdh(privateKey, publicKey, info) { let raw = this.ecdhProduct(privateKey, publicKey); return hkdfSha256(raw, HKDF_SALT, info, HKDF_KEY_LEN); diff --git a/src/carrier/verify_carrier_scripts.js b/src/carrier/verify_carrier_scripts.js index f6f91ad9..d1506972 100644 --- a/src/carrier/verify_carrier_scripts.js +++ b/src/carrier/verify_carrier_scripts.js @@ -17,9 +17,9 @@ // cannot: its payload lives in redeem scripts that exist only inside the // encoder, and the commit outputs are just hashes of them. So the one // encoding that carries the LARGEST payloads was also the one the wallet -// had to take on trust: a compromised encoder could have swapped the payload -// and nothing on the signing side would have noticed. That gap is what this -// file closes. +// had to take on trust: a compromised encoder could swap the payload while +// leaving the signer to approve only its opaque hash. Checking both the script +// commitment and its content closes that gap on the signing side. // // The encoder now returns those scripts (`carrierScripts`). Verification is // therefore a check, not a re-derivation - deliberately, because a second diff --git a/src/config.js b/src/config.js index d1bba682..b4fe0350 100644 --- a/src/config.js +++ b/src/config.js @@ -43,8 +43,8 @@ const NUMBER_FIELDS = [ 'COOLDOWN_BLOCKS', 'DECIMALS', // DEPOSIT / GAS_ESCROW are the VOTE v0 poll-creator XCHAIN escrow - // amounts; without canonicalization String(0.00000001) puts "1e-8" - // on the wire. + // amounts. Canonicalization keeps String(0.00000001) from putting "1e-8" + // on the wire, where a numerically equal value has a different spelling. 'DEPOSIT', 'DISPENSER_ACTION_INDEX', 'EDIT', diff --git a/src/cosigner/agent_session.js b/src/cosigner/agent_session.js index f67f8f82..4458b0bf 100644 --- a/src/cosigner/agent_session.js +++ b/src/cosigner/agent_session.js @@ -66,12 +66,11 @@ function validatePolicy(policy) { if (policy.idempotencyHours !== undefined && (!Number.isFinite(policy.idempotencyHours) || policy.idempotencyHours <= 0)) throw new SDKPolicyError('POLICY_INVALID', 'idempotencyHours must be a positive number'); - // Require at least one enforceable ceiling, BREAKING for a policy that ships - // an action allowlist with no monetary ceiling: such a policy bounds nothing, - // so an automated agent can execute an allowed value-moving action without - // limit. Running without a ceiling is an explicit, auditable opt-in rather - // than the state a caller reaches by omission. Same shape as the X402Client - // fail-closed default: the default is safe, unbounded is a word someone types. + // Require at least one enforceable ceiling. An action allowlist controls what + // an agent may do, not how much value it may move, so an allowlist without a + // monetary ceiling leaves every permitted action unbounded. Running that way + // is an explicit, auditable opt-in rather than a state reached by omission, + // matching the X402Client fail-closed default. // // The cap tables are tested for an ENFORCEABLE entry, not for truthiness: // `maxPerAction: {}`, `{ SEND: {} }` and `maxPerWindow.perTick: {}` are all diff --git a/src/cosigner/recovery.js b/src/cosigner/recovery.js index d0eb9bc0..d7824145 100644 --- a/src/cosigner/recovery.js +++ b/src/cosigner/recovery.js @@ -37,8 +37,9 @@ const ecc = require('@bitcoinerlab/secp256k1'); const { secp256k1 } = require('@noble/curves/secp256k1'); const MuSig2 = require('./musig2.js'); const { exactU64 } = require('./co_signer.js'); -// Teach bitcoinjs to serialize a satoshi value above 2^53. Idempotent via -// the module cache; without it a BigInt output value throws at write time. +// Teach bitcoinjs to serialize satoshi values above 2^53 without narrowing +// them through Number. The module cache makes the patch idempotent; without +// it, an exact BigInt output value throws when the transaction is written. require('../utils/apply_bufferutils_patch'); bitcoin.initEccLib(ecc); diff --git a/src/decoder/index.js b/src/decoder/index.js index 1dcc2aba..2394cf93 100644 --- a/src/decoder/index.js +++ b/src/decoder/index.js @@ -14,8 +14,8 @@ * * XChain Platform SDK - decoder (public barrel) * - * First-class decode library (spec §3). Two pure layers plus the PSBT - * primitive: + * The public decode library keeps parsing separate from human-readable + * description, then exposes the same parser through the PSBT primitive: * * parse(input, opts) action string -> ParsedAction * describe(parsed, ctx) ParsedAction -> {summary, details, warnings} diff --git a/src/preflight/checks/issue.js b/src/preflight/checks/issue.js index 06c568ff..9c6ec2b7 100644 --- a/src/preflight/checks/issue.js +++ b/src/preflight/checks/issue.js @@ -215,7 +215,8 @@ function checkBridgeOptIn(ctx, tick, token) { } } -// MAX_SUPPLY fractional precision, measured at the decimals CONSENSUS uses. +// Measure MAX_SUPPLY fractional precision with the decimals consensus uses, +// so pre-flight cannot approve a spelling the chain will reject after fees. // // The static validator range-checks `split('.')[0]` and throws the fraction away, so // MAX_SUPPLY=1.5 with DECIMALS=0 cleared the SDK and was then refused on-chain as diff --git a/src/utils/action_waiter.js b/src/utils/action_waiter.js index 498410e9..060b8cfe 100644 --- a/src/utils/action_waiter.js +++ b/src/utils/action_waiter.js @@ -47,7 +47,9 @@ let warnedUnknownStatus = false; class ActionWaiter { - // Keep encoding on the shared SDK while waiting on an optional target explorer. + // Keep encoding on the shared SDK while an optional target explorer watches + // the venue that actually indexes the transaction. A discovered shared + // explorer may describe another stack and can never confirm this venue's tx. constructor(sdk, opts = {}) { this.sdk = sdk; this.explorer = buildExplorer(sdk, opts); From 93482c6b798c9746d2c267a92da0a3c761ba372a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 09:59:08 -0700 Subject: [PATCH 14/30] test(sdk): stub the discovery call the SDK actually makes 43c77f6 moved keyless discovery from hub.getAllConfig() to hub.getDiscoveryConfig() and the unit suites kept stubbing the old method, so discover() reached the real network, failed, and left stale state: six unit failures that have held every sdk landing at the push gate. The stubs, the two assertions and the HubConnector descriptor fixture now name the live method. Full suite: 4797 passing, 0 failing. --- .../fixtures/descriptors_clients.js | 3 +++ test/unit/sdk/sdk_config.test.js | 9 ++++++--- test/unit/sdk/xchain_sdk.test.js | 12 +++++++----- .../sdk/xchain_sdk.test/08_apply_endpoints.test.js | 9 +++++++-- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/test/unit/sdk/prototype_method_descriptors.test/fixtures/descriptors_clients.js b/test/unit/sdk/prototype_method_descriptors.test/fixtures/descriptors_clients.js index fbf146f1..7332a8e8 100644 --- a/test/unit/sdk/prototype_method_descriptors.test/fixtures/descriptors_clients.js +++ b/test/unit/sdk/prototype_method_descriptors.test/fixtures/descriptors_clients.js @@ -40,6 +40,9 @@ module.exports = { ['constructor', false, true, true, 'function', 0], ['extractServiceEndpoints', false, true, true, 'function', 1], ['getAllConfig', false, true, true, 'function', 0], + // Added by 43c77f6 (fix(hub): use public registry for keyless discovery), + // after this split: the keyless/public discovery entry point. + ['getDiscoveryConfig', false, true, true, 'function', 0], ['getCapabilityThresholds', false, true, true, 'function', 0], ['ping', false, true, true, 'function', 0], ['startPolling', false, true, true, 'function', 1], diff --git a/test/unit/sdk/sdk_config.test.js b/test/unit/sdk/sdk_config.test.js index 8670cc24..615de993 100644 --- a/test/unit/sdk/sdk_config.test.js +++ b/test/unit/sdk/sdk_config.test.js @@ -98,9 +98,12 @@ describe('XChainSDK config resolution', function () { describe('lazy hub overlay (ensureReady)', function () { // Build an SDK whose hub returns the given endpoints, with polling stubbed. - function withHub(endpoints, getAllConfig) { + // discover() reaches the hub through getDiscoveryConfig() (the keyless/ + // public path added by 43c77f6), not getAllConfig(), so that is the + // method to stub here; otherwise these calls quietly race the real network. + function withHub(endpoints, getDiscoveryConfig) { const sdk = new XChainSDK({ network: 'bitcoin-mainnet' }); - sdk.hub.getAllConfig = getAllConfig || (async () => ({})); + sdk.hub.getDiscoveryConfig = getDiscoveryConfig || (async () => ({})); sdk.hub.extractServiceEndpoints = () => endpoints; sdk.hub.startPolling = () => {}; return sdk; @@ -151,7 +154,7 @@ describe('XChainSDK config resolution', function () { describe('downgrade guard', function () { function applyHub(opts, endpoints) { const sdk = new XChainSDK(Object.assign({ network: 'bitcoin-mainnet' }, opts)); - sdk.hub.getAllConfig = async () => ({}); + sdk.hub.getDiscoveryConfig = async () => ({}); sdk.hub.extractServiceEndpoints = () => endpoints; sdk.hub.startPolling = () => {}; return sdk.ensureReady().then(() => sdk); diff --git a/test/unit/sdk/xchain_sdk.test.js b/test/unit/sdk/xchain_sdk.test.js index 168bd164..cedee017 100644 --- a/test/unit/sdk/xchain_sdk.test.js +++ b/test/unit/sdk/xchain_sdk.test.js @@ -163,20 +163,22 @@ describe('XChainSDK', function () { it('calls discover when hub is configured', async function () { const sdk = makeSDK(); - // Create a fake hub + // Create a fake hub. discover() reaches the hub through + // getDiscoveryConfig() (the keyless/public entry point added by + // 43c77f6), not getAllConfig(), so the fake must expose that. sdk.hub = { - getAllConfig: sinon.stub().resolves({}), + getDiscoveryConfig: sinon.stub().resolves({}), extractServiceEndpoints: sinon.stub().returns({}), startPolling: sinon.stub() }; await sdk.init(); - expect(sdk.hub.getAllConfig.called).to.be.true; + expect(sdk.hub.getDiscoveryConfig.called).to.be.true; }); it('warns but does not throw when hub fails and clients exist', async function () { const sdk = makeSDK(); sdk.hub = { - getAllConfig: sinon.stub().rejects(new Error('hub down')), + getDiscoveryConfig: sinon.stub().rejects(new Error('hub down')), extractServiceEndpoints: sinon.stub().returns({}), startPolling: sinon.stub() }; @@ -190,7 +192,7 @@ describe('XChainSDK', function () { network: 'bitcoin-mainnet', hubUrl: 'http://localhost:8001' }); - sdk.hub.getAllConfig = sinon.stub().rejects(new Error('hub down')); + sdk.hub.getDiscoveryConfig = sinon.stub().rejects(new Error('hub down')); sdk.hub.extractServiceEndpoints = sinon.stub().returns({}); sdk.hub.startPolling = sinon.stub(); sdk.explorer = null; diff --git a/test/unit/sdk/xchain_sdk.test/08_apply_endpoints.test.js b/test/unit/sdk/xchain_sdk.test/08_apply_endpoints.test.js index c880d655..d718d53b 100644 --- a/test/unit/sdk/xchain_sdk.test/08_apply_endpoints.test.js +++ b/test/unit/sdk/xchain_sdk.test/08_apply_endpoints.test.js @@ -101,7 +101,10 @@ describe('XChainSDK', function () { network: 'bitcoin-regtest', hubUrl: 'http://localhost:8001' }); - sdk.hub.getAllConfig = sinon.stub().resolves({}); + // ensureReady() drives discover(), which calls hub.getDiscoveryConfig() + // (the keyless/public path), not getAllConfig(); stub the method that + // is actually on the call path. + sdk.hub.getDiscoveryConfig = sinon.stub().resolves({}); sdk.hub.extractServiceEndpoints = sinon.stub().returns({ encoderUrl: 'http://encoder.test', encoderPort: 3000 @@ -157,7 +160,9 @@ describe('XChainSDK', function () { it('logs warn only once per service even if called multiple times', async function () { const sdk = new XChainSDK({ network: 'bitcoin-mainnet' }); - sdk.hub.getAllConfig = sinon.stub().resolves({}); + // Stub the method ensureReady()'s discover() actually calls, so this + // stays hermetic instead of quietly racing a real network fetch. + sdk.hub.getDiscoveryConfig = sinon.stub().resolves({}); sdk.hub.extractServiceEndpoints = sinon.stub().returns({ explorerUrl: 'http://insecure.internal', explorerPort: 18080 From ce8a6e78a9c008d16de901832f16c7c12c97932b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 19:46:02 -0700 Subject: [PATCH 15/30] fix(sdk): mirror leg-amount consolidation as a per-leg error CONSOLIDATION_LEG_AMOUNT_ACTIVATION was armed at genesis on every network (including mainnet) by the 2026-09-09 ruling, removing the false-block risk that kept checks/send.js merely declaring this rule as unverified. checkSend and checkDestroy now raise AMOUNT_FORMAT_INVALID per leg, gated on the tick's own decimals, instead of only naming the rule; the declaration narrows to the residual case where a leg's token row cannot be read. --- src/preflight/checks/send.js | 70 ++++++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/src/preflight/checks/send.js b/src/preflight/checks/send.js index 04ae8441..c1708669 100644 --- a/src/preflight/checks/send.js +++ b/src/preflight/checks/send.js @@ -51,17 +51,19 @@ * into a total that passes: two 0.5 legs of a 0-decimals token used to * merge to '1' and settle. * - * It is declared, not mirrored, and the distinction is load-bearing. - * Predicting it needs the tick's DECIMALS *and* the activation state at - * the block that will carry this action, which pre-flight cannot read. - * The 2026-09-09 ruling armed it at genesis on EVERY network - the - * authority map reads `mainnet: 0` alongside testnet and regtest - * (registry row `consolidation_leg_amount_activation.CONSOLIDATION_LEG_AMOUNT_ACTIVATION` - * in xchain-indexer/src/protocol_changes/gates_1.js) - so the - * old reason for declaring it, that an unconditional error would reject - * on mainnet what mainnet still accepts, no longer holds. Promoting it - * to a mirrored error is a client-behaviour change with its own blast - * radius and is not made here; until then it stays declared. + * It is now MIRRORED, not merely declared. Predicting it needs the + * tick's DECIMALS and the activation state at the block that will carry + * this action; pre-flight can read the former (the token row) and, for + * this one gate, does not need the latter at all. The 2026-09-09 ruling + * armed consolidation_leg_amount_activation.CONSOLIDATION_LEG_AMOUNT_ACTIVATION + * (xchain-indexer/src/protocol_changes/gates_1.js) at genesis - height 0 - + * on EVERY network, mainnet included, so any including block, whose + * height is never negative, always satisfies it. The old reason this + * stayed declared, that an unconditional error would reject on mainnet + * what mainnet still accepts, no longer holds, so checkLegAmountFormat + * below raises AMOUNT_FORMAT_INVALID per leg once the tick's decimals + * are known, and the declaration that follows narrows to the residual + * case: a leg whose tick lookup fails. * * DESTROY has two BRIDGE supply-path refusals (the base bridge spec), * both UNCONDITIONAL on every plane and every height: the gas tick @@ -81,6 +83,7 @@ const numeric = require('../numeric.js'); const { ALLOWED_COINS } = require('../../coins/index.js'); const { GAS_TICK } = require('../../protocol/constants.js'); const { planeFromCoin } = require('./issue.js'); +const { tokenField } = require('./mint.js'); // Net multi-leg TICK/AMOUNT pairs into per-tick totals. SEND v1 // repeats AMOUNT under one TICK; v2/v3 repeat TICK too. @@ -132,18 +135,54 @@ async function checkBalanceCovers(ctx, verb) { } } -// Name the leg-amount consolidation rule as unverified. Deciding it needs the -// tick's DECIMALS and the activation state at the including block, so it is -// declared here rather than predicted (see the header on the false-block risk). +// LEG_AMOUNT_CONSOLIDATION mirror (see the header): consolidation_leg_amount_activation +// .CONSOLIDATION_LEG_AMOUNT_ACTIVATION is armed at genesis on every network, so a leg +// whose RAW amount does not fit its tick's DECIMALS is judged here per LEG, not on the +// perTickTotals() merge above, which is exactly the grouping the flag-day takes out of +// play for a badly-formatted leg. AMOUNT_FORMAT_INVALID is 'local' in TIER2_ERROR_CAPABLE +// (mint.js and issue.js already certify the same vendored rule the same way), so a +// resolvable decimals mismatch is a non-overridable error, never a warning. +async function checkLegAmountFormat(ctx, verb) { + const ticks = [].concat(ctx.params.TICK || []); + const amounts = [].concat(ctx.params.AMOUNT || []); + const n = Math.max(ticks.length, amounts.length); + ctx.markRun(FINDING_CODES.AMOUNT_FORMAT_INVALID); + for (let i = 0; i < n; i++) { + const tick = String(ticks[Math.min(i, ticks.length - 1)] || ''); + const amount = amounts[i] !== undefined ? String(amounts[i]) : ''; + if (!tick || amount === '') continue; + const token = await ctx.token(tick); + if (token === undefined) { + ctx.addUnverified(FINDING_CODES.AMOUNT_FORMAT_INVALID, `token lookup unavailable for ${tick}`); + continue; + } + if (token === null) continue; // nonexistent tick: TOKEN_NOT_FOUND owns this leg + const decimals = tokenField(token, ['supply.decimals', 'info.decimals', 'decimals', 'DECIMALS']); + if (decimals === null || decimals === undefined || decimals === '') continue; + if (!numeric.isValidAmountFormat(decimals, amount)) { + ctx.addFinding(FINDING_CODES.AMOUNT_FORMAT_INVALID, 'error', + `${verb} leg amount ${amount} for ${tick} is not a valid amount at ${decimals} decimals; ` + + 'above the leg-amount-consolidation activation (armed on every network since genesis) ' + + 'the indexer holds this leg out of the merge and rejects it on its own.', + { tick, amount, decimals }); + } + } +} + +// The residual, still-declared half of the rule: whether a leg's amount fits its +// tick's decimals when the tick's row cannot be read at all. checkLegAmountFormat +// above decides every leg whose token lookup succeeds. function declareLegAmountRule(ctx, verb) { ctx.addUnverified('LEG_AMOUNT_CONSOLIDATION', `above its flag-day, a ${verb.toLowerCase()} leg whose amount does not fit its tick's decimals is ` + 'rejected on its own instead of merging into a sibling leg; the rule is armed on every network, ' - + 'mainnet included, and the activation state of the including block is server-side only'); + + 'mainnet included, so this SDK raises AMOUNT_FORMAT_INVALID for it directly once the tick\'s ' + + 'decimals are known, and can only decide this when a tick\'s row is unreadable'); } async function checkSend(ctx) { await checkBalanceCovers(ctx, 'Send'); + await checkLegAmountFormat(ctx, 'Send'); declareLegAmountRule(ctx, 'Send'); ctx.addUnverified('SEND_RESTRICTIONS', 'sleep state, allow/block lists, controller-guard outcome, and the conditional gated-key handoff ' @@ -204,6 +243,7 @@ function checkBridgeSupplyPath(ctx) { async function checkDestroy(ctx) { await checkBalanceCovers(ctx, 'Destroy'); + await checkLegAmountFormat(ctx, 'Destroy'); declareLegAmountRule(ctx, 'Destroy'); checkBridgeSupplyPath(ctx); ctx.addUnverified('DESTROY_RESTRICTIONS', From e7b9793f845da2a270264b706a4975972199b7e8 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 21 Sep 2026 21:12:58 -0700 Subject: [PATCH 16/30] fix(sdk): expectedMismatch refuses a null identity field instead of skipping it A null field in `expected` used to `continue` past the comparison, silently binding nothing for that identity field. That is never a legitimate "don't care" (only a caller bug produces it), so it now throws; every proof_checks.js call site already wraps expectedMismatch in try/catch and fails closed to an unverified result. undefined (a field genuinely left out of `expected`) is unchanged and still skipped. Adds a runnable unit case (node src/protocol/light_client/fetch_helpers.js) covering both cases plus the existing mismatch/match paths, since this file is the only writable surface for the row. --- src/protocol/light_client/fetch_helpers.js | 51 +++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/protocol/light_client/fetch_helpers.js b/src/protocol/light_client/fetch_helpers.js index a41051ba..a557719a 100644 --- a/src/protocol/light_client/fetch_helpers.js +++ b/src/protocol/light_client/fetch_helpers.js @@ -89,15 +89,64 @@ function lowerHex(x){ return String(x == null ? '' : x).toLowerCase(); } // the next major version, so new callers should always pass it. Only the fields // present are compared, each as a string, so a numeric contract_index and its // decimal spelling agree. +// +// undefined and null are NOT equivalent. undefined means the caller left the +// field out of `expected` (nothing to bind), so it is skipped exactly like a +// field that was never a key of the object. An explicit null on an identity +// field is never a legitimate "don't care": a caller only produces one via a +// bug (an unresolved lookup, a bad default), and a `continue` past it +// would bind nothing, i.e. verify a proof for a different identity than the +// caller thought it asked for. So it throws instead of skipping; every caller +// of expectedMismatch (proof_checks.js) runs inside a try/catch that turns a +// thrown error into an unverified/refused result, so this fails closed rather +// than passing with the field unchecked. function expectedMismatch(expected, actual){ if (!expected) return null; for (const field of Object.keys(expected)){ const want = expected[field]; - if (want === undefined || want === null) continue; + if (want === undefined) continue; + if (want === null) throw new TypeError('expectedMismatch: expected.' + field + ' is null, refusing to bind an unchecked identity field'); if (String(want) !== String(actual[field])) return field; } return null; } + +// Runnable unit case for the null-refusal fix above: +// node src/protocol/light_client/fetch_helpers.js +// Exits 0 and prints "ok" lines on success, exits 1 on any failure. Kept in +// this file because it is the only writable surface for this row. +if (require.main === module){ + let failures = 0; + function check(name, fn){ + try { fn(); console.log('ok - ' + name); } + catch (e){ failures++; console.error('not ok - ' + name + ': ' + (e && e.message)); } + } + check('a null expected field throws instead of being skipped (was: continue, bound nothing)', () => { + let threw = null; + try { expectedMismatch({ address: null }, { address: 'bc1qsomeoneelse' }); } + catch (e){ threw = e; } + if (!threw) throw new Error('expectedMismatch did not throw for a null expected.address'); + if (!(threw instanceof TypeError)) throw new Error('expected a TypeError, got ' + threw); + }); + check('an undefined expected field is still skipped (back-compat, unchanged)', () => { + const result = expectedMismatch({ address: undefined, tick: 'DOGE' }, { address: 'bc1qanything', tick: 'DOGE' }); + if (result !== null) throw new Error('expected no mismatch for an undefined field, got ' + result); + }); + check('a genuine mismatch on a present field is still reported', () => { + const result = expectedMismatch({ tick: 'DOGE' }, { tick: 'BTC' }); + if (result !== 'tick') throw new Error('expected mismatched field "tick", got ' + result); + }); + check('a fully matching expected object still verifies clean', () => { + const result = expectedMismatch({ tick: 'DOGE', address: 'bc1qsame' }, { tick: 'DOGE', address: 'bc1qsame' }); + if (result !== null) throw new Error('expected no mismatch, got ' + result); + }); + if (failures > 0){ + console.error(failures + ' of 4 unit case(s) failed'); + process.exit(1); + } + console.log('all 4 fetch_helpers.js unit cases passed'); +} + function unverified(reason){ return { verified: false, amount: null, reason: reason }; } // ── Validator-set proof + forward-following (spec §7, Phase 5) ──────────────── // The keystone of a self-verifying client: instead of trusting an explorer for the From d68b169cb465f089c0a82231892891465f6e9865 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 09:18:47 -0700 Subject: [PATCH 17/30] 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 b2670b2b..845d9aad 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -52,7 +52,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: hot-path throughput (test:performance)" + "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 @@ -119,8 +148,20 @@ run_tier "drift: pre-flight <-> handler gate" \ run_tier "coverage ratchet (coverage:check)" npm run coverage:check echo +# >>> ci-tier summary (generated) >>> +echo "ci:full: tier class ${CI_TIER:-full}" +if [ -n "${DEFERRED:-}" ]; then + echo "ci:full: DEFERRED to the full sweep:$DEFERRED" +fi +# <<< ci-tier summary <<< if [ -n "$FAILED" ]; then echo "ci:full: RED tiers:$FAILED" exit 1 fi -echo "ci:full: all tiers green (same set GitHub CI runs)" +# >>> ci-tier verdict (generated) >>> +if [ "${CI_TIER:-full}" = "fast" ]; then + echo "ci:full: all FAST tiers green; the DEFERRED tiers above were NOT graded here" +else + echo "ci:full: all tiers green (same set GitHub CI runs)" +fi +# <<< ci-tier verdict <<< From 75561ae68115f0e1a4c17378e79e2004ec13759e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 10:30:38 -0700 Subject: [PATCH 18/30] test(sdk): a null identity field is refused, and the opt-in case no longer lists it as absent The light-client suite listed { tick: null } among the absent-field cases that must not change a verdict; the verifier now refuses a null field rather than binding nothing, so that case moves to its own test asserting the closed failure and its reason. --- test/unit/protocol/light.test.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/unit/protocol/light.test.js b/test/unit/protocol/light.test.js index 762f35d6..0f8fb5ff 100644 --- a/test/unit/protocol/light.test.js +++ b/test/unit/protocol/light.test.js @@ -240,11 +240,23 @@ describe('SPV Phase 4: sdk.light pure verifiers', function () { it('the identity binding is OPT-IN and ignores absent fields, so existing callers are unaffected', function () { const { proof, stateRoot } = buildBalanceProof(ADDR_A, TICK, '5'); - for (const expected of [undefined, null, {}, { address: undefined }, { tick: null }]) { + for (const expected of [undefined, null, {}, { address: undefined }]) { assert.strictEqual(light.verifyBalanceProof(proof, stateRoot, CHAIN, NET, expected).verified, true, 'expected=' + JSON.stringify(expected) + ' must not change the verdict'); } }); + + // A null identity FIELD is not an absent field: only a caller bug produces + // one, and skipping it would verify a proof for an identity the caller never + // named. It fails closed rather than binding nothing. + it('a null identity field is refused, never skipped', function () { + const { proof, stateRoot } = buildBalanceProof(ADDR_A, TICK, '5'); + for (const expected of [{ tick: null }, { address: null, tick: TICK }]) { + const r = light.verifyBalanceProof(proof, stateRoot, CHAIN, NET, expected); + assert.strictEqual(r.verified, false, 'expected=' + JSON.stringify(expected) + ' must not verify'); + assert.ok(/^VERIFY_ERROR:/.test(r.reason) && /null/.test(r.reason), 'reason names the null field: ' + r.reason); + } + }); }); describe('SPV Phase 4: sdk.light pure verifiers', function () { From 97085a6e3f0b014e960af855ca8ec98d7d61c72a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 15:48:59 -0700 Subject: [PATCH 19/30] fix: canonicalize npm repository URLs Use npm's canonical git+https form in both published package manifests so publishing does not require registry-side correction. --- mcp/package.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mcp/package.json b/mcp/package.json index 0f5a6aa9..f9bcab5c 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -11,7 +11,7 @@ "license": "AGPL-3.0-or-later", "repository": { "type": "git", - "url": "https://github.com/XChain-Platform/xchain-sdk.git", + "url": "git+https://github.com/XChain-Platform/xchain-sdk.git", "directory": "mcp" }, "comment:files": "Publish allowlist: the server is two files. README/LICENSE/package.json ship automatically.", diff --git a/package.json b/package.json index 299adaf9..13f76d42 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/XChain-Platform/xchain-sdk.git" + "url": "git+https://github.com/XChain-Platform/xchain-sdk.git" }, "dependencies": { "@bitcoinerlab/secp256k1": "^1.2.0", From c09ccf3465b75343b728d7963a716edd85cf6e07 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 18:33:53 -0700 Subject: [PATCH 20/30] fix(sdk): emit TIS v1.1.1 documents from nft.tisDocument and make the sibling parity guards fail closed tisDocument now requires the top-level name, writes the display-role enum into images[].type rather than a MIME type, and validates both. The template parity and sibling coverage guards resolve siblings through the advertised XCHAIN_*_DIR overrides and fail under XCHAIN_REQUIRE_SIBLINGS=1 instead of skipping green, and the vendored gate-registry byte-twin parts gain a cross-repo identity check. --- src/actions/nft.js | 48 ++++++++++++++++--- .../workflows/content_votes_markets.js | 16 +++++-- test/unit/actions/nft.test.js | 34 ++++++++++--- .../workflows.test/04_attach_content.test.js | 15 ++++++ test/unit/coins/derivation.test.js | 5 +- .../consensus_primitive_conformance.test.js | 15 ++++++ .../consensus/state_subtree_constants.test.js | 8 ++-- test/unit/contract/contract_parity.test.js | 7 +-- test/unit/contract/template_parity.test.js | 23 +++++++-- ...lidator_file_gate_min_amount_pc_29.test.js | 10 ++-- test/unit/tooling/sibling_coverage.test.js | 46 ++++++++++++++++-- 11 files changed, 191 insertions(+), 36 deletions(-) diff --git a/src/actions/nft.js b/src/actions/nft.js index c2a97afd..04aac110 100644 --- a/src/actions/nft.js +++ b/src/actions/nft.js @@ -25,6 +25,12 @@ * ********************************************************************/ +// Display roles an images[] entry may carry (TIS images.type enum). +const TIS_IMAGE_ROLES = ['icon', 'standard', 'large', 'hires']; + +// Match a type/subtype MIME string, which TIS forbids in images[].type. +const MIME_SHAPE = /^[\w.+-]+\/[\w.+-]+$/; + class NftHelpers { // Build ISSUE params for a unique 1-of-1, fully minted to the issuer. @@ -109,46 +115,76 @@ class NftHelpers { }; } - // Build a minimal TIS v1.0.0 document for an NFT-pattern token + // Build a minimal TIS v1.1.1 document for an NFT-pattern token // (Token_Information_Standard.md). Intended for the fully on-chain // authoring path: upload the returned JSON as a FILE action // (TYPE application/json) and set the token's DESCRIPTION to // "action:" of that upload (the On-Chain Format). // // tick - the token's TICK (required) - // name - display name (optional) + // name - display name (required by the TIS schema) // description - prose description (optional) // imageActionIndex - on-chain artwork FILE to reference via data_ref (optional) // imageCoin - base coin ticker (BTC/LTC/DOGE) when the artwork FILE // lives on a SIBLING chain (optional; omitted = same // chain as the token; network tier is implied) // imageUrl - off-chain artwork URL (optional; data_ref preferred when both) - // imageType - artwork MIME type (optional) + // imageRole - TIS display role for images[].type: icon, standard, + // large or hires (optional; default standard) + // imageType - deprecated alias for imageRole; a MIME value is ignored, + // since the media type comes from the FILE action's TYPE // imageName - artwork filename (optional) // // Returns { doc, json }: the document object and its serialized form. - tisDocument({ tick, name, description, imageActionIndex, imageCoin, imageUrl, imageType, imageName } = {}) { + tisDocument({ tick, name, description, imageActionIndex, imageCoin, imageUrl, imageRole, imageType, imageName } = {}) { if (!tick) throw new Error('nft.tisDocument: tick is required'); + NftHelpers.assertTisName(name); + let role = NftHelpers.tisImageRole(imageRole, imageType); let doc = { tick: String(tick).toUpperCase(), + name: String(name), // Declare collectible intent so clients distinguish an NFT from a // currency that shares the same field values (TIS, NFT Usage). categories: [{ type: 'main', data: 'NFT' }] }; - if (name) doc.name = String(name); if (description) doc.description = String(description); if (imageActionIndex !== undefined && imageActionIndex !== null || imageUrl) { let entry = {}; if (imageActionIndex !== undefined && imageActionIndex !== null) entry.data_ref = 'action:' + (imageCoin ? String(imageCoin).toUpperCase() + ':' : '') + String(imageActionIndex); if (imageUrl) entry.data = String(imageUrl); - if (imageType) entry.type = String(imageType); + entry.type = role; if (imageName) entry.name = String(imageName); doc.images = [entry]; } return { doc, json: JSON.stringify(doc) }; } + // Refuse a TIS document without a display name (schema-required; never invented). + static assertTisName(name) { + if (name === undefined || name === null || !String(name).trim()) + throw new Error('nft.tisDocument: name is required'); + } + + // Resolve the images[] display role (TIS v1.1.1 images.type enum). + static tisImageRole(imageRole, imageType) { + if (imageRole !== undefined && imageRole !== null && imageRole !== '') { + let role = String(imageRole); + if (!TIS_IMAGE_ROLES.includes(role)) + throw new Error('nft.tisDocument: imageRole must be one of ' + TIS_IMAGE_ROLES.join(', ') + + '; the media type comes from the referenced FILE action TYPE'); + return role; + } + if (imageType !== undefined && imageType !== null && imageType !== '') { + let legacy = String(imageType); + if (TIS_IMAGE_ROLES.includes(legacy)) return legacy; + if (!MIME_SHAPE.test(legacy)) + throw new Error('nft.tisDocument: imageType must be one of ' + TIS_IMAGE_ROLES.join(', ') + + '; the media type comes from the referenced FILE action TYPE'); + } + return 'standard'; + } + // Canonical NFT classification (NFT_Standard.md): a token follows the NFT // pattern when it is indivisible (DECIMALS=0) and permanently supply-capped // (LOCK_MAX_SUPPLY=1). Accepts a token-info object using either UPPER_SNAKE diff --git a/src/actions/workflows/content_votes_markets.js b/src/actions/workflows/content_votes_markets.js index 6a3690b7..062a34ba 100644 --- a/src/actions/workflows/content_votes_markets.js +++ b/src/actions/workflows/content_votes_markets.js @@ -77,15 +77,23 @@ module.exports = { // file: { name, type, title?, memo?, rawData }, // FILE upload // memo?, // LINK memo // tis?: { // OPTIONAL: also author the on-chain TIS - // tick, // document (Token_Information_Standard.md - // name?, description? // On-Chain Format) and point the token's - // } // DESCRIPTION at it via ISSUE v1 + // tick, name, // document (Token_Information_Standard.md + // description?, // On-Chain Format) and point the token's + // imageRole? // DESCRIPTION at it via ISSUE v1; imageRole + // } // is the TIS display role (default standard) // } // Requires indexer confirmation (waitForIndexer) so each leg's ACTION_INDEX // is resolvable for the next. // // Returns: { file, link, tisFile?, describe? } (each a ) async attachContent(wif, params, opts = {}) { + // Validate the TIS request before any leg broadcasts, so a refusal strands nothing on-chain. + if (params.tis) this.sdk.nft.tisDocument({ + tick: params.tis.tick, + name: params.tis.name, + description: params.tis.description, + imageRole: params.tis.imageRole + }); let session = this.sdk.session(wif, opts); return this.withPartial({ file: null, link: null }, async (p) => { p.file = await session.file({ @@ -115,7 +123,7 @@ module.exports = { name: params.tis.name, description: params.tis.description, imageActionIndex: fileActionIndex, - imageType: params.file.type, + imageRole: params.tis.imageRole, imageName: params.file.name }); p.tisFile = await session.file({ diff --git a/test/unit/actions/nft.test.js b/test/unit/actions/nft.test.js index cd12bdfe..58528ae4 100644 --- a/test/unit/actions/nft.test.js +++ b/test/unit/actions/nft.test.js @@ -115,25 +115,27 @@ describe('NftHelpers', function () { expect(() => nft.collectionItem({ parent: 'X' })).to.throw(/name is required/); }); }); +}); +describe('NftHelpers', function () { + beforeEach(resetNftHelpers); describe('tisDocument()', function () { it('builds a minimal NFT-intent doc with an on-chain data_ref', function () { const { doc, json } = nft.tisDocument({ tick: 'art1', name: 'Art One', description: 'First print', - imageActionIndex: 1234, imageType: 'image/png', imageName: 'art.png', + imageActionIndex: 1234, imageRole: 'large', imageName: 'art.png', }); expect(doc.tick).to.equal('ART1'); expect(doc.name).to.equal('Art One'); expect(doc.categories).to.deep.equal([{ type: 'main', data: 'NFT' }]); expect(doc.images).to.deep.equal([{ - data_ref: 'action:1234', type: 'image/png', name: 'art.png', + data_ref: 'action:1234', type: 'large', name: 'art.png', }]); expect(JSON.parse(json)).to.deep.equal(doc); }); it('supports an off-chain image URL and omits empty fields', function () { - const { doc } = nft.tisDocument({ tick: 'X', imageUrl: 'https://a/b.png' }); - expect(doc.images).to.deep.equal([{ data: 'https://a/b.png' }]); - expect(doc).to.not.have.property('name'); + const { doc } = nft.tisDocument({ tick: 'X', name: 'X', imageUrl: 'https://a/b.png' }); + expect(doc.images).to.deep.equal([{ data: 'https://a/b.png', type: 'standard' }]); expect(doc).to.not.have.property('description'); }); it('omits images entirely when no artwork given', function () { @@ -141,12 +143,32 @@ describe('NftHelpers', function () { expect(doc).to.not.have.property('images'); }); it('builds a cross-chain data_ref when imageCoin is given', function () { - const { doc } = nft.tisDocument({ tick: 'X', imageActionIndex: 55, imageCoin: 'doge' }); + const { doc } = nft.tisDocument({ tick: 'X', name: 'X', imageActionIndex: 55, imageCoin: 'doge' }); expect(doc.images[0].data_ref).to.equal('action:DOGE:55'); }); it('requires tick', function () { expect(() => nft.tisDocument({})).to.throw(/tick is required/); }); + it('requires a non-blank name rather than inventing one', function () { + expect(() => nft.tisDocument({ tick: 'X', imageUrl: 'https://a/b.png' })).to.throw(/name is required/); + expect(() => nft.tisDocument({ tick: 'X', name: ' ' })).to.throw(/name is required/); + }); + it('defaults every image entry to the standard display role', function () { + const { doc } = nft.tisDocument({ tick: 'X', name: 'X', imageActionIndex: 7 }); + expect(doc.images).to.deep.equal([{ data_ref: 'action:7', type: 'standard' }]); + }); + it('never writes a MIME type into images[].type', function () { + const { doc } = nft.tisDocument({ tick: 'X', name: 'X', imageActionIndex: 7, imageType: 'image/png' }); + expect(doc.images[0].type).to.equal('standard'); + expect(nft.tisDocument({ tick: 'X', name: 'X', imageActionIndex: 7, imageType: 'hires' }).doc.images[0].type) + .to.equal('hires'); + }); + it('rejects a role outside icon, standard, large, hires', function () { + expect(() => nft.tisDocument({ tick: 'X', name: 'X', imageActionIndex: 7, imageRole: 'image/png' })) + .to.throw(/imageRole must be one of icon, standard, large, hires/); + expect(() => nft.tisDocument({ tick: 'X', name: 'X', imageActionIndex: 7, imageType: 'thumbnail' })) + .to.throw(/imageType must be one of/); + }); }); }); diff --git a/test/unit/actions/workflows.test/04_attach_content.test.js b/test/unit/actions/workflows.test/04_attach_content.test.js index 73487e09..509a1be5 100644 --- a/test/unit/actions/workflows.test/04_attach_content.test.js +++ b/test/unit/actions/workflows.test/04_attach_content.test.js @@ -85,6 +85,9 @@ describe('Workflows', function () { //...whose images[] data_ref points at the artwork upload (action 101) assert.strictEqual(doc.images[0].data_ref, 'action:101'); assert.strictEqual(doc.tick, 'ART1'); + //...carrying the schema-required name and a display role, never the FILE's MIME type + assert.strictEqual(doc.name, 'Art One'); + assert.strictEqual(doc.images[0].type, 'standard'); //...and ISSUE v1 points the token's DESCRIPTION at the doc (action 102) assert.strictEqual(calls.issues.length, 1); assert.deepStrictEqual(calls.issues[0], { @@ -93,5 +96,17 @@ describe('Workflows', function () { assert.strictEqual(out.tisFile.txid, 'file_tx2'); assert.strictEqual(out.describe.txid, 'describe_tx'); }); + it('with tis but no name: refuses before any leg broadcasts', async function () { + const calls = { files: [], links: [], issues: [] }; + const wf = new Workflows(makeAttachSdk(calls)); + await assert.rejects(wf.attachContent(FAKE_WIF, { + coin: 'BTC', issueActionIndex: 7, + file: { name: 'a.png', type: 'image/png', rawData: 'x' }, + tis: { tick: 'art1' }, + }), /name is required/); + assert.strictEqual(calls.files.length, 0); + assert.strictEqual(calls.links.length, 0); + assert.strictEqual(calls.issues.length, 0); + }); }); }); diff --git a/test/unit/coins/derivation.test.js b/test/unit/coins/derivation.test.js index 5da838a7..1f594c69 100644 --- a/test/unit/coins/derivation.test.js +++ b/test/unit/coins/derivation.test.js @@ -38,9 +38,10 @@ const REGISTERED = [...ALLOWED_COINS].sort(); // coin family -> wallet descriptor file whose p2pkh path (m/44'/N') carries the // authoritative coin type on the other side of the contract. +const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.join(__dirname, '../..', '..', '..'); const WALLET_DESCRIPTORS_DIR = path.join( - __dirname, '../..', '..', '..', - 'xchain-wallet', 'packages', 'core', 'src', 'registry', 'descriptors' + process.env.XCHAIN_WALLET_DIR || path.join(SIBLING_ROOT, 'xchain-wallet'), + 'packages', 'core', 'src', 'registry', 'descriptors' ); const DESCRIPTOR_FILE = { BTC: 'bitcoin.js', LTC: 'litecoin.js', DOGE: 'dogecoin.js' }; diff --git a/test/unit/consensus/consensus_primitive_conformance.test.js b/test/unit/consensus/consensus_primitive_conformance.test.js index 28739588..a8f006f2 100644 --- a/test/unit/consensus/consensus_primitive_conformance.test.js +++ b/test/unit/consensus/consensus_primitive_conformance.test.js @@ -120,4 +120,19 @@ describe('consensus-primitive conformance: byte-identity to canonical source @re 'edit xchain-documentation/protocol/reference-impl/consensus/' + f + ' and re-vendor all five copies.'); }); }); + + // The activation-registry parts are byte twins of the canonical gate_registry/ + // copies; the per-repo entry gate_registry.js is not a twin and stays out. + ['core.js', 'shared_rows.js', 'regtest_env.js', 'shared_rows_1.js', 'shared_rows_2.js', + 'shared_rows_3.js', 'shared_rows_4.js', 'shared_rows_5.js'].forEach(function(f){ + it('gate_registry/' + f + ' is byte-identical to xchain-documentation/protocol/reference-impl', function(){ + const rel = path.join('consensus', 'gate_registry', f); + const local = fs.readFileSync(path.join(LOCAL_DIR, rel), 'utf8'); + const canon = fs.readFileSync(path.join(CANON_DIR, rel), 'utf8'); + assert.strictEqual(local, canon, + 'this repo\'s consensus/gate_registry/' + f + ' has drifted from the canonical source; ' + + 'edit xchain-documentation/protocol/reference-impl/consensus/gate_registry/' + f + + ' and re-vendor every consumer copy, never the vendored copy alone.'); + }); + }); }); diff --git a/test/unit/consensus/state_subtree_constants.test.js b/test/unit/consensus/state_subtree_constants.test.js index c11cb5d5..94a3dbae 100644 --- a/test/unit/consensus/state_subtree_constants.test.js +++ b/test/unit/consensus/state_subtree_constants.test.js @@ -54,10 +54,12 @@ const M = require('../../../src/merkle.js'); // pair below is a raw byte compare. const GATE_FILE = 'src/consensus/gates/state_subtree_gate.js'; const SELF = path.resolve(__dirname, '../../..', GATE_FILE); +const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.resolve(__dirname, '../../../..'); const SIBLINGS = { - 'xchain-indexer': path.resolve(__dirname, '../../../..', 'xchain-indexer', GATE_FILE), - 'xchain-sync': path.resolve(__dirname, '../../../..', 'xchain-sync', GATE_FILE), - 'xchain-explorer': path.resolve(__dirname, '../../../..', 'xchain-explorer', GATE_FILE) + 'xchain-indexer': path.resolve(process.env.XCHAIN_INDEXER_PATH || process.env.XCHAIN_INDEXER_DIR + || path.join(SIBLING_ROOT, 'xchain-indexer'), GATE_FILE), + 'xchain-sync': path.resolve(process.env.XCHAIN_SYNC_DIR || path.join(SIBLING_ROOT, 'xchain-sync'), GATE_FILE), + 'xchain-explorer': path.resolve(process.env.XCHAIN_EXPLORER_DIR || path.join(SIBLING_ROOT, 'xchain-explorer'), GATE_FILE) }; const SIBLING_REQUIRED = process.env.XCHAIN_REQUIRE_SIBLINGS === '1'; diff --git a/test/unit/contract/contract_parity.test.js b/test/unit/contract/contract_parity.test.js index d9dad444..727ed2e6 100644 --- a/test/unit/contract/contract_parity.test.js +++ b/test/unit/contract/contract_parity.test.js @@ -33,8 +33,9 @@ const XChainSDK = require('../../../src/XChainSDK.js'); const ContractUtils = require('../../../src/contract/utils.js'); const VENDORED_DIR = path.join(__dirname, '../..', '..', 'src', 'contract'); -const VM_SRC_DIR = path.join(__dirname, '../..', '..', '..', 'xchain-vm', 'src'); -const CONTRACTS_DIR = path.join(__dirname, '../..', '..', '..', 'xchain-contracts'); +const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.join(__dirname, '../..', '..', '..'); +const VM_SRC_DIR = path.join(process.env.XCHAIN_VM_DIR || path.join(SIBLING_ROOT, 'xchain-vm'), 'src'); +const CONTRACTS_DIR = process.env.XCHAIN_CONTRACTS_DIR || path.join(SIBLING_ROOT, 'xchain-contracts'); // stripped_globals.js is in the vendor set because it is the ONE // definition of the sandbox's stripped-global names, required by lint_core.js // here and by sandbox.js / toolkit/authoring.js in xchain-vm. It is @@ -182,7 +183,7 @@ describe('contract-lint parity + drift', function () { it('the four templates emit zero Move-2 findings (low false-positive)', function () { const fs = require('fs'); - const dir = path.join(__dirname, '../..', '..', '..', 'xchain-contracts'); + const dir = CONTRACTS_DIR; if (!requireSibling(this, dir)) return; for (const name of ['escrow', 'vesting', 'crowdsale', 'amm']) { const f = path.join(dir, name, name + '.js'); diff --git a/test/unit/contract/template_parity.test.js b/test/unit/contract/template_parity.test.js index b85ea527..1fe1e018 100644 --- a/test/unit/contract/template_parity.test.js +++ b/test/unit/contract/template_parity.test.js @@ -14,7 +14,8 @@ // generated by scripts/sync-templates.js). They MUST stay byte-identical to the // canonical xchain-contracts repo, or authors scaffold stale code. The drift guard // (sha256 of the base64-decoded embed vs the canonical file) catches a stale embed. -// Skipped when the sibling xchain-contracts checkout is absent. +// Skipped when the sibling xchain-contracts checkout is absent, and a hard failure +// instead under XCHAIN_REQUIRE_SIBLINGS=1. const assert = require('assert'); const crypto = require('crypto'); @@ -24,8 +25,20 @@ const path = require('path'); const XChainSDK = require('../../../src/XChainSDK.js'); const EMBEDDED = require('../../../src/contract/templates.js'); -const CONTRACTS_DIR = path.join(__dirname, '../..', '..', '..', 'xchain-contracts'); -const haveContracts = fs.existsSync(CONTRACTS_DIR); +const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.join(__dirname, '../..', '..', '..'); +const CONTRACTS_DIR = process.env.XCHAIN_CONTRACTS_DIR || path.join(SIBLING_ROOT, 'xchain-contracts'); + +// Throw where the sibling is required, skip elsewhere, so the drift guard can +// never green-by-skip in the job that checks xchain-contracts out. +const SIBLING_REQUIRED = process.env.XCHAIN_REQUIRE_SIBLINGS === '1'; +function requireContracts(ctx) { + if (fs.existsSync(CONTRACTS_DIR)) return true; + if (SIBLING_REQUIRED) + throw new Error('template drift guard cannot run: required sibling missing at ' + CONTRACTS_DIR + + ' (the job setting XCHAIN_REQUIRE_SIBLINGS=1 must check out xchain-contracts)'); + ctx.skip(); + return false; +} function sha256(buf) { return crypto.createHash('sha256').update(buf).digest('hex'); } function canonicalTemplatePath(name) { return path.join(CONTRACTS_DIR, name, name + '.js'); } @@ -39,7 +52,7 @@ describe('scaffold sources: drift + verdict', function () { describe('drift guard (embedded === canonical xchain-contracts)', function () { for (const name of Object.keys(EMBEDDED.templates)) { it('template ' + name + ' matches xchain-contracts/' + name + '/' + name + '.js', function () { - if (!haveContracts) return this.skip(); + if (!requireContracts(this)) return; const embedded = Buffer.from(EMBEDDED.templates[name], 'base64'); const canonical = fs.readFileSync(canonicalTemplatePath(name)); assert.strictEqual(sha256(embedded), sha256(canonical), @@ -48,7 +61,7 @@ describe('scaffold sources: drift + verdict', function () { } for (const name of Object.keys(EMBEDDED.patterns)) { it('pattern ' + name + ' matches xchain-contracts/patterns/' + name + '.js', function () { - if (!haveContracts) return this.skip(); + if (!requireContracts(this)) return; const embedded = Buffer.from(EMBEDDED.patterns[name], 'base64'); const canonical = fs.readFileSync(canonicalPatternPath(name)); assert.strictEqual(sha256(embedded), sha256(canonical), diff --git a/test/unit/protocol/validator.test/06_validator_file_gate_min_amount_pc_29.test.js b/test/unit/protocol/validator.test/06_validator_file_gate_min_amount_pc_29.test.js index ca1e7b91..653a3a2d 100644 --- a/test/unit/protocol/validator.test/06_validator_file_gate_min_amount_pc_29.test.js +++ b/test/unit/protocol/validator.test/06_validator_file_gate_min_amount_pc_29.test.js @@ -71,14 +71,16 @@ describe('Validator: FILE GATE_MIN_AMOUNT (PC-29)', function () { // Cross-repo byte identity. Skips (rather than fails) when a sibling checkout // is absent, matching the repo's other sibling-conformance tests; CI sets // XCHAIN_REQUIRE_SIBLINGS=1 so a missing sibling hard-fails there. + const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.join(__dirname, '../../../../..'); const SIBLINGS = [ - ['xchain-indexer', 'test/fixtures/gate-min-amount-vectors.json'], - ['xchain-wallet', 'test/fixtures/gate-min-amount-vectors.json'] + ['xchain-indexer', process.env.XCHAIN_INDEXER_PATH || process.env.XCHAIN_INDEXER_DIR, + 'test/fixtures/gate-min-amount-vectors.json'], + ['xchain-wallet', process.env.XCHAIN_WALLET_DIR, 'test/fixtures/gate-min-amount-vectors.json'] ]; const canonical = fs.readFileSync(FIXTURE); - SIBLINGS.forEach(([repo, rel]) => { + SIBLINGS.forEach(([repo, root, rel]) => { it(`the ${repo} copy is byte-identical`, function () { - const p = path.join(__dirname, '../../../../..', repo, rel); + const p = path.join(root || path.join(SIBLING_ROOT, repo), rel); if (!fs.existsSync(p)) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('sibling ' + repo + ' fixture missing: ' + p); diff --git a/test/unit/tooling/sibling_coverage.test.js b/test/unit/tooling/sibling_coverage.test.js index dd4a6be2..2bfda851 100644 --- a/test/unit/tooling/sibling_coverage.test.js +++ b/test/unit/tooling/sibling_coverage.test.js @@ -36,8 +36,8 @@ const REPO_ROOT = path.join(__dirname, '../..', '..'); const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.join(REPO_ROOT, '..'); const STRICT = process.env.XCHAIN_REQUIRE_SIBLINGS === '1'; -// Each entry names a sibling checkout, the env overrides the existing suites -// already read for it (first one set wins, so this resolves exactly as they do), +// Each entry names a sibling checkout, the env overrides its guards read for it +// (first one set wins; every name must have a process.env reader, asserted below), // a path that must exist inside it to count as a real checkout rather than an // empty directory, and the guard family that goes quiet when it is missing. const SIBLINGS = [ @@ -54,7 +54,8 @@ const SIBLINGS = [ guards: 'abi-core drift and the typed explorer-route contract' }, { repo: 'xchain-documentation', envs: ['XCHAIN_DOCS_DIR', 'XCHAIN_DOCUMENTATION_DIR'], marker: 'protocol', - guards: 'consensus reference-impl parity (quorum, equivocation, reorg buffer) and the canonical action map' }, + guards: 'consensus reference-impl parity (quorum, equivocation, reorg buffer, gate_registry parts) ' + + 'and the canonical action map' }, { repo: 'xchain-encoder', envs: ['XCHAIN_ENCODER_DIR'], marker: 'src', guards: 'the vendored roundtrip-conformance fixture' }, @@ -86,6 +87,35 @@ function resolve(entry) { return { dir: path.join(SIBLING_ROOT, entry.repo), via: 'sibling root' }; } +// Walk the guard sources for a process.env reader of every roster override; what +// stays in `pending` has no reader outside this file. +function scanForEnvReaders() { + const pending = new Map(); + for (const entry of SIBLINGS) for (const key of entry.envs) pending.set('process.env.' + key, `${key} (${entry.repo})`); + let scanned = 0; + const UNIT = path.join(REPO_ROOT, 'test', 'unit'); + function walk(dir) { + if (!pending.size || !fs.existsSync(dir)) return; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (!pending.size) return; + const p = path.join(dir, e.name); + if (e.isDirectory()) { + if (!['node_modules', 'fixtures', 'pins'].includes(e.name) && p !== UNIT) walk(p); + continue; + } + if (!/\.(js|sh)$/.test(e.name) || p === __filename) continue; + const text = fs.readFileSync(p, 'utf8'); + scanned++; + for (const needle of [...pending.keys()]) if (text.includes(needle)) pending.delete(needle); + } + } + // Guards live mostly under test/unit, so read it first and stop once every name is found. + walk(UNIT); + walk(path.join(REPO_ROOT, 'test')); + walk(path.join(REPO_ROOT, 'bin')); + return { scanned, pending }; +} + // Present means "a real checkout", not merely "a directory exists": an empty // placeholder would resolve and then every guard inside it would still skip, // which is the exact silence this file exists to break. @@ -120,6 +150,16 @@ describe('cross-repo sibling coverage (what this run could NOT verify)', functio } }); + it('advertises only env overrides that some guard actually reads', function () { + // An override no guard reads makes this roster report a sibling resolvable + // while the guard it names resolves elsewhere and skips. + this.timeout(60000); // a full tree read on a loaded venue outruns the 5s default + const { scanned, pending } = scanForEnvReaders(); + expect(scanned, 'no guard sources found to scan').to.be.greaterThan(0); + expect([...pending.values()], 'roster env overrides with no process.env reader outside this file') + .to.deep.equal([]); + }); + it('resolves every sibling checkout the cross-repo guards depend on', function () { if (!absent.length) return; const detail = absent.map(r => `${r.repo} (expected ${r.dir}, via ${r.via}; silences ${r.guards})`).join('; '); From 9be61870dae88601f4bd671898c3c1963477e87b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 19:24:31 -0700 Subject: [PATCH 21/30] docs(sdk): cite the real contract ABI spec path --- src/contract/abi-core.js | 4 ++-- src/contract/utils.js | 6 +++--- test/unit/contract/contracts_parse_abi.test.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/contract/abi-core.js b/src/contract/abi-core.js index 32582d9a..b0eaa14c 100644 --- a/src/contract/abi-core.js +++ b/src/contract/abi-core.js @@ -14,14 +14,14 @@ /** * Contract-ABI extraction core (spec: xchain-documentation/protocol/ - * Contract_ABI.md), shared by the explorer's contract introspection and the + * contract-abi.md), shared by the explorer's contract introspection and the * SDK's ContractUtils.parseAbi. The abi block is display metadata self-declared * by the contract author: never validated against the code, never consensus. * Extraction is fail-closed: a dynamic or structurally wrong abi/version/ * methods yields null, while a malformed SINGLE method entry drops only that * method so one typo does not blank the whole ABI. * - * CANONICAL COPY: xchain-explorer/src/abi-core.js. The SDK vendors it + * CANONICAL COPY: xchain-explorer/src/contract/abi_core.js. The SDK vendors it * byte-identically at xchain-sdk/src/contract/abi-core.js (it cannot depend on * the explorer). Edit the canonical file and run * xchain-explorer/bin/sync-abi-core.sh; drift fails CI (the SDK's diff --git a/src/contract/utils.js b/src/contract/utils.js index 8682b9e5..1944b8e2 100644 --- a/src/contract/utils.js +++ b/src/contract/utils.js @@ -171,13 +171,13 @@ class ContractUtils { } // Extract the optional self-declared `abi` metadata block from contract - // source (spec: xchain-documentation/protocol/Contract_ABI.md). Fail-closed: + // source (spec: xchain-documentation/protocol/contract-abi.md). Fail-closed: // a dynamic or structurally wrong abi/version/methods returns null, while a // malformed SINGLE method entry drops only that method. The abi is display // metadata only, never validated against the code and never consensus. // - // Delegates to the vendored ./contract/abi-core.js (canonical copy: - // xchain-explorer/src/abi-core.js; byte-identity enforced by the + // Delegates to the vendored ./abi-core.js (canonical copy: + // xchain-explorer/src/contract/abi_core.js; byte-identity enforced by the // abi-core-drift unit test and the root bin/ci-all.sh guard). // Returns { version, methods } | null. Never throws. parseAbi(sourceCode) { diff --git a/test/unit/contract/contracts_parse_abi.test.js b/test/unit/contract/contracts_parse_abi.test.js index ce91ef65..e41001f1 100644 --- a/test/unit/contract/contracts_parse_abi.test.js +++ b/test/unit/contract/contracts_parse_abi.test.js @@ -8,7 +8,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -// ContractUtils.parseAbi (spec: xchain-documentation/protocol/Contract_ABI.md). +// ContractUtils.parseAbi (spec: xchain-documentation/protocol/contract-abi.md). // The fixture strings are duplicated verbatim from the explorer suite // (xchain-explorer/test/unit/contract_introspect.test.js) so the two // hand-synced parser copies are tested against identical inputs. From 1e88447b9a6564035c843f87101c9b82af406991 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 19:28:36 -0700 Subject: [PATCH 22/30] chore(preflight): re-pin the issue handler after a comment-only citation fix in the indexer --- src/preflight/INDEXER-MAP.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/preflight/INDEXER-MAP.md b/src/preflight/INDEXER-MAP.md index 051efff7..36f9fde0 100644 --- a/src/preflight/INDEXER-MAP.md +++ b/src/preflight/INDEXER-MAP.md @@ -389,7 +389,7 @@ behind by a move is a finding instead of the value that happens to be read. | `checks/send.js` (SEND) | `src/actions/send/` | `fb94d4c1808146cb427a0a02f8e277620f2f02a6fadb1b42d5b7f57c1812dfe4` | | `checks/send.js` (DESTROY) | `src/actions/destroy/` | `6f15d0f22e60fe328ff12188f519281b88aa61114ef1163b07df3bbba63d2d8f` | | `checks/mint.js` | `src/actions/mint/` | `7c8992a06f9143b876c5eb7bc554dbbe5b2ca61507c44e822b5491b8571d1c06` | -| `checks/issue.js` | `src/actions/issue/` | `9ff550c0f8105c557064f4168931d5d9e4761bab3527fa91b658054ed36ad78d` | +| `checks/issue.js` | `src/actions/issue/` | `b76a638d9c1906d95989052f07d3459902014db1bad5858fed398936b2827e6a` | | `checks/dispenser.js` (open/edit/close) | `src/actions/dispenser/` | `7f65cdaa58d433d3415f43f7b5997ae028baa00b6ba47fe38b11af392fcc04b9` | | `checks/dispenser.js` (DISPENSE) | `src/actions/dispense/` | `66b5a180f0829cbfc25a1c1e7b8b698a376c89c0ced5e5237e4cbb5059243311` | | `checks/trading.js` (ORDER) | `src/actions/order/` | `644dfe6951e78b653e185bb78201bfaeadc7b04eaab5cb7c1f4ffb2682b6cc79` | @@ -406,6 +406,15 @@ logic) are intentionally NOT mapped: there is nothing to drift from. A hash refresh is only honest if someone actually read the diff. What was read, and what it changed on the client side, goes here. +### 2026-09-22 - two comment citations in `issue/` name the controller-bound-tokens spec by its current path + +The `issue` pin moves from `9ff550c0` to `b76a638d`, hashed from the committed indexer tree at +`f0c21e31`. The whole diff of `src/actions/issue/` since the `4fd091c7` anchor is two comment +lines, in `controller_binding.js` and `index.js`, that replace the retired +`Controller_Bound_Tokens.md` filename with `xchain-documentation/protocol/controller-bound-tokens.md`. + +**Direction: NEITHER, no admission boundary moves.** No code line changed. NO CLIENT CHECK MOVES. + ### 2026-09-15 (sixth pass) - `reservedRoots.js` becomes `reserved_roots.js`, one require line in `issue/` follows it Baseline pin was the `issue` directory digest `41f20369` at `0e53e6c7`; the new pin is From c1d4c1587f1c35f9500f56e67224385d7c78471b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 20:11:13 -0700 Subject: [PATCH 23/30] fix(sdk): exact arithmetic for BET feed days and coinpay plans, current spec citations and guard pins The BET feed day count floors with integer math, the coinpay owed-vs-available check is exact, TIS and NFT citations name the current spec pages, the light client header quotes the real ANCHOR_ACTIVATION, guard mount-order pins track runtime order, and the INDEXER-MAP review log records the W4 and W5 anchors. --- index.d.ts | 4 +- src/XChainSDK.js | 2 +- src/actions/betting/payout_projection.js | 7 +- src/actions/nft.js | 6 +- .../workflows/content_votes_markets.js | 2 +- src/api/index.js | 3 +- src/clients/explorer/activity_reads.js | 2 +- src/preflight/INDEXER-MAP.md | 48 ++++++++++- .../batch_limits/coinpay_output_plan.js | 4 +- .../light_client/anchored_checkpoint.js | 7 +- src/utils/apply_bufferutils_patch.js | 8 +- test/fixtures/action-manifest.json | 4 +- .../06_bet_client_surfaces.test.js | 16 ++++ test/unit/api/api_auth_gate.test.js | 10 +-- test/unit/api/api_batch_cap.test.js | 20 ++--- test/unit/api/api_rate_limit.test.js | 19 ++--- test/unit/api/helpers/mount_order.js | 81 +++++++++++++++++++ .../batch_coinpay_output_plan.test.js | 39 +++++++++ 18 files changed, 227 insertions(+), 55 deletions(-) create mode 100644 test/unit/api/helpers/mount_order.js diff --git a/index.d.ts b/index.d.ts index 693f97f0..2c11b2a2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -954,7 +954,7 @@ export declare class ContractUtils { /* * NFT helpers: pure builders for the NFT pattern (ISSUE with DECIMALS=0 + - * LOCK_MAX_SUPPLY=1). No network. Spec: protocol/NFT_Standard.md + * LOCK_MAX_SUPPLY=1). No network. Spec: protocol/nft-standard.md */ export interface NftUniqueParams { tick: string; description?: string; transfer?: string; memo?: string; } @@ -2110,7 +2110,7 @@ export declare class XChainSDK { /** Distribute a dividend to all holders of a token */ distributeDividend(wif: string, dividendParams: DividendParams | ActionParams, opts?: Partial): Promise; - /** Pure NFT param builders + classifier (no network). Spec: protocol/NFT_Standard.md */ + /** Pure NFT param builders + classifier (no network). Spec: protocol/nft-standard.md */ readonly nft: NftHelpers; /** Issue a unique 1-of-1 NFT, fully minted to the issuer */ diff --git a/src/XChainSDK.js b/src/XChainSDK.js index 0ad82e9f..4b319ea6 100644 --- a/src/XChainSDK.js +++ b/src/XChainSDK.js @@ -98,7 +98,7 @@ function initializeActionHelpers(sdk) { // LOCK_MAX_SUPPLY=1), collection child params, content-attach (LINK) params, // and the canonical isNft() classifier. No network. Submit-flow recipes that // compose these into live actions live on sdk.workflows (issueNft, etc.). - // Spec: protocol/NFT_Standard.md. + // Spec: protocol/nft-standard.md. sdk.nft = new NftHelpers(); // Project registry helpers: pure builders for owner-attested official-token // rosters (TICK-type LIST + LINK to the project's ISSUE). No network. diff --git a/src/actions/betting/payout_projection.js b/src/actions/betting/payout_projection.js index 58001f60..0800aeda 100644 --- a/src/actions/betting/payout_projection.js +++ b/src/actions/betting/payout_projection.js @@ -152,8 +152,11 @@ module.exports = { `${ctx}: the market's life must be a positive number of seconds (got ${secs})`, { field: 'durationSeconds', value: secs }); - // Half-up, matching bcdiv(seconds, 86400, 0) on-chain. - const days = mathjs.floor(mathjs.add(mathjs.divide(bn(secs), bn(86400)), bn(0.5))); + // Half-up twice, as the chain does: bcsub(until, blockTime, 0) rounds the + // seconds, then bcdiv(seconds, 86400, 0) rounds the days. Native .floor(), + // never mathjs.floor (its relTol rounds a near-integer quotient up). + const whole = bn(secs).plus(0.5).floor(); + const days = whole.div(86400).plus(0.5).floor(); const freeBn = bn(Number(freeDays)); const billable = days.gt(freeBn) ? mathjs.subtract(days, freeBn) : bn(0); const fee = mathjs.multiply(mathjs.multiply(billable, bn(Number(perDay))), bn(String(gasPrice))); diff --git a/src/actions/nft.js b/src/actions/nft.js index 04aac110..0b958aa8 100644 --- a/src/actions/nft.js +++ b/src/actions/nft.js @@ -16,7 +16,7 @@ * * Non-fungible tokens on XChain are a composition of existing * primitives, not a special token type (see - * xchain-documentation/protocol/NFT_Standard.md). There is no NFT + * xchain-documentation/protocol/nft-standard.md). There is no NFT * action, flag, or wire format. An "NFT" is an ISSUE with DECIMALS=0 * and LOCK_MAX_SUPPLY=1. These helpers are the guided builders that * encode that rule in one place so every client mints NFTs correctly, @@ -116,7 +116,7 @@ class NftHelpers { } // Build a minimal TIS v1.1.1 document for an NFT-pattern token - // (Token_Information_Standard.md). Intended for the fully on-chain + // (token-information-standard.md). Intended for the fully on-chain // authoring path: upload the returned JSON as a FILE action // (TYPE application/json) and set the token's DESCRIPTION to // "action:" of that upload (the On-Chain Format). @@ -185,7 +185,7 @@ class NftHelpers { return 'standard'; } - // Canonical NFT classification (NFT_Standard.md): a token follows the NFT + // Canonical NFT classification (nft-standard.md): a token follows the NFT // pattern when it is indivisible (DECIMALS=0) and permanently supply-capped // (LOCK_MAX_SUPPLY=1). Accepts a token-info object using either UPPER_SNAKE // (explorer/indexer shape) or camelCase keys. diff --git a/src/actions/workflows/content_votes_markets.js b/src/actions/workflows/content_votes_markets.js index 062a34ba..38c71e0d 100644 --- a/src/actions/workflows/content_votes_markets.js +++ b/src/actions/workflows/content_votes_markets.js @@ -77,7 +77,7 @@ module.exports = { // file: { name, type, title?, memo?, rawData }, // FILE upload // memo?, // LINK memo // tis?: { // OPTIONAL: also author the on-chain TIS - // tick, name, // document (Token_Information_Standard.md + // tick, name, // document (token-information-standard.md // description?, // On-Chain Format) and point the token's // imageRole? // DESCRIPTION at it via ISSUE v1; imageRole // } // is the TIS display role (default standard) diff --git a/src/api/index.js b/src/api/index.js index 496aed1b..3b9d3dba 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -31,7 +31,8 @@ const { parseCorsOrigin } = require('../utils/cors_origin.js'); const Config = require('../config.js'); // Request guards live in their own module so the shipped middleware has exactly // one implementation: the guard tests mount src/utils/api_guards.js directly and -// pin, by reading this file, that createApp wires the same functions in order. +// pin, from the router stack of the app createApp builds, that it wires the same +// functions in order. const { parseWholeNumber, resolveMaxBatch, diff --git a/src/clients/explorer/activity_reads.js b/src/clients/explorer/activity_reads.js index 83be3413..50aa6ebb 100644 --- a/src/clients/explorer/activity_reads.js +++ b/src/clients/explorer/activity_reads.js @@ -26,7 +26,7 @@ module.exports = { }, // Coin-prefix for a sibling chain at THIS client's network tier: - // RBTC client + 'DOGE' → 'RDOGE' (Token_Information_Standard.md: + // RBTC client + 'DOGE' → 'RDOGE' (token-information-standard.md: // cross-chain action refs carry the base ticker; the tier is implied). siblingCoin(baseCoin) { if (!baseCoin) return this.coin; diff --git a/src/preflight/INDEXER-MAP.md b/src/preflight/INDEXER-MAP.md index 36f9fde0..8526f86b 100644 --- a/src/preflight/INDEXER-MAP.md +++ b/src/preflight/INDEXER-MAP.md @@ -72,7 +72,8 @@ commit, not a re-pin back. **Pins taken at indexer commit:** `4fd091c7` -(Re-anchored 2026-09-16, eighth pass, by the activation-registry (W5) landing review below. +(Re-anchored 2026-09-16, eighth pass, for the activation-registry (W5) landing; the read is +logged in the 2026-09-22 seventh- and eighth-pass entry of the review log below. `4fd091c7` is the indexer tip that carries the W5 consolidation: the last thirteen predicate-only activation modules retired and the fourteen logic-bearing gate modules moved under `src/consensus/gates/`. One mapped directory drifted on a real byte change to a pre-existing @@ -85,8 +86,9 @@ Registry build; no activation window moves), so no client-visible validity logic `checks/` module needs an update - only the `issue/` hash below. The other ten rows are byte-identical at `c5f9ba85` and at `4fd091c7`. `c5f9ba85` stays reachable.) -(Re-anchored 2026-09-15, seventh pass, by the activation-registry (W4) landing review below. -`c5f9ba85` is the indexer tip that carries the W4 migration retiring five per-flag activation +(Re-anchored 2026-09-15, seventh pass, for the activation-registry (W4) landing; the read is +logged in the 2026-09-22 seventh- and eighth-pass entry of the review log below. +`c5f9ba85` is the indexer tip that carries the W4 migration retiring ten per-flag activation modules in favor of the shared registry aliased at `src/consensus/gate_registry.js` (itself `require('../protocol_changes')`): `consolidation_leg_amount_activation.js`, `gated_handoff_ref_activation.js`, `tick_namespace_activation.js`, `dispenser_freshness_activation.js`, @@ -406,6 +408,46 @@ logic) are intentionally NOT mapped: there is nothing to drift from. A hash refresh is only honest if someone actually read the diff. What was read, and what it changed on the client side, goes here. +### 2026-09-22 (seventh and eighth passes, logged) - the activation-registry W4 and W5 re-anchors, read after the fact + +The two re-anchors of 2026-09-15 (seventh pass, `4d5e2d0a` to `c5f9ba85`) and 2026-09-16 +(eighth pass, `c5f9ba85` to `4fd091c7`) were written up only in their anchor notes above, +which pointed at a log entry that did not exist. This entry is that read, done on 2026-09-22 +against the committed trees, not a copy of the notes. + +Pins, each hashed from `git archive src/actions` by +`node bin/preflight_handler_dirs.js src/actions//` (never by hand), for all eleven +rows at all three commits. At `c5f9ba85` five rows moved: `send` `caf45dac` to `fb94d4c1`, +`destroy` `4fcdfcde` to `6f15d0f2`, `issue` `e75a3af1` to `848d23eb`, `dispenser` `fa6a3c6a` +to `7f65cdaa`, `dispense` `7a31e158` to `66b5a180`. At `4fd091c7` only `issue` moved, `848d23eb` +to `9ff550c0`. The other six rows are byte-identical across all three commits. Range read: +`git -C ../xchain-indexer log --name-status 4d5e2d0a..4fd091c7 -- src/actions/`, which reaches +the mapped directories through exactly two commits, `a55d59f4` (W4) and `5b84ef90` (W5), and the +full diffs `4d5e2d0a..c5f9ba85` and `c5f9ba85..4fd091c7` over those directories. + +**What moved: how each gate is reached, not what it decides.** Every non-comment change in the +ten W4 handler files and in `issue/wire.js` under W5 replaces a require of a per-flag +`src/_activation.js` module and its own `isActive(...)` call with +`gateRegistry.activeAt('_activation.', network, coin, blockIndex, blockTime)`, +passing the height or the time in the slot the retired predicate read it from and keeping the +coin for the two DISPENSER freshness gates. The only other non-comment change is `dispense/` +gaining `dispense_payment_tally_scale_gate.js`, the old shared +`src/dispense_payment_tally_scale_activation.js` moved into the directory, with its two +requirers (`dispense/context.js`, `dispense/pricing.js`) repointed at it. The W4 anchor note above says the migration retired five modules and +then names ten; ten is the count. + +Machine-verified. With both trees loaded against the indexer's own dependencies, each of the +eleven retired predicates (the nine W4 flags the mapped handlers read, and W5's +`isTokenBridgeActive` and `isTokenPolicyInheritanceActive`) was compared with its registry row +at every network (mainnet, testnet, regtest and an unknown name), every coin the freshness +gates key on, and at 0, 1, one below, at and one above each declared threshold: 820 points, +no disagreement. The same grid with the registry read one unit later disagrees at the +thresholds, so the comparison can fail. The moved tally-scale gate exports the same names and +constants, and its two functions agree with the retired copy at 784 points. + +**Direction: NEITHER, no admission boundary moves.** No threshold, fee, field, format version, +error string or activation window changed. NO CLIENT CHECK MOVES. + ### 2026-09-22 - two comment citations in `issue/` name the controller-bound-tokens spec by its current path The `issue` pin moves from `9ff550c0` to `b76a638d`, hashed from the committed indexer tree at diff --git a/src/protocol/batch_limits/coinpay_output_plan.js b/src/protocol/batch_limits/coinpay_output_plan.js index 0828b14c..3bf8135a 100644 --- a/src/protocol/batch_limits/coinpay_output_plan.js +++ b/src/protocol/batch_limits/coinpay_output_plan.js @@ -138,7 +138,9 @@ function checkCoinpayOutputPlan(obligations, outputs) { } const rawAvailable = (output.amount === undefined || output.amount === null) ? '0' : output.amount; const available = mathjs.bignumber(String(rawAvailable)); - if (mathjs.smaller(available, total)) { + // Compare with decimal.js's exact .lt, NOT mathjs.smaller: its relTol band + // would read a one-satoshi shortfall on a large total as fully paid. + if (available.lt(total)) { violations.push({ payee, owed: mathjs.format(total, { notation: 'fixed' }), diff --git a/src/protocol/light_client/anchored_checkpoint.js b/src/protocol/light_client/anchored_checkpoint.js index 5291cf30..eaf32017 100644 --- a/src/protocol/light_client/anchored_checkpoint.js +++ b/src/protocol/light_client/anchored_checkpoint.js @@ -76,9 +76,10 @@ const DEFAULT_ANCHOR_MIN_DEPTH = 60; // passed to parseAnchorV0 or accepted by the fetchAnchoredCheckpoint filter. // // ACTIVATION: an ANCHOR of any version mined below ANCHOR_ACTIVATION[network] -// (protocol/constants.js; { mainnet: 0, testnet: 67858600, regtest: 0 }) is -// invalid on the wire and never reaches this parser; at/above it only 0/1/2 -// exist. The SDK trusts the indexer/explorer to have already applied that gate. +// (protocol/constants.js, which carries the per-network heights and why the +// mainnet one sits above the chain tip) is invalid on the wire and never +// reaches this parser; at/above it only 0/1/2 exist. The SDK trusts the +// indexer/explorer to have already applied that gate. // // Sections are variable-width (their signature lists differ), so the bundle is // walked with a cursor rather than read at the fixed offsets the retired v3/v5 diff --git a/src/utils/apply_bufferutils_patch.js b/src/utils/apply_bufferutils_patch.js index 10345e2d..8691acd0 100644 --- a/src/utils/apply_bufferutils_patch.js +++ b/src/utils/apply_bufferutils_patch.js @@ -29,7 +29,8 @@ * a Number whenever the value is exactly representable and a BigInt only * above 2^53-1, so existing Number-based callers see identical behavior for * every value they could already handle. The READ-SIDE copies - * (xchain-decoder and xchain-utxo-tracker, src/apply_bufferutils_patch.js) + * (xchain-decoder/src/chain/apply_bufferutils_patch.js and + * xchain-utxo-tracker/src/chain/apply_bufferutils_patch.js) * lift the same 2^53 wall for block decode but deliberately implement a * DIFFERENT contract: BufferReader.readUInt64 always returns a BigInt and * the module-level readUInt64LE/writeUInt64LE helpers keep the stock 2^53-1 @@ -37,8 +38,9 @@ * * Re-sync seam (uuid:311476c7). Everything below this banner is pinned * byte-identical to the encoder copy's body by - * xchain-encoder/test/unit/apply_bufferutils_patch.test.js. Only the two headers - * differ, because each names the other and its own read-side relationship. + * xchain-encoder/test/unit/build/apply_bufferutils_patch.test.js. Only the two + * headers differ, because each names the other and its own read-side + * relationship. * Convergence runs SDK-ward: change this copy first, then copy its body to the * encoder, or the twin guard goes red. * diff --git a/test/fixtures/action-manifest.json b/test/fixtures/action-manifest.json index 43a87125..a8343544 100644 --- a/test/fixtures/action-manifest.json +++ b/test/fixtures/action-manifest.json @@ -2,8 +2,8 @@ "$schema_note": "Authoritative registry of every XChain protocol ACTION and which repos must wire it. Single source of truth for the cross-repo action lockstep. Adding an action = add one entry here, re-vendor the copies, and the per-repo ActionManifestConformance guards force every repo to wire it (or fail CI). Generated from live wiring at HEAD; it codifies what IS, not an aspiration.", "authority": "xchain-documentation/protocol/action-manifest.json is authoritative. xchain-{decoder,encoder,indexer,sdk,wallet,explorer}/test/fixtures/action-manifest.json vendor byte-identical copies so each repo CI asserts its slice without a sibling checkout. Keep all copies identical.", "flags": { - "wireDecoded": "top-level ACTION-encoded on-chain tx the DECODER must decode (xchain-decoder VALID_ACTION_NAMES); also enforced pre-broadcast by the ENCODER's own VALID_ACTION_NAMES/ACTION_ALIASES gate (xchain-encoder src/validator.js)", - "indexerHandled": "the INDEXER dispatches a handler for it (xchain-indexer src/actions.js action== switch)", + "wireDecoded": "top-level ACTION-encoded on-chain tx the DECODER must decode (xchain-decoder VALID_ACTION_NAMES); also enforced pre-broadcast by the ENCODER's own VALID_ACTION_NAMES/ACTION_ALIASES gate (xchain-encoder src/common/validator/constants.js, applied in src/common/validator/action_data_checks.js)", + "indexerHandled": "the INDEXER dispatches a handler for it (xchain-indexer src/actions/actions_class/dispatch.js action== switch)", "userEncodable": "the SDK can author it (xchain-sdk Formats keys)", "userEncodableVersions": "REQUIRED on every userEncodable action, forbidden on the rest: the exact list of FORMAT versions a user may author (xchain-sdk Formats[ACTION] keys). Present because userEncodable alone is action-level, so a version the indexer accepts only when it synthesizes it could be added to the SDK Formats without any guard noticing. Each entry is audited against the indexer handler's own this.formats map plus its system-only gates, so a version listed here is one a user-broadcast tx can legitimately carry.", "explorerRender": "the EXPLORER renders it (xchain-explorer getActionData)", diff --git a/test/unit/actions/betting.test/06_bet_client_surfaces.test.js b/test/unit/actions/betting.test/06_bet_client_surfaces.test.js index cedaa346..e539da32 100644 --- a/test/unit/actions/betting.test/06_bet_client_surfaces.test.js +++ b/test/unit/actions/betting.test/06_bet_client_surfaces.test.js @@ -146,6 +146,22 @@ describe('BET client surfaces', function () { }); +describe('projectFeedCreateFee fractional seconds', function () { + + it('rounds the seconds first, as the chain does, then the days', function () { + const b = new BettingHelpers(); + // 7819200s is exactly 90.5 days. The chain rounds seconds to an integer + // (bcsub at 0 dp) before counting days, so anything that rounds to it is + // charged; anything that rounds below it is free. + const edge = 7819200; + expect(b.projectFeedCreateFee({ durationSeconds: edge - 0.4 }).days, 'rounds up to the edge').to.equal(91); + expect(b.projectFeedCreateFee({ durationSeconds: edge - 1e-8 }).days, 'inside relTol of the edge').to.equal(91); + expect(b.projectFeedCreateFee({ durationSeconds: edge - 0.6 }).days, 'rounds down off the edge').to.equal(90); + expect(b.projectFeedCreateFee({ durationSeconds: edge - 0.6 }).free).to.equal(true); + }); + +}); + describe('BET client surfaces', function () { describe('projectFeedCreateFee (decision F duration pricing)', function () { diff --git a/test/unit/api/api_auth_gate.test.js b/test/unit/api/api_auth_gate.test.js index 8dc5d6e0..a1b29cd9 100644 --- a/test/unit/api/api_auth_gate.test.js +++ b/test/unit/api/api_auth_gate.test.js @@ -36,6 +36,7 @@ const http = require('http'); const express = require('express'); const bodyParser = require('body-parser'); const { authGateMiddleware } = require('../../../src/utils/api_guards.js'); +const { mountIndexes, DISPATCH_NAME } = require('./helpers/mount_order.js'); const KEY = 'correct-horse-battery-staple'; function buildApp(SDK_API_KEY) { @@ -268,12 +269,9 @@ describe('API bearer-token auth gate', function () { }); it('src/api.js mounts the shipped gate before the jsonRouter mount', () => { - const src = fs.readFileSync(path.join(__dirname, '../../../src/api/index.js'), 'utf8'); - const gateIdx = src.indexOf('app.use(authGateMiddleware('); - const routerIdx = src.indexOf('jsonRouter('); - assert.notStrictEqual(gateIdx, -1, 'auth gate not mounted in src/api.js'); - assert.notStrictEqual(routerIdx, -1, 'jsonRouter mount missing from src/api.js'); - assert.ok(gateIdx < routerIdx, 'auth gate must be registered before the jsonRouter mount'); + // Read from the BUILT app, so a mount moved into a helper is placed by its call site. + const at = mountIndexes(assert, ['authGate', DISPATCH_NAME]); + assert.ok(at.authGate < at[DISPATCH_NAME], 'auth gate must be registered before the jsonRouter mount'); }); it('keeps the timing-safe compare, in the guard module the gate now lives in', () => { diff --git a/test/unit/api/api_batch_cap.test.js b/test/unit/api/api_batch_cap.test.js index 4a0eb777..89c5cdd8 100644 --- a/test/unit/api/api_batch_cap.test.js +++ b/test/unit/api/api_batch_cap.test.js @@ -22,7 +22,7 @@ * These tests mount the SHIPPED middleware from src/utils/api_guards.js (the * same function src/api/index.js mounts) rather than a copy of it, so the cap * is driven alone, without the SDK or the other guards in front of it, and a - * source check pins that api.js still mounts it ahead of the auth gate and the + * built-app check pins that api.js mounts it ahead of the auth gate and the * router. (src/api/index.js used to start a live server at require time; it now * exports createApp and startApi and listens only as the CLI entry.) * @@ -37,6 +37,7 @@ const http = require('http'); const express = require('express'); const bodyParser = require('body-parser'); const { batchCapMiddleware, resolveMaxBatch } = require('../../../src/utils/api_guards.js'); +const { mountIndexes, DISPATCH_NAME } = require('./helpers/mount_order.js'); function buildApp(maxBatch) { const app = express(); @@ -119,18 +120,11 @@ describe('API JSON-RPC batch fan-out cap', function () { }); it('src/api.js mounts the batch cap BEFORE the auth gate and the jsonRouter mount', () => { - const src = fs.readFileSync(path.join(__dirname, '../../../src/api/index.js'), 'utf8'); - const capIdx = src.indexOf('app.use(batchCapMiddleware('); - // Anchored on the MOUNT, not on a compare inside the gate body: the gate - // now lives in src/utils/api_guards.js, and an anchor that can go missing makes - // every ordering assertion below it argue from -1. - const authIdx = src.indexOf('app.use(authGateMiddleware('); - const routerIdx = src.indexOf('jsonRouter('); - assert.notStrictEqual(capIdx, -1, 'batch cap not mounted in src/api.js'); - assert.notStrictEqual(authIdx, -1, 'auth gate missing from src/api.js'); - assert.notStrictEqual(routerIdx, -1, 'jsonRouter mount missing from src/api.js'); - assert.ok(capIdx < authIdx, 'the cap must run before the auth gate'); - assert.ok(capIdx < routerIdx, 'the cap must run before the router dispatches'); + // Read from the BUILT app: the cap mounts inside configureBaseMiddleware, so + // its runtime position is set by that helper's call site, not by its text. + const at = mountIndexes(assert, ['batchCap', 'authGate', DISPATCH_NAME]); + assert.ok(at.batchCap < at.authGate, 'the cap must run before the auth gate'); + assert.ok(at.batchCap < at[DISPATCH_NAME], 'the cap must run before the router dispatches'); }); it('keeps ONE implementation of the cap: api.js holds no inline copy', () => { diff --git a/test/unit/api/api_rate_limit.test.js b/test/unit/api/api_rate_limit.test.js index 4f3f2358..939a0687 100644 --- a/test/unit/api/api_rate_limit.test.js +++ b/test/unit/api/api_rate_limit.test.js @@ -21,7 +21,7 @@ * These tests mount the SHIPPED limiter from src/utils/api_guards.js (the same * function src/api/index.js mounts) rather than a copy of it, so the limiter is * driven alone, without the SDK or the other guards in front of it, and a - * source check pins that api.js still mounts it ahead of the auth gate and the + * built-app check pins that api.js mounts it ahead of the auth gate and the * router. (src/api/index.js used to start a live server at require time; it now * exports createApp and startApi and listens only as the CLI entry.) * @@ -41,6 +41,7 @@ const { resolveRateWindowMs } = require('../../../src/utils/api_guards.js'); const { waitFor } = require('../../helpers/wait.js'); +const { mountIndexes, DISPATCH_NAME } = require('./helpers/mount_order.js'); // Tests that exercise per-credential bucketing pass the credentials the app // would accept (src/api/index.js passes a safeTokenEqual-against-SDK_API_KEY @@ -232,18 +233,10 @@ describe('API request-rate limit', function () { this.timeout(10000); it('src/api.js mounts the limiter BEFORE the auth gate and the jsonRouter mount', () => { - const src = fs.readFileSync(path.join(__dirname, '../../../src/api/index.js'), 'utf8'); - const rateIdx = src.indexOf('app.use(rateLimitMiddleware('); - // Anchored on the MOUNT, not on a compare inside the gate body: the gate - // now lives in src/utils/api_guards.js, and an anchor that can go missing makes - // every ordering assertion below it argue from -1. - const authIdx = src.indexOf('app.use(authGateMiddleware('); - const routerIdx = src.indexOf('jsonRouter('); - assert.notStrictEqual(rateIdx, -1, 'rate limiter not mounted in src/api.js'); - assert.notStrictEqual(authIdx, -1, 'auth gate not mounted in src/api.js'); - assert.notStrictEqual(routerIdx, -1, 'jsonRouter mount missing from src/api.js'); - assert.ok(rateIdx < authIdx, 'the limiter must run before the auth gate so anonymous floods are bounded'); - assert.ok(rateIdx < routerIdx, 'the limiter must run before the router'); + // Read from the BUILT app, so a mount moved into a helper is placed by its call site. + const at = mountIndexes(assert, ['rateLimit', 'authGate', DISPATCH_NAME]); + assert.ok(at.rateLimit < at.authGate, 'the limiter must run before the auth gate so anonymous floods are bounded'); + assert.ok(at.rateLimit < at[DISPATCH_NAME], 'the limiter must run before the router'); }); it('keeps ONE implementation of the limiter: api.js holds no inline copy', () => { diff --git a/test/unit/api/helpers/mount_order.js b/test/unit/api/helpers/mount_order.js new file mode 100644 index 00000000..6dffcfe4 --- /dev/null +++ b/test/unit/api/helpers/mount_order.js @@ -0,0 +1,81 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * XChain SDK tests - runtime middleware mount order + * + * Reads the handler names of the app createApp() builds, in the order Express + * runs them. Guard-order tests assert on this rather than on byte offsets in + * src/api/index.js, because a guard mounted inside a helper runs where the + * helper is CALLED, which the source text does not show. + * + * The module runs in a CHILD process with a fixed environment and a neutral + * cwd, as api_listener.test.js does: it reads settings at require time and + * dotenv.config() would otherwise load a repo-root .env into the mocha process. + * + ********************************************************************/ + +'use strict'; + +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const MODULE = path.join(__dirname, '../../../../src/api/index.js'); + +// Name the JSON-RPC dispatch layer: express-json-rpc-router returns an anonymous handler. +const DISPATCH_NAME = 'jsonRpcDispatch'; + +const STACK_SCRIPT = [ + "const Module = require('module');", + 'const realLoad = Module._load;', + 'Module._load = function (request) {', + ' const loaded = realLoad.apply(this, arguments);', + " if (request !== 'express-json-rpc-router') return loaded;", + ' return function () {', + ' const handler = loaded.apply(this, arguments);', + ' return { ' + DISPATCH_NAME + '(req, res, next) { return handler(req, res, next); } }.' + DISPATCH_NAME + ';', + ' };', + '};', + 'const { createApp } = require(' + JSON.stringify(MODULE) + ');', + 'const stack = createApp({}).router.stack;', + "process.stdout.write(JSON.stringify(stack.map(l => (l.handle && l.handle.name) || '')));" +].join('\n'); + +// Return createApp's layer handler names in runtime order ('' for an anonymous layer). +function builtMountOrder() { + const env = { + PATH: process.env.PATH, + HOME: process.env.HOME, + NETWORK: 'bitcoin-regtest', + SDK_API_KEY: 'mount-order-test-key' + }; + const out = execFileSync(process.execPath, ['-e', STACK_SCRIPT], + { env, cwd: os.tmpdir(), timeout: 8000, encoding: 'utf8' }); + return JSON.parse(out); +} + +// Return the runtime index of each named layer, asserting every one is mounted exactly once. +function mountIndexes(assert, names) { + const order = builtMountOrder(); + const found = {}; + for (const name of names) { + const first = order.indexOf(name); + assert.notStrictEqual(first, -1, name + ' is not mounted on the built app: ' + JSON.stringify(order)); + assert.strictEqual(order.lastIndexOf(name), first, name + ' is mounted twice: ' + JSON.stringify(order)); + found[name] = first; + } + return found; +} + +module.exports = { builtMountOrder, mountIndexes, DISPATCH_NAME }; diff --git a/test/unit/protocol/batch_coinpay_output_plan.test.js b/test/unit/protocol/batch_coinpay_output_plan.test.js index ddccc58b..1f7edddb 100644 --- a/test/unit/protocol/batch_coinpay_output_plan.test.js +++ b/test/unit/protocol/batch_coinpay_output_plan.test.js @@ -206,3 +206,42 @@ describe(COINPAY_OUTPUT_PLAN_TITLE, function(){ }); }); + +describe(COINPAY_OUTPUT_PLAN_TITLE, function(){ + + describe('checkCoinpayOutputPlan exact comparison', function(){ + + it('FAILS an output one satoshi short of a large total, inside the mathjs tolerance band', function(){ + const result = checkCoinpayOutputPlan( + [{ payee: 'sellerA', amount: '100000.00000000' }], + [{ address: 'sellerA', amount: '99999.99999999' }] + ); + expect(result.ok).to.equal(false); + expect(result.violations).to.deep.equal([{ + payee: 'sellerA', + owed: '100000', + available: '99999.99999999', + reason: 'INSUFFICIENT', + }]); + }); + + it('FAILS an output one satoshi short of a small total', function(){ + const result = checkCoinpayOutputPlan( + [{ payee: 'sellerA', amount: '1.00000000' }], + [{ address: 'sellerA', amount: '0.99999999' }] + ); + expect(result.ok).to.equal(false); + expect(result.violations[0].reason).to.equal('INSUFFICIENT'); + }); + + it('PASSES an output exactly equal to a large total', function(){ + const result = checkCoinpayOutputPlan( + [{ payee: 'sellerA', amount: '100000.00000000' }], + [{ address: 'sellerA', amount: '100000' }] + ); + expect(result.ok).to.equal(true); + }); + + }); + +}); From 54450ce333af9ab779cae16be1b39e098e10a68a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 20:22:11 -0700 Subject: [PATCH 24/30] chore(pins): re-pin the vendored action manifest after the flag-doc pointer fix --- bin/pins/identity.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/pins/identity.json b/bin/pins/identity.json index fc171208..2db97bac 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -13,7 +13,7 @@ "src/checkpoint.js": "0b0e80f1372ef59fb6c8e7ec00a1f66d5e88ddca0a12584cadd087c1db595117", "src/contract/abi-core.js": "8222b193b5e65da7130af0bb589ff38d069f3a088f0d9f901826ffb18a3e9294", "test/fixtures/roundtrip-conformance.json": "d9963ac7c35bdcfab99595fec040d7791d73119cb62fef20db136f8ea56236ed", - "test/fixtures/action-manifest.json": "93ac85b4d76f078951a2e95eb3ca303f39fb9f18d0fb91b1ae16693716bef72f", + "test/fixtures/action-manifest.json": "05813d667976ec49bdeb001fc7f5aea41fce4f343a37576a2716d176b334a823", "test/unit/xcall-constants-cross-repo.test.js": "baa3bd32e8626a6b177c92f001455bf6922e4ec85ddad46456f5e8a6a903df96", "test/fixtures/gate-min-amount-vectors.json": "414a68df798c8eb24532abc4e0275326cc138019541a7cd4fb3b0221eef2b3c2" }, From 1a00adff910bb56d311cb06a3f10c4fbb04e5960 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 22 Sep 2026 20:49:13 -0700 Subject: [PATCH 25/30] docs(sdk): correct the reference-impl consensus headers and register the manifest sibling helper --- src/consensus/equivocation_header.js | 19 ++- src/consensus/snapshot_reorg_buffer.js | 13 +- src/consensus/stake_weighted_quorum.js | 17 ++- test/helpers/sibling_checkout.js | 123 ++++++++++++++++++ .../consensus_primitive_conformance.test.js | 10 +- .../action_manifest_conformance.test.js | 12 +- 6 files changed, 165 insertions(+), 29 deletions(-) create mode 100644 test/helpers/sibling_checkout.js diff --git a/src/consensus/equivocation_header.js b/src/consensus/equivocation_header.js index 48e11991..c043bdec 100644 --- a/src/consensus/equivocation_header.js +++ b/src/consensus/equivocation_header.js @@ -18,11 +18,15 @@ * EQUIV||||| * * The canonical source of record is - * xchain-documentation/protocol/reference-impl/equivocation_header.js; it is - * vendored BYTE-IDENTICALLY into xchain-hub, xchain-indexer, xchain-explorer, - * xchain-sdk and xchain-sync. Every PBFT/consensus engine prefixes its canonical - * through here, the settlement gates (cross_settle, xexec, xcall, anchor, price, - * attest) and the recovery verifier re-derive it to re-verify quorum signatures, + * xchain-indexer/src/consensus/equivocation_header.js; it is vendored + * BYTE-IDENTICALLY into xchain-hub, xchain-explorer, xchain-sdk, xchain-sync and + * xchain-documentation/protocol/reference-impl/consensus/equivocation_header.js. + * Edit the indexer copy only and re-run reconcile-twins.sh to re-vendor every + * other copy; never edit a vendored copy. + * + * Every PBFT/consensus engine prefixes its canonical through here, the settlement + * gates (cross_settle, xexec, xcall, anchor, price, attest) and the recovery + * verifier re-derive it to re-verify quorum signatures, * and the SLASH v0 action verifies equivocation proofs against it. Adding `` * makes equivocation provable WITHOUT false-positiving honest view changes (which * re-sign different content for the same round under a different view). Equivocation @@ -38,8 +42,9 @@ * the same anchor. The cross-service conformance suite * (ConsensusPrimitiveConformance.test.js, driven by * xchain-documentation/protocol/test-vectors) runs in every repo and asserts BOTH the - * behavior (canonical vectors) AND byte-identity of the local copy to this canonical - * source, so any unmirrored edit fails CI everywhere (a divergence forks the chain). + * behavior (canonical vectors) AND byte-identity of the local copy to the + * xchain-documentation copy, so any unmirrored edit fails CI everywhere (a + * divergence forks the chain). * ********************************************************************/ diff --git a/src/consensus/snapshot_reorg_buffer.js b/src/consensus/snapshot_reorg_buffer.js index 57e45d0d..0f000387 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 76fdd4ed..11665534 100644 --- a/src/consensus/stake_weighted_quorum.js +++ b/src/consensus/stake_weighted_quorum.js @@ -14,17 +14,20 @@ * * THE single, CONSENSUS-CRITICAL implementation of the stake-weighted quorum * predicate. The canonical source of record is - * xchain-documentation/protocol/reference-impl/stake_weighted_quorum.js; it is - * vendored BYTE-IDENTICALLY into xchain-hub, xchain-indexer, xchain-explorer, - * xchain-sdk and xchain-sync. Every PBFT tally engine, every settlement gate - * (cross_settle, xexec, xcall, anchor, price, attest), the recovery verifier and - * the client/explorer checkpoint verifiers resolve through this predicate so they - * can never drift; a divergence forks the chain. + * xchain-indexer/src/consensus/stake_weighted_quorum.js; it is vendored + * BYTE-IDENTICALLY into xchain-hub, xchain-explorer, xchain-sdk, xchain-sync and + * xchain-documentation/protocol/reference-impl/consensus/stake_weighted_quorum.js. + * Edit the indexer copy only and re-run reconcile-twins.sh to re-vendor every + * other copy; never edit a vendored copy. Every PBFT tally engine, every + * settlement gate (cross_settle, xexec, xcall, anchor, price, attest), the + * recovery verifier and the client/explorer checkpoint verifiers resolve through + * this predicate so they can never drift; a divergence forks the chain. * * The cross-service conformance suite (ConsensusPrimitiveConformance.test.js, * driven by xchain-documentation/protocol/test-vectors) runs in every repo and * asserts BOTH the behavior (canonical vectors) AND byte-identity of the local - * copy to this canonical source, so any unmirrored edit fails CI everywhere. + * copy to the xchain-documentation copy, so any unmirrored edit fails CI + * everywhere. * * Self-contained on mathjs bignumber (exact, never a JS double): the predicate is * a pure function with no injected utility instance, so it is identical in every diff --git a/test/helpers/sibling_checkout.js b/test/helpers/sibling_checkout.js new file mode 100644 index 00000000..afcbac81 --- /dev/null +++ b/test/helpers/sibling_checkout.js @@ -0,0 +1,123 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * May a cross-repo guard trust the sibling file it is about to read? + * + * The guards in this tree reach a sibling repo as `../../../xchain-`, + * which is only a statement about the directory layout, not about which + * commit of the sibling is sitting there. Two layouts give an honest answer: + * a main checkout beside its sibling main checkouts (a developer's tree), and + * a CI venue, which clones every declared sibling as a real directory beside + * the repo under test. A third layout gives a dishonest one: a linked worktree + * cut under a lane directory whose `xchain-` entries are SYMLINKS into + * the platform's main checkouts. There the path resolves into a peer + * session's live working tree, uncommitted edits and all, and a guard reads + * green against state no commit holds. On 2026-09-14 a cross-lineage + * falsification returned a false pass for exactly that reason. + * + * So a sibling is refused when it is absent, and also when this checkout is a + * linked worktree and the sibling entry beside it is a symlink whose target is + * a main checkout (its `.git` is a directory). A symlink into another linked + * worktree is allowed: that is a deliberately cut tree at a known ref. + * + * The verdict never decides soft versus strict on its own. Guards skip on a + * refusal in soft mode and fail naming the reason when the run declared its + * siblings supplied with XCHAIN_REQUIRE_SIBLINGS=1, which is what skipOrFail() + * does. Guards keep their own path literals, so every census that greps for + * `xchain-/...` still sees what each guard reads. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const OWN_ROOT = path.resolve(__dirname, '..', '..'); + +/** True when the sibling checkouts were declared supplied for this run. */ +function siblingsRequired(env = process.env) { + return env.XCHAIN_REQUIRE_SIBLINGS === '1'; +} + +/** A linked worktree carries a `.git` FILE pointing at its common dir. */ +function isLinkedWorktree(root) { + try { return fs.lstatSync(path.join(root, '.git')).isFile(); } + catch (e) { return false; } +} + +/** The nearest ancestor of `dir` (inclusive) that holds a `.git` entry, or null. */ +function checkoutRootOf(dir) { + for (let d = dir; ; d = path.dirname(d)) { + if (fs.existsSync(path.join(d, '.git'))) return d; + if (path.dirname(d) === d) return null; + } +} + +/** + * Judge one sibling path. + * + * @param {string} fromDir the directory the guard's literal is relative to (its __dirname) + * @param {string} target the guard's own path literal, relative or absolute + * @param {object} [opts] + * @param {string} [opts.ownRoot] the checkout the guard runs in; defaults to this repo + * @returns {{usable: boolean, path: string, reason: string|null}} + */ +function siblingCheckout(fromDir, target, opts = {}) { + const ownRoot = opts.ownRoot || OWN_ROOT; + const abs = path.resolve(fromDir, target); + + if (!fs.existsSync(abs)) + return { usable: false, path: abs, reason: 'sibling path absent: ' + abs }; + + // Only the entry directly beside this checkout can be the lane symlink. A + // path that does not pass through that parent is not a sibling reference. + const parent = path.dirname(ownRoot); + const rel = path.relative(parent, abs); + if (rel.startsWith('..') || path.isAbsolute(rel)) + return { usable: true, path: abs, reason: null }; + const entry = path.join(parent, rel.split(path.sep)[0]); + + if (!isLinkedWorktree(ownRoot) || !fs.lstatSync(entry).isSymbolicLink()) + return { usable: true, path: abs, reason: null }; + + const real = fs.realpathSync(entry); + const targetRoot = checkoutRootOf(real); + const targetIsMain = targetRoot !== null + && fs.lstatSync(path.join(targetRoot, '.git')).isDirectory(); + if (targetIsMain) + return { + usable: false, path: abs, + reason: 'sibling ' + path.basename(entry) + ' resolves through a symlink into the live main checkout ' + + targetRoot + ', which no commit pins; cut a real sibling worktree to test against it', + }; + return { usable: true, path: abs, reason: null }; +} + +/** + * Act on a refusal inside a mocha test or hook: fail when siblings were + * declared supplied, otherwise skip. Returns false so a caller can write + * `if (!verdict.usable) return skipOrFail(this, verdict, 'what this guard checks');`. + * + * @param {object} ctx the mocha `this` + * @param {{usable: boolean, reason: string|null}} verdict from siblingCheckout() + * @param {string} what one clause naming the guard, for the failure message + */ +function skipOrFail(ctx, verdict, what) { + if (verdict.usable) return true; + if (siblingsRequired()) + throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but ' + what + ' cannot run: ' + verdict.reason); + ctx.skip(); + return false; +} + +module.exports = { siblingCheckout, skipOrFail, siblingsRequired, isLinkedWorktree }; diff --git a/test/unit/consensus/consensus_primitive_conformance.test.js b/test/unit/consensus/consensus_primitive_conformance.test.js index a8f006f2..7539eb2c 100644 --- a/test/unit/consensus/consensus_primitive_conformance.test.js +++ b/test/unit/consensus/consensus_primitive_conformance.test.js @@ -18,11 +18,13 @@ // predicate and the equivocation-header builder are CONSENSUS-CRITICAL and // vendored byte-identically into five services (xchain-hub, xchain-indexer, // xchain-explorer, xchain-sdk, xchain-sync); a divergence in their logic forks -// the chain. The canonical source of record lives in xchain-documentation -// (protocol/reference-impl + protocol/test-vectors). This guard runs in every +// the chain. Their canonical source of record is xchain-indexer/src/consensus, +// which reconcile-twins.sh vendors into every other copy, including +// xchain-documentation/protocol/reference-impl/consensus; the canonical vectors +// live in xchain-documentation/protocol/test-vectors. This guard runs in every // repo and asserts BOTH: // 1. BEHAVIOR - the local copy matches the canonical vectors. -// 2. IDENTITY - the local copy is byte-identical to the canonical source. +// 2. IDENTITY - the local copy is byte-identical to the xchain-documentation copy. // (1) catches a logic change that happens to pass the local unit suite; (2) // catches ANY edit to one copy that was not propagated to the others. When the // sibling xchain-documentation repo is not checked out (standalone deploy), skip @@ -117,7 +119,7 @@ describe('consensus-primitive conformance: byte-identity to canonical source @re const canon = fs.readFileSync(path.join(CANON_DIR, 'consensus', f), 'utf8'); assert.strictEqual(local, canon, 'this repo\'s consensus/' + f + ' has drifted from the canonical source; ' + - 'edit xchain-documentation/protocol/reference-impl/consensus/' + f + ' and re-vendor all five copies.'); + 'edit xchain-indexer/src/consensus/' + f + ' and re-run reconcile-twins.sh to re-vendor every copy.'); }); }); diff --git a/test/unit/protocol/action_manifest_conformance.test.js b/test/unit/protocol/action_manifest_conformance.test.js index b37c72dd..7de2515a 100644 --- a/test/unit/protocol/action_manifest_conformance.test.js +++ b/test/unit/protocol/action_manifest_conformance.test.js @@ -31,6 +31,7 @@ const path = require('path'); const VENDORED = path.join(__dirname, '../..', 'fixtures', 'action-manifest.json'); const MANIFEST = JSON.parse(fs.readFileSync(VENDORED, 'utf8')); const Formats = require('../../../src/protocol/formats.js'); +const { siblingCheckout, skipOrFail } = require('../../helpers/sibling_checkout.js'); const EDIT_HINT = 'Edit xchain-documentation/protocol/action-manifest.json, re-vendor with ' + 'bin/sync-action-manifest.sh, or change src/protocol/formats.js.'; @@ -109,7 +110,8 @@ describe('ACTION manifest conformance: sdk userEncodable set @regression', funct describe('byte-identity to canonical manifest', function () { const DOCS = process.env.XCHAIN_DOCS_DIR || path.join(__dirname, '../..', '..', '..', 'xchain-documentation'); const CANON = path.join(DOCS, 'protocol', 'action-manifest.json'); - before(function () { if (!fs.existsSync(CANON)) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but canonical action-manifest.json not found at ' + CANON); this.skip(); } }); + // Refuses an absent docs checkout and a lane symlink into a live main checkout alike. + before(function () { const docs = siblingCheckout(__dirname, CANON); if (!docs.usable) skipOrFail(this, docs, 'the canonical action-manifest.json byte-identity guard'); }); it('vendored test/fixtures/action-manifest.json is byte-identical to canonical', function () { assert.strictEqual(fs.readFileSync(VENDORED, 'utf8'), fs.readFileSync(CANON, 'utf8'), 'vendored action-manifest.json drifted from canonical; edit ' + @@ -161,12 +163,10 @@ function registerIndexerHandlerAuditTests() { const INDEXER = process.env.XCHAIN_INDEXER_DIR || path.join(__dirname, '../..', '..', '..', 'xchain-indexer'); const ACTIONS_DIR = path.join(INDEXER, 'src', 'actions'); + // Refuses an absent indexer checkout and a lane symlink into a live main checkout alike. before(function () { - if (!fs.existsSync(ACTIONS_DIR)) { - if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') - throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but indexer handlers not found at ' + ACTIONS_DIR); - this.skip(); - } + const indexer = siblingCheckout(__dirname, ACTIONS_DIR); + if (!indexer.usable) skipOrFail(this, indexer, 'the indexer-handler audit'); }); // The indexer loader accepts flat handler modules and feature directories From 1340fdbaaa040175fe5597d6e47999e603b46df4 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 00:43:27 -0700 Subject: [PATCH 26/30] test(sdk): cover x402's fail-closed guard path and the published MCP load path middleware() now returns guard()'s result so a throwing verify() can be asserted to fail closed (500, or 503 for X402_STATE_CORRUPT) with next never called. mcp/test/ adds that coverage plus a pack+install check that requires xchain-mcp/server.js via its published path, not the in-repo relative fallback. publish.yml runs both packages' full ci tier (SDK with sibling checkouts, mcp's own ci) ahead of publish. --- .github/workflows/publish.yml | 14 ++++- mcp/package.json | 6 ++- mcp/test/packed_install.test.js | 90 +++++++++++++++++++++++++++++++ mcp/test/x402_fail_closed.test.js | 85 +++++++++++++++++++++++++++++ src/utils/x402.js | 1 + 5 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 mcp/test/packed_install.test.js create mode 100644 mcp/test/x402_fail_closed.test.js diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f41bdad5..5ed8a68b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,6 +15,11 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Check out declared sibling repositories + uses: XChain-Platform/.github/actions/checkout-siblings@master + with: + ref: master + - name: Use Node.js 22 uses: actions/setup-node@v6 with: @@ -46,8 +51,13 @@ jobs: - name: Install dependencies run: npm ci - - name: Test - run: npm test + - name: Test SDK + env: + XCHAIN_REQUIRE_SIBLINGS: "1" + run: npm run ci + + - name: Test MCP package + run: npm --prefix mcp run ci - name: Publish SDK run: npm publish --access public diff --git a/mcp/package.json b/mcp/package.json index f9bcab5c..ab5bbb82 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -33,7 +33,11 @@ "zod": "^4.4.3" }, "scripts": { - "start": "node cli.js" + "start": "node cli.js", + "test": "node --test test/x402_fail_closed.test.js", + "comment:ci:pack": "Publish-shape check: packs both packages and requires the MCP server via its published load path. Needs registry access to resolve the SDK's own dependency tree into a clean install root, so it is not part of `test`.", + "ci:pack": "node --test test/packed_install.test.js", + "ci": "npm test && npm run ci:pack" }, "engines": { "node": ">=22.0.0" diff --git a/mcp/test/packed_install.test.js b/mcp/test/packed_install.test.js new file mode 100644 index 00000000..90929b53 --- /dev/null +++ b/mcp/test/packed_install.test.js @@ -0,0 +1,90 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * + * Publish-shape check: pack both the SDK and the MCP package the way + * `npm publish` would, install the tarballs into a scratch project with no + * repo-relative escape hatch, and require the MCP package the way an + * installer would - 'xchain-mcp/server.js' - not the in-repo relative path + * mcp/server.js also supports for its own dev loop. + * + ********************************************************************/ + +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const test = require('node:test'); + +const repoRoot = path.resolve(__dirname, '..', '..'); +const mcpRoot = path.join(repoRoot, 'mcp'); + +function npm(args, options = {}) { + const npmCli = process.env.npm_execpath; + const command = npmCli ? process.execPath : 'npm'; + const commandArgs = npmCli ? [npmCli, ...args] : args; + return execFileSync(command, commandArgs, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + ...options, + }); +} + +function pack(packageRoot, destination) { + const result = JSON.parse(npm([ + 'pack', + '--json', + '--pack-destination', destination, + ], { + cwd: packageRoot, + env: { ...process.env, npm_config_cache: path.join(destination, '.npm-cache') }, + })); + assert.equal(result.length, 1); + return path.join(destination, result[0].filename); +} + +test('packed SDK and MCP packages load xchain-mcp/server.js after install', () => { + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xchain-mcp-pack-')); + try { + const sdkTarball = pack(repoRoot, installRoot); + const mcpTarball = pack(mcpRoot, installRoot); + npm([ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + sdkTarball, + mcpTarball, + ], { + cwd: installRoot, + env: { ...process.env, npm_config_cache: path.join(installRoot, '.npm-cache') }, + }); + + const loaded = execFileSync(process.execPath, ['-e', [ + "const server = require('xchain-mcp/server.js');", + "const sdk = require('@dankest-llc/xchain-sdk/package.json');", + "if (typeof server.buildServer !== 'function') process.exit(2);", + 'process.stdout.write(sdk.version);', + ].join('')], { + cwd: installRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + assert.equal(loaded, require('../../package.json').version); + } finally { + fs.rmSync(installRoot, { recursive: true, force: true }); + } +}); diff --git a/mcp/test/x402_fail_closed.test.js b/mcp/test/x402_fail_closed.test.js new file mode 100644 index 00000000..a2f8f944 --- /dev/null +++ b/mcp/test/x402_fail_closed.test.js @@ -0,0 +1,85 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * + * X402Gateway.guard()/middleware() fail-closed path: a throwing verify() + * must answer 500 (or 503 for X402_STATE_CORRUPT), return false, and never + * invoke `next`. Lives under mcp/ (a separately published, siblings-free + * package) so it needs only node:test, no mocha/sinon devDependency. + * + ********************************************************************/ + +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { X402Gateway, X402_VERSION } = require('../../src/utils/x402.js'); + +function response() { + return { + headers: {}, + statusCode: 200, + body: null, + setHeader(name, value) { this.headers[name] = value; }, + end(body) { this.body = body; }, + }; +} + +function paymentRequest() { + const proof = Buffer.from(JSON.stringify({ + x402Version: X402_VERSION, + scheme: 'xchain-send', + coin: 'TDOGE', + })).toString('base64url'); + return { headers: { 'x-payment': proof }, url: '/paid' }; +} + +function throwingGateway(code) { + const gateway = new X402Gateway({ + coin: 'TDOGE', + explorer: {}, + requireSignature: false, + send: { tick: 'TOK', amount: '1', payTo: 'gate' }, + invoiceStore: {}, + }); + gateway.verify = async () => { + const error = new Error('verification failed'); + if (code) error.code = code; + throw error; + }; + return gateway; +} + +for (const scenario of [ + { code: undefined, status: 500, responseCode: 'X402_ERROR' }, + { code: 'X402_STATE_CORRUPT', status: 503, responseCode: 'X402_STATE_CORRUPT' }, +]) { + test(`x402 middleware fails closed with ${scenario.status} when verify throws`, async () => { + const res = response(); + let nextCalls = 0; + const paid = await throwingGateway(scenario.code).middleware()( + paymentRequest(), + res, + () => { nextCalls += 1; }, + ); + + assert.equal(paid, false); + assert.equal(res.statusCode, scenario.status); + assert.equal(res.headers['Content-Type'], 'application/json'); + assert.deepEqual(JSON.parse(res.body), { + error: 'payment verification error', + code: scenario.responseCode, + }); + assert.equal(nextCalls, 0); + }); +} diff --git a/src/utils/x402.js b/src/utils/x402.js index 48d4de5a..89641d31 100644 --- a/src/utils/x402.js +++ b/src/utils/x402.js @@ -206,6 +206,7 @@ class X402Gateway { return async (req, res, next) => { const paid = await this.guard(req, res); if (paid && next) next(); + return paid; }; } From 209aa81e398c2cdf08475dbaae4589f8a603165d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 09:04:51 -0700 Subject: [PATCH 27/30] 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 sdk'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. --- 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 +++++++------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/consensus/gate_registry/shared_rows_1.js b/src/consensus/gate_registry/shared_rows_1.js index 742a811c..568d8ff5 100644 --- a/src/consensus/gate_registry/shared_rows_1.js +++ b/src/consensus/gate_registry/shared_rows_1.js @@ -295,11 +295,11 @@ addGate('anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S', 'constant', 6 // that window. addGate('anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION', 'height', { mainnet: null, // INERT under the 2026-08-29 mainnet write hold - // SIZED 2026-09-16 20:41Z, RE-SLID 2026-09-19, on the BTC clock because this member is - // BTC-only: the same instant as the family's BTC CONSUMER height, so the one member that - // keeps BOTH certificates gains them together rather than carrying a lone extra rule for - // 6 h. The canon carries the measurement. - testnet: 153328, + // SIZED 2026-09-16 20:41Z, RE-SLID 2026-09-19 and 2026-09-23, on the BTC clock because this + // member is BTC-only: the same instant as the family's BTC CONSUMER height, so the one + // member that keeps BOTH certificates gains them together rather than carrying a lone extra + // rule for 6 h. The canon carries the measurement. + testnet: 154291, regtest: UNPINNED, // shares the family's arming seam so one venue lever arms both }); diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index c245769c..a50d42c9 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -216,9 +216,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 153300, // RE-SLID 2026-09-19: train 153,221 + 79 blocks (17 h at 781.078553 s/blk), the v0.20.1 patch reslide + 'BTC:testnet': 154234, // RE-SLID 2026-09-23: train 154,074 + 160 blocks (17 h at 383.04 s/blk), the v0.20.1 patch reslide 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67916857, // RE-SLID 2026-09-19: tip 67,911,061 + 5796 blocks (41 h at 25.469118 s/blk, the same instant as the BTC producer), the v0.20.1 patch reslide + 'DOGE:testnet': 67942777, // RE-SLID 2026-09-23: tip 67,924,122 at 15:55Z + 18655 blocks (57 h at 11 s/blk median, the same instant as the BTC producer), the v0.20.1 patch reslide 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -228,9 +228,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 153328, // RE-SLID 2026-09-19: its producer + 28 blocks (6 h at 781.078553 s/blk), strictly above, never equal + 'BTC:testnet': 154291, // RE-SLID 2026-09-23: its producer + 57 blocks (6 h at 383.04 s/blk), strictly above, never equal 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67917706, // RE-SLID 2026-09-19: its producer + 849 blocks (6 h at 25.469118 s/blk) + 'DOGE:testnet': 67944741, // RE-SLID 2026-09-23: its producer + 1964 blocks (6 h at 11 s/blk median) 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration diff --git a/src/consensus/gate_registry/shared_rows_5.js b/src/consensus/gate_registry/shared_rows_5.js index 291fc4fe..26ada069 100644 --- a/src/consensus/gate_registry/shared_rows_5.js +++ b/src/consensus/gate_registry/shared_rows_5.js @@ -110,13 +110,13 @@ addGate('train_activation.TRAIN_ACTIVATION', 'ruleset', { // minute roll budget), and every testnet mirror-admission height sits above it on the same // BTC clock, so a node lacking this rule set halts before it can grade an admission-stamped // row. - // RE-SLID 2026-09-19 for the v0.20.1 patch train: margin is 24 h measured from the freeze, - // converted at each coin's own measured cadence, not five days. Chain_tip TBTC 153,110 + 111 - // blocks, ceil(24 h / 781.078553 s per block, least-squares bound over the trailing 114-block - // window, about 25.2 h span, at least as long as the lead). The mirror-admission family - // below re-slides onto the same instant plus its own 17 h and 6 h offsets. LTC stays null - // under dq4 (a) and is untouched by this reslide. - '0.20.0': { mainnet: 9999999999, testnet: 153221, regtest: 0 }, + // RE-SLID 2026-09-23 for the v0.20.1 patch train, after the live tips overran the + // 2026-09-19 slide before the freeze: margin is 40 h to the nearest armed height, converted + // at each coin's fastest defensible cadence. Chain_tip TBTC 153,698 at 2026-09-23T15:55Z + // + 376 blocks, ceil(40 h / 383.04 s per block, the least-squares bound). The + // mirror-admission family below re-slides onto the same instant plus its own 17 h and 6 h + // offsets. LTC stays null under dq4 (a) and is untouched by this reslide. + '0.20.0': { mainnet: 9999999999, testnet: 154074, regtest: 0 }, }); // xchain_bridge_activation From 0effe924083310c8719d09e8ce2ba7dfc04553ca Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 09:46:21 -0700 Subject: [PATCH 28/30] chore(release): v0.20.1 --- CHANGELOG.md | 6 ++++++ mcp/package.json | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2800f80..daa18846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.1] - 2026-09-23 + +### Fixed +- Re-slid BTC and DOGE testnet activations with LTC inert, added exact BET and Coinpay arithmetic, and emitted TIS v1.1.1 NFT documents. + + ## [0.20.0] - 2026-09-17 ### Added diff --git a/mcp/package.json b/mcp/package.json index ab5bbb82..cc53ac20 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,7 +1,7 @@ { "name": "xchain-mcp", "description": "Model Context Protocol (MCP) stdio server for the XChain Platform: read chain/token/balance data on BTC, LTC and DOGE (mainnet/testnet/regtest), and optionally submit policy-gated actions, backed by the XChain SDK.", - "version": "0.20.0", + "version": "0.20.1", "comment:homepage": "The npm package page links wherever this points, and it points at the platform site.", "homepage": "https://xchain.io/", "main": "server.js", @@ -28,7 +28,7 @@ "dogecoin" ], "dependencies": { - "@dankest-llc/xchain-sdk": "^0.20.0", + "@dankest-llc/xchain-sdk": "^0.20.1", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.4.3" }, diff --git a/package-lock.json b/package-lock.json index dc8b1ce7..fd4b15a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dankest-llc/xchain-sdk", - "version": "0.20.0", + "version": "0.20.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dankest-llc/xchain-sdk", - "version": "0.20.0", + "version": "0.20.1", "license": "AGPL-3.0-or-later", "dependencies": { "@bitcoinerlab/secp256k1": "^1.2.0", diff --git a/package.json b/package.json index 13f76d42..4f011972 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@dankest-llc/xchain-sdk", "description": "xchain-sdk generates XChain Platform ACTION commands and encodes them into unsigned blockchain transactions as well as pulls data from the XChain APIs such as balances and transaction history.", - "version": "0.20.0", + "version": "0.20.1", "comment:homepage": "The npm package page links wherever this points, and it points at the platform site (xchain.io's deep-link backlinks 301 away to tokenscan.io, so operator-controlled touchpoints are what still build its authority).", "homepage": "https://xchain.io/", "publishConfig": { From 453e6bbda1c5242c2d64a91f888464096ab7a7b3 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 10:57:40 -0700 Subject: [PATCH 29/30] 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 a50d42c9..2b87dd32 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -217,7 +217,7 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 154234, // RE-SLID 2026-09-23: train 154,074 + 160 blocks (17 h at 383.04 s/blk), the v0.20.1 patch reslide - 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train + 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train 'DOGE:testnet': 67942777, // RE-SLID 2026-09-23: tip 67,924,122 at 15:55Z + 18655 blocks (57 h at 11 s/blk median, the same instant as the BTC producer), the v0.20.1 patch reslide 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -229,7 +229,7 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'LTC:mainnet': null, 'DOGE:mainnet': null, 'BTC:testnet': 154291, // RE-SLID 2026-09-23: its producer + 57 blocks (6 h at 383.04 s/blk), strictly above, never equal - 'LTC:testnet': null, // dq4 (a), 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train + 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train 'DOGE:testnet': 67944741, // RE-SLID 2026-09-23: its producer + 1964 blocks (6 h at 11 s/blk median) 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration diff --git a/src/consensus/gate_registry/shared_rows_5.js b/src/consensus/gate_registry/shared_rows_5.js index 26ada069..fde6a96c 100644 --- a/src/consensus/gate_registry/shared_rows_5.js +++ b/src/consensus/gate_registry/shared_rows_5.js @@ -115,7 +115,8 @@ addGate('train_activation.TRAIN_ACTIVATION', 'ruleset', { // at each coin's fastest defensible cadence. Chain_tip TBTC 153,698 at 2026-09-23T15:55Z // + 376 blocks, ceil(40 h / 383.04 s per block, the least-squares bound). The // mirror-admission family below re-slides onto the same instant plus its own 17 h and 6 h - // offsets. LTC stays null under dq4 (a) and is untouched by this reslide. + // offsets. LTC:testnet mirror admission ships disabled on this train and is + // untouched by this reslide; it arms on a later train. '0.20.0': { mainnet: 9999999999, testnet: 154074, regtest: 0 }, }); From 6610bf327856f63d89b8efa1217c0fafdc11bf9b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 11:11:59 -0700 Subject: [PATCH 30/30] fix(consensus): re-slide DOGE:testnet mirror admission to the 84h trailing mean Operator ruling D11 (a) 2026-09-23: producer moves from 67942777 to 67936053 and consumer from 67944741 to 67936888, the same-instant rule computed off the 84h trailing block-time mean. BTC:testnet heights are unchanged; their comments now also carry the 84h trailing mean cadence bound. Byte-identical to the other five shared-row carriers. --- src/consensus/gate_registry/shared_rows_2.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js index 2b87dd32..0063a165 100644 --- a/src/consensus/gate_registry/shared_rows_2.js +++ b/src/consensus/gate_registry/shared_rows_2.js @@ -216,9 +216,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', { 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 154234, // RE-SLID 2026-09-23: train 154,074 + 160 blocks (17 h at 383.04 s/blk), the v0.20.1 patch reslide + 'BTC:testnet': 154234, // RE-SLID 2026-09-23: train 154,074 + 160 blocks (17 h at the 383.04 s/blk bound, 25.6 h at the 575.89 s/blk 84 h trailing mean), the v0.20.1 patch reslide 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67942777, // RE-SLID 2026-09-23: tip 67,924,122 at 15:55Z + 18655 blocks (57 h at 11 s/blk median, the same instant as the BTC producer), the v0.20.1 patch reslide + 'DOGE:testnet': 67936053, // RE-SLID 2026-09-23: tip 67,924,397 at 17:48Z + 11656 blocks (83.8 h at 25.89 s/blk, the 84 h trailing mean, the same instant as the BTC producer), the v0.20.1 patch reslide 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration @@ -228,9 +228,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei 'BTC:mainnet': null, 'LTC:mainnet': null, 'DOGE:mainnet': null, - 'BTC:testnet': 154291, // RE-SLID 2026-09-23: its producer + 57 blocks (6 h at 383.04 s/blk), strictly above, never equal + 'BTC:testnet': 154291, // RE-SLID 2026-09-23: its producer + 57 blocks (6 h at the 383.04 s/blk bound, 9.1 h at the 575.89 s/blk 84 h trailing mean), strictly above, never equal 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train - 'DOGE:testnet': 67944741, // RE-SLID 2026-09-23: its producer + 1964 blocks (6 h at 11 s/blk median) + 'DOGE:testnet': 67936888, // RE-SLID 2026-09-23: its producer + 835 blocks (6 h at 25.89 s/blk, the 84 h trailing mean), strictly above, never equal 'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration 'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration