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/.github/workflows/nightly-e2e.yml b/.github/workflows/nightly-e2e.yml index 4e089f3a..2f51b266 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 @@ -422,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: | @@ -450,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/CHANGELOG.md b/CHANGELOG.md index 8c38a63e..1ff73c75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ 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 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 +- 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 ### Fixed 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
-
+
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/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": {
diff --git a/src/config/env_views.js b/src/config/env_views.js
index 860c95dc..64d17c42 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",
@@ -57,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).
@@ -71,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/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
}
}
}
diff --git a/src/services/config_service.js b/src/services/config_service.js
index 361f06d6..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]
@@ -1148,6 +1154,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,
@@ -1281,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/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/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 })
+ }
})
})
})
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')
+ })
+})
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)
+ })
+ })
+})
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..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']) {
@@ -181,11 +195,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',
})
})
}
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)
+ })
})
})