From 3914f0e1fbfaf01905b90601ee1a2f94312cfced Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 16 Sep 2026 07:22:18 -0700 Subject: [PATCH 01/16] ci(e2e): boot the bitcoin gas rail beside the litecoin and dogecoin matrix legs Since xchain-e2e-test 8df86bb the only XCHAIN gas path onto a non-BTC chain is the bridge: the suite MINTs on BTC and locks it across with an XBRIDGE v0 (gasHelper.bridgeGasIn). A single-coin stack has no bitcoin hub config, so the litecoin and dogecoin legs of every dispatch since 2026-09-12 failed in initialCheck's beforeAll with "[chainRail] hub has no config for bitcoin/regtest" (runs 35102837148 and 35102839982; last green legs 34690792431, three hours before that commit). The two non-BTC legs now install the bitcoin regtest stack after the coin under test, at the same ref. Two stacks on one runner need distinct host ports (the default block collides at the second install's port check), so the leg writes config/-regtest and config/bitcoin-regtest with the blocks chainRail's DEFAULT_PORTS already expects, and BTC_SERVICE_HOST points the e2e container at the docker bridge gateway, the address the spawned containers already reach the runner's MariaDB on. Ports and that host only; credentials keep coming from the install's own .local sidecars. The bitcoin leg is unchanged. The test drives both step scripts under bash with a recording node stub and asserts the install calls, their order and the file contents, per leg. --- .github/workflows/nightly-e2e.yml | 64 ++++++++ .../two_stack_legs.test.js | 138 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js diff --git a/.github/workflows/nightly-e2e.yml b/.github/workflows/nightly-e2e.yml index f39e2437..6a3b2495 100644 --- a/.github/workflows/nightly-e2e.yml +++ b/.github/workflows/nightly-e2e.yml @@ -355,6 +355,60 @@ jobs: # settings file; the 0600 signing.key is not echoed). node src/index.js validator status + # WHY THE LITECOIN AND DOGECOIN LEGS ALSO BOOT A BITCOIN STACK. Since + # xchain-e2e-test 8df86bb (2026-09-12) the only way XCHAIN gas reaches a + # non-BTC chain is the bridge: the suite MINTs on BTC and locks it across with + # an XBRIDGE v0 (gasHelper.bridgeGasIn), because the bridge's supply-path + # closure refuses a local ISSUE of the gas tick off BTC even on regtest. A + # single-coin stack has no bitcoin hub config, so both legs failed in + # initialCheck's beforeAll with `[chainRail] hub has no config for + # bitcoin/regtest` on every dispatch after that commit (last green legs: run + # 34690792431, three hours before it). The bitcoin leg is unchanged: it + # still boots one stack and gasses in with a local MINT. + # + # Two stacks on one runner need two things the single-stack leg never did: + # + # 1. Distinct HOST ports. With no config/-regtest file every coin + # publishes the same default block (tracker 3001, decoder 3002, encoder + # 3003, indexer 3004, miner 3005), and the second install refuses at + # the collision check. The blocks below are the ones the e2e suite's + # chainRail.js DEFAULT_PORTS already expects (BTC 3020-3025, DOGE + # 3120-3125, LTC 3220-3225), the same files the operator rail carries. + # 2. A route from the e2e container to the bitcoin rail. chainRail reaches + # every BTC service through ONE host (BTC_SERVICE_HOST) at those + # published ports, so container DNS names cannot serve it; the docker + # bridge gateway can, and it is the address the spawned containers + # already reach the runner's MariaDB on. Every key in the coin's config + # file is injected into that coin's containers, the e2e runner included, + # which is how this one variable reaches the suite. + # + # Only ports and that host go in the files: credentials are generated into the + # untracked .local sidecars by the install itself, exactly as before. + - name: Publish distinct host ports for the two-stack legs + if: env.COIN != 'bitcoin' + run: | + case "$COIN" in + litecoin) BASE=3220 ;; + dogecoin) BASE=3120 ;; + *) echo "unexpected coin $COIN"; exit 1 ;; + esac + write_ports() { + # $1 = file, $2 = first port of the block, $3... = extra lines + local file="$1" base="$2"; shift 2 + { + echo "NODE_EXPOSED_PORT=$base" + echo "UTXO_TRACKER_PORT=$((base + 1))" + echo "DECODER_PORT=$((base + 2))" + echo "ENCODER_PORT=$((base + 3))" + echo "INDEXER_PORT=$((base + 4))" + echo "REGTEST_MINER_PORT=$((base + 5))" + for line in "$@"; do echo "$line"; done + } > "$file" + echo "--- $file"; cat "$file" + } + write_ports "config/$COIN-regtest" "$BASE" "BTC_SERVICE_HOST=$XCHAIN_NODE_EXTERNAL_DB_HOST" + write_ports config/bitcoin-regtest 3020 + - name: Boot the regtest stack (clones every service at ${{ github.event.inputs.ref || 'develop' }}) # First heavy step - repo clones + docker image builds + coin daemon. # Watch disk and image-build time here (the 120-min job timeout covers it). @@ -362,10 +416,20 @@ jobs: # already proven - it creates the xchain-node DB on 172.17.0.1:3306 before # this step. Service→DB networking (spawned containers → host DB) is the # remaining thing to confirm once the stack boots. + # + # The coin under test is installed first so the leg is byte-identical to + # the single-stack shape up to that point; the bitcoin gas rail follows + # on the non-BTC legs (see the ports step above for why). `install` is + # idempotent for the shared hub and explorer, so the second call adds the + # bitcoin coin stack and pushes both coins' config to the one hub. run: | mkdir -p "$XCHAIN_NODE_DATA_DIR" echo "Installing the stack at ref: $STACK_REF" node src/index.js install "$STACK_REF" all "$COIN" regtest + if [ "$COIN" != "bitcoin" ]; then + echo "Installing the bitcoin gas rail beside $COIN at ref: $STACK_REF" + node src/index.js install "$STACK_REF" all bitcoin regtest + fi - name: Run the e2e action suite # Now exits non-zero on failure (cli.js e2etest propagates the suite's diff --git a/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js b/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js new file mode 100644 index 00000000..89db3c99 --- /dev/null +++ b/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js @@ -0,0 +1,138 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. +// +// The `E2E (regtest)` matrix legs for litecoin and dogecoin boot a bitcoin stack +// beside the coin under test, because the bridge lock from BTC is the only gas +// path onto a non-BTC chain (xchain-e2e-test 8df86bb). This drives the two +// workflow steps that make that work, as bash with a recording `node` stub, so +// what is asserted is what the runner would do: which stacks get installed, in +// which order, and what the per-coin config files say. The port blocks are +// pinned to the values xchain-e2e-test/test/helpers/chainRail.js DEFAULT_PORTS +// expects (BTC 3020-3025, DOGE 3120-3125, LTC 3220-3225): a leg that published +// any other block would boot green and then fail to reach its own gas rail. + +const { expect } = require('chai') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { execFileSync } = require('child_process') +const yaml = require('js-yaml') + +const WORKFLOW = path.join(__dirname, '../../../.github/workflows/nightly-e2e.yml') + +const CHAIN_RAIL_DEFAULT_PORTS = { + bitcoin: { NODE_EXPOSED_PORT: 3020, UTXO_TRACKER_PORT: 3021, DECODER_PORT: 3022, ENCODER_PORT: 3023, INDEXER_PORT: 3024, REGTEST_MINER_PORT: 3025 }, + dogecoin: { NODE_EXPOSED_PORT: 3120, UTXO_TRACKER_PORT: 3121, DECODER_PORT: 3122, ENCODER_PORT: 3123, INDEXER_PORT: 3124, REGTEST_MINER_PORT: 3125 }, + litecoin: { NODE_EXPOSED_PORT: 3220, UTXO_TRACKER_PORT: 3221, DECODER_PORT: 3222, ENCODER_PORT: 3223, INDEXER_PORT: 3224, REGTEST_MINER_PORT: 3225 }, +} + +function loadSteps() { + const doc = yaml.load(fs.readFileSync(WORKFLOW, 'utf8')) + const steps = doc.jobs.e2e.steps + const find = (prefix) => { + const step = steps.find(s => typeof s.name === 'string' && s.name.startsWith(prefix)) + if (!step) throw new Error('workflow step not found: ' + prefix) + return step + } + return { ports: find('Publish distinct host ports'), boot: find('Boot the regtest stack') } +} + +// Parses KEY=VALUE lines the way ConfigService.getDefaultConfig reads a +// config/- file: first `=` splits, everything after is the value. +function parseConfigFile(file) { + const out = {} + for (const line of fs.readFileSync(file, 'utf8').split('\n')) { + const eq = line.indexOf('=') + if (eq > 0) out[line.substring(0, eq)] = line.substring(eq + 1) + } + return out +} + +// Runs one step's `run` script under bash in a scratch checkout with a `node` +// stub that appends its argv to a log, so an install is observed rather than +// performed. Returns the scratch dir, the stub's call log and the step output. +function runStep(step, env) { + const dir = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'nightly-e2e-')), 'checkout') + fs.mkdirSync(path.join(dir, 'config'), { recursive: true }) + const bin = path.join(dir, 'stub-bin') + fs.mkdirSync(bin) + const log = path.join(dir, 'node-calls.log') + fs.writeFileSync(path.join(bin, 'node'), '#!/usr/bin/env bash\nprintf "%s\\n" "$*" >> "' + log + '"\n', { mode: 0o755 }) + const stdout = execFileSync('bash', ['-e', '-c', step.run], { + cwd: dir, + env: Object.assign({ PATH: bin + ':' + process.env.PATH, HOME: dir }, env), + encoding: 'utf8', + }) + const calls = fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [] + return { dir, calls, stdout } +} + +describe('nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail)', function () { + let steps + before(function () { steps = loadSteps() }) + + it('gates the ports step off the bitcoin leg, whose single-stack shape stays as it was', function () { + expect(steps.ports.if).to.equal("env.COIN != 'bitcoin'") + }) + + for (const coin of ['litecoin', 'dogecoin']) { + describe(coin + ' leg', function () { + const env = { COIN: coin, STACK_REF: 'release/vX.Y.Z', XCHAIN_NODE_EXTERNAL_DB_HOST: '172.17.0.1' } + + it('writes the coin and bitcoin config files with the chainRail port blocks', function () { + const { dir } = runStep(steps.ports, env) + const own = parseConfigFile(path.join(dir, 'config', coin + '-regtest')) + const btc = parseConfigFile(path.join(dir, 'config', 'bitcoin-regtest')) + for (const [key, port] of Object.entries(CHAIN_RAIL_DEFAULT_PORTS[coin])) expect(own[key], coin + ' ' + key).to.equal(String(port)) + for (const [key, port] of Object.entries(CHAIN_RAIL_DEFAULT_PORTS.bitcoin)) expect(btc[key], 'bitcoin ' + key).to.equal(String(port)) + // Same host block twice would collide at the second install's port check. + expect(new Set([...Object.values(own), ...Object.values(btc)].filter(v => /^\d+$/.test(v))).size).to.equal(12) + }) + + it('routes the e2e container to the bitcoin rail through the docker bridge gateway', function () { + const { dir } = runStep(steps.ports, env) + const own = parseConfigFile(path.join(dir, 'config', coin + '-regtest')) + const btc = parseConfigFile(path.join(dir, 'config', 'bitcoin-regtest')) + expect(own.BTC_SERVICE_HOST).to.equal('172.17.0.1') + // The bitcoin stack's own containers need no such route. + expect(btc).to.not.have.property('BTC_SERVICE_HOST') + }) + + it('never writes a credential into either file (the install generates those into the .local sidecars)', function () { + const { dir } = runStep(steps.ports, env) + for (const file of [coin + '-regtest', 'bitcoin-regtest']) { + const keys = Object.keys(parseConfigFile(path.join(dir, 'config', file))) + expect(keys.filter(k => /USER|PASS|SECRET|KEY/.test(k)), file).to.deep.equal([]) + } + }) + + it('installs the coin under test first, then the bitcoin gas rail, both at the same ref', function () { + const { calls, dir } = runStep(steps.boot, Object.assign({ XCHAIN_NODE_DATA_DIR: path.join(os.tmpdir(), 'nightly-e2e-data-' + process.pid) }, env)) + expect(calls).to.deep.equal([ + 'src/index.js install release/vX.Y.Z all ' + coin + ' regtest', + 'src/index.js install release/vX.Y.Z all bitcoin regtest', + ]) + expect(dir).to.be.a('string') + }) + }) + } + + describe('bitcoin leg', function () { + it('boots exactly one stack, unchanged from the single-stack shape', function () { + const { calls } = runStep(steps.boot, { + COIN: 'bitcoin', STACK_REF: 'develop', + XCHAIN_NODE_DATA_DIR: path.join(os.tmpdir(), 'nightly-e2e-data-' + process.pid), + }) + expect(calls).to.deep.equal(['src/index.js install develop all bitcoin regtest']) + }) + }) +}) From 111f833a9b64ee64249b541b26e6532a6e26ec8b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 16 Sep 2026 07:30:35 -0700 Subject: [PATCH 02/16] ci(e2e): create config/ before writing the two-stack port files Every file under config/ is gitignored, so actions/checkout materializes no such directory and the ports step died on its first dispatch (run 35108669606: "config/litecoin-regtest: No such file or directory"). The test had created the directory in its scratch checkout, which is why it stayed green; it now runs the step in a checkout with no config/, exactly as the runner does, and the step creates the directory itself. --- .github/workflows/nightly-e2e.yml | 3 +++ test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nightly-e2e.yml b/.github/workflows/nightly-e2e.yml index 6a3b2495..65aea3ec 100644 --- a/.github/workflows/nightly-e2e.yml +++ b/.github/workflows/nightly-e2e.yml @@ -406,6 +406,9 @@ jobs: } > "$file" echo "--- $file"; cat "$file" } + # Every file under config/ is gitignored, so a fresh checkout has no + # such directory at all; ConfigService reads it by path, never creates it. + mkdir -p config write_ports "config/$COIN-regtest" "$BASE" "BTC_SERVICE_HOST=$XCHAIN_NODE_EXTERNAL_DB_HOST" write_ports config/bitcoin-regtest 3020 diff --git a/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js b/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js index 89db3c99..cadba0fe 100644 --- a/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js +++ b/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js @@ -60,9 +60,14 @@ function parseConfigFile(file) { // Runs one step's `run` script under bash in a scratch checkout with a `node` // stub that appends its argv to a log, so an install is observed rather than // performed. Returns the scratch dir, the stub's call log and the step output. +// +// The scratch checkout has NO config/ directory, like a real one: every file +// under config/ is gitignored, so actions/checkout never materializes it, and +// the first dispatch of this step died on exactly that (run 35108669606, +// "config/litecoin-regtest: No such file or directory"). function runStep(step, env) { const dir = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'nightly-e2e-')), 'checkout') - fs.mkdirSync(path.join(dir, 'config'), { recursive: true }) + fs.mkdirSync(dir, { recursive: true }) const bin = path.join(dir, 'stub-bin') fs.mkdirSync(bin) const log = path.join(dir, 'node-calls.log') From 0bbfc8a0d134f217425b028a1edb7548807cb631 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 16 Sep 2026 07:46:24 -0700 Subject: [PATCH 03/16] ci(e2e): raise the runner MariaDB connection cap for the two-stack legs mariadb:11 defaults max_connections to 151. Every service holds a pool of about ten (indexer, decoder, hub, the explorer's per-coin pools, the e2e runner's pool per rail); one coin stack fits under that and two do not. The first two-stack litecoin leg (run 35109600216) booted both stacks, the hub served bitcoin and litecoin, chainRail built the BTC rail and funded the BTC address, then died opening the BTC rail's indexer pool with "Too many connections". The CI DB is per-run and runner-local, so the cap protected nothing; 1000 is headroom. --- .github/workflows/nightly-e2e.yml | 9 ++++++++- .../nightly_e2e_workflow.test/two_stack_legs.test.js | 10 +++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly-e2e.yml b/.github/workflows/nightly-e2e.yml index 65aea3ec..e95f65c0 100644 --- a/.github/workflows/nightly-e2e.yml +++ b/.github/workflows/nightly-e2e.yml @@ -282,9 +282,16 @@ jobs: echo "XCHAIN_NODE_EXTERNAL_DB_ROOT_PASSWORD=$XCHAIN_NODE_EXTERNAL_DB_ROOT_PASSWORD" >> "$GITHUB_ENV" + # max_connections: the image default is 151. Every service holds a pool of + # about ten (indexer, decoder, hub, the explorer's per-coin pools, the e2e + # runner's pool per rail), which one coin stack fits under and two do not: + # the first two-stack litecoin leg (run 35109600216) died in initialCheck + # with `Too many connections` while opening the BTC rail's indexer pool. + # 1000 is headroom, not a measurement; this container is per-run and + # runner-local, so the cap protects nothing here. docker run -d --name xchain-ci-db \ -e MARIADB_ROOT_PASSWORD="$XCHAIN_NODE_EXTERNAL_DB_ROOT_PASSWORD" \ - -p 3306:3306 mariadb:11 + -p 3306:3306 mariadb:11 --max-connections=1000 # Wait for readiness before xchain-node tries to connect. for i in $(seq 1 30); do if docker exec xchain-ci-db mariadb-admin -uroot -p"$XCHAIN_NODE_EXTERNAL_DB_ROOT_PASSWORD" ping 2>/dev/null | grep -q "is alive"; then diff --git a/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js b/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js index cadba0fe..e59195c6 100644 --- a/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js +++ b/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js @@ -43,7 +43,7 @@ function loadSteps() { if (!step) throw new Error('workflow step not found: ' + prefix) return step } - return { ports: find('Publish distinct host ports'), boot: find('Boot the regtest stack') } + return { ports: find('Publish distinct host ports'), boot: find('Boot the regtest stack'), db: find('Start headless MariaDB') } } // Parses KEY=VALUE lines the way ConfigService.getDefaultConfig reads a @@ -89,6 +89,14 @@ describe('nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the expect(steps.ports.if).to.equal("env.COIN != 'bitcoin'") }) + it('raises the runner DB connection cap above what two stacks of ten-connection pools need', function () { + // mariadb:11 defaults to 151, which the first two-stack leg exhausted while + // opening the BTC rail's indexer pool (run 35109600216). + const m = /mariadb:11 --max-connections=(\d+)/.exec(steps.db.run) + expect(m, 'docker run mariadb:11 --max-connections=N').to.not.equal(null) + expect(parseInt(m[1], 10)).to.be.at.least(400) + }) + for (const coin of ['litecoin', 'dogecoin']) { describe(coin + ' leg', function () { const env = { COIN: coin, STACK_REF: 'release/vX.Y.Z', XCHAIN_NODE_EXTERNAL_DB_HOST: '172.17.0.1' } From 124dc1f8672c952b58616cd56ca50979cadd2594 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 16 Sep 2026 08:48:06 -0700 Subject: [PATCH 04/16] ci(e2e): run the litecoin and dogecoin legs in validator mode with a seeded local validator The two-stack legs gas in over the bridge lock from BTC, and a standalone hub never finalizes that transfer: startCrossChain returns before constructing CrossChainBridgeEngine without a peerManager, so runs 35115449692 and 35115452598 got the lock valid and debited with no credit on the destination. The validator init step now runs for every non-bitcoin leg without the input, and exports XDEX_SEED_LOCAL_VALIDATOR=1 for those legs only, because an identity alone signs against a cross_chain capability set that is empty on a fresh regtest. The bitcoin leg keeps its opt-in shape and local MINT gas path. --- .github/workflows/nightly-e2e.yml | 20 +++++++- .../two_stack_legs.test.js | 51 ++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly-e2e.yml b/.github/workflows/nightly-e2e.yml index e95f65c0..ba447a34 100644 --- a/.github/workflows/nightly-e2e.yml +++ b/.github/workflows/nightly-e2e.yml @@ -331,7 +331,16 @@ jobs: fi - name: Initialize the validator identity (opt-in; must precede install) - if: github.event.inputs.validator == 'true' + # The litecoin and dogecoin legs run in validator mode WITHOUT the input. + # Their only gas path is the bridge lock from BTC (see the two-stack note + # below), and a standalone hub never finalizes that transfer: startCrossChain + # returns before constructing CrossChainBridgeEngine when there is no + # peerManager, so the lock was `valid` and debited on BTC while the credit + # never landed on the destination (runs 35115449692 and 35115452598, hub + # log "Capability registry initialized (no identity; peer-receive only)"). + # The bitcoin leg keeps the opt-in shape: it gasses in with a local MINT and + # needs no bridge, and the price-regime caveat on the input still applies. + if: github.event.inputs.validator == 'true' || env.COIN != 'bitcoin' # MUST run before install: ConfigService reads the settings this writes # when it generates the hub's container env (getValidatorEnv) and the # e2e container's VALIDATOR_PUBKEY. Initializing afterwards would leave @@ -358,6 +367,15 @@ jobs: echo "HUB_NETWORK=regtest" echo "ORACLE_MIN_SUBMISSIONS=1" } >> "$GITHUB_ENV" + # An identity alone does not finalize a bridge transfer: the engine signs + # against the cross_chain capability set, which is EMPTY on a fresh regtest + # until something STAKEs, and an empty set fails closed. This host-env + # passthrough (ConfigService hubPassthroughVars) makes the hub seed itself + # as the single validator on an empty set; the hub honours it on regtest + # only. Non-bitcoin legs only, so the bitcoin opt-in keeps its regime. + if [ "$COIN" != "bitcoin" ]; then + echo "XDEX_SEED_LOCAL_VALIDATOR=1" >> "$GITHUB_ENV" + fi # Prints the pubkey and never the seed (validator status reads the # settings file; the 0600 signing.key is not echoed). node src/index.js validator status diff --git a/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js b/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js index e59195c6..adf22183 100644 --- a/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js +++ b/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js @@ -43,7 +43,12 @@ function loadSteps() { if (!step) throw new Error('workflow step not found: ' + prefix) return step } - return { ports: find('Publish distinct host ports'), boot: find('Boot the regtest stack'), db: find('Start headless MariaDB') } + return { + ports: find('Publish distinct host ports'), boot: find('Boot the regtest stack'), db: find('Start headless MariaDB'), + validator: find('Initialize the validator identity'), + // Step order matters for the validator step: it must precede the install. + order: steps.map(s => s.name || ''), + } } // Parses KEY=VALUE lines the way ConfigService.getDefaultConfig reads a @@ -139,6 +144,50 @@ describe('nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the }) } + // The bridged credit needs a hub that FINALIZES transfers, which a standalone + // hub never does: startCrossChain returns before constructing + // CrossChainBridgeEngine without a peerManager, and even with an identity the + // engine signs against the cross_chain capability set, empty on a fresh + // regtest until XDEX_SEED_LOCAL_VALIDATOR=1 seeds the hub's own key into it + // (xchain-hub src/cross_chain/bridge/plumbing.js resolveCapabilityValidators). + // Runs 35115449692 and 35115452598 got the BTC lock valid and debited with no + // credit on the destination for exactly this reason. + describe('validator mode on the two-stack legs', function () { + function runValidatorStep(coin) { + const githubEnv = path.join(os.tmpdir(), 'nightly-e2e-github-env-' + process.pid + '-' + coin) + fs.writeFileSync(githubEnv, '') + const out = runStep(steps.validator, { COIN: coin, GITHUB_ENV: githubEnv }) + out.exported = parseConfigFile(githubEnv) + return out + } + + it('runs the validator init for every non-bitcoin leg, and for bitcoin only on the opt-in input', function () { + expect(steps.validator.if).to.equal("github.event.inputs.validator == 'true' || env.COIN != 'bitcoin'") + }) + + it('initializes the identity BEFORE the ports and boot steps, which render the hub env from it', function () { + const at = (prefix) => steps.order.findIndex(n => n.startsWith(prefix)) + expect(at('Initialize the validator identity')).to.be.below(at('Publish distinct host ports')) + expect(at('Publish distinct host ports')).to.be.below(at('Boot the regtest stack')) + }) + + for (const coin of ['litecoin', 'dogecoin']) { + it(coin + ': inits a cross_chain-capable identity and exports the seed so the hub finalizes the gas lock', function () { + const { calls, exported } = runValidatorStep(coin) + expect(calls[0]).to.match(/^src\/index\.js validator init --oracle-epoch-start \d+ --capabilities [a-z_,]+$/) + expect(calls[0].split('--capabilities ')[1].split(',')).to.include('cross_chain') + expect(calls[calls.length - 1]).to.equal('src/index.js validator status') + expect(exported).to.deep.equal({ HUB_NETWORK: 'regtest', ORACLE_MIN_SUBMISSIONS: '1', XDEX_SEED_LOCAL_VALIDATOR: '1' }) + }) + } + + it('bitcoin (opt-in): keeps the identity but never seeds, so the input changes nothing beyond the price regime it documents', function () { + const { calls, exported } = runValidatorStep('bitcoin') + expect(calls[calls.length - 1]).to.equal('src/index.js validator status') + expect(exported).to.deep.equal({ HUB_NETWORK: 'regtest', ORACLE_MIN_SUBMISSIONS: '1' }) + }) + }) + describe('bitcoin leg', function () { it('boots exactly one stack, unchanged from the single-stack shape', function () { const { calls } = runStep(steps.boot, { From fa0614b2a9bb7cbc79da6ff0b0719a09603b97db Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 16 Sep 2026 09:27:43 -0700 Subject: [PATCH 05/16] fix(config): hand the e2e-test container the hub API key on a keyed host The harness discovers every rail's node and indexer credentials through the hub's getallconfigs, which a keyed hub gates behind HUB_API_KEY. A validator-mode host is keyed (validator init mints the key into the hub sidecar), and the hub, the indexer and the shared services all resolved it, but the e2e container was still deployed keyless: the litecoin and dogecoin matrix legs 401'd in initialCheck's beforeAll as "[chainRail] hub has no config for bitcoin/regtest" (run 35120852486) while the standalone bitcoin leg never noticed. Same resolution order as the indexer: host env first, then the sidecar, and a keyless host stays keyless. Three unit cases, the first two red without the passthrough. (cherry picked from commit 973cc2cc89dd037a2801f04f9ea06b75c0d050fb) --- CHANGELOG.md | 3 ++ src/services/config_service.js | 16 ++++++++ .../sidecar_and_bootstrap.test.js | 37 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a86fb87f..2aa68748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bumped the `@dankest-llc/xchain-sdk` dependency from 0.11.1 to 0.18.0. +### Fixed +- The e2e-test container receives the hub API key on a keyed (validator-mode) host, so its `getallconfigs` credential discovery no longer 401s. + ## [0.18.0] - 2026-09-11 ### Fixed diff --git a/src/services/config_service.js b/src/services/config_service.js index 6aa7be60..361f06d6 100644 --- a/src/services/config_service.js +++ b/src/services/config_service.js @@ -643,6 +643,22 @@ async function getDefaultConfig(module, coin, network) { if (validatorSettings && validatorSettings.pubkey) { defaultValues["VALIDATOR_PUBKEY"] = validatorSettings.pubkey } + + // The harness discovers every rail's node and indexer credentials through + // the hub's getallconfigs (test/helpers/chainRail.js), and a keyed hub + // gates that read behind HUB_API_KEY. A validator-mode host is keyed + // (`validator init` mints the key into the hub sidecar), so without this + // passthrough the e2e container was the one hub client on the host still + // calling keyless: the litecoin and dogecoin matrix legs 401'd in + // initialCheck's beforeAll (`[chainRail] hub has no config for + // bitcoin/regtest`, run 35120852486) while the standalone bitcoin leg, + // whose hub has no key, never noticed. Host env first, then the sidecar, + // exactly as the indexer and the shared services resolve it; a keyless + // host stays keyless. + if (config.HUB_API_KEY !== undefined && config.HUB_API_KEY !== "") { + defaultValues.HUB_API_KEY = config.HUB_API_KEY + } + await applyHubApiKeyFromSidecar(defaultValues) } // Genesis-ledger bootstrap env (xchain-indexer only). The indexer binds its diff --git a/test/unit/config_service.test/sidecar_and_bootstrap.test.js b/test/unit/config_service.test/sidecar_and_bootstrap.test.js index d48ad57c..0e44959f 100644 --- a/test/unit/config_service.test/sidecar_and_bootstrap.test.js +++ b/test/unit/config_service.test/sidecar_and_bootstrap.test.js @@ -84,6 +84,42 @@ function hubApiKeySidecar() { } +// The harness reads every rail's credentials through the hub's getallconfigs, +// which a keyed hub gates; the validator-mode matrix legs 401'd there while the +// hub, indexer and sync on the same host all carried the key (run 35120852486). +function hubApiKeyReachesTheE2eContainer() { + const SIDECAR_FIXTURE = 'sidecar-fixture-value-not-a-credential' + let saved + + beforeEach(function () { + saved = process.env.HUB_API_KEY + delete process.env.HUB_API_KEY + }) + afterEach(function () { + if (saved === undefined) delete process.env.HUB_API_KEY + else process.env.HUB_API_KEY = saved + }) + + it('gives the e2e-test container the sidecar key, so its config discovery is not the one keyless hub client', async function () { + const { cs } = makeMemoryConfigService({ [hubSidecar]: 'HUB_API_KEY=' + SIDECAR_FIXTURE + '\n' }) + const e2eCfg = await cs.getDefaultConfig(XChainService.XCHAIN_E2E_TEST, 'dogecoin', 'regtest') + expect(e2eCfg['HUB_API_KEY']).to.equal(SIDECAR_FIXTURE) + }) + + it('prefers a host-env key over the sidecar for the e2e-test container too', async function () { + process.env.HUB_API_KEY = 'host-env-fixture-value' + const { cs } = makeMemoryConfigService({ [hubSidecar]: 'HUB_API_KEY=' + SIDECAR_FIXTURE + '\n' }) + const e2eCfg = await cs.getDefaultConfig(XChainService.XCHAIN_E2E_TEST, 'dogecoin', 'regtest') + expect(e2eCfg['HUB_API_KEY']).to.equal('host-env-fixture-value') + }) + + it('leaves a keyless host keyless (a standalone regtest leg must not start sending a key nothing checks)', async function () { + const { cs } = makeMemoryConfigService() + const e2eCfg = await cs.getDefaultConfig(XChainService.XCHAIN_E2E_TEST, 'dogecoin', 'regtest') + expect(e2eCfg['HUB_API_KEY']).to.equal(undefined) + }) +} + function feeDestinationInjection() { const FEE_ENV = 'XCHAIN_FEE_DESTINATION_BTC_REGTEST' let savedPerCoin, savedGeneric @@ -206,6 +242,7 @@ describe('ConfigService', function () { describe('getDefaultConfig()', function () { describe('with coin and network (coin-specific config)', function () { describe('HUB_API_KEY from the shared hub sidecar', hubApiKeySidecar) + describe('HUB_API_KEY reaches the e2e-test container', hubApiKeyReachesTheE2eContainer) }) }) }) From 0697233848d16bb8b0bb73fe7843238dd49a856f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 16 Sep 2026 09:44:17 -0700 Subject: [PATCH 06/16] ci(e2e): hand the validator-mode hub its indexer URLs before the stacks exist The hub's cross-chain engines resolve each chain's indexer URL once at engine start, from env or from the configs table, and skip a chain with none on every later tick. On the runner the hub boots before either coin stack, so the table is empty at that moment and the seeded validator never polled the BTC lock: run 35122297316 got the lock valid and debited with "CrossChainBridge: no indexer URL for chain BTC/LTC" and the credit never landed. The validator init step now exports the in-network indexer container names on port 3004 for the leg's coin and for bitcoin, which the hub passthrough carries, the same shape the rail's .env gives its hub. Unit case extended on both legs. (cherry picked from commit f4f05042e16b0b8d13ee3a09642eddc138bdacf8) --- .github/workflows/nightly-e2e.yml | 16 ++++++++++++++++ .../two_stack_legs.test.js | 11 ++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nightly-e2e.yml b/.github/workflows/nightly-e2e.yml index ba447a34..4e089f3a 100644 --- a/.github/workflows/nightly-e2e.yml +++ b/.github/workflows/nightly-e2e.yml @@ -373,8 +373,24 @@ jobs: # passthrough (ConfigService hubPassthroughVars) makes the hub seed itself # as the single validator on an empty set; the hub honours it on regtest # only. Non-bitcoin legs only, so the bitcoin opt-in keeps its regime. + # The hub's cross-chain engines resolve each chain's indexer URL ONCE, at + # engine start, from env or from the configs table, and skip a chain with + # none on every later tick. On this runner the hub boots before either + # coin stack exists, so the table is empty at that moment and the seeded + # validator never polls the BTC lock (run 35122297316: lock valid and + # debited, "CrossChainBridge: no indexer URL for chain BTC/LTC", credit + # never landed). ConfigService's hub passthrough carries these names, and + # the hub joins every stack's docker network, so the in-network container + # names on the indexer's own port 3004 are reachable once the stacks are + # up; the same shape a regtest rail's .env gives its hub. if [ "$COIN" != "bitcoin" ]; then echo "XDEX_SEED_LOCAL_VALIDATOR=1" >> "$GITHUB_ENV" + code=$(case "$COIN" in litecoin) echo LTC ;; dogecoin) echo DOGE ;; esac) + { + echo "BTC_INDEXER_API_URL=http://xchain-node-bitcoin-regtest-xchain-indexer:3004" + echo "BTC_INDEXER_URL=http://xchain-node-bitcoin-regtest-xchain-indexer:3004" + echo "${code}_INDEXER_URL=http://xchain-node-$COIN-regtest-xchain-indexer:3004" + } >> "$GITHUB_ENV" fi # Prints the pubkey and never the seed (validator status reads the # settings file; the 0600 signing.key is not echoed). diff --git a/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js b/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js index adf22183..5296e26f 100644 --- a/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js +++ b/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js @@ -177,7 +177,16 @@ describe('nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the expect(calls[0]).to.match(/^src\/index\.js validator init --oracle-epoch-start \d+ --capabilities [a-z_,]+$/) expect(calls[0].split('--capabilities ')[1].split(',')).to.include('cross_chain') expect(calls[calls.length - 1]).to.equal('src/index.js validator status') - expect(exported).to.deep.equal({ HUB_NETWORK: 'regtest', ORACLE_MIN_SUBMISSIONS: '1', XDEX_SEED_LOCAL_VALIDATOR: '1' }) + // The indexer URLs ride the same export: the hub's cross-chain + // engines resolve them once at start, before either stack exists + // on this runner, so the configs table cannot supply them in time. + const code = { litecoin: 'LTC', dogecoin: 'DOGE' }[coin] + expect(exported).to.deep.equal({ + HUB_NETWORK: 'regtest', ORACLE_MIN_SUBMISSIONS: '1', XDEX_SEED_LOCAL_VALIDATOR: '1', + BTC_INDEXER_API_URL: 'http://xchain-node-bitcoin-regtest-xchain-indexer:3004', + BTC_INDEXER_URL: 'http://xchain-node-bitcoin-regtest-xchain-indexer:3004', + [code + '_INDEXER_URL']: 'http://xchain-node-' + coin + '-regtest-xchain-indexer:3004', + }) }) } From 214138562173aebb0edb8080342b2356b4c9ce0a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 16 Sep 2026 11:50:17 -0700 Subject: [PATCH 07/16] fix(hub): pass XCHAIN_CONFIRMATIONS_BTC/LTC/DOGE through to the hub, pinned to 1 on the nightly two-stack legs The hub's cross-chain engines propose a source leg only once it sits at the per-coin confirmation depth (coins/index.js resolveConfirmations), six blocks on BTC by default, and nothing on the nightly runner mines six BTC blocks behind a bridge lock: run 35124072478 held the litecoin leg's gas lock at "CrossChainBridge: not proposing BTC:3 (below depth 6)" until the suite gave up on the credit. The three names now ride the hub passthrough (hubPassthroughVars and the HUB_PASSTHROUGH_ENV view, which must mirror each other or the read throws), and the validator init step exports them at 1 for the litecoin and dogecoin legs, the way a regtest rail pins them. Inert on mainnet and testnet: the hub clamps a value below the per-coin default up to that default off regtest, so the passthrough can only raise the depth there. --- .github/workflows/nightly-e2e.yml | 10 ++++ CHANGELOG.md | 1 + src/config/env_views.js | 4 +- src/services/config_service.js | 9 +++ .../shared_service_guards.test.js | 58 +++++++++++++++++++ .../two_stack_legs.test.js | 5 ++ 6 files changed, 86 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nightly-e2e.yml b/.github/workflows/nightly-e2e.yml index 4e089f3a..a8555768 100644 --- a/.github/workflows/nightly-e2e.yml +++ b/.github/workflows/nightly-e2e.yml @@ -383,6 +383,13 @@ jobs: # the hub joins every stack's docker network, so the in-network container # names on the indexer's own port 3004 are reachable once the stacks are # up; the same shape a regtest rail's .env gives its hub. + # The engine then proposes a source leg only at the hub's per-coin + # confirmation depth, six blocks on BTC by default, and nothing on this + # runner mines six BTC blocks behind a lock: run 35124072478 reached + # "CrossChainBridge: not proposing BTC:3 (below depth 6)" and the suite + # gave up on the credit 120 s later. The depths are pinned to 1 the way a + # regtest rail pins them; the hub clamps them back up to the default off + # regtest, so the same three names are inert on any real network. if [ "$COIN" != "bitcoin" ]; then echo "XDEX_SEED_LOCAL_VALIDATOR=1" >> "$GITHUB_ENV" code=$(case "$COIN" in litecoin) echo LTC ;; dogecoin) echo DOGE ;; esac) @@ -390,6 +397,9 @@ jobs: echo "BTC_INDEXER_API_URL=http://xchain-node-bitcoin-regtest-xchain-indexer:3004" echo "BTC_INDEXER_URL=http://xchain-node-bitcoin-regtest-xchain-indexer:3004" echo "${code}_INDEXER_URL=http://xchain-node-$COIN-regtest-xchain-indexer:3004" + echo "XCHAIN_CONFIRMATIONS_BTC=1" + echo "XCHAIN_CONFIRMATIONS_LTC=1" + echo "XCHAIN_CONFIRMATIONS_DOGE=1" } >> "$GITHUB_ENV" fi # Prints the pubkey and never the seed (validator status reads the diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa68748..ae122fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - The e2e-test container receives the hub API key on a keyed (validator-mode) host, so its `getallconfigs` credential discovery no longer 401s. +- `XCHAIN_CONFIRMATIONS_BTC/LTC/DOGE` pass through the host env to the hub container (the hub clamps them up to its per-coin default off regtest), and the nightly litecoin and dogecoin legs pin them to 1 so the bridged gas lock finalizes on the next block. ## [0.18.0] - 2026-09-11 diff --git a/src/config/env_views.js b/src/config/env_views.js index 860c95dc..eb31c39e 100644 --- a/src/config/env_views.js +++ b/src/config/env_views.js @@ -45,7 +45,9 @@ const LIST_VIEWS = { "ANCHOR_ELECTION_TOLERANCE_BLOCKS", "ANCHOR_REWARD_PER_PUBLISH", "ANCHOR_CHECKPOINT_EVERY_N", "DOGE_ENCODER_URL", "DOGE_ENCODER_API_KEY", "DOGE_ADDRESS", "DOGE_PUBKEY_HEX", "DOGE_LOW_BALANCE_THRESHOLD", "XDEX_SEED_LOCAL_VALIDATOR", - "XDEX_SNAPSHOT_BLOCK", "HUB_TRUST_PROXY", "HUB_NETWORK", "ORACLE_MIN_SUBMISSIONS", + "XDEX_SNAPSHOT_BLOCK", + "XCHAIN_CONFIRMATIONS_BTC", "XCHAIN_CONFIRMATIONS_LTC", "XCHAIN_CONFIRMATIONS_DOGE", + "HUB_TRUST_PROXY", "HUB_NETWORK", "ORACLE_MIN_SUBMISSIONS", "ORACLE_ROUND_INTERVAL", "ORACLE_SUBMISSION_WINDOW", "ORACLE_BATCH_WINDOW_ROUNDS", "ORACLE_BATCH_GRACE_MS", "ORACLE_BATCH_SIGN_TIMEOUT_MS", "ORACLE_BATCH_BUFFER_MAX_ROUNDS", "ORACLE_BATCH_LANDING_RESERVE_MS", diff --git a/src/services/config_service.js b/src/services/config_service.js index 361f06d6..b5434429 100644 --- a/src/services/config_service.js +++ b/src/services/config_service.js @@ -1148,6 +1148,15 @@ async function getDefaultConfig(module, coin, network) { "DOGE_ENCODER_URL", "DOGE_ENCODER_API_KEY", "DOGE_ADDRESS", "DOGE_PUBKEY_HEX", "DOGE_LOW_BALANCE_THRESHOLD", "XDEX_SEED_LOCAL_VALIDATOR", "XDEX_SNAPSHOT_BLOCK", + // Per-coin confirmation depth the hub's cross-chain engines wait for + // before proposing a source leg (coins/index.js resolveConfirmations). + // A regtest venue pins these to 1 so a bridge lock finalizes on the + // next block instead of six BTC blocks nothing is mining (the nightly + // two-stack legs sat on "not proposing BTC:3 (below depth 6)" until + // the 120 s credit wait gave up). Inert on mainnet and testnet: the + // hub clamps a value below the per-coin default UP to that default + // off regtest, so this can only raise the depth on a real network. + "XCHAIN_CONFIRMATIONS_BTC", "XCHAIN_CONFIRMATIONS_LTC", "XCHAIN_CONFIRMATIONS_DOGE", // Reverse-proxy trust for the hub's express API (rate-limiter IP // keying). Default 'loopback' suits the Apache-on-same-host prod // topology; containerized hubs see the docker bridge as the peer, diff --git a/test/unit/config_service.test/shared_service_guards.test.js b/test/unit/config_service.test/shared_service_guards.test.js index 9e0625a8..fd08c5e0 100644 --- a/test/unit/config_service.test/shared_service_guards.test.js +++ b/test/unit/config_service.test/shared_service_guards.test.js @@ -200,6 +200,56 @@ function attestOverridePassthrough() { } +// The per-coin confirmation depth the hub's cross-chain engines gate a source +// leg on. A regtest venue pins it to 1 so a bridge lock finalizes on the next +// block; the hub clamps a value below the per-coin default up to that default +// off regtest, so the passthrough is inert on mainnet and testnet. This suite +// pins that the three names reach the hub config from the host env and that an +// unset one leaves the hub on its own default. +function confirmationsPassthrough() { + const CONFIRMATION_VARS = [ + 'XCHAIN_CONFIRMATIONS_BTC', 'XCHAIN_CONFIRMATIONS_LTC', 'XCHAIN_CONFIRMATIONS_DOGE' + ] + let saved + beforeEach(function () { + saved = {} + for (const k of CONFIRMATION_VARS) { saved[k] = process.env[k]; delete process.env[k] } + }) + afterEach(function () { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + }) + + it('injects all three XCHAIN_CONFIRMATIONS_* depths from host env into the hub config', async function () { + process.env.XCHAIN_CONFIRMATIONS_BTC = '1' + process.env.XCHAIN_CONFIRMATIONS_LTC = '1' + process.env.XCHAIN_CONFIRMATIONS_DOGE = '1' + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(HUB_MODULE_NAME, null, null) + expect(config['XCHAIN_CONFIRMATIONS_BTC']).to.equal('1') + expect(config['XCHAIN_CONFIRMATIONS_LTC']).to.equal('1') + expect(config['XCHAIN_CONFIRMATIONS_DOGE']).to.equal('1') + }) + + it('injects only the one depth set, leaving the other two absent', async function () { + process.env.XCHAIN_CONFIRMATIONS_BTC = '2' + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(HUB_MODULE_NAME, null, null) + expect(config['XCHAIN_CONFIRMATIONS_BTC']).to.equal('2') + expect(config).to.not.have.property('XCHAIN_CONFIRMATIONS_LTC') + expect(config).to.not.have.property('XCHAIN_CONFIRMATIONS_DOGE') + }) + + it('leaves all three depths absent when the host env carries none (hub default unchanged)', async function () { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(HUB_MODULE_NAME, null, null) + for (const k of CONFIRMATION_VARS) expect(config).to.not.have.property(k) + }) + +} + describe('ConfigService', function () { describe('getDefaultConfig()', function () { describe('without coin/network (shared service config)', function () { @@ -208,6 +258,14 @@ describe('ConfigService', function () { }) }) +describe('ConfigService', function () { + describe('getDefaultConfig()', function () { + describe('without coin/network (shared service config)', function () { + describe('XCHAIN_CONFIRMATIONS_* passthrough', confirmationsPassthrough) + }) + }) +}) + describe('ConfigService', function () { describe('getDefaultConfig()', function () { describe('without coin/network (shared service config)', function () { diff --git a/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js b/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js index 5296e26f..6385bffa 100644 --- a/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js +++ b/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js @@ -181,11 +181,16 @@ describe('nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the // engines resolve them once at start, before either stack exists // on this runner, so the configs table cannot supply them in time. const code = { litecoin: 'LTC', dogecoin: 'DOGE' }[coin] + // The confirmation depths ride it too: at the hub's default six + // BTC blocks nothing on the runner mines behind the lock, so the + // engine held the leg "below depth 6" for the whole credit wait + // (run 35124072478). The hub clamps these up off regtest. expect(exported).to.deep.equal({ HUB_NETWORK: 'regtest', ORACLE_MIN_SUBMISSIONS: '1', XDEX_SEED_LOCAL_VALIDATOR: '1', BTC_INDEXER_API_URL: 'http://xchain-node-bitcoin-regtest-xchain-indexer:3004', BTC_INDEXER_URL: 'http://xchain-node-bitcoin-regtest-xchain-indexer:3004', [code + '_INDEXER_URL']: 'http://xchain-node-' + coin + '-regtest-xchain-indexer:3004', + XCHAIN_CONFIRMATIONS_BTC: '1', XCHAIN_CONFIRMATIONS_LTC: '1', XCHAIN_CONFIRMATIONS_DOGE: '1', }) }) } From e79af6ea7b0098b5cac140e1ef798cbfafecb7f3 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 16 Sep 2026 13:01:10 -0700 Subject: [PATCH 08/16] fix(nightly): route the coin indexer to the bitcoin indexer for the bridge escrow proof on the two-stack legs With the depth pinned to 1 the hub finalized the gas lock (run 35140173657: "CrossChainBridge: finalized transfer ... BTC:3 -> DOGE 1 XCHAIN"), and the next layer showed: before it credits a bridged transfer the destination indexer fetches the escrow proof from the origin chain's indexer, resolved from its own env as BTC_INDEXER_API_URL, and with nothing wired it holds the block at the bridge proof barrier ("no origin-chain indexer endpoint is configured for the escrow proof") for the 900 s ceiling while the chain ran 143 blocks ahead. The ports step now writes that route into the coin's config file beside BTC_SERVICE_HOST, which reaches every container of the coin, the indexer included; the install already attaches the bitcoin indexer to the coin's docker network, so its container name on the indexer's own port serves. --- .github/workflows/nightly-e2e.yml | 19 ++++++++++++++++--- .../two_stack_legs.test.js | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/workflows/nightly-e2e.yml b/.github/workflows/nightly-e2e.yml index a8555768..2f51b266 100644 --- a/.github/workflows/nightly-e2e.yml +++ b/.github/workflows/nightly-e2e.yml @@ -432,9 +432,20 @@ jobs: # already reach the runner's MariaDB on. Every key in the coin's config # file is injected into that coin's containers, the e2e runner included, # which is how this one variable reaches the suite. + # 3. A route from the coin's INDEXER to the bitcoin indexer. Before it + # credits a bridged transfer the destination indexer fetches the escrow + # proof from the origin chain's indexer, resolved from its own env as + # BTC_INDEXER_API_URL (xchain-indexer bridge_proof_client transport, + # the hub's three-tier idiom); with nothing wired it holds the block at + # the bridge proof barrier instead of guessing. Run 35140173657 (the + # first leg past the depth gate) sat there for the 900 s hold ceiling, + # "no origin-chain indexer endpoint is configured for the escrow proof", + # while the chain ran 143 blocks ahead. The install attaches the bitcoin + # indexer to the coin's docker network, so its container name on the + # indexer's own port serves, the same value the hub is handed above. # - # Only ports and that host go in the files: credentials are generated into the - # untracked .local sidecars by the install itself, exactly as before. + # Only ports and those two routes go in the files: credentials are generated + # into the untracked .local sidecars by the install itself, exactly as before. - name: Publish distinct host ports for the two-stack legs if: env.COIN != 'bitcoin' run: | @@ -460,7 +471,9 @@ jobs: # Every file under config/ is gitignored, so a fresh checkout has no # such directory at all; ConfigService reads it by path, never creates it. mkdir -p config - write_ports "config/$COIN-regtest" "$BASE" "BTC_SERVICE_HOST=$XCHAIN_NODE_EXTERNAL_DB_HOST" + write_ports "config/$COIN-regtest" "$BASE" \ + "BTC_SERVICE_HOST=$XCHAIN_NODE_EXTERNAL_DB_HOST" \ + "BTC_INDEXER_API_URL=http://xchain-node-bitcoin-regtest-xchain-indexer:3004" write_ports config/bitcoin-regtest 3020 - name: Boot the regtest stack (clones every service at ${{ github.event.inputs.ref || 'develop' }}) diff --git a/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js b/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js index 6385bffa..c40c7ac8 100644 --- a/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js +++ b/test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js @@ -125,6 +125,20 @@ describe('nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the expect(btc).to.not.have.property('BTC_SERVICE_HOST') }) + it('routes the coin indexer to the bitcoin indexer for the bridge escrow proof', function () { + // The destination indexer fetches the escrow proof from the origin + // chain's indexer at BTC_INDEXER_API_URL before it credits a bridged + // transfer, and holds the block at the proof barrier when nothing is + // wired (run 35140173657: 900 s at bridge_proof_barrier, 143 blocks + // behind). The bitcoin indexer joins the coin's docker network, so + // its container name on the indexer's own port is the route. + const { dir } = runStep(steps.ports, env) + const own = parseConfigFile(path.join(dir, 'config', coin + '-regtest')) + const btc = parseConfigFile(path.join(dir, 'config', 'bitcoin-regtest')) + expect(own.BTC_INDEXER_API_URL).to.equal('http://xchain-node-bitcoin-regtest-xchain-indexer:3004') + expect(btc).to.not.have.property('BTC_INDEXER_API_URL') + }) + it('never writes a credential into either file (the install generates those into the .local sidecars)', function () { const { dir } = runStep(steps.ports, env) for (const file of [coin + '-regtest', 'bitcoin-regtest']) { From c553c7579510a960acd4b443a2b921af56b529bf Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 16 Sep 2026 13:01:54 -0700 Subject: [PATCH 09/16] docs(changelog): move the confirmations passthrough line back under Unreleased after the v0.19.0 level Levelling develop with master (2d4b33b) placed the 0.19.0 section above the Unreleased line that was already on develop, which left that line under a second "### Fixed" heading inside 0.19.0 beside a repeat of the hub API key entry. The passthrough is not in v0.19.0; it and the escrow-proof route sit under Unreleased, and the duplicate block is gone. --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ad6d18..442559e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- `XCHAIN_CONFIRMATIONS_BTC/LTC/DOGE` pass through the host env to the hub container (the hub clamps them up to its per-coin default off regtest), and the nightly litecoin and dogecoin legs pin them to 1 so the bridged gas lock finalizes on the next block. +- The nightly litecoin and dogecoin legs hand the coin's indexer the bitcoin indexer's address (`BTC_INDEXER_API_URL`), so the bridged gas credit's escrow proof can be fetched instead of holding the block at the bridge proof barrier. + ## [0.19.0] - 2026-09-16 ### Added @@ -22,10 +26,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `buildAndUp` refuses a bitcoin indexer or a validator-mode hub on a roll-call-armed network with no Dogecoin read (`DOGE_INDEXER_API_URL` or `DOGE_INDEXER_URL`), naming the armed height and the close it must prove, downgradable with `XCHAIN_NODE_ALLOW_NO_DOGE_READ=1`; `ps` now shows a stalled indexer or a halted tracker beside the decoder's halt instead of reporting three healthy rows. - The e2e-test container receives the hub API key on a keyed (validator-mode) host, so its `getallconfigs` credential discovery no longer 401s. -### Fixed -- The e2e-test container receives the hub API key on a keyed (validator-mode) host, so its `getallconfigs` credential discovery no longer 401s. -- `XCHAIN_CONFIRMATIONS_BTC/LTC/DOGE` pass through the host env to the hub container (the hub clamps them up to its per-coin default off regtest), and the nightly litecoin and dogecoin legs pin them to 1 so the bridged gas lock finalizes on the next block. - ## [0.18.0] - 2026-09-11 ### Fixed From db472dc38599802b554364477d5f877d4665eb0e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 16 Sep 2026 13:57:33 -0700 Subject: [PATCH 10/16] fix(bootstrap): resolve the signing pubkey from src/config after the service moved into src/services/bootstrap_service/ The code-structure pass moved BootstrapService.js into src/services/bootstrap_service/archive_signing.js but kept the key path one level short, so DEFAULT_BOOTSTRAP_PUBKEY_PATH aimed at src/services/config/bootstrap_signing_pubkey.pem, which does not exist. loadBootstrapPublicKey() therefore returned null unless XCHAIN_NODE_BOOTSTRAP_PUBKEY was set: a signed-bootstrap restore refused as "unsigned" and the auto-bootstrap install path fell back to a full sync from genesis. The signing suite never caught it because every case writes its own key pair to a tmpdir and pins it through the env override, so the repo-pinned default was never exercised. Four cases now drive the default path with real fs and no override: the key ships where the module looks, it parses as an ed25519 public key, it is byte-identical to the file in the tree, and checkBootstrapSignature() consults it (a signature from a foreign key is refused for the right reason). --- CHANGELOG.md | 1 + .../bootstrap_service/archive_signing.js | 6 +- test/unit/bootstrap_signing.test.js | 56 +++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 442559e2..038c73ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- The pinned bootstrap signing public key is resolved from `src/config/` again, so a signed-bootstrap restore verifies instead of refusing as unsigned and syncing from genesis. - `XCHAIN_CONFIRMATIONS_BTC/LTC/DOGE` pass through the host env to the hub container (the hub clamps them up to its per-coin default off regtest), and the nightly litecoin and dogecoin legs pin them to 1 so the bridged gas lock finalizes on the next block. - The nightly litecoin and dogecoin legs hand the coin's indexer the bitcoin indexer's address (`BTC_INDEXER_API_URL`), so the bridged gas credit's escrow proof can be fetched instead of holding the block at the bridge proof barrier. diff --git a/src/services/bootstrap_service/archive_signing.js b/src/services/bootstrap_service/archive_signing.js index 5aa13a3e..479f6aec 100644 --- a/src/services/bootstrap_service/archive_signing.js +++ b/src/services/bootstrap_service/archive_signing.js @@ -71,7 +71,11 @@ function configureDependencies(dependencies) { // multi-GB archives never need to be buffered). const BOOTSTRAP_SIG_SUFFIX = '.sig' -const DEFAULT_BOOTSTRAP_PUBKEY_PATH = path.join(__dirname, '../config/bootstrap_signing_pubkey.pem') +// Two levels up, not one: this module lives in src/services/bootstrap_service/, +// so the pinned key at src/config/ is __dirname/../../config. A single '..' aims +// at src/services/config/, which does not exist, and loadBootstrapPublicKey() +// then returns null and every restore refuses as "unsigned". +const DEFAULT_BOOTSTRAP_PUBKEY_PATH = path.join(__dirname, '..', '..', 'config', 'bootstrap_signing_pubkey.pem') // A restore that stops because the archive failed its provenance/integrity // gates is the gate WORKING, not the tool breaking. Left as a bare Error it diff --git a/test/unit/bootstrap_signing.test.js b/test/unit/bootstrap_signing.test.js index 3a06de2e..c5fa14b2 100644 --- a/test/unit/bootstrap_signing.test.js +++ b/test/unit/bootstrap_signing.test.js @@ -275,6 +275,62 @@ describe('Bootstrap signing', function () { }) }) +describe('Bootstrap signing', function () { + beforeEach(setupBootstrapSigningTest) + afterEach(teardownBootstrapSigningTest) + + // Every other case in this file pins XCHAIN_NODE_BOOTSTRAP_PUBKEY at a key + // the test itself wrote, so none of them ever touched the repo-pinned + // default. The code-structure pass moved the service a directory deeper and + // left the key path one level short, and the whole suite stayed green while + // the shipped trust anchor was unreachable. These cases exercise the DEFAULT + // path, with real fs and no override, which is the install-path behaviour. + describe('the repo-pinned default public key', function () { + + // Derived from the repo root, independently of the module's own + // __dirname arithmetic, so the two have to agree on where the key is. + const repoPinnedKeyPath = path.join(__dirname, '..', '..', 'src', 'config', 'bootstrap_signing_pubkey.pem') + + it('ships at src/config/bootstrap_signing_pubkey.pem', function () { + expect(fs.existsSync(repoPinnedKeyPath), `pinned key missing at ${repoPinnedKeyPath}`).to.be.true + }) + + it('loads with no env override and parses as an ed25519 public key', function () { + expect(process.env.XCHAIN_NODE_BOOTSTRAP_PUBKEY, 'override must be unset for this case').to.be.undefined + + const key = svc.loadBootstrapPublicKey() + expect(key, 'loadBootstrapPublicKey() returned null: the default path does not resolve to the pinned key').to.not.be.null + expect(key.type).to.equal('public') + expect(key.asymmetricKeyType).to.equal('ed25519') + }) + + it('is the same key the repository ships, not some other file', function () { + const loaded = svc.loadBootstrapPublicKey() + const onDisk = crypto.createPublicKey(fs.readFileSync(repoPinnedKeyPath, 'utf8')) + const loadedDer = loaded.export({ type: 'spki', format: 'der' }) + const onDiskDer = onDisk.export({ type: 'spki', format: 'der' }) + expect(loadedDer.equals(onDiskDer), 'default path resolved to a different key than the pinned one').to.be.true + }) + + it('verifies a real signature end to end through checkBootstrapSignature()', async function () { + // Sign with the private half of the pinned key is impossible (the + // secret never lives here), so instead prove the default anchor is + // what the policy path actually consults: a .sig made by another key + // must be REFUSED by the pinned default, with no override in play. + await svc.signBootstrapArchive(archivePath, privPath) + + let threw = false + try { + await svc.checkBootstrapSignature(archivePath) + } catch (err) { + threw = true + expect(err.message).to.match(/signature verification FAILED/) + } + expect(threw, 'the pinned default key must reject a foreign signature').to.be.true + }) + }) +}) + describe('Bootstrap signing', function () { beforeEach(setupBootstrapSigningTest) afterEach(teardownBootstrapSigningTest) From 4fec630e12524766684d316f43b1e6ddaed3a407 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 16 Sep 2026 17:04:29 -0700 Subject: [PATCH 11/16] fix(migration-precondition): add the v0.19.0+ db/migration CLI path MIGRATE_CLI_PATHS only knew the two pre-v0.19.0 layouts, so the deploy precondition guard read null and refused with no scoped command against a container running the current indexer layout. The newest path is now checked first, and the two older ones stay for older still-supported builds. A pinned-list test fails if a future indexer move drops a path this guard needs again. --- src/utils/indexer_migrate_cli.js | 11 ++--- test/unit/utils/indexer_migrate_cli.test.js | 49 +++++++++++++++++---- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/utils/indexer_migrate_cli.js b/src/utils/indexer_migrate_cli.js index bbbe96c0..4a9655cd 100644 --- a/src/utils/indexer_migrate_cli.js +++ b/src/utils/indexer_migrate_cli.js @@ -17,11 +17,12 @@ * found. ********************************************************************/ -// Newest first. The indexer moved the CLI from the top of src/ into its -// feature directory. The deploy guard reads the container being REPLACED, -// which can run a build from either side of that move, so both spellings stay -// readable for as long as a supported indexer build carries the old one. -const MIGRATE_CLI_PATHS = ['src/migration/migrate.js', 'src/migrate.js'] +// Newest first. The indexer has moved the CLI twice: from the top of src/ to +// src/migration/, then (v0.19.0) into src/db/migration/ alongside the rest of +// the db layer. The deploy guard reads the container being REPLACED, which +// can run a build from any of the three layouts, so every spelling stays +// readable for as long as a supported indexer build carries it. +const MIGRATE_CLI_PATHS = ['src/db/migration/migrate.js', 'src/migration/migrate.js', 'src/migrate.js'] // Container name -> the path its last successful read answered at. The remedy // the refusal prints runs on THAT build, so it has to name the path the read diff --git a/test/unit/utils/indexer_migrate_cli.test.js b/test/unit/utils/indexer_migrate_cli.test.js index 7c147d58..3c00c985 100644 --- a/test/unit/utils/indexer_migrate_cli.test.js +++ b/test/unit/utils/indexer_migrate_cli.test.js @@ -10,9 +10,9 @@ // Where the indexer's operator migration CLI is read from inside a container. // The deploy guard reads the container being REPLACED, which can run an indexer -// build from before or after the CLI moved into src/migration/, so the probe -// has to answer for both layouts and the refusal has to print the path that -// running build really carries. +// build from any of three layouts (top of src/, src/migration/, or the +// v0.19.0+ src/db/migration/), so the probe has to answer for all of them and +// the refusal has to print the path that running build really carries. const sinon = require('sinon') const { expect } = require('chai') @@ -24,9 +24,10 @@ const { assertRequiredMigrationsApplied } = require('../../../src/services/migration_precondition_service') -const GATED = '2026-07-24-pubkeys-widen-uncompressed.sql' -const NEW_PATH = 'src/migration/migrate.js' -const OLD_PATH = 'src/migrate.js' +const GATED = '2026-07-24-pubkeys-widen-uncompressed.sql' +const NEWEST_PATH = 'src/db/migration/migrate.js' +const NEW_PATH = 'src/migration/migrate.js' +const OLD_PATH = 'src/migrate.js' // Source text for a CLI that parses --file, and for one that predates it. const WITH_FILE = "if(a === '--file' || a === '-f'){ push(argv[i + 1]) }" @@ -44,12 +45,29 @@ function catFor(files) { describe('indexer migrate CLI location', () => { + // Pins the full candidate list, newest first, so a future indexer layout + // move that edits MIGRATE_CLI_PATHS without adding the new path (or drops + // an old one a still-supported build carries) fails here first, rather + // than silently reappearing as the 'could not be read' refusal on the + // next roll. + it('pins every known CLI layout, newest first', () => { + expect(MIGRATE_CLI_PATHS).to.deep.equal([NEWEST_PATH, NEW_PATH, OLD_PATH]) + }) + describe('readMigrateCli', () => { - it('reads the moved CLI before the pre-move path', async () => { + it('reads the v0.19.0+ db/migration layout before either older path', async () => { + const cat = catFor({ [NEWEST_PATH]: WITH_FILE, [NEW_PATH]: WITHOUT_FILE, [OLD_PATH]: WITHOUT_FILE }) + const found = await readMigrateCli(cat, 'c-newest') + expect(found).to.deep.equal({ cliPath: NEWEST_PATH, source: WITH_FILE }) + expect(cat.firstCall.args[1]).to.equal(NEWEST_PATH) + expect(migrateCliPathFor('c-newest')).to.equal(NEWEST_PATH) + }) + + it('reads the moved CLI before the pre-move path on a build without the newest layout', async () => { const cat = catFor({ [NEW_PATH]: WITH_FILE, [OLD_PATH]: WITHOUT_FILE }) const found = await readMigrateCli(cat, 'c-both') expect(found).to.deep.equal({ cliPath: NEW_PATH, source: WITH_FILE }) - expect(cat.firstCall.args[1]).to.equal(NEW_PATH) + expect(cat.firstCall.args[1]).to.equal(NEWEST_PATH) expect(migrateCliPathFor('c-both')).to.equal(NEW_PATH) }) @@ -128,5 +146,20 @@ describe('indexer migrate CLI location', () => { expect(message).to.contain('node ' + NEW_PATH + ' --file ' + GATED) expect(message).to.not.contain('node ' + OLD_PATH) }) + + it('names the v0.19.0+ db/migration CLI path when the running build carries that', async () => { + const message = await refusalFor({ [NEWEST_PATH]: WITH_FILE }) + expect(message, 'the deploy must be refused').to.not.equal(null) + expect(message).to.contain('node ' + NEWEST_PATH + ' --file ' + GATED) + expect(message).to.not.contain('node ' + NEW_PATH) + expect(message).to.not.contain('node ' + OLD_PATH) + }) + + it('still refuses, naming the newest layout as the remedy, when the container answers at none of the known paths', async () => { + const message = await refusalFor({}) + expect(message, 'the deploy must be refused').to.not.equal(null) + expect(message).to.contain('could not be read') + expect(message).to.contain('node ' + NEWEST_PATH) + }) }) }) From 4df75e02f28048968bff1d3c18dfeb33425c2531 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 16 Sep 2026 17:46:27 -0700 Subject: [PATCH 12/16] fix(bootstrap-service-test): point the healthy-listing probe at a fresh empty dir The 'exits non-zero when the listing fails' test also proves the listing command succeeds against a healthy directory, using os.tmpdir() directly. A long-lived dev box's TMPDIR can accumulate tens of thousands of entries, so `ls -A` against it runs past mocha's 2000ms budget and times out for a reason that has nothing to do with the code under test. Use a directory this test creates and removes instead, which keeps both assertions (the listing fails on a nonexistent path, succeeds on a healthy one) intact. --- test/unit/bootstrap_service.test.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/test/unit/bootstrap_service.test.js b/test/unit/bootstrap_service.test.js index 35c01bf6..c9fb1e0b 100644 --- a/test/unit/bootstrap_service.test.js +++ b/test/unit/bootstrap_service.test.js @@ -345,7 +345,9 @@ describe('BootstrapService', function () { afterEach(restoreRequireSignedBootstrapSetting) describe('utxoTrackerVolumeFreshness()', function () { it('uses a listing command that exits non-zero when the listing fails', async function () { + const fs = require('fs') const os = require('os') + const path = require('path') const { spawnSync } = require('child_process') const stubs = makeStubs() let lastArgs = null @@ -365,10 +367,17 @@ describe('BootstrapService', function () { expect(failed.status).to.not.equal(0) // A healthy directory must still succeed, or every probe answers unknown. - const healthy = spawnSync('/bin/sh', - ['-c', bs.UTXO_TRACKER_LISTING_COMMAND.replace('/data', os.tmpdir())], - { encoding: 'utf8' }) - expect(healthy.status).to.equal(0) + // Use a dir this test creates, not the shared OS tmpdir: a crowded + // TMPDIR makes `ls -A` outrun the mocha timeout on a long-lived box. + const healthyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xchain-freshness-probe-')) + try { + const healthy = spawnSync('/bin/sh', + ['-c', bs.UTXO_TRACKER_LISTING_COMMAND.replace('/data', healthyDir)], + { encoding: 'utf8' }) + expect(healthy.status).to.equal(0) + } finally { + fs.rmSync(healthyDir, { recursive: true, force: true }) + } }) }) }) From a4ff20ab8b9d5e1beee507bcc1b5d11649ec0476 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 16 Sep 2026 18:30:47 -0700 Subject: [PATCH 13/16] fix(ci): roster-driven need_sib and honest sibling guards in coverage bin/ci-full.sh hard-coded need_sib to xchain-hub alone, so a hand run missing xchain-indexer sailed past the gate and only broke later, confusingly, inside npm run ci. It now reads .ci-siblings (the same roster ci-reusable.yml's sibling-checkout step reads) so the two cannot drift, and arms XCHAIN_REQUIRE_SIBLINGS for the ci tier the same way ci-reusable.yml arms it once siblings are checked out. The coverage CI job re-ran the unit suite with no siblings checked out and no XCHAIN_REQUIRE_SIBLINGS, so every cross-repo guard silently skipped there: green by absence, not by the guard actually running. Mirrors ci-reusable.yml's own sibling-checkout step verbatim so coverage now measures the same suite the ci job runs, and only arms the flag when the checkout genuinely happened. --- .github/workflows/ci.yml | 35 ++++++ bin/ci-full.sh | 23 +++- .../sibling_roster.test.js | 113 ++++++++++++++++++ 3 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 test/unit/ci_full_sibling_roster.test/sibling_roster.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7beaa05..d63f8ceb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,36 @@ jobs: steps: - uses: actions/checkout@v4 + # Re-runs the same suite the `ci` job runs, so it needs the same + # sibling checkout that job's shared workflow does elsewhere. + # Mirrored verbatim (same roster, same clone) so they cannot drift. + - name: Check out declared sibling repositories + id: siblings + env: + SIBLINGS_REF: ${{ github.ref == 'refs/heads/master' && 'master' || 'develop' }} + OWNER: ${{ github.repository_owner }} + run: | + set -euo pipefail + if [ ! -f .ci-siblings ]; then + echo "no .ci-siblings in this repo; single-repo layout" + exit 0 + fi + repos=$(sed 's/#.*//' .ci-siblings | tr -d '\r' | awk 'NF') + cd "$GITHUB_WORKSPACE/.." + for repo in $repos; do + rm -rf "$repo" + url="https://github.com/$OWNER/$repo.git" + git clone --quiet --depth 1 --branch "$SIBLINGS_REF" "$url" "$repo" 2>/dev/null \ + || git clone --quiet --depth 1 "$url" "$repo" + echo "sibling $repo @ $(git -C "$repo" rev-parse --abbrev-ref HEAD) $(git -C "$repo" rev-parse --short HEAD)" + if [ -f "$repo/package.json" ]; then + ( cd "$repo" && npm ci --omit=dev --ignore-scripts --no-audit --no-fund >/dev/null 2>&1 ) \ + || ( cd "$repo" && npm install --omit=dev --ignore-scripts --no-audit --no-fund >/dev/null 2>&1 ) \ + || echo "sibling $repo: dependency install failed, guards needing its modules will say so" + fi + done + echo "checked-out=1" >> "$GITHUB_OUTPUT" + - name: Use Node.js 22 uses: actions/setup-node@v4 with: @@ -104,7 +134,12 @@ jobs: # pool, so the fetch 403s, installModules throws a ReleaseIntegrityError # before the bootstrap summary is ever reported, and the failure surfaces as # an unrelated "expected false to be true" on a stub assertion. + # + # Armed only when the step above actually checked siblings out, + # so a repo with none still reports real coverage instead of + # failing on an environment gap coverage:check did not create. - name: Coverage thresholds (c8 --check-coverage) env: + XCHAIN_REQUIRE_SIBLINGS: ${{ steps.siblings.outputs.checked-out == '1' && '1' || '' }} GITHUB_TOKEN: ${{ github.token }} run: npm run coverage:check diff --git a/bin/ci-full.sh b/bin/ci-full.sh index 36f8e9b4..74dc594e 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -62,10 +62,29 @@ need_sib() { done } -need_sib xchain-hub +# Read the roster ci-reusable.yml's own sibling-checkout step reads +# (.ci-siblings) with the same parse, instead of a second hard-coded +# list the two could drift behind. +CI_SIBLINGS_FILE="$SELF/.ci-siblings" +DECLARED_SIBLINGS=() +if [ -f "$CI_SIBLINGS_FILE" ]; then + while IFS= read -r s; do + DECLARED_SIBLINGS+=("$s") + done < <(sed 's/#.*//' "$CI_SIBLINGS_FILE" | tr -d '\r' | awk 'NF') +fi +if [ "${#DECLARED_SIBLINGS[@]}" -gt 0 ]; then + need_sib "${DECLARED_SIBLINGS[@]}" +fi # --- job: ci (XChain-Platform/.github ci-reusable.yml -> npm run ci) ------- -run_tier "ci" npm run ci +# ci-reusable.yml arms XCHAIN_REQUIRE_SIBLINGS whenever it checked +# siblings out, so every sibling guard fails loud on a miss instead of +# skipping; match that here for a true local twin. +if [ "${#DECLARED_SIBLINGS[@]}" -gt 0 ]; then + run_tier "ci" env XCHAIN_REQUIRE_SIBLINGS=1 npm run ci +else + run_tier "ci" npm run ci +fi # --- job: drift-guards ------------------------------------------------------- # Run FROM the parent so sync-coins.sh sees the canonical + vendored pair the diff --git a/test/unit/ci_full_sibling_roster.test/sibling_roster.test.js b/test/unit/ci_full_sibling_roster.test/sibling_roster.test.js new file mode 100644 index 00000000..d868401d --- /dev/null +++ b/test/unit/ci_full_sibling_roster.test/sibling_roster.test.js @@ -0,0 +1,113 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. +// +// Drives the real bin/ci-full.sh (copied beside a scratch sibling layout, +// never the real ../xchain-indexer checkout) to prove a declared-but-missing +// sibling fails loud and by name at need_sib, before any tier runs. The +// coverage-job half is asserted structurally against the parsed workflow, +// the way test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js does. + +const { expect } = require('chai') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { execFileSync } = require('child_process') +const yaml = require('js-yaml') + +const REPO_ROOT = path.join(__dirname, '../../..') +const CI_FULL = path.join(REPO_ROOT, 'bin/ci-full.sh') +const WORKFLOW = path.join(REPO_ROOT, '.github/workflows/ci.yml') +const REAL_CI_SIBLINGS = fs.readFileSync(path.join(REPO_ROOT, '.ci-siblings'), 'utf8') + +// A throwaway `xchain-node/` plus declared siblings, laid out the way +// bin/ci-full.sh expects, with a stub `npm` so the "ci" tier does not +// try to run the real suite. +function makeFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-full-sib-')) + const nodeDir = path.join(root, 'xchain-node', 'bin') + fs.mkdirSync(nodeDir, { recursive: true }) + fs.copyFileSync(CI_FULL, path.join(nodeDir, 'ci-full.sh')) + fs.chmodSync(path.join(nodeDir, 'ci-full.sh'), 0o755) + fs.writeFileSync(path.join(root, 'xchain-node', '.ci-siblings'), REAL_CI_SIBLINGS) + for (const sib of ['xchain-hub', 'xchain-indexer']) fs.mkdirSync(path.join(root, sib)) + + const fakebin = path.join(root, 'fakebin') + fs.mkdirSync(fakebin) + const npmStub = '#!/usr/bin/env bash\n' + + 'echo "FAKE-NPM args=[$*] XCHAIN_REQUIRE_SIBLINGS=[${XCHAIN_REQUIRE_SIBLINGS:-unset}]"\n' + + 'exit 0\n' + fs.writeFileSync(path.join(fakebin, 'npm'), npmStub) + fs.chmodSync(path.join(fakebin, 'npm'), 0o755) + + return { root, fakebin, scriptPath: path.join(nodeDir, 'ci-full.sh') } +} + +function runCiFull({ scriptPath, fakebin }) { + try { + const stdout = execFileSync('bash', [scriptPath], { + env: { ...process.env, PATH: fakebin + ':' + process.env.PATH }, + encoding: 'utf8', + }) + return { status: 0, stdout, stderr: '' } + } catch (err) { + return { status: err.status, stdout: err.stdout || '', stderr: err.stderr || '' } + } +} + +describe('bin/ci-full.sh sibling roster', () => { + it('reads .ci-siblings instead of a second hard-coded list', () => { + const src = fs.readFileSync(CI_FULL, 'utf8') + expect(src).to.match(/\.ci-siblings/, 'ci-full.sh no longer cites the roster file') + expect(src).to.not.match(/^need_sib xchain-hub\s*$/m, + 'ci-full.sh still hard-codes need_sib to a single sibling instead of the roster') + }) + + it('passes need_sib and arms XCHAIN_REQUIRE_SIBLINGS when every declared sibling is present', () => { + const fx = makeFixture() + const res = runCiFull(fx) + fs.rmSync(fx.root, { recursive: true, force: true }) + + expect(res.stdout).to.include('ci:full ===== ci =====', 'never reached the ci tier') + expect(res.stdout).to.include('XCHAIN_REQUIRE_SIBLINGS=[1]', + 'ci tier ran without the flag ci-reusable.yml arms when siblings are checked out') + }) + + it('FALSIFIES: refuses at need_sib, naming the missing sibling, when xchain-indexer is absent', () => { + const fx = makeFixture() + fs.rmSync(path.join(fx.root, 'xchain-indexer'), { recursive: true, force: true }) + const res = runCiFull(fx) + fs.rmSync(fx.root, { recursive: true, force: true }) + + expect(res.status).to.equal(1, 'a missing declared sibling must fail the run') + expect(res.stderr).to.include('MISSING SIBLING') + expect(res.stderr).to.include('xchain-indexer', 'refusal did not name the missing sibling') + expect(res.stdout).to.not.include('ci:full ===== ci =====', + 'this must fail at need_sib, before any tier runs') + }) +}) + +describe('.github/workflows/ci.yml coverage job', () => { + const doc = yaml.load(fs.readFileSync(WORKFLOW, 'utf8')) + const steps = doc.jobs.coverage.steps + + it('checks declared siblings out before re-running the unit suite for coverage', () => { + const siblingStep = steps.find((s) => s.id === 'siblings') + expect(siblingStep, 'coverage job has no sibling-checkout step').to.exist + expect(siblingStep.run).to.include('.ci-siblings') + expect(siblingStep.run).to.include('GITHUB_OUTPUT') + }) + + it('arms XCHAIN_REQUIRE_SIBLINGS for coverage:check only when the checkout actually happened', () => { + const coverageStep = steps.find((s) => s.name === 'Coverage thresholds (c8 --check-coverage)') + expect(coverageStep.env.XCHAIN_REQUIRE_SIBLINGS).to.include('steps.siblings.outputs.checked-out') + }) +}) From b99812153aa857aa968cee5a15769c2a03743adf Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 16 Sep 2026 22:20:05 -0700 Subject: [PATCH 14/16] feat(config): pass XC_MIRROR_ADMISSION_ACTIVATION through to regtest hub and indexer containers Same shape as the rollcall gates lever: regtest only, absent on testnet and mainnet. Without it the admission-height crossing could not be armed on the rail. --- CHANGELOG.md | 3 + src/config/env_views.js | 4 +- src/services/config_service.js | 15 +++- .../get_default_config_regtest.test.js | 3 +- .../rollcall_and_mirror.test.js | 70 +++++++++++++++++++ 5 files changed, 91 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5420f87..1d1e9143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `XC_MIRROR_ADMISSION_ACTIVATION` passes through to both the hub and the regtest indexer, so a rail venue can arm the admission-map mirror producer, consumer and anchor-attest barrier together at one height. + ### Fixed - `XCHAIN_CONFIRMATIONS_BTC/LTC/DOGE` pass through the host env to the hub container (the hub clamps them up to its per-coin default off regtest), and the nightly litecoin and dogecoin legs pin them to 1 so the bridged gas lock finalizes on the next block. - The nightly litecoin and dogecoin legs hand the coin's indexer the bitcoin indexer's address (`BTC_INDEXER_API_URL`), so the bridged gas credit's escrow proof can be fetched instead of holding the block at the bridge proof barrier. diff --git a/src/config/env_views.js b/src/config/env_views.js index eb31c39e..64d17c42 100644 --- a/src/config/env_views.js +++ b/src/config/env_views.js @@ -59,6 +59,7 @@ const LIST_VIEWS = { "XCHAIN_PRICE_CONFIRMATION_BUFFER", "XCHAIN_PRICE_BOOTSTRAP_SATS", "XCHAIN_PRICE_MIN_BTC_VOLUME", "HUB_ALLOW_UNAUTHENTICATED", "XC_ROLLCALL_REGTEST_ACTIVATION", "XC_ROLLCALL_GATES_REGTEST_ACTIVATION", + "XC_MIRROR_ADMISSION_ACTIVATION", "XCHAIN_PRICE_INDEXER_DB_SECRET" ]], // The indexer's regtest genesis bootstrap (ConfigService genesisPassthroughVars). @@ -73,7 +74,8 @@ const LIST_VIEWS = { INDEXER_ROLLCALL_ENV: ['indexer rollcall', [ "DOGE_INDEXER_API_URL", "DOGE_INDEXER_API_KEY", "XC_ROLLCALL_REGTEST_ACTIVATION", "XC_ROLLCALL_GATES_REGTEST_ACTIVATION", "HUB_SYNC_ANCHOR_ATTEST_GRACE_S", - "HUB_PRICE_SYNC_TIMEOUT_MS", "XCHAIN_COINPAY_EXPIRATION_S" + "HUB_PRICE_SYNC_TIMEOUT_MS", "XCHAIN_COINPAY_EXPIRATION_S", + "XC_MIRROR_ADMISSION_ACTIVATION" ]], // The hub-sync watermark graces a regtest indexer is handed (hubSyncRegtestGraceVars). HUB_SYNC_GRACE_ENV: ['hub sync grace', [ diff --git a/src/services/config_service.js b/src/services/config_service.js index b5434429..9aa88971 100644 --- a/src/services/config_service.js +++ b/src/services/config_service.js @@ -772,11 +772,17 @@ async function getDefaultConfig(module, coin, network) { // XC_ROLLCALL_GATES_REGTEST_ACTIVATION follows XC_ROLLCALL_REGTEST_ACTIVATION's // same env-derived regtest shape (D84): it arms ROLLCALL v1 and the rules-aware // attestation set separately from the rail, so a venue can drive v0 as its control. + // + // XC_MIRROR_ADMISSION_ACTIVATION rides the same regtest-only shape: without a + // path here the indexer side of the admission-map mirror can never be armed on + // regtest (row 24x), and it must arm together with the hub's copy above or the + // admission-era canonical refuses a legacy-map row and halts the block loop. if (network === Network.REGTEST) rollcallPassthroughVars.push("XC_ROLLCALL_REGTEST_ACTIVATION", "XC_ROLLCALL_GATES_REGTEST_ACTIVATION", "HUB_SYNC_ANCHOR_ATTEST_GRACE_S", "HUB_PRICE_SYNC_TIMEOUT_MS", - "XCHAIN_COINPAY_EXPIRATION_S") + "XCHAIN_COINPAY_EXPIRATION_S", + "XC_MIRROR_ADMISSION_ACTIVATION") for (const varName of rollcallPassthroughVars) { if (config.INDEXER_ROLLCALL_ENV[varName] !== undefined && config.INDEXER_ROLLCALL_ENV[varName] !== "") { defaultValues[varName] = config.INDEXER_ROLLCALL_ENV[varName] @@ -1290,7 +1296,12 @@ async function getDefaultConfig(module, coin, network) { // no-network-gate reasoning: it arms ROLLCALL v1 and the rules-aware // attestation set separately from the rail, so a venue can drive v0 as its // control, and the hub's own rollcall_gates_activation.js gates it for real. - "XC_ROLLCALL_GATES_REGTEST_ACTIVATION" + "XC_ROLLCALL_GATES_REGTEST_ACTIVATION", + // XC_MIRROR_ADMISSION_ACTIVATION follows the same no-network-gate shape + // (D84 precedent): it arms the admission-map mirror and its consumer and + // barrier gates together, so a venue arms as a unit; the hub's own + // mirror-admission gate module gates it for real. + "XC_MIRROR_ADMISSION_ACTIVATION" ] for (const varName of hubPassthroughVars) { // Secret-bearing names in this list (XCHAIN_PRICE_INDEXER_DB_PASS) are also diff --git a/test/unit/config_service.test/get_default_config_regtest.test.js b/test/unit/config_service.test/get_default_config_regtest.test.js index b4b2d916..e326988f 100644 --- a/test/unit/config_service.test/get_default_config_regtest.test.js +++ b/test/unit/config_service.test/get_default_config_regtest.test.js @@ -33,7 +33,8 @@ function regtestOnlyPassthrough() { 'XC_ROLLCALL_GATES_REGTEST_ACTIVATION', 'HUB_SYNC_ANCHOR_ATTEST_GRACE_S', 'HUB_PRICE_SYNC_TIMEOUT_MS', - 'XCHAIN_COINPAY_EXPIRATION_S' + 'XCHAIN_COINPAY_EXPIRATION_S', + 'XC_MIRROR_ADMISSION_ACTIVATION' ] let saved diff --git a/test/unit/config_service.test/rollcall_and_mirror.test.js b/test/unit/config_service.test/rollcall_and_mirror.test.js index 920b3559..9e7608aa 100644 --- a/test/unit/config_service.test/rollcall_and_mirror.test.js +++ b/test/unit/config_service.test/rollcall_and_mirror.test.js @@ -183,6 +183,68 @@ function rollcallPassthrough3() { }) } +// XC_MIRROR_ADMISSION_ACTIVATION (D84 precedent, row 24x): follows the same +// env-derived regtest shape as the ROLLCALL gates above. It arms the +// admission-map mirror producer, consumer and the anchor-attest barrier +// together at one height, so a venue arms as a unit the same way ROLLCALL does. +function mirrorAdmissionPassthrough() { + const MIRROR_ADMISSION_VARS = ['XC_MIRROR_ADMISSION_ACTIVATION'] + + let saved + + beforeEach(function () { + saved = {} + for (const v of MIRROR_ADMISSION_VARS) { saved[v] = process.env[v]; delete process.env[v] } + }) + + afterEach(function () { + for (const v of MIRROR_ADMISSION_VARS) { + if (saved[v] === undefined) delete process.env[v]; else process.env[v] = saved[v] + } + }) + + it('arms the indexer on regtest when the host opts in', async function () { + process.env.XC_MIRROR_ADMISSION_ACTIVATION = '5124' + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig('xchain-indexer', 'bitcoin', 'regtest') + expect(config['XC_MIRROR_ADMISSION_ACTIVATION']).to.equal('5124') + }) + + it('NEVER arms a shared-ledger indexer, whatever the host env says', async function () { + process.env.XC_MIRROR_ADMISSION_ACTIVATION = '5124' + const cs = makeServiceWithConfig('') + for (const net of ['mainnet', 'testnet']) { + const config = await cs.getDefaultConfig('xchain-indexer', 'bitcoin', net) + expect(config, net).to.not.have.property('XC_MIRROR_ADMISSION_ACTIVATION') + } + }) + + it('does NOT inject the var into a non-indexer coin module (decoder)', async function () { + process.env.XC_MIRROR_ADMISSION_ACTIVATION = '5124' + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig('xchain-decoder', 'bitcoin', 'regtest') + expect(config).to.not.have.property('XC_MIRROR_ADMISSION_ACTIVATION') + }) + + // One variable arms producer, consumer and the anchor-attest barrier + // together; the hub carries the producer side, so it must take the same + // variable or the indexer's consumer arms with no producer to match it. + it('arms the container hub from the same variable, so the venue arms as a unit', async function () { + process.env.XC_MIRROR_ADMISSION_ACTIVATION = '5124' + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig('xchain-hub', null, null) + expect(config['XC_MIRROR_ADMISSION_ACTIVATION']).to.equal('5124') + }) + + it('omits the var when unset, so a venue ships INERT', async function () { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig('xchain-indexer', 'bitcoin', 'regtest') + expect(config).to.not.have.property('XC_MIRROR_ADMISSION_ACTIVATION') + const hub = await cs.getDefaultConfig('xchain-hub', null, null) + expect(hub).to.not.have.property('XC_MIRROR_ADMISSION_ACTIVATION') + }) +} + // Regtest mirror arming: the regtest indexer's hub-mirror connection, unset // before this row, and the three watermark graces that must be zeroed alongside // it or an armed regtest venue wedges every freshly mined block (the price-grace @@ -277,3 +339,11 @@ describe('ConfigService', function () { }) }) }) + +describe('ConfigService', function () { + describe('getDefaultConfig()', function () { + describe('with coin and network (coin-specific config)', function () { + describe('MIRROR ADMISSION passthrough', mirrorAdmissionPassthrough) + }) + }) +}) From d84b7a285af58c10bdde2037b98aeda5c2fd844c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 17 Sep 2026 13:58:54 -0700 Subject: [PATCH 15/16] chore(release): bump to 0.20.0, refresh the README badge and record the changelog --- CHANGELOG.md | 14 +++++++++++--- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d1e9143..1ff73c75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.0] - 2026-09-17 + ### Added -- `XC_MIRROR_ADMISSION_ACTIVATION` passes through to both the hub and the regtest indexer, so a rail venue can arm the admission-map mirror producer, consumer and anchor-attest barrier together at one height. +- `XC_MIRROR_ADMISSION_ACTIVATION` passes through to the hub and regtest indexer containers. + +### Changed +- Nightly Litecoin and Dogecoin jobs provision validator configuration, API access, database headroom, and a Bitcoin gas rail. ### Fixed -- `XCHAIN_CONFIRMATIONS_BTC/LTC/DOGE` pass through the host env to the hub container (the hub clamps them up to its per-coin default off regtest), and the nightly litecoin and dogecoin legs pin them to 1 so the bridged gas lock finalizes on the next block. -- The nightly litecoin and dogecoin legs hand the coin's indexer the bitcoin indexer's address (`BTC_INDEXER_API_URL`), so the bridged gas credit's escrow proof can be fetched instead of holding the block at the bridge proof barrier. +- Confirmation overrides pass through to the hub for nightly bridged-gas finalization. +- Litecoin and Dogecoin indexers receive the Bitcoin indexer URL needed for bridge escrow proofs. +- Bootstrap restores resolve the signing public key from its relocated configuration path. +- Migration preflight accepts the v0.19.0 and later `db/migration` CLI layout. +- Coverage checks derive sibling requirements from the repository roster. ## [0.19.1] - 2026-09-16 diff --git a/README.md b/README.md index d880e065..c03688dd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # XChain Platform Node

- Version + Version Tests Node License diff --git a/package-lock.json b/package-lock.json index 9554f40a..988e3208 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-node", - "version": "0.19.1", + "version": "0.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-node", - "version": "0.19.1", + "version": "0.20.0", "license": "AGPL-3.0-or-later", "dependencies": { "@dankest-llc/xchain-sdk": "^0.18.0", diff --git a/package.json b/package.json index 32e97389..d026c282 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xchain-node", - "version": "0.19.1", + "version": "0.20.0", "description": "xchain-node allows users to install, configure and run XChain platform nodes.", "license": "AGPL-3.0-or-later", "repository": { From 81ccd7e1fbba32c76b0d259d5e139391270b1e90 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 18 Sep 2026 16:04:24 -0700 Subject: [PATCH 16/16] release: regenerate the v0.20.0 manifest from the signed sibling tags Pins eleven components at their signed, origin-verified v0.20.0 tags and carries xchain-contracts forward at v0.17.0, which this train does not touch. Arms the 0.20.0 rule set at BTC testnet 153116, computed from tip 153018. Generated by bin/write-release-manifest.js against origin, never by hand. --- src/release-manifest.json | 60 +++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/release-manifest.json b/src/release-manifest.json index c14c56b0..bb23033d 100644 --- a/src/release-manifest.json +++ b/src/release-manifest.json @@ -1,9 +1,9 @@ { "_comment": [ - "Pinned component set for XChain Platform v0.19.1.", + "Pinned component set for XChain Platform v0.20.0.", "Generated by bin/write-release-manifest.js from the ACTUAL tagged master merge commits.", "xchain-node is the carrier and is not listed: checking out its tag IS this manifest.", - "A train tags only the repos it touches. This one moves xchain-sync.", + "A train tags only the repos it touches. This one moves xchain-vm, xchain-decoder, xchain-indexer, xchain-hub, xchain-sync, xchain-encoder, xchain-utxo-tracker, xchain-explorer, xchain-sdk, xchain-e2e-test and xchain-regtest-miner.", "Every other component is unchanged and keeps the tag it already carries, which is", "what section 4 means by a version being the platform version at which a component", "last changed. A gap is unchanged, not skipped.", @@ -12,70 +12,70 @@ "repo's origin/master, and is a GPG-signed annotated tag verifying against the", "platform release key (fingerprint 1DA7C4896F56EA22CF491EDF4361611A82F90B70)." ], - "platform_version": "0.19.1", - "released": "2026-09-16", + "platform_version": "0.20.0", + "released": "2026-09-18", "components": { "xchain-vm": { - "tag": "v0.19.0", - "commit": "45d2fd23e20b2d77df796615a50342310915abd2" + "tag": "v0.20.0", + "commit": "c28d3338318ba0494eebe4203432dfe78b127f2a" }, "xchain-decoder": { - "tag": "v0.19.0", - "commit": "bb758689212da96a03369e7e6fd4ebc72f4dc4c9" + "tag": "v0.20.0", + "commit": "0b07c59464e3631db5d0d775f5aa9c550bbe7b0c" }, "xchain-indexer": { - "tag": "v0.19.0", - "commit": "715b2fee2032cc20f1eb04227d1bf6ef8cac88b5" + "tag": "v0.20.0", + "commit": "777ef8604b400f398a05bdb5b0da4e2242896b98" }, "xchain-hub": { - "tag": "v0.19.0", - "commit": "16ef8adc97e88c24ebc865aa4bfa248deeb4d45a" + "tag": "v0.20.0", + "commit": "0f7c78ac6fb9c139155d8df018e9668e70ca63f3" }, "xchain-sync": { - "tag": "v0.19.1", - "commit": "5b44cb558b3e595ef46a2403a9536ec5183fd2e7" + "tag": "v0.20.0", + "commit": "5c03236b2b77d6b49b5282cc1b4d6ebbe1913efe" }, "xchain-encoder": { - "tag": "v0.19.0", - "commit": "8616f3d09ca54013bec6ff70ac851a6ba10b035a" + "tag": "v0.20.0", + "commit": "830d937c0c2cbbbe4d5cb6ce2b11d308e30a16ca" }, "xchain-utxo-tracker": { - "tag": "v0.19.0", - "commit": "7e3223e6caeefb10f9d918c19c73856dbe559a9e" + "tag": "v0.20.0", + "commit": "a14eea102792338ee6d02f3afeea4719981936f4" }, "xchain-explorer": { - "tag": "v0.19.0", - "commit": "dd79d7fb584d532df2b366a22704fd3442bac93a" + "tag": "v0.20.0", + "commit": "6daa89b0ef67315d52328b8b1bc4416139f8c3d5" }, "xchain-sdk": { - "tag": "v0.19.0", - "commit": "d488b0d2ad95c3021b20e439efedec7cc47cf0e7" + "tag": "v0.20.0", + "commit": "340ff29550d8b24f2360b0eec97eb3c5a64b3701" }, "xchain-e2e-test": { - "tag": "v0.19.0", - "commit": "1f9486c74aa9c3b68b440250c0c1000006cacbe9" + "tag": "v0.20.0", + "commit": "469bd3e652ae9c715ac58d4a28bb9e19cf3abd8d" }, "xchain-contracts": { "tag": "v0.17.0", "commit": "684a6311be7232542346c984bd9f2d62f3251810" }, "xchain-regtest-miner": { - "tag": "v0.19.0", - "commit": "1f447321bce3386635d080285a337d7ed03b191d" + "tag": "v0.20.0", + "commit": "9426829225b19844f2d5de434dd2bed3605fcb20" } }, "trainActivation": { - "ruleSetVersion": "0.19.0", + "ruleSetVersion": "0.20.0", "classification": "major", "heights": { "mainnet": 9999999999, "regtest": 0, - "testnet": 152787 + "testnet": 153116 }, "computedFromBtcTip": { - "mainnet": 967299, + "mainnet": 967625, "regtest": 0, - "testnet": 152716 + "testnet": 153018 } } }