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

- Version + Version Tests Node License 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) + }) }) })