diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..1f374249 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "attribution": false, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "git config user.name 'J-Dog' && git config user.email 'j-dog@j-dog.net' || true", + "timeout": 10, + "statusMessage": "Setting the repo git identity" + } + ] + } + ] + } +} diff --git a/.github/attest-mirror/compose.yml b/.github/attest-mirror/compose.yml index 64f093f6..742c99d3 100644 --- a/.github/attest-mirror/compose.yml +++ b/.github/attest-mirror/compose.yml @@ -202,6 +202,11 @@ services: INDEXER_COIN: BTC INDEXER_NETWORK: regtest INDEXER_API_PORT: 3004 + # Every host-side caller (the venue's five hubs, its indexers and the suite) + # reaches this indexer through the published port from one source address, so + # the per-IP default of 600 a minute is spent in about 40 s and the hubs' tip + # reads are refused for the rest of each window. The same applies to LTC and DOGE. + INDEXER_RATE_LIMIT_RPM: 99999 INDEXER_DB_HOST: db INDEXER_DB_PORT: 3306 INDEXER_DB_NAME: XChain_BTC_Regtest_Indexer @@ -409,6 +414,7 @@ services: INDEXER_COIN: LTC INDEXER_NETWORK: regtest INDEXER_API_PORT: 3004 + INDEXER_RATE_LIMIT_RPM: 99999 INDEXER_DB_HOST: db INDEXER_DB_PORT: 3306 INDEXER_DB_NAME: XChain_LTC_Regtest_Indexer @@ -592,6 +598,7 @@ services: INDEXER_COIN: DOGE INDEXER_NETWORK: regtest INDEXER_API_PORT: 3004 + INDEXER_RATE_LIMIT_RPM: 99999 INDEXER_DB_HOST: db INDEXER_DB_PORT: 3306 INDEXER_DB_NAME: XChain_DOGE_Regtest_Indexer diff --git a/.github/attest-mirror/run-leg.sh b/.github/attest-mirror/run-leg.sh index 0a12018c..7e441950 100755 --- a/.github/attest-mirror/run-leg.sh +++ b/.github/attest-mirror/run-leg.sh @@ -53,7 +53,7 @@ for p in "$BTC_NODE_PORT" "$LTC_NODE_PORT" "$DOGE_NODE_PORT"; do done cd "${ATTEST_MIRROR_E2E_DIR:-$root/xchain-e2e-test}" printf "=== leg %s start %s node %s stack xca7%s venue-base %s file %s extra-env [%s]\n" "$tag" "$(date -u +%FT%TZ)" "$(node -v)" "$stack" "$venue_base" "$leg" "$*" -env "$@" npx mocha --timeout 0 --exit --require ./test/initialCheck.test.js "$leg" 2>&1 | node -e 'const rl=require("readline").createInterface({input:process.stdin});rl.on("line",l=>process.stdout.write(new Date().toISOString().slice(11,19)+" "+l+"\n"))' +env "$@" npx mocha --timeout 0 --exit --require ./test/initial_check.test.js "$leg" 2>&1 | node -e 'const rl=require("readline").createInterface({input:process.stdin});rl.on("line",l=>process.stdout.write(new Date().toISOString().slice(11,19)+" "+l+"\n"))' rc=${PIPESTATUS[0]} printf "=== leg %s exit=%s end %s\n" "$tag" "$rc" "$(date -u +%FT%TZ)" exit "$rc" diff --git a/.github/attest-mirror/seed-stack-config.js b/.github/attest-mirror/seed-stack-config.js index 5c028ac7..4f77c6f2 100644 --- a/.github/attest-mirror/seed-stack-config.js +++ b/.github/attest-mirror/seed-stack-config.js @@ -4,10 +4,14 @@ const port = Number(process.argv[2]); const pass = process.env.DB_PASSWORD; if (!port || !pass) process.exit(2); const ixHost = { BTC: 'indexer', LTC: 'ltc-indexer', DOGE: 'doge-indexer' }; +// The indexer entry's host and port are its API; db_host and db_port say where its +// database lives. The explorer builds its pools from db_host/db_port and falls back to +// host/port without them, so it would dial the indexer API as MariaDB and never serve. function services(code) { return { 'xchain-decoder': { host: 'db', port: '3306', name: `XChain_${code}_Regtest_Decoder`, user: 'root', pass }, - 'xchain-indexer': { host: ixHost[code], port: '3004', name: `XChain_${code}_Regtest_Indexer`, user: 'root', pass } + 'xchain-indexer': { host: ixHost[code], port: '3004', db_host: 'db', db_port: '3306', + name: `XChain_${code}_Regtest_Indexer`, user: 'root', pass } }; } const config = { diff --git a/.github/attest-mirror/setup-stack.sh b/.github/attest-mirror/setup-stack.sh index 597d93c5..9099ff0e 100755 --- a/.github/attest-mirror/setup-stack.sh +++ b/.github/attest-mirror/setup-stack.sh @@ -57,6 +57,26 @@ for _ in $(seq 1 180); do set +a node "$root/seed-stack-config.js" "$HUB_HOST_PORT" node "$root/wait-indexer-schema.js" + # The explorer answers ping 503 until its next hub config poll picks up the coins + # seeded above, which can trail a warm boot by a minute. A suite that pings it + # first fails its initial check, so the stack is ready only once it answers. + explorer_ping='{"jsonrpc":"2.0","method":"ping","id":1}' + explorer_ok=0 + for _ in $(seq 1 120); do + if curl -fsS -o /dev/null -H 'content-type: application/json' --data "$explorer_ping" \ + "http://127.0.0.1:$EXPLORER_HTTP_HOST_PORT/" 2>/dev/null; then + explorer_ok=1 + break + fi + sleep 2 + done + if [ "$explorer_ok" != 1 ]; then + date -u + printf "explorer=%s\n" "$(health explorer)" + printf "explorer ping timeout for xca7%s\n" "$stack" + exit 1 + fi + printf "explorer ready\n" date -u exit 0 fi diff --git a/.github/attest-mirror/wait-indexer-schema.js b/.github/attest-mirror/wait-indexer-schema.js index 3b55483a..22f946cd 100644 --- a/.github/attest-mirror/wait-indexer-schema.js +++ b/.github/attest-mirror/wait-indexer-schema.js @@ -1,23 +1,94 @@ -'use strict'; -// Venue warm-up: waits until the stack indexer's schema exists (issues table) and its /status answers, so a leg's -// initialCheck ISSUE never races the indexer's first migration. Credentials come from the stack .env via env. -const mariadb = require('module').createRequire(require('path').join(process.env.ATTEST_MIRROR_E2E_DIR, 'package.json'))('mariadb'); -const waitMs = Number(process.env.ATTEST_SCHEMA_WAIT_MS || 1200000); -const deadline = Date.now() + waitMs; -(async () => { +'use strict' + +const path = require('path') +const { createRequire } = require('module') + +const DATABASE = 'XChain_BTC_Regtest_Indexer' +const TABLE = 'issues' +const DEFAULT_WAIT_MS = 1200000 +const DEFAULT_INTERVAL_MS = 3000 + +function loadMariadb (env) { + return createRequire(path.join(env.ATTEST_MIRROR_E2E_DIR, 'package.json'))('mariadb') +} + +async function issuesTableReady (config) { + let connection = null + try { + connection = await config.connect(config.dbOptions) + const rows = await connection.query( + "SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = '" + + DATABASE + "' AND table_name = '" + TABLE + "'") + return Number(rows[0].n) === 1 + } catch (_) { + return false + } finally { + if (connection) await connection.end().catch(() => {}) + } +} + +async function schemaReady (config) { + let body + try { + const response = await config.fetchImpl(config.statusUrl) + if (!response || !response.ok) return false + body = await response.json() + } catch (_) { + return false + } + if (!body || typeof body !== 'object') return false + if (Object.prototype.hasOwnProperty.call(body, 'schemaReady')) return body.schemaReady === true + return issuesTableReady(config) +} + +async function waitForSchema (config) { + const deadline = config.now() + config.waitMs for (;;) { - let ok = false; - try { - const options = { host: '127.0.0.1', port: Number(process.env.DB_HOST_PORT), user: 'root' }; - options['pass' + 'word'] = process.env['DB_' + 'PASSWORD']; - const c = await mariadb.createConnection(options); - const rows = await c.query("SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = 'XChain_BTC_Regtest_Indexer' AND table_name = 'issues'"); - await c.end(); - const res = await fetch(`http://127.0.0.1:${process.env.INDEXER_HOST_PORT}/status`).catch(() => null); - ok = Number(rows[0].n) === 1 && !!res && res.ok; - } catch (e) { ok = false; } - if (ok) { console.log('indexer schema ready'); return; } - if (Date.now() > deadline) { console.log('indexer schema NOT ready after ' + Math.round(waitMs / 1000) + ' s'); process.exit(5); } - await new Promise((r) => setTimeout(r, 3000)); + if (await schemaReady(config)) return true + if (config.now() > deadline) return false + await config.sleep(config.intervalMs) + } +} + +function configFromEnv (env, dependencies) { + const dbOptions = { host: '127.0.0.1', port: Number(env.DB_HOST_PORT), user: 'root' } + dbOptions['pass' + 'word'] = env['DB_' + 'PASSWORD'] + return { + connect: dependencies.mariadb.createConnection, + dbOptions, + fetchImpl: dependencies.fetchImpl, + statusUrl: 'http://127.0.0.1:' + env.INDEXER_HOST_PORT + '/status', + waitMs: Number(env.ATTEST_SCHEMA_WAIT_MS || DEFAULT_WAIT_MS), + intervalMs: DEFAULT_INTERVAL_MS, + now: dependencies.now || Date.now, + sleep: dependencies.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))), + } +} + +async function main (runtime) { + const context = runtime || {} + const env = context.env || process.env + const mariadb = context.mariadb || loadMariadb(env) + const config = configFromEnv(env, { + mariadb, + fetchImpl: context.fetchImpl || fetch, + now: context.now, + sleep: context.sleep, + }) + const log = context.log || console.log + if (await waitForSchema(config)) { + log('indexer schema ready') + return 0 } -})(); + log('indexer schema NOT ready after ' + Math.round(config.waitMs / 1000) + ' s') + return 5 +} + +if (require.main === module) { + main().then((code) => { process.exitCode = code }).catch((error) => { + console.error(error.message) + process.exitCode = 5 + }) +} + +module.exports = { DATABASE, TABLE, issuesTableReady, schemaReady, waitForSchema, configFromEnv, main } diff --git a/.github/hub-failover/compose.testnet-ids.yml b/.github/hub-failover/compose.testnet-ids.yml new file mode 100644 index 00000000..4e8ea1ca --- /dev/null +++ b/.github/hub-failover/compose.testnet-ids.yml @@ -0,0 +1,19 @@ +services: + hub-a: + environment: + P2P_VALIDATOR_ADDR: mverify5szar1XwwAjqAnPUHbA814kfHh4 + + hub-b: + environment: + P2P_VALIDATOR_ADDR: mverify7szar1XwwAjqAnPUHbA814kfHh4 + + hub-federation-init: + command: + - | + for hub in hub-a hub-b; do + for registration in \ + '{"jsonrpc":"2.0","id":1,"method":"registervalidator","params":{"signing_pubkey":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","addr":"mverify5szar1XwwAjqAnPUHbA814kfHh4"}}' \ + '{"jsonrpc":"2.0","id":2,"method":"registervalidator","params":{"signing_pubkey":"29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7","addr":"mverify7szar1XwwAjqAnPUHbA814kfHh4"}}'; do + wget -qO- --header="x-api-key: $${HUB_API_KEY}" --header="Content-Type: application/json" --post-data="$${registration}" "http://$${hub}:10000/" | grep -q '"status":"success"' + done + done diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 00323c9e..ae746db4 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -31,11 +31,15 @@ jobs: - name: Run Stryker run: npm run test:mutation + - name: Verify mutation reports + if: always() + run: test -s reports/mutation/report.json && test -s reports/mutation/index.html + - name: Upload mutation report if: always() uses: actions/upload-artifact@v4 with: name: mutation-report path: reports/mutation/ - if-no-files-found: warn + if-no-files-found: error retention-days: 30 diff --git a/.github/workflows/nightly-e2e.yml b/.github/workflows/nightly-e2e.yml index fdd2a5f3..e3b3e29f 100644 --- a/.github/workflows/nightly-e2e.yml +++ b/.github/workflows/nightly-e2e.yml @@ -163,7 +163,7 @@ permissions: # # A shard is a fresh stack, so every suite already had to be runnable alone # (the `suite` input has always relied on that); sharding adds no new ordering -# assumption. Five shards bring the bitcoin weights to ~42 minutes of action +# assumption. Six shards bring the bitcoin weights to ~34 minutes of action # per shard, about an hour per job with setup and boot. jobs: plan: @@ -176,10 +176,10 @@ jobs: env: STACK_REF: ${{ github.event.inputs.ref || 'develop' }} SUITE: ${{ github.event.inputs.suite || '' }} - # Sized off run 35605562229: ~204 minutes of bitcoin action weight over 5 - # shards is ~42 each. Raise it here if the suite grows; nothing else - # hardcodes the count. Three coins at 5 shards is 15 concurrent runners. - E2E_SHARDS: '5' + # Sized off run 35605562229: ~204 minutes of bitcoin action weight over 6 + # shards is ~34 each. Raise it here if the suite grows; nothing else + # hardcodes the count. Three coins at 6 shards is 18 concurrent runners. + E2E_SHARDS: '6' steps: # The plan and merge scripts come from THIS workflow's commit, not from # the ref under test, so an older ref (a published vX.Y.Z) that predates @@ -235,9 +235,9 @@ jobs: coin: ${{ fromJSON(needs.plan.outputs.coins) }} shard: ${{ fromJSON(needs.plan.outputs.shards) }} # 360 was the unsharded ceiling, set because a whole coin pass had no - # measured duration to size against. A shard does: ~42 minutes of action + # measured duration to size against. A shard does: ~34 minutes of action # plus ~15 of setup and boot on bitcoin, somewhat more on the two-stack - # legs. 240 is four times that, so a slow leg is never truncated, while a + # legs. 240 is about five times that, so a slow leg is never truncated, while a # WEDGED shard (a hung container, a stalled indexer poll) reports in four # hours instead of six. timeout-minutes: 240 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a77fee1..10f441c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.23.0] - 2026-10-11 + +### Added +- Added co-located indexer URLs to the hub config and honored per-coin hub seed and route settings. +- Added an isolated bootstrap restore rehearsal and recovery of shared containers after a redeploy. +- Recognized the UNARMED go-live sentinels in the release guards. + +### Changed +- Applied container limits and resource overrides consistently at creation. +- Made update all continue past a guard refusal and failed closed when the latest release lookup errors. + +### Fixed +- Guarded coin node and datadir bind mount sources against drift. +- Preserved the container environment on recreate and blocked stake teardown on a sleeping source address. +- Dropped the encoder from autoheal because a restart cannot clear a tracker halt. + ## [0.22.7] - 2026-10-06 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be8c6cc9..6dd58834 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -118,6 +118,7 @@ CI is the unit gate. Before opening a PR: 2. Update `CHANGELOG.md` with a terse entry for your change. 3. Make sure `git status` is clean apart from intended changes (no `node_modules/`, no editor leftovers, no generated `.env` files). 4. Open the PR with a clear title and a description of what changed and why. +5. The CLA Assistant bot records your signature on your first PR; one signature covers every XChain Platform repository. Its `license/cla` check must pass before the PR can merge. For non-security bugs, open an issue at . For security bugs, see [`SECURITY.md`](./SECURITY.md). diff --git a/NOTICE.md b/NOTICE.md index ed913df8..bbf40b84 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -21,8 +21,10 @@ For commercial licensing, contact **legal@dankest.llc**. ## Attribution -Any redistribution or modification of this software must retain the following -attribution, per the AGPL and the project's trademark policy: +As an additional term under section 7(b) of the GNU Affero General Public +License v3.0, any redistribution or modification of this software must preserve +the following author attribution in its source and in the Appropriate Legal +Notices displayed by any work containing it: > Based on XChain Platform by Dankest, LLC - https://dankest.llc diff --git a/README.md b/README.md index c6ec5cef..4277fbf6 100644 --- a/README.md +++ b/README.md @@ -136,15 +136,17 @@ When xchain-node manages its own MariaDB container, these optional env vars over ## Autoheal (restart-on-unhealthy) -Every persistent service container carries a Docker healthcheck, but Docker itself takes no action on the `unhealthy` state (`--restart unless-stopped` only fires when the process exits). An alive-but-stalled service would otherwise stay wedged until an operator notices it in `docker ps`. `xchain-node autoheal` closes that loop: it restarts containers that have been continuously unhealthy past a grace window, for services opted in via `autoheal: true` in the healthcheck table (currently the decoder, encoder, and indexer; the utxo-tracker is deliberately excluded because it halts on purpose rather than exiting, and a restart would just re-halt it). +Every persistent service container carries a Docker healthcheck, but Docker itself takes no action on the `unhealthy` state (`--restart unless-stopped` only fires when the process exits). An alive-but-stalled service would otherwise stay wedged until an operator notices it in `docker ps`. `xchain-node autoheal` closes that loop: it restarts containers that have been continuously unhealthy past a grace window, for services opted in via `autoheal: true` in the healthcheck table (currently the decoder and indexer; the utxo-tracker is deliberately excluded because it halts on purpose rather than exiting, and a restart would just re-halt it; the encoder is deliberately excluded because its health probe reports utxo-tracker readiness (unreachable, halted, lagging or mempool not ready), which restarting the encoder cannot clear). The command is one-shot and never prompts or daemonizes. Unattended remediation requires wiring it to a cron entry or systemd timer, e.g. `*/5 * * * * xchain_node autoheal`. Detection-to-restart latency is the timer interval plus the health retry budget plus the grace window. Use `--dry-run` to see restart candidates without acting. Exit code is non-zero only when a restart was attempted and failed. | Variable | Default | What it controls | |---|---|---| | `XCHAIN_NODE_AUTOHEAL_GRACE_MS` | `120000` | How long a container must be continuously unhealthy before a restart is considered | -| `XCHAIN_NODE_AUTOHEAL_COOLDOWN_MS` | `600000` | Minimum time between two autoheal restarts of the same container (anti-flap) | -| `XCHAIN_NODE_AUTOHEAL_STATE_DIR` | `~/.xchain-node` | Where the anti-flap state file (`autoheal-state.json`) lives | +| `XCHAIN_NODE_AUTOHEAL_COOLDOWN_MS` | `600000` | Base wait before autoheal restarts the same container again (anti-flap). The first retry waits this long; each further restart of a container that has not recovered doubles the wait, up to `XCHAIN_NODE_AUTOHEAL_COOLDOWN_CEILING_MS` (with defaults: 10m, 20m, 40m and so on, capped at 6h). The doubling resets once the container recovers | +| `XCHAIN_NODE_AUTOHEAL_COOLDOWN_CEILING_MS` | `21600000` | Upper bound on the doubled cooldown. A container that never recovers settles at one restart per ceiling; there is no attempt cap, and every skipped pass logs an "investigate" line | +| `XCHAIN_NODE_AUTOHEAL_PROBATION_MS` | `150000` | How long after an autoheal restart a passing health probe counts as that restart's own artifact rather than recovery, so it does not reset the backoff. If you widen a service's start period with `XCHAIN_NODE_HEALTH_START_PERIOD_`, widen this to match (start period plus interval times retries, plus margin) | +| `XCHAIN_NODE_AUTOHEAL_STATE_DIR` | `~/.xchain-node` | Where the state file (`autoheal-state.json`) lives: last restart times, unhealthy onsets and the per-container restart count that drives the backoff. Falls back to `XCHAIN_NODE_LOCK_DIR` when that is set, otherwise `~/.xchain-node` | ## Telemetry diff --git a/bin/ci-full.sh b/bin/ci-full.sh index 005b640a..655e8417 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -74,19 +74,16 @@ fast_defer() { # >>> ci-tier timer (generated block; re-run the tier wirer to update) >>> run_tier() { ci_tier_deferred "$1" && return 0 # ci-tier guard (generated) + if [ "${CI_TIER:-full}" = "fast" ] && [ -n "${FAILED:-}" ]; then echo; echo "ci:full ===== $1 NOT RUN (a push stops at its first red tier, its verdict already red; the full sweep runs it) ====="; return 0; fi # ci-tier stop (generated) local name="$1"; shift local __ci_tier_t0=$SECONDS echo; echo "ci:full ===== $name =====" - local root - root="$(mktemp -d "${TMPDIR:-/tmp}/xchain-node-ci-full.XXXXXX")" - mkdir -p "$root/config" "$root/data" - if ( export XCHAIN_NODE_CONFIG_DIR="$root/config" XCHAIN_NODE_DATA_DIR="$root/data"; "$@" ); then + if "$@"; then echo "ci:full ----- $name PASS ($(( SECONDS - __ci_tier_t0 ))s)" else FAILED="$FAILED [$name]" echo "ci:full ----- $name FAIL ($(( SECONDS - __ci_tier_t0 ))s)" fi - rm -rf -- "$root" } # <<< ci-tier timer <<< need_sib() { diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index 929b5d6f..6ca99b22 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -1,96 +1,76 @@ { "titleSets": { - "013fb4868c83ee37": [ - "CredentialsService XCHAIN_NODE_DB equals \"xchain_node\"", - "CredentialsService generatePassword() accepts custom byte length", - "CredentialsService generatePassword() returns a non-empty base64url string", - "CredentialsService generatePassword() returns different values on successive calls", - "CredentialsService getCredentialsDir() returns ~/.xchain-node", - "CredentialsService getCredentialsPath() returns ~/.xchain-node/credentials.json", - "CredentialsService getOsUserDbName() returns xchain_node_", - "CredentialsService getOsUserDbName() sanitizes username with special chars", - "CredentialsService getOsUserDbName() truncates username at 60 chars", - "CredentialsService hasCredentials() returns false when existsSync throws", - "CredentialsService hasCredentials() returns false when file does not exist", - "CredentialsService hasCredentials() returns true when credentials file exists", - "CredentialsService hasExternalDbConfig() returns false when externalDb block is absent", - "CredentialsService hasExternalDbConfig() returns false when host is not a string", - "CredentialsService hasExternalDbConfig() returns false when port is not a number", - "CredentialsService hasExternalDbConfig() returns false when readFileSync throws", - "CredentialsService hasExternalDbConfig() returns false when root_password is not a string", - "CredentialsService hasExternalDbConfig() returns false when root_user is not a string", - "CredentialsService hasExternalDbConfig() returns true when credentials.json has a well-formed externalDb block", - "CredentialsService loadCredentials() returns null when file contains null", - "CredentialsService loadCredentials() returns null when file is not valid JSON", - "CredentialsService loadCredentials() returns null when parsed object is missing password field", - "CredentialsService loadCredentials() returns null when parsed object is missing user field", - "CredentialsService loadCredentials() returns null when readFileSync throws", - "CredentialsService loadCredentials() returns parsed object with user and password", - "CredentialsService loadDbRootPassword() / saveDbRootPassword() loadDbRootPassword returns null when the file or key is absent", - "CredentialsService loadDbRootPassword() / saveDbRootPassword() loadDbRootPassword returns the stored value and rejects non-string/empty shapes", - "CredentialsService loadDbRootPassword() / saveDbRootPassword() saveDbRootPassword creates the 0700 dir when missing", - "CredentialsService loadDbRootPassword() / saveDbRootPassword() saveDbRootPassword read-modify-writes with mode 0600, preserving sibling keys", - "CredentialsService loadExternalDbConfig() returns null when externalDb is absent", - "CredentialsService loadExternalDbConfig() returns null when file cannot be parsed", - "CredentialsService loadExternalDbConfig() returns the externalDb block when present", - "CredentialsService sanitizeForMariaDb() passes through alphanumeric + underscore unchanged", - "CredentialsService sanitizeForMariaDb() replaces all special chars with underscores", - "CredentialsService sanitizeForMariaDb() replaces hyphens and spaces with underscores", - "CredentialsService sanitizeForMariaDb() replaces non-alphanumeric chars (including !) with underscores", - "CredentialsService sanitizeForMariaDb() returns \"user\" for empty input (zero-length cleaned string)", - "CredentialsService saveCredentials() calls chmodSync on file after writing", - "CredentialsService saveCredentials() creates dir if it does not exist", - "CredentialsService saveCredentials() does not create dir when it already exists", - "CredentialsService saveCredentials() preserves the externalDb block written by saveExternalDbConfig", - "CredentialsService saveCredentials() silently ignores chmodSync errors (Windows compat)", - "CredentialsService saveCredentials() writes JSON to credentials file with mode 0600", - "CredentialsService saveExternalDbConfig() coerces types: host/root_user/root_password to string, port to number", - "CredentialsService saveExternalDbConfig() creates dir if it does not exist", - "CredentialsService saveExternalDbConfig() merges externalDb into existing credentials.json", - "CredentialsService saveExternalDbConfig() silently ignores chmodSync errors", - "CredentialsService saveExternalDbConfig() writes file with mode 0600" + "011e3638fa7bd665": [ + "Chaos: Docker Resilience Experiment 4c: State storage failure after successful docker run rejects when LevelDB insert returns false" ], - "0226ac96a43d54d4": [ - "ValidatorStakeService planMints() flags a shortfall above the per-address cap", - "ValidatorStakeService planMints() flags a token with no open mint", - "ValidatorStakeService planMints() mints only the difference when partly funded", - "ValidatorStakeService planMints() needs no mint when the balance already covers the stake", - "ValidatorStakeService planMints() never mints on mainnet", - "ValidatorStakeService planMints() splits the shortfall into per-transaction cap-sized mints", - "ValidatorStakeService stakeTiming() gives no duration on regtest, where blocks are mined on demand", - "ValidatorStakeService stakeTiming() glosses both counts as the same durations on every chain", - "ValidatorStakeService stakeTiming() reads both clocks from the coin registry, not from the CLI", - "ValidatorStakeService stakeTiming() tracks the per-chain values for litecoin and dogecoin", - "ValidatorStakeService stakeValidator() --no-wait returns as soon as the STAKE is broadcast", - "ValidatorStakeService stakeValidator() --serialize keeps the old block-per-action behaviour", - "ValidatorStakeService stakeValidator() calls explorer methods that the published SDK actually exposes", - "ValidatorStakeService stakeValidator() does nothing when the pubkey already carries a valid stake", - "ValidatorStakeService stakeValidator() dry run prints the plan and sends nothing", - "ValidatorStakeService stakeValidator() funds each action from the previous one, forcing the in-block order", - "ValidatorStakeService stakeValidator() names the missing gas token and the bootstrap instead of a raw explorer 404", - "ValidatorStakeService stakeValidator() on mainnet never mints and blocks on a shortfall", - "ValidatorStakeService stakeValidator() passes --fee-per-kb through to the encoder", - "ValidatorStakeService stakeValidator() refuses a WIF that does not control the recorded stake address", - "ValidatorStakeService stakeValidator() refuses when the address has no coin for fees", - "ValidatorStakeService stakeValidator() refuses without a validator", - "ValidatorStakeService stakeValidator() reports pending mints instead of staking when they never index", - "ValidatorStakeService stakeValidator() sends everything back to back and waits only on the STAKE", - "ValidatorStakeService stakeValidator() skips minting when already funded", - "ValidatorStakeService stakeValidator() the plan states the escrow and the cooldown that frees it", - "ValidatorStakeService stakeValidator() the post-broadcast summary takes the activation delay from the registry", - "ValidatorStakeService stakeValidator() waits for the mints to index when no chain can be formed, rather than racing the STAKE", - "ValidatorStakeService stakeValidator() warns and keeps going when the validator set cannot be read, instead of reading the failure as \"not staked\"", - "ValidatorStakeService stakeValidator() with --broadcast mints the shortfall in order, then stakes", - "ValidatorStakeService unstakeValidator() does nothing when the pubkey carries no valid stake", - "ValidatorStakeService unstakeValidator() dry run reports the active stake and sends nothing", - "ValidatorStakeService unstakeValidator() never claims the XCHAIN is spendable once the stake leaves the set", - "ValidatorStakeService unstakeValidator() omits the duration on regtest, where block spacing is meaningless", - "ValidatorStakeService unstakeValidator() refuses a WIF that does not control the recorded stake address", - "ValidatorStakeService unstakeValidator() refuses when the validator set cannot be read", - "ValidatorStakeService unstakeValidator() refuses without a validator", - "ValidatorStakeService unstakeValidator() the plan prints BOTH clocks: 6 blocks to leave the set, 1000 to unlock", - "ValidatorStakeService unstakeValidator() the post-broadcast summary repeats both clocks, not just the delay", - "ValidatorStakeService unstakeValidator() with --broadcast sends UNSTAKE v0 for this pubkey" + "01ce537bf318afb2": [ + "DbCredentialDrift assertNoHubDbCredentialDrift ignores a container the caller is about to replace, and never inspects it", + "DbCredentialDrift assertNoHubDbCredentialDrift proceeds with a warning when the override env is set", + "DbCredentialDrift assertNoHubDbCredentialDrift resolves when every holder of the shared account agrees", + "DbCredentialDrift assertNoHubDbCredentialDrift throws a tagged error naming a sibling hub under a different prefix", + "DbCredentialDrift assertNoHubDbCredentialDrift treats an unreadable docker daemon as no drift" + ], + "0257e547c2aa2e36": [ + "moduleOperations updateModules(): partial update continuation continues after reporting unrelated credential drift from a confirmed rebuild", + "moduleOperations updateModules(): partial update continuation does not infer a rebuild from an unrelated coin named in the error", + "moduleOperations updateModules(): partial update continuation fails closed when the credential refusal belongs to the requested coin", + "moduleOperations updateModules(): partial update continuation rethrows ordinary install failures without attempting later coins" + ], + "0586050116b0dd94": [ + "VersionService checkAllRemoteVersions() is a function that returns a promise", + "VersionService checkRemoteNodeVersion() calls gitHubDownloader for bitcoin with correct owner/repo", + "VersionService checkRemoteNodeVersion() calls gitHubDownloader for dogecoin with correct owner/repo", + "VersionService checkRemoteNodeVersion() calls gitHubDownloader for litecoin with correct owner/repo", + "VersionService checkRemoteNodeVersion() stores result in remoteModuleVersions with node- key", + "VersionService getContainerModuleVersion() prefers the exec read over the host copy", + "VersionService getContainerModuleVersion() reads package.json from container and returns version", + "VersionService getContainerModuleVersion() reads the version with docker exec even when docker cp is permanently broken", + "VersionService getContainerModuleVersion() throws on docker cp error", + "VersionService getContainerNodeVersion() reads version file from container via docker cp", + "VersionService getContainerNodeVersion() throws on docker cp error", + "VersionService getLocalModuleVersion() reads version from module package.json", + "VersionService getLocalModuleVersion() rejects when package.json does not exist", + "VersionService getLocalModuleVersion() rejects when package.json has no version field", + "VersionService getLocalModuleVersion() rejects when package.json is malformed", + "VersionService getLocalNodeVersion() reads version from correct file path", + "VersionService getLocalNodeVersion() rejects on read error", + "VersionService getLocalNodeVersion() rejects when version file does not exist", + "VersionService readContainerFile() falls back to docker cp when the container cannot be exec'd", + "VersionService readContainerFile() names BOTH failures when neither read works" + ], + "06cdae1616e269c6": [ + "coverage ratchet floors enforces every declared floor, at the declared value", + "coverage ratchet floors fails the job on a shortfall rather than only reporting it", + "coverage ratchet floors measures source files that the unit suite does not load", + "coverage ratchet floors ships the coverage:check script the CI coverage job invokes" + ], + "072c4edd724004b8": [ + "DockerService checkContainerdDataRootRelocation() honors XCHAIN_NODE_CONTAINERD_ROOT override", + "DockerService checkContainerdDataRootRelocation() returns null (best-effort) when docker info fails", + "DockerService checkContainerdDataRootRelocation() returns null when containerd lives on a third off-root disk", + "DockerService checkContainerdDataRootRelocation() returns null when containerd was also relocated off / (same disk as data-root)", + "DockerService checkContainerdDataRootRelocation() returns null when data-root is still on the root filesystem", + "DockerService checkContainerdDataRootRelocation() returns null when docker info returns an empty data-root", + "DockerService checkContainerdDataRootRelocation() returns null when the containerd path does not exist", + "DockerService checkContainerdDataRootRelocation() returns null when the data-root path is unreadable", + "DockerService checkContainerdDataRootRelocation() warns when data-root moved off / but containerd is still on /", + "DockerService startContainer() rejects on exec error", + "DockerService startContainer() rejects when stdout does not match container ID", + "DockerService startContainer() runs docker start " + ], + "08508bce5c7e9e87": [ + "Security Bootstrap directory permissions uses chmod 755 instead of 777 for bootstrap directories" + ], + "08758d91b3b5705b": [ + "E2E: Reset Command (Scenario 4.8) E2E-050: Reset lifecycle (stop → clear → restart) LevelDB entries preserved after reset", + "E2E: Reset Command (Scenario 4.8) E2E-050: Reset lifecycle (stop → clear → restart) stops and then restarts the specified module", + "E2E: Reset Command (Scenario 4.8) E2E-051: Reset decoder resets database executes DROP DATABASE and CREATE DATABASE for decoder DB", + "E2E: Reset Command (Scenario 4.8) E2E-052: Reset all stops multiple modules stops node, utxo-tracker, decoder, indexer, and regtest-miner" + ], + "0a574baed3231d6e": [ + "moduleOperations resetModules() the registry read that decides what to stop aborts before any wipe when a target row cannot be read", + "moduleOperations resetModules() the registry read that decides what to stop reports a module the rollback cannot resolve as still down", + "moduleOperations resetModules() the registry read that decides what to stop still skips a module that is genuinely absent from the registry" ], "0b0e062a08751d6c": [ "ReleaseSignatureService assertDigestMatches() REFUSES a tampered artifact", @@ -128,24 +108,76 @@ "ReleaseSignatureService verifyManifestForTag() the opt-out is only the explicit falsy values, never any set value", "ReleaseSignatureService verifyManifestForTag() verifies signature THEN digest, and reports the key" ], - "113421dff9a3e19e": [ - "ui/menu exports mainMenu as a function", - "ui/menu exports modulesSelectionInterface as a function", - "ui/menu exports restoreBootstrapInterface as a function", - "ui/menu exports startInterface as a function", - "ui/menu per-module action labels are all handled ACTION_INSTALL_LOCAL_IN_CONTAINER (\"Install Local Version in Container\") reaches a handler", - "ui/menu per-module action labels are all handled ACTION_REINSTALL_CONTAINER (\"Reinstall\") reaches a handler", - "ui/menu per-module action labels are all handled ACTION_REINSTALL_REMOTE (\"Reinstall from remote\") reaches a handler", - "ui/menu per-module action labels are all handled ACTION_UPDATE_CONTAINER (\"Update Container\") reaches a handler", - "ui/menu per-module action labels are all handled ACTION_UPDATE_LOCAL (\"Update local version\") reaches a handler", - "ui/menu per-module action labels are all handled every offered per-module action label has a handler branch", - "ui/menu requiring the module has no throwing side effects", - "ui/menu restoreBootstrapInterface non-interactive resolution falls back to the newest archive when there is no TTY to prompt on", - "ui/menu restoreBootstrapInterface non-interactive resolution rejects a --file that is not present instead of silently picking another", - "ui/menu restoreBootstrapInterface non-interactive resolution restores exactly the named archive on --file", - "ui/menu restoreBootstrapInterface non-interactive resolution restores the newest archive on --latest without prompting", - "ui/menu restoreBootstrapInterface non-interactive resolution says so plainly when there is nothing to restore", - "ui/menu restoreBootstrapInterface non-interactive resolution surfaces a failed restore as an error rather than a bare false" + "0b7a214d72e8bdcc": [ + "constants: the e2e image stages every sibling its suites require bundles sync, so the consensus hash drift-lock can run instead of skipping", + "constants: the e2e image stages every sibling its suites require keeps the siblings the other suites resolve directly" + ], + "0bc33761807f31ad": [ + "rail follower clone container plan builds docker create arguments without publishing a port", + "rail follower clone container plan rejects every missing docker create field", + "rail follower clone database plan builds the database and two-table clone statements", + "rail follower clone database plan rejects bad SQL identifiers", + "rail follower clone database plan rejects equal clone databases", + "rail follower clone environment plan keeps source configuration order while dropping container defaults and redirecting the database", + "rail follower clone environment plan rejects a follower database equal to the source", + "rail follower clone environment plan rejects empty follower databases", + "rail follower clone environment plan rejects invalid follower database identifiers", + "rail follower clone environment plan rejects source environments without a database" + ], + "0cb0374496e1a1a8": [ + "DatabaseService getDatabaseContainerId() returns container ID when inspect returns valid 64-char hex", + "DatabaseService getDatabaseContainerId() returns null when inspect command throws", + "DatabaseService getDatabaseContainerId() returns null when inspect returns non-hex output" + ], + "0d34c41e6c892a40": [ + "StatusService: getStatus() branch column includes branch column in output when any module is on a non-master branch", + "StatusService: getStatus() branch column omits branch column when all modules are on master" + ], + "0d73a5716f9c6909": [ + "AutohealService getLastHealthyProbeMs returns null on an empty or missing log", + "AutohealService getLastHealthyProbeMs returns null rather than throwing on an unparseable timestamp", + "AutohealService getLastHealthyProbeMs returns null when every retained probe failed", + "AutohealService getLastHealthyProbeMs returns the newest passing probe", + "AutohealService getUnhealthySinceMs returns null on an empty log", + "AutohealService getUnhealthySinceMs returns null when the newest probe passed", + "AutohealService getUnhealthySinceMs returns the start of the trailing failing run" + ], + "0df0aabf6e94707f": [ + "Chaos: Download Resilience Experiment 8: GitHub API unreachable throws descriptive error on request timeout" + ], + "0e15713d36178e5f": [ + "ReleaseManifestService the provenance gate a refusal from the gate aborts the install", + "ReleaseManifestService the provenance gate fetchReleaseAsset downloads the asset bytes", + "ReleaseManifestService the provenance gate fetchReleaseAsset follows the storage redirect WITHOUT the credentials", + "ReleaseManifestService the provenance gate fetchReleaseAsset reports a rate-limited asset hop with the reset time and GITHUB_TOKEN advice", + "ReleaseManifestService the provenance gate fetchReleaseAsset rethrows any other asset-hop failure unchanged", + "ReleaseManifestService the provenance gate fetchReleaseAsset returns null when the release carries no such asset", + "ReleaseManifestService the provenance gate fetchReleaseAsset returns null when the release itself is absent", + "ReleaseManifestService the provenance gate verifies the fetched manifest BEFORE parsing it" + ], + "10f7c66de1d99f6d": [ + "anchor arm passthrough hands the hub and a regtest indexer the same three values", + "anchor arm passthrough never hands a shared-ledger indexer any of them" + ], + "11478527623fc361": [ + "ValidatorStakeService planMints() flags a shortfall above the per-address cap", + "ValidatorStakeService planMints() flags a token with no open mint", + "ValidatorStakeService planMints() mints only the difference when partly funded", + "ValidatorStakeService planMints() needs no mint when the balance already covers the stake", + "ValidatorStakeService planMints() never mints on mainnet", + "ValidatorStakeService planMints() splits the shortfall into per-transaction cap-sized mints", + "ValidatorStakeService stakeTiming() gives no duration on regtest, where blocks are mined on demand", + "ValidatorStakeService stakeTiming() glosses both counts as the same durations on every chain", + "ValidatorStakeService stakeTiming() reads both clocks from the coin registry, not from the CLI", + "ValidatorStakeService stakeTiming() tracks the per-chain values for litecoin and dogecoin" + ], + "1192154391900a7e": [ + "preCheck: the hub is staged at the ref the command named passes null when the command named no ref, preserving the old behaviour", + "preCheck: the hub is staged at the ref the command named passes the ref through to installHubModule" + ], + "11c6854d9b4d1eb2": [ + "GitHubDownloader hasHash(): arch-specific returns true for object entry with matching arch", + "GitHubDownloader hasHash(): arch-specific returns true for object entry with no arch requirement when keys exist" ], "12558605c0d0415c": [ "crypto-node build scaffold (XCHAIN_NODE_CRYPTO_NODES_DIR) covers every coin and network the installer supports", @@ -169,6 +201,46 @@ "installHubModule() stages the hub from the release manifest pins the hub to the manifest of a named release, not to a hub tag of that name", "installHubModule() stages the hub from the release manifest resolves the latest release for a no-ref install, pinned, instead of cloning master" ], + "13616050e0170f89": [ + "ModuleService buildAndUp(): hub consensus-env drift guard wiring calls assertNoHubConsensusEnvDrift with the generated hub env", + "ModuleService buildAndUp(): hub consensus-env drift guard wiring does not call the hub guard for an unrelated module", + "ModuleService buildAndUp(): hub consensus-env drift guard wiring does not reach the real guard from a default harness load (venue independence)", + "ModuleService buildAndUp(): hub consensus-env drift guard wiring propagates a refusal from the guard instead of building the hub", + "ModuleService buildAndUp(): log retention and observability env adds no observability name when the deploy host sets none", + "ModuleService buildAndUp(): log retention and observability env caps json-file logs at 50m x 4 so 48 h of history survives", + "ModuleService buildAndUp(): log retention and observability env forwards a host-set LOG_LEVEL as a bare --env name, value out of argv", + "ModuleService cloneGit() rewrite over an existing checkout clones into a staging dir, never over the live checkout", + "ModuleService cloneGit() rewrite over an existing checkout leaves the checkout in place when the branch is missing from the remote", + "ModuleService cloneGit() rewrite over an existing checkout leaves the checkout in place when the clone fails for any other reason", + "ModuleService cloneGit() rewrite over an existing checkout points a missing branch at the real mechanism (remote clone / local-path override)", + "ModuleService cloneGit() rewrite over an existing checkout rejects an invalid branch name before touching the filesystem", + "ModuleService cloneGit() rewrite over an existing checkout rejects an unknown module before touching the filesystem", + "ModuleService cloneGit() rewrite over an existing checkout restores the original checkout when the swap-in rename fails", + "ModuleService resolveObservabilityEnv() carries a deploy-host value into the container env", + "ModuleService resolveObservabilityEnv() fabricates nothing when neither source sets a name", + "ModuleService resolveObservabilityEnv() leaves a per-install config-store value alone", + "ModuleService resolveObservabilityEnv() names exactly the four shim controls", + "ModuleService resolveObservabilityEnv() never carries a name that is not one of the four", + "ModuleService resolveObservabilityEnv() treats an empty host value as unset" + ], + "139b1b28aef36fe3": [ + "utils/docker_mariadb dockerMariadbArgs() builds a docker exec invocation forwarding MYSQL_PWD by name only", + "utils/docker_mariadb dockerMariadbArgs() inserts -i before the env forward when interactive", + "utils/docker_mariadb dockerMariadbArgs() never embeds a password value in argv", + "utils/docker_mariadb mariadbEnv() does not mutate process.env", + "utils/docker_mariadb mariadbEnv() returns process.env plus MYSQL_PWD" + ], + "13ff9670bc05bf2e": [ + "S-SMOKE-007 – Docker Command Construction exports all expected Docker operation functions" + ], + "148e787f79bb3562": [ + "StatusService: getStatus() DB not ready returns empty object when db is not ready", + "StatusService: getStatus() cache hit prints cached status when printStatus=true and cache is hot", + "StatusService: getStatus() cache hit returns cached status immediately when isStatusUpdated is true", + "StatusService: statusChanged() reports the hub failure first when both pushes reject", + "StatusService: statusChanged() sets statusUpdated to false and calls updateHub + updateExplorer", + "StatusService: statusChanged() still pushes explorer config when updateHub rejects, then rethrows" + ], "14bd28e1e4b711a9": [ "DiscoveryService.classifyContainer classifies shared modules (no coin/network)", "DiscoveryService.classifyContainer classifies the coin node module", @@ -186,6 +258,12 @@ "DiscoveryService.scanAndRegisterModules registers a live container whose image tag was stolen (the incident case)", "DiscoveryService.scanAndRegisterModules still purges genuinely orphaned rows" ], + "14d2f05367245fea": [ + "Regression Suite [regression:p2] Utility Functions R-HLP-001: stringToCoin maps all supported coins correctly", + "Regression Suite [regression:p2] Utility Functions R-HLP-002: stringToXChainService maps all services correctly", + "Regression Suite [regression:p2] Utility Functions R-HLP-003: stringToNetwork parses coin-network pairs correctly", + "Regression Suite [regression:p2] Utility Functions R-HLP-004: constants enums contain all expected values" + ], "152f5f77b38555af": [ "utils/helpers assertSafeArchiveMemberNames() accepts an empty listing", "utils/helpers assertSafeArchiveMemberNames() accepts names merely containing dots (\"a..b\", \"..hidden\")", @@ -201,11 +279,11 @@ "utils/helpers decompressTarGz() resolves true on success", "utils/helpers decompressTarGz() sets cwd to the parent directory of the file on extraction", "utils/helpers redactSecrets() accepts an Error and redacts secrets from its stack", - "utils/helpers redactSecrets() masks IDENTIFIED BY '\u2026' literals", + "utils/helpers redactSecrets() masks IDENTIFIED BY '…' literals", "utils/helpers redactSecrets() masks MYSQL_PWD / MYSQL_ROOT_PASSWORD env values", "utils/helpers redactSecrets() masks credentials embedded in a git/remote URL", "utils/helpers redactSecrets() masks the per-install service secrets in a failed docker-run argv", - "utils/helpers redactSecrets() masks the value inside PASSWORD('\u2026')", + "utils/helpers redactSecrets() masks the value inside PASSWORD('…')", "utils/helpers redactSecrets() returns clean strings unchanged and handles null", "utils/helpers sleep() resolves after the specified delay", "utils/helpers sleep() returns a promise that resolves", @@ -229,122 +307,91 @@ "utils/helpers stringToXChainService() returns null for empty string", "utils/helpers stringToXChainService() returns null for unknown service" ], - "1ea3450bb4fcc6a7": [ - "mutating command lock wait refuses a held lock immediately when the env var is unset (interactive default)", - "mutating command lock wait takes the lock once the holder releases mid-wait", - "mutating command lock wait waits for the holder when XCHAIN_NODE_MUTATING_LOCK_WAIT_MS is set" + "1535ee8397f03ca7": [ + "ModuleService assertNoHostPortConflicts() does NOT flag the container being re-created (selfName excluded)", + "ModuleService assertNoHostPortConflicts() parses IP-scoped publish specs (IP:HOST:CONTAINER)", + "ModuleService assertNoHostPortConflicts() reports every conflicting port, not just the first", + "ModuleService assertNoHostPortConflicts() resolves when no host ports are requested", + "ModuleService assertNoHostPortConflicts() resolves when requested ports are free on the host", + "ModuleService assertNoHostPortConflicts() throws naming the conflicting container when a host port is taken", + "ModuleService containerExistsByName(): via singleton installModule with inspect rejection treats docker inspect rejection as container-not-present (proceeds with install)", + "ModuleService getModuleBranch() returns trimmed branch name from git rev-parse", + "ModuleService installModule(): node: checkRemoteNodeVersion called when coin not in remoteVersions throws when getCryptoNode fails", + "ModuleService unit-suite venue independence leaves proxyquire in noCallThru mode after a call-through load", + "ModuleService unit-suite venue independence trips the guard instead of probing the host when a call-through load forgets the stub" ], - "2093a3bb7f34dc89": [ - "CLI `recreate` exit code exits NON-ZERO when every requested module was refused", - "CLI `recreate` exit code exits NON-ZERO when recreate throws", - "CLI `recreate` exit code exits ZERO when a container was actually recreated", - "CLI `uninstall` exit code exits NON-ZERO when a module failed to uninstall", - "CLI `uninstall` exit code exits ZERO when every module came down", - "CLI `update` exit code exits NON-ZERO when the update changed nothing (silent no-op redeploy)", - "CLI `update` exit code exits NON-ZERO when the update throws (skew-guard refusal)", - "CLI `update` exit code exits ZERO when a module was actually updated", - "CLI unhandled-rejection backstop keeps the stack when the reason is a real Error", - "CLI unhandled-rejection backstop prints a string reason readably and exits non-zero", - "CLI unhandled-rejection backstop registers exactly one handler" + "15b1667f5500cf45": [ + "Security NODE_PREFIX validation accepts valid lowercase alphanumeric prefix", + "Security NODE_PREFIX validation rejects prefix with dollar sign", + "Security NODE_PREFIX validation rejects prefix with shell metacharacters via env var", + "Security NODE_PREFIX validation rejects prefix with spaces" ], - "2442df610ebb6911": [ - "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins BTC.js is byte-identical to the canonical xchain-hub copy", - "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins DOGE.js is byte-identical to the canonical xchain-hub copy", - "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins LTC.js is byte-identical to the canonical xchain-hub copy", - "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins consensus_pin.js is byte-identical to the canonical xchain-hub copy", - "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins index.js is byte-identical to the canonical xchain-hub copy", - "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files BTC/regtest vendored pin matches the vendored consensusHash", - "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files BTC/testnet vendored pin matches the vendored consensusHash", - "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files DOGE/regtest vendored pin matches the vendored consensusHash", - "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files DOGE/testnet vendored pin matches the vendored consensusHash", - "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files LTC/regtest vendored pin matches the vendored consensusHash", - "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files LTC/testnet vendored pin matches the vendored consensusHash", - "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files verifyConsensusPin passes for every network on the vendored bundle" + "16b722b1d1f9fa39": [ + "DatabaseService resetDatabases() drops and recreates each database module", + "DatabaseService resetDatabases() drops the CONFIGURED database names, not the derived defaults", + "DatabaseService resetDatabases() falls back to the derived name when config carries no database name", + "DatabaseService resetDatabases() refuses the docker-mode reset when a derived database name is not a safe identifier", + "DatabaseService resetDatabases() refuses the external-DB reset on the second module before the first is dropped", + "DatabaseService resetDatabases() refuses the reset when a CONFIGURED database name is not a safe identifier", + "DatabaseService resetDatabases() refuses to drop anything when no MariaDB container exists" ], - "26da132c6adf5f9c": [ - "GitHubDownloader _getHashForArch() returns arch-specific hash for object entry", - "GitHubDownloader _getHashForArch() returns null for missing repo/version", - "GitHubDownloader _getHashForArch() returns null for unknown arch in object entry", - "GitHubDownloader _getHashForArch() returns string hash for legacy string entry (any arch)", - "GitHubDownloader calculateDirectoryHash() computes SHA-256 hash from file contents", - "GitHubDownloader constructor creates empty hashes file if it does not exist", - "GitHubDownloader constructor loads hashes file on construction", - "GitHubDownloader constructor throws on invalid hash format (non-64 char hex)", - "GitHubDownloader downloadReleaseAsset() downloads gz asset using axios stream + pipeline, extracts with tar", - "GitHubDownloader downloadReleaseAsset() extracts zip asset and calls unzip", - "GitHubDownloader downloadReleaseAsset() handles single extracted directory by flattening contents", - "GitHubDownloader downloadReleaseAsset() refuses to extract a gz asset with an unsafe member path", - "GitHubDownloader downloadReleaseAsset() refuses to extract a zip asset with an unsafe member path", - "GitHubDownloader downloadReleaseAsset() throws when no matching linux asset found for host arch", - "GitHubDownloader downloadReleaseAsset() throws when tar fails with non-zero exit code", - "GitHubDownloader downloadReleaseAsset() throws when unzip fails with non-zero exit code", - "GitHubDownloader downloadReleaseAsset() warns on unrecognized file extension (no extraction)", - "GitHubDownloader downloadReleaseAsset(): verifyHash=true calls verifyRepositoryHash when verifyHash=true", - "GitHubDownloader downloadRepoVersion() cleans up the staging directory when download fails and leaves the previous tree alone", - "GitHubDownloader downloadRepoVersion() extracts into a staging directory and swaps it over the previous version tree", - "GitHubDownloader downloadRepoVersion() throws when hash not found for repo/version", - "GitHubDownloader downloadRepoVersion() writes version file on successful download", - "GitHubDownloader getAllFiles() returns empty array on error", - "GitHubDownloader getAllFiles() returns single file in array when path is a file", - "GitHubDownloader getAllFiles(): directory traversal returns all files recursively from a directory", - "GitHubDownloader getLatestCompatibleVersion() returns the latest release regardless of hash when verifyHash=false", - "GitHubDownloader getLatestCompatibleVersion() returns the latest release with a matching hash when verifyHash=true", - "GitHubDownloader getLatestCompatibleVersion() throws when no version has a matching hash", - "GitHubDownloader getReleaseByTag() calls correct GitHub API URL with tag", - "GitHubDownloader getReleaseByTag(): error throws with descriptive message on error", - "GitHubDownloader getReleases() a plain 403 (not rate-limited) keeps the generic API error", - "GitHubDownloader getReleases() calls GitHub API with correct URL", - "GitHubDownloader getReleases() returns release data on success", - "GitHubDownloader getReleases() sends Authorization: Bearer when GITHUB_TOKEN is set", - "GitHubDownloader getReleases() sends no Authorization header without a token", - "GitHubDownloader getReleases() surfaces a rate-limit-specific error on 403 with remaining=0", - "GitHubDownloader getReleases() throws with descriptive message on 404", - "GitHubDownloader getReleases(): generic error throws with generic error message on non-404 errors", - "GitHubDownloader hasHash() returns false for unknown repo", - "GitHubDownloader hasHash() returns false when hash does not exist", - "GitHubDownloader hasHash() returns true when hash exists for repo/version", - "GitHubDownloader hasHash(): arch-specific returns true for object entry with matching arch", - "GitHubDownloader hasHash(): arch-specific returns true for object entry with no arch requirement when keys exist", - "GitHubDownloader loadHashesFile(): invalid hash in object entry throws when arch hash is invalid in object entry", - "GitHubDownloader loadHashesFile(): invalid hash in object entry throws when top-level repo entry is not an object", - "GitHubDownloader loadHashesFile(): invalid hash in object entry throws when version entry is neither string nor object", - "GitHubDownloader verifyFileHash() computes the SHA-256 of the file bytes", - "GitHubDownloader verifyFileHash() fails closed when no hash is registered for the version/arch", - "GitHubDownloader verifyFileHash() passes when the tarball hash matches the registered per-arch hash", - "GitHubDownloader verifyFileHash() throws when the tarball hash does not match", - "GitHubDownloader verifyRepositoryHash() does not throw when hash matches", - "GitHubDownloader verifyRepositoryHash() passes explicit arch parameter to _getHashForArch", - "GitHubDownloader verifyRepositoryHash() throws when hash does not match", - "GitHubDownloader verifyRepositoryHash() throws when no hash registered for the repo/version/arch" + "1a6e2ea5d2022758": [ + "StatusService: loadInstalledModules() calls checkRemoteNodeVersion when checkVersions=true", + "StatusService: loadInstalledModules() handles multiple modules for same coin/network", + "StatusService: loadInstalledModules() populates installedModules from db.getAllModuleContainers" ], - "2ac8a00f5806cacb": [ - "ValidatorService ROLLCALL status reporting getActiveSignerFile() falls back to the generated signer.js once init has written one", - "ValidatorService ROLLCALL status reporting getActiveSignerFile() is null when no signer is configured at all", - "ValidatorService ROLLCALL status reporting getActiveSignerFile() prefers an operator-supplied signer directory", - "ValidatorService ROLLCALL status reporting getRollcallStatus() a streak of one reads as a warning shot, not an eviction", - "ValidatorService ROLLCALL status reporting getRollcallStatus() a streak of two with evicted:1 on the head row reads as evicted", - "ValidatorService ROLLCALL status reporting getRollcallStatus() degrades the DOGE runway to unavailable rather than reporting 0 roll calls", - "ValidatorService ROLLCALL status reporting getRollcallStatus() degrades to unavailable, never a reassuring zero, when the absence read throws", - "ValidatorService ROLLCALL status reporting getRollcallStatus() degrades to unavailable, not \"no absences\", when the indexer predates this read", - "ValidatorService ROLLCALL status reporting getRollcallStatus() healthy: a DOGE runway, no absences, and a publish-capable signer", - "ValidatorService ROLLCALL status reporting getRollcallStatus() reports a signer with no broadcast export, naming the file, independent of wallet state", - "ValidatorService ROLLCALL status reporting rollcallAbsenceStreak() counts a single absence as a streak of one", - "ValidatorService ROLLCALL status reporting rollcallAbsenceStreak() counts two absences one epoch apart as a streak of two", - "ValidatorService ROLLCALL status reporting rollcallAbsenceStreak() only counts the run starting at the head of the array, never a streak buried deeper in the history", - "ValidatorService ROLLCALL status reporting rollcallAbsenceStreak() returns 0 for an empty list (healthy: no absence on record, not \"unknown\")", - "ValidatorService ROLLCALL status reporting rollcallAbsenceStreak() stops the streak at a rolled (present) epoch between two absences, even though only absences are in the array", - "ValidatorService ROLLCALL status reporting rollcallEpochBlocks() is 30 on regtest and 1008 on testnet, mainnet, and anything unrecognized", - "ValidatorService ROLLCALL status reporting signerModuleExportsBroadcast() is not fooled by a TODO comment INSIDE the exports block that mentions broadcast(", - "ValidatorService ROLLCALL status reporting signerModuleExportsBroadcast() is not fooled by a contract comment BEFORE module.exports that only mentions broadcast", - "ValidatorService ROLLCALL status reporting signerModuleExportsBroadcast() recognizes the CLI-generated template shape (object-literal broadcast beside walletSign)", - "ValidatorService ROLLCALL status reporting signerModuleExportsBroadcast() recognizes the separate exports.broadcast = assignment form", - "ValidatorService ROLLCALL status reporting signerModuleExportsBroadcast() returns false for a hand-built module that only signs", - "ValidatorService ROLLCALL status reporting signerModuleExportsBroadcast() returns null when there is no signer file to check", - "ValidatorService VALIDATOR_DIR is configDir/validator", - "ValidatorService capability-config layout migration MOVES rather than copies, so only one config can ever be edited", - "ValidatorService capability-config layout migration does not migrate the empty DIRECTORY docker leaves at a missing mount source", - "ValidatorService capability-config layout migration is a no-op once migrated", - "ValidatorService capability-config layout migration moves a pre-hub-caps capabilities.json into its own directory", + "1ad790e7ec7606c5": [ + "ValidatorService getCapabilityConfigMountDir() REFUSES to hand the hub a directory that also holds the signing key", + "ValidatorService getCapabilityConfigMountDir() refuses any other stray file in the mounted directory", + "ValidatorService getCapabilityConfigMountDir() returns null when this node is not a validator", + "ValidatorService getCapabilityConfigMountDir() returns the DIRECTORY holding the capability config, not the file" + ], + "1b2bb6c7e95e88ac": [ + "DockerService stopContainer() rejects when stdout does not match", + "DockerService stopContainer() runs docker stop ", + "DockerService stopContainer() with a budget rejects with docker's own error object, so a missing container stays distinguishable", + "DockerService stopContainer() with a budget runs docker stop -t and resolves the outcome read after it", + "DockerService stopContainer() with a budget still rejects when docker exits 0 without echoing the id", + "DockerService stopContainerByName() carries a non-zero self exit out as exitCode without calling it a kill", + "DockerService stopContainerByName() reports a kill when the container exited 137", + "DockerService stopContainerByName() reports not stopped, and does not inspect, when docker did not confirm the stop", + "DockerService stopContainerByName() runs docker stop -t and reads the exit code after it", + "DockerService stopContainerByName() treats an unreadable exit code as clean unless the stop consumed the whole budget" + ], + "1b74d54e11533ef4": [ + "GitHubDownloader downloadReleaseAsset(): verifyHash=true calls verifyRepositoryHash when verifyHash=true" + ], + "1b873fc16e610f8e": [ + "Chaos: Process Resilience Experiment: Precheck failure cascade Docker check failure stops all subsequent precheck steps" + ], + "1bac4d9ab7be0cd2": [ + "information_schema SQL builders builds the multi-table existence query with escaped literals", + "information_schema SQL builders builds the schema existence query", + "information_schema SQL builders builds the schema size query", + "information_schema SQL builders builds the table count query", + "information_schema SQL builders builds the table existence query" + ], + "1ce50380a1677475": [ + "Security stringToDockerContainerFile safety uses spawn with tee instead of exec with shell interpolation" + ], + "1d4f5ea539108f42": [ + "CredentialsService loadExternalDbConfig() returns null when externalDb is absent", + "CredentialsService loadExternalDbConfig() returns null when file cannot be parsed", + "CredentialsService loadExternalDbConfig() returns the externalDb block when present" + ], + "1d8efbe793503797": [ + "DockerService createDockerNetwork() creates network when inspect fails (network does not exist)", + "DockerService createDockerNetwork() resolves true when network already exists", + "DockerService getStatusFromContainer() rejects on exec error", + "DockerService getStatusFromContainer() runs docker inspect and returns parsed JSON" + ], + "1dc33a33679f1947": [ + "ValidatorService coin wallets hub env wiring a validator without wallets gets no DOGE wiring (pre-wallets install)", + "ValidatorService coin wallets hub env wiring an operator-supplied signer directory takes precedence over the generated one", + "ValidatorService coin wallets hub env wiring hands the hub the DOGE publisher address, pubkey, encoder and signer module, never the WIF", + "ValidatorService coin wallets hub env wiring host env wins over the recorded values" + ], + "1ee619322512f5f5": [ "ValidatorService capability-drift probe alerts when a host that resolved standalone is still in a live set", "ValidatorService capability-drift probe alerts when a resolved validator is in NO capability set", "ValidatorService capability-drift probe an indexer error body reads as unknown rather than an empty set", @@ -361,95 +408,187 @@ "ValidatorService capability-drift probe says there is nothing to compare, and asks no indexer, when the host has no identity", "ValidatorService capability-drift probe settles membership at the block it was handed, when one is given", "ValidatorService capability-drift probe still compares a DISABLED validator, whose key can be staked and in a set", - "ValidatorService capability-drift probe treats a TRUNCATED set as unknown, never as \"not in the set\"", - "ValidatorService coin wallets --force-wallets replaces them", - "ValidatorService coin wallets --network names the federation and picks the matching port", - "ValidatorService coin wallets --no-wallets skips wallet generation entirely", - "ValidatorService coin wallets --oracle-epoch-start still overrides the federation default", - "ValidatorService coin wallets both federation default epochs are in the past, and differ", - "ValidatorService coin wallets creates that mountpoint at mount time too, for a config written before it existed", - "ValidatorService coin wallets creates the node_modules mountpoint the SDK mount lands on", - "ValidatorService coin wallets defaults ORACLE_EPOCH_START to the mainnet federation value", - "ValidatorService coin wallets defaults ORACLE_EPOCH_START to the testnet federation value", - "ValidatorService coin wallets does not claim a signer mount when the signer files are absent", - "ValidatorService coin wallets fills oracle_publish in the fresh capabilities file from the DOGE wallet", - "ValidatorService coin wallets generates a testnet stake wallet and a testnet DOGE wallet for port 10002", - "ValidatorService coin wallets hub env wiring a validator without wallets gets no DOGE wiring (pre-wallets install)", - "ValidatorService coin wallets hub env wiring an operator-supplied signer directory takes precedence over the generated one", - "ValidatorService coin wallets hub env wiring hands the hub the DOGE publisher address, pubkey, encoder and signer module, never the WIF", - "ValidatorService coin wallets hub env wiring host env wins over the recorded values", - "ValidatorService coin wallets imports operator-supplied WIFs from the environment and derives their addresses", - "ValidatorService coin wallets keeps existing wallets across --force (a funded address must not be abandoned silently)", - "ValidatorService coin wallets leaves ORACLE_EPOCH_START null when the network is unknown", - "ValidatorService coin wallets points the signer at the DOGE wallet and the public testnet encoder", - "ValidatorService coin wallets publicWalletInfo never carries a WIF", - "ValidatorService coin wallets refuses to import a key when the network is unknown", - "ValidatorService coin wallets rejects a WIF from the wrong network", - "ValidatorService coin wallets rejects an unknown --network", - "ValidatorService coin wallets skips wallets on a non-standard port and says so, without failing init", - "ValidatorService coin wallets the emitted signer marks post-funding failures does not tag a successful two-phase publish", - "ValidatorService coin wallets the emitted signer marks post-funding failures is valid JavaScript once the template literal is expanded", - "ValidatorService coin wallets the emitted signer marks post-funding failures leaves a pre-funding failure untagged, so the round stays retryable", - "ValidatorService coin wallets the emitted signer marks post-funding failures tags a definitive phase-2 rejection with fundsCommitted and the phase-1 txid", - "ValidatorService coin wallets writes wallets.env and the signer .env with mode 0600, and the signer module beside it", - "ValidatorService container mount points keeps the hub-side file path unchanged, so the hub needs no change", - "ValidatorService container mount points mounts the container DIRECTORY that path sits in", - "ValidatorService getCapabilityConfigHostPath() returns CAPS_FILE path when initialized and caps file exists", - "ValidatorService getCapabilityConfigHostPath() returns null when initialized but caps file missing", - "ValidatorService getCapabilityConfigHostPath() returns null when not initialized", - "ValidatorService getCapabilityConfigMountDir() REFUSES to hand the hub a directory that also holds the signing key", - "ValidatorService getCapabilityConfigMountDir() refuses any other stray file in the mounted directory", - "ValidatorService getCapabilityConfigMountDir() returns null when this node is not a validator", - "ValidatorService getCapabilityConfigMountDir() returns the DIRECTORY holding the capability config, not the file", - "ValidatorService getValidatorEnv() omits ORACLE_EPOCH_START when null", - "ValidatorService getValidatorEnv() returns empty object when not initialized", - "ValidatorService getValidatorEnv() returns env vars with SIGNING_PRIVKEY_HEX when initialized", - "ValidatorService getValidatorSettings() returns null when not initialized", - "ValidatorService getValidatorSettings() returns null when readFileSync throws", - "ValidatorService getValidatorSettings() returns null when settings JSON is invalid", - "ValidatorService getValidatorSettings() returns null when settings.enabled is false", - "ValidatorService getValidatorSettings() returns settings when initialized and enabled=true", - "ValidatorService initValidator() creates the capability-config directory and writes the config inside it", - "ValidatorService initValidator() creates validator dir if it does not exist", - "ValidatorService initValidator() defaults P2P_VALIDATOR_ADDR to 0.0.0.0: when p2pAddr not given", - "ValidatorService initValidator() falls back to the mainnet federation epoch when none is supplied", - "ValidatorService initValidator() fills only the publisher placeholders in an existing capabilities file", - "ValidatorService initValidator() hub API key a re-run over an already-initialized node does NOT mint a key on a keyless host", - "ValidatorService initValidator() hub API key a re-run over an already-initialized node does NOT mint under --force either", - "ValidatorService initValidator() hub API key a re-run over an already-initialized node mints when --mint-hub-api-key asks it to", - "ValidatorService initValidator() hub API key a re-run over an already-initialized node names the consequence instead of minting silently", - "ValidatorService initValidator() hub API key a re-run over an already-initialized node still reports an existing key, without rotating it", - "ValidatorService initValidator() hub API key ensures a hub API key exists as part of init", - "ValidatorService initValidator() hub API key prints no key material, only the path", - "ValidatorService initValidator() hub API key reports a pre-existing key as reused rather than claiming a fresh one", - "ValidatorService initValidator() hub API key tells the operator WHERE the credential lives", - "ValidatorService initValidator() leaves an existing, operator-tuned capabilities file alone when force is false", - "ValidatorService initValidator() re-generates key with force=true even when already initialized", - "ValidatorService initValidator() records the network on a validator.json that predates the field", - "ValidatorService initValidator() repairs a pre-wallets validator on a re-run, without touching the signing key", - "ValidatorService initValidator() returns existing settings and never rotates the signing key when already initialized", - "ValidatorService initValidator() sets ORACLE_EPOCH_START from opts.oracleEpochStart", - "ValidatorService initValidator() sets P2P_PORT from opts.p2pPort", - "ValidatorService initValidator() sets P2P_VALIDATOR_ADDR from opts.p2pAddr", - "ValidatorService initValidator() sets SEED_NODES from comma-separated opts.seedNodes", - "ValidatorService initValidator() skips validator dir creation if it already exists", - "ValidatorService initValidator() uses partial capabilities from opts.capabilities", - "ValidatorService initValidator() writes key file with mode 0600", - "ValidatorService initValidator() writes key, settings, and capabilities files on first run", - "ValidatorService isInitialized() returns false when key file is missing", - "ValidatorService isInitialized() returns false when neither file exists", - "ValidatorService isInitialized() returns false when settings file is missing", - "ValidatorService isInitialized() returns true when both settings and key files exist", - "ValidatorService pubkeyFromSeedHex() different seeds \u2192 different pubkeys", - "ValidatorService pubkeyFromSeedHex() is deterministic: same seed \u2192 same pubkey", - "ValidatorService pubkeyFromSeedHex() returns a 64-char hex string", - "ValidatorService validatorModeReport() calls a HALF-present state incomplete, never standalone, and names what is missing", - "ValidatorService validatorModeReport() handles missing SEED_NODES field gracefully", - "ValidatorService validatorModeReport() is incomplete with the key present and the settings gone, the other half of the pair", - "ValidatorService validatorModeReport() joins SEED_NODES array into comma-separated string", - "ValidatorService validatorModeReport() reports standalone, and still names the directory it checked", - "ValidatorService validatorModeReport() reports validator mode and the directory it resolved from", - "ValidatorService validatorModeReport() separates a DISABLED validator from one that was never configured" + "ValidatorService capability-drift probe treats a TRUNCATED set as unknown, never as \"not in the set\"" + ], + "1f2aa9f88ca90972": [ + "BootstrapHealthGate durable halt markers REFUSES an indexer whose OWN database carries an uncleared sync halt", + "BootstrapHealthGate durable halt markers does not run marker queries for the utxo-tracker (LevelDB, no such table)", + "BootstrapHealthGate durable halt markers gating a decoder queries no second database" + ], + "1f9845108589a523": [ + "E2E: Update Flow (Scenario 4.6) E2E-040: Update replaces container kills old container, removes it, builds new image, and runs new container", + "E2E: Update Flow (Scenario 4.6) E2E-041: LevelDB updated with new container ID new container ID is stored after update", + "E2E: Update Flow (Scenario 4.6) E2E-042: Update with specific branch git clone uses the specified branch", + "E2E: Update Flow (Scenario 4.6) E2E-043: Update multiple modules updates each module independently" + ], + "1fcd3f9e733e968a": [ + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) bitcoin leg boots exactly one stack, unchanged from the single-stack shape", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) dogecoin leg installs the coin under test first, then the bitcoin gas rail, both at the same ref", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) dogecoin leg never writes a credential into either file (the install generates those into the .local sidecars)", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) dogecoin leg routes the coin indexer to the bitcoin indexer for the bridge escrow proof", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) dogecoin leg routes the e2e container to the bitcoin rail through the docker bridge gateway", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) dogecoin leg writes the coin and bitcoin config files with the chainRail port blocks", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) gates the ports step off the bitcoin leg, whose single-stack shape stays as it was", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) litecoin leg installs the coin under test first, then the bitcoin gas rail, both at the same ref", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) litecoin leg never writes a credential into either file (the install generates those into the .local sidecars)", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) litecoin leg routes the coin indexer to the bitcoin indexer for the bridge escrow proof", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) litecoin leg routes the e2e container to the bitcoin rail through the docker bridge gateway", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) litecoin leg writes the coin and bitcoin config files with the chainRail port blocks", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) raises the runner DB connection cap above what two stacks of ten-connection pools need", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) validator mode on the two-stack legs bitcoin (opt-in): keeps the identity but never seeds, so the input changes nothing beyond the price regime it documents", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) validator mode on the two-stack legs dogecoin: inits a cross_chain-capable identity and exports the seed so the hub finalizes the gas lock", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) validator mode on the two-stack legs initializes the identity BEFORE the ports and boot steps, which render the hub env from it", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) validator mode on the two-stack legs litecoin: inits a cross_chain-capable identity and exports the seed so the hub finalizes the gas lock", + "nightly-e2e.yml two-stack legs (litecoin and dogecoin gas in over the bitcoin rail) validator mode on the two-stack legs runs the validator init for every non-bitcoin leg, and for bitcoin only on the opt-in input" + ], + "20b64a17eccbdd28": [ + "getHostArch maps arm64 to aarch64", + "getHostArch maps x64 to x86_64", + "getHostArch rejects unsupported architecture ia32", + "getHostArch rejects unsupported architecture ppc64" + ], + "2229616699655894": [ + "Integration: Multi-Module Orchestration installModules orchestration creates Docker network and database before installing modules", + "Integration: Multi-Module Orchestration installModules orchestration processes multiple coin/network stacks sequentially", + "Integration: Multi-Module Orchestration installModules orchestration skips database creation for shared services (empty coin/network)" + ], + "222cf77585f733f4": [ + "Chaos: Config Resilience Experiment 12: Config path traversal allows valid coin-network combinations", + "Chaos: Config Resilience Experiment 12: Config path traversal blocks basic path traversal with ../", + "Chaos: Config Resilience Experiment 12: Config path traversal blocks traversal with encoded separators in coin name" + ], + "23b229f4464bf168": [ + "moduleOperations resetModules() EXTERNAL_DB pre-wipe reachability guard aborts before anything is stopped or wiped when the external DB is unreachable", + "moduleOperations resetModules() EXTERNAL_DB pre-wipe reachability guard aborts the same way when the external config cannot be resolved", + "moduleOperations resetModules() EXTERNAL_DB pre-wipe reachability guard does not probe the external DB when no database is being reset", + "moduleOperations resetModules() EXTERNAL_DB pre-wipe reachability guard proceeds to the reset when the external DB answers", + "moduleOperations resetModules() docker-mode MariaDB root password precheck aborts before anything is stopped or wiped when root cannot be resolved", + "moduleOperations resetModules() docker-mode MariaDB root password precheck asks for no root password when no database is being reset", + "moduleOperations resetModules() docker-mode MariaDB root password precheck leaves EXTERNAL_DB mode on its own reachability probe", + "moduleOperations resetModules() docker-mode MariaDB root password precheck resolves root before the first stop when it succeeds" + ], + "2442df610ebb6911": [ + "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins BTC.js is byte-identical to the canonical xchain-hub copy", + "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins DOGE.js is byte-identical to the canonical xchain-hub copy", + "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins LTC.js is byte-identical to the canonical xchain-hub copy", + "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins consensus_pin.js is byte-identical to the canonical xchain-hub copy", + "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins index.js is byte-identical to the canonical xchain-hub copy", + "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files BTC/regtest vendored pin matches the vendored consensusHash", + "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files BTC/testnet vendored pin matches the vendored consensusHash", + "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files DOGE/regtest vendored pin matches the vendored consensusHash", + "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files DOGE/testnet vendored pin matches the vendored consensusHash", + "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files LTC/regtest vendored pin matches the vendored consensusHash", + "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files LTC/testnet vendored pin matches the vendored consensusHash", + "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files verifyConsensusPin passes for every network on the vendored bundle" + ], + "24bd240f4198a15b": [ + "Chaos: Config Resilience Experiment: Argument parsing resilience accepts valid branch names with dots, hyphens, underscores, slashes", + "Chaos: Config Resilience Experiment: Argument parsing resilience handles empty args array", + "Chaos: Config Resilience Experiment: Argument parsing resilience handles null/undefined args in array", + "Chaos: Config Resilience Experiment: Argument parsing resilience refuses extremely long unrecognized argument strings without hanging", + "Chaos: Config Resilience Experiment: Argument parsing resilience rejects branch name with backticks", + "Chaos: Config Resilience Experiment: Argument parsing resilience rejects branch name with semicolons", + "Chaos: Config Resilience Experiment: Argument parsing resilience rejects branch name with shell injection characters", + "Chaos: Config Resilience Experiment: Argument parsing resilience rejects branch name with spaces" + ], + "252355e9bf94d77b": [ + "GitHubDownloader getHashForArch() returns arch-specific hash for object entry", + "GitHubDownloader getHashForArch() returns null for missing repo/version", + "GitHubDownloader getHashForArch() returns null for unknown arch in object entry", + "GitHubDownloader getHashForArch() returns string hash for legacy string entry (any arch)" + ], + "25498cac11aac073": [ + "Integration: Multi-Module Orchestration updateModules orchestration clones git for non-node modules before installing", + "Integration: Multi-Module Orchestration updateModules orchestration reads old container ID from LevelDB and passes it to installModule" + ], + "26f27dbd3b14ec37": [ + "ValidatorService VALIDATOR_DIR is configDir/validator" + ], + "277a78225d926189": [ + "Boundary Tests ConfigService: naming helper boundaries getDockerContainerImageName uses prefix for all shared modules", + "Boundary Tests ConfigService: naming helper boundaries getDockerNetwork with both empty strings returns just prefix", + "Boundary Tests ConfigService: naming helper boundaries getDockerNetwork with coin only returns prefix-coin", + "Boundary Tests ConfigService: naming helper boundaries getModuleDatabaseName capitalizes network correctly", + "Boundary Tests ConfigService: naming helper boundaries getModuleDatabaseName uses correct ticker for all coins" + ], + "2828c04ca92985e6": [ + "Chaos: Network Resilience (Hub/Explorer) Experiment 15: Hub connector resilience returns false when hub is unreachable (ECONNREFUSED)", + "Chaos: Network Resilience (Hub/Explorer) Experiment 15: Hub connector resilience returns false when hub returns empty response", + "Chaos: Network Resilience (Hub/Explorer) Experiment 15: Hub connector resilience returns false when hub returns null data", + "Chaos: Network Resilience (Hub/Explorer) Experiment 15: Hub connector resilience returns false when hub times out", + "Chaos: Network Resilience (Hub/Explorer) Experiment 15: Hub connector resilience returns true when hub responds with result", + "Chaos: Network Resilience (Hub/Explorer) Experiment 15: Hub connector resilience updateConfig returns false when hub is unreachable", + "Chaos: Network Resilience (Hub/Explorer) Experiment 15: Hub connector resilience updateConfig returns false when hub returns HTTP 500", + "Chaos: Network Resilience (Hub/Explorer) Experiment 15: Hub connector resilience updateConfig returns false when response has no result", + "Chaos: Network Resilience (Hub/Explorer) Experiment 15: Hub connector resilience updateConfig returns true on successful response", + "Chaos: Network Resilience (Hub/Explorer) Experiment 15: Hub connector resilience updateConfig sends correct JSON-RPC payload", + "Chaos: Network Resilience (Hub/Explorer) Experiment 15b: Explorer connector resilience returns false when explorer is unreachable", + "Chaos: Network Resilience (Hub/Explorer) Experiment 15b: Explorer connector resilience returns false when explorer returns empty response", + "Chaos: Network Resilience (Hub/Explorer) Experiment 15b: Explorer connector resilience returns false when explorer returns error in response", + "Chaos: Network Resilience (Hub/Explorer) Experiment 15b: Explorer connector resilience returns false when explorer times out", + "Chaos: Network Resilience (Hub/Explorer) Experiment 15b: Explorer connector resilience returns true when explorer responds with result", + "Chaos: Network Resilience (Hub/Explorer) Experiment 15b: Explorer connector resilience sends correct JSON-RPC ping payload", + "Chaos: Network Resilience (Hub/Explorer) Experiment: DNS resolution failure for service endpoints explorer returns false on DNS failure", + "Chaos: Network Resilience (Hub/Explorer) Experiment: DNS resolution failure for service endpoints hub returns false on DNS failure", + "Chaos: Network Resilience (Hub/Explorer) Experiment: Malformed JSON-RPC responses hub returns false when response.data is undefined", + "Chaos: Network Resilience (Hub/Explorer) Experiment: Malformed JSON-RPC responses hub returns false when result is null", + "Chaos: Network Resilience (Hub/Explorer) Experiment: Malformed JSON-RPC responses reports the hub reachable when a false result is present" + ], + "28c7e0d3022480dd": [ + "ExplorerService: installExplorerModule() updateExplorer error in loop retries on updateExplorer error and returns true when subsequent ping+updateExplorer succeed" + ], + "28f3c87021ea9588": [ + "Chaos: Process Resilience Experiment: Precheck failure cascade Hub install failure throws with descriptive message" + ], + "2957ae7991de09d4": [ + "MariaDbStore assertReady() passes once the pool is open", + "MariaDbStore assertReady() throws on an unconfigured store, naming the operation", + "MariaDbStore createDatabase() / isReady() / close() is idempotent: second createDatabase returns the existing pool", + "MariaDbStore createDatabase() / isReady() / close() opens the pool and reports ready", + "MariaDbStore createDatabase() / isReady() / close() reports not ready after close", + "MariaDbStore createDatabase() / isReady() / close() reports not ready before createDatabase", + "MariaDbStore createDatabase() / isReady() / close() throws when createDatabase is called without config", + "MariaDbStore createDatabase() connection retry retries and succeeds after transient connection failures", + "MariaDbStore createDatabase() connection retry throws after 6 failed connection attempts", + "MariaDbStore deleteModuleContainer() does not affect other entries", + "MariaDbStore deleteModuleContainer() entry is no longer retrievable after removal", + "MariaDbStore deleteModuleContainer() removes an existing entry and returns the container ID", + "MariaDbStore deleteModuleContainer() returns false when pool is not ready", + "MariaDbStore deleteModuleContainer() returns true for non-existent key (idempotent delete)", + "MariaDbStore getAllModuleContainers() always includes shared modules in filtered results", + "MariaDbStore getAllModuleContainers() filters by coin and network and includes shared modules", + "MariaDbStore getAllModuleContainers() returns all entries when no filters", + "MariaDbStore getAllModuleContainers() returns empty array when no rows", + "MariaDbStore getAllModuleContainers() returns empty array when pool is not ready", + "MariaDbStore getAllModuleContainers() returns rows shaped as { module, coin, network, container_id }", + "MariaDbStore setModuleContainer() + getModuleContainer() coerces null coin/network to empty string for keying", + "MariaDbStore setModuleContainer() + getModuleContainer() get returns null when pool is not ready", + "MariaDbStore setModuleContainer() + getModuleContainer() insert is a no-op (returns false) when pool is not ready", + "MariaDbStore setModuleContainer() + getModuleContainer() keeps separate entries for different coin/network combos", + "MariaDbStore setModuleContainer() + getModuleContainer() overwrites existing entry on re-insert", + "MariaDbStore setModuleContainer() + getModuleContainer() returns null for non-existent key", + "MariaDbStore setModuleContainer() + getModuleContainer() returns true on successful insert", + "MariaDbStore setModuleContainer() + getModuleContainer() stores and retrieves a container ID", + "MariaDbStore setModuleContainer() + getModuleContainer() stores shared modules with empty coin/network" + ], + "29583491ff7dbbc4": [ + "ModuleService buildAndUp(): module-specific port/volume branches includes --restart unless-stopped for non-execution containers", + "ModuleService buildAndUp(): module-specific port/volume branches includes port mapping for xchain-indexer and invokes cpSync filter for bundled libs", + "ModuleService buildAndUp(): module-specific port/volume branches includes port mapping for xchain-regtest-miner", + "ModuleService buildAndUp(): module-specific port/volume branches mounts the capability config DIRECTORY when HUB_CAPABILITY_CONFIG is set", + "ModuleService buildAndUp(): module-specific port/volume branches omits --restart for onlyExecution containers", + "ModuleService buildAndUp(): module-specific port/volume branches refuses to build on a host without buildx and never invokes docker build", + "ModuleService buildAndUp(): module-specific port/volume branches rejects when docker build fails", + "ModuleService buildAndUp(): module-specific port/volume branches rejects when docker run fails", + "ModuleService buildAndUp(): module-specific port/volume branches rejects when port value is invalid (non-numeric)", + "ModuleService buildAndUp(): module-specific port/volume branches runs docker build with DOCKER_BUILDKIT=1 even when the host exports DOCKER_BUILDKIT=0", + "ModuleService buildAndUp(): module-specific port/volume branches runs the host-port preflight BEFORE docker build so a conflict fails fast" + ], + "29d03dcc1c784287": [ + "NodeService: getCryptoNode() a tarball the operator placed by hand is used, not destroyed discards a file that fails the pinned hash and downloads instead", + "NodeService: getCryptoNode() a tarball the operator placed by hand is used, not destroyed never decompresses bytes that fail the pinned hash", + "NodeService: getCryptoNode() a tarball the operator placed by hand is used, not destroyed skips the download entirely when the tarball is already at the path", + "NodeService: getCryptoNode() a tarball the operator placed by hand is used, not destroyed still verifies the placed tarball against the pinned hash before using it" ], "2ae188b9157fab7b": [ "crossChainNetworksFor applies to the indexer only: every other module gets no extra networks", @@ -461,193 +600,63 @@ "crossChainNetworksFor never crosses network tiers: a testnet sibling is invisible to a regtest indexer", "crossChainNetworksFor returns all same-tier siblings, sorted, when several are installed" ], - "2be99405409c0a53": [ - "ConfigService checkIfCryptoNodeSourceExists() returns false when Dockerfile missing", - "ConfigService checkIfCryptoNodeSourceExists() returns true when dir, Dockerfile, and src exist", - "ConfigService checkIfModuleExists() returns false when Dockerfile is missing", - "ConfigService checkIfModuleExists() returns false when package.json is missing", - "ConfigService checkIfModuleExists() returns false when src directory is missing", - "ConfigService checkIfModuleExists() returns true when dir, Dockerfile, src, and package.json all exist", - "ConfigService ensureHubApiKey() adopts a key the operator wrote by hand rather than replacing it", - "ConfigService ensureHubApiKey() creates the config directory when it does not exist yet", - "ConfigService ensureHubApiKey() generates a key into the shared hub sidecar on a host that has none", - "ConfigService ensureHubApiKey() locks the sidecar to 0600, so no other local user can read the credential", - "ConfigService ensureHubApiKey() never returns the key itself, only where it lives", - "ConfigService ensureHubApiKey() preserves the other credentials already in the sidecar", - "ConfigService ensureHubApiKey() readHubApiKey() leaves a sidecar that holds other credentials byte-identical", - "ConfigService ensureHubApiKey() readHubApiKey() never returns the key itself, only whether there is one", - "ConfigService ensureHubApiKey() readHubApiKey() reports a present key without rotating it", - "ConfigService ensureHubApiKey() readHubApiKey() reports absence and writes NOTHING on a keyless host", - "ConfigService ensureHubApiKey() reuses an existing key and leaves the file byte-identical", - "ConfigService filterCommandParameters() accepts the short shared-service names operators actually type", - "ConfigService filterCommandParameters() adds explorer to servicesList[\"\"][\"\"] when modules is \"all\"", - "ConfigService filterCommandParameters() expands \"all\" coins to bitcoin, dogecoin, litecoin", - "ConfigService filterCommandParameters() expands \"all\" modules to full service list plus node", - "ConfigService filterCommandParameters() expands \"all\" networks to mainnet, testnet, regtest", - "ConfigService filterCommandParameters() filters regtest-only modules from mainnet", - "ConfigService filterCommandParameters() filters regtest-only modules from testnet", - "ConfigService filterCommandParameters() handles \"explorer\" module name", - "ConfigService filterCommandParameters() handles \"node\" module name", - "ConfigService filterCommandParameters() handles null coins (expands to all)", - "ConfigService filterCommandParameters() handles null networks (expands to all)", - "ConfigService filterCommandParameters() includes regtest-miner for regtest but excludes e2e-test from all", - "ConfigService filterCommandParameters() lists the coin node before every service under \"all\", so install creates it first", - "ConfigService filterCommandParameters() passes single module/coin/network through unchanged", - "ConfigService filterCommandParameters() returns correct structure for all coins + all networks + all modules", - "ConfigService getCryptoNodeDir() returns correct path for BITCOIN (enum key)", - "ConfigService getCryptoNodeDir() returns correct path for bitcoin (string value)", - "ConfigService getCryptoNodeDir() returns correct path for dogecoin", - "ConfigService getCryptoNodeDir() returns correct path for litecoin", - "ConfigService getDefaultConfig() regtest-only env passthrough to the indexer carries every regtest-only var onto a regtest indexer", - "ConfigService getDefaultConfig() regtest-only env passthrough to the indexer carries none of them onto mainnet, so a host variable cannot reach a shared ledger", - "ConfigService getDefaultConfig() regtest-only env passthrough to the indexer carries none of them onto testnet, so a host variable cannot reach a shared ledger", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) HUB_API_KEY from the shared hub sidecar deploys the hub KEYED off the sidecar instead of declaring it keyless", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) HUB_API_KEY from the shared hub sidecar gives the co-located indexer the same key the hub runs with", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) HUB_API_KEY from the shared hub sidecar leaves a node with no generated key on its prior keyless declaration", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) HUB_API_KEY from the shared hub sidecar yields to a host-env key (an operator override is never overwritten)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) HUB_DB_PASS falls back to the shared static default when rotation cannot apply it", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) HUB_DB_PASS is generated, shared, and persisted when rotation can apply it", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) LevelDB tuning passthrough (LEVELDB_CACHE_BYTES / LEVELDB_WRITE_BUFFER_BYTES) does not leak the LevelDB passthrough onto encoder or decoder configs", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) LevelDB tuning passthrough (LEVELDB_CACHE_BYTES / LEVELDB_WRITE_BUFFER_BYTES) emits neither key when the host env carries no LevelDB passthrough values", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) LevelDB tuning passthrough (LEVELDB_CACHE_BYTES / LEVELDB_WRITE_BUFFER_BYTES) passes LEVELDB_CACHE_BYTES and LEVELDB_WRITE_BUFFER_BYTES through from the host env", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) NODE_URL is the coin-scoped node container name, distinct per coin", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough NEVER arms a shared-ledger indexer, whatever the host env says", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough NEVER arms the gates flag on a shared-ledger indexer, whatever the host env says", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough arms the container hub from the same variable, so the venue arms as a unit", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough arms the container hub gates flag from the same variable, so the venue arms as a unit", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough arms the indexer gates flag on regtest when the host opts in", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough arms the indexer on regtest when the host opts in", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough carries a bare arming height through unaltered", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough does NOT inject the rollcall vars into a non-indexer coin module (decoder)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough omits every rollcall var when unset, so a venue ships INERT", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough passes the DOGE proof peer through on testnet as well", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough passes the DOGE proof peer through to the indexer on regtest", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) an operator NODE_URL override in the config file still wins over the default", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) an operator override in the main config file wins and is not regenerated", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) config file values override defaults", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) default values used when config file is empty", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) defaults INDEXER_ALLOW_UNAUTHENTICATED=true on keyless regtest installs (gated methods usable)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) does NOT auto-generate DB passwords where rotation cannot apply them (native/no-container -> static default)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) does not include REGTEST_MINER_URL for mainnet", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) does not include REGTEST_MINER_URL for testnet", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) encoder passthrough (ENCODER_TRUST_PROXY / ENCODER_RATE_LIMIT_RPM) does not leak the encoder passthrough onto decoder or utxo-tracker configs", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) encoder passthrough (ENCODER_TRUST_PROXY / ENCODER_RATE_LIMIT_RPM) emits neither key when the host env carries no encoder passthrough values", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) encoder passthrough (ENCODER_TRUST_PROXY / ENCODER_RATE_LIMIT_RPM) keeps the regtest 99999 literal when the host env sets no override", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) encoder passthrough (ENCODER_TRUST_PROXY / ENCODER_RATE_LIMIT_RPM) lets a host ENCODER_RATE_LIMIT_RPM win over the regtest 99999 literal", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) encoder passthrough (ENCODER_TRUST_PROXY / ENCODER_RATE_LIMIT_RPM) passes ENCODER_TRUST_PROXY and ENCODER_RATE_LIMIT_RPM through from the host env", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) generates per-install DB passwords under EXTERNAL_DB (native-path rotation)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) generates per-install DB passwords when a DB container exists (rotation can apply them) and persists them", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) generates random NODE_USER and NODE_PASSWORD when absent from config file", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) generates the MISSING RPC credential when only one of NODE_USER/NODE_PASSWORD is present (#2404: no both-or-nothing fallback to \"rpc\")", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) genesis-ledger bootstrap passthrough does NOT inject genesis vars into a non-indexer module (decoder)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) genesis-ledger bootstrap passthrough injects the genesis env vars into the indexer config from the host env", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) genesis-ledger bootstrap passthrough omits genesis vars entirely when unset (genesis stays disabled)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) genesis-ledger bootstrap passthrough passes the airdrop set through to the indexer (regtest dry-run seam)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) includes REGTEST_MINER_URL for regtest network", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) indexer HUB_DB_PASS matches the generated per-install INDEXER_DB_PASS when rotation can apply", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) indexer HUB_DB_PASS matches the indexer account static default when rotation cannot apply (never undefined)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) keeps regtest fail-closed when a host INDEXER_API_KEY is provided", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) native-coin fee destination injection defaults to the vendored coin-registry pin when no host env is set", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) native-coin fee destination injection falls back to a generic FEE_DESTINATION host env var", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) native-coin fee destination injection ignores host env overrides on mainnet and injects the registry pin", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) native-coin fee destination injection injects FEE_DESTINATION (decoder) + per-coin var (indexer) from the host env", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) never hands the e2e-test container the signing seed, only the public half", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) omits INDEXER_API_KEY when unset in host env (indexer stays fail-closed)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) omits VALIDATOR_PUBKEY on a standalone node, so the onboarding suite skips rather than staking a key nothing runs as", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) passes INDEXER_API_KEY through from host env to the indexer config (federation auth)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) passes the validator pubkey to the e2e-test container when one is configured", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) points HUB_API_URL at the same hub container HUB_API_HOST names", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) recovers RPC credentials glued onto a preceding setting with no separating newline (#2405)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) regtest mirror arming arms the regtest indexer hub-mirror pointer at its own DB account", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) regtest mirror arming defaults all three watermark graces to 0 on regtest when the host sets none of them", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) regtest mirror arming leaves mainnet/testnet mirror arming exactly as before (same account, no grace defaults)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) regtest mirror arming lets a host-set grace value win over the regtest default", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns DECODER_DB_HOST as mariadb", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns DECODER_DB_PORT as 3306", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns HUB_PORT as 10000", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns NETWORK matching the network arg", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct DECODER_DB_NAME", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct DECODER_DB_USER format", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct INDEXER_COIN for dogecoin", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct INDEXER_COIN ticker", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct INDEXER_DB_NAME", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct INDEXER_DB_USER format", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct NODE_PORT for mainnet (8332)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct NODE_PORT for regtest (18444)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct NODE_PORT for testnet (18332)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct UTXO_TRACKER_URL as Docker image name", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) reuses the persisted DB password on subsequent calls (stable across installs/updates)", - "ConfigService getDefaultConfig() with coin and network (coin-specific config) sets HUB_API_URL so the indexer hub client is enabled", - "ConfigService getDefaultConfig() without coin/network (shared service config) ATTEST response mirror regtest-only override passthrough injects both ATTEST override knobs from host env into the hub config", - "ConfigService getDefaultConfig() without coin/network (shared service config) ATTEST response mirror regtest-only override passthrough injects only the one override set, leaving the other unset", - "ConfigService getDefaultConfig() without coin/network (shared service config) ATTEST response mirror regtest-only override passthrough leaves both ATTEST override knobs unset when host env is absent", - "ConfigService getDefaultConfig() without coin/network (shared service config) HUB_NETWORK passthrough injects HUB_NETWORK from host env into the hub config", - "ConfigService getDefaultConfig() without coin/network (shared service config) HUB_NETWORK passthrough leaves HUB_NETWORK unset when host env is absent (standalone unchanged)", - "ConfigService getDefaultConfig() without coin/network (shared service config) HUB_NETWORK passthrough reads no validator identity off the host filesystem", - "ConfigService getDefaultConfig() without coin/network (shared service config) ORACLE_BATCH_* passthrough injects all five ORACLE_BATCH_* knobs from host env into the hub config", - "ConfigService getDefaultConfig() without coin/network (shared service config) ORACLE_BATCH_* passthrough leaves all five ORACLE_BATCH_* knobs unset when host env is absent (hub default unchanged)", - "ConfigService getDefaultConfig() without coin/network (shared service config) does not include coin-specific keys like NODE_PORT", - "ConfigService getDefaultConfig() without coin/network (shared service config) emits INDEXER_API_URL__ for every coin and network", - "ConfigService getDefaultConfig() without coin/network (shared service config) emits no EXPLORER_BATCH_RATE_LIMIT_RPM when the host env carries none", - "ConfigService getDefaultConfig() without coin/network (shared service config) emits no per-route explorer rate-limit keys when the host env carries none", - "ConfigService getDefaultConfig() without coin/network (shared service config) emits no serving-limit keys when the host env carries none", - "ConfigService getDefaultConfig() without coin/network (shared service config) honours a HUB_PORT host env override", - "ConfigService getDefaultConfig() without coin/network (shared service config) hub keyless declaration declares keyless operation when no HUB_API_KEY is in the host env", - "ConfigService getDefaultConfig() without coin/network (shared service config) hub keyless declaration does NOT declare keyless on a mainnet hub", - "ConfigService getDefaultConfig() without coin/network (shared service config) hub keyless declaration does not declare keyless when a key is present (the hub is keyed)", - "ConfigService getDefaultConfig() without coin/network (shared service config) hub keyless declaration still declares keyless on a regtest hub", - "ConfigService getDefaultConfig() without coin/network (shared service config) hub keyless declaration yields to an explicit host-env refusal (HUB_ALLOW_UNAUTHENTICATED=false)", - "ConfigService getDefaultConfig() without coin/network (shared service config) leaves HUB_PORT at the 10000 default when host env sets no override", - "ConfigService getDefaultConfig() without coin/network (shared service config) omits HUB_API_KEY from shared-service configs when unset in host env", - "ConfigService getDefaultConfig() without coin/network (shared service config) passes HUB_API_KEY through from host env to shared-service configs (keyed sensitive-read tier)", - "ConfigService getDefaultConfig() without coin/network (shared service config) passes the batch explorer rate limit through from the host env", - "ConfigService getDefaultConfig() without coin/network (shared service config) passes the explorer serving limits through from the host env", - "ConfigService getDefaultConfig() without coin/network (shared service config) passes the five per-route explorer rate limits through from the host env", - "ConfigService getDefaultConfig() without coin/network (shared service config) returns EXPLORER_API_PORT_HTTP as 8080", - "ConfigService getDefaultConfig() without coin/network (shared service config) returns EXPLORER_PORT as 18080", - "ConfigService getDefaultConfig() without coin/network (shared service config) returns EXPLORER_PORT_HTTPS as 18081", - "ConfigService getDefaultConfig() without coin/network (shared service config) returns HUB_PORT as 10000", - "ConfigService getDefaultConfig() without coin/network (shared service config) returns SYNC_API_PORT as 3006", - "ConfigService getDefaultConfig() without coin/network (shared service config) returns SYNC_MODE as server", - "ConfigService getDefaultConfig() without coin/network (shared service config) yields INDEXER_API_URL__ to the host env", - "ConfigService getDockerContainerImageName() builds coin-specific image name: xchain-node-bitcoin-mainnet-xchain-encoder", - "ConfigService getDockerContainerImageName() builds correct names for all coin/network combos", - "ConfigService getDockerContainerImageName() builds database name: xchain-node-database", - "ConfigService getDockerContainerImageName() builds explorer name: xchain-node-xchain-explorer", - "ConfigService getDockerContainerImageName() builds shared module name without coin/network: xchain-node-xchain-hub", - "ConfigService getDockerContainerImageName() builds sync name: xchain-node-xchain-sync", - "ConfigService getDockerNetwork() handles only coin without network", - "ConfigService getDockerNetwork() returns xchain-node for empty coin and network", - "ConfigService getDockerNetwork() returns xchain-node-bitcoin-mainnet for bitcoin mainnet", - "ConfigService getModuleDatabaseName() allows the shared-service empty-string network", - "ConfigService getModuleDatabaseName() capitalizes module name correctly (utxo-tracker -> Utxo-tracker)", - "ConfigService getModuleDatabaseName() returns XChain_BTC_Mainnet_Decoder for bitcoin mainnet decoder", - "ConfigService getModuleDatabaseName() returns XChain_DOGE_Testnet_Indexer for dogecoin testnet indexer", - "ConfigService getModuleDatabaseName() returns XChain_LTC_Regtest_Decoder for litecoin regtest decoder", - "ConfigService getModuleDatabaseName() throws on an unknown network (e.g. a typo from a raw CLI arg)", - "ConfigService getModuleDatabaseName() uses correct ticker for all coins", - "ConfigService getModuleDir() returns moduleDir + / + module", - "ConfigService getModuleTmpDir() returns tmpDir + / + module", - "ConfigService getUtxoTrackerVolumeName() keeps the legacy unprefixed name under the default NODE_PREFIX", - "ConfigService getUtxoTrackerVolumeName() prefixes the name under a non-default NODE_PREFIX", - "ConfigService moduleDirExists() returns false for non-existent module", - "ConfigService moduleDirExists() returns true when directory exists", - "ConfigService persistSidecarCreds() does not add a blank line when the existing file already ends in a newline", - "ConfigService persistSidecarCreds() does not prepend a newline when creating a fresh file", - "ConfigService persistSidecarCreds() inserts a separating newline when appending to a file not ending in one (#2406)", - "ConfigService resolveArgs() service resolution leaves the branch slot free to take an arbitrary name", - "ConfigService resolveArgs() service resolution names the valid services in the refusal, so the operator can correct it", - "ConfigService resolveArgs() service resolution refuses an unrecognized service name instead of expanding to every service", - "ConfigService resolveArgs() service resolution resolves the canonical shared-service names", - "ConfigService resolveArgs() service resolution resolves the short shared-service aliases to their canonical names", - "ConfigService resolveArgs() service resolution still accepts every legitimate argument shape" - ], "2c16774921b9b66c": [ "HubConnector: a non-throwing failure still records why a 401 makes updateConfig return false AND records the status in lastFailures", "HubConnector: a non-throwing failure still records why a successful push clears the previous failures rather than leaving them stale", "HubConnector: a non-throwing failure still records why an unreachable endpoint records the connection code, not a bare null", "HubConnector: a non-throwing failure still records why names every endpoint it tried, so a multi-endpoint failure is diagnosable" ], + "2cda429c0af356d2": [ + "DatabaseService addUserPasswordToDatabase(): EXTERNAL_DB path creates database and user via native mariadb when EXTERNAL_DB=true", + "DatabaseService addUserPasswordToDatabase(): EXTERNAL_DB path grants MVH permissions for hub module via native mariadb (EXTERNAL_DB)", + "DatabaseService addUserPasswordToDatabase(): EXTERNAL_DB path grants SLAVE MONITOR to an indexer account via native mariadb (EXTERNAL_DB)", + "DatabaseService addUserPasswordToDatabase(): EXTERNAL_DB path skips creates when db and user already exist (EXTERNAL_DB)", + "DatabaseService addUserPasswordToDatabase(): EXTERNAL_DB path throws when native mariadb command fails (EXTERNAL_DB)" + ], + "2d5951fd05d23617": [ + "Chaos: Process Resilience Experiment 11: Async error propagation handles rejection from removeContainer during overwrite gracefully" + ], + "2dca3358a936b011": [ + "Bootstrap signing checkBootstrapSignature() policy fails closed BY DEFAULT when no public key is pinned", + "Bootstrap signing checkBootstrapSignature() policy fails closed when the key is pinned but the signature file is missing and REQUIRE is set", + "Bootstrap signing checkBootstrapSignature() policy fails closed without a pinned key when XCHAIN_NODE_REQUIRE_SIGNED_BOOTSTRAP is set", + "Bootstrap signing checkBootstrapSignature() policy throws when pinned key + signature are present but the signature is bad", + "Bootstrap signing checkBootstrapSignature() policy verifies and passes when pinned key + valid signature are present", + "Bootstrap signing checkBootstrapSignature() policy warns but proceeds when enforcement is explicitly disabled (=0)", + "Bootstrap signing downloadBootstrap(): companion signature fetch downloads and stores the .sig next to the archive", + "Bootstrap signing downloadBootstrap(): companion signature fetch removes a stale local .sig when the server has none (404)", + "Bootstrap signing refusals are classified as BootstrapIntegrityError names a bad signature", + "Bootstrap signing refusals are classified as BootstrapIntegrityError names a malformed signature file", + "Bootstrap signing refusals are classified as BootstrapIntegrityError names an unsigned-bootstrap refusal", + "Bootstrap signing refusals are classified as BootstrapIntegrityError still carries the operator-facing reason in the message", + "Bootstrap signing rejects a malformed signature file", + "Bootstrap signing rejects a signature from the wrong key", + "Bootstrap signing rejects a tampered archive", + "Bootstrap signing sign → verify round-trip passes", + "Bootstrap signing the repo-pinned default public key is the same key the repository ships, not some other file", + "Bootstrap signing the repo-pinned default public key loads with no env override and parses as an ed25519 public key", + "Bootstrap signing the repo-pinned default public key ships at src/config/bootstrap_signing_pubkey.pem", + "Bootstrap signing the repo-pinned default public key verifies a real signature end to end through checkBootstrapSignature()" + ], + "2e555b11d8f2b782": [ + "moduleOperations resetModules() aborts a missing-MariaDB reset without stopping anything", + "moduleOperations resetModules() aborts before any wipe when a target fails to stop, and restarts what it stopped", + "moduleOperations resetModules() aborts when stopContainer rejects a non-Error value", + "moduleOperations resetModules() clears the hub price ingest fence when the indexer DB is reset", + "moduleOperations resetModules() does not continue past a failed stopContainer", + "moduleOperations resetModules() leaves the fence alone on a decoder-only reset (that chain keeps pushing prices)", + "moduleOperations resetModules() only resets node data when service=node", + "moduleOperations resetModules() reports a fence-clear failure without aborting the restart pass", + "moduleOperations resetModules() resets all services when service=all", + "moduleOperations resetModules() resets decoder: stops, resets DB, and bounces", + "moduleOperations resetModules() resets node data when nodeDataPath exists", + "moduleOperations resetModules() scopes the hand-run fence statement to the reset network, plus the legacy bucket", + "moduleOperations resetModules() still treats a \"no such container\" stop as a skip and completes the reset", + "moduleOperations resetModules() stops and resets utxo-tracker when service=xchain-utxo-tracker", + "moduleOperations resetModules() throws on an unknown coin", + "moduleOperations resetModules() throws on an unknown network", + "moduleOperations resetModules() throws on an unknown service instead of a silent no-op success" + ], "2e8ada203563571b": [ "clone integrity (manifest-pinned installs) cloneGit() commit verification REFUSES a clone whose HEAD differs from the pinned commit", "clone integrity (manifest-pinned installs) cloneGit() commit verification accepts a clone whose HEAD matches the pinned commit", @@ -664,21 +673,66 @@ "clone integrity (manifest-pinned installs) resolveBundledLibRef() uses the manifest pin during a release install", "clone integrity (manifest-pinned installs) source-level guard stages no bundled library with a null ref" ], - "2fa482502d5d1dbe": [ - "BootstrapArchiveMeta buildBootstrapMeta() carries the format, the combo and an integer height", - "BootstrapArchiveMeta buildBootstrapMeta() stamps a creation time when none is given", - "BootstrapArchiveMeta buildBootstrapMeta() turns a height it cannot vouch for into null rather than a number", - "BootstrapArchiveMeta parseTarHeader() reads the name, the octal size and the type flag, stripping a ./ prefix", - "BootstrapArchiveMeta parseTarHeader() returns null for the end-of-archive zero block and for a short block", - "BootstrapArchiveMeta readLeadingArchiveMember() refuses a leading member larger than the metadata ceiling", - "BootstrapArchiveMeta readLeadingArchiveMember() skips pax extension headers ahead of the member", - "BootstrapArchiveMeta writeBootstrapMeta() + readBootstrapArchiveMeta() answers null for a leading member that is not format-1 metadata", - "BootstrapArchiveMeta writeBootstrapMeta() + readBootstrapArchiveMeta() answers null for an archive published before the member existed", - "BootstrapArchiveMeta writeBootstrapMeta() + readBootstrapArchiveMeta() answers null when the member is present but does not lead the archive", - "BootstrapArchiveMeta writeBootstrapMeta() + readBootstrapArchiveMeta() answers null, never rejects, for a missing, truncated or non-gzip file", - "BootstrapArchiveMeta writeBootstrapMeta() + readBootstrapArchiveMeta() reads a null height as null and ignores a non-integer height", - "BootstrapArchiveMeta writeBootstrapMeta() + readBootstrapArchiveMeta() reads the height off a large archive without reading the whole archive", - "BootstrapArchiveMeta writeBootstrapMeta() + readBootstrapArchiveMeta() round-trips the height through a real wrapper when the member leads it" + "2e8e24675a738be3": [ + "DockerService logContainer(): TTY keypress branches Ctrl-C keypress kills child process", + "DockerService logContainer(): TTY keypress branches ESC keypress kills child process", + "DockerService logContainer(): TTY keypress branches sets up stdin raw mode and keypress handler when follow=true and isTTY=true" + ], + "2f3edfcd7987a004": [ + "BootstrapService restoreBootstrap(): archive identity records an identity mismatch on the install path as a failure that kept the archive, with no wipe", + "BootstrapService restoreBootstrap(): archive identity refuses a tracker archive declaring another coin/network before anything is stopped, wiped or extracted", + "BootstrapService restoreBootstrap(): archive identity refuses an indexer archive offered to the decoder (module-only mismatch) before the DROP", + "BootstrapService restoreBootstrap(): archive identity restores a converted tracker archive whose coin/network are null, checking only its module", + "BootstrapService restoreBootstrap(): archive identity restores a matching archive exactly as before", + "BootstrapService the node tip guard refusal checks the archive identity before the archive is retired keeps a refused indexer archive offered to the decoder (module-only mismatch)", + "BootstrapService the node tip guard refusal checks the archive identity before the archive is retired keeps a refused tracker archive declaring another coin and records the mismatch, not node lag", + "BootstrapService the node tip guard refusal checks the archive identity before the archive is retired still retires a refused archive whose identity matches, recording node-behind", + "BootstrapService the node tip guard refusal is signature-verified before the archive is retired keeps a refused archive that fails its signature check and records the integrity failure, not node lag", + "BootstrapService the node tip guard refusal is signature-verified before the archive is retired retires a refused archive once its signature check passes, recording node-behind as before" + ], + "30dce11a237ad0fd": [ + "moduleOperations resetModules() the regtest re-genesis hub purge does not abort the restart pass when the purge throws", + "moduleOperations resetModules() the regtest re-genesis hub purge leaves the hub rows alone when the chain itself is not reset", + "moduleOperations resetModules() the regtest re-genesis hub purge names the hub rows in the confirmation for a regtest node reset", + "moduleOperations resetModules() the regtest re-genesis hub purge never purges off regtest, where these rows are live federation history", + "moduleOperations resetModules() the regtest re-genesis hub purge purges after the price fence on reset all", + "moduleOperations resetModules() the regtest re-genesis hub purge purges the hub cross-chain rows on a regtest node reset" + ], + "30f77b585ec67a07": [ + "Chaos: Docker Resilience Experiment: Invalid port in config during buildAndUp rejects when config has invalid port value" + ], + "3111978f0fd33252": [ + "Fuzz: Docker Command Construction appends dockerCmd args to docker run when provided", + "Fuzz: Docker Command Construction docker run includes --hostname flag", + "Fuzz: Docker Command Construction docker run includes --network flag", + "Fuzz: Docker Command Construction docker run includes -t flag with image name", + "Fuzz: Docker Command Construction docker run starts with \"docker run -d\"", + "Fuzz: Docker Command Construction does not append dockerCmd when null", + "Fuzz: Docker Command Construction env var key with equals sign does not break quoting", + "Fuzz: Docker Command Construction every -e flag is followed by a raw KEY=value pair", + "Fuzz: Docker Command Construction git clone command branch flag is placed immediately after \"clone\"", + "Fuzz: Docker Command Construction git clone command includes correct git URL from modulesUrls", + "Fuzz: Docker Command Construction git clone command no -b flag when branch is null", + "Fuzz: Docker Command Construction module-specific Docker configuration decoder includes bootstrap volume mount", + "Fuzz: Docker Command Construction module-specific Docker configuration utxo-tracker includes data volume", + "Fuzz: Docker Command Construction module-specific Docker configuration utxo-tracker includes ulimit setting" + ], + "312597dee63fa214": [ + "buildModuleChoices fallback offers installation and return when the coin or network is absent", + "buildModuleChoices on regtest places the E2E action immediately before uninstall and return", + "buildModuleChoices populated status colours installed modules, maps their status, and lists every missing module", + "expectedModules ends with the node and database module names on every network", + "expectedModules excludes the E2E module on every network", + "expectedModules includes the regtest miner only on regtest" + ], + "312914fcdff3183e": [ + "DatabaseService getExternalDbConfig() fails fast instead of prompting when stdin is not a TTY", + "DatabaseService getExternalDbConfig() fails fast on a partial env set rather than prompting headlessly", + "DatabaseService getExternalDbConfig() re-prompts interactively when saved config ping fails", + "DatabaseService getExternalDbConfig() returns config from env vars when all four are set", + "DatabaseService getExternalDbConfig() returns saved config from credentials when ping succeeds", + "DatabaseService getExternalDbConfig() throws on a malformed external-DB port from env instead of returning NaN", + "DatabaseService getExternalDbConfig() throws on an out-of-range external-DB port from env" ], "31b05a9177135645": [ "TelemetryConnector an explicit url argument wins over the env override", @@ -686,26 +740,212 @@ "TelemetryConnector honors the XCHAIN_NODE_TELEMETRY_URL override", "TelemetryConnector report() resolves false (never throws) when the endpoint is unreachable" ], - "33a5dd7bc655d47e": [ - "SelfUpdateService compareVersions() orders numeric triples and ignores a leading v", - "SelfUpdateService describeCarrier() answers not-a-repo when git refuses", - "SelfUpdateService describeCarrier() lists dirty tracked files and the current commit", - "SelfUpdateService explicitUpdateArgs() always names every slot and the tag, whatever the operator typed", - "SelfUpdateService noticeNewerRelease() caches the lookup for an hour, including a failed one", - "SelfUpdateService noticeNewerRelease() gives up on a slow lookup rather than holding the command", - "SelfUpdateService noticeNewerRelease() prints one line naming the newer release and the command to run", - "SelfUpdateService noticeNewerRelease() stays silent inside an update and never asks the network there", - "SelfUpdateService noticeNewerRelease() stays silent when current", - "SelfUpdateService selfUpdateAndReexec() does nothing inside the re-executed child (loop guard)", - "SelfUpdateService selfUpdateAndReexec() does nothing when the CLI already runs the target version", - "SelfUpdateService selfUpdateAndReexec() does nothing when told not to, and says so", - "SelfUpdateService selfUpdateAndReexec() exits with the child status", - "SelfUpdateService selfUpdateAndReexec() fetches, verifies the tag, checks it out, installs, hands back the lock and re-executes the explicit command", - "SelfUpdateService selfUpdateAndReexec() moves anyway, loudly, when signature checks are switched off", - "SelfUpdateService selfUpdateAndReexec() puts the previous commit back when npm install fails", - "SelfUpdateService selfUpdateAndReexec() refuses a dirty tracked tree with nothing changed", - "SelfUpdateService selfUpdateAndReexec() refuses a tag the release key did not sign, before anything is checked out", - "SelfUpdateService selfUpdateAndReexec() warns and continues when the CLI is not a git checkout" + "31f99897a525a280": [ + "SERVICE_REGISTRY buildModuleDockerArgs() conditional port omitted when a key is missing", + "SERVICE_REGISTRY buildModuleDockerArgs() decoder: conditional port + unconditional bootstrap volume", + "SERVICE_REGISTRY buildModuleDockerArgs() encoder: port only, no volume", + "SERVICE_REGISTRY buildModuleDockerArgs() explorer: singleton with two unconditional port mappings", + "SERVICE_REGISTRY buildModuleDockerArgs() hub: a mount refusal from ValidatorService fails the build instead of silently dropping the config", + "SERVICE_REGISTRY buildModuleDockerArgs() hub: mounts the capability config DIRECTORY (ro) when HUB_CAPABILITY_CONFIG is present", + "SERVICE_REGISTRY buildModuleDockerArgs() hub: mounts the generated DOGE signer (ro) with this package's node_modules beside it", + "SERVICE_REGISTRY buildModuleDockerArgs() hub: singleton, unconditional single port, no static volumes when unconfigured", + "SERVICE_REGISTRY buildModuleDockerArgs() hub: the P2P_PORT row is non-always, so it publishes in validator mode only", + "SERVICE_REGISTRY buildModuleDockerArgs() sync: singleton, conditional port present", + "SERVICE_REGISTRY buildModuleDockerArgs() unknown / docker-less module yields empty args (e2e-test, node, database)", + "SERVICE_REGISTRY buildModuleDockerArgs() utxo-tracker: port + dynamic data volume + bootstrap volume + nofile ulimit", + "SERVICE_REGISTRY coverage parity with canonical service enums has a docker facet for every buildAndUp-installed service", + "SERVICE_REGISTRY coverage parity with canonical service enums has a hubConfig facet for every module the hub reports on", + "SERVICE_REGISTRY coverage parity with canonical service enums marks exactly hub/explorer/sync as singletons", + "SERVICE_REGISTRY coverage parity with canonical service enums the one-shot e2e-test runner has no docker facet (no ports/volumes)", + "SERVICE_REGISTRY coverage parity with canonical service enums the singleton services contribute no hub config" + ], + "326c83a2aeae717d": [ + "AutohealService caps the doubled cooldown at the ceiling instead of growing without bound", + "AutohealService rates a pass as recovery only when no restart can claim it or it outlasted probation" + ], + "32b1e008856a0902": [ + "stake_key_reuse_activation mirror agrees with the xchain-indexer gate row carries every key and height the indexer row carries, and nothing else", + "validator stake free-key rules a delegation holds the key until its deactivation block has passed", + "validator stake free-key rules after the gate a row releases the key at deactivation_block + cooldown, not a block before", + "validator stake free-key rules before the gate every valid row holds the key, withdrawn or not", + "validator stake free-key rules pages the pubkey lookup to the end and refuses a response with no row list", + "validator stake free-key rules resolves the reuse gate COIN:network first, then the bare network", + "validator stake free-key rules sums amounts exactly in 1e-8 units and refuses to guess on a malformed one" + ], + "332b1a20f0f85580": [ + "ValidatorService coin wallets the emitted signer marks post-funding failures does not tag a successful two-phase publish", + "ValidatorService coin wallets the emitted signer marks post-funding failures is valid JavaScript once the template literal is expanded", + "ValidatorService coin wallets the emitted signer marks post-funding failures leaves a pre-funding failure untagged, so the round stays retryable", + "ValidatorService coin wallets the emitted signer marks post-funding failures tags a definitive phase-2 rejection with fundsCommitted and the phase-1 txid" + ], + "332bea463a49f99c": [ + "Regression Suite [regression:p0] Security R-SEC-001: branch name with shell metacharacters rejected", + "Regression Suite [regression:p0] Security R-SEC-002: branch name with backticks and $() rejected", + "Regression Suite [regression:p0] Security R-SEC-003: container ID with non-hex characters rejected", + "Regression Suite [regression:p0] Security R-SEC-004: container ID with injection payload rejected", + "Regression Suite [regression:p0] Security R-SEC-005: DockerService uses execFile (no shell) for all commands", + "Regression Suite [regression:p0] Security R-SEC-006: shell metacharacters in env values pass literally via the child env, never argv", + "Regression Suite [regression:p0] Security R-SEC-007: NODE_PREFIX regex rejects shell metacharacters" + ], + "33623e8a12ec8dfa": [ + "BootstrapHealthGate evaluateStatusPayload() REFUSES a decoder parked on a REORG_HALT whose marker was never written, even under a raised lag ceiling", + "BootstrapHealthGate evaluateStatusPayload() REFUSES a decoder that reports not-halted having never completed a probe", + "BootstrapHealthGate evaluateStatusPayload() REFUSES a lag that is not a number, rather than coercing it to zero", + "BootstrapHealthGate evaluateStatusPayload() REFUSES a negative lag: the committed tip sits above the node tip (orphaned view)", + "BootstrapHealthGate evaluateStatusPayload() REFUSES a payload with no lag field at all: position unverifiable is not caught-up", + "BootstrapHealthGate evaluateStatusPayload() REFUSES an indexer reporting its block counter wedged", + "BootstrapHealthGate evaluateStatusPayload() REFUSES an indexer stopped on a train-activation halt", + "BootstrapHealthGate evaluateStatusPayload() does not demand a probe timestamp from an indexer payload", + "BootstrapHealthGate evaluateStatusPayload() formats a block-fetch desync object instead of printing [object Object]", + "BootstrapHealthGate evaluateStatusPayload() passes a healthy payload whose lag is zero", + "BootstrapHealthGate evaluateStatusPayload() passes a pending, unevaluated, absent or malformed train_activation", + "BootstrapHealthGate evaluateStatusPayload() passes the healthy future-block wait and an in-grace barrier defer", + "BootstrapHealthGate evaluateStatusPayload() passes the same decoder once a probe has actually completed", + "BootstrapHealthGate evaluateStatusPayload() refuses an indexer frozen behind a halted decoder", + "BootstrapHealthGate evaluateStatusPayload() refuses when the node tip is stale, since the lag is then unknowable", + "BootstrapHealthGate evaluateStatusPayload() still accepts the two shapes a producer legitimately emits", + "BootstrapHealthGate evaluateStatusPayload() treats an empty payload as a refusal, not a pass" + ], + "33a411988d29bb7b": [ + "parseCountTokens() accepts whole nonnegative integers", + "parseCountTokens() quotes the offending output in the refusal so the operator can see it", + "parseCountTokens() refuses a value above max when one is given, and ignores max when it is not", + "parseCountTokens() refuses empty and whitespace-only output", + "parseCountTokens() refuses partial tokens, signs, decimals and exponents", + "parseCountTokens() refuses the wrong token count in either direction" + ], + "33a5dd7bc655d47e": [ + "SelfUpdateService compareVersions() orders numeric triples and ignores a leading v", + "SelfUpdateService describeCarrier() answers not-a-repo when git refuses", + "SelfUpdateService describeCarrier() lists dirty tracked files and the current commit", + "SelfUpdateService explicitUpdateArgs() always names every slot and the tag, whatever the operator typed", + "SelfUpdateService noticeNewerRelease() caches the lookup for an hour, including a failed one", + "SelfUpdateService noticeNewerRelease() gives up on a slow lookup rather than holding the command", + "SelfUpdateService noticeNewerRelease() prints one line naming the newer release and the command to run", + "SelfUpdateService noticeNewerRelease() stays silent inside an update and never asks the network there", + "SelfUpdateService noticeNewerRelease() stays silent when current", + "SelfUpdateService selfUpdateAndReexec() does nothing inside the re-executed child (loop guard)", + "SelfUpdateService selfUpdateAndReexec() does nothing when the CLI already runs the target version", + "SelfUpdateService selfUpdateAndReexec() does nothing when told not to, and says so", + "SelfUpdateService selfUpdateAndReexec() exits with the child status", + "SelfUpdateService selfUpdateAndReexec() fetches, verifies the tag, checks it out, installs, hands back the lock and re-executes the explicit command", + "SelfUpdateService selfUpdateAndReexec() moves anyway, loudly, when signature checks are switched off", + "SelfUpdateService selfUpdateAndReexec() puts the previous commit back when npm install fails", + "SelfUpdateService selfUpdateAndReexec() refuses a dirty tracked tree with nothing changed", + "SelfUpdateService selfUpdateAndReexec() refuses a tag the release key did not sign, before anything is checked out", + "SelfUpdateService selfUpdateAndReexec() warns and continues when the CLI is not a git checkout" + ], + "33bb86ffb68d924e": [ + "price fence SQL builders builds the chain-wide fence clear", + "price fence SQL builders builds the manual network-scoped fence clear", + "price fence SQL builders builds the network-scoped fence clear with the legacy bucket", + "price fence SQL builders escapes embedded quotes in the chain ticker" + ], + "33e59b9e09cfcd60": [ + "validator stake token balance read rejects a response without a data array as incomplete", + "validator stake token balance read rejects after the maximum readable pages without fetching another page", + "validator stake token balance read rejects an empty page before the reported total is reached", + "validator stake token balance read rejects when the reported total shrinks between pages", + "validator stake token balance read returns a matching row from the first page with the bounded request", + "validator stake token balance read returns null for a short page when no total is reported", + "validator stake token balance read returns null when the reported total equals the rows seen" + ], + "34be82a1cf77dc44": [ + "Integration: Database Service Chain buildDatabaseModule (first install) includes network flag when coin/network are provided", + "Integration: Database Service Chain buildDatabaseModule (first install) pulls mariadb, tags it, and runs container" + ], + "34cab5185dd9084a": [ + "Chaos: Download Resilience Experiment 8: GitHub API unreachable throws descriptive error on DNS resolution failure", + "Chaos: Download Resilience Experiment 8: GitHub API unreachable throws descriptive error on connection refused" + ], + "35f2baf0b0932dcf": [ + "Security Database command safety executeDockerMariaDbCommand pipes SQL via stdin, never argv", + "Security Database command safety password travels via MYSQL_PWD env, never a -p argv token" + ], + "3637a8c63a1fa122": [ + "ExplorerService: installExplorerModule() full happy path clones and builds when ping initially fails and status cache is cold", + "ExplorerService: installExplorerModule() full happy path clones, builds, then returns true when first ping succeeds" + ], + "3670f7eb60a0bd6e": [ + "ValidatorService coin wallets --force-wallets replaces them", + "ValidatorService coin wallets --no-wallets skips wallet generation entirely", + "ValidatorService coin wallets both federation default epochs are in the past, and differ", + "ValidatorService coin wallets creates that mountpoint at mount time too, for a config written before it existed", + "ValidatorService coin wallets creates the node_modules mountpoint the SDK mount lands on", + "ValidatorService coin wallets does not claim a signer mount when the signer files are absent", + "ValidatorService coin wallets imports operator-supplied WIFs from the environment and derives their addresses", + "ValidatorService coin wallets keeps existing wallets across --force (a funded address must not be abandoned silently)", + "ValidatorService coin wallets publicWalletInfo never carries a WIF", + "ValidatorService coin wallets refuses to import a key when the network is unknown", + "ValidatorService coin wallets rejects a WIF from the wrong network", + "ValidatorService coin wallets skips wallets on a non-standard port and says so, without failing init" + ], + "36964ba24beb3edb": [ + "Fuzz: Branch Name Validation accepts a very long but valid branch name", + "Fuzz: Branch Name Validation accepts branch with slashes: feature/sub/deep", + "Fuzz: Branch Name Validation accepts valid branch name: \"0.1.7\"", + "Fuzz: Branch Name Validation accepts valid branch name: \"A/B/C/D/E\"", + "Fuzz: Branch Name Validation accepts valid branch name: \"a\"", + "Fuzz: Branch Name Validation accepts valid branch name: \"develop\"", + "Fuzz: Branch Name Validation accepts valid branch name: \"feature/add-login\"", + "Fuzz: Branch Name Validation accepts valid branch name: \"hotfix/fix_bug\"", + "Fuzz: Branch Name Validation accepts valid branch name: \"master\"", + "Fuzz: Branch Name Validation accepts valid branch name: \"my.branch.name\"", + "Fuzz: Branch Name Validation accepts valid branch name: \"release-1.2.3\"", + "Fuzz: Branch Name Validation accepts valid branch name: \"under_score\"", + "Fuzz: Branch Name Validation accepts valid branch name: \"v1.0.0\"", + "Fuzz: Branch Name Validation branch name starting with -- is rejected by regex (hyphen allowed but double-dash prefix is safe because -b flag precedes it)", + "Fuzz: Branch Name Validation does not include -b flag when branch is null", + "Fuzz: Branch Name Validation rejects invalid branch name: \"--template=/tmp/evil\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"--upload-pack=evil\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch!name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch#name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch$name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch%name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch&name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch'name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch(name)\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch*name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch+name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch:name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branchname\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch?name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch@name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch[name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch\\\"name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch\\\\name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch\\nname\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch\\tname\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch\\u0000name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch]name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch^name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch{name}\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"branch~name\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"bränch\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"master && echo pwned\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"master | cat /etc/passwd\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"master$(whoami)\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"master; rm -rf /\"", + "Fuzz: Branch Name Validation rejects invalid branch name: \"master`id`\"", + "Fuzz: Branch Name Validation treats empty string branch same as null (no -b flag)" + ], + "36c5f071500439b1": [ + "CredentialsService hasExternalDbConfig() returns false when externalDb block is absent", + "CredentialsService hasExternalDbConfig() returns false when host is not a string", + "CredentialsService hasExternalDbConfig() returns false when port is not a number", + "CredentialsService hasExternalDbConfig() returns false when readFileSync throws", + "CredentialsService hasExternalDbConfig() returns false when root_password is not a string", + "CredentialsService hasExternalDbConfig() returns false when root_user is not a string", + "CredentialsService hasExternalDbConfig() returns true when credentials.json has a well-formed externalDb block" + ], + "37a925183bf82ad0": [ + "mutating command lock wait lets the re-executed release update outwait a mutator that took the lock in the self-update handoff", + "mutating command lock wait refuses a held lock immediately when the env var is unset (interactive default)", + "mutating command lock wait takes the lock once the holder releases mid-wait", + "mutating command lock wait waits for the holder when XCHAIN_NODE_MUTATING_LOCK_WAIT_MS is set" ], "37d41880660612cd": [ "secret-env ConfigService accepts the renamed sidecar keys a credential in the MAIN config file is relocated to the sidecar under either name", @@ -736,6 +976,21 @@ "secret-env the alias table covers the three xchain-node sidecar keys the audit sweep flagged", "secret-env the alias table every preferred name is one automatic redaction actually matches" ], + "38b8c58e2f7907b5": [ + "ModuleService buildAndUp() ROLLCALL wiring guard REFUSES a BTC mainnet indexer whose env carries no DOGE read, before any docker call", + "ModuleService buildAndUp() ROLLCALL wiring guard deploys the wired default fixture, whose env carries the DOGE read", + "ModuleService buildAndUp() ROLLCALL wiring guard lets a one-shot execution container through: it never closes an epoch" + ], + "38eae73b5e2c3897": [ + "S-SMOKE-003 – Global Options Registration registers --interactive option", + "S-SMOKE-003 – Global Options Registration registers --no-bootstrap option", + "S-SMOKE-003 – Global Options Registration registers --no-explorer option", + "S-SMOKE-003 – Global Options Registration registers --verbose option", + "S-SMOKE-003 – Global Options Registration registers --version option", + "S-SMOKE-003 – Global Options Registration registers -V as short flag for --version", + "S-SMOKE-003 – Global Options Registration registers -i as short flag for --interactive", + "S-SMOKE-003 – Global Options Registration registers -v as short flag for --verbose" + ], "3916386139147a8b": [ "BootstrapNodeTipGuard assessNodeTipForRestore() XCHAIN_NODE_SKIP_NODE_TIP_GUARD skips the comparison, loudly", "BootstrapNodeTipGuard assessNodeTipForRestore() falls back to the module version when the service cannot be probed", @@ -762,6 +1017,60 @@ "BootstrapNodeTipGuard serviceWaitsOutCatchUp() the indexer always passes: it follows the decoder, not the node", "BootstrapNodeTipGuard version parsing accepts a leading v and ignores a suffix" ], + "39370a6135d33aff": [ + "AutohealService --dry-run reports candidates without restarting or arming the cooldown", + "AutohealService skips (does not restart) when the health log gives no timing evidence" + ], + "396cc4554bb16f87": [ + "DatabaseService buildDatabaseModule(): EXTERNAL_DB path pings external MariaDB and returns true when reachable", + "DatabaseService buildDatabaseModule(): EXTERNAL_DB path throws descriptive error when external MariaDB is unreachable" + ], + "399f8b388cacf9f5": [ + "ValidatorStakeService free-key check prints the exact sum of the undeactivated rows, not one row and not a float", + "ValidatorStakeService free-key check refuses a withdrawn key on mainnet, where the gate is not armed", + "ValidatorStakeService free-key check refuses a withdrawn key on testnet below the gate height, naming that height", + "ValidatorStakeService free-key check refuses inside the cooldown and names the block the key is released at", + "ValidatorStakeService free-key check stakes again on testnet past the reuse gate once every row is withdrawn and cooled down", + "ValidatorStakeService free-key check, delegations asks the explorer for the delegations by pubkey", + "ValidatorStakeService free-key check, delegations blocks when the pubkey lookup answers without a row list", + "ValidatorStakeService free-key check, delegations blocks, rather than passes, when the explorer cannot answer the pubkey lookup", + "ValidatorStakeService free-key check, delegations does not count a delegation whose deactivation block has passed", + "ValidatorStakeService free-key check, delegations refuses a key an active delegation holds, before any MINT is sent" + ], + "39ba977a5cb2416a": [ + "StatusService: getStatus() with installed modules annotates the STATUS cell with restart count and non-healthy health status", + "StatusService: getStatus() with installed modules calls checkRemoteNodeVersion when checkVersions=true and version absent", + "StatusService: getStatus() with installed modules formats port bindings into a comma-separated string", + "StatusService: getStatus() with installed modules handles node module versions via getLocalNodeVersion / getContainerNodeVersion", + "StatusService: getStatus() with installed modules leaves a clean container (no restarts, healthy) unchanged and green", + "StatusService: getStatus() with installed modules prints status output when printStatus=true on fresh call" + ], + "39d0192fdb2e885d": [ + "DatabaseService askMariadbRootPassword() falls back to password prompt when no container found", + "DatabaseService askMariadbRootPassword() falls back to the credentials-store copy when the container has no MYSQL_ROOT_PASSWORD env", + "DatabaseService askMariadbRootPassword() falls through to the container-env read when XCHAIN_NODE_DB_ROOT_PASSWORD does not verify", + "DatabaseService askMariadbRootPassword() ignores a stale credentials-store copy and reaches the prompt on a TTY", + "DatabaseService askMariadbRootPassword() persists the container-env password to the credentials store on accept", + "DatabaseService askMariadbRootPassword() reads from XCHAIN_NODE_DB_ROOT_PASSWORD env var when not cached and no container is running", + "DatabaseService askMariadbRootPassword() reads from external DB config when EXTERNAL_DB=true and not cached", + "DatabaseService askMariadbRootPassword() reads root password from running container printenv", + "DatabaseService askMariadbRootPassword() returns cached password immediately", + "DatabaseService askMariadbRootPassword() stays silent when the env override authenticates", + "DatabaseService askMariadbRootPassword() throws an actionable error instead of prompting when stdin is not a TTY", + "DatabaseService askMariadbRootPassword() verifies XCHAIN_NODE_DB_ROOT_PASSWORD against a running container before trusting it", + "DatabaseService askMariadbRootPassword() warns, without printing a value, when the env override does not authenticate" + ], + "3aa22b5c17bf0837": [ + "Integration: Docker Command Construction xchain-indexer Docker run command includes indexer DB env vars and correct port" + ], + "3ab21fe7dc6e1bf4": [ + "DbCredentialDrift formatDbCredentialDriftError leaks no credential value into the message", + "DbCredentialDrift formatDbCredentialDriftError names the container and the remediation but never a password" + ], + "3ad798643f338e59": [ + "Chaos: Download Resilience Experiment 8d: No compatible version available returns latest release when hash verification is disabled", + "Chaos: Download Resilience Experiment 8d: No compatible version available throws when no release has a matching hash entry" + ], "3b04cc05990c4f9a": [ "InstallTargetService inferInstallTarget() classifies a node with no checkouts as a release node", "InstallTargetService inferInstallTarget() classifies any module on a named branch as a branch node on that branch", @@ -776,153 +1085,502 @@ "InstallTargetService resolveUpdateTarget() falls back to the checkouts when there is no record", "InstallTargetService resolveUpdateTarget() prefers the record over the checkouts" ], - "40db4f1a28173b1f": [ - "ExplorerService: installExplorerModule() already running returns true immediately when ping succeeds and force=false", - "ExplorerService: installExplorerModule() already running returns true when status is cached and explorer is in lastStatus (force=false)", - "ExplorerService: installExplorerModule() exhausts retries throws when explorer never responds after 10 tries", - "ExplorerService: installExplorerModule() force=true kills and removes existing container when force=true and container exists", - "ExplorerService: installExplorerModule() force=true skips kill/remove when no existing container in DB (force=true)", - "ExplorerService: installExplorerModule() force=true swallows errors from killContainer/removeContainer during force rebuild", - "ExplorerService: installExplorerModule() full happy path clones and builds when ping initially fails and status cache is cold", - "ExplorerService: installExplorerModule() full happy path clones, builds, then returns true when first ping succeeds", - "ExplorerService: installExplorerModule() honours the install ref clones a release branch verbatim, so a frozen-ref e2e tests the frozen tree", - "ExplorerService: installExplorerModule() honours the install ref clones at the branch the caller named", - "ExplorerService: installExplorerModule() honours the install ref passes null when no ref was named, keeping the default-branch behaviour", - "ExplorerService: installExplorerModule() honours the install ref passes the manifest pin (ref AND commit) when a release install is active", - "ExplorerService: installExplorerModule() on a stack with no coins yet a healthy explorer is accepted whether or not a coin is installed", - "ExplorerService: installExplorerModule() on a stack with no coins yet accepts an answering-but-degraded explorer when no coin is installed", - "ExplorerService: installExplorerModule() on a stack with no coins yet refuses a container that never answers at all, coins or not", - "ExplorerService: installExplorerModule() on a stack with no coins yet still REFUSES an answering-but-degraded explorer once a coin is installed", - "ExplorerService: installExplorerModule() persistent attach failure exhausts the ping retries and throws instead of returning success", - "ExplorerService: installExplorerModule() updateExplorer error in loop retries on updateExplorer error and returns true when subsequent ping+updateExplorer succeed", + "3be16671d270b423": [ + "preCheck(): a hub that is not answering does not wedge its own repair @regression a NON-repairing command still aborts against a hub that is not answering", + "preCheck(): a hub that is not answering does not wedge its own repair @regression a repairing command against a HEALTHY hub still pushes and still fails on a real error", + "preCheck(): a hub that is not answering does not wedge its own repair @regression hydrates the sidecar key before the liveness probe on the unhealthy path", + "preCheck(): a hub that is not answering does not wedge its own repair @regression is not consulted at all for a read-only command that skips the push", + "preCheck(): a hub that is not answering does not wedge its own repair @regression lets a repairing command through on a warning, skipping the push", + "preCheck(): a hub that is not answering does not wedge its own repair @regression still continues when the skipped push leaves updateHub rejecting anyway", + "preCheck(): a hub that is not answering does not wedge its own repair @regression treats a liveness probe that throws as \"not answering\"" + ], + "3c997331d0dba970": [ + "mergeDefaults hub seed selection keeps an explicit per-coin hub API URL when no seed URLs are set", + "mergeDefaults hub seed selection keeps the default hub API URL when no seed URLs are set", + "mergeDefaults hub seed selection keeps the default hub API URL when per-coin config disables host seed URLs", + "mergeDefaults hub seed selection omits the default hub API URL for seeded dogecoin mainnet", + "mergeDefaults hub seed selection omits the default hub API URL for seeded dogecoin regtest", + "mergeDefaults hub seed selection omits the default hub API URL for seeded litecoin mainnet", + "mergeDefaults hub seed selection omits the default hub API URL for seeded litecoin regtest", + "mergeDefaults hub seed selection omits the hub API URL when host defaults have seed URLs" + ], + "3e5e1395ce2262f2": [ + "ModuleService buildAndUp(): module-specific port/volume branches includes port mapping for xchain-hub", + "ModuleService buildAndUp(): module-specific port/volume branches includes port mapping for xchain-sync", + "ModuleService buildAndUp(): module-specific port/volume branches includes two port mappings for xchain-explorer", + "ModuleService installModule(): DB module calls buildDatabaseModule and statusChanged for DB module", + "ModuleService installModule(): DB module throws when buildDatabaseModule fails", + "ModuleService installModule(): explorer module calls installExplorerModule and statusChanged", + "ModuleService installModule(): generic module full build clones, builds, and returns containerId for a fresh generic module", + "ModuleService installModule(): generic module full build throws when cloneGit fails during installModule", + "ModuleService installModule(): generic module, container version already known returns false when container version is already set and remoteUpdate=false" + ], + "3f5ce8defd4fbaec": [ + "CLI dispatch: commands that hand the lock back after preCheck exec releases the lock once preCheck returns", + "CLI dispatch: commands that hand the lock back after preCheck logs releases the lock once preCheck returns", + "CLI dispatch: commands that hand the lock back after preCheck ps releases the lock once preCheck returns", + "CLI dispatch: commands that hand the lock back after preCheck shell releases the lock once preCheck returns", + "CLI dispatch: commands that hold the lock through their action bootstrap keeps the lock and refuses a held one at once", + "CLI dispatch: commands that hold the lock through their action clear-reorg-halt --dry-run is serialized too, so its verdict cannot describe a DB mid-reset", + "CLI dispatch: commands that hold the lock through their action clear-reorg-halt keeps the lock through its database check and audited write", + "CLI dispatch: commands that hold the lock through their action install keeps the lock and refuses a held one at once", + "CLI dispatch: commands that hold the lock through their action reset keeps the lock and refuses a held one at once", + "CLI dispatch: commands that hold the lock through their action update keeps the lock and refuses a held one at once", + "CLI dispatch: the release update a self-update re-executes gives no other mutator the floor, even with the target marker set", + "CLI dispatch: the release update a self-update re-executes keeps an operator wait longer than the handoff floor", + "CLI dispatch: the release update a self-update re-executes waits out a mutator that took the handed-back lock instead of refusing at once", + "CLI dispatch: validator subcommands validator drift takes no lock and runs no preCheck", + "CLI dispatch: validator subcommands validator init --force holds the lock to exit and still skips preCheck", + "CLI dispatch: validator subcommands validator init --force is refused, and never regenerates the key, while another run holds the lock", + "CLI dispatch: validator subcommands validator init --force-wallets holds the lock to exit and still skips preCheck", + "CLI dispatch: validator subcommands validator init holds the lock to exit and still skips preCheck", + "CLI dispatch: validator subcommands validator stake --broadcast takes no lock and runs no preCheck", + "CLI dispatch: validator subcommands validator stake takes no lock and runs no preCheck", + "CLI dispatch: validator subcommands validator status takes no lock and runs no preCheck", + "CLI dispatch: validator subcommands validator takes no lock and runs no preCheck", + "CLI dispatch: validator subcommands validator unstake --broadcast takes no lock and runs no preCheck", + "CLI dispatch: validator subcommands validator unstake takes no lock and runs no preCheck" + ], + "3fa5333e295302e7": [ + "validator stake send lock hold describes every earlier broadcast when a later hold fails", + "validator stake send lock hold holds the lock before a send", + "validator stake send lock hold rethrows the original error before anything was sent", + "validator stake send lock selection and release provides a frozen no-op lock", + "validator stake send lock selection and release releases only when a send reaches its index wait", + "validator stake send lock selection and release selects a supplied lock only for broadcasts" + ], + "3fa86dc751918535": [ + "Integration: Docker Command Construction xchain-utxo-tracker Docker run command includes data volume, bootstrap volume, and ulimit" + ], + "3fec2f60b896a660": [ + "prototype method descriptors GitHubDownloader.calculateDirectoryHash is non-enumerable and callable", + "prototype method descriptors GitHubDownloader.calculateFileHash is non-enumerable and callable", + "prototype method descriptors GitHubDownloader.getAllFiles is non-enumerable and callable", + "prototype method descriptors GitHubDownloader.getHashForArch is non-enumerable and callable", + "prototype method descriptors GitHubDownloader.hasHash is non-enumerable and callable", + "prototype method descriptors GitHubDownloader.verifyFileHash is non-enumerable and callable", + "prototype method descriptors GitHubDownloader.verifyRepositoryHash is non-enumerable and callable", + "prototype method descriptors MariaDbStore.deleteModuleContainer is non-enumerable and callable", + "prototype method descriptors MariaDbStore.getAllModuleContainers is non-enumerable and callable", + "prototype method descriptors MariaDbStore.getModuleContainer is non-enumerable and callable", + "prototype method descriptors MariaDbStore.getModuleContainerStrict is non-enumerable and callable", + "prototype method descriptors MariaDbStore.getModuleCount is non-enumerable and callable", + "prototype method descriptors MariaDbStore.setModuleContainer is non-enumerable and callable" + ], + "4168e4dba2ad4bbb": [ + "StopBudgetService gives an unstamped decoder its default budget, and leaves unlisted and unreadable ones on the current one", + "StopBudgetService keeps an explicit value at or above the budget but warns that docker will kill first", + "StopBudgetService moduleShutdownTimeoutMs() leaves a service on the plain default budget, and the coin node, to their own defaults", + "StopBudgetService moduleShutdownTimeoutMs() moves with an override in both directions and stays strictly under the budget", + "StopBudgetService moduleShutdownTimeoutMs() reproduces the decoder and tracker default of 100000 ms from the default 120 s budget", + "StopBudgetService moduleStopTimeoutSeconds() answers the coin node from its own resolver so `stop node` and `stop all` share one path", + "StopBudgetService moduleStopTimeoutSeconds() gives the decoder and tracker more than docker's ten seconds, and the rest a default above it", + "StopBudgetService moduleStopTimeoutSeconds() ignores a value that is not a whole number of seconds, and says so", + "StopBudgetService moduleStopTimeoutSeconds() reads the per-service override by the derived variable name", + "StopBudgetService reads the container through docker when the caller passes no reader, as update and uninstall do", + "StopBudgetService shutdownTimeoutEnv() adds the derived value unless the module config sets its own", + "StopBudgetService stays quiet for an explicit value under the budget or one the service would ignore", + "StopBudgetService stopModuleContainer() on a container that predates its budget never reads the coin node, whose budget is a flush and not a drain", + "StopBudgetService stopModuleContainer() on a container that predates its budget stays quiet for a container that matches its budget, at the default or recreated", + "StopBudgetService stopModuleContainer() on a container that predates its budget stays quiet for a default-budget service that never carried a forwarded drain", + "StopBudgetService stopModuleContainer() on a container that predates its budget warns before the stop when the container was stamped with another budget", + "StopBudgetService stopModuleContainer() on a container that predates its budget warns when an override is set but the container never got SHUTDOWN_TIMEOUT_MS", + "StopBudgetService stopModuleContainer() on a container that predates its budget warns when an override was dropped but the container still carries the drain it derived", + "StopBudgetService stopModuleContainer() says nothing when there was nothing to stop", + "StopBudgetService stopModuleContainer() still reports a clean stop for exit 0 and for a drainless service ended by SIGTERM (143)", + "StopBudgetService stopModuleContainer() stops with the budget and reports a clean stop with the time it took", + "StopBudgetService stopModuleContainer() warns when the service exited non-zero inside the budget, naming its own drain timer", + "StopBudgetService stopModuleContainer() warns when the service ran out of budget and was killed, naming the override", + "StopBudgetService stopTimeoutArgs() stamps the same budget on the container so a plain docker stop honours it", + "StopBudgetService stops an old container with the budget it was stamped with after the override is lowered", + "StopBudgetService uses the lowered budget once the container was recreated under it, and a raised one at once", + "StopBudgetService warns before the stop when the container carries a drain timer at or above its budget" + ], + "41f655e5214c0500": [ + "GitHubDownloader downloadRepoVersion(): host-arch hash gate refuses before any asset download when the host arch has no hash", + "GitHubDownloader getLatestCompatibleVersion(): host-arch hash gate skips a newer release with no hash for an aarch64 host", + "GitHubDownloader getLatestCompatibleVersion(): host-arch hash gate still picks the newest release on an x86_64 host", + "GitHubDownloader getLatestCompatibleVersion(): host-arch hash gate throws when no release has a hash for the host arch" + ], + "41f7f97eefd13478": [ + "migrate CLI status contract guard does not execute a file-only CLI at the current path", + "migrate CLI status contract guard does not execute a file-only CLI at the old path", + "migrate CLI status contract guard does not execute a strict CLI without the status contract", + "migrate CLI status contract guard executes a CLI that advertises the status contract" + ], + "4229d76191f1dae4": [ + "CLI: validator stake|unstake hand their service a send lock validator stake --broadcast passes a lock named \"validator stake\"", + "CLI: validator stake|unstake hand their service a send lock validator unstake --broadcast passes a lock named \"validator unstake\"", + "CLI: validatorSendLock releases on process exit while held, and drops that handler once released", + "CLI: validatorSendLock takes it again after a release, for the send that follows an index wait", + "CLI: validatorSendLock takes the lock once per hold, under the command's name, refusing a held one at once", + "CLI: validatorSendLock waits as long as any mutator is told to wait for a held lock" + ], + "426085f91f8b7f22": [ + "ui/menu/node_version_order NODE_VERSION_ORDER orders by numeric parts, not by string or by semver", + "ui/menu/node_version_order NODE_VERSION_ORDER treats a missing trailing part as zero and ignores the v", + "ui/menu/node_version_order nodeVersionParts() reads two-, three- and four-part daemon tags, with or without the v", + "ui/menu/node_version_order nodeVersionParts() rejects the menu sentinel \"0\" and anything that is not a dotted version", + "ui/menu/node_version_order remoteNodeVersion() reads the release tag, or \"0\" when no release was stored" + ], + "428aec81edc63b3f": [ + "Integration: Hub/Explorer Config Update updateHub network connectivity connects hub container to all installed coin/network Docker networks" + ], + "429a966c6057bfb5": [ + "moduleOperations resetModules() node datadir resolution falls back to the configured datadir when the container reports no mount", + "moduleOperations resetModules() node datadir resolution names the container, the configured path and the env var in the refusal", + "moduleOperations resetModules() node datadir resolution refuses the whole reset when the datadir resolves to nothing", + "moduleOperations resetModules() node datadir resolution skips the node wipe out loud, and completes, when no node is installed", + "moduleOperations resetModules() node datadir resolution wipes the path the node container reports, not the env-derived one", + "moduleOperations resetModules() node datadir resolution with an unreadable registry refuses, rather than skips, when the node registry read fails and the datadir is unresolvable", + "moduleOperations resetModules() node datadir resolution with an unreadable registry still wipes a datadir it resolved when only the registry read fails" + ], + "448f1a53a6792be5": [ + "Integration: Docker Command Construction every coin/network combo produces valid Docker run commands xchain-encoder on bitcoin/mainnet", + "Integration: Docker Command Construction every coin/network combo produces valid Docker run commands xchain-encoder on bitcoin/regtest", + "Integration: Docker Command Construction every coin/network combo produces valid Docker run commands xchain-encoder on bitcoin/testnet", + "Integration: Docker Command Construction every coin/network combo produces valid Docker run commands xchain-encoder on dogecoin/mainnet", + "Integration: Docker Command Construction every coin/network combo produces valid Docker run commands xchain-encoder on dogecoin/regtest", + "Integration: Docker Command Construction every coin/network combo produces valid Docker run commands xchain-encoder on dogecoin/testnet", + "Integration: Docker Command Construction every coin/network combo produces valid Docker run commands xchain-encoder on litecoin/mainnet", + "Integration: Docker Command Construction every coin/network combo produces valid Docker run commands xchain-encoder on litecoin/regtest", + "Integration: Docker Command Construction every coin/network combo produces valid Docker run commands xchain-encoder on litecoin/testnet" + ], + "449638b7f5819a93": [ + "ValidatorService initValidator() creates the capability-config directory and writes the config inside it", + "ValidatorService initValidator() creates validator dir if it does not exist", + "ValidatorService initValidator() falls back to the mainnet federation epoch when none is supplied", + "ValidatorService initValidator() fills only the publisher placeholders in an existing capabilities file", + "ValidatorService initValidator() leaves an existing, operator-tuned capabilities file alone when force is false", + "ValidatorService initValidator() sets ORACLE_EPOCH_START from opts.oracleEpochStart", + "ValidatorService initValidator() skips validator dir creation if it already exists", + "ValidatorService initValidator() uses partial capabilities from opts.capabilities", + "ValidatorService initValidator() writes key file with mode 0600" + ], + "45106b7980f98941": [ + "AutohealService clears the persisted onset when the container recovers, so the next episode restarts the clock" + ], + "45392e97fad62cd2": [ + "DockerService saveContainerLogs() rejects when spawn emits error", + "DockerService saveContainerLogs() spawns docker logs and pipes to file, resolves when output finishes", + "DockerService stringToDockerContainerFile() rejects on spawn error", + "DockerService stringToDockerContainerFile() rejects when tee exits with non-zero code", + "DockerService stringToDockerContainerFile() resolves true when tee exits with code 0" + ], + "462a874ef1e65e98": [ + "ModuleService buildAndUp() init and wget timeout passes init and a bounded wget command for a healthchecked module", + "ModuleService buildAndUp() init and wget timeout passes init when healthcheck argument construction returns no arguments" + ], + "480937c8f65a1475": [ + "ModuleService configureDependencies() configureDependencies keeps its own getPublishedHostPorts and getUtxoTrackerVolumeName when the injected value is absent", + "ModuleService parsePortSpec() keeps a bracketed IPv6 host address whole", + "ModuleService parsePortSpec() returns null for a colon-less value or a non-string", + "ModuleService parsePortSpec() splits HOST:CONTAINER with an empty ip", + "ModuleService parsePortSpec() splits IP:HOST:CONTAINER on the last colon", + "ModuleService validatePortArgs() accepts in-range HOST:CONTAINER pairs and skips colon-less values", + "ModuleService validatePortArgs() refuses an IP-scoped spec even when both ports are valid", + "ModuleService validatePortArgs() throws on a dangling -p with no value", + "ModuleService validatePortArgs() throws on an out-of-range or non-numeric port" + ], + "49e4d868739931f2": [ + "Boundary Tests ConfigService: filterCommandParameters boundaries explorer special case sets coins to empty", + "Boundary Tests ConfigService: filterCommandParameters boundaries full expansion (all/all/all) produces correct structure", + "Boundary Tests ConfigService: filterCommandParameters boundaries includes regtest-miner on regtest", + "Boundary Tests ConfigService: filterCommandParameters boundaries returns empty module list for regtest-only service on mainnet", + "Boundary Tests ConfigService: filterCommandParameters boundaries returns empty module list for regtest-only service on testnet", + "Boundary Tests ConfigService: filterCommandParameters boundaries treats unknown service as literal module name" + ], + "4a50c1aca5c12ab0": [ + "Chaos: Process Resilience Experiment 11: Async error propagation propagates rejection from docker build failure" + ], + "4b546ae8078d0aae": [ + "MemoryLimitService countInstalledTrackers() counts one when the registry cannot be read or the handle has no registry", + "MemoryLimitService countInstalledTrackers() counts the registered trackers plus the one being created", + "MemoryLimitService countInstalledTrackers() does not double-count a tracker that is already registered (recreate)", + "MemoryLimitService memoryArgsFor() 0 disables the derived tracker cap, and says so", + "MemoryLimitService memoryArgsFor() an explicit XCHAIN_NODE_MODULE_MEMORY_MB_ wins for any module", + "MemoryLimitService memoryArgsFor() caps the tracker with --memory and an equal --memory-swap", + "MemoryLimitService memoryArgsFor() derives the env key from the service name", + "MemoryLimitService memoryArgsFor() ignores an unparseable value rather than guessing, and names it", + "MemoryLimitService memoryArgsFor() leaves every other module uncapped by default", + "MemoryLimitService memoryArgsFor() note, which chain it is about leaves a singleton service, which has no chain, named by module alone", + "MemoryLimitService memoryArgsFor() note, which chain it is about names the chain in an explicit limit note too", + "MemoryLimitService memoryArgsFor() note, which chain it is about names the chain in the note", + "MemoryLimitService memoryArgsFor() warns in the note when the share is under the floor", + "MemoryLimitService the no-memory-limit-support warnings carries the same host fix in both lines", + "MemoryLimitService the no-memory-limit-support warnings quotes what docker itself said in the preflight line", + "MemoryLimitService the no-memory-limit-support warnings says what was asked for, that it was not kept, and that the container is uncapped", + "MemoryLimitService trackerMemoryLimitMb() ceilings a huge host", + "MemoryLimitService trackerMemoryLimitMb() divides the half among the trackers on the host (the 16 GB three-chain case)", + "MemoryLimitService trackerMemoryLimitMb() floors, and says so, when the host cannot afford its trackers", + "MemoryLimitService trackerMemoryLimitMb() gives one tracker half the host", + "MemoryLimitService trackerMemoryLimitMb() treats a missing or zero count as one" + ], + "4c6708564426099f": [ + "DockerService getDockerContainerFileData(): error branch rejects when docker cp fails" + ], + "4e86adb287c39e1d": [ + "CLI `rollback` incident-path behaviour does NOT run the Docker/MariaDB precheck for a command that provisions nothing", + "CLI `rollback` incident-path behaviour does NOT take the mutating command lock, so it cannot queue behind a deploy", + "CLI `rollback` incident-path behaviour echoes the operator's own arguments back, so the guidance is copy-pasteable", + "CLI `rollback` incident-path behaviour exits NON-ZERO instead of leaving the process alive on open handles", + "CLI `rollback` incident-path behaviour leaves the precheck and the lock in place for a command that does provision", + "CLI `rollback` incident-path behaviour names the reset-and-restore recovery path as runnable commands", + "CLI `rollback` incident-path behaviour says it did nothing, so no operator reads the refusal as a completed rollback" + ], + "4ea2fe0c5595ff3a": [ + "SDK encoder compatibility broadcasts from the emitted signer with _requireEncoder", + "SDK encoder compatibility broadcasts from the emitted signer with requireEncoder", + "SDK encoder compatibility chains stake actions with the underscore encoder method" + ], + "4fc4333ff26b31fa": [ + "Regression Suite [regression:p0] Configuration Generation R-CFG-001: getDefaultConfig returns correct ports for each coin/network combo", + "Regression Suite [regression:p0] Configuration Generation R-CFG-002: config file overrides merge correctly over defaults", + "Regression Suite [regression:p0] Configuration Generation R-CFG-003: Docker image names follow prefix-coin-network-module pattern", + "Regression Suite [regression:p0] Configuration Generation R-CFG-003b: shared modules omit coin/network from image name", + "Regression Suite [regression:p0] Configuration Generation R-CFG-004: Docker network names follow prefix-coin-network pattern", + "Regression Suite [regression:p0] Configuration Generation R-CFG-005: database names follow XChain_TICKER_Network_Module pattern", + "Regression Suite [regression:p0] Configuration Generation R-CFG-006: all 9 coin/network combos produce correct ticker and DB user", + "Regression Suite [regression:p0] Configuration Generation R-CFG-007: regtest config includes REGTEST_MINER_URL, mainnet/testnet do not", + "Regression Suite [regression:p0] Configuration Generation R-CFG-008: shared service config (null coin/network) has correct keys", + "Regression Suite [regression:p0] Configuration Generation R-CFG-009: filterCommandParameters all/all/all expands fully" + ], + "50198cfa94230b48": [ + "DockerService getContainerStopSettings() reports each value as null when the container was created without it", + "DockerService getContainerStopSettings() resolves null when docker fails or answers something unparseable", + "DockerService getContainerStopSettings() returns the stamped budget and SHUTDOWN_TIMEOUT_MS and nothing else from the env" + ], + "501f38adb1b23b15": [ + "ModuleService buildAndUp() healthcheck args dependency-derived healthcheck start periods grants the encoder a window at least as long as the utxo-tracker it probes", + "ModuleService buildAndUp() healthcheck args dependency-derived healthcheck start periods grants the hub and the explorer at least the DB start period their probes SELECT 1 against", + "ModuleService buildAndUp() healthcheck args emits a docker-parsable duration for every start-period it returns", + "ModuleService buildAndUp() healthcheck args ignores a malformed start-period override and keeps the descriptor default", + "ModuleService buildAndUp() healthcheck args includes --health-interval, --health-timeout, --health-retries, --health-start-period for encoder", + "ModuleService buildAndUp() healthcheck args normalizes a bare-seconds start-period override to a docker duration", + "ModuleService buildAndUp() healthcheck args omits healthcheck args for onlyExecution containers", + "ModuleService buildAndUp() healthcheck args returns [] with no warning for a module that has no healthcheck descriptor", + "ModuleService cloneGit() branch handling clones with -b flag when a valid branch is specified", + "ModuleService cloneGit() branch handling rejects (no silent fallback) when the requested branch is not found", + "ModuleService cloneGit() branch handling rejects on invalid branch name", + "ModuleService installModule() singleton guard skips re-creating a singleton module whose container already exists", + "ModuleService uninstallModule() removes stale tracking row when no container in status but row exists in DB", + "ModuleService uninstallModule() returns true when module is not found (already uninstalled)", + "ModuleService uninstallModule() skips kill for exited containers", + "ModuleService uninstallModule() stops a running container with its budget, never docker kill, before removing", + "ModuleService uninstallModule() throws when container kill/remove fails (catch rethrows)", + "ModuleService uninstallModule() throws when trying to uninstall database" + ], + "5066451bdf737206": [ "ExplorerService: updateExplorer() connects explorer container to all installed coin networks", "ExplorerService: updateExplorer() rejects naming the unreachable networks when the attach keeps failing", "ExplorerService: updateExplorer() retries a failed attach once and returns true when the retry succeeds", "ExplorerService: updateExplorer() returns true immediately when explorer container is exited", "ExplorerService: updateExplorer() returns true immediately when explorer entry is absent from lastStatus", - "ExplorerService: updateExplorer() skips network connection when no explorerContainerId found in DB", - "ExplorerService: waitForExplorerReady() gives up early, reporting no problem, when nothing is listening at all", - "ExplorerService: waitForExplorerReady() keeps waiting through degraded replies, then succeeds when it converges", - "ExplorerService: waitForExplorerReady() reports false when a talking explorer never converges inside the budget", - "ExplorerService: waitForExplorerReady() returns true as soon as the explorer reports healthy" + "ExplorerService: updateExplorer() skips network connection when no explorerContainerId found in DB" ], - "40fdfec30fa58f39": [ - "NodeService: buildCryptoNode() XCHAIN_NODE_BLOCKS_DIR (blocks + txindex split) bitcoin testnet: relocates txindex under testnet4 (bitcoin-testnet.conf sets testnet4=1)", - "NodeService: buildCryptoNode() XCHAIN_NODE_BLOCKS_DIR (blocks + txindex split) bitcoin: -blocksdir against /blocks + relocates txindex", - "NodeService: buildCryptoNode() XCHAIN_NODE_BLOCKS_DIR (blocks + txindex split) dogecoin mainnet: nests blocks onto the in-datadir path, never -blocksdir", - "NodeService: buildCryptoNode() XCHAIN_NODE_BLOCKS_DIR (blocks + txindex split) dogecoin regtest: nests under the regtest subdir", - "NodeService: buildCryptoNode() XCHAIN_NODE_BLOCKS_DIR (blocks + txindex split) dogecoin testnet: nests under the testnet3 subdir", - "NodeService: buildCryptoNode() XCHAIN_NODE_BLOCKS_DIR (blocks + txindex split) litecoin testnet: relocates txindex under testnet4", - "NodeService: buildCryptoNode() XCHAIN_NODE_BLOCKS_DIR (blocks + txindex split) litecoin: honors -blocksdir (Bitcoin 0.21 base)", - "NodeService: buildCryptoNode() XCHAIN_NODE_BLOCKS_DIR (blocks + txindex split) no blocks/txindex mounts when the env var is unset", - "NodeService: buildCryptoNode() aborts before the docker build when a host-port conflict is detected", - "NodeService: buildCryptoNode() bakes no CRYPTO_NODE_VERSION env into the coin-node container", - "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard does not rewrite the sidecar when it already holds the same value", - "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard falls back to the persisted sidecar value when the env var is absent", - "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard persists the env value to the config/node.local sidecar", - "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard refuses to replace a container whose bind mounts the new spec would drop", - "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard replaces normally when the new spec keeps every existing bind mount", - "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard stop budget and its outcome falls back to the default on a value that is not a whole number of seconds, and says so", - "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard stop budget and its outcome reports a clean stop with the time it took and the budget", - "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard stop budget and its outcome says nothing about the stop when there was no previous daemon", - "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard stop budget and its outcome takes the budget from XCHAIN_NODE_STOP_TIMEOUT_SECONDS and stamps it on the new container", - "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard stop budget and its outcome warns when the daemon ran out of budget and was killed, naming the override", - "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard stops the previous daemon gracefully, with a flush budget, before force-removing it", - "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard treats an existing symlink at the blocks host path as provisioned (no mkdir)", - "NodeService: buildCryptoNode() includes --restart unless-stopped in docker run args", - "NodeService: buildCryptoNode() includes --ulimit nofile flag in docker run args", - "NodeService: buildCryptoNode() includes port mapping when NODE_EXPOSED_PORT and NODE_PORT are configured", - "NodeService: buildCryptoNode() reads and rewrites conf file when it exists", - "NodeService: buildCryptoNode() rejects (does not hang) when docker run output is not a container id", - "NodeService: buildCryptoNode() rejects (does not hang) when the blocksDir mkdir fails", - "NodeService: buildCryptoNode() rejects (does not hang) when the docker build fails", - "NodeService: buildCryptoNode() rejects when db.insertModuleContainer returns false", - "NodeService: buildCryptoNode() rejects when docker run fails", - "NodeService: buildCryptoNode() runs docker build with correct image name and cwd", - "NodeService: buildCryptoNode() stores container ID and fires statusChanged on success", - "NodeService: getCryptoNode() a broken mirror is not the whole install answers the all:true form Node actually calls it with", - "NodeService: getCryptoNode() a broken mirror is not the whole install does not try other mirrors for an HTTP status, where every mirror agrees", - "NodeService: getCryptoNode() a broken mirror is not the whole install names the URL, the cause and a way forward when every mirror fails", - "NodeService: getCryptoNode() a broken mirror is not the whole install retries on another mirror address after a TLS failure and succeeds", - "NodeService: getCryptoNode() a broken mirror is not the whole install still makes one attempt when the name cannot be resolved at all", - "NodeService: getCryptoNode() a tarball the operator placed by hand is used, not destroyed discards a file that fails the pinned hash and downloads instead", - "NodeService: getCryptoNode() a tarball the operator placed by hand is used, not destroyed never decompresses bytes that fail the pinned hash", - "NodeService: getCryptoNode() a tarball the operator placed by hand is used, not destroyed skips the download entirely when the tarball is already at the path", - "NodeService: getCryptoNode() a tarball the operator placed by hand is used, not destroyed still verifies the placed tarball against the pinned hash before using it", - "NodeService: getCryptoNode() downloads bitcoin node for x64 arch, decompresses and renames", - "NodeService: getCryptoNode() downloads dogecoin node via gitHubDownloader", - "NodeService: getCryptoNode() downloads litecoin node via gitHubDownloader", - "NodeService: getCryptoNode() rejects (and never decompresses) when the bitcoin tarball hash mismatches", - "NodeService: getCryptoNode() rejects on a non-200 download response", - "NodeService: getCryptoNode() rejects when decompressTarGz fails", - "NodeService: getCryptoNode() strips leading \"v\" from bitcoin version string", - "NodeService: getCryptoNode() throws for an unsupported coin", - "NodeService: getCryptoNode() throws on unsupported architecture", - "NodeService: getCryptoNode() uses rmSync when node version >= 14.14.0 and old bitcoin dir exists", - "NodeService: getCryptoNode() verifies the downloaded bitcoin tarball before decompressing", - "NodeService: installNode() bootstraps utxo tracker when volume is fresh", - "NodeService: installNode() does NOT install regtest miner when network is mainnet", - "NodeService: installNode() fetches remote version and calls getCryptoNode when not installed (dogecoin)", - "NodeService: installNode() installs regtest miner when network is regtest", - "NodeService: installNode() succeeds when local node version already installed (skips getCryptoNode)", - "NodeService: installNode() throws when no valid remote version is available", - "NodeService: node version pin (XCHAIN_NODE_NODE_VERSION_) assertNodeVersionPin throws on a mismatched local daemon", - "NodeService: node version pin (XCHAIN_NODE_NODE_VERSION_) installNode downloads the pinned version, bypassing remote-latest lookup", - "NodeService: node version pin (XCHAIN_NODE_NODE_VERSION_) installNode fails loudly when a cached local daemon mismatches the pin", - "NodeService: node version pin (XCHAIN_NODE_NODE_VERSION_) resolveNodeVersionPin returns null when env unset or blank", - "NodeService: node version pin (XCHAIN_NODE_NODE_VERSION_) resolveNodeVersionPin returns the trimmed pin for the coin" + "51093ddbe0fee12c": [ + "ROLLCALL arming mirror agrees with xchain-indexer gate registry rows for rollcall_activation on the accept window and the proof delay", + "ROLLCALL arming mirror agrees with xchain-indexer gate registry rows for rollcall_activation on the arming heights", + "ROLLCALL arming mirror mainnet arms at genesis, testnet at 151200, regtest only from the venue variable", + "ROLLCALL arming mirror reads the regtest variable the way the indexer does, garbage included", + "ROLLCALL arming mirror spells the close block the way the wedge is computed" ], - "4405034a199528ec": [ - "coverage ratchet floors enforces every declared floor, at the declared value", - "coverage ratchet floors fails the job on a shortfall rather than only reporting it", - "coverage ratchet floors ships the coverage:check script the CI coverage job invokes" + "5125fdb96876c007": [ + "EncoderMaintenanceWindow clearEncoderMaintenance() is a no-op with no encoder on this host", + "EncoderMaintenanceWindow clearEncoderMaintenance() never throws when the removal fails", + "EncoderMaintenanceWindow clearEncoderMaintenance() removes the sentinel from the encoder container", + "EncoderMaintenanceWindow declareEncoderMaintenance() falls back to a generic reason rather than writing an empty one", + "EncoderMaintenanceWindow declareEncoderMaintenance() honours an explicit window length", + "EncoderMaintenanceWindow declareEncoderMaintenance() never throws when the container lookup fails", + "EncoderMaintenanceWindow declareEncoderMaintenance() never throws when the write fails", + "EncoderMaintenanceWindow declareEncoderMaintenance() reports false and writes nothing when there is no encoder here", + "EncoderMaintenanceWindow declareEncoderMaintenance() writes a bounded, currently-open sentinel into the encoder container", + "EncoderMaintenanceWindow sentinel path defaults to the path the encoder reads" ], - "482a3259eebf8fa8": [ - "DockerService addContainerToNetwork() connects container when not already in network", - "DockerService addContainerToNetwork() rejects when network connect fails", - "DockerService addContainerToNetwork() skips connect when container already in network", - "DockerService checkContainerdDataRootRelocation() honors XCHAIN_NODE_CONTAINERD_ROOT override", - "DockerService checkContainerdDataRootRelocation() returns null (best-effort) when docker info fails", - "DockerService checkContainerdDataRootRelocation() returns null when containerd lives on a third off-root disk", - "DockerService checkContainerdDataRootRelocation() returns null when containerd was also relocated off / (same disk as data-root)", - "DockerService checkContainerdDataRootRelocation() returns null when data-root is still on the root filesystem", - "DockerService checkContainerdDataRootRelocation() returns null when docker info returns an empty data-root", - "DockerService checkContainerdDataRootRelocation() returns null when the containerd path does not exist", - "DockerService checkContainerdDataRootRelocation() returns null when the data-root path is unreadable", - "DockerService checkContainerdDataRootRelocation() warns when data-root moved off / but containerd is still on /", + "52555b17f82ac069": [ + "BootstrapHealthGate XCHAIN_NODE_BOOTSTRAP_SKIP_HEALTH_GATE=1 bypasses the gate (loudly)", + "BootstrapHealthGate durable halt markers REFUSES a MariaDB source whose schema reports no events table", + "BootstrapHealthGate durable halt markers REFUSES a REORG_HALT count that is a partial number", + "BootstrapHealthGate durable halt markers REFUSES a database carrying an uncleared xchain-sync divergence halt", + "BootstrapHealthGate durable halt markers REFUSES a decoder whose database carries a REORG_HALT row", + "BootstrapHealthGate durable halt markers REFUSES a marker-table count outside 0..1", + "BootstrapHealthGate durable halt markers REFUSES a negative REORG_HALT count", + "BootstrapHealthGate durable halt markers REFUSES a sync_halt count that is a partial number", + "BootstrapHealthGate durable halt markers REFUSES an indexer when the paired decoder database is absent", + "BootstrapHealthGate durable halt markers REFUSES an indexer when the paired decoder probe throws", + "BootstrapHealthGate durable halt markers REFUSES an indexer whose PAIRED DECODER database carries a REORG_HALT row", + "BootstrapHealthGate durable halt markers REFUSES an indexer whose paired decoder database carries an uncleared sync halt", + "BootstrapHealthGate durable halt markers REFUSES in external-DB mode when the native probe answers with nothing", + "BootstrapHealthGate durable halt markers REFUSES when a marker-table token is a partial number, and does not skip sync_halt", + "BootstrapHealthGate durable halt markers REFUSES when the REORG_HALT count itself is unreadable", + "BootstrapHealthGate durable halt markers REFUSES when the marker query itself fails (fail closed, never assume clean)", + "BootstrapHealthGate durable halt markers REFUSES when the marker-table probe returns non-numeric output", + "BootstrapHealthGate durable halt markers REFUSES when the marker-table probe returns nothing (unreadable, not clean)", + "BootstrapHealthGate durable halt markers REFUSES when the marker-table probe returns the wrong number of tokens", + "BootstrapHealthGate durable halt markers REFUSES when the sync_halt count itself is unreadable", + "BootstrapHealthGate durable halt markers dump-window watermark REFUSES a halt raised and cleared while the dump was streaming", + "BootstrapHealthGate durable halt markers dump-window watermark REFUSES a halt raised inside the window in the paired decoder database", + "BootstrapHealthGate durable halt markers dump-window watermark REFUSES a sync_halt raised inside the window even though it is cleared now", + "BootstrapHealthGate durable halt markers dump-window watermark REFUSES an unusable watermark rather than trusting it", + "BootstrapHealthGate durable halt markers dump-window watermark REFUSES when the id sequence went backwards", + "BootstrapHealthGate durable halt markers dump-window watermark hands the caller a watermark for both marker tables", + "BootstrapHealthGate durable halt markers dump-window watermark passes when nothing was raised inside the window", + "BootstrapHealthGate durable halt markers dump-window watermark reports a null sync_halt watermark when the table does not exist", + "BootstrapHealthGate durable halt markers passes a decoder with no marker rows", + "BootstrapHealthGate durable halt markers passes an indexer when BOTH its own and the decoder database are clean", + "BootstrapHealthGate durable halt markers skips the sync_halt query on a schema that has no sync_halt table" + ], + "5391d29ad9735760": [ + "Boundary Tests ConfigService: resolveArgs boundaries classifies args correctly regardless of order", + "Boundary Tests ConfigService: resolveArgs boundaries handles empty args array", + "Boundary Tests ConfigService: resolveArgs boundaries recognizes \"database\" as a service", + "Boundary Tests ConfigService: resolveArgs boundaries recognizes \"explorer\" as a service", + "Boundary Tests ConfigService: resolveArgs boundaries recognizes \"node\" as a service", + "Boundary Tests ConfigService: resolveArgs boundaries rejects a second unrecognized argument after filling the branch slot", + "Boundary Tests ConfigService: resolveArgs boundaries rejects an unrecognized argument when expectBranch is false", + "Boundary Tests ConfigService: resolveArgs boundaries returns all defaults when all args are \"all\"", + "Boundary Tests ConfigService: resolveArgs boundaries returns all defaults when all args are null", + "Boundary Tests ConfigService: resolveArgs boundaries returns all defaults when all args are undefined", + "Boundary Tests ConfigService: resolveArgs boundaries treats unrecognized arg as branch when expectBranch is true", + "Boundary Tests ConfigService: resolveArgs boundaries uses custom defaultBranch when specified", + "Boundary Tests ConfigService: resolveArgs boundaries uses defaultBranch when no branch found and expectBranch is true" + ], + "53a93d0ea7bffc56": [ + "ReleaseManifestService resolveInstallTarget() classifies a branch ref without touching the network", + "ReleaseManifestService resolveInstallTarget() falls back to the default branch when NO release exists (pre-first-train)", + "ReleaseManifestService resolveInstallTarget() falls back to the default branch when the lookup fails outright", + "ReleaseManifestService resolveInstallTarget() loads the manifest for an explicit release ref", + "ReleaseManifestService resolveInstallTarget() prefers the LOCAL manifest when the running checkout is that release", + "ReleaseManifestService resolveInstallTarget() refuses to fall back to a branch when told not to and no release exists", + "ReleaseManifestService resolveInstallTarget() refuses to fall back to a branch when told not to and the lookup fails", + "ReleaseManifestService resolveInstallTarget() rejects a manifest that is not valid JSON", + "ReleaseManifestService resolveInstallTarget() reports a release ref that has no manifest, instead of installing tips", + "ReleaseManifestService resolveInstallTarget() resolves the latest release when no ref is given" + ], + "5431f5284081e044": [ + "Regression Suite [regression:p0] Docker Command Construction R-DCK-001: buildAndUp constructs docker run with env vars and returns 64-char container ID", + "Regression Suite [regression:p0] Docker Command Construction R-DCK-002: createDockerNetwork constructs correct network create command", + "Regression Suite [regression:p0] Docker Command Construction R-DCK-004: env vars from config reach docker run via --env NAME + process env, never argv values", + "Regression Suite [regression:p0] Docker Command Construction R-DCK-005: checkDockerInstalledAndReachable rejects when Docker missing" + ], + "54441a6949465539": [ + "nightly-e2e.yml action-suite sharding the merge fails the coin on a red shard, and writes no merged action log", + "nightly-e2e.yml action-suite sharding the merge fails the coin on a shard that ran no tests", + "nightly-e2e.yml action-suite sharding the merge fails the coin when a shard never uploaded", + "nightly-e2e.yml action-suite sharding the merge writes ONE bannered action log whose last summary is the sum, and no second leg log anywhere", + "nightly-e2e.yml action-suite sharding the plan keeps a single-suite dispatch as one shard running its argument verbatim, with no extra legs", + "nightly-e2e.yml action-suite sharding the plan keeps a suite and its .test/ part files on one shard", + "nightly-e2e.yml action-suite sharding the plan puts security and performance on shard 1 only", + "nightly-e2e.yml action-suite sharding the plan refuses more shards than groups rather than leaving one empty", + "nightly-e2e.yml action-suite sharding the plan schedules a suite with no measured weight instead of dropping it", + "nightly-e2e.yml action-suite sharding the plan selects, through mocha lookupFiles, exactly the files the unsharded glob selects, each once", + "nightly-e2e.yml action-suite sharding the workflow expands the coin list the way the unsharded matrix did: schedule and all are three coins, a dispatch is one", + "nightly-e2e.yml action-suite sharding the workflow keeps the aggregate job id `e2e` with the coin as its only matrix key, so it renders as `e2e ()`", + "nightly-e2e.yml action-suite sharding the workflow runs security and performance on shard 1 of a full pass only", + "nightly-e2e.yml action-suite sharding the workflow runs the aggregate after the shards even when one failed, and fails it per coin" + ], + "5486dc9f5b20dac7": [ + "StatusService: getStatus() STALL and HALTED columns a tracker whose surface cannot be read stays plain running", + "StatusService: getStatus() STALL and HALTED columns leaves an indexer deferring inside a barrier grace window, and an advancing one, plain running", + "StatusService: getStatus() STALL and HALTED columns prints HALTED for a tracker that halted on a reorg past its window, with the reason and the reset under the table", + "StatusService: getStatus() STALL and HALTED columns prints STALL plus the reason word for an indexer deferring on a missing DOGE read, and the note names the variable" + ], + "54f2031de8d7e024": [ + "makeBootstrap() consults the source health gate aborts the create when the gate refuses", + "makeBootstrap() consults the source health gate gates the utxo-tracker BEFORE stopping its container, so a refusal costs no downtime", + "makeBootstrap() consults the source health gate still rejects an unsupported module before consulting the gate" + ], + "56897dc4174a8788": [ + "Chaos: Download Resilience Experiment 8c: SHA-256 hash mismatch succeeds when hash matches exactly", + "Chaos: Download Resilience Experiment 8c: SHA-256 hash mismatch throws with expected vs actual hash on mismatch" + ], + "568cce545e5bfbec": [ + "S-SMOKE-009 – State Module Initialization exports all expected getter/setter functions", + "S-SMOKE-009 – State Module Initialization exports db instance", + "S-SMOKE-009 – State Module Initialization exports gitHubDownloader instance", + "S-SMOKE-009 – State Module Initialization getDbRootPassword returns null initially", + "S-SMOKE-009 – State Module Initialization getInstalledModules returns empty object initially", + "S-SMOKE-009 – State Module Initialization getRemoteModuleVersions returns empty object initially", + "S-SMOKE-009 – State Module Initialization isVerbose returns false initially", + "S-SMOKE-009 – State Module Initialization setVerbose and isVerbose work as getter/setter" + ], + "5756d1c8d96ada07": [ + "Integration: Multi-Module Orchestration uninstallModules orchestration calls uninstallModule for each service in the list", + "Integration: Multi-Module Orchestration uninstallModules orchestration continues uninstalling remaining modules when one fails" + ], + "576e1ff9362cace7": [ + "CredentialsService saveExternalDbConfig() coerces types: host/root_user/root_password to string, port to number", + "CredentialsService saveExternalDbConfig() creates dir if it does not exist", + "CredentialsService saveExternalDbConfig() merges externalDb into existing credentials.json", + "CredentialsService saveExternalDbConfig() silently ignores chmodSync errors", + "CredentialsService saveExternalDbConfig() writes file with mode 0600" + ], + "57b561a8898acb97": [ + "moduleOperations resetModules() the utxo-tracker volume wipe aborts and restores the stack when the wipe fails with nothing else touched", + "moduleOperations resetModules() the utxo-tracker volume wipe refuses the reset when the volume presence cannot be determined", + "moduleOperations resetModules() the utxo-tracker volume wipe refuses to drop the databases after a failed wipe on reset all", + "moduleOperations resetModules() the utxo-tracker volume wipe treats docker's own no-such-volume as absence and completes" + ], + "57d2979882e18339": [ + "ValidatorService validatorModeReport() calls a HALF-present state incomplete, never standalone, and names what is missing", + "ValidatorService validatorModeReport() handles missing SEED_NODES field gracefully", + "ValidatorService validatorModeReport() is incomplete with the key present and the settings gone, the other half of the pair", + "ValidatorService validatorModeReport() joins SEED_NODES array into comma-separated string", + "ValidatorService validatorModeReport() reports standalone, and still names the directory it checked", + "ValidatorService validatorModeReport() reports validator mode and the directory it resolved from", + "ValidatorService validatorModeReport() separates a DISABLED validator from one that was never configured" + ], + "57f98eb33f7521ab": [ + "CredentialsService generatePassword() accepts custom byte length", + "CredentialsService generatePassword() returns a non-empty base64url string", + "CredentialsService generatePassword() returns different values on successive calls", + "CredentialsService getCredentialsDir() returns ~/.xchain-node", + "CredentialsService getCredentialsPath() returns ~/.xchain-node/credentials.json", + "CredentialsService getOsUserDbName() returns xchain_node_", + "CredentialsService getOsUserDbName() sanitizes username with special chars", + "CredentialsService getOsUserDbName() truncates username at 60 chars", + "CredentialsService hasCredentials() returns false when existsSync throws", + "CredentialsService hasCredentials() returns false when file does not exist", + "CredentialsService hasCredentials() returns true when credentials file exists", + "CredentialsService loadCredentials() returns null when file contains null", + "CredentialsService loadCredentials() returns null when file is not valid JSON", + "CredentialsService loadCredentials() returns null when parsed object is missing password field", + "CredentialsService loadCredentials() returns null when parsed object is missing user field", + "CredentialsService loadCredentials() returns null when readFileSync throws", + "CredentialsService loadCredentials() returns parsed object with user and password", + "CredentialsService sanitizeForMariaDb() passes through alphanumeric + underscore unchanged", + "CredentialsService sanitizeForMariaDb() replaces all special chars with underscores", + "CredentialsService sanitizeForMariaDb() replaces hyphens and spaces with underscores", + "CredentialsService sanitizeForMariaDb() replaces non-alphanumeric chars (including !) with underscores", + "CredentialsService sanitizeForMariaDb() returns \"user\" for empty input (zero-length cleaned string)" + ], + "58089c71dd0e074e": [ + "[regression:p0] Deploy-checkout rollback safety keeps the checkout when the remote is unreachable", + "[regression:p0] Deploy-checkout rollback safety keeps the checkout when the requested branch is absent from the remote", + "[regression:p0] Deploy-checkout rollback safety leaves no staging or backup directories behind after a failure", + "[regression:p0] Deploy-checkout rollback safety refuses a clone over an existing checkout when rewrite is off", + "[regression:p0] Deploy-checkout rollback safety replaces the checkout with the freshly cloned source on success" + ], + "581f74e2d7477acf": [ "DockerService checkDockerInstalledAndReachable() checkBuildKitAvailable rejects with the install hint when buildx is missing", "DockerService checkDockerInstalledAndReachable() checkBuildKitAvailable resolves when docker buildx version succeeds", "DockerService checkDockerInstalledAndReachable() rejects when docker --version fails", "DockerService checkDockerInstalledAndReachable() rejects when docker --version returns unexpected format", "DockerService checkDockerInstalledAndReachable() rejects when docker ps fails (user not in docker group)", "DockerService checkDockerInstalledAndReachable() resolves true when docker --version and docker ps succeed", - "DockerService createDockerNetwork() creates network when inspect fails (network does not exist)", - "DockerService createDockerNetwork() resolves true when network already exists", - "DockerService createDockerNetwork(): network create failure rejects false when network create fails", - "DockerService execContainer() runs docker exec -i ", - "DockerService execContainer(): error branch rejects when docker exec fails", - "DockerService getDockerContainerFileCat() runs docker exec cat ", - "DockerService getDockerContainerFileCat(): error branch rejects when docker exec cat fails", - "DockerService getDockerContainerFileData() runs docker cp and reads the copied file", - "DockerService getDockerContainerFileData(): error branch rejects when docker cp fails", - "DockerService getDockerNetworkInspect() runs docker network inspect and parses JSON", - "DockerService getDockerNetworkInspect(): error branch rejects when docker network inspect fails", - "DockerService getPublishedHostPorts() ignores exposed-but-unpublished ports (no \"->\")", - "DockerService getPublishedHostPorts() maps host ports to the containers publishing them (0.0.0.0 + :::)", - "DockerService getPublishedHostPorts() parses host-IP-scoped bindings (127.0.0.1:13306->3306)", - "DockerService getPublishedHostPorts() records multiple containers contending for the same host port", - "DockerService getPublishedHostPorts() returns an empty map when docker ps fails (best-effort)", - "DockerService getPublishedHostPorts() returns an empty map when there are no running containers", - "DockerService getStatusFromContainer() rejects on exec error", - "DockerService getStatusFromContainer() runs docker inspect and returns parsed JSON", - "DockerService killContainer() runs docker kill ", - "DockerService killContainer(): error branches rejects on exec error", - "DockerService killContainer(): error branches rejects when stdout does not match container ID", - "DockerService logContainer() calls spawn with --tail and --follow for follow=true", - "DockerService logContainer() calls spawn without --follow for follow=false", - "DockerService logContainer(): TTY keypress branches Ctrl-C keypress kills child process", - "DockerService logContainer(): TTY keypress branches ESC keypress kills child process", - "DockerService logContainer(): TTY keypress branches sets up stdin raw mode and keypress handler when follow=true and isTTY=true", + "DockerService checkMemoryLimitSupport() returns null (best-effort) when docker info fails or answers nothing", + "DockerService checkMemoryLimitSupport() returns null on a host with no warnings at all", + "DockerService checkMemoryLimitSupport() returns null rather than throwing on output that is not JSON", + "DockerService checkMemoryLimitSupport() returns null when the only shortfall is swap, which is survivable", + "DockerService checkMemoryLimitSupport() returns the warning when Docker says it cannot enforce a memory limit" + ], + "582d3f8bc57f4d02": [ + "sql_safety assertSafeDbIdentifier returns a safe identifier unchanged", + "sql_safety assertSafeDbIdentifier throws naming the kind for an unsafe identifier", + "sql_safety escapeSqlStringLiteral backslash-escapes a quote and returns one quoted literal", + "sql_safety escapeSqlStringLiteral escapes the NUL, the newline and a backslash per the switch table" + ], + "58f277bb14951850": [ "DockerService probeContainerPresenceByName() reports 'exists' on a clean 64-hex id", "DockerService probeContainerPresenceByName() reports 'gone' only when docker says no such container", "DockerService probeContainerPresenceByName() reports 'unknown' on a clean exit that yielded no id", @@ -931,110 +1589,25 @@ "DockerService removeContainer() rejects on other errors", "DockerService removeContainer() rejects when stdout does not match container ID and no stderr error", "DockerService removeContainer() runs docker rm ", - "DockerService removeContainer() treats \"No such container\" as success (idempotent)", - "DockerService restartContainer() runs docker restart ", - "DockerService restartContainer(): error branches rejects when execFile errors", - "DockerService restartContainer(): error branches rejects when stdout does not match container ID", - "DockerService saveContainerLogs() rejects when spawn emits error", - "DockerService saveContainerLogs() spawns docker logs and pipes to file, resolves when output finishes", - "DockerService shellContainer() calls spawnSync with docker exec -it bash", - "DockerService startContainer() rejects on exec error", - "DockerService startContainer() rejects when stdout does not match container ID", - "DockerService startContainer() runs docker start ", - "DockerService startDockerMonitor() rejects when containerIds is empty", - "DockerService startDockerMonitor() rejects when containerIds is null", - "DockerService startDockerMonitor(): with containers does not warn and keeps the plain banner when at most MAX_CONTAINERS are requested", - "DockerService startDockerMonitor(): with containers sets up blessed UI, spawns docker logs for each container, resolves on q key", - "DockerService startDockerMonitor(): with containers spawns docker logs without -f flag when follow=false", - "DockerService startDockerMonitor(): with containers warns and names omitted containers plus labels banner \"n of N\" when more than MAX_CONTAINERS are requested", - "DockerService stopContainer() rejects when stdout does not match", - "DockerService stopContainer() runs docker stop ", - "DockerService stopContainer(): error branches rejects on exec error", - "DockerService stopContainerByName() reports a kill when the container exited 137", - "DockerService stopContainerByName() reports not stopped, and does not inspect, when docker did not confirm the stop", - "DockerService stopContainerByName() runs docker stop -t and reads the exit code after it", - "DockerService stopContainerByName() treats an unreadable exit code as clean unless the stop consumed the whole budget", - "DockerService stringToDockerContainerFile() rejects on spawn error", - "DockerService stringToDockerContainerFile() rejects when tee exits with non-zero code", - "DockerService stringToDockerContainerFile() resolves true when tee exits with code 0", - "DockerService waitContainer() rejects on error", - "DockerService waitContainer() returns non-zero exit code", - "DockerService waitContainer() runs docker wait and returns parsed exit code" - ], - "4e86adb287c39e1d": [ - "CLI `rollback` incident-path behaviour does NOT run the Docker/MariaDB precheck for a command that provisions nothing", - "CLI `rollback` incident-path behaviour does NOT take the mutating command lock, so it cannot queue behind a deploy", - "CLI `rollback` incident-path behaviour echoes the operator's own arguments back, so the guidance is copy-pasteable", - "CLI `rollback` incident-path behaviour exits NON-ZERO instead of leaving the process alive on open handles", - "CLI `rollback` incident-path behaviour leaves the precheck and the lock in place for a command that does provision", - "CLI `rollback` incident-path behaviour names the reset-and-restore recovery path as runnable commands", - "CLI `rollback` incident-path behaviour says it did nothing, so no operator reads the refusal as a completed rollback" - ], - "4fed77ea7d38ca97": [ - "ps: NODE UNREACHABLE surface reads null, an absent field, an older image and a malformed object as reachable", - "ps: NODE UNREACHABLE surface reduces an unreachable payload to since, the last answer and the duration", - "ps: NODE UNREACHABLE surface renders durations the way an operator scans them", - "ps: NODE UNREACHABLE surface the note names the last answer when there was one", - "ps: NODE UNREACHABLE surface the note says never answered, for how long, that it is not the IBD wait, and where to look", - "ps: NODE UNREACHABLE surface tolerates a missing or negative duration", - "ps: WAITING FOR NODE surface reads null, an absent field, an older image and a malformed object as not waiting", - "ps: WAITING FOR NODE surface reduces a waiting payload to the two heights and the start of the wait", - "ps: WAITING FOR NODE surface the note names the service, both heights, the remaining gap and that it resolves itself", - "ps: WAITING FOR NODE surface tolerates a missing stored height", - "ps: decoder REORG_HALT surface reads an older image with no reorg_halted field as not halted, and a cleared halt as not halted", - "ps: decoder REORG_HALT surface reduces a halted health payload", - "ps: decoder REORG_HALT surface returns null for anything that is not a payload", - "ps: decoder REORG_HALT surface the note names the coin, the meaning, the halt reason and both recoveries" + "DockerService removeContainer() treats \"No such container\" as success (idempotent)" ], - "5125fdb96876c007": [ - "EncoderMaintenanceWindow clearEncoderMaintenance() is a no-op with no encoder on this host", - "EncoderMaintenanceWindow clearEncoderMaintenance() never throws when the removal fails", - "EncoderMaintenanceWindow clearEncoderMaintenance() removes the sentinel from the encoder container", - "EncoderMaintenanceWindow declareEncoderMaintenance() falls back to a generic reason rather than writing an empty one", - "EncoderMaintenanceWindow declareEncoderMaintenance() honours an explicit window length", - "EncoderMaintenanceWindow declareEncoderMaintenance() never throws when the container lookup fails", - "EncoderMaintenanceWindow declareEncoderMaintenance() never throws when the write fails", - "EncoderMaintenanceWindow declareEncoderMaintenance() reports false and writes nothing when there is no encoder here", - "EncoderMaintenanceWindow declareEncoderMaintenance() writes a bounded, currently-open sentinel into the encoder container", - "EncoderMaintenanceWindow sentinel path defaults to the path the encoder reads" + "5b0b3cfef75bc4fd": [ + "rail leg expect resolver rejects a JSON array", + "rail leg expect resolver rejects a bad value and names its key", + "rail leg expect resolver rejects an unknown name and names it", + "rail leg expect resolver writes XC_E2E_PRICE_FEE_BATCH_LANDED independently", + "rail leg expect resolver writes XC_JSON_STRINGIFY_HOOK_EXPECT independently", + "rail leg expect resolver writes XC_VOTE_CALLBACK_BINDING_EXPECT independently", + "rail leg expect resolver writes all three values in allowlist order", + "rail leg expect resolver writes an empty file for null", + "rail leg expect resolver writes an empty file for {}", + "rail-leg.yml leg expect values keeps expect optional in the plan check", + "rail-leg.yml leg expect values resolves matrix.expect into the job environment after the R2 arms" ], - "54cd8fb7321c2356": [ - "MariaDbStore assertReady() passes once the pool is open", - "MariaDbStore assertReady() throws on an unconfigured store, naming the operation", - "MariaDbStore countModules() returns 0 when no rows", - "MariaDbStore countModules() returns 0 when pool is not ready", - "MariaDbStore countModules() returns the number of registered modules", - "MariaDbStore createDatabase() / isReady() / close() is idempotent: second createDatabase returns the existing pool", - "MariaDbStore createDatabase() / isReady() / close() opens the pool and reports ready", - "MariaDbStore createDatabase() / isReady() / close() reports not ready after close", - "MariaDbStore createDatabase() / isReady() / close() reports not ready before createDatabase", - "MariaDbStore createDatabase() / isReady() / close() throws when createDatabase is called without config", - "MariaDbStore createDatabase() connection retry retries and succeeds after transient connection failures", - "MariaDbStore createDatabase() connection retry throws after 6 failed connection attempts", - "MariaDbStore getAllModuleContainers() always includes shared modules in filtered results", - "MariaDbStore getAllModuleContainers() filters by coin and network and includes shared modules", - "MariaDbStore getAllModuleContainers() returns all entries when no filters", - "MariaDbStore getAllModuleContainers() returns empty array when no rows", - "MariaDbStore getAllModuleContainers() returns empty array when pool is not ready", - "MariaDbStore getAllModuleContainers() returns rows shaped as { module, coin, network, container_id }", - "MariaDbStore insertModuleContainer() + getModuleContainer() coerces null coin/network to empty string for keying", - "MariaDbStore insertModuleContainer() + getModuleContainer() get returns null when pool is not ready", - "MariaDbStore insertModuleContainer() + getModuleContainer() insert is a no-op (returns false) when pool is not ready", - "MariaDbStore insertModuleContainer() + getModuleContainer() keeps separate entries for different coin/network combos", - "MariaDbStore insertModuleContainer() + getModuleContainer() overwrites existing entry on re-insert", - "MariaDbStore insertModuleContainer() + getModuleContainer() returns null for non-existent key", - "MariaDbStore insertModuleContainer() + getModuleContainer() returns true on successful insert", - "MariaDbStore insertModuleContainer() + getModuleContainer() stores and retrieves a container ID", - "MariaDbStore insertModuleContainer() + getModuleContainer() stores shared modules with empty coin/network", - "MariaDbStore registry scoping by NODE_PREFIX gives a second stack its own table, so neither upsert nor purge can reach the first", - "MariaDbStore registry scoping by NODE_PREFIX keeps the bare `modules` table on the default prefix, so an existing install migrates nothing", - "MariaDbStore registry scoping by NODE_PREFIX never gives two distinct prefixes the same table, separator or length", - "MariaDbStore registry scoping by NODE_PREFIX sanitizes a prefix that is legal for docker but not for a MariaDB identifier", - "MariaDbStore removeModuleContainer() does not affect other entries", - "MariaDbStore removeModuleContainer() entry is no longer retrievable after removal", - "MariaDbStore removeModuleContainer() removes an existing entry and returns the container ID", - "MariaDbStore removeModuleContainer() returns false when pool is not ready", - "MariaDbStore removeModuleContainer() returns true for non-existent key (idempotent delete)" + "5b79af81ded16b09": [ + "AutohealService does NOT restart a container an operator stopped, whose health is frozen at unhealthy", + "AutohealService does NOT restart a healthy container", + "AutohealService does NOT restart while still inside the grace window" ], "5cc2b8143bdd5808": [ "Security: remediated dependency advisories @regression @tier4 ADV-10: the installed mariadb reports a patched runtime version", @@ -1064,43 +1637,111 @@ "Security: remediated dependency advisories @regression @tier4 ADV-4: brace-expansion survives the CVE-2026-14257 unbounded-length input", "Security: remediated dependency advisories @regression @tier4 ADV-5: the installed axios reports a patched runtime version" ], - "5cd927f17b924ef8": [ - "AutohealService --dry-run reports candidates without restarting or arming the cooldown", - "AutohealService SERVICE_HEALTHCHECK opt-in flags xchain-indexer is opted in", - "AutohealService SERVICE_HEALTHCHECK opt-in flags xchain-utxo-tracker is never opted in (deliberate stable-halt design)", - "AutohealService caps the doubled cooldown at the ceiling instead of growing without bound", - "AutohealService clears the persisted onset when the container recovers, so the next episode restarts the clock", - "AutohealService does NOT restart a container an operator stopped, whose health is frozen at unhealthy", - "AutohealService does NOT restart a healthy container", - "AutohealService does NOT restart an unhealthy container whose service is not opted in (utxo-tracker)", - "AutohealService does NOT restart the same container twice within the cooldown window", - "AutohealService does NOT restart while still inside the grace window", - "AutohealService doubles the cooldown for each restart that does not clear the wedge", - "AutohealService drops the earned backoff when retained probes show a recovery past the restart probation", - "AutohealService drops the episode onset while a container is stopped, so a restarted one gets a fresh grace window", - "AutohealService getLastHealthyProbeMs returns null on an empty or missing log", - "AutohealService getLastHealthyProbeMs returns null rather than throwing on an unparseable timestamp", - "AutohealService getLastHealthyProbeMs returns null when every retained probe failed", - "AutohealService getLastHealthyProbeMs returns the newest passing probe", - "AutohealService getUnhealthySinceMs returns null on an empty log", - "AutohealService getUnhealthySinceMs returns null when the newest probe passed", - "AutohealService getUnhealthySinceMs returns the start of the trailing failing run", - "AutohealService honors XCHAIN_NODE_AUTOHEAL_GRACE_MS override", - "AutohealService keeps the earned backoff when a pass catches the container not running", - "AutohealService keeps the earned backoff when a restarted container is still in Docker starting probation", - "AutohealService keeps the earned backoff when the only retained pass falls inside restart probation", - "AutohealService prunes persisted onsets for containers that left the registry", - "AutohealService rates a pass as recovery only when no restart can claim it or it outlasted probation", - "AutohealService reads a legacy state file that predates the unhealthySince map", - "AutohealService records a failed restart without aborting the pass and reports it", - "AutohealService refuses to run against an unconfigured module registry", - "AutohealService resets the backoff when the container recovers, so the next episode starts at the base cooldown", - "AutohealService restarts a container wedged past the grace window even though Health.Log only spans ~60s", - "AutohealService restarts again once the cooldown window has elapsed", - "AutohealService restarts an unhealthy container whose service opted in (autoheal: true)", - "AutohealService restarts the grace clock when retained probes show a recovery after the persisted onset", - "AutohealService skips (does not restart) when the health log gives no timing evidence", - "AutohealService skips a container that cannot be inspected instead of failing the pass" + "5d1c28de2f41c7b1": [ + "moduleOperations updateModules() aborts the update, leaving the running container untouched, when the guard refuses", + "moduleOperations updateModules() checks the migration precondition BEFORE the container is rebuilt", + "moduleOperations updateModules() counts a rebuilt node as updated", + "moduleOperations updateModules() fetches existing container ID before updating", + "moduleOperations updateModules() handles the node module via installModule (built from releases, not git-cloned)", + "moduleOperations updateModules() leaves the running node container to buildCryptoNode instead of force-removing it up front", + "moduleOperations updateModules() passes container ID to installModule for replacement", + "moduleOperations updateModules() rebuilds non-node modules via installModule (which re-clones internally)", + "moduleOperations updateModules() rebuilds the image, unlike recreate", + "moduleOperations updateModules() records a declined NODE rebuild as a no-op too", + "moduleOperations updateModules() records a module installModule declined as a no-op, not as updated", + "moduleOperations updateModules() recreates the node even when its container is missing (no silent no-op)", + "moduleOperations updateModules() refuses the database instead of reporting an untouched container as updated", + "moduleOperations updateModules() reports an uninstalled module as SKIPPED (empty updated list), and warns about it", + "moduleOperations updateModules() reports what it updated so a no-op run is not indistinguishable from success", + "moduleOperations updateModules() still skips a NON-node module whose container is missing", + "moduleOperations updateModules() still updates the other requested modules when the request also names the database" + ], + "5db8daf74cc46607": [ + "Chaos: Docker Resilience Experiment 4: Docker build failure does not store container ID on build failure", + "Chaos: Docker Resilience Experiment 4: Docker build failure rejects with error message when docker build fails" + ], + "5e4c10931f801551": [ + "injected logger seams preserves injected object loggers without consulting the default adapter", + "injected logger seams preserves the stake and unstake deps.log callback seam", + "release signature adapter routes the unsigned-release fallback through getLogger().warn", + "self update adapter routes self-update progress, warning and spawn failure through getLogger()", + "self update adapter routes the newer-release notice through getLogger().info", + "stake default logger routes stake and unstake defaults through getLogger().info" + ], + "61281c3139eea8a1": [ + "E2E: Configuration Overrides (Scenario 4.4) E2E-020: Default config env vars encoder on mainnet gets NODE_PORT=8332", + "E2E: Configuration Overrides (Scenario 4.4) E2E-020: Default config env vars encoder on regtest gets NODE_PORT=18444 and ENCODER_API_PORT=3003", + "E2E: Configuration Overrides (Scenario 4.4) E2E-020: Default config env vars encoder on testnet gets NODE_PORT=18332", + "E2E: Configuration Overrides (Scenario 4.4) E2E-021: Config file overrides DECODER_PORT override flows to docker run", + "E2E: Configuration Overrides (Scenario 4.4) E2E-021: Config file overrides ENCODER_PORT override flows to docker run port mapping", + "E2E: Configuration Overrides (Scenario 4.4) E2E-021: Config file overrides config file values take precedence over defaults", + "E2E: Configuration Overrides (Scenario 4.4) E2E-021: Config file overrides unspecified values fall back to defaults", + "E2E: Configuration Overrides (Scenario 4.4) E2E-022: Regtest-specific config values mainnet does NOT include REGTEST_MINER_URL", + "E2E: Configuration Overrides (Scenario 4.4) E2E-022: Regtest-specific config values regtest includes REGTEST_MINER_URL", + "E2E: Configuration Overrides (Scenario 4.4) E2E-023: Database naming follows convention DB user follows xchain___ pattern", + "E2E: Configuration Overrides (Scenario 4.4) E2E-023: Database naming follows convention decoder bitcoin/mainnet → XChain_BTC_Mainnet_Decoder", + "E2E: Configuration Overrides (Scenario 4.4) E2E-023: Database naming follows convention decoder litecoin/regtest → XChain_LTC_Regtest_Decoder", + "E2E: Configuration Overrides (Scenario 4.4) E2E-023: Database naming follows convention indexer dogecoin/testnet → XChain_DOGE_Testnet_Indexer" + ], + "616adeca1c559bb1": [ + "Integration: Database Service Chain addUserPasswordToDatabase creates database, user, and grants for decoder", + "Integration: Database Service Chain addUserPasswordToDatabase uses \"%\" as the grant host regardless of the Docker network gateway" + ], + "61b150a62d838628": [ + "ExplorerService: installExplorerModule() already running returns true immediately when ping succeeds and force=false", + "ExplorerService: installExplorerModule() already running returns true when status is cached and explorer is in lastStatus (force=false)" + ], + "62a678730fd646c7": [ + "DockerService startDockerMonitor(): with containers does not warn and keeps the plain banner when at most MAX_CONTAINERS are requested", + "DockerService startDockerMonitor(): with containers sets up blessed UI, spawns docker logs for each container, resolves on q key", + "DockerService startDockerMonitor(): with containers spawns docker logs without -f flag when follow=false", + "DockerService startDockerMonitor(): with containers warns and names omitted containers plus labels banner \"n of N\" when more than MAX_CONTAINERS are requested" + ], + "62f79f2877e9a543": [ + "Integration: Hub/Explorer Config Update updateHubOrExplorer for explorer writes config.json to explorer container via docker exec" + ], + "63034e15e659f6bb": [ + "ModuleService buildAndUp() healthcheck args does not warn and returns health args when the portKey is present", + "ModuleService buildAndUp() healthcheck args grants sync a start period covering the whole MAX_HUB_WAIT_MS hub wait", + "ModuleService buildAndUp() healthcheck args honors XCHAIN_NODE_HEALTH_START_PERIOD_ override", + "ModuleService buildAndUp() healthcheck args includes --health-cmd for decoder (http_get probe on /live, not /status)", + "ModuleService buildAndUp() healthcheck args includes --health-cmd for encoder (http_get probe)", + "ModuleService buildAndUp() healthcheck args includes --health-cmd for explorer (jsonrpc_ping probe)", + "ModuleService buildAndUp() healthcheck args includes --health-cmd for indexer (http_get probe)", + "ModuleService buildAndUp() healthcheck args probes /health (not /status) for sync (http_get probe)", + "ModuleService buildAndUp() healthcheck args probes health (not ping) for hub (jsonrpc_health probe)", + "ModuleService buildAndUp() healthcheck args probes health (not ping) for regtest-miner (jsonrpc_health probe)", + "ModuleService buildAndUp() healthcheck args warns and returns [] when the descriptor portKey is missing from the env", + "ModuleService buildAndUp() hub credential ordering does not touch the hub account for a one-shot execution container", + "ModuleService buildAndUp() hub credential ordering does not touch the hub account for any other module", + "ModuleService buildAndUp() hub credential ordering rotates the grant even when the config push then fails", + "ModuleService buildAndUp() hub credential ordering rotates the hub grant BEFORE the config push that needs the hub to answer", + "ModuleService buildAndUp() reuseImage creates the container without running docker build", + "ModuleService buildAndUp() reuseImage does not re-clone the bundled libraries", + "ModuleService buildAndUp() reuseImage leaves the default path building the image", + "ModuleService buildAndUp() reuseImage still tears the old container down and registers the new id", + "ModuleService buildAndUp() reuseImage verifies the tag exists first, and says so when it does not" + ], + "6442e5713907c431": [ + "Integration: Module Lifecycle (LevelDB state) LevelDB key format and CRUD deleteModuleContainer deletes the entry", + "Integration: Module Lifecycle (LevelDB state) LevelDB key format and CRUD getAllModuleContainers always returns shared services (empty coin/network)", + "Integration: Module Lifecycle (LevelDB state) LevelDB key format and CRUD getAllModuleContainers returns all entries for a coin/network", + "Integration: Module Lifecycle (LevelDB state) LevelDB key format and CRUD getModuleContainer returns null for non-existent key", + "Integration: Module Lifecycle (LevelDB state) LevelDB key format and CRUD setModuleContainer overwrites existing entry", + "Integration: Module Lifecycle (LevelDB state) LevelDB key format and CRUD setModuleContainer stores and getModuleContainer retrieves by MC key", + "Integration: Module Lifecycle (LevelDB state) moduleOperations uses LevelDB for container lookups execModules reads container ID and executes command", + "Integration: Module Lifecycle (LevelDB state) moduleOperations uses LevelDB for container lookups restartModules reads IDs and calls docker restart", + "Integration: Module Lifecycle (LevelDB state) moduleOperations uses LevelDB for container lookups startModules gracefully handles missing LevelDB entry", + "Integration: Module Lifecycle (LevelDB state) moduleOperations uses LevelDB for container lookups startModules reads container IDs from LevelDB and calls docker start", + "Integration: Module Lifecycle (LevelDB state) moduleOperations uses LevelDB for container lookups stopModules handles multiple modules across coin/networks", + "Integration: Module Lifecycle (LevelDB state) moduleOperations uses LevelDB for container lookups stopModules reads container IDs from LevelDB and calls docker stop", + "Integration: Module Lifecycle (LevelDB state) multi-module state isolation same module on different coin/network combos are independent", + "Integration: Module Lifecycle (LevelDB state) multi-module state isolation three modules on same coin/network have independent entries" + ], + "645789d933f8115c": [ + "preCheck: the CLI presents the sidecar HUB_API_KEY to the hub @regression hydrates process.env from the sidecar before the hub is installed or pushed to", + "preCheck: the CLI presents the sidecar HUB_API_KEY to the hub @regression is a no-op on a host with no sidecar (a standalone install stays keyless)", + "preCheck: the CLI presents the sidecar HUB_API_KEY to the hub @regression runs the hydration through the real sidecar reader with a host-env key left untouched" ], "66a744498ed159be": [ "SkewGuardService assertHubNotBehind REFUSES (fail-closed) when the hub is installed but its version is unreadable", @@ -1120,6 +1761,112 @@ "SkewGuardService getRequiredHubVersion returns the trimmed version when declared", "SkewGuardService getRequiredHubVersion throws on a non-version value (range syntax not supported)" ], + "678cb9bc8dc99e4d": [ + "AutohealService honors XCHAIN_NODE_AUTOHEAL_GRACE_MS override" + ], + "67acfa55737fd29a": [ + "Chaos: Download Resilience Experiment: Download cleanup on failure throws when hash is required but not found for version" + ], + "68322c4def9bb3be": [ + "rail leg publisher funding wiring calls the funding script exactly once with an LTC stack beside them", + "rail leg publisher funding wiring funds the publisher after the DOGE stack and before the BTC stack", + "rail leg publisher funding wiring names a script that exists and exits quietly with no anchor arm set" + ], + "695dc8c07f6e66ae": [ + "MariaDbStore getModuleCount() returns 0 when no rows", + "MariaDbStore getModuleCount() returns 0 when pool is not ready", + "MariaDbStore getModuleCount() returns the number of registered modules" + ], + "6bd3d7c12a20c4eb": [ + "E2E: Install Lifecycle (Scenarios 4.1, 4.3) E2E-001: Full install all services for bitcoin/regtest creates Docker network, builds and runs all modules, stores IDs in LevelDB", + "E2E: Install Lifecycle (Scenarios 4.1, 4.3) E2E-001: Full install all services for bitcoin/regtest database container is stored with empty coin/network (shared service)", + "E2E: Install Lifecycle (Scenarios 4.1, 4.3) E2E-001: Full install all services for bitcoin/regtest docker build commands use correct container image names", + "E2E: Install Lifecycle (Scenarios 4.1, 4.3) E2E-001: Full install all services for bitcoin/regtest docker run commands include correct hostnames and networks", + "E2E: Install Lifecycle (Scenarios 4.1, 4.3) E2E-001: Full install all services for bitcoin/regtest stores bitcoin/regtest as the coin/network for each module", + "E2E: Install Lifecycle (Scenarios 4.1, 4.3) E2E-002: Stop all services LevelDB entries are preserved after stop (containers not removed)", + "E2E: Install Lifecycle (Scenarios 4.1, 4.3) E2E-002: Stop all services calls docker stop for each container stored in LevelDB", + "E2E: Install Lifecycle (Scenarios 4.1, 4.3) E2E-003: Start all services calls docker start for each container stored in LevelDB", + "E2E: Install Lifecycle (Scenarios 4.1, 4.3) E2E-004: Uninstall all services removes coin-specific LevelDB entries after uninstall", + "E2E: Install Lifecycle (Scenarios 4.1, 4.3) E2E-004: Uninstall all services shared services (database, hub, explorer) are preserved unless --include-shared", + "E2E: Install Lifecycle (Scenarios 4.1, 4.3) E2E-005: Selective install (decoder only) docker build called only for decoder, not other services", + "E2E: Install Lifecycle (Scenarios 4.1, 4.3) E2E-005: Selective install (decoder only) installs only decoder, database, hub, and explorer", + "E2E: Install Lifecycle (Scenarios 4.1, 4.3) E2E-006: Full lifecycle sequence install → stop → start → uninstall all succeed in sequence" + ], + "6befe4bcc41421e4": [ + "rail follower hash comparison CLI exits one when the control height is missing", + "rail follower hash comparison CLI exits two for a malformed file", + "rail follower hash comparison CLI exits two for a missing flag", + "rail follower hash comparison CLI injects a control difference without mutating its input", + "rail follower hash comparison CLI rejects a bad arm height", + "rail follower hash comparison builds the four-join hash query", + "rail follower hash comparison edge cases compares string block indexes to numeric heights", + "rail follower hash comparison edge cases marks a one-sided undefined actions_hash missing", + "rail follower hash comparison edge cases marks a one-sided undefined contract_hash missing", + "rail follower hash comparison edge cases marks a one-sided undefined ledger_hash missing", + "rail follower hash comparison edge cases marks a one-sided undefined state_hash missing", + "rail follower hash comparison edge cases marks an absent height missing", + "rail follower hash comparison edge cases marks an all-null side missing", + "rail follower hash comparison edge cases marks identical rows with a null actions_hash missing", + "rail follower hash comparison edge cases marks identical rows with a null contract_hash missing", + "rail follower hash comparison edge cases marks identical rows with a null ledger_hash missing", + "rail follower hash comparison edge cases marks identical rows with a null state_hash missing", + "rail follower hash comparison matches hashes at all three armed heights", + "rail follower hash comparison names one differing hash field" + ], + "6c7dbd2ae98e853b": [ + "ValidatorService getValidatorSettings() returns null when not initialized", + "ValidatorService getValidatorSettings() returns null when readFileSync throws", + "ValidatorService getValidatorSettings() returns null when settings JSON is invalid", + "ValidatorService getValidatorSettings() returns null when settings.enabled is false", + "ValidatorService getValidatorSettings() returns settings when initialized and enabled=true" + ], + "6cf7aae9519b9d5c": [ + "Chaos: Docker Resilience Experiment: Operations on non-existent containers rejects when killing a non-existent container", + "Chaos: Docker Resilience Experiment: Operations on non-existent containers rejects when removing a non-existent container", + "Chaos: Docker Resilience Experiment: Operations on non-existent containers rejects when restarting a non-existent container", + "Chaos: Docker Resilience Experiment: Operations on non-existent containers rejects when stopping a non-existent container" + ], + "6d01b9f2dc998281": [ + "DockerService execContainer() runs docker exec -i ", + "DockerService getDockerNetworkInspect() runs docker network inspect and parses JSON" + ], + "6d7114a33630b804": [ + "ValidatorService coin wallets --network names the federation and picks the matching port", + "ValidatorService coin wallets --oracle-epoch-start still overrides the federation default", + "ValidatorService coin wallets defaults ORACLE_EPOCH_START to the mainnet federation value", + "ValidatorService coin wallets defaults ORACLE_EPOCH_START to the testnet federation value", + "ValidatorService coin wallets fills oracle_publish in the fresh capabilities file from the DOGE wallet", + "ValidatorService coin wallets generates a testnet stake wallet and a testnet DOGE wallet for port 10002", + "ValidatorService coin wallets leaves ORACLE_EPOCH_START null when the network is unknown", + "ValidatorService coin wallets points the signer at the complete public encoder URL for every network", + "ValidatorService coin wallets rejects an unknown --network", + "ValidatorService coin wallets writes wallets.env and the signer .env with mode 0600, and the signer module beside it", + "ValidatorService regtest initialization recognizes P2P port 10003 as regtest without federation seeds", + "ValidatorService regtest initialization records regtest on its local port without mainnet federation seeds" + ], + "6e05f9dad9987bd7": [ + "Chaos: Config Resilience Experiment: Port validation under chaos rejects Infinity", + "Chaos: Config Resilience Experiment: Port validation under chaos rejects NaN", + "Chaos: Config Resilience Experiment: Port validation under chaos rejects float values", + "Chaos: Config Resilience Experiment: Port validation under chaos rejects negative ports", + "Chaos: Config Resilience Experiment: Port validation under chaos rejects non-numeric strings", + "Chaos: Config Resilience Experiment: Port validation under chaos rejects null/undefined/objects", + "Chaos: Config Resilience Experiment: Port validation under chaos rejects port 0", + "Chaos: Config Resilience Experiment: Port validation under chaos rejects ports above 65535" + ], + "6e1a01cbd58d312a": [ + "DatabaseService clearHubPriceIngestWatermark() a hub database shared with another network clears the legacy unset bucket alongside the network row", + "DatabaseService clearHubPriceIngestWatermark() a hub database shared with another network folds the reset network case- and whitespace-insensitively, matching the hub", + "DatabaseService clearHubPriceIngestWatermark() a hub database shared with another network issues a different WHERE clause per network for the same chain", + "DatabaseService clearHubPriceIngestWatermark() a hub database shared with another network no longer refuses on a hub configured for another network", + "DatabaseService clearHubPriceIngestWatermark() a hub database shared with another network scopes the delete to the network being reset, leaving every other network's fence", + "DatabaseService clearHubPriceIngestWatermark() deletes only the reset chain's fence row from the hub DB", + "DatabaseService clearHubPriceIngestWatermark() goes through the native driver in EXTERNAL_DB mode", + "DatabaseService clearHubPriceIngestWatermark() prints the manual statement and returns false when this MariaDB has no hub table", + "DatabaseService clearHubPriceIngestWatermark() refuses a hub DB name that is not a safe identifier", + "DatabaseService clearHubPriceIngestWatermark() throws on an unknown coin rather than deleting nothing and reporting success", + "DatabaseService clearHubPriceIngestWatermark() warns instead of throwing when the hub config carries no HUB_DB_NAME" + ], "6e3e97a6bec46f56": [ "ConfigService hub network and BTC indexer composition BTC_INDEXER_API_URL composes the co-located BTC indexer for the hub network", "ConfigService hub network and BTC indexer composition BTC_INDEXER_API_URL composes the indexer the running install is about to create", @@ -1134,7 +1881,150 @@ "ConfigService hub network and BTC indexer composition HUB_NETWORK prefers the registered stacks over the command tokens", "ConfigService hub network and BTC indexer composition HUB_NETWORK takes the network the running install names when no stack is registered yet" ], - "774bd66498878fa9": [ + "6f0b8ea924793400": [ + "DbCredentialDrift isDbCredentialDriftError separates the drift refusal from an unrelated failure" + ], + "6fbe6a70deeed73b": [ + "NodeService: getCryptoNode() downloads bitcoin node for x64 arch, decompresses and renames", + "NodeService: getCryptoNode() downloads dogecoin node via gitHubDownloader", + "NodeService: getCryptoNode() downloads litecoin node via gitHubDownloader", + "NodeService: getCryptoNode() rejects (and never decompresses) when the bitcoin tarball hash mismatches", + "NodeService: getCryptoNode() rejects on a non-200 download response", + "NodeService: getCryptoNode() rejects when decompressTarGz fails", + "NodeService: getCryptoNode() strips leading \"v\" from bitcoin version string", + "NodeService: getCryptoNode() throws for an unsupported coin", + "NodeService: getCryptoNode() throws on unsupported architecture", + "NodeService: getCryptoNode() uses rmSync when node version >= 14.14.0 and old bitcoin dir exists", + "NodeService: getCryptoNode() verifies the downloaded bitcoin tarball before decompressing" + ], + "701a6f07eaa918d0": [ + "Integration: Database Service Chain setDatabaseParameters creates users for all installed decoders and indexers" + ], + "70708eb35b723d7a": [ + "cli maybeSelfUpdateBeforeUpdate() does not move the CLI for a branch update", + "cli maybeSelfUpdateBeforeUpdate() does not move the CLI on a branch node", + "cli maybeSelfUpdateBeforeUpdate() does nothing inside the re-executed child", + "cli maybeSelfUpdateBeforeUpdate() does nothing when self-update is switched off", + "cli maybeSelfUpdateBeforeUpdate() hands the resolved tag on when the CLI is already there, so the action does not look it up again", + "cli maybeSelfUpdateBeforeUpdate() leaves an unparseable invocation for the action to report", + "cli maybeSelfUpdateBeforeUpdate() lets a lookup failure propagate as the update failure", + "cli maybeSelfUpdateBeforeUpdate() serializes on the command lock and hands it to the move as the pre-spawn release", + "cli maybeSelfUpdateBeforeUpdate() takes the self-update lock with the same wait every mutator honours", + "cli maybeSelfUpdateBeforeUpdate() targets the latest release for a no-ref update on a release node", + "cli maybeSelfUpdateBeforeUpdate() targets the named release and keeps the operator's scope in the child command" + ], + "7141a5716344fb9c": [ + "AutohealService restarts the grace clock when retained probes show a recovery after the persisted onset" + ], + "7167d0becc0b4237": [ + "halt marker exports and live SQL builders builds the live reorg halt query", + "halt marker exports and live SQL builders builds the live sync halt query", + "halt marker exports and live SQL builders builds the marker table discovery query", + "halt marker exports and live SQL builders exports exactly the supported builders", + "halt marker watermark SQL builders builds the events watermark query", + "halt marker watermark SQL builders builds the reorg halt query after a watermark", + "halt marker watermark SQL builders builds the sync halt query after a watermark", + "halt marker watermark SQL builders builds the sync halt watermark query" + ], + "718e1e0ef8441e60": [ + "moduleOperations updateModules(): the `all` expansion collapses the absent services into one line under `all`, and keeps the per-service warning for a targeted update", + "moduleOperations updateModules(): the `all` expansion continues the hub update when the validator repair fails, and says so", + "moduleOperations updateModules(): the `all` expansion does not install a coin node that is absent under `all`, but still recreates one on a targeted update", + "moduleOperations updateModules(): the `all` expansion does not touch validator config on a node that is not a validator, or when the hub is out of scope", + "moduleOperations updateModules(): the `all` expansion leaves a coin node running when it already carries the pinned daemon version", + "moduleOperations updateModules(): the `all` expansion leaves the hub and sync out of a targeted update, as before", + "moduleOperations updateModules(): the `all` expansion re-runs the validator repair before rebuilding the hub on an initialized validator", + "moduleOperations updateModules(): the `all` expansion rebuilds a coin node on a TARGETED update whatever version it runs", + "moduleOperations updateModules(): the `all` expansion rebuilds a coin node whose pinned version it cannot determine", + "moduleOperations updateModules(): the `all` expansion reports a shared service that is not installed as skipped, not as a failure", + "moduleOperations updateModules(): the `all` expansion still rebuilds a coin node that is behind the pinned daemon version", + "moduleOperations updateModules(): the `all` expansion updates the hub, then sync, then the explorer, before any coin stack" + ], + "73eac28b1927f020": [ + "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard does not rewrite the sidecar when it already holds the same value", + "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard falls back to the persisted sidecar value when the env var is absent", + "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard persists the env value to the config/node.local sidecar", + "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard refuses to replace a container whose bind mounts the new spec would drop", + "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard replaces normally when the new spec keeps every existing bind mount", + "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard stop budget and its outcome falls back to the default on a value that is not a whole number of seconds, and says so", + "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard stop budget and its outcome reports a clean stop with the time it took and the budget", + "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard stop budget and its outcome says nothing about the stop when there was no previous daemon", + "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard stop budget and its outcome takes the budget from XCHAIN_NODE_STOP_TIMEOUT_SECONDS and stamps it on the new container", + "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard stop budget and its outcome warns when the daemon exited non-zero inside the budget instead of calling it clean", + "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard stop budget and its outcome warns when the daemon ran out of budget and was killed, naming the override", + "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard stops the previous daemon gracefully, with a flush budget, before force-removing it", + "NodeService: buildCryptoNode() blocks-dir persistence + mount-drift guard treats an existing symlink at the blocks host path as provisioned (no mkdir)" + ], + "73eee5e34f3d25a8": [ + "DockerService stopContainer(): error branches rejects on exec error" + ], + "7705d14ae3ffca27": [ + "Chaos: Process Resilience Experiment: Multi-step operation error propagation cloneGit error prevents buildAndUp from running" + ], + "7709742b49b662fb": [ + "DatabaseService checkIfDatabaseModuleExists() returns container ID when database container exists and is valid", + "DatabaseService checkIfDatabaseModuleExists() returns null when container does not have State.Status", + "DatabaseService checkIfDatabaseModuleExists() returns null when docker inspect fails", + "DatabaseService checkIfDatabaseModuleExists() returns null when getStatusFromContainer throws", + "DatabaseService executeDockerMariaDbCommand() appends commandOptions when provided", + "DatabaseService executeDockerMariaDbCommand() pipes SQL via stdin (never argv) with the password in env, not argv", + "DatabaseService executeDockerMariaDbCommand() rejects on non-zero exit", + "DatabaseService executeDockerMariaDbCommand() scrubs the SQL from a failed command (avoids leaking an embedded password)", + "DatabaseService executeNativeMariaDbCommand() scrubs the SQL from a driver query error (avoids leaking an embedded password)" + ], + "77345bb5d35c1fac": [ + "Chaos: Download Resilience Experiment 8f: Hashes file corruption throws on invalid JSON in hashes file", + "Chaos: Download Resilience Experiment 8f: Hashes file corruption throws on invalid hash format (too short)" + ], + "78a3bf2e7afee8ba": [ + "DockerService killContainer() runs docker kill ", + "DockerService restartContainer() runs docker restart " + ], + "78db390dade7aa08": [ + "ModuleService buildAndUp() calls statusChanged after storing container ID", + "ModuleService buildAndUp() caps the tracker with --memory and an equal --memory-swap", + "ModuleService buildAndUp() create narration names the chain in the memory note", + "ModuleService buildAndUp() create narration surfaces what a SUCCESSFUL docker run wrote to stderr", + "ModuleService buildAndUp() includes --network flag in docker run", + "ModuleService buildAndUp() includes port mapping for encoder", + "ModuleService buildAndUp() includes ulimit for utxo-tracker", + "ModuleService buildAndUp() includes volume mount for decoder", + "ModuleService buildAndUp() keeps the legacy tracker volume name under the default NODE_PREFIX", + "ModuleService buildAndUp() kills and removes old container when overwriteContainerId is provided", + "ModuleService buildAndUp() memory limit readback does not read anything back for a module that asked for no cap", + "ModuleService buildAndUp() memory limit readback reads the new container back and says nothing when the cap stuck", + "ModuleService buildAndUp() memory limit readback warns loudly, and does NOT fail the create, when docker kept no limit", + "ModuleService buildAndUp() memory limit readback warns when a limit was kept but is not the one that was asked for", + "ModuleService buildAndUp() passes environment variables via the child env (bare --env NAME), not as argv values", + "ModuleService buildAndUp() prefixes the tracker volume name under a non-default NODE_PREFIX (F11)", + "ModuleService buildAndUp() runs docker build with correct image name and cwd", + "ModuleService buildAndUp() stores container ID in LevelDB on success", + "ModuleService buildAndUp() throws when module does not exist" + ], + "78efb8bd8fd832cc": [ + "DatabaseService ensureXchainNodeAccess(): EXTERNAL_DB path provisions fresh creds when no existing credentials stored", + "DatabaseService ensureXchainNodeAccess(): EXTERNAL_DB path reprovisions via native commands when existing creds fail on external MariaDB", + "DatabaseService ensureXchainNodeAccess(): EXTERNAL_DB path returns existing creds when they work against external MariaDB" + ], + "7a9a45411521e34f": [ + "NodeService: node version pin (XCHAIN_NODE_NODE_VERSION_) assertNodeVersionPin throws on a mismatched local daemon", + "NodeService: node version pin (XCHAIN_NODE_NODE_VERSION_) installNode downloads the pinned version, bypassing remote-latest lookup", + "NodeService: node version pin (XCHAIN_NODE_NODE_VERSION_) installNode fails loudly when a cached local daemon mismatches the pin", + "NodeService: node version pin (XCHAIN_NODE_NODE_VERSION_) resolveNodeVersionPin returns null when env unset or blank", + "NodeService: node version pin (XCHAIN_NODE_NODE_VERSION_) resolveNodeVersionPin returns the trimmed pin for the coin" + ], + "7b146e00b4342dfd": [ + "AutohealService keeps the earned backoff when a pass catches the container not running" + ], + "7bb2fe8765cc4426": [ + "E2E: PreCheck Pipeline (Scenario 4.5) E2E-030: Directory creation and LevelDB init creates data, modules, tmp, and containers_files directories", + "E2E: PreCheck Pipeline (Scenario 4.5) E2E-031: Docker verification calls docker --version and docker ps during precheck", + "E2E: PreCheck Pipeline (Scenario 4.5) E2E-032: Hub auto-install calls installHubModule during precheck", + "E2E: PreCheck Pipeline (Scenario 4.5) E2E-033: Docker unreachable throws descriptive error throws when docker --version fails", + "E2E: PreCheck Pipeline (Scenario 4.5) E2E-033: Docker unreachable throws descriptive error throws when docker ps fails (daemon not running)", + "E2E: PreCheck Pipeline (Scenario 4.5) E2E-034: Base Docker network creation creates the xchain-node base network" + ], + "7cccd4dc81f793a1": [ "ReleaseManifestService getComponentPin() THROWS when a pin has a malformed commit", "ReleaseManifestService getComponentPin() THROWS when a pin has no commit", "ReleaseManifestService getComponentPin() returns null for a component the manifest does not carry", @@ -1164,353 +2054,469 @@ "ReleaseManifestService resolveComponentRef() returns the fallback ref when the active install is a branch install", "ReleaseManifestService resolveComponentRef() returns the fallback ref, unpinned, when no install is active", "ReleaseManifestService resolveComponentRef() returns the manifest pin during a release install", - "ReleaseManifestService resolveInstallTarget() classifies a branch ref without touching the network", - "ReleaseManifestService resolveInstallTarget() falls back to the default branch when NO release exists (pre-first-train)", - "ReleaseManifestService resolveInstallTarget() falls back to the default branch when the lookup fails outright", - "ReleaseManifestService resolveInstallTarget() loads the manifest for an explicit release ref", - "ReleaseManifestService resolveInstallTarget() prefers the LOCAL manifest when the running checkout is that release", - "ReleaseManifestService resolveInstallTarget() refuses to fall back to a branch when told not to and no release exists", - "ReleaseManifestService resolveInstallTarget() refuses to fall back to a branch when told not to and the lookup fails", - "ReleaseManifestService resolveInstallTarget() rejects a manifest that is not valid JSON", - "ReleaseManifestService resolveInstallTarget() reports a release ref that has no manifest, instead of installing tips", - "ReleaseManifestService resolveInstallTarget() resolves the latest release when no ref is given", "ReleaseManifestService resolveLatestReleaseTag() propagates a non-404 failure rather than reporting \"no release\"", "ReleaseManifestService resolveLatestReleaseTag() returns null (not an error) when no release has been published yet", - "ReleaseManifestService resolveLatestReleaseTag() returns the tag from releases/latest", - "ReleaseManifestService the provenance gate a refusal from the gate aborts the install", - "ReleaseManifestService the provenance gate fetchReleaseAsset downloads the asset bytes", - "ReleaseManifestService the provenance gate fetchReleaseAsset follows the storage redirect WITHOUT the credentials", - "ReleaseManifestService the provenance gate fetchReleaseAsset returns null when the release carries no such asset", - "ReleaseManifestService the provenance gate fetchReleaseAsset returns null when the release itself is absent", - "ReleaseManifestService the provenance gate verifies the fetched manifest BEFORE parsing it", - "ReleaseManifestService the shipped manifest declares a platform version and a release date", - "ReleaseManifestService the shipped manifest does not pin xchain-node itself", - "ReleaseManifestService the shipped manifest pins every component to a tag and a full 40-hex commit", - "ReleaseManifestService the shipped manifest pins no component above the platform version it ships in" + "ReleaseManifestService resolveLatestReleaseTag() returns the tag from releases/latest" ], - "79cc27613f3e7980": [ - "cli maybeSelfUpdateBeforeUpdate() does not move the CLI for a branch update", - "cli maybeSelfUpdateBeforeUpdate() does not move the CLI on a branch node", - "cli maybeSelfUpdateBeforeUpdate() does nothing inside the re-executed child", - "cli maybeSelfUpdateBeforeUpdate() does nothing when self-update is switched off", - "cli maybeSelfUpdateBeforeUpdate() hands the resolved tag on when the CLI is already there, so the action does not look it up again", - "cli maybeSelfUpdateBeforeUpdate() leaves an unparseable invocation for the action to report", - "cli maybeSelfUpdateBeforeUpdate() lets a lookup failure propagate as the update failure", - "cli maybeSelfUpdateBeforeUpdate() serializes on the command lock and hands it to the move as the pre-spawn release", - "cli maybeSelfUpdateBeforeUpdate() targets the latest release for a no-ref update on a release node", - "cli maybeSelfUpdateBeforeUpdate() targets the named release and keeps the operator's scope in the child command" + "7cebdb1d5a480c84": [ + "AutohealService drops the earned backoff when retained probes show a recovery past the restart probation" ], - "82171a751987af6e": [ - "nine-field FILE scan (pre-window gate) BATCH unwrapping a clean BATCH is clean", - "nine-field FILE scan (pre-window gate) BATCH unwrapping finds a nine-field FILE inside a BATCH", - "nine-field FILE scan (pre-window gate) BATCH unwrapping splits every command, not just the first", - "nine-field FILE scan (pre-window gate) BATCH unwrapping tolerates a trailing separator without inventing an empty command", - "nine-field FILE scan (pre-window gate) scanning rows counts what it scanned and reports every hit with its location", - "nine-field FILE scan (pre-window gate) scanning rows handles no rows at all", - "nine-field FILE scan (pre-window gate) scanning rows survives null and empty payloads", - "nine-field FILE scan (pre-window gate) the gate report counts only payload-bearing rows as the corpus", - "nine-field FILE scan (pre-window gate) the gate report distinguishes clean-by-emptiness from a scan that examined rows", - "nine-field FILE scan (pre-window gate) the gate report names every hit and refuses to let it pass as a footnote", - "nine-field FILE scan (pre-window gate) the gate report says CLEAN in a way that states what was established", - "nine-field FILE scan (pre-window gate) the gate report says so when the corpus was not measured, rather than implying a full scan", - "nine-field FILE scan (pre-window gate) the query reads the RAW payload, which is the whole point filters on the raw payload prefix and pages by keyset", - "nine-field FILE scan (pre-window gate) the query reads the RAW payload, which is the whole point selects the decoder payload column and never a parsed params table", - "nine-field FILE scan (pre-window gate) what is a hit does NOT flag a non-gated FILE with trailing fields stripped", - "nine-field FILE scan (pre-window gate) what is a hit does NOT flag a trailing EMPTY ninth field", - "nine-field FILE scan (pre-window gate) what is a hit does NOT flag the ordinary eight-field form", - "nine-field FILE scan (pre-window gate) what is a hit flags a FILE carrying a non-empty ninth field", - "nine-field FILE scan (pre-window gate) what is a hit flags a TENTH field even when the ninth is empty, because the wire is malformed", - "nine-field FILE scan (pre-window gate) what is a hit ignores actions that are not FILE", - "nine-field FILE scan (pre-window gate) what is a hit is case- and whitespace-tolerant on the action name" + "7d9da93b27a0235d": [ + "DatabaseService buildDatabaseModule() aborts the install (no docker run) when a host-port conflict is detected", + "DatabaseService buildDatabaseModule() appends MariaDB tuning args to docker run when env vars are set", + "DatabaseService buildDatabaseModule() defaults max-connections to 1000 when the env var is unset (multi-chain saturation guard)", + "DatabaseService buildDatabaseModule() does not enroll the DB in any restart path", + "DatabaseService buildDatabaseModule() fails fast with an actionable error when the existing DB container is not running", + "DatabaseService buildDatabaseModule() force-removes only after docker positively reports the container gone", + "DatabaseService buildDatabaseModule() gives the DB container a health probe so a stalled mariadbd is visible", + "DatabaseService buildDatabaseModule() installs mariadb when no existing database found", + "DatabaseService buildDatabaseModule() omits MariaDB tuning args when env vars are unset", + "DatabaseService buildDatabaseModule() passes MYSQL_ROOT_PASSWORD via docker env, never in the argv", + "DatabaseService buildDatabaseModule() refuses to force-remove the MariaDB container when the probe says 'exists'", + "DatabaseService buildDatabaseModule() refuses to force-remove the MariaDB container when the probe says 'unknown'", + "DatabaseService buildDatabaseModule() runs the multi-stack host-port pre-flight before docker run", + "DatabaseService buildDatabaseModule() skips installation when database already exists and connects to network", + "DatabaseService buildDatabaseModule() throws instead of returning undefined when docker run output is not a 64-hex id" ], - "842ea67df5e152c0": [ - "TelemetryService.gatherModules() (via gatherPayload) adds a null-safe `health` field and leaves `running` a pure liveness signal", - "TelemetryService.gatherModules() (via gatherPayload) never throws a TypeError when status shape is sparse" + "7e3664b747eb33a1": [ + "AutohealService drops the episode onset while a container is stopped, so a restarted one gets a fresh grace window" ], - "88431f058ac97635": [ - "MigrationPreconditionService assertRequiredMigrationsApplied checks the database belonging to the module, coin and network being updated", - "MigrationPreconditionService assertRequiredMigrationsApplied covers the indexer and the decoder, the two migration-bearing modules", - "MigrationPreconditionService assertRequiredMigrationsApplied does NOT print a scoped command the running build would ignore", - "MigrationPreconditionService assertRequiredMigrationsApplied does not touch the database when the target source declares no preconditions", - "MigrationPreconditionService assertRequiredMigrationsApplied is inert for a module that ships no migrations", - "MigrationPreconditionService assertRequiredMigrationsApplied passes when every declared precondition is in the ledger", - "MigrationPreconditionService assertRequiredMigrationsApplied proceeds on a genuinely empty database (a fresh install cannot be behind)", - "MigrationPreconditionService assertRequiredMigrationsApplied proceeds with a warning when the source itself cannot be cloned", - "MigrationPreconditionService assertRequiredMigrationsApplied proceeds, loudly, when the skip env is set", - "MigrationPreconditionService assertRequiredMigrationsApplied reads the source tree about to be deployed, at the pinned ref", - "MigrationPreconditionService assertRequiredMigrationsApplied refuses when the migration state cannot be read, and says so is not the same as missing", - "MigrationPreconditionService assertRequiredMigrationsApplied refuses when the target DB has not applied a declared precondition", - "MigrationPreconditionService assertRequiredMigrationsApplied still refuses when the capability probe itself throws", - "MigrationPreconditionService assertRequiredMigrationsApplied treats an unreadable container as lacking the capability", - "MigrationPreconditionService listDeployPreconditionMigrations reads the REAL indexer tree and finds the bridge-tables migration", - "MigrationPreconditionService listDeployPreconditionMigrations reads the REAL indexer tree and finds the migration behind the 2026-08-09 halt", - "MigrationPreconditionService listDeployPreconditionMigrations returns [] for a missing directory (a ref with no migrations declares nothing)", - "MigrationPreconditionService listDeployPreconditionMigrations returns only the tagged .sql files, sorted", - "MigrationPreconditionService migrationDeclaresDeployPrecondition ignores the token on a comment line that is not the directive", - "MigrationPreconditionService migrationDeclaresDeployPrecondition ignores the token once the SQL body has started", - "MigrationPreconditionService migrationDeclaresDeployPrecondition is false for an ordinary migration and for an empty file", - "MigrationPreconditionService migrationDeclaresDeployPrecondition reads the tag off the xchain:migration directive line", - "MigrationPreconditionService migrationDeclaresDeployPrecondition sees the tag through a long license banner", - "MigrationPreconditionService migrationDeclaresDeployPrecondition tolerates spacing around the token", - "MigrationPreconditionService migrationMode ignores a mode token that appears after the prologue", - "MigrationPreconditionService migrationMode reads the mode a header declares", - "MigrationPreconditionService migrationMode returns null when no mode is declared", - "MigrationPreconditionService pendingManualMigrations excludes what the ledger already carries", - "MigrationPreconditionService pendingManualMigrations lists only manual migrations the ledger has not recorded", - "MigrationPreconditionService pendingManualMigrations yields nothing for a missing directory rather than throwing", - "MigrationPreconditionService readAppliedMigrations calls a database with no tables empty, not unreadable", - "MigrationPreconditionService readAppliedMigrations calls a populated database with no ledger table UNREADABLE, never empty", - "MigrationPreconditionService readAppliedMigrations reads the ledger into a set of applied names", - "MigrationPreconditionService readAppliedMigrations refuses a database name that is not a plain identifier, before any query runs", - "MigrationPreconditionService readAppliedMigrations refuses an empty-string table count instead of reading it as zero", - "MigrationPreconditionService readAppliedMigrations refuses an unreadable ledger-presence count instead of reading it as \"no ledger\"", - "MigrationPreconditionService readAppliedMigrations refuses an unreadable table count instead of collapsing it into empty-database", - "MigrationPreconditionService readAppliedMigrations tolerates a ledger read that comes back empty", - "MigrationPreconditionService readAppliedMigrations turns a driver failure into unreadable instead of throwing past the guard" + "7f55fc09c93c9a4c": [ + "BootstrapService makeBootstrapUtxoTracker(): an unclean tracker stop is never archived refuses to snapshot, compress or sign when the tracker exited non-zero inside the budget, and restarts it", + "BootstrapService makeBootstrapUtxoTracker(): an unclean tracker stop is never archived refuses to snapshot, compress or sign when the tracker was killed at the budget, and restarts it", + "BootstrapService makeBootstrapUtxoTracker(): an unclean tracker stop is never archived stops the tracker with its 120 s service budget when the container carries no stamp", + "BootstrapService makeBootstrapUtxoTracker(): archive height is read after the restart keeps the pre-stop height when a rollback leaves the post-restart reading lower", + "BootstrapService makeBootstrapUtxoTracker(): archive height is read after the restart records the post-restart height on the snapshot path, not the pre-stop one", + "BootstrapService makeBootstrapUtxoTracker(): archive height is read after the restart restarts the tracker after the compress and before the wrap on the stopped-tracker fallback", + "BootstrapService tracker archive height helpers chooseArchiveHeight never records below either reading and keeps the floor when the post-read fails", + "BootstrapService tracker archive height helpers readTrackerHeightAfterRestart gives up with null after its attempts", + "BootstrapService tracker archive height helpers readTrackerHeightAfterRestart retries a tracker that is still coming up" ], - "8b127ec2a1140cac": [ - "BootstrapHealthGate XCHAIN_NODE_BOOTSTRAP_SKIP_HEALTH_GATE=1 bypasses the gate (loudly)", - "BootstrapHealthGate container state REFUSES a container that restarted moments ago", - "BootstrapHealthGate container state REFUSES a restarting (crash-looping) container", - "BootstrapHealthGate container state REFUSES a stopped container", - "BootstrapHealthGate container state REFUSES when docker inspect cannot be run", - "BootstrapHealthGate container state REFUSES when docker reports the healthcheck unhealthy", - "BootstrapHealthGate container state REFUSES when no container is registered", - "BootstrapHealthGate container state accepts a container that restarted long ago and has been stable since", - "BootstrapHealthGate container state reads RestartCount from the top level, not from .State (docker rejects the latter)", - "BootstrapHealthGate durable halt markers REFUSES a MariaDB source whose schema reports no events table", - "BootstrapHealthGate durable halt markers REFUSES a REORG_HALT count that is a partial number", - "BootstrapHealthGate durable halt markers REFUSES a database carrying an uncleared xchain-sync divergence halt", - "BootstrapHealthGate durable halt markers REFUSES a decoder whose database carries a REORG_HALT row", - "BootstrapHealthGate durable halt markers REFUSES a marker-table count outside 0..1", - "BootstrapHealthGate durable halt markers REFUSES a negative REORG_HALT count", - "BootstrapHealthGate durable halt markers REFUSES a sync_halt count that is a partial number", - "BootstrapHealthGate durable halt markers REFUSES an indexer when the paired decoder database is absent", - "BootstrapHealthGate durable halt markers REFUSES an indexer when the paired decoder probe throws", - "BootstrapHealthGate durable halt markers REFUSES an indexer whose PAIRED DECODER database carries a REORG_HALT row", - "BootstrapHealthGate durable halt markers REFUSES an indexer whose paired decoder database carries an uncleared sync halt", - "BootstrapHealthGate durable halt markers REFUSES in external-DB mode when the native probe answers with nothing", - "BootstrapHealthGate durable halt markers REFUSES when a marker-table token is a partial number, and does not skip sync_halt", - "BootstrapHealthGate durable halt markers REFUSES when the REORG_HALT count itself is unreadable", - "BootstrapHealthGate durable halt markers REFUSES when the marker query itself fails (fail closed, never assume clean)", - "BootstrapHealthGate durable halt markers REFUSES when the marker-table probe returns non-numeric output", - "BootstrapHealthGate durable halt markers REFUSES when the marker-table probe returns nothing (unreadable, not clean)", - "BootstrapHealthGate durable halt markers REFUSES when the marker-table probe returns the wrong number of tokens", - "BootstrapHealthGate durable halt markers REFUSES when the sync_halt count itself is unreadable", - "BootstrapHealthGate durable halt markers does not run marker queries for the utxo-tracker (LevelDB, no such table)", - "BootstrapHealthGate durable halt markers dump-window watermark REFUSES a halt raised and cleared while the dump was streaming", - "BootstrapHealthGate durable halt markers dump-window watermark REFUSES a halt raised inside the window in the paired decoder database", - "BootstrapHealthGate durable halt markers dump-window watermark REFUSES a sync_halt raised inside the window even though it is cleared now", - "BootstrapHealthGate durable halt markers dump-window watermark REFUSES an unusable watermark rather than trusting it", - "BootstrapHealthGate durable halt markers dump-window watermark REFUSES when the id sequence went backwards", - "BootstrapHealthGate durable halt markers dump-window watermark hands the caller a watermark for both marker tables", - "BootstrapHealthGate durable halt markers dump-window watermark passes when nothing was raised inside the window", - "BootstrapHealthGate durable halt markers dump-window watermark reports a null sync_halt watermark when the table does not exist", - "BootstrapHealthGate durable halt markers gating a decoder queries no second database", - "BootstrapHealthGate durable halt markers passes a decoder with no marker rows", - "BootstrapHealthGate durable halt markers passes an indexer when BOTH its own and the decoder database are clean", - "BootstrapHealthGate durable halt markers skips the sync_halt query on a schema that has no sync_halt table", - "BootstrapHealthGate evaluateContainerState() accepts a stable running container with no healthcheck", - "BootstrapHealthGate evaluateContainerState() treats unreadable inspect output as a refusal", - "BootstrapHealthGate evaluateStatusPayload() REFUSES a decoder that reports not-halted having never completed a probe", - "BootstrapHealthGate evaluateStatusPayload() REFUSES a lag that is not a number, rather than coercing it to zero", - "BootstrapHealthGate evaluateStatusPayload() REFUSES a negative lag: the committed tip sits above the node tip (orphaned view)", - "BootstrapHealthGate evaluateStatusPayload() REFUSES a payload with no lag field at all: position unverifiable is not caught-up", - "BootstrapHealthGate evaluateStatusPayload() REFUSES an indexer reporting its block counter wedged", - "BootstrapHealthGate evaluateStatusPayload() does not demand a probe timestamp from an indexer payload", - "BootstrapHealthGate evaluateStatusPayload() formats a block-fetch desync object instead of printing [object Object]", - "BootstrapHealthGate evaluateStatusPayload() passes a healthy payload whose lag is zero", - "BootstrapHealthGate evaluateStatusPayload() passes the healthy future-block wait and an in-grace barrier defer", - "BootstrapHealthGate evaluateStatusPayload() passes the same decoder once a probe has actually completed", - "BootstrapHealthGate evaluateStatusPayload() refuses an indexer frozen behind a halted decoder", - "BootstrapHealthGate evaluateStatusPayload() refuses when the node tip is stale, since the lag is then unknowable", - "BootstrapHealthGate evaluateStatusPayload() still accepts the two shapes a producer legitimately emits", - "BootstrapHealthGate evaluateStatusPayload() treats an empty payload as a refusal, not a pass", - "BootstrapHealthGate service health probe REFUSES a decoder reporting its own latent REORG_HALT marker", - "BootstrapHealthGate service health probe REFUSES a halted utxo-tracker", - "BootstrapHealthGate service health probe REFUSES a service materially behind its tip", - "BootstrapHealthGate service health probe REFUSES a service reporting unhealthy", - "BootstrapHealthGate service health probe REFUSES when the health probe cannot be run at all", - "BootstrapHealthGate service health probe REFUSES when the service cannot say how far behind it is", - "BootstrapHealthGate service health probe falls back to GET /status when the JSON-RPC route is unavailable", - "BootstrapHealthGate service health probe honours XCHAIN_NODE_BOOTSTRAP_MAX_LAG_BLOCKS", - "BootstrapHealthGate service health probe reports EVERY reason, not just the first", - "makeBootstrap() consults the source health gate aborts the create when the gate refuses", - "makeBootstrap() consults the source health gate gates the utxo-tracker BEFORE stopping its container, so a refusal costs no downtime", - "makeBootstrap() consults the source health gate still rejects an unsupported module before consulting the gate", - "parseCountTokens() accepts whole nonnegative integers", - "parseCountTokens() quotes the offending output in the refusal so the operator can see it", - "parseCountTokens() refuses a value above max when one is given, and ignores max when it is not", - "parseCountTokens() refuses empty and whitespace-only output", - "parseCountTokens() refuses partial tokens, signs, decimals and exponents", - "parseCountTokens() refuses the wrong token count in either direction" + "7f8472425104dee9": [ + "moduleOperations runE2ETest() builds mocha docker args when testName is provided", + "moduleOperations runE2ETest() clones the suite at the ref it was given", + "moduleOperations runE2ETest() includes --grep when grep is provided with testName", + "moduleOperations runE2ETest() installs e2e module, waits, saves logs, removes container", + "moduleOperations runE2ETest() passes null dockerCmdArgs when no testName or script", + "moduleOperations runE2ETest() passes null when no ref was given, keeping the default-branch behaviour", + "moduleOperations runE2ETest() uses npm run script when script is provided", + "moduleOperations uninstallModules(): includeShared fails a module whose registry read throws instead of skipping it", + "moduleOperations uninstallModules(): includeShared includes shared modules when includeShared=true", + "moduleOperations uninstallModules(): includeShared includes xchain-sync when includeShared=true", + "moduleOperations uninstallModules(): includeShared keeps a shared module when another coin/network is still installed", + "moduleOperations uninstallModules(): includeShared orders the shared pass LAST, so a full teardown still reaches it", + "moduleOperations uninstallModules(): includeShared refuses the shared removal rather than guessing when the registry is unreadable", + "moduleOperations uninstallModules(): includeShared skips module when container ID is null", + "moduleOperations uninstallModules(): includeShared skips shared modules by default", + "moduleOperations uninstallModules(): includeShared skips xchain-sync by default (shared singleton, same guard as database/hub/explorer)", + "moduleOperations uninstallModules(): includeShared still removes shared modules once the last coin/network is gone", + "moduleOperations uninstallModules(): includeShared still uninstalls the rest of the list after one registry read throws" ], - "8d3d114fcc0b254f": [ - "ModuleService assertNoHostPortConflicts() does NOT flag the container being re-created (selfName excluded)", - "ModuleService assertNoHostPortConflicts() parses IP-scoped publish specs (IP:HOST:CONTAINER)", - "ModuleService assertNoHostPortConflicts() reports every conflicting port, not just the first", - "ModuleService assertNoHostPortConflicts() resolves when no host ports are requested", - "ModuleService assertNoHostPortConflicts() resolves when requested ports are free on the host", - "ModuleService assertNoHostPortConflicts() throws naming the conflicting container when a host port is taken", - "ModuleService buildAndUp() calls statusChanged after storing container ID", - "ModuleService buildAndUp() healthcheck args dependency-derived healthcheck start periods grants the encoder a window at least as long as the utxo-tracker it probes", - "ModuleService buildAndUp() healthcheck args dependency-derived healthcheck start periods grants the hub and the explorer at least the DB start period their probes SELECT 1 against", - "ModuleService buildAndUp() healthcheck args does not warn and returns health args when the portKey is present", - "ModuleService buildAndUp() healthcheck args emits a docker-parsable duration for every start-period it returns", - "ModuleService buildAndUp() healthcheck args grants sync a start period covering the whole MAX_HUB_WAIT_MS hub wait", - "ModuleService buildAndUp() healthcheck args honors XCHAIN_NODE_HEALTH_START_PERIOD_ override", - "ModuleService buildAndUp() healthcheck args ignores a malformed start-period override and keeps the descriptor default", - "ModuleService buildAndUp() healthcheck args includes --health-cmd for decoder (http_get probe on /live, not /status)", - "ModuleService buildAndUp() healthcheck args includes --health-cmd for encoder (http_get probe)", - "ModuleService buildAndUp() healthcheck args includes --health-cmd for explorer (jsonrpc_ping probe)", - "ModuleService buildAndUp() healthcheck args includes --health-cmd for indexer (http_get probe)", - "ModuleService buildAndUp() healthcheck args includes --health-interval, --health-timeout, --health-retries, --health-start-period for encoder", - "ModuleService buildAndUp() healthcheck args normalizes a bare-seconds start-period override to a docker duration", - "ModuleService buildAndUp() healthcheck args omits healthcheck args for onlyExecution containers", - "ModuleService buildAndUp() healthcheck args probes /health (not /status) for sync (http_get probe)", - "ModuleService buildAndUp() healthcheck args probes health (not ping) for hub (jsonrpc_health probe)", - "ModuleService buildAndUp() healthcheck args probes health (not ping) for regtest-miner (jsonrpc_health probe)", - "ModuleService buildAndUp() healthcheck args returns [] with no warning for a module that has no healthcheck descriptor", - "ModuleService buildAndUp() healthcheck args warns and returns [] when the descriptor portKey is missing from the env", - "ModuleService buildAndUp() hub credential ordering does not touch the hub account for a one-shot execution container", - "ModuleService buildAndUp() hub credential ordering does not touch the hub account for any other module", - "ModuleService buildAndUp() hub credential ordering rotates the grant even when the config push then fails", - "ModuleService buildAndUp() hub credential ordering rotates the hub grant BEFORE the config push that needs the hub to answer", - "ModuleService buildAndUp() includes --network flag in docker run", - "ModuleService buildAndUp() includes port mapping for encoder", - "ModuleService buildAndUp() includes ulimit for utxo-tracker", - "ModuleService buildAndUp() includes volume mount for decoder", - "ModuleService buildAndUp() keeps the legacy tracker volume name under the default NODE_PREFIX", - "ModuleService buildAndUp() kills and removes old container when overwriteContainerId is provided", - "ModuleService buildAndUp() passes environment variables via the child env (bare --env NAME), not as argv values", - "ModuleService buildAndUp() prefixes the tracker volume name under a non-default NODE_PREFIX (F11)", - "ModuleService buildAndUp() reuseImage creates the container without running docker build", - "ModuleService buildAndUp() reuseImage does not re-clone the bundled libraries", - "ModuleService buildAndUp() reuseImage leaves the default path building the image", - "ModuleService buildAndUp() reuseImage still tears the old container down and registers the new id", - "ModuleService buildAndUp() reuseImage verifies the tag exists first, and says so when it does not", - "ModuleService buildAndUp() runs docker build with correct image name and cwd", - "ModuleService buildAndUp() stores container ID in LevelDB on success", - "ModuleService buildAndUp() throws when module does not exist", - "ModuleService buildAndUp(): hub consensus-env drift guard wiring calls assertNoHubConsensusEnvDrift with the generated hub env", - "ModuleService buildAndUp(): hub consensus-env drift guard wiring does not call the hub guard for an unrelated module", - "ModuleService buildAndUp(): hub consensus-env drift guard wiring does not reach the real guard from a default harness load (venue independence)", - "ModuleService buildAndUp(): hub consensus-env drift guard wiring propagates a refusal from the guard instead of building the hub", - "ModuleService buildAndUp(): log retention and observability env adds no observability name when the deploy host sets none", - "ModuleService buildAndUp(): log retention and observability env caps json-file logs at 50m x 4 so 48 h of history survives", - "ModuleService buildAndUp(): log retention and observability env forwards a host-set LOG_LEVEL as a bare --env name, value out of argv", - "ModuleService buildAndUp(): module-specific port/volume branches includes --restart unless-stopped for non-execution containers", - "ModuleService buildAndUp(): module-specific port/volume branches includes port mapping for xchain-hub", - "ModuleService buildAndUp(): module-specific port/volume branches includes port mapping for xchain-indexer and invokes cpSync filter for bundled libs", - "ModuleService buildAndUp(): module-specific port/volume branches includes port mapping for xchain-regtest-miner", - "ModuleService buildAndUp(): module-specific port/volume branches includes port mapping for xchain-sync", - "ModuleService buildAndUp(): module-specific port/volume branches includes two port mappings for xchain-explorer", - "ModuleService buildAndUp(): module-specific port/volume branches mounts the capability config DIRECTORY when HUB_CAPABILITY_CONFIG is set", - "ModuleService buildAndUp(): module-specific port/volume branches omits --restart for onlyExecution containers", - "ModuleService buildAndUp(): module-specific port/volume branches refuses to build on a host without buildx and never invokes docker build", + "7fcd70cf0e0cd046": [ "ModuleService buildAndUp(): module-specific port/volume branches rejects when container ID returned is not a valid 64-char hex", - "ModuleService buildAndUp(): module-specific port/volume branches rejects when docker build fails", - "ModuleService buildAndUp(): module-specific port/volume branches rejects when docker run fails", - "ModuleService buildAndUp(): module-specific port/volume branches rejects when insertModuleContainer returns false", - "ModuleService buildAndUp(): module-specific port/volume branches rejects when port value is invalid (non-numeric)", - "ModuleService buildAndUp(): module-specific port/volume branches runs docker build with DOCKER_BUILDKIT=1 even when the host exports DOCKER_BUILDKIT=0", - "ModuleService buildAndUp(): module-specific port/volume branches runs the host-port preflight BEFORE docker build so a conflict fails fast", + "ModuleService buildAndUp(): module-specific port/volume branches rejects when setModuleContainer returns false", "ModuleService buildAndUp(): module-specific port/volume branches skips db insert for onlyExecution=true and resolves containerId directly", - "ModuleService cloneGit() branch handling clones with -b flag when a valid branch is specified", - "ModuleService cloneGit() branch handling rejects (no silent fallback) when the requested branch is not found", - "ModuleService cloneGit() branch handling rejects on invalid branch name", - "ModuleService cloneGit() clones to module directory with correct git URL", - "ModuleService cloneGit() rejects on git clone error", - "ModuleService cloneGit() rejects when directory exists and rewrite=false", - "ModuleService cloneGit() rejects when module has no URL mapping", - "ModuleService cloneGit() removes existing directory when rewrite=true", - "ModuleService cloneGit() rewrite over an existing checkout clones into a staging dir, never over the live checkout", - "ModuleService cloneGit() rewrite over an existing checkout leaves the checkout in place when the branch is missing from the remote", - "ModuleService cloneGit() rewrite over an existing checkout leaves the checkout in place when the clone fails for any other reason", - "ModuleService cloneGit() rewrite over an existing checkout points a missing branch at the real mechanism (remote clone / local-path override)", - "ModuleService cloneGit() rewrite over an existing checkout rejects an invalid branch name before touching the filesystem", - "ModuleService cloneGit() rewrite over an existing checkout rejects an unknown module before touching the filesystem", - "ModuleService cloneGit() rewrite over an existing checkout restores the original checkout when the swap-in rename fails", - "ModuleService cloneGit() uses tmp directory when useTmp=true", "ModuleService containerExistsByName() via installModule rebuilds singleton when remoteUpdate=true even if container exists", - "ModuleService containerExistsByName(): via singleton installModule with inspect rejection treats docker inspect rejection as container-not-present (proceeds with install)", - "ModuleService getModuleBranch() returns trimmed branch name from git rev-parse", - "ModuleService installModule() singleton guard skips re-creating a singleton module whose container already exists", - "ModuleService installModule(): DB module calls buildDatabaseModule and statusChanged for DB module", - "ModuleService installModule(): DB module throws when buildDatabaseModule fails", "ModuleService installModule(): bootstrap paths via @global proxyquire calls ensureBootstrapMariaDb when decoder DB was fresh", "ModuleService installModule(): bootstrap paths via @global proxyquire calls ensureBootstrapUtxoTracker when utxo-tracker volume was fresh", "ModuleService installModule(): bootstrap paths via @global proxyquire does NOT call ensureBootstrapMariaDb when the decoder DB freshness is unknown", - "ModuleService installModule(): bootstrap paths via @global proxyquire refuses a drifting decoder update before tearing the container down", - "ModuleService installModule(): branch switch path reclones when existing branch differs from requested branch", - "ModuleService installModule(): explorer error path throws when installExplorerModule fails", - "ModuleService installModule(): explorer module calls installExplorerModule and statusChanged", - "ModuleService installModule(): generic module full build clones, builds, and returns containerId for a fresh generic module", - "ModuleService installModule(): generic module full build throws when cloneGit fails during installModule", - "ModuleService installModule(): generic module, container version already known returns false when container version is already set and remoteUpdate=false", - "ModuleService installModule(): node module builds crypto node when no container version and localNodeVersion is null", - "ModuleService installModule(): node module returns false when node container_version already set and remoteUpdate=false", - "ModuleService installModule(): node module skips getCryptoNode when localNodeVersion already set and remoteUpdate=false", - "ModuleService installModule(): node: checkRemoteNodeVersion called when coin not in remoteVersions calls checkRemoteNodeVersion when coin not in remote versions map", - "ModuleService installModule(): node: checkRemoteNodeVersion called when coin not in remoteVersions throws when getCryptoNode fails", - "ModuleService resolveObservabilityEnv() carries a deploy-host value into the container env", - "ModuleService resolveObservabilityEnv() fabricates nothing when neither source sets a name", - "ModuleService resolveObservabilityEnv() leaves a per-install config-store value alone", - "ModuleService resolveObservabilityEnv() names exactly the four shim controls", - "ModuleService resolveObservabilityEnv() never carries a name that is not one of the four", - "ModuleService resolveObservabilityEnv() treats an empty host value as unset", - "ModuleService uninstallModule() removes stale tracking row when no container in status but row exists in DB", - "ModuleService uninstallModule() returns true when module is not found (already uninstalled)", - "ModuleService uninstallModule() skips kill for exited containers", - "ModuleService uninstallModule() stops a running container with its budget, never docker kill, before removing", - "ModuleService uninstallModule() throws when container kill/remove fails (catch rethrows)", - "ModuleService uninstallModule() throws when trying to uninstall database", - "ModuleService uninstallModule(): removeModuleContainer returns false throws when removeModuleContainer returns false after successful container removal", - "ModuleService unit-suite venue independence leaves proxyquire in noCallThru mode after a call-through load", - "ModuleService unit-suite venue independence trips the guard instead of probing the host when a call-through load forgets the stub" + "ModuleService installModule(): bootstrap paths via @global proxyquire refuses a drifting decoder update before tearing the container down" + ], + "80fa21ba0e27730f": [ + "Integration: Multi-Module Orchestration filterCommandParameters -> installModules end-to-end \"all bitcoin mainnet\" installs correct set in correct order", + "Integration: Multi-Module Orchestration filterCommandParameters -> installModules end-to-end \"all bitcoin regtest\" includes regtest-miner but not e2e-test" + ], + "81304df51b8b5a6e": [ + "StatusService: getInstalledCoinsAndNetworks() ignores coins that are not in the Coin enum", + "StatusService: getInstalledCoinsAndNetworks() returns coins and networks filtered by known Coin/Network values", + "StatusService: getInstalledCoinsAndNetworks() returns empty object when no modules are installed" + ], + "8159641021bab8a6": [ + "ps: NODE UNREACHABLE surface reads null, an absent field, an older image and a malformed object as reachable", + "ps: NODE UNREACHABLE surface reduces an unreachable payload to since, the last answer and the duration", + "ps: NODE UNREACHABLE surface renders durations the way an operator scans them", + "ps: NODE UNREACHABLE surface the note names the last answer when there was one", + "ps: NODE UNREACHABLE surface the note says never answered, for how long, that it is not the IBD wait, and where to look", + "ps: NODE UNREACHABLE surface tolerates a missing or negative duration", + "ps: WAITING FOR NODE surface reads null, an absent field, an older image and a malformed object as not waiting", + "ps: WAITING FOR NODE surface reduces a waiting payload to the two heights and the start of the wait", + "ps: WAITING FOR NODE surface the note names the service, both heights, the remaining gap and that it resolves itself", + "ps: WAITING FOR NODE surface tolerates a missing stored height", + "ps: decoder REORG_HALT surface reads an older image with no reorg_halted field as not halted, and a cleared halt as not halted", + "ps: decoder REORG_HALT surface reduces a halted health payload", + "ps: decoder REORG_HALT surface returns null for anything that is not a payload", + "ps: decoder REORG_HALT surface the note names the coin, the meaning, the halt reason and both recoveries", + "ps: indexer STALL surface a barrier past its grace window gets the mirror line, and a decoder halt points at the decoder", + "ps: indexer STALL surface does not show the two stalls the indexer itself calls healthy", + "ps: indexer STALL surface reads an older image that names a reason but grades nothing as stalled, and no reason as advancing", + "ps: indexer STALL surface reduces a wedged host fault to the reason, its word and the last commit as an instant", + "ps: indexer STALL surface shows a host fault at once, even inside the grace window", + "ps: indexer STALL surface takes the word before the colon of a reason that carries prose", + "ps: indexer STALL surface the roll-call note names the variable, the update command and the reason it defers", + "ps: tracker HALTED surface reads an older image with no halted field, a string flag and a running tracker as not halted", + "ps: tracker HALTED surface reduces a halted payload, folding the reason to one line", + "ps: tracker HALTED surface the note says no restart clears it, quotes the reason and names the reset once" + ], + "81888023f59d00e3": [ + "Integration: Hub/Explorer Config Update checkpoint self-sync opt-in survives the shell that set it emits a checkpoint block for EVERY installed coin when only the explorer container remembers the opt-in", + "Integration: Hub/Explorer Config Update checkpoint self-sync opt-in survives the shell that set it emits none when neither the env nor the explorer container was ever opted in" + ], + "82171a751987af6e": [ + "nine-field FILE scan (pre-window gate) BATCH unwrapping a clean BATCH is clean", + "nine-field FILE scan (pre-window gate) BATCH unwrapping finds a nine-field FILE inside a BATCH", + "nine-field FILE scan (pre-window gate) BATCH unwrapping splits every command, not just the first", + "nine-field FILE scan (pre-window gate) BATCH unwrapping tolerates a trailing separator without inventing an empty command", + "nine-field FILE scan (pre-window gate) scanning rows counts what it scanned and reports every hit with its location", + "nine-field FILE scan (pre-window gate) scanning rows handles no rows at all", + "nine-field FILE scan (pre-window gate) scanning rows survives null and empty payloads", + "nine-field FILE scan (pre-window gate) the gate report counts only payload-bearing rows as the corpus", + "nine-field FILE scan (pre-window gate) the gate report distinguishes clean-by-emptiness from a scan that examined rows", + "nine-field FILE scan (pre-window gate) the gate report names every hit and refuses to let it pass as a footnote", + "nine-field FILE scan (pre-window gate) the gate report says CLEAN in a way that states what was established", + "nine-field FILE scan (pre-window gate) the gate report says so when the corpus was not measured, rather than implying a full scan", + "nine-field FILE scan (pre-window gate) the query reads the RAW payload, which is the whole point filters on the raw payload prefix and pages by keyset", + "nine-field FILE scan (pre-window gate) the query reads the RAW payload, which is the whole point selects the decoder payload column and never a parsed params table", + "nine-field FILE scan (pre-window gate) what is a hit does NOT flag a non-gated FILE with trailing fields stripped", + "nine-field FILE scan (pre-window gate) what is a hit does NOT flag a trailing EMPTY ninth field", + "nine-field FILE scan (pre-window gate) what is a hit does NOT flag the ordinary eight-field form", + "nine-field FILE scan (pre-window gate) what is a hit flags a FILE carrying a non-empty ninth field", + "nine-field FILE scan (pre-window gate) what is a hit flags a TENTH field even when the ninth is empty, because the wire is malformed", + "nine-field FILE scan (pre-window gate) what is a hit ignores actions that are not FILE", + "nine-field FILE scan (pre-window gate) what is a hit is case- and whitespace-tolerant on the action name" + ], + "831f3db33b66159c": [ + "ConfigService getDefaultConfig() indexer origin endpoints does not inject an unset origin-chain value", + "ConfigService getDefaultConfig() indexer origin endpoints passes configured origin-chain URLs and API keys to every indexer" + ], + "83351766649e0c73": [ + "Fuzz: Container ID Validation accepts a valid 64-char lowercase hex container ID", + "Fuzz: Container ID Validation accepts all-f container ID", + "Fuzz: Container ID Validation accepts all-zero container ID", + "Fuzz: Container ID Validation rejects container ID that is 64 chars only with whitespace padding", + "Fuzz: Container ID Validation rejects invalid container ID: 12-char short hash", + "Fuzz: Container ID Validation rejects invalid container ID: 63 chars (too short)", + "Fuzz: Container ID Validation rejects invalid container ID: 65 chars (too long)", + "Fuzz: Container ID Validation rejects invalid container ID: empty string", + "Fuzz: Container ID Validation rejects invalid container ID: mixed case hex", + "Fuzz: Container ID Validation rejects invalid container ID: newlines mixed in", + "Fuzz: Container ID Validation rejects invalid container ID: non-hex characters (g)", + "Fuzz: Container ID Validation rejects invalid container ID: non-hex characters (z)", + "Fuzz: Container ID Validation rejects invalid container ID: null bytes", + "Fuzz: Container ID Validation rejects invalid container ID: single char", + "Fuzz: Container ID Validation rejects invalid container ID: spaces", + "Fuzz: Container ID Validation rejects invalid container ID: special chars", + "Fuzz: Container ID Validation rejects invalid container ID: unicode hex-lookalike", + "Fuzz: Container ID Validation rejects invalid container ID: uppercase hex", + "Fuzz: Container ID Validation rejects invalid container ID: with shell injection", + "Fuzz: Container ID Validation trims leading/trailing whitespace from container ID" + ], + "8403c6ea9dad5fe3": [ + "ValidatorStakeService validator set paging pages oldest first from page 1 and stops at the total", + "ValidatorStakeService validator set paging refuses a response with no data array rather than reading it as empty", + "ValidatorStakeService validator set paging stake finds a stake that sits past the first page and sends nothing", + "ValidatorStakeService validator set paging stake reports a page that comes back short of the total as unknown", + "ValidatorStakeService validator set paging stake reports an unreadable page as unknown, never as \"not staked\"", + "ValidatorStakeService validator set paging treats a short page with no total as the whole set", + "ValidatorStakeService validator set paging unstake finds a stake that sits past the first page", + "ValidatorStakeService validator set paging unstake refuses and sends nothing when a page cannot be read" + ], + "841397b0f12a1dbd": [ + "Chaos: Process Resilience Experiment 11: Async error propagation propagates string errors from docker run" + ], + "846dd1095f28252d": [ + "ModuleService buildAndUp(): BuildKit probe precedes clone and database work refuses on the buildx probe before configuration/database lookup or the bundled-library clone" + ], + "85a3edce75ca5fc0": [ + "Boundary Tests ModuleService: branch name validation accepts valid branch names: feature/my-branch", + "Boundary Tests ModuleService: branch name validation accepts valid branch names: master", + "Boundary Tests ModuleService: branch name validation accepts valid branch names: release_2.0", + "Boundary Tests ModuleService: branch name validation accepts valid branch names: v1.0.0", + "Boundary Tests ModuleService: branch name validation allows null branch (no -b flag)", + "Boundary Tests ModuleService: branch name validation rejects branch name with $() (command substitution)", + "Boundary Tests ModuleService: branch name validation rejects branch name with backticks", + "Boundary Tests ModuleService: branch name validation rejects branch name with pipe", + "Boundary Tests ModuleService: branch name validation rejects branch name with semicolon (shell injection)", + "Boundary Tests ModuleService: branch name validation rejects branch name with spaces", + "Boundary Tests ModuleService: branch name validation rejects module with unknown git URL" + ], + "8602a614e6adc857": [ + "ValidatorService getCapabilityConfigHostPath() returns CAPS_FILE path when initialized and caps file exists", + "ValidatorService getCapabilityConfigHostPath() returns null when initialized but caps file missing", + "ValidatorService getCapabilityConfigHostPath() returns null when not initialized" + ], + "8656c9fadc3daefb": [ + "DockerService logContainer() calls spawn with --tail and --follow for follow=true", + "DockerService logContainer() calls spawn without --follow for follow=false", + "DockerService shellContainer() calls spawnSync with docker exec -it bash" + ], + "86b6926a26306f8a": [ + "ExplorerService: installExplorerModule() force=true kills and removes existing container when force=true and container exists", + "ExplorerService: installExplorerModule() force=true skips kill/remove when no existing container in DB (force=true)", + "ExplorerService: installExplorerModule() force=true swallows errors from killContainer/removeContainer during force rebuild" + ], + "8736dac4ae50eb08": [ + "Chaos: Download Resilience Experiment 8e: Release tag not found throws when specific tag does not exist" + ], + "87bbeeec71aa7b85": [ + "attest-mirror hub-llm-probe.js exits 0 on a sound turn and redacts non-JSON output too", + "attest-mirror hub-llm-probe.js hands the CLI the hub token and config dir and drops ambient credentials", + "attest-mirror hub-llm-probe.js prints the CLI verdict with the token redacted and exits 1 on a failed turn", + "attest-mirror hub-llm-probe.js skips with exit 0 when the leg carries no hub credential", + "attest-mirror hub-llm-probe.js uses the hub spawn argv with no tools and no settings" + ], + "8882bef46ee2ac3f": [ + "Chaos: Docker Resilience Experiment: File write to container failure rejects when spawn child exits with non-zero code", + "Chaos: Docker Resilience Experiment: File write to container failure rejects when spawn emits error event" + ], + "8927121fa98c797e": [ + "Integration: Docker Command Construction docker build working directory cwd is set to the module directory" + ], + "89a860ec2e7d06b3": [ + "NodeService: buildCryptoNode() XCHAIN_NODE_BLOCKS_DIR (blocks + txindex split) bitcoin testnet: relocates txindex under testnet4 (bitcoin-testnet.conf sets testnet4=1)", + "NodeService: buildCryptoNode() XCHAIN_NODE_BLOCKS_DIR (blocks + txindex split) bitcoin: -blocksdir against /blocks + relocates txindex", + "NodeService: buildCryptoNode() XCHAIN_NODE_BLOCKS_DIR (blocks + txindex split) dogecoin mainnet: nests blocks onto the in-datadir path, never -blocksdir", + "NodeService: buildCryptoNode() XCHAIN_NODE_BLOCKS_DIR (blocks + txindex split) dogecoin regtest: nests under the regtest subdir", + "NodeService: buildCryptoNode() XCHAIN_NODE_BLOCKS_DIR (blocks + txindex split) dogecoin testnet: nests under the testnet3 subdir", + "NodeService: buildCryptoNode() XCHAIN_NODE_BLOCKS_DIR (blocks + txindex split) litecoin testnet: relocates txindex under testnet4", + "NodeService: buildCryptoNode() XCHAIN_NODE_BLOCKS_DIR (blocks + txindex split) litecoin: honors -blocksdir (Bitcoin 0.21 base)", + "NodeService: buildCryptoNode() XCHAIN_NODE_BLOCKS_DIR (blocks + txindex split) no blocks/txindex mounts when the env var is unset" + ], + "8b660477cd3be877": [ + "Integration: Status Query Chain getInstalledCoinsAndNetworks returns installed coins and their networks", + "Integration: Status Query Chain getStatus when Docker inspect fails removes entire coin/network when all modules fail inspection", + "Integration: Status Query Chain getStatus when Docker inspect fails removes modules that fail inspection from result", + "Integration: Status Query Chain getStatus with empty LevelDB returns empty object and does not throw", + "Integration: Status Query Chain getStatus with installed modules returns exited status for stopped module", + "Integration: Status Query Chain getStatus with installed modules returns multiple modules across different coins/networks", + "Integration: Status Query Chain getStatus with installed modules returns running status for installed module", + "Integration: Status Query Chain status caching second call returns cached result without re-querying Docker" + ], + "8bafabf4238fc37a": [ + "DatabaseService addUserPasswordToDatabase(): provisioning-SQL safety escapes a quote-containing password into a single well-formed string literal (docker path)", + "DatabaseService addUserPasswordToDatabase(): provisioning-SQL safety leaves a plain password byte-for-byte unchanged (no behaviour drift for existing installs)", + "DatabaseService addUserPasswordToDatabase(): provisioning-SQL safety rejects an injection-bearing database name before any SQL runs", + "DatabaseService addUserPasswordToDatabase(): provisioning-SQL safety rejects an injection-bearing user name before any SQL runs" + ], + "8ce1d908dd38ef02": [ + "moduleOperations installModules() builds database before installing modules", + "moduleOperations installModules() calls installModule for each module in the list", + "moduleOperations installModules() creates Docker network for each coin/network pair", + "moduleOperations installModules() handles multiple coins and networks", + "moduleOperations installModules() reports a module installModule declined as skipped, not installed", + "moduleOperations installModules() reports the bootstrap outcomes even when the install throws", + "moduleOperations installModules() reports what it installed on success", + "moduleOperations installModules() skips network creation and database for shared services (empty coin/network)", + "moduleOperations installModules() still reports the bootstrap outcomes on a clean install" + ], + "8d417dad40169b50": [ + "Security GitHubDownloader archive extraction safety uses fs.unlinkSync instead of shell rm for cleanup", + "Security GitHubDownloader archive extraction safety uses spawnSync instead of execSync for tar extraction" + ], + "8df8514bdcae5e67": [ + "Integration: Docker Command Construction xchain-hub Docker run command publishes and binds the hub on HUB_PORT_OVERRIDE for a second co-located install", + "Integration: Docker Command Construction xchain-hub Docker run command uses base network and hub port" + ], + "8e25d0290c1bd265": [ + "Chaos: Process Resilience Experiment: Precheck failure cascade Network creation failure throws with descriptive message" + ], + "8e826477c40148d2": [ + "Integration: Docker Command Construction xchain-explorer Docker run command uses base network and both HTTP/HTTPS ports" + ], + "8ec32726c4949c89": [ + "Security Branch name validation cloneGit accepts valid branch names", + "Security Branch name validation cloneGit rejects branch names with $() command substitution", + "Security Branch name validation cloneGit rejects branch names with backticks", + "Security Branch name validation cloneGit rejects branch names with shell metacharacters", + "Security Branch name validation resolveArgs accepts valid branch names", + "Security Branch name validation resolveArgs rejects invalid branch names" + ], + "8ef5fabc3ebb1554": [ + ".github/workflows/ci.yml coverage job arms XCHAIN_REQUIRE_SIBLINGS for coverage:check only when the checkout actually happened", + ".github/workflows/ci.yml coverage job checks declared siblings out before re-running the unit suite for coverage", + "bin/ci-full.sh sibling roster FALSIFIES: refuses at need_sib, naming the missing sibling, when xchain-indexer is absent", + "bin/ci-full.sh sibling roster passes need_sib and arms XCHAIN_REQUIRE_SIBLINGS when every declared sibling is present", + "bin/ci-full.sh sibling roster reads .ci-siblings instead of a second hard-coded list" + ], + "8f5d3a3ee1b75f78": [ + "ValidatorService initValidator() defaults P2P_VALIDATOR_ADDR to 0.0.0.0: when p2pAddr not given", + "ValidatorService initValidator() re-generates key with force=true even when already initialized", + "ValidatorService initValidator() records the network on a validator.json that predates the field", + "ValidatorService initValidator() repairs a pre-wallets validator on a re-run, without touching the signing key", + "ValidatorService initValidator() returns existing settings and never rotates the signing key when already initialized", + "ValidatorService initValidator() sets P2P_PORT from opts.p2pPort", + "ValidatorService initValidator() sets P2P_VALIDATOR_ADDR from opts.p2pAddr", + "ValidatorService initValidator() sets SEED_NODES from comma-separated opts.seedNodes", + "ValidatorService initValidator() writes key, settings, and capabilities files on first run" + ], + "90560b45328159e3": [ + "S-SMOKE-005 – Config Template File Integrity bitcoin-mainnet all non-comment lines match KEY=VALUE format", + "S-SMOKE-005 – Config Template File Integrity bitcoin-mainnet contains DUST_AMOUNT", + "S-SMOKE-005 – Config Template File Integrity bitcoin-mainnet contains NETWORK", + "S-SMOKE-005 – Config Template File Integrity bitcoin-mainnet contains NODE_EXPOSED_PORT", + "S-SMOKE-005 – Config Template File Integrity bitcoin-mainnet has no duplicate keys", + "S-SMOKE-005 – Config Template File Integrity bitcoin-mainnet is non-empty", + "S-SMOKE-005 – Config Template File Integrity bitcoin-regtest all non-comment lines match KEY=VALUE format", + "S-SMOKE-005 – Config Template File Integrity bitcoin-regtest contains DUST_AMOUNT", + "S-SMOKE-005 – Config Template File Integrity bitcoin-regtest contains NETWORK", + "S-SMOKE-005 – Config Template File Integrity bitcoin-regtest contains NODE_EXPOSED_PORT", + "S-SMOKE-005 – Config Template File Integrity bitcoin-regtest has no duplicate keys", + "S-SMOKE-005 – Config Template File Integrity bitcoin-regtest is non-empty", + "S-SMOKE-005 – Config Template File Integrity bitcoin-testnet all non-comment lines match KEY=VALUE format", + "S-SMOKE-005 – Config Template File Integrity bitcoin-testnet contains DUST_AMOUNT", + "S-SMOKE-005 – Config Template File Integrity bitcoin-testnet contains NETWORK", + "S-SMOKE-005 – Config Template File Integrity bitcoin-testnet contains NODE_EXPOSED_PORT", + "S-SMOKE-005 – Config Template File Integrity bitcoin-testnet has no duplicate keys", + "S-SMOKE-005 – Config Template File Integrity bitcoin-testnet is non-empty", + "S-SMOKE-005 – Config Template File Integrity dogecoin-mainnet all non-comment lines match KEY=VALUE format", + "S-SMOKE-005 – Config Template File Integrity dogecoin-mainnet contains DUST_AMOUNT", + "S-SMOKE-005 – Config Template File Integrity dogecoin-mainnet contains NETWORK", + "S-SMOKE-005 – Config Template File Integrity dogecoin-mainnet contains NODE_EXPOSED_PORT", + "S-SMOKE-005 – Config Template File Integrity dogecoin-mainnet has no duplicate keys", + "S-SMOKE-005 – Config Template File Integrity dogecoin-mainnet is non-empty", + "S-SMOKE-005 – Config Template File Integrity dogecoin-regtest all non-comment lines match KEY=VALUE format", + "S-SMOKE-005 – Config Template File Integrity dogecoin-regtest contains DUST_AMOUNT", + "S-SMOKE-005 – Config Template File Integrity dogecoin-regtest contains NETWORK", + "S-SMOKE-005 – Config Template File Integrity dogecoin-regtest contains NODE_EXPOSED_PORT", + "S-SMOKE-005 – Config Template File Integrity dogecoin-regtest has no duplicate keys", + "S-SMOKE-005 – Config Template File Integrity dogecoin-regtest is non-empty", + "S-SMOKE-005 – Config Template File Integrity dogecoin-testnet all non-comment lines match KEY=VALUE format", + "S-SMOKE-005 – Config Template File Integrity dogecoin-testnet contains DUST_AMOUNT", + "S-SMOKE-005 – Config Template File Integrity dogecoin-testnet contains NETWORK", + "S-SMOKE-005 – Config Template File Integrity dogecoin-testnet contains NODE_EXPOSED_PORT", + "S-SMOKE-005 – Config Template File Integrity dogecoin-testnet has no duplicate keys", + "S-SMOKE-005 – Config Template File Integrity dogecoin-testnet is non-empty", + "S-SMOKE-005 – Config Template File Integrity litecoin-mainnet all non-comment lines match KEY=VALUE format", + "S-SMOKE-005 – Config Template File Integrity litecoin-mainnet contains DUST_AMOUNT", + "S-SMOKE-005 – Config Template File Integrity litecoin-mainnet contains NETWORK", + "S-SMOKE-005 – Config Template File Integrity litecoin-mainnet contains NODE_EXPOSED_PORT", + "S-SMOKE-005 – Config Template File Integrity litecoin-mainnet has no duplicate keys", + "S-SMOKE-005 – Config Template File Integrity litecoin-mainnet is non-empty", + "S-SMOKE-005 – Config Template File Integrity litecoin-regtest all non-comment lines match KEY=VALUE format", + "S-SMOKE-005 – Config Template File Integrity litecoin-regtest contains DUST_AMOUNT", + "S-SMOKE-005 – Config Template File Integrity litecoin-regtest contains NETWORK", + "S-SMOKE-005 – Config Template File Integrity litecoin-regtest contains NODE_EXPOSED_PORT", + "S-SMOKE-005 – Config Template File Integrity litecoin-regtest has no duplicate keys", + "S-SMOKE-005 – Config Template File Integrity litecoin-regtest is non-empty", + "S-SMOKE-005 – Config Template File Integrity litecoin-testnet all non-comment lines match KEY=VALUE format", + "S-SMOKE-005 – Config Template File Integrity litecoin-testnet contains DUST_AMOUNT", + "S-SMOKE-005 – Config Template File Integrity litecoin-testnet contains NETWORK", + "S-SMOKE-005 – Config Template File Integrity litecoin-testnet contains NODE_EXPOSED_PORT", + "S-SMOKE-005 – Config Template File Integrity litecoin-testnet has no duplicate keys", + "S-SMOKE-005 – Config Template File Integrity litecoin-testnet is non-empty" + ], + "910cade45cc50889": [ + "CredentialsService XCHAIN_NODE_DB equals \"xchain_node\"" + ], + "92ecc535e12a2126": [ + "moduleOperations updateModules(): branch handling falls back to getModuleBranch (current branch) when no branch specified", + "moduleOperations updateModules(): branch handling proceeds with null branch if getModuleBranch throws", + "moduleOperations updateModules(): branch handling skips module when container ID is not found", + "moduleOperations updateModules(): branch handling threads the provided branch through to installModule" ], - "9778dd0f1c360aef": [ - "VersionService checkAllRemoteVersions() is a function that returns a promise", - "VersionService checkRemoteNodeVersion() calls gitHubDownloader for bitcoin with correct owner/repo", - "VersionService checkRemoteNodeVersion() calls gitHubDownloader for dogecoin with correct owner/repo", - "VersionService checkRemoteNodeVersion() calls gitHubDownloader for litecoin with correct owner/repo", - "VersionService checkRemoteNodeVersion() stores result in remoteModuleVersions with node- key", - "VersionService getContainerModuleVersion() prefers the exec read over the host copy", - "VersionService getContainerModuleVersion() reads package.json from container and returns version", - "VersionService getContainerModuleVersion() reads the version with docker exec even when docker cp is permanently broken", - "VersionService getContainerModuleVersion() throws on docker cp error", - "VersionService getContainerNodeVersion() reads version file from container via docker cp", - "VersionService getContainerNodeVersion() throws on docker cp error", - "VersionService getGithubProjectVersion() calls GitHub API with correct URL", - "VersionService getGithubProjectVersion() preserves tag_name without v prefix", - "VersionService getGithubProjectVersion() returns the release id", - "VersionService getGithubProjectVersion() strips leading v from tag_name", - "VersionService getLocalModuleVersion() reads version from module package.json", - "VersionService getLocalModuleVersion() rejects when package.json does not exist", - "VersionService getLocalModuleVersion() rejects when package.json has no version field", - "VersionService getLocalModuleVersion() rejects when package.json is malformed", - "VersionService getLocalNodeVersion() reads version from correct file path", - "VersionService getLocalNodeVersion() rejects on read error", - "VersionService getLocalNodeVersion() rejects when version file does not exist", - "VersionService readContainerFile() falls back to docker cp when the container cannot be exec'd", - "VersionService readContainerFile() names BOTH failures when neither read works" + "935687035bb6ce2b": [ + "rail-leg.yml drive environment service URLs requires the three URLs in the unconditional setup step before every drive", + "rail-leg.yml drive environment service URLs writes host-reachable BTC and DOGE service URLs without changing generic bitcoin endpoints" ], - "99bd35e26fa0632d": [ - "MemoryLimitService countInstalledTrackers() counts one when the registry cannot be read or the handle has no registry", - "MemoryLimitService countInstalledTrackers() counts the registered trackers plus the one being created", - "MemoryLimitService countInstalledTrackers() does not double-count a tracker that is already registered (recreate)", - "MemoryLimitService memoryArgsFor() 0 disables the derived tracker cap, and says so", - "MemoryLimitService memoryArgsFor() an explicit XCHAIN_NODE_MODULE_MEMORY_MB_ wins for any module", - "MemoryLimitService memoryArgsFor() caps the tracker with --memory and an equal --memory-swap", - "MemoryLimitService memoryArgsFor() derives the env key from the service name", - "MemoryLimitService memoryArgsFor() ignores an unparseable value rather than guessing, and names it", - "MemoryLimitService memoryArgsFor() leaves every other module uncapped by default", - "MemoryLimitService memoryArgsFor() warns in the note when the share is under the floor", - "MemoryLimitService trackerMemoryLimitMb() ceilings a huge host", - "MemoryLimitService trackerMemoryLimitMb() divides the half among the trackers on the host (the 16 GB three-chain case)", - "MemoryLimitService trackerMemoryLimitMb() floors, and says so, when the host cannot afford its trackers", - "MemoryLimitService trackerMemoryLimitMb() gives one tracker half the host", - "MemoryLimitService trackerMemoryLimitMb() treats a missing or zero count as one" + "946b75a6f83d1f21": [ + "S-SMOKE-001 – Module Import Chain requires ConfigService without throwing", + "S-SMOKE-001 – Module Import Chain requires DockerService without throwing", + "S-SMOKE-001 – Module Import Chain requires ExplorerConnector without throwing", + "S-SMOKE-001 – Module Import Chain requires GitHubDownloader without throwing", + "S-SMOKE-001 – Module Import Chain requires HubConnector without throwing", + "S-SMOKE-001 – Module Import Chain requires MariaDbStore without throwing", + "S-SMOKE-001 – Module Import Chain requires config/constants without throwing", + "S-SMOKE-001 – Module Import Chain requires moduleOperations without throwing", + "S-SMOKE-001 – Module Import Chain requires precheck without throwing", + "S-SMOKE-001 – Module Import Chain requires state module without throwing", + "S-SMOKE-001 – Module Import Chain requires utils/helpers without throwing" + ], + "95e6da4b094313a0": [ + "Security helpers.js decompressTarGz safety uses execFile instead of exec" + ], + "97f5eba9d682a101": [ + "GitHubDownloader downloadReleaseAsset() downloads gz asset using axios stream + pipeline, extracts with tar", + "GitHubDownloader downloadReleaseAsset() extracts zip asset and calls unzip", + "GitHubDownloader downloadReleaseAsset() refuses to extract a gz asset with an unsafe member path", + "GitHubDownloader downloadReleaseAsset() refuses to extract a zip asset with an unsafe member path", + "GitHubDownloader downloadReleaseAsset() throws when no matching linux asset found for host arch" + ], + "9806bd4412e5d53d": [ + "BootstrapHealthGate service health probe REFUSES a decoder reporting its own latent REORG_HALT marker", + "BootstrapHealthGate service health probe REFUSES a halted utxo-tracker", + "BootstrapHealthGate service health probe REFUSES a service materially behind its tip", + "BootstrapHealthGate service health probe REFUSES a service reporting unhealthy", + "BootstrapHealthGate service health probe REFUSES when the health probe cannot be run at all", + "BootstrapHealthGate service health probe REFUSES when the service cannot say how far behind it is", + "BootstrapHealthGate service health probe falls back to GET /status when the JSON-RPC route is unavailable", + "BootstrapHealthGate service health probe honours XCHAIN_NODE_BOOTSTRAP_MAX_LAG_BLOCKS", + "BootstrapHealthGate service health probe reports EVERY reason, not just the first" + ], + "986d6e1069c073c2": [ + "ValidatorStakeService stakeValidator() --no-wait returns as soon as the STAKE is broadcast", + "ValidatorStakeService stakeValidator() --serialize keeps the old block-per-action behaviour", + "ValidatorStakeService stakeValidator() calls explorer methods that the published SDK actually exposes", + "ValidatorStakeService stakeValidator() does nothing when the pubkey already carries a valid stake", + "ValidatorStakeService stakeValidator() dry run prints the plan and sends nothing", + "ValidatorStakeService stakeValidator() funds each action from the previous one, forcing the in-block order", + "ValidatorStakeService stakeValidator() keeps the mints chained and waits when only the last MINT to STAKE hop breaks", + "ValidatorStakeService stakeValidator() names the missing gas token and the bootstrap instead of a raw explorer 404", + "ValidatorStakeService stakeValidator() on mainnet never mints and blocks on a shortfall", + "ValidatorStakeService stakeValidator() passes --fee-per-kb through to the encoder", + "ValidatorStakeService stakeValidator() prints the complete validator URL for every network", + "ValidatorStakeService stakeValidator() refuses a WIF that does not control the recorded stake address", + "ValidatorStakeService stakeValidator() refuses when the address has no coin for fees", + "ValidatorStakeService stakeValidator() refuses without a validator", + "ValidatorStakeService stakeValidator() reports pending mints instead of staking when they never index", + "ValidatorStakeService stakeValidator() sends everything back to back and waits only on the STAKE", + "ValidatorStakeService stakeValidator() sends no STAKE when the STAKE hop breaks and the mint never indexes", + "ValidatorStakeService stakeValidator() skips minting when already funded", + "ValidatorStakeService stakeValidator() the plan states the escrow and the cooldown that frees it", + "ValidatorStakeService stakeValidator() the post-broadcast summary takes the activation delay from the registry", + "ValidatorStakeService stakeValidator() waits for a single mint to index when only the STAKE hop cannot chain", + "ValidatorStakeService stakeValidator() waits for the mints to index when no chain can be formed, rather than racing the STAKE", + "ValidatorStakeService stakeValidator() with --broadcast mints the shortfall in order, then stakes", + "ValidatorStakeService stakeValidator() with an unreadable validator set refuses when the validator set cannot be read, instead of reading the failure as \"not staked\"", + "ValidatorStakeService stakeValidator() with an unreadable validator set sends no MINT and no STAKE with --broadcast" + ], + "998d59a6710ceaa1": [ + "rail leg R2 arm resolver keeps all configured values independent", + "rail leg R2 arm resolver rejects a bad value by name without printing its value", + "rail leg R2 arm resolver writes XC_AMOUNTS_PRICE_REGTEST_ACTIVATION independently", + "rail leg R2 arm resolver writes XC_AMOUNTS_PRICE_REGTEST_TIME independently", + "rail leg R2 arm resolver writes XC_CONTRACTS_REGTEST_ACTIVATION independently", + "rail leg R2 arm resolver writes an empty file when no value is set", + "rail leg R2 arm resolver writes the same values into the drive environment without logging them", + "rail-leg.yml R2 arm values declares ten inputs and resolves the three optional R2 values beside the anchor step", + "rail-leg.yml R2 arm values names every R2 value in both standing stack configuration loops" + ], + "999b6d7dbb8afa0a": [ + "moduleOperations recreateModules() does not probe docker when the registry has a container id", + "moduleOperations recreateModules() does not rotate the hub account when the hub was not recreated", + "moduleOperations recreateModules() finishes the sweep past a failing venue and names every failure", + "moduleOperations recreateModules() never re-clones or rebuilds through installModule", + "moduleOperations recreateModules() propagates a failure instead of reporting success", + "moduleOperations recreateModules() provisions the DB accounts only after every container is back on config values", + "moduleOperations recreateModules() provisions the venues that came back before failing the run", + "moduleOperations recreateModules() recreates a container the registry has lost rather than skipping it", + "moduleOperations recreateModules() recreates from the current config while reusing the existing image", + "moduleOperations recreateModules() refuses the modules whose containers are not built from the config map", + "moduleOperations recreateModules() refuses to recreate a module docker positively reports gone", + "moduleOperations recreateModules() rotates the shared hub DB account after recreating the hub", + "moduleOperations recreateModules() skips DB provisioning for a service that owns no DB account", + "moduleOperations recreateModules() still recreates the supported modules when the request also names an unsupported one", + "moduleOperations recreateModules() still recreates when the presence probe is inconclusive" + ], + "9a4ded303b89d06c": [ + "rail-leg.yml anchor arm heights canonicalizes any populated input into one height for all three gates", + "rail-leg.yml anchor arm heights declares three optional inputs and resolves them before any stack install", + "rail-leg.yml anchor arm heights exports nothing when unset and rejects conflicting or invalid heights", + "rail-leg.yml anchor arm heights leaves the drive environment and missing-value diagnostics unchanged when unset", + "rail-leg.yml anchor arm heights leaves the standing stack and both indexer configs unchanged when unset", + "rail-leg.yml anchor arm heights puts the same height in the standing stack and both indexer configs", + "rail-leg.yml anchor arm heights writes the resolved heights into the drive environment without logging values" + ], + "9b06c8565cfe1f81": [ + "Chaos: Process Resilience Experiment 11: Async error propagation propagates rejection from statusChanged()" ], "9b210872d17a3ffa": [ "identify-deployed-ref (deploy-informing script) argument parsing accepts several labelled hashes in one walk", @@ -1544,6 +2550,78 @@ "identify-deployed-ref (deploy-informing script) tree hash depends on manifest order, so two trees of the same files differ", "identify-deployed-ref (deploy-informing script) tree hash is stable for the same manifest" ], + "9b540951387cb2cb": [ + "GitHubDownloader getReleases(): generic error throws with generic error message on non-404 errors" + ], + "9c20d50b97776974": [ + "AutohealService keeps the earned backoff when a restarted container is still in Docker starting probation" + ], + "9d1f2fd0b2ea4240": [ + "Chaos: Download Resilience Experiment 8b: GitHub rate limiting throws descriptive error on HTTP 500 (server error)" + ], + "9dc7ea4f45a7ab68": [ + "TelemetryService opt-out precedence gives the CLI flag priority and persists that choice", + "TelemetryService opt-out precedence honours the environment when the CLI flag is absent", + "TelemetryService opt-out precedence honours the saved preference when flag and environment are absent", + "TelemetryService opt-out precedence reports when flag, environment, and preference all allow it", + "TelemetryService payload privacy emits only allowlisted payload keys with no IP or host field", + "TelemetryService.gatherModules() (via gatherPayload) adds a null-safe `health` field and leaves `running` a pure liveness signal", + "TelemetryService.gatherModules() (via gatherPayload) never throws a TypeError when status shape is sparse" + ], + "9e678ad6dc8a9808": [ + "moduleOperations clearDecoderReorgHalt() ignores non-decoder modules in the list", + "moduleOperations clearDecoderReorgHalt() passes --force and --dry-run through", + "moduleOperations clearDecoderReorgHalt() refuses a real clear with no reason at all without touching any container", + "moduleOperations clearDecoderReorgHalt() refuses a trivial reason without touching any container", + "moduleOperations clearDecoderReorgHalt() reports false when no decoder container is installed for the target", + "moduleOperations clearDecoderReorgHalt() reports false when the script refuses (non-zero exit) and prints its text", + "moduleOperations clearDecoderReorgHalt() runs a dry run without a reason and passes no --reason", + "moduleOperations clearDecoderReorgHalt() runs the decoder's own clear script inside the decoder container with the reason", + "moduleOperations clearDecoderReorgHalt(): decoder without the clear tool adds no update hint when the probe finds the tool or cannot tell", + "moduleOperations clearDecoderReorgHalt(): decoder without the clear tool names a decoder update when the container has no src/clear_reorg_halt.js", + "moduleOperations execModules() passes command to execContainer", + "moduleOperations restartModules() calls restartContainer and statusChanged", + "moduleOperations startModules() looks up container ID and calls startContainer", + "moduleOperations startModules() skips module when container ID is not found", + "moduleOperations stopModules() looks up the container id and stops it with the service budget, not a bare docker stop", + "moduleOperations stopModules() reads the container before the stop so a drain that predates the budget is named", + "moduleOperations uninstallModules() calls uninstallModule for each module", + "moduleOperations uninstallModules() continues on error for individual modules, then fails the batch", + "moduleOperations uninstallModules() rejects when all modules fail, naming every one of them", + "moduleOperations uninstallModules() reports what it removed when every module succeeds" + ], + "9e93b855fedba971": [ + "ExplorerService: installExplorerModule() persistent attach failure exhausts the ping retries and throws instead of returning success" + ], + "9edf66b2734f0658": [ + "ModuleService installModule(): node: no remote release to download refuses by name, never with a TypeError, for a null entry", + "ModuleService installModule(): node: no remote release to download refuses by name, never with a TypeError, for an entry with no tag_name" + ], + "9f5847124ddc28f6": [ + "S-SMOKE-002 – Commander CLI Registration bootstrap has correct argument counts (4 required, 0 optional)", + "S-SMOKE-002 – Commander CLI Registration e2etest has correct argument counts (1 required, 1 optional)", + "S-SMOKE-002 – Commander CLI Registration each command has an action handler", + "S-SMOKE-002 – Commander CLI Registration exec has correct argument counts (4 required, 0 optional)", + "S-SMOKE-002 – Commander CLI Registration install has correct argument counts (0 required, 4 optional)", + "S-SMOKE-002 – Commander CLI Registration logs has correct argument counts (0 required, 3 optional)", + "S-SMOKE-002 – Commander CLI Registration monitor has correct argument counts (0 required, 3 optional)", + "S-SMOKE-002 – Commander CLI Registration ps has correct argument counts (0 required, 0 optional)", + "S-SMOKE-002 – Commander CLI Registration registers all expected commands", + "S-SMOKE-002 – Commander CLI Registration reset has correct argument counts (3 required, 0 optional)", + "S-SMOKE-002 – Commander CLI Registration restart has correct argument counts (1 required, 2 optional)", + "S-SMOKE-002 – Commander CLI Registration rollback has correct argument counts (4 required, 0 optional)", + "S-SMOKE-002 – Commander CLI Registration shell has correct argument counts (3 required, 0 optional)", + "S-SMOKE-002 – Commander CLI Registration start has correct argument counts (1 required, 2 optional)", + "S-SMOKE-002 – Commander CLI Registration stop has correct argument counts (1 required, 2 optional)", + "S-SMOKE-002 – Commander CLI Registration tail has correct argument counts (0 required, 3 optional)", + "S-SMOKE-002 – Commander CLI Registration tailmonitor has correct argument counts (0 required, 3 optional)", + "S-SMOKE-002 – Commander CLI Registration uninstall has correct argument counts (1 required, 2 optional)", + "S-SMOKE-002 – Commander CLI Registration update has correct argument counts (0 required, 4 optional)" + ], + "9fbe6ba46e39d44d": [ + "AutohealService records a failed restart without aborting the pass and reports it", + "AutohealService skips a container that cannot be inspected instead of failing the pass" + ], "a04c8cc3113456d6": [ "deploy source identity freshness of a named branch REFUSES the deploy when the ref keeps resolving to a stale commit", "deploy source identity freshness of a named branch accepts a clone that landed on the branch tip, with no second clone", @@ -1562,175 +2640,228 @@ "deploy source identity reporting names the full commit, its date and its subject on every deploy clone", "deploy source identity reporting reports after the swap on a rewrite over an existing checkout" ], - "a40634de4e012c02": [ - "state dbRootPassword can be reset to null", - "state dbRootPassword defaults to null", - "state dbRootPassword round-trips a password", - "state installedModules defaults to empty object", - "state installedModules resetInstalledModules clears to empty object", - "state installedModules round-trips a nested object", - "state lastPrintedStatus appendLastPrintedStatus concatenates", - "state lastPrintedStatus defaults to empty string after reset", - "state lastPrintedStatus round-trips a string", - "state lastStatus defaults to null", - "state lastStatus round-trips an object", - "state remoteModuleVersions can set and retrieve individual versions", - "state remoteModuleVersions preserves previous versions when adding new ones", - "state statusUpdated can be set to true", - "state statusUpdated defaults to false", - "state verbose can be set to true", - "state verbose defaults to false" - ], - "a5c2a73d73682ccc": [ - "DatabaseService addUserPasswordToDatabase() creates database and user via docker when db does not exist", - "DatabaseService addUserPasswordToDatabase() grants DrillB parity permissions to a NON-mainnet indexer", - "DatabaseService addUserPasswordToDatabase() grants MVH test permissions when module is xchain-hub", - "DatabaseService addUserPasswordToDatabase() grants SLAVE MONITOR to indexer and decoder accounts, on MAINNET too", - "DatabaseService addUserPasswordToDatabase() rotates the password via ALTER USER on the docker path when the user does not match", - "DatabaseService addUserPasswordToDatabase() skips create when database already exists (dbCount != 0)", - "DatabaseService addUserPasswordToDatabase() throws when docker exec fails", - "DatabaseService addUserPasswordToDatabase() withholds SLAVE MONITOR from the hub account", - "DatabaseService addUserPasswordToDatabase() withholds the DrillB grant from a MAINNET indexer", - "DatabaseService addUserPasswordToDatabase(): EXTERNAL_DB path creates database and user via native mariadb when EXTERNAL_DB=true", - "DatabaseService addUserPasswordToDatabase(): EXTERNAL_DB path grants MVH permissions for hub module via native mariadb (EXTERNAL_DB)", - "DatabaseService addUserPasswordToDatabase(): EXTERNAL_DB path grants SLAVE MONITOR to an indexer account via native mariadb (EXTERNAL_DB)", - "DatabaseService addUserPasswordToDatabase(): EXTERNAL_DB path skips creates when db and user already exist (EXTERNAL_DB)", - "DatabaseService addUserPasswordToDatabase(): EXTERNAL_DB path throws when native mariadb command fails (EXTERNAL_DB)", - "DatabaseService addUserPasswordToDatabase(): provisioning-SQL safety escapes a quote-containing password into a single well-formed string literal (docker path)", - "DatabaseService addUserPasswordToDatabase(): provisioning-SQL safety leaves a plain password byte-for-byte unchanged (no behaviour drift for existing installs)", - "DatabaseService addUserPasswordToDatabase(): provisioning-SQL safety rejects an injection-bearing database name before any SQL runs", - "DatabaseService addUserPasswordToDatabase(): provisioning-SQL safety rejects an injection-bearing user name before any SQL runs", - "DatabaseService askMariadbRootPassword() falls back to password prompt when no container found", - "DatabaseService askMariadbRootPassword() falls back to the credentials-store copy when the container has no MYSQL_ROOT_PASSWORD env", - "DatabaseService askMariadbRootPassword() falls through to the container-env read when XCHAIN_NODE_DB_ROOT_PASSWORD does not verify", - "DatabaseService askMariadbRootPassword() ignores a stale credentials-store copy and reaches the prompt on a TTY", - "DatabaseService askMariadbRootPassword() persists the container-env password to the credentials store on accept", - "DatabaseService askMariadbRootPassword() reads from XCHAIN_NODE_DB_ROOT_PASSWORD env var when not cached and no container is running", - "DatabaseService askMariadbRootPassword() reads from external DB config when EXTERNAL_DB=true and not cached", - "DatabaseService askMariadbRootPassword() reads root password from running container printenv", - "DatabaseService askMariadbRootPassword() returns cached password immediately", - "DatabaseService askMariadbRootPassword() stays silent when the env override authenticates", - "DatabaseService askMariadbRootPassword() throws an actionable error instead of prompting when stdin is not a TTY", - "DatabaseService askMariadbRootPassword() verifies XCHAIN_NODE_DB_ROOT_PASSWORD against a running container before trusting it", - "DatabaseService askMariadbRootPassword() warns, without printing a value, when the env override does not authenticate", - "DatabaseService buildDatabaseModule() aborts the install (no docker run) when a host-port conflict is detected", - "DatabaseService buildDatabaseModule() appends MariaDB tuning args to docker run when env vars are set", - "DatabaseService buildDatabaseModule() defaults max-connections to 1000 when the env var is unset (multi-chain saturation guard)", - "DatabaseService buildDatabaseModule() does not enroll the DB in any restart path", - "DatabaseService buildDatabaseModule() fails fast with an actionable error when the existing DB container is not running", - "DatabaseService buildDatabaseModule() force-removes only after docker positively reports the container gone", - "DatabaseService buildDatabaseModule() gives the DB container a health probe so a stalled mariadbd is visible", - "DatabaseService buildDatabaseModule() installs mariadb when no existing database found", - "DatabaseService buildDatabaseModule() omits MariaDB tuning args when env vars are unset", - "DatabaseService buildDatabaseModule() passes MYSQL_ROOT_PASSWORD via docker env, never in the argv", - "DatabaseService buildDatabaseModule() refuses to force-remove the MariaDB container when the probe says 'exists'", - "DatabaseService buildDatabaseModule() refuses to force-remove the MariaDB container when the probe says 'unknown'", - "DatabaseService buildDatabaseModule() runs the multi-stack host-port pre-flight before docker run", - "DatabaseService buildDatabaseModule() skips installation when database already exists and connects to network", - "DatabaseService buildDatabaseModule() throws instead of returning undefined when docker run output is not a 64-hex id", - "DatabaseService buildDatabaseModule(): EXTERNAL_DB path pings external MariaDB and returns true when reachable", - "DatabaseService buildDatabaseModule(): EXTERNAL_DB path throws descriptive error when external MariaDB is unreachable", - "DatabaseService buildDatabaseModule(): no coin/network appends DB data dir volume mount when XCHAIN_NODE_DB_DATA_DIR is set", - "DatabaseService buildDatabaseModule(): no coin/network returns true without network join when coin+network are empty", - "DatabaseService buildDatabaseModule(): no coin/network throws string error when addContainerToNetwork fails on existing container", - "DatabaseService checkIfDatabaseIsReady() honours a caller-supplied retry budget", - "DatabaseService checkIfDatabaseIsReady() passes -D database arg when database is specified", - "DatabaseService checkIfDatabaseIsReady() passes the password via MYSQL_PWD env, never in argv", - "DatabaseService checkIfDatabaseIsReady() retries up to 10 times then returns false", - "DatabaseService checkIfDatabaseIsReady() returns true when database responds", - "DatabaseService checkIfDatabaseIsReady() still spends the full readiness budget when the caller passes none", - "DatabaseService checkIfDatabaseModuleExists() returns container ID when database container exists and is valid", - "DatabaseService checkIfDatabaseModuleExists() returns null when container does not have State.Status", - "DatabaseService checkIfDatabaseModuleExists() returns null when docker inspect fails", - "DatabaseService checkIfDatabaseModuleExists() returns null when getStatusFromContainer throws", - "DatabaseService clearHubPriceIngestWatermark() a hub database shared with another network clears the legacy unset bucket alongside the network row", - "DatabaseService clearHubPriceIngestWatermark() a hub database shared with another network folds the reset network case- and whitespace-insensitively, matching the hub", - "DatabaseService clearHubPriceIngestWatermark() a hub database shared with another network issues a different WHERE clause per network for the same chain", - "DatabaseService clearHubPriceIngestWatermark() a hub database shared with another network no longer refuses on a hub configured for another network", - "DatabaseService clearHubPriceIngestWatermark() a hub database shared with another network scopes the delete to the network being reset, leaving every other network's fence", - "DatabaseService clearHubPriceIngestWatermark() deletes only the reset chain's fence row from the hub DB", - "DatabaseService clearHubPriceIngestWatermark() goes through the native driver in EXTERNAL_DB mode", - "DatabaseService clearHubPriceIngestWatermark() prints the manual statement and returns false when this MariaDB has no hub table", - "DatabaseService clearHubPriceIngestWatermark() refuses a hub DB name that is not a safe identifier", - "DatabaseService clearHubPriceIngestWatermark() throws on an unknown coin rather than deleting nothing and reporting success", - "DatabaseService clearHubPriceIngestWatermark() warns instead of throwing when the hub config carries no HUB_DB_NAME", - "DatabaseService ensureDatabasePool() creates database pool when not ready (docker path)", - "DatabaseService ensureDatabasePool() is a no-op when db is already ready", - "DatabaseService ensureXchainNodeAccess() reprovisions when stored credentials no longer work", - "DatabaseService ensureXchainNodeAccess() returns existing working credentials without reprovisioning", - "DatabaseService ensureXchainNodeAccess() throws \"MariaDB is not responding\" when root checkIfDatabaseIsReady returns false", - "DatabaseService ensureXchainNodeAccess() throws when container not found and no credentials", - "DatabaseService ensureXchainNodeAccess(): EXTERNAL_DB path provisions fresh creds when no existing credentials stored", - "DatabaseService ensureXchainNodeAccess(): EXTERNAL_DB path reprovisions via native commands when existing creds fail on external MariaDB", - "DatabaseService ensureXchainNodeAccess(): EXTERNAL_DB path returns existing creds when they work against external MariaDB", - "DatabaseService executeDockerMariaDbCommand() appends commandOptions when provided", - "DatabaseService executeDockerMariaDbCommand() pipes SQL via stdin (never argv) with the password in env, not argv", - "DatabaseService executeDockerMariaDbCommand() rejects on non-zero exit", - "DatabaseService executeDockerMariaDbCommand() scrubs the SQL from a failed command (avoids leaking an embedded password)", - "DatabaseService executeNativeMariaDbCommand() asks the driver for array rows when a clustered flag grants batch mode", - "DatabaseService executeNativeMariaDbCommand() closes connection even when query throws", - "DatabaseService executeNativeMariaDbCommand() recognizes --batch flag", - "DatabaseService executeNativeMariaDbCommand() recognizes the clustered short flag -BN as batch mode", - "DatabaseService executeNativeMariaDbCommand() recognizes the clustered short flag in either order (-NB)", - "DatabaseService executeNativeMariaDbCommand() returns empty string for DDL (non-array result)", - "DatabaseService executeNativeMariaDbCommand() returns empty string for SELECT without batch mode", - "DatabaseService executeNativeMariaDbCommand() returns empty string for empty batch result", - "DatabaseService executeNativeMariaDbCommand() returns tab-delimited rows in batch mode (-B -N)", - "DatabaseService executeNativeMariaDbCommand() returns tab-joined columns in batch mode for multi-column rows", - "DatabaseService executeNativeMariaDbCommand() scrubs the SQL from a driver query error (avoids leaking an embedded password)", - "DatabaseService executeNativeMariaDbCommand() still returns empty for an option string carrying no batch flag", - "DatabaseService getDatabaseContainerId() returns container ID when inspect returns valid 64-char hex", - "DatabaseService getDatabaseContainerId() returns null when inspect command throws", - "DatabaseService getDatabaseContainerId() returns null when inspect returns non-hex output", - "DatabaseService getDatabaseHostPort() parses port from docker port output", - "DatabaseService getDatabaseHostPort() returns default port when docker port fails", - "DatabaseService getDatabaseHostPort() returns default port when output has no port match", - "DatabaseService getExternalDbConfig() fails fast instead of prompting when stdin is not a TTY", - "DatabaseService getExternalDbConfig() fails fast on a partial env set rather than prompting headlessly", - "DatabaseService getExternalDbConfig() re-prompts interactively when saved config ping fails", - "DatabaseService getExternalDbConfig() returns config from env vars when all four are set", - "DatabaseService getExternalDbConfig() returns saved config from credentials when ping succeeds", - "DatabaseService getExternalDbConfig() throws on a malformed external-DB port from env instead of returning NaN", - "DatabaseService getExternalDbConfig() throws on an out-of-range external-DB port from env", - "DatabaseService purgeHubCrossChainRows() goes through the native driver in EXTERNAL_DB mode", - "DatabaseService purgeHubCrossChainRows() keeps the snapshots when the co-located hub is configured for another network", - "DatabaseService purgeHubCrossChainRows() keeps the snapshots when the hub database holds another network's rows", - "DatabaseService purgeHubCrossChainRows() prints the manual statements and returns false when this MariaDB has no hub tables", - "DatabaseService purgeHubCrossChainRows() purges all three tables for a Bitcoin regtest re-genesis", - "DatabaseService purgeHubCrossChainRows() purges only the leg-scoped rows for a non-Bitcoin regtest re-genesis", - "DatabaseService purgeHubCrossChainRows() refuses a hub DB name that is not a safe identifier", - "DatabaseService purgeHubCrossChainRows() throws on an unknown coin rather than purging nothing and reporting success", - "DatabaseService purgeHubCrossChainRows() touches nothing at all off regtest", - "DatabaseService resetDatabases() drops and recreates each database module", - "DatabaseService resetDatabases() drops the CONFIGURED database names, not the derived defaults", - "DatabaseService resetDatabases() falls back to the derived name when config carries no database name", - "DatabaseService resetDatabases() refuses the docker-mode reset when a derived database name is not a safe identifier", - "DatabaseService resetDatabases() refuses the external-DB reset on the second module before the first is dropped", - "DatabaseService resetDatabases() refuses the reset when a CONFIGURED database name is not a safe identifier", - "DatabaseService resetDatabases() refuses to drop anything when no MariaDB container exists", - "DatabaseService setDatabaseParameters() checks for credential drift with the config values, before any account write", - "DatabaseService setDatabaseParameters() does not blame the docker network for a drift refusal", - "DatabaseService setDatabaseParameters() iterates installed coins+networks and adds DB users", - "DatabaseService setDatabaseParameters() refuses to run against an unconfigured module registry", - "DatabaseService setDatabaseParameters() throws and logs when coin network processing fails", - "DatabaseService setDatabaseParameters() throws instead of reporting success when no coin/network is installed", - "DatabaseService setDatabaseParameters() throws when the registry lists a coin but holds no decoder/indexer container", - "DatabaseService setDatabaseParameters() writes no account when the drift guard refuses", - "DatabaseService: clearHubPriceIngestWatermark() against a pre-network-column hub does not fall back for an unknown column that is not `network`", - "DatabaseService: clearHubPriceIngestWatermark() against a pre-network-column hub does not fall back on an ordinary SQL failure", - "DatabaseService: clearHubPriceIngestWatermark() against a pre-network-column hub falls back to the chain-only delete and reports success", - "DatabaseService: clearHubPriceIngestWatermark() against a pre-network-column hub recognises the driver-shaped ER_BAD_FIELD_ERROR in EXTERNAL_DB mode", - "DatabaseService: clearHubPriceIngestWatermark() against a pre-network-column hub warns once, naming the column and the hub version that carries it" - ], - "a67181a3796fad54": [ - "StopBudgetService moduleStopTimeoutSeconds() answers the coin node from its own resolver so `stop node` and `stop all` share one path", - "StopBudgetService moduleStopTimeoutSeconds() gives the decoder and tracker more than docker's ten seconds, and the rest a default above it", - "StopBudgetService moduleStopTimeoutSeconds() ignores a value that is not a whole number of seconds, and says so", - "StopBudgetService moduleStopTimeoutSeconds() reads the per-service override by the derived variable name", - "StopBudgetService stopModuleContainer() says nothing when there was nothing to stop", - "StopBudgetService stopModuleContainer() stops with the budget and reports a clean stop with the time it took", - "StopBudgetService stopModuleContainer() warns when the service ran out of budget and was killed, naming the override", - "StopBudgetService stopTimeoutArgs() stamps the same budget on the container so a plain docker stop honours it" + "a11cf22041537e1c": [ + "ExplorerService: installExplorerModule() on a stack with no coins yet a healthy explorer is accepted whether or not a coin is installed", + "ExplorerService: installExplorerModule() on a stack with no coins yet accepts an answering-but-degraded explorer when no coin is installed", + "ExplorerService: installExplorerModule() on a stack with no coins yet refuses a container that never answers at all, coins or not", + "ExplorerService: installExplorerModule() on a stack with no coins yet still REFUSES an answering-but-degraded explorer once a coin is installed" + ], + "a2020242e0b8d026": [ + "ConfigService preferred (_SECRET) sidecar names a fresh coin/network sidecar writes RPC and DB credentials under their redaction-safe _SECRET names", + "ConfigService preferred (_SECRET) sidecar names a full install (generate) plus reload emits no deprecated-name warning", + "ConfigService preferred (_SECRET) sidecar names a reload of a freshly-generated sidecar resolves the same credentials without regenerating them", + "ConfigService preferred (_SECRET) sidecar names a rotation on a sidecar already using the new name does not reintroduce the legacy twin", + "ConfigService preferred (_SECRET) sidecar names legacy fallback: an existing old-name sidecar still resolves and is not force-renamed", + "ConfigService preferred (_SECRET) sidecar names still warns when an existing sidecar carries a deprecated legacy name" + ], + "a21838fb4a8d1697": [ + "DockerService getDockerNetworkInspect(): error branch rejects when docker network inspect fails" + ], + "a21fd2dad9b609f9": [ + "StatusService: getStatus() with installed modules builds status for a running container and returns installedModules", + "StatusService: getStatus() with installed modules cleans up empty coin slot when all containers are confirmed gone", + "StatusService: getStatus() with installed modules does NOT prune or touch the registry on a transient inspect failure (daemon unreachable)", + "StatusService: getStatus() with installed modules removes module from installedModules AND reconciles the registry when docker confirms the container is gone", + "StatusService: getStatus() with installed modules sets local_version and container_version on module", + "StatusService: getStatus() with installed modules sets remote_version on module from remoteModuleVersions" + ], + "a38c76fb41c7130a": [ + "Regression Suite [regression:p1] Config Parsing Boundaries R-BND-001: config values containing \"=\" are preserved fully", + "Regression Suite [regression:p1] Config Parsing Boundaries R-BND-002: base64 values with trailing \"=\" are preserved", + "Regression Suite [regression:p1] Config Parsing Boundaries R-BND-003: empty value after \"=\" is preserved as empty string", + "Regression Suite [regression:p1] Config Parsing Boundaries R-BND-004: blank lines and lines without \"=\" are skipped", + "Regression Suite [regression:p1] Config Parsing Boundaries R-BND-005: missing config file falls back to defaults" + ], + "a3960f64f6ba6129": [ + "Fuzz: Port Validation in buildAndUp() rejects when ENCODER_API_PORT is NaN", + "Fuzz: Port Validation in buildAndUp() rejects when ENCODER_PORT is above 65535", + "Fuzz: Port Validation in buildAndUp() rejects when ENCODER_PORT is negative", + "Fuzz: Port Validation in buildAndUp() rejects when port contains shell injection", + "Fuzz: Port Validation in buildAndUp() rejects when port is a float", + "Fuzz: Port Validation in buildAndUp() rejects when port is zero", + "Fuzz: Port Validation in buildAndUp() succeeds with no port mapping (module without port config)", + "Fuzz: Port Validation in buildAndUp() succeeds with valid port pair", + "Fuzz: validatePort() accepts port as string \"1\"", + "Fuzz: validatePort() accepts port as string \"3000\"", + "Fuzz: validatePort() accepts port as string \"65535\"", + "Fuzz: validatePort() accepts valid port: 1", + "Fuzz: validatePort() accepts valid port: 18080", + "Fuzz: validatePort() accepts valid port: 3000", + "Fuzz: validatePort() accepts valid port: 3306", + "Fuzz: validatePort() accepts valid port: 443", + "Fuzz: validatePort() accepts valid port: 65535", + "Fuzz: validatePort() accepts valid port: 80", + "Fuzz: validatePort() accepts valid port: 8080", + "Fuzz: validatePort() accepts valid port: 8332", + "Fuzz: validatePort() accepts valid port: 8443", + "Fuzz: validatePort() rejects invalid port: -Infinity (null)", + "Fuzz: validatePort() rejects invalid port: Infinity (null)", + "Fuzz: validatePort() rejects invalid port: Infinity string (\"Infinity\")", + "Fuzz: validatePort() rejects invalid port: NaN (null)", + "Fuzz: validatePort() rejects invalid port: NaN string (\"NaN\")", + "Fuzz: validatePort() rejects invalid port: above range (65536)", + "Fuzz: validatePort() rejects invalid port: alphabetic (\"abc\")", + "Fuzz: validatePort() rejects invalid port: array ([])", + "Fuzz: validatePort() rejects invalid port: boolean false (false)", + "Fuzz: validatePort() rejects invalid port: boolean true (true)", + "Fuzz: validatePort() rejects invalid port: empty string (\"\")", + "Fuzz: validatePort() rejects invalid port: float (3.14)", + "Fuzz: validatePort() rejects invalid port: float string (\"3.14\")", + "Fuzz: validatePort() rejects invalid port: hex string (\"0x1F90\")", + "Fuzz: validatePort() rejects invalid port: huge number (1000000)", + "Fuzz: validatePort() rejects invalid port: injection (\"3000; rm -rf /\")", + "Fuzz: validatePort() rejects invalid port: negative (-1)", + "Fuzz: validatePort() rejects invalid port: negative large (-65535)", + "Fuzz: validatePort() rejects invalid port: null (null)", + "Fuzz: validatePort() rejects invalid port: object ({})", + "Fuzz: validatePort() rejects invalid port: scientific notation (\"1e4\")", + "Fuzz: validatePort() rejects invalid port: undefined (undefined)", + "Fuzz: validatePort() rejects invalid port: very large (99999)", + "Fuzz: validatePort() rejects invalid port: zero (0)" + ], + "a40634de4e012c02": [ + "state dbRootPassword can be reset to null", + "state dbRootPassword defaults to null", + "state dbRootPassword round-trips a password", + "state installedModules defaults to empty object", + "state installedModules resetInstalledModules clears to empty object", + "state installedModules round-trips a nested object", + "state lastPrintedStatus appendLastPrintedStatus concatenates", + "state lastPrintedStatus defaults to empty string after reset", + "state lastPrintedStatus round-trips a string", + "state lastStatus defaults to null", + "state lastStatus round-trips an object", + "state remoteModuleVersions can set and retrieve individual versions", + "state remoteModuleVersions preserves previous versions when adding new ones", + "state statusUpdated can be set to true", + "state statusUpdated defaults to false", + "state verbose can be set to true", + "state verbose defaults to false" + ], + "a4a05cec47b48b65": [ + "Fuzz: filterCommandParameters() \"all\" coins produces exactly 3 coin entries", + "Fuzz: filterCommandParameters() \"all\" modules adds explorer under empty coin/network keys", + "Fuzz: filterCommandParameters() \"all\" modules includes every non-e2e service plus node", + "Fuzz: filterCommandParameters() \"all\" networks produces exactly 3 network entries per coin", + "Fuzz: filterCommandParameters() \"explorer\" module produces only explorer entry", + "Fuzz: filterCommandParameters() all x all x all produces bounded output", + "Fuzz: filterCommandParameters() does not crash with invalid coin: \"\"", + "Fuzz: filterCommandParameters() does not crash with invalid coin: \"BITCOIN\"", + "Fuzz: filterCommandParameters() does not crash with invalid coin: \"Bitcoin\"", + "Fuzz: filterCommandParameters() does not crash with invalid coin: \"bitcoin; echo pwned\"", + "Fuzz: filterCommandParameters() does not crash with invalid coin: \"ethereum\"", + "Fuzz: filterCommandParameters() does not crash with invalid coin: 123", + "Fuzz: filterCommandParameters() does not crash with invalid module: \"\"", + "Fuzz: filterCommandParameters() does not crash with invalid module: \"../../../etc/passwd\"", + "Fuzz: filterCommandParameters() does not crash with invalid module: \"ALL\"", + "Fuzz: filterCommandParameters() does not crash with invalid module: \"Node\"", + "Fuzz: filterCommandParameters() does not crash with invalid module: \"nonexistent-service\"", + "Fuzz: filterCommandParameters() does not crash with invalid module: \"xchain-encoder; rm -rf /\"", + "Fuzz: filterCommandParameters() does not crash with invalid module: \"xchain-encoder\\u0000\"", + "Fuzz: filterCommandParameters() does not crash with invalid module: 123", + "Fuzz: filterCommandParameters() does not crash with invalid network: \"\"", + "Fuzz: filterCommandParameters() does not crash with invalid network: \"MAINNET\"", + "Fuzz: filterCommandParameters() does not crash with invalid network: \"Mainnet\"", + "Fuzz: filterCommandParameters() does not crash with invalid network: \"devnet\"", + "Fuzz: filterCommandParameters() does not crash with invalid network: \"mainnet && echo pwned\"", + "Fuzz: filterCommandParameters() e2e-test is never included in \"all\" expansion", + "Fuzz: filterCommandParameters() explicitly-named shared service \"database\" routes under empty coin/network only", + "Fuzz: filterCommandParameters() explicitly-named shared service \"xchain-explorer\" routes under empty coin/network only", + "Fuzz: filterCommandParameters() explicitly-named shared service \"xchain-hub\" routes under empty coin/network only", + "Fuzz: filterCommandParameters() explicitly-named shared service \"xchain-sync\" routes under empty coin/network only", + "Fuzz: filterCommandParameters() regtest-miner IS included for regtest network", + "Fuzz: filterCommandParameters() regtest-only modules excluded from all non-regtest networks", + "Fuzz: filterCommandParameters() shared service \"database\" ignores coin/network args (always shared key)", + "Fuzz: filterCommandParameters() shared service \"xchain-explorer\" ignores coin/network args (always shared key)", + "Fuzz: filterCommandParameters() shared service \"xchain-hub\" ignores coin/network args (always shared key)", + "Fuzz: filterCommandParameters() shared service \"xchain-sync\" ignores coin/network args (always shared key)", + "Fuzz: filterCommandParameters() throws on null module (not iterable)", + "Fuzz: filterCommandParameters() throws on undefined module (not iterable)" + ], + "a543adaaff87951c": [ + "Node update data directory preflight preflights the complete request before updating a service listed ahead of the node", + "Node update data directory preflight refuses a node update with no configured data directory before replacement begins", + "Node update data directory preflight updates the node when the data directory is explicitly configured" + ], + "a55e0292bbecf0f6": [ + "MigrationPreconditionService assertRequiredMigrationsApplied checks the database belonging to the module, coin and network being updated", + "MigrationPreconditionService assertRequiredMigrationsApplied covers the indexer and the decoder, the two migration-bearing modules", + "MigrationPreconditionService assertRequiredMigrationsApplied does NOT print a scoped command the running build would ignore", + "MigrationPreconditionService assertRequiredMigrationsApplied does not touch the database when the target source declares no preconditions", + "MigrationPreconditionService assertRequiredMigrationsApplied is inert for a module that ships no migrations", + "MigrationPreconditionService assertRequiredMigrationsApplied passes when every declared precondition is in the ledger", + "MigrationPreconditionService assertRequiredMigrationsApplied proceeds on a genuinely empty database (a fresh install cannot be behind)", + "MigrationPreconditionService assertRequiredMigrationsApplied proceeds with a warning when the source itself cannot be cloned", + "MigrationPreconditionService assertRequiredMigrationsApplied proceeds, loudly, when the skip env is set", + "MigrationPreconditionService assertRequiredMigrationsApplied reads the source tree about to be deployed, at the pinned ref", + "MigrationPreconditionService assertRequiredMigrationsApplied refuses when the migration state cannot be read, and says so is not the same as missing", + "MigrationPreconditionService assertRequiredMigrationsApplied refuses when the target DB has not applied a declared precondition", + "MigrationPreconditionService assertRequiredMigrationsApplied still refuses when the capability probe itself throws", + "MigrationPreconditionService assertRequiredMigrationsApplied treats an unreadable container as lacking the capability", + "MigrationPreconditionService assertRequiredMigrationsApplied uses the moved default migration reader when no deps are passed", + "MigrationPreconditionService listDeployPreconditionMigrations reads the REAL indexer tree and finds the bridge-tables migration", + "MigrationPreconditionService listDeployPreconditionMigrations reads the REAL indexer tree and finds the migration behind the 2026-08-09 halt", + "MigrationPreconditionService listDeployPreconditionMigrations returns [] for a missing directory (a ref with no migrations declares nothing)", + "MigrationPreconditionService listDeployPreconditionMigrations returns only the tagged .sql files, sorted", + "MigrationPreconditionService migrationDeclaresDeployPrecondition ignores the token on a comment line that is not the directive", + "MigrationPreconditionService migrationDeclaresDeployPrecondition ignores the token once the SQL body has started", + "MigrationPreconditionService migrationDeclaresDeployPrecondition is false for an ordinary migration and for an empty file", + "MigrationPreconditionService migrationDeclaresDeployPrecondition reads the tag off the xchain:migration directive line", + "MigrationPreconditionService migrationDeclaresDeployPrecondition sees the tag through a long license banner", + "MigrationPreconditionService migrationDeclaresDeployPrecondition tolerates spacing around the token", + "MigrationPreconditionService migrationMode defaults to manual when no recognized mode is declared", + "MigrationPreconditionService migrationMode ignores a mode token that appears after the prologue", + "MigrationPreconditionService migrationMode matches the indexer and decoder runners' rule", + "MigrationPreconditionService migrationMode reads the mode a header declares", + "MigrationPreconditionService pendingManualMigrations excludes what the ledger already carries", + "MigrationPreconditionService pendingManualMigrations lists only manual migrations the ledger has not recorded", + "MigrationPreconditionService pendingManualMigrations yields nothing for a missing directory rather than throwing", + "MigrationPreconditionService readAppliedMigrations calls a database with no tables empty, not unreadable", + "MigrationPreconditionService readAppliedMigrations calls a populated database with no ledger table UNREADABLE, never empty", + "MigrationPreconditionService readAppliedMigrations reads the ledger into a set of applied names", + "MigrationPreconditionService readAppliedMigrations refuses a database name that is not a plain identifier, before any query runs", + "MigrationPreconditionService readAppliedMigrations refuses an empty-string table count instead of reading it as zero", + "MigrationPreconditionService readAppliedMigrations refuses an unreadable ledger-presence count instead of reading it as \"no ledger\"", + "MigrationPreconditionService readAppliedMigrations refuses an unreadable table count instead of collapsing it into empty-database", + "MigrationPreconditionService readAppliedMigrations tolerates a ledger read that comes back empty", + "MigrationPreconditionService readAppliedMigrations turns a driver failure into unreadable instead of throwing past the guard" + ], + "a5a5aa45a05118df": [ + "Integration: Config Pipeline CLI params -> filterCommandParameters -> getDefaultConfig \"all\" expansion produces configs for every valid coin/network combo", + "Integration: Config Pipeline CLI params -> filterCommandParameters -> getDefaultConfig \"all\" on mainnet does not include regtest modules", + "Integration: Config Pipeline CLI params -> filterCommandParameters -> getDefaultConfig \"all\" on regtest includes regtest-miner but not e2e-test", + "Integration: Config Pipeline CLI params -> filterCommandParameters -> getDefaultConfig decoder on dogecoin/testnet gets correct DB names and ports", + "Integration: Config Pipeline CLI params -> filterCommandParameters -> getDefaultConfig indexer on litecoin/regtest gets regtest port and regtest miner config", + "Integration: Config Pipeline CLI params -> filterCommandParameters -> getDefaultConfig single encoder on bitcoin/mainnet produces correct full config", + "Integration: Config Pipeline Docker image naming Docker network naming is consistent", + "Integration: Config Pipeline Docker image naming coin-specific modules include coin and network in image name", + "Integration: Config Pipeline Docker image naming shared modules (hub, database, explorer) omit coin/network", + "Integration: Config Pipeline config correctness across all coin/network combos bitcoin/mainnet: NODE_PORT, INDEXER_COIN, DB names correct", + "Integration: Config Pipeline config correctness across all coin/network combos bitcoin/regtest: NODE_PORT, INDEXER_COIN, DB names correct", + "Integration: Config Pipeline config correctness across all coin/network combos bitcoin/testnet: NODE_PORT, INDEXER_COIN, DB names correct", + "Integration: Config Pipeline config correctness across all coin/network combos dogecoin/mainnet: NODE_PORT, INDEXER_COIN, DB names correct", + "Integration: Config Pipeline config correctness across all coin/network combos dogecoin/regtest: NODE_PORT, INDEXER_COIN, DB names correct", + "Integration: Config Pipeline config correctness across all coin/network combos dogecoin/testnet: NODE_PORT, INDEXER_COIN, DB names correct", + "Integration: Config Pipeline config correctness across all coin/network combos litecoin/mainnet: NODE_PORT, INDEXER_COIN, DB names correct", + "Integration: Config Pipeline config correctness across all coin/network combos litecoin/regtest: NODE_PORT, INDEXER_COIN, DB names correct", + "Integration: Config Pipeline config correctness across all coin/network combos litecoin/testnet: NODE_PORT, INDEXER_COIN, DB names correct", + "Integration: Config Pipeline config file override propagation config file values override defaults", + "Integration: Config Pipeline config file override propagation empty config file uses all defaults", + "Integration: Config Pipeline config file override propagation missing config file falls back to defaults only (shared module)", + "Integration: Config Pipeline config file override propagation multiple overrides in config file all take effect" + ], + "a64692a5a21e15d1": [ + "Boundary Tests ConfigService: config file parsing config file value override priority config file values override hardcoded defaults", + "Boundary Tests ConfigService: config file parsing config file value override priority hardcoded defaults fill in for keys not in config file", + "Boundary Tests ConfigService: config file parsing empty and blank config values handles empty value after \"=\"", + "Boundary Tests ConfigService: config file parsing empty and blank config values skips blank lines", + "Boundary Tests ConfigService: config file parsing empty and blank config values skips lines that start with \"=\" (no key)", + "Boundary Tests ConfigService: config file parsing empty and blank config values skips lines without \"=\"", + "Boundary Tests ConfigService: config file parsing missing config file (Fix 2) does not attempt file read for shared services (null coin/network)", + "Boundary Tests ConfigService: config file parsing missing config file (Fix 2) falls back to defaults when config file does not exist", + "Boundary Tests ConfigService: config file parsing values containing \"=\" (Fix 1) handles base64-encoded values with trailing \"=\"", + "Boundary Tests ConfigService: config file parsing values containing \"=\" (Fix 1) handles value that is just \"=\"", + "Boundary Tests ConfigService: config file parsing values containing \"=\" (Fix 1) preserves full value when it contains \"=\"" ], "a67cabacd0112216": [ "GoLiveGate armed (XCHAIN_NODE_GO_LIVE=1): refuses honors the explicit skip escape hatch, loudly", @@ -1764,172 +2895,164 @@ "GoLiveGate scope ignores read surfaces (decoder) on mainnet even when armed", "GoLiveGate scope ignores write surfaces on regtest even when armed" ], - "a88dc187e71ed005": [ - "constants: the e2e image stages every sibling its suites require bundles sync, so the consensus hash drift-lock can run instead of skipping", - "constants: the e2e image stages every sibling its suites require keeps the siblings the other suites resolve directly", - "moduleOperations clearDecoderReorgHalt() ignores non-decoder modules in the list", - "moduleOperations clearDecoderReorgHalt() passes --force and --dry-run through", - "moduleOperations clearDecoderReorgHalt() refuses a trivial reason without touching any container", - "moduleOperations clearDecoderReorgHalt() reports false when no decoder container is installed for the target", - "moduleOperations clearDecoderReorgHalt() reports false when the script refuses (non-zero exit) and prints its text", - "moduleOperations clearDecoderReorgHalt() runs the decoder's own clear script inside the decoder container with the reason", - "moduleOperations execModules() passes command to execContainer", - "moduleOperations execModules(): error path continues after execContainer error", - "moduleOperations execModules(): error path skips when container ID is null", - "moduleOperations installModules() builds database before installing modules", - "moduleOperations installModules() calls installModule for each module in the list", - "moduleOperations installModules() creates Docker network for each coin/network pair", - "moduleOperations installModules() handles multiple coins and networks", - "moduleOperations installModules() reports a module installModule declined as skipped, not installed", - "moduleOperations installModules() reports the bootstrap outcomes even when the install throws", - "moduleOperations installModules() reports what it installed on success", - "moduleOperations installModules() skips network creation and database for shared services (empty coin/network)", - "moduleOperations installModules() still reports the bootstrap outcomes on a clean install", - "moduleOperations logModules() calls logContainer with follow=true by default", - "moduleOperations logModules() follow: only attaches to the first service (single-TTY limit)", - "moduleOperations logModules() non-follow: dumps every selected service, not just the first", - "moduleOperations logModules() passes follow=false when specified", - "moduleOperations logModules(): no containers prints \"No service was selected\" when no containers found", - "moduleOperations monitorModules() collects container IDs and passes to startDockerMonitor", - "moduleOperations recreateModules() does not probe docker when the registry has a container id", - "moduleOperations recreateModules() does not rotate the hub account when the hub was not recreated", - "moduleOperations recreateModules() finishes the sweep past a failing venue and names every failure", - "moduleOperations recreateModules() never re-clones or rebuilds through installModule", - "moduleOperations recreateModules() propagates a failure instead of reporting success", - "moduleOperations recreateModules() provisions the DB accounts only after every container is back on config values", - "moduleOperations recreateModules() provisions the venues that came back before failing the run", - "moduleOperations recreateModules() recreates a container the registry has lost rather than skipping it", - "moduleOperations recreateModules() recreates from the current config while reusing the existing image", - "moduleOperations recreateModules() refuses the modules whose containers are not built from the config map", - "moduleOperations recreateModules() refuses to recreate a module docker positively reports gone", - "moduleOperations recreateModules() rotates the shared hub DB account after recreating the hub", - "moduleOperations recreateModules() skips DB provisioning for a service that owns no DB account", - "moduleOperations recreateModules() still recreates the supported modules when the request also names an unsupported one", - "moduleOperations recreateModules() still recreates when the presence probe is inconclusive", - "moduleOperations resetModules() EXTERNAL_DB pre-wipe reachability guard aborts before anything is stopped or wiped when the external DB is unreachable", - "moduleOperations resetModules() EXTERNAL_DB pre-wipe reachability guard aborts the same way when the external config cannot be resolved", - "moduleOperations resetModules() EXTERNAL_DB pre-wipe reachability guard does not probe the external DB when no database is being reset", - "moduleOperations resetModules() EXTERNAL_DB pre-wipe reachability guard proceeds to the reset when the external DB answers", - "moduleOperations resetModules() aborts a missing-MariaDB reset without stopping anything", - "moduleOperations resetModules() aborts before any wipe when a target fails to stop, and restarts what it stopped", - "moduleOperations resetModules() aborts when stopContainer rejects a non-Error value", - "moduleOperations resetModules() clears the hub price ingest fence when the indexer DB is reset", - "moduleOperations resetModules() does not continue past a failed stopContainer", - "moduleOperations resetModules() leaves the fence alone on a decoder-only reset (that chain keeps pushing prices)", - "moduleOperations resetModules() marks the reindexed combos for a forced bootstrap republish does not abort the reset when the ledger cannot be written", - "moduleOperations resetModules() marks the reindexed combos for a forced bootstrap republish marks all three on a reset all", - "moduleOperations resetModules() marks the reindexed combos for a forced bootstrap republish marks nothing for a node-only reset", - "moduleOperations resetModules() marks the reindexed combos for a forced bootstrap republish marks nothing when the reset aborts before any wipe", - "moduleOperations resetModules() marks the reindexed combos for a forced bootstrap republish marks the tracker combo when the tracker volume is wiped", - "moduleOperations resetModules() node datadir resolution falls back to the configured datadir when the container reports no mount", - "moduleOperations resetModules() node datadir resolution names the container, the configured path and the env var in the refusal", - "moduleOperations resetModules() node datadir resolution refuses the whole reset when the datadir resolves to nothing", - "moduleOperations resetModules() node datadir resolution skips the node wipe out loud, and completes, when no node is installed", - "moduleOperations resetModules() node datadir resolution wipes the path the node container reports, not the env-derived one", - "moduleOperations resetModules() only resets node data when service=node", - "moduleOperations resetModules() reports a fence-clear failure without aborting the restart pass", - "moduleOperations resetModules() resets all services when service=all", - "moduleOperations resetModules() resets decoder: stops, resets DB, and bounces", - "moduleOperations resetModules() resets node data when nodeDataPath exists", - "moduleOperations resetModules() scopes the hand-run fence statement to the reset network, plus the legacy bucket", - "moduleOperations resetModules() still treats a \"no such container\" stop as a skip and completes the reset", - "moduleOperations resetModules() stops and resets utxo-tracker when service=xchain-utxo-tracker", - "moduleOperations resetModules() the coupled decoder/indexer pair aborts the decoder-only reset when the indexer registry row cannot be read", - "moduleOperations resetModules() the coupled decoder/indexer pair allows a decoder-only reset when no indexer is installed to strand", - "moduleOperations resetModules() the coupled decoder/indexer pair leaves `reset all` alone, which already moves both", - "moduleOperations resetModules() the coupled decoder/indexer pair names the joint form in the refusal, so the remedy is runnable", - "moduleOperations resetModules() the coupled decoder/indexer pair refuses a decoder-only reset while an indexer is installed", - "moduleOperations resetModules() the coupled decoder/indexer pair resets both halves together when the joint form is used", - "moduleOperations resetModules() the coupled decoder/indexer pair still allows an indexer-only reset", - "moduleOperations resetModules() the registry read that decides what to stop aborts before any wipe when a target row cannot be read", - "moduleOperations resetModules() the registry read that decides what to stop reports a module the rollback cannot resolve as still down", - "moduleOperations resetModules() the registry read that decides what to stop still skips a module that is genuinely absent from the registry", - "moduleOperations resetModules() the regtest re-genesis hub purge does not abort the restart pass when the purge throws", - "moduleOperations resetModules() the regtest re-genesis hub purge leaves the hub rows alone when the chain itself is not reset", - "moduleOperations resetModules() the regtest re-genesis hub purge names the hub rows in the confirmation for a regtest node reset", - "moduleOperations resetModules() the regtest re-genesis hub purge never purges off regtest, where these rows are live federation history", - "moduleOperations resetModules() the regtest re-genesis hub purge purges after the price fence on reset all", - "moduleOperations resetModules() the regtest re-genesis hub purge purges the hub cross-chain rows on a regtest node reset", - "moduleOperations resetModules() the utxo-tracker volume wipe aborts and restores the stack when the wipe fails with nothing else touched", - "moduleOperations resetModules() the utxo-tracker volume wipe refuses the reset when the volume presence cannot be determined", - "moduleOperations resetModules() the utxo-tracker volume wipe refuses to drop the databases after a failed wipe on reset all", - "moduleOperations resetModules() the utxo-tracker volume wipe treats docker's own no-such-volume as absence and completes", - "moduleOperations resetModules() throws on an unknown coin", - "moduleOperations resetModules() throws on an unknown network", - "moduleOperations resetModules() throws on an unknown service instead of a silent no-op success", - "moduleOperations resetModules(): confirmation guard force=true skips the confirmation prompt entirely", - "moduleOperations resetModules(): confirmation guard refuses to reset on a non-interactive terminal without --yes/force", - "moduleOperations restartModules() calls restartContainer and statusChanged", - "moduleOperations restartModules(): error path continues after restartContainer error", - "moduleOperations runE2ETest() builds mocha docker args when testName is provided", - "moduleOperations runE2ETest() clones the suite at the ref it was given", - "moduleOperations runE2ETest() includes --grep when grep is provided with testName", - "moduleOperations runE2ETest() installs e2e module, waits, saves logs, removes container", - "moduleOperations runE2ETest() passes null dockerCmdArgs when no testName or script", - "moduleOperations runE2ETest() passes null when no ref was given, keeping the default-branch behaviour", - "moduleOperations runE2ETest() uses npm run script when script is provided", - "moduleOperations shellModule() calls shellContainer for the first module only", - "moduleOperations shellModule(): error path continues after shellContainer error and returns true", - "moduleOperations shellModule(): error path returns true when no container found", - "moduleOperations startModules() looks up container ID and calls startContainer", - "moduleOperations startModules() skips module when container ID is not found", - "moduleOperations startModules(): error path continues after startContainer error", - "moduleOperations stopModules() looks up the container id and stops it with the service budget, not a bare docker stop", - "moduleOperations stopModules(): skip when no container continues after stopContainer error", - "moduleOperations stopModules(): skip when no container skips when container ID is not found", - "moduleOperations uninstallModules() calls uninstallModule for each module", - "moduleOperations uninstallModules() continues on error for individual modules, then fails the batch", - "moduleOperations uninstallModules() rejects when all modules fail, naming every one of them", - "moduleOperations uninstallModules() reports what it removed when every module succeeds", - "moduleOperations uninstallModules(): includeShared includes shared modules when includeShared=true", - "moduleOperations uninstallModules(): includeShared includes xchain-sync when includeShared=true", - "moduleOperations uninstallModules(): includeShared keeps a shared module when another coin/network is still installed", - "moduleOperations uninstallModules(): includeShared orders the shared pass LAST, so a full teardown still reaches it", - "moduleOperations uninstallModules(): includeShared refuses the shared removal rather than guessing when the registry is unreadable", - "moduleOperations uninstallModules(): includeShared skips module when container ID is null", - "moduleOperations uninstallModules(): includeShared skips shared modules by default", - "moduleOperations uninstallModules(): includeShared skips xchain-sync by default (shared singleton, same guard as database/hub/explorer)", - "moduleOperations uninstallModules(): includeShared still removes shared modules once the last coin/network is gone", - "moduleOperations updateModules() aborts the update, leaving the running container untouched, when the guard refuses", - "moduleOperations updateModules() checks the migration precondition BEFORE the container is rebuilt", - "moduleOperations updateModules() counts a rebuilt node as updated", - "moduleOperations updateModules() fetches existing container ID before updating", - "moduleOperations updateModules() handles the node module via installModule (built from releases, not git-cloned)", - "moduleOperations updateModules() leaves the running node container to buildCryptoNode instead of force-removing it up front", - "moduleOperations updateModules() passes container ID to installModule for replacement", - "moduleOperations updateModules() rebuilds non-node modules via installModule (which re-clones internally)", - "moduleOperations updateModules() rebuilds the image, unlike recreate", - "moduleOperations updateModules() records a declined NODE rebuild as a no-op too", - "moduleOperations updateModules() records a module installModule declined as a no-op, not as updated", - "moduleOperations updateModules() recreates the node even when its container is missing (no silent no-op)", - "moduleOperations updateModules() refuses the database instead of reporting an untouched container as updated", - "moduleOperations updateModules() reports an uninstalled module as SKIPPED (empty updated list), and warns about it", - "moduleOperations updateModules() reports what it updated so a no-op run is not indistinguishable from success", - "moduleOperations updateModules() still skips a NON-node module whose container is missing", - "moduleOperations updateModules() still updates the other requested modules when the request also names the database", - "moduleOperations updateModules(): branch handling falls back to getModuleBranch (current branch) when no branch specified", - "moduleOperations updateModules(): branch handling proceeds with null branch if getModuleBranch throws", - "moduleOperations updateModules(): branch handling skips module when container ID is not found", - "moduleOperations updateModules(): branch handling threads the provided branch through to installModule", - "moduleOperations updateModules(): no-ref target resolution clones the default branch, never \"HEAD\", for a detached module the manifest does not carry", - "moduleOperations updateModules(): no-ref target resolution keeps a BRANCH node on its branch and takes newer commits", - "moduleOperations updateModules(): no-ref target resolution moves a RELEASE node to the latest release, pinned, refusing any branch fallback", - "moduleOperations updateModules(): no-ref target resolution records what the node is on, so the next no-ref update converges on it", - "moduleOperations updateModules(): no-ref target resolution stops with nothing changed when the latest release cannot be resolved", - "moduleOperations updateModules(): no-ref target resolution treats an explicitly named branch as a decision and records it", - "moduleOperations updateModules(): no-ref target resolution uses the tag a self-update already resolved instead of looking it up again", - "moduleOperations updateModules(): the `all` expansion collapses the absent services into one line under `all`, and keeps the per-service warning for a targeted update", - "moduleOperations updateModules(): the `all` expansion continues the hub update when the validator repair fails, and says so", - "moduleOperations updateModules(): the `all` expansion does not install a coin node that is absent under `all`, but still recreates one on a targeted update", - "moduleOperations updateModules(): the `all` expansion does not touch validator config on a node that is not a validator, or when the hub is out of scope", - "moduleOperations updateModules(): the `all` expansion leaves a coin node running when it already carries the pinned daemon version", - "moduleOperations updateModules(): the `all` expansion leaves the hub and sync out of a targeted update, as before", - "moduleOperations updateModules(): the `all` expansion re-runs the validator repair before rebuilding the hub on an initialized validator", - "moduleOperations updateModules(): the `all` expansion rebuilds a coin node on a TARGETED update whatever version it runs", - "moduleOperations updateModules(): the `all` expansion rebuilds a coin node whose pinned version it cannot determine", - "moduleOperations updateModules(): the `all` expansion reports a shared service that is not installed as skipped, not as a failure", - "moduleOperations updateModules(): the `all` expansion still rebuilds a coin node that is behind the pinned daemon version", - "moduleOperations updateModules(): the `all` expansion updates the hub, then sync, then the explorer, before any coin stack", + "a856fcb5d9d5fe15": [ + "Chaos: Docker Resilience Experiment 3: Docker daemon unavailable rejects when docker binary is not found (ENOENT)", + "Chaos: Docker Resilience Experiment 3: Docker daemon unavailable rejects when docker version returns empty output", + "Chaos: Docker Resilience Experiment 3: Docker daemon unavailable rejects when docker version returns permission denied", + "Chaos: Docker Resilience Experiment 3: Docker daemon unavailable rejects when docker version succeeds but daemon is down" + ], + "a8b71b9c9e21302c": [ + "rail-leg.yml DOGE follower comparison: arm wiring clones before the drive and compares after it only for an armed run", + "rail-leg.yml DOGE follower comparison: arm wiring uses the canonical height for fold-only, stake-only and slash-only inputs", + "rail-leg.yml DOGE follower comparison: clone and reads clones the stopped standing database and launches an unexposed follower", + "rail-leg.yml DOGE follower comparison: clone and reads reads H-1, H and H+5 from both followers before proving equality", + "rail-leg.yml DOGE follower comparison: mismatch control requires the injected-difference control to report a mismatch" + ], + "a96906ed1c35e555": [ + "GitHubDownloader getReleaseByTag(): error throws with descriptive message on error" + ], + "af0367fb70381488": [ + "Integration: Docker Command Construction buildAndUp with overwriteContainerId kills and removes old container before creating new one" + ], + "af0eab4698ebf59a": [ + "DockerService getPublishedHostPorts() ignores exposed-but-unpublished ports (no \"->\")", + "DockerService getPublishedHostPorts() maps host ports to the containers publishing them (0.0.0.0 + :::)", + "DockerService getPublishedHostPorts() parses host-IP-scoped bindings (127.0.0.1:13306->3306)", + "DockerService getPublishedHostPorts() records multiple containers contending for the same host port", + "DockerService getPublishedHostPorts() returns an empty map when docker ps fails (best-effort)", + "DockerService getPublishedHostPorts() returns an empty map when there are no running containers", + "DockerService waitContainer() rejects on error", + "DockerService waitContainer() returns non-zero exit code", + "DockerService waitContainer() runs docker wait and returns parsed exit code" + ], + "b01987f449c4e2d6": [ + "Boundary Tests ModuleService: Docker env var passing (execFile) handles boolean false values", + "Boundary Tests ModuleService: Docker env var passing (execFile) handles numeric values without error", + "Boundary Tests ModuleService: Docker env var passing (execFile) passes backslashes in environment variable values unescaped", + "Boundary Tests ModuleService: Docker env var passing (execFile) passes backticks in environment variable values unescaped", + "Boundary Tests ModuleService: Docker env var passing (execFile) passes dollar signs in environment variable values unescaped", + "Boundary Tests ModuleService: Docker env var passing (execFile) passes double quotes in environment variable values unescaped" + ], + "b15d571802c13994": [ + "Chaos: Process Resilience Experiment: Empty container ID propagation rejects when docker run returns whitespace-only container ID" + ], + "b15f63c78bbeaa42": [ + "Integration: Database Service Chain buildDatabaseModule (already exists) reuses existing container and adds to network" + ], + "b160cd15b0db200d": [ + "ui/menu coin node remote actions run update, never a clone \"Reinstall from remote\" on the node runs update node for that coin and network", + "ui/menu coin node remote actions run update, never a clone \"Update local version\" on a module still clones its source", + "ui/menu coin node remote actions run update, never a clone \"Update local version\" on the node runs update node for that coin and network", + "ui/menu coin node remote actions run update, never a clone reports a refused node update and stays in the menu", + "ui/menu coin node remote versions answers \"0\" for a node release that has not been looked up yet", + "ui/menu coin node remote versions offers a reinstall when the installed node matches the release", + "ui/menu coin node remote versions offers an update for each coin whose installed node is behind the release", + "ui/menu coin node remote versions reads the node remote version from the release tag_name", + "ui/menu coin node remote versions still compares a module as semver", + "ui/menu exports mainMenu as a function", + "ui/menu exports modulesSelectionInterface as a function", + "ui/menu exports restoreBootstrapInterface as a function", + "ui/menu exports startInterface as a function", + "ui/menu mutating actions hold the command lock holds the lock around the scan and releases it after", + "ui/menu mutating actions hold the command lock never calls a mutating operation bare from a handler", + "ui/menu mutating actions hold the command lock releases the lock when the action throws", + "ui/menu mutating actions hold the command lock skips the action and stays in the menu when another command holds the lock", + "ui/menu per-module action labels are all handled ACTION_INSTALL_LOCAL_IN_CONTAINER (\"Install Local Version in Container\") reaches a handler", + "ui/menu per-module action labels are all handled ACTION_REINSTALL_CONTAINER (\"Reinstall\") reaches a handler", + "ui/menu per-module action labels are all handled ACTION_REINSTALL_REMOTE (\"Reinstall from remote\") reaches a handler", + "ui/menu per-module action labels are all handled ACTION_UPDATE_CONTAINER (\"Update Container\") reaches a handler", + "ui/menu per-module action labels are all handled ACTION_UPDATE_LOCAL (\"Update local version\") reaches a handler", + "ui/menu per-module action labels are all handled every offered per-module action label has a handler branch", + "ui/menu requiring the module has no throwing side effects", + "ui/menu restoreBootstrapInterface non-interactive resolution falls back to the newest archive when there is no TTY to prompt on", + "ui/menu restoreBootstrapInterface non-interactive resolution rejects a --file that is not present instead of silently picking another", + "ui/menu restoreBootstrapInterface non-interactive resolution restores exactly the named archive on --file", + "ui/menu restoreBootstrapInterface non-interactive resolution restores the newest archive on --latest without prompting", + "ui/menu restoreBootstrapInterface non-interactive resolution says so plainly when there is nothing to restore", + "ui/menu restoreBootstrapInterface non-interactive resolution surfaces a failed restore as an error rather than a bare false" + ], + "b23bbb069f1bf215": [ + "release_assets names the release integrity assets", + "release_assets pins a forty-character upper-case hexadecimal fingerprint", + "release_assets points to the shipped release signing key", + "release_assets provides an Error subclass that preserves its name and message" + ], + "b2439743a81a59fe": [ + "Regression Suite [regression:p2] Precheck & Error Handling R-PRE-001: checkDockerInstalledAndReachable succeeds when Docker is available", + "Regression Suite [regression:p2] Precheck & Error Handling R-PRE-002: startModules gracefully handles missing LevelDB entry", + "Regression Suite [regression:p2] Precheck & Error Handling R-PRE-004: path traversal in config coin parameter is caught" + ], + "b2b6dd472622839b": [ + "Integration: Docker Command Construction xchain-encoder Docker run command includes correct hostname, network, port mapping, and env vars", + "Integration: Docker Command Construction xchain-encoder Docker run command propagates config file overrides into Docker env vars", + "Integration: Docker Command Construction xchain-encoder Docker run command uses testnet port when network is testnet" + ], + "b469f566dbc11f8a": [ + "DockerService getDockerContainerFileCat() runs docker exec cat ", + "DockerService getDockerContainerFileData() runs docker cp and reads the copied file" + ], + "b53dc5b9aa6da7b0": [ + "ConfigService getDefaultConfig() ANCHOR fold passthrough NEVER reaches a shared-ledger indexer, whatever the host env says", + "ConfigService getDefaultConfig() ANCHOR fold passthrough carries a host value of 0 to a regtest indexer as the string 0", + "ConfigService getDefaultConfig() ANCHOR fold passthrough is absent from both configs when the host leaves it unset", + "ConfigService getDefaultConfig() ANCHOR fold passthrough reaches the container hub, so the venue arms as a unit" + ], + "b68740453ee65cdd": [ + "DatabaseService buildDatabaseModule(): no coin/network appends DB data dir volume mount when XCHAIN_NODE_DB_DATA_DIR is set", + "DatabaseService buildDatabaseModule(): no coin/network returns true without network join when coin+network are empty", + "DatabaseService buildDatabaseModule(): no coin/network throws string error when addContainerToNetwork fails on existing container" + ], + "b7114a10b0030562": [ + "validator stake XCHAIN balance paging finds an XCHAIN row that sits past the first page", + "validator stake XCHAIN balance paging prints an unknown XCHAIN balance as unavailable rather than \"null held\"", + "validator stake XCHAIN balance paging reads a truncated list as an unknown balance that blocks, never as zero", + "validator stake upstream reads blocks on an unknown balance as an outage, never as an address to fund", + "validator stake upstream reads names a token with no mints group instead of reporting MAX_MINT 0", + "validator stake upstream reads prints an unknown balance as unavailable rather than \"null confirmed\"", + "validator stake upstream reads reads a tracker outage as an unknown balance, not as zero", + "validator stake upstream reads unwraps a one-element array envelope on the address and token reads" + ], + "b7758e1ca876051c": [ + "ExplorerService: waitForExplorerReady() gives up early, reporting no problem, when nothing is listening at all", + "ExplorerService: waitForExplorerReady() keeps waiting through degraded replies, then succeeds when it converges", + "ExplorerService: waitForExplorerReady() reports false when a talking explorer never converges inside the budget", + "ExplorerService: waitForExplorerReady() returns true as soon as the explorer reports healthy" + ], + "b79e9f220b2c5589": [ + "Chaos: Process Resilience Experiment: Precheck failure cascade Directory creation failure does not throw (mkdirSync creates dirs)" + ], + "b81d9b9c07cd6df9": [ + "ValidatorStakeService unstakeValidator() does nothing when the pubkey carries no valid stake", + "ValidatorStakeService unstakeValidator() dry run reports the active stake and sends nothing", + "ValidatorStakeService unstakeValidator() never claims the XCHAIN is spendable once the stake leaves the set", + "ValidatorStakeService unstakeValidator() omits the duration on regtest, where block spacing is meaningless", + "ValidatorStakeService unstakeValidator() prints the complete validator URL for every network", + "ValidatorStakeService unstakeValidator() refuses a WIF that does not control the recorded stake address", + "ValidatorStakeService unstakeValidator() refuses when the validator set cannot be read", + "ValidatorStakeService unstakeValidator() refuses without a validator", + "ValidatorStakeService unstakeValidator() stake lifecycle classifyUnstakeRows sorts every lifecycle state", + "ValidatorStakeService unstakeValidator() stake lifecycle refuses a stake still inside its activation delay", + "ValidatorStakeService unstakeValidator() stake lifecycle refuses the partial-unstake shape: old rows deactivated, residual still pending", + "ValidatorStakeService unstakeValidator() stake lifecycle refuses when the chain tip cannot be read", + "ValidatorStakeService unstakeValidator() stake lifecycle reports an unstake already in progress and sends nothing", + "ValidatorStakeService unstakeValidator() stake lifecycle reports that sum exactly where a float would round it away", + "ValidatorStakeService unstakeValidator() stake lifecycle sends nothing for a stake unstaked and cooled down long ago", + "ValidatorStakeService unstakeValidator() stake lifecycle withdraws only the admissible rows and reports their sum", + "ValidatorStakeService unstakeValidator() stake ownership gives a foreign pending stake the ownership refusal, not \"not active yet\"", + "ValidatorStakeService unstakeValidator() stake ownership ignores a foreign deactivated row beside an owned live one", + "ValidatorStakeService unstakeValidator() stake ownership refuses a row that does not say who owns it", + "ValidatorStakeService unstakeValidator() stake ownership refuses a stake owned by another address, dry run or broadcast", + "ValidatorStakeService unstakeValidator() the plan prints BOTH clocks: 6 blocks to leave the set, 1000 to unlock", + "ValidatorStakeService unstakeValidator() the post-broadcast summary repeats both clocks, not just the delay", + "ValidatorStakeService unstakeValidator() with --broadcast sends UNSTAKE v0 for this pubkey" + ], + "b85bf0e6b4e19b10": [ + "DatabaseService ensureXchainNodeAccess() reprovisions when stored credentials no longer work", + "DatabaseService ensureXchainNodeAccess() returns existing working credentials without reprovisioning", + "DatabaseService ensureXchainNodeAccess() throws \"MariaDB is not responding\" when root checkIfDatabaseIsReady returns false", + "DatabaseService ensureXchainNodeAccess() throws when container not found and no credentials" + ], + "b874404951e97c8a": [ "moduleOperations: syncSharedServicesAfterInstall() does nothing when the run installed no coin stack", "moduleOperations: syncSharedServicesAfterInstall() honours XCHAIN_NODE_ALLOW_DEGRADED_EXPLORER for install-then-fix flows", "moduleOperations: syncSharedServicesAfterInstall() pushes hub config, attaches the explorer, then waits for it to serve", @@ -2090,60 +3213,44 @@ "BootstrapService utxoTrackerVolumeFreshness() reports unknown when the inspect fails for any other reason", "BootstrapService utxoTrackerVolumeFreshness() uses a listing command that exits non-zero when the listing fails" ], - "bb7e5453878cf8dd": [ - "preCheck(): a hub that is not answering does not wedge its own repair @regression a NON-repairing command still aborts against a hub that is not answering", - "preCheck(): a hub that is not answering does not wedge its own repair @regression a repairing command against a HEALTHY hub still pushes and still fails on a real error", - "preCheck(): a hub that is not answering does not wedge its own repair @regression hydrates the sidecar key before the liveness probe on the unhealthy path", - "preCheck(): a hub that is not answering does not wedge its own repair @regression is not consulted at all for a read-only command that skips the push", - "preCheck(): a hub that is not answering does not wedge its own repair @regression lets a repairing command through on a warning, skipping the push", - "preCheck(): a hub that is not answering does not wedge its own repair @regression still continues when the skipped push leaves updateHub rejecting anyway", - "preCheck(): a hub that is not answering does not wedge its own repair @regression treats a liveness probe that throws as \"not answering\"", - "preCheck(): containerd data-root relocation warning @regression does not block the command when the containerd probe throws", - "preCheck(): containerd data-root relocation warning @regression prints a warning when Docker data-root moved off / but containerd is still on /", - "preCheck(): containerd data-root relocation warning @regression prints no containerd warning when the probe reports no relocation hazard", - "preCheck(): hub/explorer config push @regression pushes both and returns true when neither rejects", - "preCheck(): hub/explorer config push @regression still pushes explorer config when updateHub rejects, and fails the command", - "preCheck(): remote version check degrades gracefully @regression a GitHub 403 rate-limit rejection does not abort preCheck", - "preCheck(): remote version check degrades gracefully @regression a successful version check keeps checkVersions=true for getStatus", - "preCheck(): remote version check degrades gracefully @regression checkVersions=false skips the remote version fetch entirely", - "preCheck(): xchain_node pool host/port resolution @regression EXTERNAL_DB \u2192 never falls back to the 127.0.0.1 load-time default", - "preCheck(): xchain_node pool host/port resolution @regression EXTERNAL_DB \u2192 opens the pool against the getExternalDbConfig() host/port", - "preCheck(): xchain_node pool host/port resolution @regression a failing external-DB resolution surfaces as the pool-open error", - "preCheck(): xchain_node pool host/port resolution @regression docker mode \u2192 127.0.0.1 with the live container port-forward", - "preCheck: the CLI presents the sidecar HUB_API_KEY to the hub @regression hydrates process.env from the sidecar before the hub is installed or pushed to", - "preCheck: the CLI presents the sidecar HUB_API_KEY to the hub @regression is a no-op on a host with no sidecar (a standalone install stays keyless)", - "preCheck: the CLI presents the sidecar HUB_API_KEY to the hub @regression runs the hydration through the real sidecar reader with a host-env key left untouched", - "preCheck: the hub is staged at the ref the command named passes null when the command named no ref, preserving the old behaviour", - "preCheck: the hub is staged at the ref the command named passes the ref through to installHubModule" + "ba43f3b4dd958f04": [ + "Chaos: Docker Resilience Experiment: Docker inspect with invalid JSON throws when docker inspect returns empty array", + "Chaos: Docker Resilience Experiment: Docker inspect with invalid JSON throws when docker inspect returns malformed JSON" ], - "bcfaa8d7327dcba5": [ - "DbCredentialDrift assertNoDbCredentialDrift ignores the container the caller is about to replace", - "DbCredentialDrift assertNoDbCredentialDrift is a no-op when no decoder or indexer container is running", - "DbCredentialDrift assertNoDbCredentialDrift proceeds with a warning when the override env is set", - "DbCredentialDrift assertNoDbCredentialDrift resolves when the running containers agree with the config", - "DbCredentialDrift assertNoDbCredentialDrift still flags a sibling that would be locked out, and names the excluded module in the remedy", - "DbCredentialDrift assertNoDbCredentialDrift throws a tagged error naming the locked-out container", - "DbCredentialDrift assertNoHubDbCredentialDrift ignores a container the caller is about to replace, and never inspects it", - "DbCredentialDrift assertNoHubDbCredentialDrift proceeds with a warning when the override env is set", - "DbCredentialDrift assertNoHubDbCredentialDrift resolves when every holder of the shared account agrees", - "DbCredentialDrift assertNoHubDbCredentialDrift throws a tagged error naming a sibling hub under a different prefix", - "DbCredentialDrift assertNoHubDbCredentialDrift treats an unreadable docker daemon as no drift", - "DbCredentialDrift findDbCredentialDrift flags a decoder that disagrees on the shared decoder account", - "DbCredentialDrift findDbCredentialDrift flags an indexer built from another install on both accounts", - "DbCredentialDrift findDbCredentialDrift handles an empty container list and a missing intended map", - "DbCredentialDrift findDbCredentialDrift ignores a key absent from either side", - "DbCredentialDrift findDbCredentialDrift ignores env keys the module does not authenticate with", - "DbCredentialDrift findDbCredentialDrift reports nothing when every container carries the intended password", - "DbCredentialDrift findDbCredentialDrift treats an empty intended value as no claim rather than as a mismatch", - "DbCredentialDrift findHubDbCredentialDrift does not flag an indexer whose hub connection uses its own account", - "DbCredentialDrift findHubDbCredentialDrift flags a sibling install whose hub carries a different shared password", - "DbCredentialDrift findHubDbCredentialDrift treats an absent or empty value on either side as no claim", - "DbCredentialDrift formatDbCredentialDriftError leaks no credential value into the message", - "DbCredentialDrift formatDbCredentialDriftError names the container and the remediation but never a password", - "DbCredentialDrift isDbCredentialDriftError separates the drift refusal from an unrelated failure", - "DbCredentialDrift readContainerEnv parses docker inspect output into a plain object", - "DbCredentialDrift readContainerEnv returns null for a container that does not exist", - "DbCredentialDrift readContainerEnv returns null on unparseable output rather than throwing" + "baf6a5649ec3a388": [ + "Chaos: Docker Resilience Experiment: Docker exec failure rejects when exec fails on frozen container" + ], + "bba74ea3b5ec4339": [ + "ModuleService cloneGit() clones to module directory with correct git URL", + "ModuleService cloneGit() rejects on git clone error", + "ModuleService cloneGit() rejects when directory exists and rewrite=false", + "ModuleService cloneGit() rejects when module has no URL mapping", + "ModuleService cloneGit() removes existing directory when rewrite=true", + "ModuleService cloneGit() uses tmp directory when useTmp=true" + ], + "bbd3a050c99fdc1f": [ + "Integration: Hub/Explorer Config Update updateHubOrExplorer payload for hub builds JSON config with module connection details from installed modules", + "Integration: Hub/Explorer Config Update updateHubOrExplorer payload for hub includes multiple coin/network stacks", + "Integration: Hub/Explorer Config Update updateHubOrExplorer payload for hub includes node config with correct ports and credentials" + ], + "bbdb0aa3f3d1d42e": [ + "migration_files migrationFiles lists a flat directory by basename, .sql only", + "migration_files migrationFiles orders bucketed files by BASENAME, not by the path a bucket gives them", + "migration_files migrationFiles yields [] for a missing directory", + "migration_files migrationsDirOf names the flat home even when neither exists, so the caller sees a missing directory", + "migration_files migrationsDirOf reads the flat home the indexer and decoder use today", + "migration_files migrationsDirOf reads the moved home when the tree carries it", + "migration_files the guard over a bucketed tree finds a deploy-precondition migration inside a bucket", + "migration_files the guard over a bucketed tree names pending manual migrations by basename across buckets" + ], + "bc3f855b95f524d5": [ + "GitHubDownloader getAllFiles(): directory traversal returns all files recursively from a directory" + ], + "bc521f9dd0954bf6": [ + "GitHubDownloader downloadReleaseAsset() handles single extracted directory by flattening contents", + "GitHubDownloader downloadReleaseAsset() throws when tar fails with non-zero exit code", + "GitHubDownloader downloadReleaseAsset() throws when unzip fails with non-zero exit code", + "GitHubDownloader downloadReleaseAsset() warns on unrecognized file extension (no extraction)" ], "bdea07439a6b5cec": [ "HubConsensusEnvGuard assertNoHubConsensusEnvDrift() is a no-op (returns empty) on a fresh install with no running hub container", @@ -2183,12 +3290,280 @@ "HubConsensusEnvGuard network gating resolves the network from this deploy's own HUB_NETWORK first", "HubConsensusEnvGuard network gating scopes the walked key list to the network" ], - "cdda2d3e70c102b1": [ - "utils/dockerMariadb dockerMariadbArgs() builds a docker exec invocation forwarding MYSQL_PWD by name only", - "utils/dockerMariadb dockerMariadbArgs() inserts -i before the env forward when interactive", - "utils/dockerMariadb dockerMariadbArgs() never embeds a password value in argv", - "utils/dockerMariadb mariadbEnv() does not mutate process.env", - "utils/dockerMariadb mariadbEnv() returns process.env plus MYSQL_PWD" + "bf75da756c4b378c": [ + "E2E: Multi-Coin Installation (Scenario 4.2) E2E-010: Install bitcoin/regtest + litecoin/regtest creates separate Docker networks for each coin", + "E2E: Multi-Coin Installation (Scenario 4.2) E2E-010: Install bitcoin/regtest + litecoin/regtest database container is shared (single entry in LevelDB)", + "E2E: Multi-Coin Installation (Scenario 4.2) E2E-010: Install bitcoin/regtest + litecoin/regtest stores separate LevelDB entries per coin", + "E2E: Multi-Coin Installation (Scenario 4.2) E2E-011: Uninstall bitcoin, litecoin remains bitcoin modules removed from LevelDB, litecoin modules preserved", + "E2E: Multi-Coin Installation (Scenario 4.2) E2E-011: Uninstall bitcoin, litecoin remains shared services remain after partial uninstall", + "E2E: Multi-Coin Installation (Scenario 4.2) E2E-012: Container naming uses coin-specific prefixes docker run commands use xchain-node--- naming" + ], + "c05d3d101b7b8d94": [ + "Security Container ID validation ModuleService buildAndUp validates container ID as 64-char hex", + "Security Container ID validation rejects container IDs shorter than 64 chars", + "Security Container ID validation rejects container IDs with shell metacharacters", + "Security Container ID validation rejects non-hex container IDs" + ], + "c0a179d054a8c1ef": [ + "indexer migrate CLI location pins every known CLI layout, newest first", + "indexer migrate CLI location readMigrateCli answers null and forgets what it found before when no known path answers", + "indexer migrate CLI location readMigrateCli counts an empty read as absent, as the single-path probe did", + "indexer migrate CLI location readMigrateCli falls back to the pre-move path on a build that still carries it", + "indexer migrate CLI location readMigrateCli reads the v0.19.0+ db/migration layout before the older path", + "indexer migrate CLI location refusal remedy keeps the refusal and omits a scoped command when the CLI rejects --status", + "indexer migrate CLI location refusal remedy names the pre-move CLI path when the running build carries only that", + "indexer migrate CLI location refusal remedy names the v0.19.0+ db/migration CLI path when the running build carries that", + "indexer migrate CLI location refusal remedy still refuses, naming the newest layout as the remedy, when the container answers at none of the known paths", + "indexer migrate CLI location runningBuildSupportsPerFileMigrations accepts a valid status response from a build after the path move", + "indexer migrate CLI location runningBuildSupportsPerFileMigrations accepts a valid status response from a build before the path move", + "indexer migrate CLI location runningBuildSupportsPerFileMigrations answers null for a container with no CLI at any known path", + "indexer migrate CLI location runningBuildSupportsPerFileMigrations rejects malformed status JSON", + "indexer migrate CLI location runningBuildSupportsPerFileMigrations reports a CLI without --status support as lacking the capability" + ], + "c127af1e131a631f": [ + "CredentialsService saveCredentials() calls chmodSync on file after writing", + "CredentialsService saveCredentials() creates dir if it does not exist", + "CredentialsService saveCredentials() does not create dir when it already exists", + "CredentialsService saveCredentials() preserves the externalDb block written by saveExternalDbConfig", + "CredentialsService saveCredentials() silently ignores chmodSync errors (Windows compat)", + "CredentialsService saveCredentials() writes JSON to credentials file with mode 0600" + ], + "c137030db07be9d1": [ + "GitHubDownloader downloadRepoVersion() cleans up the staging directory when download fails and leaves the previous tree alone", + "GitHubDownloader downloadRepoVersion() extracts into a staging directory and swaps it over the previous version tree", + "GitHubDownloader downloadRepoVersion() throws when hash not found for repo/version", + "GitHubDownloader downloadRepoVersion() writes version file on successful download" + ], + "c148734ae2eb7b02": [ + "module names covers every exported module and file name", + "module names exports non-empty strings for every module and file name", + "module names keeps every module and file name free of whitespace and path separators", + "module names keeps module names pairwise distinct" + ], + "c1b49a38cfcc3041": [ + "moduleOperations resetModules() marks the reindexed combos for a forced bootstrap republish does not abort the reset when the ledger cannot be written", + "moduleOperations resetModules() marks the reindexed combos for a forced bootstrap republish marks all three on a reset all", + "moduleOperations resetModules() marks the reindexed combos for a forced bootstrap republish marks nothing for a node-only reset", + "moduleOperations resetModules() marks the reindexed combos for a forced bootstrap republish marks nothing when the reset aborts before any wipe", + "moduleOperations resetModules() marks the reindexed combos for a forced bootstrap republish marks the tracker combo when the tracker volume is wiped" + ], + "c29f3f159edfeb5d": [ + "StatusService: the advisory remote-version check never aborts the command getStatus completes and returns the table when the per-coin version check is rate-limited", + "StatusService: the advisory remote-version check never aborts the command loadInstalledModules still registers containers when the version check is rate-limited", + "StatusService: the advisory remote-version check never aborts the command still fills the remote version column when GitHub answers", + "StatusService: the advisory remote-version check never aborts the command warns once per pass while still attempting every coin" + ], + "c2ef0de1942c90ef": [ + "ValidatorStakeService stakeValidator() --amount refuses a malformed amount, dry run or --broadcast, and sends nothing", + "ValidatorStakeService stakeValidator() --amount stakes exactly the whole amount asked for", + "ValidatorStakeService stakeValidator() --amount stakes the 25000 default when --amount is absent" + ], + "c30fc0c4dd2e1ee9": [ + "Chaos: Process Resilience Experiment: Empty container ID propagation rejects when docker run returns container ID with non-hex characters" + ], + "c41ce4e4afc4896e": [ + "Fuzz: Environment Variable Handling with execFile carriage return characters are passed raw (safe with execFile)", + "Fuzz: Environment Variable Handling with execFile combined \\r\\n passed raw (safe with execFile)", + "Fuzz: Environment Variable Handling with execFile handles boolean env var value", + "Fuzz: Environment Variable Handling with execFile handles empty string env var value", + "Fuzz: Environment Variable Handling with execFile handles extremely long env var value without crashing", + "Fuzz: Environment Variable Handling with execFile handles null byte in env var value", + "Fuzz: Environment Variable Handling with execFile handles null env var value via String() coercion", + "Fuzz: Environment Variable Handling with execFile handles numeric env var value", + "Fuzz: Environment Variable Handling with execFile handles undefined env var value via String() coercion", + "Fuzz: Environment Variable Handling with execFile handles unicode characters in env var value", + "Fuzz: Environment Variable Handling with execFile newline characters are passed raw (safe with execFile)", + "Fuzz: Environment Variable Handling with execFile passes all dangerous shell characters raw in a single value (safe with execFile)", + "Fuzz: Environment Variable Handling with execFile passes ampersand as raw value in args array (no escaping needed)", + "Fuzz: Environment Variable Handling with execFile passes backslash as raw value in args array (no escaping needed)", + "Fuzz: Environment Variable Handling with execFile passes backtick as raw value in args array (no escaping needed)", + "Fuzz: Environment Variable Handling with execFile passes dollar paren as raw value in args array (no escaping needed)", + "Fuzz: Environment Variable Handling with execFile passes dollar sign as raw value in args array (no escaping needed)", + "Fuzz: Environment Variable Handling with execFile passes double quote as raw value in args array (no escaping needed)", + "Fuzz: Environment Variable Handling with execFile passes pipe as raw value in args array (no escaping needed)", + "Fuzz: Environment Variable Handling with execFile passes semicolon as raw value in args array (no escaping needed)" + ], + "c43e02d1acf524b0": [ + "Chaos: Docker Resilience Experiment 4b: Docker run failure does not store container ID when docker run fails", + "Chaos: Docker Resilience Experiment 4b: Docker run failure rejects when docker run returns empty stdout", + "Chaos: Docker Resilience Experiment 4b: Docker run failure rejects when docker run returns invalid container ID", + "Chaos: Docker Resilience Experiment 4b: Docker run failure rejects with error message when docker run fails (port conflict)", + "Chaos: Docker Resilience Experiment 4b: Docker run failure rejects with error when docker run returns OOM killed exit code" + ], + "c44142a8c149e3f8": [ + "DatabaseService ensureDatabasePool() creates database pool when not ready (docker path)", + "DatabaseService ensureDatabasePool() is a no-op when db is already ready" + ], + "c5bcb58acc73d969": [ + "NodeService: getCryptoNode() a broken mirror is not the whole install answers the all:true form Node actually calls it with", + "NodeService: getCryptoNode() a broken mirror is not the whole install does not try other mirrors for an HTTP status, where every mirror agrees", + "NodeService: getCryptoNode() a broken mirror is not the whole install names the URL, the cause and a way forward when every mirror fails", + "NodeService: getCryptoNode() a broken mirror is not the whole install retries on another mirror address after a TLS failure and succeeds", + "NodeService: getCryptoNode() a broken mirror is not the whole install still makes one attempt when the name cannot be resolved at all" + ], + "c60a5a54c1d26404": [ + "rail publisher funding execution rejects a failing send_funds call by method and does not mine", + "rail publisher funding execution treats JSON-RPC errors as a method failure", + "rail publisher funding execution uses only public wallet information and never exposes a WIF", + "rail publisher funding plan does nothing when no anchor arm is set", + "rail publisher funding plan funds and mines when only XC_ANCHOR_FOLD_REGTEST_ACTIVATION is set", + "rail publisher funding plan funds and mines when only XC_ANCHOR_SLASH_REGTEST_ACTIVATION is set", + "rail publisher funding plan funds and mines when only XC_ANCHOR_STAKE_REGTEST_ACTIVATION is set", + "rail publisher funding plan rejects a missing publisher address and names its field", + "rail publisher funding plan rejects a non-regtest wallet and names its network field" + ], + "c8534fb477afedec": [ + "E2E: Error Handling (Scenario 4.10) E2E-060: Docker unreachable during precheck throws descriptive error when docker --version fails", + "E2E: Error Handling (Scenario 4.10) E2E-061: Docker run failure leaves no LevelDB entry no container ID stored when docker run fails", + "E2E: Error Handling (Scenario 4.10) E2E-062: Missing module directory buildAndUp throws \"module not found\" when module dir missing", + "E2E: Error Handling (Scenario 4.10) E2E-063: Git clone failure propagates error cloneGit propagates git clone error", + "E2E: Error Handling (Scenario 4.10) E2E-064: Start with empty LevelDB startModules succeeds gracefully with no installed modules", + "E2E: Error Handling (Scenario 4.10) E2E-065: Stop with empty LevelDB stopModules succeeds gracefully with no installed modules" + ], + "c8569cbfc2ecad03": [ + "ValidatorStakeService unstakeValidator() with an owned top-up still pending names the LAST pending activation as the block a re-run covers everything from", + "ValidatorStakeService unstakeValidator() with an owned top-up still pending refuses a --broadcast run and names the pending row", + "ValidatorStakeService unstakeValidator() with an owned top-up still pending refuses the dry run the same way, before printing a plan", + "ValidatorStakeService unstakeValidator() with an owned top-up still pending withdraws the whole stake once the top-up has activated" + ], + "c9cce451d2af9893": [ + "DockerService killContainer(): error branches rejects on exec error", + "DockerService killContainer(): error branches rejects when stdout does not match container ID", + "DockerService restartContainer(): error branches rejects when execFile errors", + "DockerService restartContainer(): error branches rejects when stdout does not match container ID" + ], + "c9f879a004276208": [ + "BootstrapHealthGate evaluateContainerState() accepts a stable running container with no healthcheck", + "BootstrapHealthGate evaluateContainerState() treats unreadable inspect output as a refusal" + ], + "c9fc151f2f957a53": [ + "MariaDbStore registry scoping by NODE_PREFIX gives a second stack its own table, so neither upsert nor purge can reach the first", + "MariaDbStore registry scoping by NODE_PREFIX keeps the bare `modules` table on the default prefix, so an existing install migrates nothing", + "MariaDbStore registry scoping by NODE_PREFIX never gives two distinct prefixes the same table, separator or length", + "MariaDbStore registry scoping by NODE_PREFIX sanitizes a prefix that is legal for docker but not for a MariaDB identifier" + ], + "ca7bc42c94112709": [ + "CredentialsService loadDbRootPassword() / saveDbRootPassword() loadDbRootPassword returns null when the file or key is absent", + "CredentialsService loadDbRootPassword() / saveDbRootPassword() loadDbRootPassword returns the stored value and rejects non-string/empty shapes", + "CredentialsService loadDbRootPassword() / saveDbRootPassword() saveDbRootPassword creates the 0700 dir when missing", + "CredentialsService loadDbRootPassword() / saveDbRootPassword() saveDbRootPassword read-modify-writes with mode 0600, preserving sibling keys" + ], + "cacb2b17a1b6db97": [ + "Integration: Hub/Explorer Config Update hub update retry logic retries on failure and succeeds when hub responds", + "Integration: Hub/Explorer Config Update hub update retry logic throws after exhausting all retries" + ], + "cade957f6640118b": [ + "ExplorerService: installExplorerModule() honours the install ref clones a release branch verbatim, so a frozen-ref e2e tests the frozen tree", + "ExplorerService: installExplorerModule() honours the install ref clones at the branch the caller named", + "ExplorerService: installExplorerModule() honours the install ref passes null when no ref was named, keeping the default-branch behaviour", + "ExplorerService: installExplorerModule() honours the install ref passes the manifest pin (ref AND commit) when a release install is active" + ], + "cb42ff6da97669c6": [ + "DatabaseService getDatabaseHostPort() parses port from docker port output", + "DatabaseService getDatabaseHostPort() returns default port when docker port fails", + "DatabaseService getDatabaseHostPort() returns default port when output has no port match" + ], + "cb937309157fd5ad": [ + "tracker_height chooses safely when the archive height is ahead of the tracker height", + "tracker_height chooses safely when the archive height is behind the tracker height", + "tracker_height chooses safely when the archive height is equal to the tracker height", + "tracker_height keeps the tracker height when the archive height is missing", + "tracker_height reads the committed height through configured fakes", + "tracker_height retries the post-restart height through configured fakes", + "tracker_height returns null when both heights are missing", + "tracker_height uses the archive height when the tracker height is missing" + ], + "cb99d238d302d2c2": [ + "ValidatorService ROLLCALL status reporting getActiveSignerFile() falls back to the generated signer.js once init has written one", + "ValidatorService ROLLCALL status reporting getActiveSignerFile() is null when no signer is configured at all", + "ValidatorService ROLLCALL status reporting getActiveSignerFile() prefers an operator-supplied signer directory", + "ValidatorService ROLLCALL status reporting getRollcallStatus() a streak of one reads as a warning shot, not an eviction", + "ValidatorService ROLLCALL status reporting getRollcallStatus() a streak of two with evicted:1 on the head row reads as evicted", + "ValidatorService ROLLCALL status reporting getRollcallStatus() degrades the DOGE runway to unavailable rather than reporting 0 roll calls", + "ValidatorService ROLLCALL status reporting getRollcallStatus() degrades to unavailable, never a reassuring zero, when the absence read throws", + "ValidatorService ROLLCALL status reporting getRollcallStatus() degrades to unavailable, not \"no absences\", when the indexer predates this read", + "ValidatorService ROLLCALL status reporting getRollcallStatus() healthy: a DOGE runway, no absences, and a publish-capable signer", + "ValidatorService ROLLCALL status reporting getRollcallStatus() reports a signer with no broadcast export, naming the file, independent of wallet state", + "ValidatorService ROLLCALL status reporting rollcallAbsenceStreak() counts a single absence as a streak of one", + "ValidatorService ROLLCALL status reporting rollcallAbsenceStreak() counts two absences one epoch apart as a streak of two", + "ValidatorService ROLLCALL status reporting rollcallAbsenceStreak() only counts the run starting at the head of the array, never a streak buried deeper in the history", + "ValidatorService ROLLCALL status reporting rollcallAbsenceStreak() returns 0 for an empty list (healthy: no absence on record, not \"unknown\")", + "ValidatorService ROLLCALL status reporting rollcallAbsenceStreak() stops the streak at a rolled (present) epoch between two absences, even though only absences are in the array", + "ValidatorService ROLLCALL status reporting rollcallEpochBlocks() is 30 on regtest and 1008 on testnet, mainnet, and anything unrecognized", + "ValidatorService ROLLCALL status reporting signerModuleExportsBroadcast() is not fooled by a TODO comment INSIDE the exports block that mentions broadcast(", + "ValidatorService ROLLCALL status reporting signerModuleExportsBroadcast() is not fooled by a contract comment BEFORE module.exports that only mentions broadcast", + "ValidatorService ROLLCALL status reporting signerModuleExportsBroadcast() recognizes the CLI-generated template shape (object-literal broadcast beside walletSign)", + "ValidatorService ROLLCALL status reporting signerModuleExportsBroadcast() recognizes the separate exports.broadcast = assignment form", + "ValidatorService ROLLCALL status reporting signerModuleExportsBroadcast() returns false for a hand-built module that only signs", + "ValidatorService ROLLCALL status reporting signerModuleExportsBroadcast() returns null when there is no signer file to check" + ], + "cbdbc1d46a4e8f22": [ + "Chaos: Git Clone Resilience Experiment 7: Network failure during git clone rejects with error message when clone fails with network error", + "Chaos: Git Clone Resilience Experiment 7: Network failure during git clone rejects with error when clone times out", + "Chaos: Git Clone Resilience Experiment 7b: Invalid branch fails loud (no silent master fallback) does not fall back when error is not branch-related", + "Chaos: Git Clone Resilience Experiment 7b: Invalid branch fails loud (no silent master fallback) rejects (no fallback clone attempt) when specified branch is not found", + "Chaos: Git Clone Resilience Experiment 7c: Branch name validation in cloneGit accepts valid branch with slashes", + "Chaos: Git Clone Resilience Experiment 7c: Branch name validation in cloneGit rejects branch names with backticks", + "Chaos: Git Clone Resilience Experiment 7c: Branch name validation in cloneGit rejects branch names with dollar signs", + "Chaos: Git Clone Resilience Experiment 7c: Branch name validation in cloneGit rejects invalid branch names with shell metacharacters", + "Chaos: Git Clone Resilience Experiment 7d: Module directory conflict rejects when module directory already exists and rewrite is false", + "Chaos: Git Clone Resilience Experiment 7d: Module directory conflict replaces the existing directory when rewrite is true", + "Chaos: Git Clone Resilience Experiment 7e: Unknown module URL rejects when module has no URL mapping", + "Chaos: Git Clone Resilience Experiment 7f: useTmp mode resilience cleans up tmp directory before cloning in useTmp mode", + "Chaos: Git Clone Resilience Experiment 7f: useTmp mode resilience clones to tmp directory when useTmp=true" + ], + "cc0e44f047ba96c0": [ + "Regression Suite [regression:p0] Argument Parsing & Validation R-ARG-001: resolveArgs identifies service, coin, network from any argument order", + "Regression Suite [regression:p0] Argument Parsing & Validation R-ARG-002: resolveArgs defaults to all/all/all when no args provided", + "Regression Suite [regression:p0] Argument Parsing & Validation R-ARG-003: resolveArgs rejects invalid branch names", + "Regression Suite [regression:p0] Argument Parsing & Validation R-ARG-003b: resolveArgs accepts valid branch names", + "Regression Suite [regression:p0] Argument Parsing & Validation R-ARG-004: filterCommandParameters excludes regtest-only modules from mainnet/testnet", + "Regression Suite [regression:p0] Argument Parsing & Validation R-ARG-005: filterCommandParameters includes regtest-miner for regtest", + "Regression Suite [regression:p0] Argument Parsing & Validation R-ARG-006: validatePort rejects invalid values" + ], + "cdb0083f856567ee": [ + "Security Config path traversal prevention getDefaultConfig rejects a path-traversal coin parameter" + ], + "cdfbd81e37e90588": [ + "DatabaseService executeNativeMariaDbCommand() asks the driver for array rows when a clustered flag grants batch mode", + "DatabaseService executeNativeMariaDbCommand() closes connection even when query throws", + "DatabaseService executeNativeMariaDbCommand() recognizes --batch flag", + "DatabaseService executeNativeMariaDbCommand() recognizes the clustered short flag -BN as batch mode", + "DatabaseService executeNativeMariaDbCommand() recognizes the clustered short flag in either order (-NB)", + "DatabaseService executeNativeMariaDbCommand() returns empty string for DDL (non-array result)", + "DatabaseService executeNativeMariaDbCommand() returns empty string for SELECT without batch mode", + "DatabaseService executeNativeMariaDbCommand() returns empty string for empty batch result", + "DatabaseService executeNativeMariaDbCommand() returns tab-delimited rows in batch mode (-B -N)", + "DatabaseService executeNativeMariaDbCommand() returns tab-joined columns in batch mode for multi-column rows", + "DatabaseService executeNativeMariaDbCommand() still returns empty for an option string carrying no batch flag" + ], + "ce97a31910d4f041": [ + "NodeService: installNode() bootstraps utxo tracker when volume is fresh", + "NodeService: installNode() does NOT install regtest miner when network is mainnet", + "NodeService: installNode() fetches remote version and calls getCryptoNode when not installed (dogecoin)", + "NodeService: installNode() installs regtest miner when network is regtest", + "NodeService: installNode() succeeds when local node version already installed (skips getCryptoNode)", + "NodeService: installNode() throws the no-valid-version error, not a TypeError, when the remote entry is null", + "NodeService: installNode() throws when no valid remote version is available" + ], + "ced71ed191802081": [ + "AutohealService restarts a container wedged past the grace window even though Health.Log only spans ~60s" + ], + "d0ae5ef1702237dd": [ + "AutohealService does NOT restart the same container twice within the cooldown window", + "AutohealService restarts again once the cooldown window has elapsed" + ], + "d0cd9d15c583ff81": [ + "Chaos: Process Resilience Experiment: Multi-step operation error propagation module not found error before any docker operations" + ], + "d19943f6efeadea6": [ + "moduleOperations resetModules() the coupled decoder/indexer pair aborts the decoder-only reset when the indexer registry row cannot be read", + "moduleOperations resetModules() the coupled decoder/indexer pair allows a decoder-only reset when no indexer is installed to strand", + "moduleOperations resetModules() the coupled decoder/indexer pair leaves `reset all` alone, which already moves both", + "moduleOperations resetModules() the coupled decoder/indexer pair names the joint form in the refusal, so the remedy is runnable", + "moduleOperations resetModules() the coupled decoder/indexer pair refuses a decoder-only reset while an indexer is installed", + "moduleOperations resetModules() the coupled decoder/indexer pair resets both halves together when the joint form is used", + "moduleOperations resetModules() the coupled decoder/indexer pair still allows an indexer-only reset" ], "d1b47eeb57e49143": [ "scan-nine-field-files CLI argument reading does not match a flag name appearing as another flag value", @@ -2209,40 +3584,108 @@ "scan-nine-field-files CLI keyset paging stops on the first empty page", "scan-nine-field-files CLI the entrypoint guard exports the helpers without running the scan on require" ], - "d966ad5aa2291a4b": [ - "StatusService: getInstalledCoinsAndNetworks() ignores coins that are not in the Coin enum", - "StatusService: getInstalledCoinsAndNetworks() returns coins and networks filtered by known Coin/Network values", - "StatusService: getInstalledCoinsAndNetworks() returns empty object when no modules are installed", - "StatusService: getStatus() DB not ready returns empty object when db is not ready", - "StatusService: getStatus() branch column includes branch column in output when any module is on a non-master branch", - "StatusService: getStatus() branch column omits branch column when all modules are on master", - "StatusService: getStatus() cache hit prints cached status when printStatus=true and cache is hot", - "StatusService: getStatus() cache hit returns cached status immediately when isStatusUpdated is true", - "StatusService: getStatus() commit column never shows the module checkout as a container commit", - "StatusService: getStatus() commit column omits the commit column when no image carries a source stamp", - "StatusService: getStatus() commit column prints the commit each container was built from, per container", - "StatusService: getStatus() with installed modules annotates the STATUS cell with restart count and non-healthy health status", - "StatusService: getStatus() with installed modules builds status for a running container and returns installedModules", - "StatusService: getStatus() with installed modules calls checkRemoteNodeVersion when checkVersions=true and version absent", - "StatusService: getStatus() with installed modules cleans up empty coin slot when all containers are confirmed gone", - "StatusService: getStatus() with installed modules does NOT prune or touch the registry on a transient inspect failure (daemon unreachable)", - "StatusService: getStatus() with installed modules formats port bindings into a comma-separated string", - "StatusService: getStatus() with installed modules handles node module versions via getLocalNodeVersion / getContainerNodeVersion", - "StatusService: getStatus() with installed modules leaves a clean container (no restarts, healthy) unchanged and green", - "StatusService: getStatus() with installed modules prints status output when printStatus=true on fresh call", - "StatusService: getStatus() with installed modules removes module from installedModules AND reconciles the registry when docker confirms the container is gone", - "StatusService: getStatus() with installed modules sets local_version and container_version on module", - "StatusService: getStatus() with installed modules sets remote_version on module from remoteModuleVersions", - "StatusService: loadInstalledModules() calls checkRemoteNodeVersion when checkVersions=true", - "StatusService: loadInstalledModules() handles multiple modules for same coin/network", - "StatusService: loadInstalledModules() populates installedModules from db.getAllModuleContainers", - "StatusService: statusChanged() reports the hub failure first when both pushes reject", - "StatusService: statusChanged() sets statusUpdated to false and calls updateHub + updateExplorer", - "StatusService: statusChanged() still pushes explorer config when updateHub rejects, then rethrows", - "StatusService: the advisory remote-version check never aborts the command getStatus completes and returns the table when the per-coin version check is rate-limited", - "StatusService: the advisory remote-version check never aborts the command loadInstalledModules still registers containers when the version check is rate-limited", - "StatusService: the advisory remote-version check never aborts the command still fills the remote version column when GitHub answers", - "StatusService: the advisory remote-version check never aborts the command warns once per pass while still attempting every coin" + "d1c95662bcbee756": [ + "AutohealService doubles the cooldown for each restart that does not clear the wedge" + ], + "d306427bee129d7f": [ + "AutohealService resets the backoff when the container recovers, so the next episode starts at the base cooldown" + ], + "d408c4bf9caedafb": [ + "StatusService: getStatus() commit column never shows the module checkout as a container commit", + "StatusService: getStatus() commit column omits the commit column when no image carries a source stamp", + "StatusService: getStatus() commit column prints the commit each container was built from, per container" + ], + "d52f0bd380eb717c": [ + "AutohealService keeps the earned backoff when the only retained pass falls inside restart probation" + ], + "d539749001580fad": [ + "DatabaseService: clearHubPriceIngestWatermark() against a pre-network-column hub does not fall back for an unknown column that is not `network`", + "DatabaseService: clearHubPriceIngestWatermark() against a pre-network-column hub does not fall back on an ordinary SQL failure", + "DatabaseService: clearHubPriceIngestWatermark() against a pre-network-column hub falls back to the chain-only delete and reports success", + "DatabaseService: clearHubPriceIngestWatermark() against a pre-network-column hub recognises the driver-shaped ER_BAD_FIELD_ERROR in EXTERNAL_DB mode", + "DatabaseService: clearHubPriceIngestWatermark() against a pre-network-column hub warns once, naming the column and the hub version that carries it" + ], + "d5a94f679740e3de": [ + "Chaos: Download Resilience Experiment: Repository not found throws descriptive 404 error for non-existent repository" + ], + "d5b9066c5de4f491": [ + "Regression Suite [regression:p1] Service Lifecycle R-LIF-001: install stores container ID in LevelDB via buildAndUp", + "Regression Suite [regression:p1] Service Lifecycle R-LIF-002: startModules reads container IDs from LevelDB", + "Regression Suite [regression:p1] Service Lifecycle R-LIF-003: stopModules reads container IDs from LevelDB and stops", + "Regression Suite [regression:p1] Service Lifecycle R-LIF-004: restartModules reads IDs from LevelDB and restarts", + "Regression Suite [regression:p1] Service Lifecycle R-LIF-005: uninstall removes LevelDB entry", + "Regression Suite [regression:p1] Service Lifecycle R-LIF-008: stopModules handles multiple modules across coin/networks", + "Regression Suite [regression:p1] Service Lifecycle R-LIF-009: execModules reads container ID and passes command" + ], + "d5cad7504894bd19": [ + "Chaos: Download Resilience Experiment 8b: GitHub rate limiting throws descriptive error on HTTP 403 (rate limit)", + "Chaos: Download Resilience Experiment 8b: GitHub rate limiting throws descriptive error on HTTP 429 (too many requests)" + ], + "d5d6b83f530657fc": [ + "AutohealService SERVICE_HEALTHCHECK opt-in flags xchain-indexer is opted in", + "AutohealService SERVICE_HEALTHCHECK opt-in flags xchain-utxo-tracker is never opted in (deliberate stable-halt design)" + ], + "d6525b1aa23b05bc": [ + "Boundary Tests moduleOperations: grep/testName handling does not add --grep when testName is null even if grep is set", + "Boundary Tests moduleOperations: grep/testName handling handles null grep and null testName", + "Boundary Tests moduleOperations: grep/testName handling includes testName in file path array element", + "Boundary Tests moduleOperations: grep/testName handling passes backslashes in grep pattern unescaped", + "Boundary Tests moduleOperations: grep/testName handling passes grep pattern as separate array element" + ], + "d89bcbe0b2036003": [ + "Integration: Docker Command Construction xchain-decoder Docker run command dogecoin/testnet gets correct DB names", + "Integration: Docker Command Construction xchain-decoder Docker run command includes database env vars and bootstrap volume" + ], + "d8afb9f19d929d01": [ + "GitHubDownloader verifyRepositoryHash() does not throw when hash matches", + "GitHubDownloader verifyRepositoryHash() passes explicit arch parameter to getHashForArch", + "GitHubDownloader verifyRepositoryHash() throws when hash does not match", + "GitHubDownloader verifyRepositoryHash() throws when no hash registered for the repo/version/arch" + ], + "d90f6b0a3738dda4": [ + "DatabaseService addUserPasswordToDatabase() creates database and user via docker when db does not exist", + "DatabaseService addUserPasswordToDatabase() grants DrillB parity permissions to a NON-mainnet indexer", + "DatabaseService addUserPasswordToDatabase() grants MVH test permissions when module is xchain-hub", + "DatabaseService addUserPasswordToDatabase() grants SLAVE MONITOR to indexer and decoder accounts, on MAINNET too", + "DatabaseService addUserPasswordToDatabase() grants a MAINNET indexer only its own HubMirror database by exact name", + "DatabaseService addUserPasswordToDatabase() keeps the HubMirror wildcard off a MAINNET indexer", + "DatabaseService addUserPasswordToDatabase() rotates the password via ALTER USER on the docker path when the user does not match", + "DatabaseService addUserPasswordToDatabase() skips create when database already exists (dbCount != 0)", + "DatabaseService addUserPasswordToDatabase() throws when docker exec fails", + "DatabaseService addUserPasswordToDatabase() withholds SLAVE MONITOR from the hub account", + "DatabaseService addUserPasswordToDatabase() withholds the DrillB grant from a MAINNET indexer" + ], + "d920ceea68258388": [ + "GitHubDownloader calculateDirectoryHash() computes SHA-256 hash from file contents", + "GitHubDownloader constructor creates empty hashes file if it does not exist", + "GitHubDownloader constructor loads hashes file on construction", + "GitHubDownloader constructor throws on invalid hash format (non-64 char hex)", + "GitHubDownloader getAllFiles() returns empty array on error", + "GitHubDownloader getAllFiles() returns single file in array when path is a file", + "GitHubDownloader getLatestCompatibleVersion() returns the latest release regardless of hash when verifyHash=false", + "GitHubDownloader getLatestCompatibleVersion() returns the latest release with a matching hash when verifyHash=true", + "GitHubDownloader getLatestCompatibleVersion() throws when no version has a matching hash", + "GitHubDownloader getReleaseByTag() calls correct GitHub API URL with tag", + "GitHubDownloader getReleases() a plain 403 (not rate-limited) keeps the generic API error", + "GitHubDownloader getReleases() calls GitHub API with correct URL", + "GitHubDownloader getReleases() returns release data on success", + "GitHubDownloader getReleases() sends Authorization: Bearer when GITHUB_TOKEN is set", + "GitHubDownloader getReleases() sends no Authorization header without a token", + "GitHubDownloader getReleases() surfaces a rate-limit-specific error on 403 with remaining=0", + "GitHubDownloader getReleases() throws with descriptive message on 404", + "GitHubDownloader hasHash() returns false for unknown repo", + "GitHubDownloader hasHash() returns false when hash does not exist", + "GitHubDownloader hasHash() returns true when hash exists for repo/version" + ], + "d94222300a70d805": [ + "S-SMOKE-006 – Config Composition getDefaultConfig returns populated config for bitcoin/mainnet", + "S-SMOKE-006 – Config Composition getDefaultConfig returns regtest-specific values for bitcoin/regtest", + "S-SMOKE-006 – Config Composition getDefaultConfig returns shared-service config when coin/network are omitted" + ], + "da1b432659303a4d": [ + "DbCredentialDrift findHubDbCredentialDrift does not flag an indexer whose hub connection uses its own account", + "DbCredentialDrift findHubDbCredentialDrift flags a sibling install whose hub carries a different shared password", + "DbCredentialDrift findHubDbCredentialDrift treats an absent or empty value on either side as no claim" ], "da1b4dbe85ee8518": [ "commandLock acquires the lock and writes pid + command to the lock file", @@ -2263,6 +3706,51 @@ "commandLock waitMs blocks for roughly waitMs against a live holder, then throws", "commandLock waitMs still steals a stale lock immediately (no needless wait)" ], + "dac87b5c804f217b": [ + "ValidatorService initValidator() hub API key a re-run over an already-initialized node does NOT mint a key on a keyless host", + "ValidatorService initValidator() hub API key a re-run over an already-initialized node does NOT mint under --force either", + "ValidatorService initValidator() hub API key a re-run over an already-initialized node mints when --mint-hub-api-key asks it to", + "ValidatorService initValidator() hub API key a re-run over an already-initialized node names the consequence instead of minting silently", + "ValidatorService initValidator() hub API key a re-run over an already-initialized node still reports an existing key, without rotating it", + "ValidatorService initValidator() hub API key ensures a hub API key exists as part of init", + "ValidatorService initValidator() hub API key prints no key material, only the path", + "ValidatorService initValidator() hub API key reports a pre-existing key as reused rather than claiming a fresh one", + "ValidatorService initValidator() hub API key tells the operator WHERE the credential lives" + ], + "dad38ac028223802": [ + "S-SMOKE-010 – Crypto Node Dockerfile Existence bitcoin/Dockerfile exists and is non-empty", + "S-SMOKE-010 – Crypto Node Dockerfile Existence dogecoin/Dockerfile exists and is non-empty", + "S-SMOKE-010 – Crypto Node Dockerfile Existence litecoin/Dockerfile exists and is non-empty" + ], + "dad3a4c39a65e9b4": [ + "GitHubDownloader verifyFileHash() computes the SHA-256 of the file bytes", + "GitHubDownloader verifyFileHash() fails closed when no hash is registered for the version/arch", + "GitHubDownloader verifyFileHash() passes when the tarball hash matches the registered per-arch hash", + "GitHubDownloader verifyFileHash() throws when the tarball hash does not match" + ], + "dd1b100fce46a627": [ + "ValidatorService isInitialized() returns false when key file is missing", + "ValidatorService isInitialized() returns false when neither file exists", + "ValidatorService isInitialized() returns false when settings file is missing", + "ValidatorService isInitialized() returns true when both settings and key files exist", + "ValidatorService pubkeyFromSeedHex() different seeds → different pubkeys", + "ValidatorService pubkeyFromSeedHex() is deterministic: same seed → same pubkey", + "ValidatorService pubkeyFromSeedHex() returns a 64-char hex string" + ], + "dd3e260439472a75": [ + "CLI `recreate` exit code exits NON-ZERO when every requested module was refused", + "CLI `recreate` exit code exits NON-ZERO when recreate throws", + "CLI `recreate` exit code exits ZERO when a container was actually recreated", + "CLI `uninstall` exit code exits NON-ZERO when a module failed to uninstall", + "CLI `uninstall` exit code exits ZERO when every module came down", + "CLI `update` exit code exits NON-ZERO when the update changed nothing (silent no-op redeploy)", + "CLI `update` exit code exits NON-ZERO when the update throws (skew-guard refusal)", + "CLI `update` exit code exits ZERO when a module was actually updated", + "CLI `update` exit code reports both halves when part of the run updated and part refused", + "CLI unhandled-rejection backstop keeps the stack when the reason is a real Error", + "CLI unhandled-rejection backstop prints a string reason readably and exits non-zero", + "CLI unhandled-rejection backstop registers exactly one handler" + ], "dd6e4f59d7ec2e9a": [ "check-rebase-completeness CLI (deploy-informing script) connection options carries the store host, port and user, coercing the port to a number", "check-rebase-completeness CLI (deploy-informing script) connection options defaults a bare store to loopback, 3306 and root", @@ -2297,23 +3785,272 @@ "check-rebase-completeness CLI (deploy-informing script) the sweep wiring end to end feeds both readers into sweepFleet and fails a surviving store", "check-rebase-completeness CLI (deploy-informing script) the sweep wiring end to end passes a store whose tables were all rebuilt inside the window" ], - "de606d67691f176b": [ - "Bootstrap signing checkBootstrapSignature() policy fails closed BY DEFAULT when no public key is pinned", - "Bootstrap signing checkBootstrapSignature() policy fails closed when the key is pinned but the signature file is missing and REQUIRE is set", - "Bootstrap signing checkBootstrapSignature() policy fails closed without a pinned key when XCHAIN_NODE_REQUIRE_SIGNED_BOOTSTRAP is set", - "Bootstrap signing checkBootstrapSignature() policy throws when pinned key + signature are present but the signature is bad", - "Bootstrap signing checkBootstrapSignature() policy verifies and passes when pinned key + valid signature are present", - "Bootstrap signing checkBootstrapSignature() policy warns but proceeds when enforcement is explicitly disabled (=0)", - "Bootstrap signing downloadBootstrap(): companion signature fetch downloads and stores the .sig next to the archive", - "Bootstrap signing downloadBootstrap(): companion signature fetch removes a stale local .sig when the server has none (404)", - "Bootstrap signing refusals are classified as BootstrapIntegrityError names a bad signature", - "Bootstrap signing refusals are classified as BootstrapIntegrityError names a malformed signature file", - "Bootstrap signing refusals are classified as BootstrapIntegrityError names an unsigned-bootstrap refusal", - "Bootstrap signing refusals are classified as BootstrapIntegrityError still carries the operator-facing reason in the message", - "Bootstrap signing rejects a malformed signature file", - "Bootstrap signing rejects a signature from the wrong key", - "Bootstrap signing rejects a tampered archive", - "Bootstrap signing sign \u2192 verify round-trip passes" + "de7155905abb418f": [ + "Fuzz: Config File Parsing config file port override is stored as-is: NaN port", + "Fuzz: Config File Parsing config file port override is stored as-is: empty port", + "Fuzz: Config File Parsing config file port override is stored as-is: float port", + "Fuzz: Config File Parsing config file port override is stored as-is: hex port", + "Fuzz: Config File Parsing config file port override is stored as-is: huge port", + "Fuzz: Config File Parsing config file port override is stored as-is: injection port", + "Fuzz: Config File Parsing config file port override is stored as-is: negative port", + "Fuzz: Config File Parsing config file port override is stored as-is: port above range", + "Fuzz: Config File Parsing config file port override is stored as-is: string port", + "Fuzz: Config File Parsing config file port override is stored as-is: zero port", + "Fuzz: Config File Parsing config file values override defaults but do not remove unmentioned defaults", + "Fuzz: Config File Parsing does not crash on config value: SQL injection", + "Fuzz: Config File Parsing does not crash on config value: extremely long value", + "Fuzz: Config File Parsing does not crash on config value: newline injection", + "Fuzz: Config File Parsing does not crash on config value: null byte", + "Fuzz: Config File Parsing does not crash on config value: path traversal", + "Fuzz: Config File Parsing does not crash on config value: shell metachar backtick", + "Fuzz: Config File Parsing does not crash on config value: shell metachar dollar", + "Fuzz: Config File Parsing does not crash on config value: shell metachar semicolon", + "Fuzz: Config File Parsing does not crash on config value: unicode BOM", + "Fuzz: Config File Parsing handles carriage return line endings", + "Fuzz: Config File Parsing handles config file with 1000 lines", + "Fuzz: Config File Parsing handles config file with no trailing newline", + "Fuzz: Config File Parsing handles config file with only blank lines", + "Fuzz: Config File Parsing handles empty config file", + "Fuzz: Config File Parsing handles key with only digits", + "Fuzz: Config File Parsing handles key with special characters", + "Fuzz: Config File Parsing handles line with empty key (= at start)", + "Fuzz: Config File Parsing handles line with empty value", + "Fuzz: Config File Parsing handles line with multiple equals signs (value contains =)", + "Fuzz: Config File Parsing handles line with no equals sign (ignored)", + "Fuzz: Config File Parsing returns shared config when coin and network are empty strings", + "Fuzz: Config File Parsing returns shared config when coin and network are null", + "Fuzz: Config File Parsing uses defaults when config file does not exist" + ], + "dec5f6ecaa466eaf": [ + "DbCredentialDrift assertNoDbCredentialDrift ignores the container the caller is about to replace", + "DbCredentialDrift assertNoDbCredentialDrift is a no-op when no decoder or indexer container is running", + "DbCredentialDrift assertNoDbCredentialDrift proceeds with a warning when the override env is set", + "DbCredentialDrift assertNoDbCredentialDrift resolves when the running containers agree with the config", + "DbCredentialDrift assertNoDbCredentialDrift still flags a sibling that would be locked out, and names the excluded module in the remedy", + "DbCredentialDrift assertNoDbCredentialDrift throws a tagged error naming the locked-out container" + ], + "df94a025d34dc919": [ + "DockerService addContainerToNetwork() connects container when not already in network", + "DockerService addContainerToNetwork() rejects when network connect fails", + "DockerService addContainerToNetwork() skips connect when container already in network", + "DockerService startDockerMonitor() rejects when containerIds is empty", + "DockerService startDockerMonitor() rejects when containerIds is null" + ], + "dfb14232aba06767": [ + "ROLLCALL wiring guard: what counts as wired either spelling of the URL, set and non-blank, in the env about to be written", + "ROLLCALL wiring guard: what counts as wired not a blank URL, not the key alone, not a missing env", + "ROLLCALL wiring guard: which deploys read roll calls a BTC indexer, on the network it is deployed to", + "ROLLCALL wiring guard: which deploys read roll calls a hub only in validator mode, keyed on its HUB_NETWORK", + "ROLLCALL wiring guard: which deploys read roll calls never an LTC or DOGE indexer", + "ROLLCALL wiring guard: which deploys read roll calls no other module, and nothing without a coin or network" + ], + "dfc874d437cc3eda": [ + "HubService failover checkpoint config stays opted in when the explorer container remembers seed mode", + "HubService failover checkpoint config writes seeds and the feed key without a pinned hub URL", + "HubService failover checkpoint config writes the feed key alongside a pinned endpoint when seeds are unset" + ], + "e1061677e9809ee2": [ + "ConfigService checkIfCryptoNodeSourceExists() returns false when Dockerfile missing", + "ConfigService checkIfCryptoNodeSourceExists() returns true when dir, Dockerfile, and src exist", + "ConfigService checkIfModuleExists() returns false when Dockerfile is missing", + "ConfigService checkIfModuleExists() returns false when package.json is missing", + "ConfigService checkIfModuleExists() returns false when src directory is missing", + "ConfigService checkIfModuleExists() returns true when dir, Dockerfile, src, and package.json all exist", + "ConfigService ensureHubApiKey() adopts a key the operator wrote by hand rather than replacing it", + "ConfigService ensureHubApiKey() creates the config directory when it does not exist yet", + "ConfigService ensureHubApiKey() generates a key into the shared hub sidecar on a host that has none", + "ConfigService ensureHubApiKey() locks the sidecar to 0600, so no other local user can read the credential", + "ConfigService ensureHubApiKey() never returns the key itself, only where it lives", + "ConfigService ensureHubApiKey() preserves the other credentials already in the sidecar", + "ConfigService ensureHubApiKey() readHubApiKey() leaves a sidecar that holds other credentials byte-identical", + "ConfigService ensureHubApiKey() readHubApiKey() never returns the key itself, only whether there is one", + "ConfigService ensureHubApiKey() readHubApiKey() reports a present key without rotating it", + "ConfigService ensureHubApiKey() readHubApiKey() reports absence and writes NOTHING on a keyless host", + "ConfigService ensureHubApiKey() reuses an existing key and leaves the file byte-identical", + "ConfigService filterCommandParameters() accepts the short shared-service names operators actually type", + "ConfigService filterCommandParameters() adds explorer to servicesList[\"\"][\"\"] when modules is \"all\"", + "ConfigService filterCommandParameters() expands \"all\" coins to bitcoin, dogecoin, litecoin", + "ConfigService filterCommandParameters() expands \"all\" modules to full service list plus node", + "ConfigService filterCommandParameters() expands \"all\" networks to mainnet, testnet, regtest", + "ConfigService filterCommandParameters() filters regtest-only modules from mainnet", + "ConfigService filterCommandParameters() filters regtest-only modules from testnet", + "ConfigService filterCommandParameters() handles \"explorer\" module name", + "ConfigService filterCommandParameters() handles \"node\" module name", + "ConfigService filterCommandParameters() handles null coins (expands to all)", + "ConfigService filterCommandParameters() handles null networks (expands to all)", + "ConfigService filterCommandParameters() includes regtest-miner for regtest but excludes e2e-test from all", + "ConfigService filterCommandParameters() lists the coin node before every service under \"all\", so install creates it first", + "ConfigService filterCommandParameters() passes single module/coin/network through unchanged", + "ConfigService filterCommandParameters() returns correct structure for all coins + all networks + all modules", + "ConfigService getCryptoNodeDir() returns correct path for BITCOIN (enum key)", + "ConfigService getCryptoNodeDir() returns correct path for bitcoin (string value)", + "ConfigService getCryptoNodeDir() returns correct path for dogecoin", + "ConfigService getCryptoNodeDir() returns correct path for litecoin", + "ConfigService getDefaultConfig() hub feed and private config routes honours an explicit config-poll key from the credential sidecar", + "ConfigService getDefaultConfig() hub feed and private config routes keeps config polling on the private hub when HUB_API_URL points at a feed", + "ConfigService getDefaultConfig() hub feed and private config routes uses the private hub key for config polling while preserving a feed key override", + "ConfigService getDefaultConfig() regtest-only env passthrough to the indexer carries every regtest-only var onto a regtest indexer", + "ConfigService getDefaultConfig() regtest-only env passthrough to the indexer carries none of them onto mainnet, so a host variable cannot reach a shared ledger", + "ConfigService getDefaultConfig() regtest-only env passthrough to the indexer carries none of them onto testnet, so a host variable cannot reach a shared ledger", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) HUB_API_KEY from the shared hub sidecar deploys the hub KEYED off the sidecar instead of declaring it keyless", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) HUB_API_KEY from the shared hub sidecar gives the co-located indexer the same key the hub runs with", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) HUB_API_KEY from the shared hub sidecar leaves a node with no generated key on its prior keyless declaration", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) HUB_API_KEY from the shared hub sidecar yields to a host-env key (an operator override is never overwritten)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) HUB_API_KEY reaches the e2e-test container gives the e2e-test container the sidecar key, so its config discovery is not the one keyless hub client", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) HUB_API_KEY reaches the e2e-test container leaves a keyless host keyless (a standalone regtest leg must not start sending a key nothing checks)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) HUB_API_KEY reaches the e2e-test container prefers a host-env key over the sidecar for the e2e-test container too", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) HUB_DB_PASS falls back to the shared static default when rotation cannot apply it", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) HUB_DB_PASS is generated, shared, and persisted when rotation can apply it", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) LevelDB tuning passthrough (LEVELDB_CACHE_BYTES / LEVELDB_WRITE_BUFFER_BYTES) does not leak the LevelDB passthrough onto encoder or decoder configs", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) LevelDB tuning passthrough (LEVELDB_CACHE_BYTES / LEVELDB_WRITE_BUFFER_BYTES) emits neither key when the host env carries no LevelDB passthrough values", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) LevelDB tuning passthrough (LEVELDB_CACHE_BYTES / LEVELDB_WRITE_BUFFER_BYTES) passes LEVELDB_CACHE_BYTES and LEVELDB_WRITE_BUFFER_BYTES through from the host env", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) MIRROR ADMISSION pacing passthrough passes host-set mirror pacing windows through to the hub defaults", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) MIRROR ADMISSION passthrough NEVER arms a shared-ledger indexer, whatever the host env says", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) MIRROR ADMISSION passthrough arms the container hub from the same variable, so the venue arms as a unit", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) MIRROR ADMISSION passthrough arms the indexer on regtest when the host opts in", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) MIRROR ADMISSION passthrough does NOT inject the var into a non-indexer coin module (decoder)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) MIRROR ADMISSION passthrough omits the var when unset, so a venue ships INERT", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) NODE_URL is the coin-scoped node container name, distinct per coin", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough NEVER arms a shared-ledger indexer, whatever the host env says", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough NEVER arms the gates flag on a shared-ledger indexer, whatever the host env says", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough arms the container hub from the same variable, so the venue arms as a unit", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough arms the container hub gates flag from the same variable, so the venue arms as a unit", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough arms the indexer gates flag on regtest when the host opts in", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough arms the indexer on regtest when the host opts in", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough carries a bare arming height through unaltered", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough does NOT inject the rollcall vars into a non-indexer coin module (decoder)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough omits every rollcall var when unset, so a venue ships INERT", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough passes the DOGE proof peer through on testnet as well", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) ROLLCALL rail passthrough passes the DOGE proof peer through to the indexer on regtest", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) an operator NODE_URL override in the config file still wins over the default", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) an operator override in the main config file wins and is not regenerated", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) config file values override defaults", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) default values used when config file is empty", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) defaults INDEXER_ALLOW_UNAUTHENTICATED=true on keyless regtest installs (gated methods usable)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) does NOT auto-generate DB passwords where rotation cannot apply them (native/no-container -> static default)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) does not include REGTEST_MINER_URL for mainnet", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) does not include REGTEST_MINER_URL for testnet", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) encoder passthrough (ENCODER_TRUST_PROXY / ENCODER_RATE_LIMIT_RPM) does not leak the encoder passthrough onto decoder or utxo-tracker configs", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) encoder passthrough (ENCODER_TRUST_PROXY / ENCODER_RATE_LIMIT_RPM) emits neither key when the host env carries no encoder passthrough values", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) encoder passthrough (ENCODER_TRUST_PROXY / ENCODER_RATE_LIMIT_RPM) keeps the regtest 99999 literal when the host env sets no override", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) encoder passthrough (ENCODER_TRUST_PROXY / ENCODER_RATE_LIMIT_RPM) lets a host ENCODER_RATE_LIMIT_RPM win over the regtest 99999 literal", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) encoder passthrough (ENCODER_TRUST_PROXY / ENCODER_RATE_LIMIT_RPM) passes ENCODER_TRUST_PROXY and ENCODER_RATE_LIMIT_RPM through from the host env", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) generates per-install DB passwords under EXTERNAL_DB (native-path rotation)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) generates per-install DB passwords when a DB container exists (rotation can apply them) and persists them", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) generates random NODE_USER and NODE_PASSWORD when absent from config file", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) generates the MISSING RPC credential when only one of NODE_USER/NODE_PASSWORD is present (#2404: no both-or-nothing fallback to \"rpc\")", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) genesis-ledger bootstrap passthrough does NOT inject genesis vars into a non-indexer module (decoder)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) genesis-ledger bootstrap passthrough injects the genesis env vars into the indexer config from the host env", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) genesis-ledger bootstrap passthrough omits genesis vars entirely when unset (genesis stays disabled)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) genesis-ledger bootstrap passthrough passes the airdrop set through to the indexer (regtest dry-run seam)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) includes REGTEST_MINER_URL for regtest network", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) indexer HUB_DB_PASS matches the generated per-install INDEXER_DB_PASS when rotation can apply", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) indexer HUB_DB_PASS matches the indexer account static default when rotation cannot apply (never undefined)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) keeps regtest fail-closed when a host INDEXER_API_KEY is provided", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) native-coin fee destination injection defaults to the vendored coin-registry pin when no host env is set", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) native-coin fee destination injection falls back to a generic FEE_DESTINATION host env var", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) native-coin fee destination injection ignores host env overrides on mainnet and injects the registry pin", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) native-coin fee destination injection injects FEE_DESTINATION (decoder) + per-coin var (indexer) from the host env", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) never hands the e2e-test container the signing seed, only the public half", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) omits INDEXER_API_KEY when unset in host env (indexer stays fail-closed)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) omits VALIDATOR_PUBKEY on a standalone node, so the onboarding suite skips rather than staking a key nothing runs as", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) passes INDEXER_API_KEY through from host env to the indexer config (federation auth)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) passes the validator pubkey to the e2e-test container when one is configured", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) points HUB_API_URL at the same hub container HUB_API_HOST names", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) recovers RPC credentials glued onto a preceding setting with no separating newline (#2405)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) regtest mirror arming arms the regtest indexer hub-mirror pointer at its own DB account", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) regtest mirror arming defaults every watermark grace to 0 on regtest when the host sets none of them", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) regtest mirror arming leaves mainnet/testnet mirror arming exactly as before (same account, no grace defaults)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) regtest mirror arming lets a host-set grace value win over the regtest default", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns DECODER_DB_HOST as mariadb", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns DECODER_DB_PORT as 3306", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns HUB_PORT as 10000", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns NETWORK matching the network arg", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct DECODER_DB_NAME", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct DECODER_DB_USER format", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct INDEXER_COIN for dogecoin", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct INDEXER_COIN ticker", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct INDEXER_DB_NAME", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct INDEXER_DB_USER format", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct NODE_PORT for mainnet (8332)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct NODE_PORT for regtest (18444)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct NODE_PORT for testnet (18332)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) returns correct UTXO_TRACKER_URL as Docker image name", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) reuses the persisted DB password on subsequent calls (stable across installs/updates)", + "ConfigService getDefaultConfig() with coin and network (coin-specific config) sets HUB_API_URL so the indexer hub client is enabled", + "ConfigService getDefaultConfig() without coin/network (shared service config) ATTEST response mirror regtest-only override passthrough injects both ATTEST override knobs from host env into the hub config", + "ConfigService getDefaultConfig() without coin/network (shared service config) ATTEST response mirror regtest-only override passthrough injects only the one override set, leaving the other unset", + "ConfigService getDefaultConfig() without coin/network (shared service config) ATTEST response mirror regtest-only override passthrough leaves both ATTEST override knobs unset when host env is absent", + "ConfigService getDefaultConfig() without coin/network (shared service config) HUB_NETWORK passthrough injects HUB_NETWORK from host env into the hub config", + "ConfigService getDefaultConfig() without coin/network (shared service config) HUB_NETWORK passthrough leaves HUB_NETWORK unset when host env is absent (standalone unchanged)", + "ConfigService getDefaultConfig() without coin/network (shared service config) HUB_NETWORK passthrough reads no validator identity off the host filesystem", + "ConfigService getDefaultConfig() without coin/network (shared service config) ORACLE_BATCH_* passthrough injects all five ORACLE_BATCH_* knobs from host env into the hub config", + "ConfigService getDefaultConfig() without coin/network (shared service config) ORACLE_BATCH_* passthrough leaves all five ORACLE_BATCH_* knobs unset when host env is absent (hub default unchanged)", + "ConfigService getDefaultConfig() without coin/network (shared service config) XCHAIN_CONFIRMATIONS_* passthrough injects all three XCHAIN_CONFIRMATIONS_* depths from host env into the hub config", + "ConfigService getDefaultConfig() without coin/network (shared service config) XCHAIN_CONFIRMATIONS_* passthrough injects only the one depth set, leaving the other two absent", + "ConfigService getDefaultConfig() without coin/network (shared service config) XCHAIN_CONFIRMATIONS_* passthrough leaves all three depths absent when the host env carries none (hub default unchanged)", + "ConfigService getDefaultConfig() without coin/network (shared service config) does not include coin-specific keys like NODE_PORT", + "ConfigService getDefaultConfig() without coin/network (shared service config) emits INDEXER_API_URL__ for every coin and network", + "ConfigService getDefaultConfig() without coin/network (shared service config) emits no EXPLORER_BATCH_RATE_LIMIT_RPM when the host env carries none", + "ConfigService getDefaultConfig() without coin/network (shared service config) emits no per-route explorer rate-limit keys when the host env carries none", + "ConfigService getDefaultConfig() without coin/network (shared service config) emits no serving-limit keys when the host env carries none", + "ConfigService getDefaultConfig() without coin/network (shared service config) honours a HUB_PORT host env override", + "ConfigService getDefaultConfig() without coin/network (shared service config) hub keyless declaration declares keyless operation when no HUB_API_KEY is in the host env", + "ConfigService getDefaultConfig() without coin/network (shared service config) hub keyless declaration does NOT declare keyless on a mainnet hub", + "ConfigService getDefaultConfig() without coin/network (shared service config) hub keyless declaration does not declare keyless when a key is present (the hub is keyed)", + "ConfigService getDefaultConfig() without coin/network (shared service config) hub keyless declaration still declares keyless on a regtest hub", + "ConfigService getDefaultConfig() without coin/network (shared service config) hub keyless declaration yields to an explicit host-env refusal (HUB_ALLOW_UNAUTHENTICATED=false)", + "ConfigService getDefaultConfig() without coin/network (shared service config) leaves HUB_PORT at the 10000 default when host env sets no override", + "ConfigService getDefaultConfig() without coin/network (shared service config) omits HUB_API_KEY from shared-service configs when unset in host env", + "ConfigService getDefaultConfig() without coin/network (shared service config) passes HUB_API_KEY through from host env to shared-service configs (keyed sensitive-read tier)", + "ConfigService getDefaultConfig() without coin/network (shared service config) passes the batch explorer rate limit through from the host env", + "ConfigService getDefaultConfig() without coin/network (shared service config) passes the explorer serving limits through from the host env", + "ConfigService getDefaultConfig() without coin/network (shared service config) passes the five per-route explorer rate limits through from the host env", + "ConfigService getDefaultConfig() without coin/network (shared service config) returns EXPLORER_API_PORT_HTTP as 8080", + "ConfigService getDefaultConfig() without coin/network (shared service config) returns EXPLORER_PORT as 18080", + "ConfigService getDefaultConfig() without coin/network (shared service config) returns EXPLORER_PORT_HTTPS as 18081", + "ConfigService getDefaultConfig() without coin/network (shared service config) returns HUB_PORT as 10000", + "ConfigService getDefaultConfig() without coin/network (shared service config) returns SYNC_API_PORT as 3006", + "ConfigService getDefaultConfig() without coin/network (shared service config) returns SYNC_MODE as server", + "ConfigService getDefaultConfig() without coin/network (shared service config) yields INDEXER_API_URL__ to the host env", + "ConfigService getDockerContainerImageName() builds coin-specific image name: xchain-node-bitcoin-mainnet-xchain-encoder", + "ConfigService getDockerContainerImageName() builds correct names for all coin/network combos", + "ConfigService getDockerContainerImageName() builds database name: xchain-node-database", + "ConfigService getDockerContainerImageName() builds explorer name: xchain-node-xchain-explorer", + "ConfigService getDockerContainerImageName() builds shared module name without coin/network: xchain-node-xchain-hub", + "ConfigService getDockerContainerImageName() builds sync name: xchain-node-xchain-sync", + "ConfigService getDockerNetwork() handles only coin without network", + "ConfigService getDockerNetwork() returns xchain-node for empty coin and network", + "ConfigService getDockerNetwork() returns xchain-node-bitcoin-mainnet for bitcoin mainnet", + "ConfigService getModuleDatabaseName() allows the shared-service empty-string network", + "ConfigService getModuleDatabaseName() capitalizes module name correctly (utxo-tracker -> Utxo-tracker)", + "ConfigService getModuleDatabaseName() returns XChain_BTC_Mainnet_Decoder for bitcoin mainnet decoder", + "ConfigService getModuleDatabaseName() returns XChain_DOGE_Testnet_Indexer for dogecoin testnet indexer", + "ConfigService getModuleDatabaseName() returns XChain_LTC_Regtest_Decoder for litecoin regtest decoder", + "ConfigService getModuleDatabaseName() throws on an unknown network (e.g. a typo from a raw CLI arg)", + "ConfigService getModuleDatabaseName() uses correct ticker for all coins", + "ConfigService getModuleDir() returns moduleDir + / + module", + "ConfigService getModuleTmpDir() returns tmpDir + / + module", + "ConfigService getUtxoTrackerVolumeName() keeps the legacy unprefixed name under the default NODE_PREFIX", + "ConfigService getUtxoTrackerVolumeName() prefixes the name under a non-default NODE_PREFIX", + "ConfigService indexer hub failover environment does not write indexer-only discovery settings to sibling coin services", + "ConfigService indexer hub failover environment keeps the pinned default when seed mode is unset", + "ConfigService indexer hub failover environment removes an old per-coin pinned feed URL when host seed mode is set", + "ConfigService indexer hub failover environment writes seed and feed credentials to every indexer without a pinned feed URL", + "ConfigService managed hub failover environment treats empty failover environment values as unset", + "ConfigService managed hub failover environment writes only the public API URL to a managed hub", + "ConfigService managed hub failover environment writes seed and feed credentials to a self-syncing explorer without a pinned feed URL", + "ConfigService moduleDirExists() returns false for non-existent module", + "ConfigService moduleDirExists() returns true when directory exists", + "ConfigService persistSidecarCreds() does not add a blank line when the existing file already ends in a newline", + "ConfigService persistSidecarCreds() does not prepend a newline when creating a fresh file", + "ConfigService persistSidecarCreds() inserts a separating newline when appending to a file not ending in one (#2406)", + "ConfigService resolveArgs() service resolution leaves the branch slot free to take an arbitrary name", + "ConfigService resolveArgs() service resolution names the valid services in the refusal, so the operator can correct it", + "ConfigService resolveArgs() service resolution refuses an unrecognized service name instead of expanding to every service", + "ConfigService resolveArgs() service resolution resolves the canonical shared-service names", + "ConfigService resolveArgs() service resolution resolves the short shared-service aliases to their canonical names", + "ConfigService resolveArgs() service resolution still accepts every legitimate argument shape" ], "e12b9a311ad07cac": [ "cli commandRepairsHub() says no when the command needs a hub it cannot fix for `all` on the verbs whose expansion leaves the hub out", @@ -2339,6 +4076,176 @@ "cli refForPreCheck() returns null when every argument is a known service/chain/network", "cli refForPreCheck() swallows a resolveArgs refusal rather than aborting before the action can report it" ], + "e187ad3068ae0375": [ + "Chaos: Config Resilience Experiment 1: Config file missing falls back to hardcoded defaults when config file does not exist", + "Chaos: Config Resilience Experiment 1: Config file missing still returns all required default keys when config file is missing", + "Chaos: Config Resilience Experiment 1b: Config file unreadable (permission denied) propagates stream error when config file cannot be read" + ], + "e1e49bbe50482377": [ + "Security No remaining exec() calls in source files cli.js does not use child_process.exec()", + "Security No remaining exec() calls in source files cli/commands.js does not use child_process.exec()", + "Security No remaining exec() calls in source files cli/dispatch.js does not use child_process.exec()", + "Security No remaining exec() calls in source files cli/errors.js does not use child_process.exec()", + "Security No remaining exec() calls in source files cli/options.js does not use child_process.exec()", + "Security No remaining exec() calls in source files cli/output.js does not use child_process.exec()", + "Security No remaining exec() calls in source files cli/parse_command.js does not use child_process.exec()", + "Security No remaining exec() calls in source files coins/BTC.js does not use child_process.exec()", + "Security No remaining exec() calls in source files coins/DOGE.js does not use child_process.exec()", + "Security No remaining exec() calls in source files coins/LTC.js does not use child_process.exec()", + "Security No remaining exec() calls in source files coins/consensus_pin.js does not use child_process.exec()", + "Security No remaining exec() calls in source files coins/index.js does not use child_process.exec()", + "Security No remaining exec() calls in source files config/env_views.js does not use child_process.exec()", + "Security No remaining exec() calls in source files config/index.js does not use child_process.exec()", + "Security No remaining exec() calls in source files config/module_names.js does not use child_process.exec()", + "Security No remaining exec() calls in source files config/secret_env.js does not use child_process.exec()", + "Security No remaining exec() calls in source files config/service_registry.js does not use child_process.exec()", + "Security No remaining exec() calls in source files db/blocks.js does not use child_process.exec()", + "Security No remaining exec() calls in source files db/connectivity.js does not use child_process.exec()", + "Security No remaining exec() calls in source files db/cross_chain.js does not use child_process.exec()", + "Security No remaining exec() calls in source files db/halt_markers.js does not use child_process.exec()", + "Security No remaining exec() calls in source files db/index.js does not use child_process.exec()", + "Security No remaining exec() calls in source files db/information_schema.js does not use child_process.exec()", + "Security No remaining exec() calls in source files db/migrations.js does not use child_process.exec()", + "Security No remaining exec() calls in source files db/modules.js does not use child_process.exec()", + "Security No remaining exec() calls in source files db/price_fence.js does not use child_process.exec()", + "Security No remaining exec() calls in source files db/transactions.js does not use child_process.exec()", + "Security No remaining exec() calls in source files index.js does not use child_process.exec()", + "Security No remaining exec() calls in source files observability/logger.js does not use child_process.exec()", + "Security No remaining exec() calls in source files operations/module_operations.js does not use child_process.exec()", + "Security No remaining exec() calls in source files operations/module_operations/module_controls.js does not use child_process.exec()", + "Security No remaining exec() calls in source files operations/module_operations/recreate_modules.js does not use child_process.exec()", + "Security No remaining exec() calls in source files operations/module_operations/repo_refs.js does not use child_process.exec()", + "Security No remaining exec() calls in source files operations/module_operations/reset_modules.js does not use child_process.exec()", + "Security No remaining exec() calls in source files operations/module_operations/shared_services.js does not use child_process.exec()", + "Security No remaining exec() calls in source files operations/module_operations/uninstall_modules.js does not use child_process.exec()", + "Security No remaining exec() calls in source files operations/module_operations/update_modules.js does not use child_process.exec()", + "Security No remaining exec() calls in source files precheck.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/autoheal_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/autoheal_service/restart_state.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/bootstrap_archive_meta.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/bootstrap_health_gate.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/bootstrap_health_gate/halt_markers.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/bootstrap_health_gate/status_evaluation.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/bootstrap_node_tip_guard.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/bootstrap_republish_ledger.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/bootstrap_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/bootstrap_service/archive_signing.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/bootstrap_service/download.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/bootstrap_service/ensure_restored.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/bootstrap_service/freshness.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/bootstrap_service/mariadb_archive.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/bootstrap_service/restore_archive.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/bootstrap_service/tracker_archive.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/bootstrap_service/tracker_height.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/bootstrap_service/workspace.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/config_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/config_service/arguments.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/config_service/coins.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/config_service/database.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/config_service/defaults.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/config_service/filters.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/config_service/networks.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/config_service/services.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/config_service/sidecars.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/config_service/validation.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/credentials_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/database_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/database_service/container_access.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/database_service/database_module.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/database_service/db_parameters.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/database_service/external_db.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/database_service/hub_cross_chain_purge.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/database_service/mariadb_exec.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/database_service/reset_databases.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/database_service/user_provisioning.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/db_credential_drift.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/discovery_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/docker_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/docker_service/container_logs.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/docker_service/environment_checks.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/docker_service/networks_and_files.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/encoder_maintenance_window.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/explorer_connector.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/explorer_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/gated_file_field_scan.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/github_downloader.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/github_downloader/hash_verification.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/github_downloader/host_arch.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/go_live_gate.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/hub_connector.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/hub_consensus_env_guard.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/hub_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/hub_service/hub_module_config.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/hub_service/update_hub_or_explorer.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/install_target_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/memory_limit_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/migration_precondition_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/migration_precondition_service/migration_scan.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/module_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/module_service/build_and_up.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/module_service/clone_and_refs.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/module_service/container_networks.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/module_service/docker_args.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/module_service/git_checkout.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/node_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/node_service/crypto_node_build.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/node_service/crypto_node_download.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/node_service/node_stop.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/peer_services.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/rebase_completeness_sweep.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/release_manifest_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/release_signature_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/release_signature_service/gpg_verification.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/release_signature_service/release_assets.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/rollcall_wiring/arming_heights.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/rollcall_wiring/index.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/self_update_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/skew_guard_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/status_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/status_service/get_status.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/status_service/installed_modules.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/status_service/status_reducers.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/stop_budget_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/telemetry_connector.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/telemetry_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/validator_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/validator_service/capability_drift.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/validator_service/capability_layout.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/validator_service/init_validator.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/validator_service/rollcall.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/validator_service/signer.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/validator_service/validator_paths.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/validator_service/validator_settings.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/validator_service/wallets.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/validator_stake_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/validator_stake_service/free_key.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/validator_stake_service/send_lock.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/validator_stake_service/stake_operations.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/validator_stake_service/token_balance_read.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/validator_stake_service/unstake_operations.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/validator_stake_service/validator_set_read.js does not use child_process.exec()", + "Security No remaining exec() calls in source files services/version_service.js does not use child_process.exec()", + "Security No remaining exec() calls in source files state.js does not use child_process.exec()", + "Security No remaining exec() calls in source files ui/menu.js does not use child_process.exec()", + "Security No remaining exec() calls in source files ui/menu/module_choices.js does not use child_process.exec()", + "Security No remaining exec() calls in source files ui/menu/restore_bootstrap_prompt.js does not use child_process.exec()", + "Security No remaining exec() calls in source files utils/command_lock.js does not use child_process.exec()", + "Security No remaining exec() calls in source files utils/docker_mariadb.js does not use child_process.exec()", + "Security No remaining exec() calls in source files utils/github_api.js does not use child_process.exec()", + "Security No remaining exec() calls in source files utils/helpers.js does not use child_process.exec()", + "Security No remaining exec() calls in source files utils/indexer_migrate_cli.js does not use child_process.exec()", + "Security No remaining exec() calls in source files utils/migration_files.js does not use child_process.exec()", + "Security No remaining exec() calls in source files utils/sql_safety.js does not use child_process.exec()" + ], + "e269ee035ad3b399": [ + "ModuleService buildAndUp() SHUTDOWN_TIMEOUT_MS gives a one-shot execution container no drain, as it gets no stop budget", + "ModuleService buildAndUp() SHUTDOWN_TIMEOUT_MS hands the decoder and tracker the drain derived from their budget, by name only on argv", + "ModuleService buildAndUp() SHUTDOWN_TIMEOUT_MS keeps an explicit SHUTDOWN_TIMEOUT_MS from the module config", + "ModuleService buildAndUp() SHUTDOWN_TIMEOUT_MS leaves a service on the plain default budget to its own drain default" + ], + "e2ced651370bfd16": [ + "Chaos: Docker Resilience Experiment: Docker network creation failure rejects when network create command fails" + ], "e2d431011abfd746": [ "publish-bootstraps.sh: forced republish after a reindex --no-forced-due falls back to the schedule alone", "publish-bootstraps.sh: forced republish after a reindex defers a due tracker but reports it on every run", @@ -2349,26 +4256,31 @@ "publish-bootstraps.sh: forced republish after a reindex pulls in a due combo the resolved plan never contained", "publish-bootstraps.sh: forced republish after a reindex republishes a due tracker when the operator accepts the downtime" ], - "e3665c51c64c9661": [ - "HubService.buildCheckpointConfig hub endpoint defaults the endpoint to the hub container on the docker network", - "HubService.buildCheckpointConfig hub endpoint emits a hub_url alongside every self_sync it advertises", - "HubService.buildCheckpointConfig hub endpoint honours an operator HUB_API_URL from the host env", - "HubService.buildCheckpointConfig hub endpoint keeps the mirror schema and indexer credentials it already carried", - "HubService.isCheckpointSelfSyncEnabled is not opted in when neither the env nor the container says so", - "HubService.isCheckpointSelfSyncEnabled is not opted in when there is no explorer container to ask", - "HubService.isCheckpointSelfSyncEnabled is opted in when the host env carries the flag", - "HubService.isCheckpointSelfSyncEnabled reads the flag itself off the container when it is there", - "HubService.isCheckpointSelfSyncEnabled stays opted in when the env is gone but the explorer container remembers", - "HubService.isCheckpointSelfSyncEnabled treats an empty env value as unset rather than as an opt-in", - "HubService.isHubAnswering asks once, with no retry loop of its own", - "HubService.isHubAnswering is false when nothing answers", - "HubService.isHubAnswering is true when the hub answers a ping", - "HubService.updateHub network attachment rejects naming the unreachable network instead of reporting success", - "HubService.updateHub network attachment retries a failed attach once and succeeds on the second try", - "HubService.updateHub network attachment returns true when every shared-container attach succeeds", - "HubService.updateHub skipConfigPush attaches the shared containers but never reaches the config push", - "HubService.updateHub skipConfigPush defaults to pushing, so no existing caller changes behaviour", - "HubService.updateHub skipConfigPush pushes as it always has when not told to skip" + "e2fdc331e1ae4b01": [ + "Chaos: Process Resilience Experiment: Uninstall resilience returns true when module is not found in status (already uninstalled)", + "Chaos: Process Resilience Experiment: Uninstall resilience throws when trying to uninstall the database module" + ], + "e34f0beb0ebd6970": [ + "ExplorerService: installExplorerModule() exhausts retries throws when explorer never responds after 10 tries" + ], + "e5fb8ceb0cbe29f3": [ + "peer_services: every bindPeerServices() caller resolves for real finds at least the known caller set under src/services/", + "peer_services: every bindPeerServices() caller resolves for real resolves every PEER_SERVICE_FILES entry from src/services/config_service.js", + "peer_services: every bindPeerServices() caller resolves for real resolves every PEER_SERVICE_FILES entry from src/services/database_service.js", + "peer_services: every bindPeerServices() caller resolves for real resolves every PEER_SERVICE_FILES entry from src/services/database_service/database_module.js", + "peer_services: every bindPeerServices() caller resolves for real resolves every PEER_SERVICE_FILES entry from src/services/module_service.js", + "peer_services: every bindPeerServices() caller resolves for real resolves every PEER_SERVICE_FILES entry from src/services/node_service.js", + "peer_services: every bindPeerServices() caller resolves for real resolves every PEER_SERVICE_FILES entry from src/services/node_service/crypto_node_build.js", + "peer_services: every bindPeerServices() caller resolves for real resolves every PEER_SERVICE_FILES entry from src/services/peer_services.js", + "peer_services: every bindPeerServices() caller resolves for real resolves every PEER_SERVICE_FILES entry from src/services/status_service.js", + "peer_services: every bindPeerServices() caller resolves for real resolves every PEER_SERVICE_FILES entry from src/services/status_service/get_status.js", + "peer_services: every bindPeerServices() caller resolves for real resolves every PEER_SERVICE_FILES entry from src/services/status_service/status_reducers.js", + "peer_services: every bindPeerServices() caller resolves for real resolves every PEER_SERVICE_FILES entry from src/services/version_service.js" + ], + "e6fde6f428c1cad7": [ + "GitHubDownloader loadHashesFile(): invalid hash in object entry throws when arch hash is invalid in object entry", + "GitHubDownloader loadHashesFile(): invalid hash in object entry throws when top-level repo entry is not an object", + "GitHubDownloader loadHashesFile(): invalid hash in object entry throws when version entry is neither string nor object" ], "e7b8ff5f97f1f7f3": [ "BootstrapRepublishLedger getReindexLedgerPath() defaults to the per-user ~/.xchain-node dir, not the data dir", @@ -2400,28 +4312,213 @@ "BootstrapRepublishLedger writeReindexLedger() replaces the file atomically and leaves no temp file behind", "BootstrapRepublishLedger writeReindexLedger() reports failure instead of throwing when the target is unwritable" ], - "eb89a6dc1945c342": [ - "SERVICE_REGISTRY buildModuleDockerArgs() conditional port omitted when a key is missing", - "SERVICE_REGISTRY buildModuleDockerArgs() decoder: conditional port + unconditional bootstrap volume", - "SERVICE_REGISTRY buildModuleDockerArgs() encoder: port only, no volume", - "SERVICE_REGISTRY buildModuleDockerArgs() explorer: singleton with two unconditional port mappings", - "SERVICE_REGISTRY buildModuleDockerArgs() hub: a mount refusal from ValidatorService fails the build instead of silently dropping the config", - "SERVICE_REGISTRY buildModuleDockerArgs() hub: mounts the capability config DIRECTORY (ro) when HUB_CAPABILITY_CONFIG is present", - "SERVICE_REGISTRY buildModuleDockerArgs() hub: mounts the generated DOGE signer (ro) with this package's node_modules beside it", - "SERVICE_REGISTRY buildModuleDockerArgs() hub: singleton, unconditional single port, no static volumes when unconfigured", - "SERVICE_REGISTRY buildModuleDockerArgs() sync: singleton, conditional port present", - "SERVICE_REGISTRY buildModuleDockerArgs() unknown / docker-less module yields empty args (e2e-test, node, database)", - "SERVICE_REGISTRY buildModuleDockerArgs() utxo-tracker: port + dynamic data volume + bootstrap volume + nofile ulimit", - "SERVICE_REGISTRY coverage parity with canonical service enums has a docker facet for every buildAndUp-installed service", - "SERVICE_REGISTRY coverage parity with canonical service enums has a hubConfig facet for every module the hub reports on", - "SERVICE_REGISTRY coverage parity with canonical service enums marks exactly hub/explorer/sync as singletons", - "SERVICE_REGISTRY coverage parity with canonical service enums the one-shot e2e-test runner has no docker facet (no ports/volumes)", - "SERVICE_REGISTRY coverage parity with canonical service enums the singleton services contribute no hub config" + "e85f9fd995faa7fc": [ + "Integration: Docker Command Construction xchain-regtest-miner Docker run command includes regtest miner port on regtest network" + ], + "e9444247682f4e2d": [ + "NodeService: buildCryptoNode() aborts before the docker build when a host-port conflict is detected", + "NodeService: buildCryptoNode() bakes no CRYPTO_NODE_VERSION env into the coin-node container", + "NodeService: buildCryptoNode() includes --restart unless-stopped in docker run args", + "NodeService: buildCryptoNode() includes --ulimit nofile flag in docker run args", + "NodeService: buildCryptoNode() includes port mapping when NODE_EXPOSED_PORT and NODE_PORT are configured", + "NodeService: buildCryptoNode() reads and rewrites conf file when it exists", + "NodeService: buildCryptoNode() rejects (does not hang) when docker run output is not a container id", + "NodeService: buildCryptoNode() rejects (does not hang) when the blocksDir mkdir fails", + "NodeService: buildCryptoNode() rejects (does not hang) when the docker build fails", + "NodeService: buildCryptoNode() rejects when db.setModuleContainer returns false", + "NodeService: buildCryptoNode() rejects when docker run fails", + "NodeService: buildCryptoNode() runs docker build with correct image name and cwd", + "NodeService: buildCryptoNode() stores container ID and fires statusChanged on success" + ], + "ea0325c156538f37": [ + "rail-leg.yml mirror admission arms only the list_share drive and keeps the plan contract unchanged", + "rail-leg.yml mirror admission leaves every stack install unarmed when activation is empty", + "rail-leg.yml mirror admission passes armed mirror admission to every stack install", + "rail-leg.yml mirror admission uses regtest-length round windows only when mirror admission is armed" + ], + "ea9aeeb75660fd91": [ + "DbCredentialDrift readContainerEnv parses docker inspect output into a plain object", + "DbCredentialDrift readContainerEnv returns null for a container that does not exist", + "DbCredentialDrift readContainerEnv returns null on unparseable output rather than throwing" + ], + "eae1a818e5f6a1fe": [ + "AutohealService does NOT restart an unhealthy container whose service is not opted in (utxo-tracker)", + "AutohealService refuses to run against an unconfigured module registry", + "AutohealService restarts an unhealthy container whose service opted in (autoheal: true)" + ], + "ed8016ccb0fe8623": [ + "BootstrapHealthGate container state REFUSES a container that restarted moments ago", + "BootstrapHealthGate container state REFUSES a restarting (crash-looping) container", + "BootstrapHealthGate container state REFUSES a stopped container", + "BootstrapHealthGate container state REFUSES when docker inspect cannot be run", + "BootstrapHealthGate container state REFUSES when docker reports the healthcheck unhealthy", + "BootstrapHealthGate container state REFUSES when no container is registered", + "BootstrapHealthGate container state accepts a container that restarted long ago and has been stable since", + "BootstrapHealthGate container state reads RestartCount from the top level, not from .State (docker rejects the latter)" + ], + "eec7cda19aee9b9b": [ + "moduleOperations execModules(): error path continues after execContainer error", + "moduleOperations execModules(): error path skips when container ID is null", + "moduleOperations logModules(): no containers prints \"No service was selected\" when no containers found", + "moduleOperations resetModules() the post-reset decoder/indexer bounce recovers silently when the retry succeeds", + "moduleOperations resetModules() the post-reset decoder/indexer bounce reports a bounce restart that fails twice instead of returning success", + "moduleOperations resetModules() the post-reset decoder/indexer bounce reports a registry read that fails at bounce time rather than skipping it as not installed", + "moduleOperations resetModules(): confirmation guard force=true skips the confirmation prompt entirely", + "moduleOperations resetModules(): confirmation guard refuses to reset on a non-interactive terminal without --yes/force", + "moduleOperations restartModules(): error path continues after restartContainer error", + "moduleOperations startModules(): error path continues after startContainer error", + "moduleOperations stopModules(): skip when no container continues after stopContainer error", + "moduleOperations stopModules(): skip when no container skips when container ID is not found" + ], + "eefea4f8da594893": [ + "DatabaseService setDatabaseParameters() checks for credential drift with the config values, before any account write", + "DatabaseService setDatabaseParameters() does not blame the docker network for a drift refusal", + "DatabaseService setDatabaseParameters() iterates installed coins+networks and adds DB users", + "DatabaseService setDatabaseParameters() refuses to run against an unconfigured module registry", + "DatabaseService setDatabaseParameters() throws and logs when coin network processing fails", + "DatabaseService setDatabaseParameters() throws instead of reporting success when no coin/network is installed", + "DatabaseService setDatabaseParameters() throws when the registry lists a coin but holds no decoder/indexer container", + "DatabaseService setDatabaseParameters() writes no account when the drift guard refuses" + ], + "efa6cc2e3df55d82": [ + "ModuleService installModule(): branch switch path reclones when existing branch differs from requested branch", + "ModuleService installModule(): explorer error path throws when installExplorerModule fails", + "ModuleService installModule(): node module builds crypto node when no container version and localNodeVersion is null", + "ModuleService installModule(): node module returns false when node container_version already set and remoteUpdate=false", + "ModuleService installModule(): node module skips getCryptoNode when localNodeVersion already set and remoteUpdate=false", + "ModuleService installModule(): node: checkRemoteNodeVersion called when coin not in remoteVersions calls checkRemoteNodeVersion when coin not in remote versions map", + "ModuleService uninstallModule(): deleteModuleContainer returns false throws when deleteModuleContainer returns false after successful container removal" + ], + "efb0052f155d4455": [ + "Fuzz: resolveArgs() branch is null when expectBranch=false", + "Fuzz: resolveArgs() captures unrecognized argument as branch when expectBranch=true", + "Fuzz: resolveArgs() defaults to all/all/all when no recognized args", + "Fuzz: resolveArgs() does not crash on garbage argument: \" \"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"--admin\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"--verbose\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"-v\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"../../../etc/passwd\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"123\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"BITCOIN\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"Bitcoin\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"Infinity\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"MAINNET\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"NaN\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"[]\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"bitCOIN\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"bitcoin$(whoami)\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"foobarbaz\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"mainnet`id`\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"null\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"true\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"undefined\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"xchain-encoder; rm -rf /\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"xchain-encoder\\u0000extra\"", + "Fuzz: resolveArgs() does not crash on garbage argument: \"{}\"", + "Fuzz: resolveArgs() does not match injection strings as valid services/chains/networks", + "Fuzz: resolveArgs() handles 100 garbage arguments without crashing", + "Fuzz: resolveArgs() handles array with null/undefined elements", + "Fuzz: resolveArgs() handles empty array", + "Fuzz: resolveArgs() last match wins when duplicate chains provided", + "Fuzz: resolveArgs() last match wins when duplicate networks provided", + "Fuzz: resolveArgs() last match wins when duplicate services provided", + "Fuzz: resolveArgs() recognizes args regardless of order: chain network only", + "Fuzz: resolveArgs() recognizes args regardless of order: service chain network", + "Fuzz: resolveArgs() recognizes every Coin enum value", + "Fuzz: resolveArgs() recognizes every Network enum value", + "Fuzz: resolveArgs() recognizes every XChainService enum value", + "Fuzz: resolveArgs() recognizes service alongside \"all\"", + "Fuzz: resolveArgs() recognizes special module names: node, database, explorer", + "Fuzz: resolveArgs() skips \"all\" arguments (preserves default)", + "Fuzz: resolveArgs() uses custom defaultBranch", + "Fuzz: resolveArgs() uses defaultBranch when no unrecognized arg and expectBranch=true" + ], + "f033f9a92030563f": [ + "Chaos: Process Resilience Experiment 11: Async error propagation propagates rejection from stopContainerByName during overwrite" + ], + "f0e58110dbd1940b": [ + "ReleaseManifestService the shipped manifest declares a platform version and a release date", + "ReleaseManifestService the shipped manifest does not pin xchain-node itself", + "ReleaseManifestService the shipped manifest pins every component to a tag and a full 40-hex commit", + "ReleaseManifestService the shipped manifest pins no component above the platform version it ships in" + ], + "f13ced249dae415e": [ + "unit config directory isolation takes effect before the production config module is loaded", + "unit config directory isolation uses an operating-system temporary directory outside the checkout" + ], + "f1b427ee16f7a8b8": [ + "Regression Suite [regression:p1] E2E Lifecycle Workflows R-E2E-001: full install -> stop -> start -> uninstall cycle succeeds", + "Regression Suite [regression:p1] E2E Lifecycle Workflows R-E2E-002: install stores correct coin/network per module in LevelDB", + "Regression Suite [regression:p1] E2E Lifecycle Workflows R-E2E-003: selective install only installs targeted module", + "Regression Suite [regression:p1] E2E Lifecycle Workflows R-E2E-004: docker build commands use correct image names", + "Regression Suite [regression:p1] E2E Lifecycle Workflows R-E2E-005: docker run commands include correct hostname and network", + "Regression Suite [regression:p1] E2E Lifecycle Workflows R-E2E-006: install answers from this env, never from host docker, config or network" + ], + "f1ea17eadb3df859": [ + "rail-leg.yml drive environment coin defaults to bitcoin when the drive coin helper is absent", + "rail-leg.yml drive environment coin passes the matrix drive and leg into the setup step", + "rail-leg.yml drive environment coin uses the drive coin helper when it exists", + "rail-leg.yml drive environment coin writes a bitcoin .env.btc beside a dogecoin leg env", + "rail-leg.yml drive environment coin writes no .env.btc on a bitcoin leg" + ], + "f228d9772b7c14c7": [ + "validator stake --broadcast: send lock across waits, dry runs and early stops --serialize retakes the lock after each mint's wait and stops, naming what went out, if it is taken", + "validator stake --broadcast: send lock across waits, dry runs and early stops a dry run takes no lock, so it plans while a deploy holds it", + "validator stake --broadcast: send lock across waits, dry runs and early stops releases the lock and its exit handler when the run stops short of sending", + "validator stake --broadcast: send-phase command lock hands the lock back for the STAKE's index wait, so another command runs meanwhile", + "validator stake --broadcast: send-phase command lock holds the lock across every chained mint and the STAKE, which send without a wait between", + "validator stake --broadcast: send-phase command lock refuses a second stake --broadcast while the first is sending, and it sends nothing", + "validator unstake --broadcast: send-phase command lock --no-wait keeps the lock to the broadcast and frees it when the run returns", + "validator unstake --broadcast: send-phase command lock hands the lock back for the UNSTAKE's index wait, so another command runs meanwhile", + "validator unstake --broadcast: send-phase command lock refuses a second unstake --broadcast while the first is sending, and it sends nothing" ], "f23059ddff16822e": [ "bundled crypto-node conf files every rpcuser/rpcpassword line carries the placeholder token, not a literal credential", "bundled crypto-node conf files finds conf files for every coin/network combo" ], + "f23ecd153dac2a33": [ + "moduleOperations logModules() calls logContainer with follow=true by default", + "moduleOperations logModules() follow: only attaches to the first service (single-TTY limit)", + "moduleOperations logModules() non-follow: dumps every selected service, not just the first", + "moduleOperations logModules() passes follow=false when specified", + "moduleOperations monitorModules() collects container IDs and passes to startDockerMonitor", + "moduleOperations shellModule() calls shellContainer for the first module only", + "moduleOperations shellModule(): error path continues after shellContainer error and returns true", + "moduleOperations shellModule(): error path returns true when no container found" + ], + "f273c6b0660c6561": [ + "rail follower row reader CLI usage exits two for a bad arm height", + "rail follower row reader CLI usage exits two for a missing flag", + "rail follower row reader CLI usage exits two for an unset password variable", + "rail follower row reader queries once with the comparison SQL and three armed heights", + "rail follower row reader rejects a bad arm height before querying", + "rail follower row reader returns an empty list for an empty answer" + ], + "f2875f51564f4312": [ + "moduleOperations updateModules(): no-ref target resolution clones the default branch, never \"HEAD\", for a detached module the manifest does not carry", + "moduleOperations updateModules(): no-ref target resolution keeps a BRANCH node on its branch and takes newer commits", + "moduleOperations updateModules(): no-ref target resolution moves a RELEASE node to the latest release, pinned, refusing any branch fallback", + "moduleOperations updateModules(): no-ref target resolution records what the node is on, so the next no-ref update converges on it", + "moduleOperations updateModules(): no-ref target resolution stops with nothing changed when the latest release cannot be resolved", + "moduleOperations updateModules(): no-ref target resolution treats an explicitly named branch as a decision and records it", + "moduleOperations updateModules(): no-ref target resolution uses the tag a self-update already resolved instead of looking it up again" + ], + "f2ac848c204021bd": [ + "AutohealService prunes persisted onsets for containers that left the registry", + "AutohealService reads a legacy state file that predates the unhealthySince map" + ], + "f2f06b67817b2eb1": [ + "DockerService createDockerNetwork(): network create failure rejects false when network create fails" + ], + "f393f541d19a1e13": [ + "Chaos: Config Resilience Experiment 2: Malformed config content handles config with duplicate keys (last value wins)", + "Chaos: Config Resilience Experiment 2: Malformed config content handles config with empty values", + "Chaos: Config Resilience Experiment 2: Malformed config content handles config with only whitespace lines", + "Chaos: Config Resilience Experiment 2: Malformed config content handles config with values containing = signs", + "Chaos: Config Resilience Experiment 2: Malformed config content handles empty config file gracefully", + "Chaos: Config Resilience Experiment 2: Malformed config content skips lines without = delimiter" + ], "f3c42962f4a597a9": [ "rebase-completeness sweep fleet sweep a passing report says so plainly", "rebase-completeness sweep fleet sweep fails the fleet when any single store fails, and keeps sweeping the rest", @@ -2444,10 +4541,116 @@ "rebase-completeness sweep timestamp handling reads DATETIME text as UTC, not local time", "rebase-completeness sweep timestamp handling returns null for what it cannot establish, never 0 and never now" ], - "fc6518fe14036c8c": [ - "HubConnector _call() prefers a healthy endpoint over a degraded one", - "HubConnector _call() returns null when an endpoint is truly unreachable (no err.response)", - "HubConnector _call() surfaces the JSON-RPC body of a 503 \"degraded\" response instead of discarding it", + "f5203ce98b5b6d9c": [ + "preCheck(): containerd data-root relocation warning @regression does not block the command when the containerd probe throws", + "preCheck(): containerd data-root relocation warning @regression prints a warning when Docker data-root moved off / but containerd is still on /", + "preCheck(): containerd data-root relocation warning @regression prints no containerd warning when the probe reports no relocation hazard", + "preCheck(): hub/explorer config push @regression pushes both and returns true when neither rejects", + "preCheck(): hub/explorer config push @regression still pushes explorer config when updateHub rejects, and fails the command", + "preCheck(): memory-limit support warning @regression does not block the command when the probe throws", + "preCheck(): memory-limit support warning @regression prints the host fix when Docker says it has no memory-limit support", + "preCheck(): memory-limit support warning @regression says nothing on a host that can enforce a limit", + "preCheck(): remote version check degrades gracefully @regression a GitHub 403 rate-limit rejection does not abort preCheck", + "preCheck(): remote version check degrades gracefully @regression a successful version check keeps checkVersions=true for getStatus", + "preCheck(): remote version check degrades gracefully @regression checkVersions=false skips the remote version fetch entirely", + "preCheck(): xchain_node pool host/port resolution @regression EXTERNAL_DB → never falls back to the 127.0.0.1 load-time default", + "preCheck(): xchain_node pool host/port resolution @regression EXTERNAL_DB → opens the pool against the getExternalDbConfig() host/port", + "preCheck(): xchain_node pool host/port resolution @regression a failing external-DB resolution surfaces as the pool-open error", + "preCheck(): xchain_node pool host/port resolution @regression docker mode → 127.0.0.1 with the live container port-forward" + ], + "f689c0ae4d32f20c": [ + "Chaos: Process Resilience Experiment: Empty container ID propagation accepts valid 64-char hex container ID" + ], + "f799873bf537e087": [ + "S-SMOKE-008 – Parameter Expansion adds explorer as shared service when using all", + "S-SMOKE-008 – Parameter Expansion expands all/bitcoin/mainnet to all non-regtest services", + "S-SMOKE-008 – Parameter Expansion expands all/bitcoin/regtest to include regtest-only modules", + "S-SMOKE-008 – Parameter Expansion expands single service across all coins and networks", + "S-SMOKE-008 – Parameter Expansion filters regtest-only modules from non-regtest networks" + ], + "f7a34d9505872f33": [ + "Security Shell injection prevention via execFile DockerService uses execFile (no shell) for all Docker commands", + "Security Shell injection prevention via execFile buildAndUp passes env vars via the child env (bare --env NAME), never as values in argv", + "Security Shell injection prevention via execFile execContainer passes command as array elements, not a shell string", + "Security Shell injection prevention via execFile shell metacharacters in container IDs are passed literally to execFile" + ], + "f7bc2ba90d800fa1": [ + "Chaos: Download Resilience Experiment 8f: Hashes file corruption throws on invalid hash format (non-hex characters)", + "Chaos: Download Resilience Experiment 8f: Hashes file corruption throws on non-object version entry" + ], + "f7ddf89f1e712c2f": [ + "BootstrapArchiveMeta buildBootstrapMeta() carries the format, the combo and an integer height", + "BootstrapArchiveMeta buildBootstrapMeta() stamps a creation time when none is given", + "BootstrapArchiveMeta buildBootstrapMeta() turns a height it cannot vouch for into null rather than a number", + "BootstrapArchiveMeta compareArchiveIdentity() matches an archive that declares the target identity", + "BootstrapArchiveMeta compareArchiveIdentity() names each field that differs", + "BootstrapArchiveMeta compareArchiveIdentity() reports a legacy archive with no metadata as unchecked, not as a mismatch", + "BootstrapArchiveMeta compareArchiveIdentity() reports null coin/network (the classic-level converter shape) as unchecked while still checking the module", + "BootstrapArchiveMeta parseTarHeader() reads the name, the octal size and the type flag, stripping a ./ prefix", + "BootstrapArchiveMeta parseTarHeader() returns null for the end-of-archive zero block and for a short block", + "BootstrapArchiveMeta readLeadingArchiveMember() refuses a leading member larger than the metadata ceiling", + "BootstrapArchiveMeta readLeadingArchiveMember() skips pax extension headers ahead of the member", + "BootstrapArchiveMeta readLeadingArchiveMember(): extension header size cap gives up at once on an extension header declaring more than the ceiling, without buffering the stream", + "BootstrapArchiveMeta readLeadingArchiveMember(): extension header size cap still skips a small pax header ahead of bootstrap.json", + "BootstrapArchiveMeta writeBootstrapMeta() + readBootstrapArchiveMeta() answers null for a leading member that is not format-1 metadata", + "BootstrapArchiveMeta writeBootstrapMeta() + readBootstrapArchiveMeta() answers null for an archive published before the member existed", + "BootstrapArchiveMeta writeBootstrapMeta() + readBootstrapArchiveMeta() answers null when the member is present but does not lead the archive", + "BootstrapArchiveMeta writeBootstrapMeta() + readBootstrapArchiveMeta() answers null, never rejects, for a missing, truncated or non-gzip file", + "BootstrapArchiveMeta writeBootstrapMeta() + readBootstrapArchiveMeta() reads a null height as null and ignores a non-integer height", + "BootstrapArchiveMeta writeBootstrapMeta() + readBootstrapArchiveMeta() reads the height off a large archive without reading the whole archive", + "BootstrapArchiveMeta writeBootstrapMeta() + readBootstrapArchiveMeta() round-trips the height through a real wrapper when the member leads it" + ], + "f832d89d8f2cdb21": [ + "DbCredentialDrift findDbCredentialDrift flags a decoder that disagrees on the shared decoder account", + "DbCredentialDrift findDbCredentialDrift flags an indexer built from another install on both accounts", + "DbCredentialDrift findDbCredentialDrift handles an empty container list and a missing intended map", + "DbCredentialDrift findDbCredentialDrift ignores a key absent from either side", + "DbCredentialDrift findDbCredentialDrift ignores env keys the module does not authenticate with", + "DbCredentialDrift findDbCredentialDrift reports nothing when every container carries the intended password", + "DbCredentialDrift findDbCredentialDrift treats an empty intended value as no claim rather than as a mismatch" + ], + "f8e8f21532c2e785": [ + "attest-mirror-leg.yml hub llm credential gating declares hub_llm as a boolean dispatch input that is off by default", + "attest-mirror-leg.yml hub llm credential gating never puts the secret in workflow-level, job-level or any other step env", + "attest-mirror-leg.yml hub llm credential gating references the secret exactly once, in the Run step env, gated on hub_llm", + "attest-mirror-leg.yml hub llm credential masking and placement installs the hub CLI and makes the hub config dir only when hub_llm is on", + "attest-mirror-leg.yml hub llm credential masking and placement keeps the hub config dir out of the uploaded leg logs", + "attest-mirror-leg.yml hub llm credential masking and placement masks the token before any leg output and never echoes or writes it anywhere else", + "attest-mirror-leg.yml hub llm credential masking and placement pins the CLI to the version the hub image installs" + ], + "f91eef5ac7acac7f": [ + "Integration: Docker Network Management addContainerToNetwork connects container when not already on network", + "Integration: Docker Network Management addContainerToNetwork skips connection when already on network", + "Integration: Docker Network Management checkDockerInstalledAndReachable rejects when docker --version fails", + "Integration: Docker Network Management checkDockerInstalledAndReachable rejects when docker ps fails (not in docker group)", + "Integration: Docker Network Management checkDockerInstalledAndReachable resolves when docker --version and docker ps succeed", + "Integration: Docker Network Management container lifecycle commands execContainer calls docker exec with correct command", + "Integration: Docker Network Management container lifecycle commands killContainer calls docker kill with correct ID", + "Integration: Docker Network Management container lifecycle commands removeContainer calls docker rm with correct ID", + "Integration: Docker Network Management container lifecycle commands restartContainer calls docker restart with correct ID", + "Integration: Docker Network Management container lifecycle commands startContainer calls docker start with correct ID", + "Integration: Docker Network Management container lifecycle commands stopContainer calls docker stop with correct ID", + "Integration: Docker Network Management createDockerNetwork creates base network xchain-node", + "Integration: Docker Network Management createDockerNetwork creates network when it does not exist", + "Integration: Docker Network Management createDockerNetwork skips creation when network already exists", + "Integration: Docker Network Management getStatusFromContainer parses docker inspect JSON and returns first element" + ], + "f97bee5a65ae0f08": [ + "DatabaseService checkIfDatabaseIsReady() honours a caller-supplied retry budget", + "DatabaseService checkIfDatabaseIsReady() passes -D database arg when database is specified", + "DatabaseService checkIfDatabaseIsReady() passes the password via MYSQL_PWD env, never in argv", + "DatabaseService checkIfDatabaseIsReady() retries up to 10 times then returns false", + "DatabaseService checkIfDatabaseIsReady() returns true when database responds", + "DatabaseService checkIfDatabaseIsReady() still spends the full readiness budget when the caller passes none" + ], + "f980e859b8476bb3": [ + "Chaos: Config Resilience Experiment: Shared module config (no coin/network) returns shared defaults when coin and network are empty strings", + "Chaos: Config Resilience Experiment: Shared module config (no coin/network) returns shared defaults when coin and network are null" + ], + "f99c9123540ed4aa": [ + "HubConnector callRpc() prefers a healthy endpoint over a degraded one", + "HubConnector callRpc() returns null when an endpoint is truly unreachable (no err.response)", + "HubConnector callRpc() surfaces the JSON-RPC body of a 503 \"degraded\" response instead of discarding it", "HubConnector constructor builds urls array with http://: from host+port args", "HubConnector constructor handles IP addresses in urls array", "HubConnector constructor stores a single-element urls array when constructed with host+port", @@ -2461,83 +4664,1101 @@ "HubConnector updateConfig() returns false when response has no result", "HubConnector updateConfig() returns true when response contains a result", "HubConnector updateConfig() sends correct JSON-RPC payload with config in params" + ], + "fb4eaf65d15059af": [ + "DatabaseService purgeHubCrossChainRows() goes through the native driver in EXTERNAL_DB mode", + "DatabaseService purgeHubCrossChainRows() keeps the snapshots when the co-located hub is configured for another network", + "DatabaseService purgeHubCrossChainRows() keeps the snapshots when the hub database holds another network's rows", + "DatabaseService purgeHubCrossChainRows() prints the manual statements and returns false when this MariaDB has no hub tables", + "DatabaseService purgeHubCrossChainRows() purges all three tables for a Bitcoin regtest re-genesis", + "DatabaseService purgeHubCrossChainRows() purges only the leg-scoped rows for a non-Bitcoin regtest re-genesis", + "DatabaseService purgeHubCrossChainRows() refuses a hub DB name that is not a safe identifier", + "DatabaseService purgeHubCrossChainRows() throws on an unknown coin rather than purging nothing and reporting success", + "DatabaseService purgeHubCrossChainRows() touches nothing at all off regtest" + ], + "fb57dd82dda75a00": [ + "ValidatorService capability-config layout migration MOVES rather than copies, so only one config can ever be edited", + "ValidatorService capability-config layout migration does not migrate the empty DIRECTORY docker leaves at a missing mount source", + "ValidatorService capability-config layout migration is a no-op once migrated", + "ValidatorService capability-config layout migration moves a pre-hub-caps capabilities.json into its own directory" + ], + "fb89a5fa8f5a55ca": [ + "E2E: Exec and Logs Commands (Scenarios 4.11, 4.12) E2E-070: Exec command executes command on the correct container from LevelDB", + "E2E: Exec and Logs Commands (Scenarios 4.11, 4.12) E2E-070: Exec command skips exec when module has no container in LevelDB", + "E2E: Exec and Logs Commands (Scenarios 4.11, 4.12) E2E-071: Logs command calls docker logs with the correct container ID", + "E2E: Exec and Logs Commands (Scenarios 4.11, 4.12) E2E-071: Logs command displays \"No service was selected\" when no modules match", + "E2E: Exec and Logs Commands (Scenarios 4.11, 4.12) E2E-072: Restart command calls docker restart for each installed module" + ], + "fbec8ac2d15abf8d": [ + "Regression Suite [regression:p1] State & Data Integrity R-STA-004: state singleton getters/setters round-trip correctly" + ], + "fbf3b6df4c4526ff": [ + "DockerService execContainer(): error branch rejects when docker exec fails" + ], + "fce889588f77cf3d": [ + "DockerService getDockerContainerFileCat(): error branch rejects when docker exec cat fails" + ], + "fcfa96254df2c351": [ + "ValidatorService container mount points keeps the hub-side file path unchanged, so the hub needs no change", + "ValidatorService container mount points mounts the container DIRECTORY that path sits in" + ], + "fd287b2db335a349": [ + "S-SMOKE-004 – Constants and Enum Integrity Coin enum contains bitcoin", + "S-SMOKE-004 – Constants and Enum Integrity Coin enum contains dogecoin", + "S-SMOKE-004 – Constants and Enum Integrity Coin enum contains litecoin", + "S-SMOKE-004 – Constants and Enum Integrity CoinTickerSymbol maps bitcoin to BTC", + "S-SMOKE-004 – Constants and Enum Integrity CoinTickerSymbol maps dogecoin to DOGE", + "S-SMOKE-004 – Constants and Enum Integrity CoinTickerSymbol maps litecoin to LTC", + "S-SMOKE-004 – Constants and Enum Integrity Directory path constants configDir is a non-empty string", + "S-SMOKE-004 – Constants and Enum Integrity Directory path constants containersFilesDir is a non-empty string", + "S-SMOKE-004 – Constants and Enum Integrity Directory path constants cryptoNodesDir is a non-empty string", + "S-SMOKE-004 – Constants and Enum Integrity Directory path constants dataDir is a non-empty string", + "S-SMOKE-004 – Constants and Enum Integrity Directory path constants moduleDir is a non-empty string", + "S-SMOKE-004 – Constants and Enum Integrity Directory path constants srcDir is a non-empty string", + "S-SMOKE-004 – Constants and Enum Integrity Directory path constants tmpDir is a non-empty string", + "S-SMOKE-004 – Constants and Enum Integrity Network enum contains mainnet", + "S-SMOKE-004 – Constants and Enum Integrity Network enum contains regtest", + "S-SMOKE-004 – Constants and Enum Integrity Network enum contains testnet", + "S-SMOKE-004 – Constants and Enum Integrity REGTEST_MODULES does not include non-regtest services", + "S-SMOKE-004 – Constants and Enum Integrity REGTEST_MODULES includes xchain-e2e-test", + "S-SMOKE-004 – Constants and Enum Integrity REGTEST_MODULES includes xchain-regtest-miner", + "S-SMOKE-004 – Constants and Enum Integrity String constants DB_SEP is defined", + "S-SMOKE-004 – Constants and Enum Integrity String constants HUB_PORT is a number", + "S-SMOKE-004 – Constants and Enum Integrity String constants NODE_PREFIX is defined", + "S-SMOKE-004 – Constants and Enum Integrity String constants SEP is defined", + "S-SMOKE-004 – Constants and Enum Integrity XChainService enum contains xchain-decoder", + "S-SMOKE-004 – Constants and Enum Integrity XChainService enum contains xchain-e2e-test", + "S-SMOKE-004 – Constants and Enum Integrity XChainService enum contains xchain-encoder", + "S-SMOKE-004 – Constants and Enum Integrity XChainService enum contains xchain-indexer", + "S-SMOKE-004 – Constants and Enum Integrity XChainService enum contains xchain-regtest-miner", + "S-SMOKE-004 – Constants and Enum Integrity XChainService enum contains xchain-utxo-tracker", + "S-SMOKE-004 – Constants and Enum Integrity modulesUrls mapping has git URL for xchain-decoder", + "S-SMOKE-004 – Constants and Enum Integrity modulesUrls mapping has git URL for xchain-e2e-test", + "S-SMOKE-004 – Constants and Enum Integrity modulesUrls mapping has git URL for xchain-encoder", + "S-SMOKE-004 – Constants and Enum Integrity modulesUrls mapping has git URL for xchain-explorer", + "S-SMOKE-004 – Constants and Enum Integrity modulesUrls mapping has git URL for xchain-hub", + "S-SMOKE-004 – Constants and Enum Integrity modulesUrls mapping has git URL for xchain-indexer", + "S-SMOKE-004 – Constants and Enum Integrity modulesUrls mapping has git URL for xchain-regtest-miner", + "S-SMOKE-004 – Constants and Enum Integrity modulesUrls mapping has git URL for xchain-sync", + "S-SMOKE-004 – Constants and Enum Integrity modulesUrls mapping has git URL for xchain-utxo-tracker", + "S-SMOKE-004 – Constants and Enum Integrity projectFolders mapping has project folder for xchain-decoder", + "S-SMOKE-004 – Constants and Enum Integrity projectFolders mapping has project folder for xchain-e2e-test", + "S-SMOKE-004 – Constants and Enum Integrity projectFolders mapping has project folder for xchain-encoder", + "S-SMOKE-004 – Constants and Enum Integrity projectFolders mapping has project folder for xchain-explorer", + "S-SMOKE-004 – Constants and Enum Integrity projectFolders mapping has project folder for xchain-hub", + "S-SMOKE-004 – Constants and Enum Integrity projectFolders mapping has project folder for xchain-indexer", + "S-SMOKE-004 – Constants and Enum Integrity projectFolders mapping has project folder for xchain-regtest-miner", + "S-SMOKE-004 – Constants and Enum Integrity projectFolders mapping has project folder for xchain-sync", + "S-SMOKE-004 – Constants and Enum Integrity projectFolders mapping has project folder for xchain-utxo-tracker" + ], + "fd296203c4043515": [ + "ValidatorService getValidatorEnv() omits ORACLE_EPOCH_START when null", + "ValidatorService getValidatorEnv() returns empty object when not initialized", + "ValidatorService getValidatorEnv() returns env vars with SIGNING_PRIVKEY_HEX when initialized" + ], + "fdd338e64863927b": [ + "rail leg standing hub anchor pipeline does not enable the standing hub anchor pipeline for a non-anchor arm", + "rail leg standing hub anchor pipeline gives every anchor-armed regtest validator the container encoder before identity initialization" + ], + "fe4485b6751ffb56": [ + "rail-leg.yml optional litecoin stack installs dogecoin then bitcoin only when stacks is omitted", + "rail-leg.yml optional litecoin stack installs litecoin and writes its expected rail configuration when selected", + "rail-leg.yml optional litecoin stack keeps stacks optional while retaining the required leg fields", + "rail-leg.yml optional litecoin stack ships the host environment writer and parseable workflow files" + ], + "fec0a74d17f9be6a": [ + "ROLLCALL wiring guard: the deploys it leaves alone a BTC regtest indexer while the venue is inert", + "ROLLCALL wiring guard: the deploys it leaves alone a BTC testnet indexer whose env carries the URL, in either spelling", + "ROLLCALL wiring guard: the deploys it leaves alone a standalone hub, and a validator hub that is wired", + "ROLLCALL wiring guard: the deploys it leaves alone an LTC or DOGE indexer with nothing set", + "ROLLCALL wiring guard: the override downgrades the refusal to one warning carrying the same text", + "ROLLCALL wiring guard: the override spelled 0 or false is not an override", + "ROLLCALL wiring guard: the refusal REFUSES a BTC mainnet indexer too: mainnet is armed from genesis, and names the mainnet explorer read", + "ROLLCALL wiring guard: the refusal REFUSES a BTC regtest indexer once the venue arms, with the regtest formula", + "ROLLCALL wiring guard: the refusal REFUSES a BTC testnet indexer with no DOGE read, naming the variable, the wedge block and the hub", + "ROLLCALL wiring guard: the refusal REFUSES a validator hub on testnet with no DOGE read", + "ROLLCALL wiring guard: the refusal a regtest refusal names no public URL", + "ROLLCALL wiring guard: the refusal names the public explorer read for testnet, with no key, and the self-hosted alternative", + "ROLLCALL wiring guard: the refusal the message names the target both ways" + ], + "fedac1cc56d1106d": [ + "Chaos: Download Resilience Experiment 8g: Archive extraction failure throws when tar exits with non-zero code" + ], + "ff1fb64b6e8edf8f": [ + "HubService.buildCheckpointConfig hub endpoint defaults the endpoint to the hub container on the docker network", + "HubService.buildCheckpointConfig hub endpoint emits a hub_url alongside every self_sync it advertises", + "HubService.buildCheckpointConfig hub endpoint honours an operator HUB_API_URL from the host env", + "HubService.buildCheckpointConfig hub endpoint keeps the mirror schema and indexer credentials it already carried", + "HubService.isCheckpointSelfSyncEnabled is not opted in when neither the env nor the container says so", + "HubService.isCheckpointSelfSyncEnabled is not opted in when there is no explorer container to ask", + "HubService.isCheckpointSelfSyncEnabled is opted in when the host env carries the flag", + "HubService.isCheckpointSelfSyncEnabled reads the flag itself off the container when it is there", + "HubService.isCheckpointSelfSyncEnabled stays opted in when the env is gone but the explorer container remembers", + "HubService.isCheckpointSelfSyncEnabled treats an empty env value as unset rather than as an opt-in", + "HubService.isHubAnswering asks once, with no retry loop of its own", + "HubService.isHubAnswering is false when nothing answers", + "HubService.isHubAnswering is true when the hub answers a ping", + "HubService.updateHub network attachment keeps missing xchain-sync containers on the existing retry and report path", + "HubService.updateHub network attachment rejects naming the unreachable network instead of reporting success", + "HubService.updateHub network attachment retries a failed attach once and succeeds on the second try", + "HubService.updateHub network attachment returns true when every shared-container attach succeeds", + "HubService.updateHub network attachment updateHub re-resolves a stale hub container id", + "HubService.updateHub skipConfigPush attaches the shared containers but never reaches the config push", + "HubService.updateHub skipConfigPush defaults to pushing, so no existing caller changes behaviour", + "HubService.updateHub skipConfigPush pushes as it always has when not told to skip", + "HubService.updateHubOrExplorer config delivery exhausts ten attempts and reports the last hub error", + "HubService.updateHubOrExplorer config delivery retries a rejected hub update and succeeds on the next attempt", + "HubService.updateHubOrExplorer config delivery writes the explorer payload to config.json in its registered container" + ], + "ff91bbaf1f49c8c2": [ + "Integration: Database Service Chain checkIfDatabaseIsReady retries until database responds" ] }, "scripts": { "test": { - "fileCount": 70, - "titleCount": 2322, + "fileCount": 308, + "titleCount": 2936, "files": { - "test/unit/AutohealService.test.js": "5cd927f17b924ef8", - "test/unit/BootstrapArchiveMeta.test.js": "2fa482502d5d1dbe", - "test/unit/BootstrapHealthGate.test.js": "8b127ec2a1140cac", - "test/unit/BootstrapNodeTipGuard.test.js": "3916386139147a8b", - "test/unit/BootstrapRepublishLedger.test.js": "e7b8ff5f97f1f7f3", - "test/unit/BootstrapService.test.js": "ba304d2c23bb7a06", - "test/unit/BootstrapSigning.test.js": "de606d67691f176b", - "test/unit/ConfigService.hubNetwork.test.js": "6e3e97a6bec46f56", - "test/unit/ConfigService.test.js": "2be99405409c0a53", - "test/unit/CredentialsService.test.js": "013fb4868c83ee37", - "test/unit/DatabaseService.test.js": "a5c2a73d73682ccc", - "test/unit/DbCredentialDrift.test.js": "bcfaa8d7327dcba5", - "test/unit/DiscoveryService.test.js": "14bd28e1e4b711a9", - "test/unit/DockerService.test.js": "482a3259eebf8fa8", - "test/unit/EncoderMaintenanceWindow.test.js": "5125fdb96876c007", - "test/unit/ExplorerConnector.test.js": "b9b75c9f2ae1b837", - "test/unit/ExplorerService.test.js": "40db4f1a28173b1f", - "test/unit/GatedFileFieldScan.test.js": "82171a751987af6e", - "test/unit/GitHubDownloader.test.js": "26da132c6adf5f9c", - "test/unit/GoLiveGate.test.js": "a67cabacd0112216", - "test/unit/HubConnector.failures.test.js": "2c16774921b9b66c", - "test/unit/HubConnector.test.js": "fc6518fe14036c8c", - "test/unit/HubConsensusEnvGuard.test.js": "bdea07439a6b5cec", - "test/unit/HubInstallPinning.test.js": "13499f2ff51c6f3c", - "test/unit/HubService.test.js": "e3665c51c64c9661", - "test/unit/InstallTargetService.test.js": "3b04cc05990c4f9a", - "test/unit/MariaDbStore.test.js": "54cd8fb7321c2356", - "test/unit/MemoryLimitService.test.js": "99bd35e26fa0632d", - "test/unit/MigrationPreconditionService.test.js": "88431f058ac97635", - "test/unit/ModuleService.test.js": "8d3d114fcc0b254f", - "test/unit/NodeService.test.js": "40fdfec30fa58f39", - "test/unit/RebaseCompletenessSweep.test.js": "f3c42962f4a597a9", - "test/unit/ReleaseManifestService.test.js": "774bd66498878fa9", - "test/unit/ReleaseSignatureService.test.js": "0b0e062a08751d6c", - "test/unit/SelfUpdateService.test.js": "33a5dd7bc655d47e", - "test/unit/ServiceRegistry.test.js": "eb89a6dc1945c342", - "test/unit/SkewGuardService.test.js": "66a744498ed159be", - "test/unit/StatusService.test.js": "d966ad5aa2291a4b", - "test/unit/StopBudgetService.test.js": "a67181a3796fad54", - "test/unit/TelemetryConnector.test.js": "31b05a9177135645", - "test/unit/TelemetryService.test.js": "842ea67df5e152c0", - "test/unit/ValidatorService.test.js": "2ac8a00f5806cacb", - "test/unit/ValidatorStakeService.test.js": "0226ac96a43d54d4", - "test/unit/VersionService.test.js": "9778dd0f1c360aef", - "test/unit/checkRebaseCompleteness.test.js": "dd6e4f59d7ec2e9a", - "test/unit/cli-error-handling.test.js": "2093a3bb7f34dc89", - "test/unit/cli-rollback.test.js": "4e86adb287c39e1d", - "test/unit/cliRefForPreCheck.test.js": "e12b9a311ad07cac", - "test/unit/cliSelfUpdate.test.js": "79cc27613f3e7980", - "test/unit/cloneIntegrity.test.js": "2e8ada203563571b", - "test/unit/coins-conformance.test.js": "2442df610ebb6911", - "test/unit/commandLock.test.js": "da1b4dbe85ee8518", - "test/unit/coverage-thresholds-sync.test.js": "4405034a199528ec", - "test/unit/crossChainNetworks.test.js": "2ae188b9157fab7b", - "test/unit/cryptoNodeBuildScaffold.test.js": "12558605c0d0415c", - "test/unit/cryptoNodeConfPlaceholders.test.js": "f23059ddff16822e", - "test/unit/deploySourceIdentity.test.js": "a04c8cc3113456d6", - "test/unit/dockerMariadb.test.js": "cdda2d3e70c102b1", + "test/unit/00_config_directory_isolation.test.js": "f13ced249dae415e", + "test/unit/autoheal_service.test.js": "eae1a818e5f6a1fe", + "test/unit/autoheal_service.test/01_health_state_guards.test.js": "5b79af81ded16b09", + "test/unit/autoheal_service.test/02_fresh_grace_after_stop.test.js": "7e3664b747eb33a1", + "test/unit/autoheal_service.test/03_restart_cooldown.test.js": "d0ae5ef1702237dd", + "test/unit/autoheal_service.test/04_exponential_backoff.test.js": "d1c95662bcbee756", + "test/unit/autoheal_service.test/05_recovery_resets_backoff.test.js": "d306427bee129d7f", + "test/unit/autoheal_service.test/06_starting_probation.test.js": "9c20d50b97776974", + "test/unit/autoheal_service.test/07_stopped_container_backoff.test.js": "7b146e00b4342dfd", + "test/unit/autoheal_service.test/08_recovery_past_probation.test.js": "7cebdb1d5a480c84", + "test/unit/autoheal_service.test/09_pass_inside_probation.test.js": "d52f0bd380eb717c", + "test/unit/autoheal_service.test/10_backoff_calculations.test.js": "326c83a2aeae717d", + "test/unit/autoheal_service.test/11_dry_run_and_missing_timing.test.js": "39370a6135d33aff", + "test/unit/autoheal_service.test/12_restart_and_inspection_errors.test.js": "9fbe6ba46e39d44d", + "test/unit/autoheal_service.test/13_ring_buffer_restart.test.js": "ced71ed191802081", + "test/unit/autoheal_service.test/14_recovery_clears_onset.test.js": "45106b7980f98941", + "test/unit/autoheal_service.test/15_retained_probe_recovery.test.js": "7141a5716344fb9c", + "test/unit/autoheal_service.test/16_registry_state.test.js": "f2ac848c204021bd", + "test/unit/autoheal_service.test/17_grace_override.test.js": "678cb9bc8dc99e4d", + "test/unit/autoheal_service.test/18_health_log_helpers.test.js": "0d73a5716f9c6909", + "test/unit/autoheal_service.test/19_service_healthcheck_flags.test.js": "d5d6b83f530657fc", + "test/unit/bootstrap_archive_meta.test.js": "f7ddf89f1e712c2f", + "test/unit/bootstrap_health_gate.test.js": "52555b17f82ac069", + "test/unit/bootstrap_health_gate.test/container_state.test.js": "ed8016ccb0fe8623", + "test/unit/bootstrap_health_gate.test/durable_halt_markers.test.js": "1f2aa9f88ca90972", + "test/unit/bootstrap_health_gate.test/evaluate_container_state.test.js": "c9f879a004276208", + "test/unit/bootstrap_health_gate.test/evaluate_status_payload.test.js": "33623e8a12ec8dfa", + "test/unit/bootstrap_health_gate.test/make_bootstrap_consults_the_source_health_gate.test.js": "54f2031de8d7e024", + "test/unit/bootstrap_health_gate.test/parse_count_tokens.test.js": "33a411988d29bb7b", + "test/unit/bootstrap_health_gate.test/service_health_probe.test.js": "9806bd4412e5d53d", + "test/unit/bootstrap_node_tip_guard.test.js": "3916386139147a8b", + "test/unit/bootstrap_republish_ledger.test.js": "e7b8ff5f97f1f7f3", + "test/unit/bootstrap_service.test.js": "ba304d2c23bb7a06", + "test/unit/bootstrap_service.test/restore_checks_archive_identity_and_refusal_signature.test.js": "2f3edfcd7987a004", + "test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js": "7f55fc09c93c9a4c", + "test/unit/bootstrap_signing.test.js": "2dca3358a936b011", + "test/unit/check_rebase_completeness.test.js": "dd6e4f59d7ec2e9a", + "test/unit/ci_full_sibling_roster.test/sibling_roster.test.js": "8ef5fabc3ebb1554", + "test/unit/cli/dispatch_lock.test.js": "3f5ce8defd4fbaec", + "test/unit/cli/validator_send_lock.test.js": "4229d76191f1dae4", + "test/unit/cli_error_handling.test.js": "dd3e260439472a75", + "test/unit/cli_ref_for_pre_check.test.js": "e12b9a311ad07cac", + "test/unit/cli_rollback.test.js": "4e86adb287c39e1d", + "test/unit/cli_self_update.test.js": "70708eb35b723d7a", + "test/unit/clone_integrity.test.js": "2e8ada203563571b", + "test/unit/coins_conformance.test.js": "2442df610ebb6911", + "test/unit/command_lock.test.js": "da1b4dbe85ee8518", + "test/unit/config/anchor_arm_passthrough.test.js": "10f7c66de1d99f6d", + "test/unit/config/module_names.test.js": "c148734ae2eb7b02", + "test/unit/config_service.test.js": "e1061677e9809ee2", + "test/unit/config_service.test/anchor_fold_passthrough.test.js": "b53dc5b9aa6da7b0", + "test/unit/config_service.test/hub_seed_suppresses_api_url.test.js": "3c997331d0dba970", + "test/unit/config_service.test/indexer_origin_urls.test.js": "831f3db33b66159c", + "test/unit/config_service.test/preferred_sidecar_names.test.js": "a2020242e0b8d026", + "test/unit/config_service_hub_network.test.js": "6e3e97a6bec46f56", + "test/unit/coverage_thresholds_sync.test.js": "06cdae1616e269c6", + "test/unit/credentials_service.test.js": "57f98eb33f7521ab", + "test/unit/credentials_service.test/01_save_credentials.test.js": "c127af1e131a631f", + "test/unit/credentials_service.test/02_load_db_root_password_save_db_root_password.test.js": "ca7bc42c94112709", + "test/unit/credentials_service.test/03_has_external_db_config.test.js": "36c5f071500439b1", + "test/unit/credentials_service.test/04_load_external_db_config.test.js": "1d4f5ea539108f42", + "test/unit/credentials_service.test/05_save_external_db_config.test.js": "576e1ff9362cace7", + "test/unit/credentials_service.test/06_xchain_node_db.test.js": "910cade45cc50889", + "test/unit/cross_chain_networks.test.js": "2ae188b9157fab7b", + "test/unit/crypto_node_build_scaffold.test.js": "12558605c0d0415c", + "test/unit/crypto_node_conf_placeholders.test.js": "f23059ddff16822e", + "test/unit/database_service.test.js": "7709742b49b662fb", + "test/unit/database_service.test/add_user_password_to_database.test.js": "d90f6b0a3738dda4", + "test/unit/database_service.test/ask_mariadb_root_password.test.js": "39d0192fdb2e885d", + "test/unit/database_service.test/build_database_module.test.js": "7d9da93b27a0235d", + "test/unit/database_service.test/check_if_database_is_ready.test.js": "f97bee5a65ae0f08", + "test/unit/database_service.test/clear_hub_price_ingest_watermark.test.js": "6e1a01cbd58d312a", + "test/unit/database_service.test/ensure_database_pool.test.js": "c44142a8c149e3f8", + "test/unit/database_service.test/ensure_xchain_node_access.test.js": "b85bf0e6b4e19b10", + "test/unit/database_service.test/execute_native_maria_db_command.test.js": "cdfbd81e37e90588", + "test/unit/database_service.test/external_db_add_user_password.test.js": "2cda429c0af356d2", + "test/unit/database_service.test/external_db_build_database_module.test.js": "396cc4554bb16f87", + "test/unit/database_service.test/external_db_ensure_xchain_node_access.test.js": "78efb8bd8fd832cc", + "test/unit/database_service.test/get_database_container_id.test.js": "0cb0374496e1a1a8", + "test/unit/database_service.test/get_database_host_port.test.js": "cb42ff6da97669c6", + "test/unit/database_service.test/get_external_db_config.test.js": "312914fcdff3183e", + "test/unit/database_service.test/no_coin_network_build_database_module.test.js": "b68740453ee65cdd", + "test/unit/database_service.test/pre_network_column_clear_hub_price_ingest_watermark.test.js": "d539749001580fad", + "test/unit/database_service.test/provisioning_sql_safety.test.js": "8bafabf4238fc37a", + "test/unit/database_service.test/purge_hub_cross_chain_rows.test.js": "fb4eaf65d15059af", + "test/unit/database_service.test/reset_databases.test.js": "16b722b1d1f9fa39", + "test/unit/database_service.test/set_database_parameters.test.js": "eefea4f8da594893", + "test/unit/db/halt_markers.test.js": "7167d0becc0b4237", + "test/unit/db/information_schema.test.js": "1bac4d9ab7be0cd2", + "test/unit/db/price_fence.test.js": "33bb86ffb68d924e", + "test/unit/db_credential_drift.test.js": "f832d89d8f2cdb21", + "test/unit/db_credential_drift.test/01_format_db_credential_drift_error.test.js": "3ab21fe7dc6e1bf4", + "test/unit/db_credential_drift.test/02_read_container_env.test.js": "ea9aeeb75660fd91", + "test/unit/db_credential_drift.test/03_assert_no_db_credential_drift.test.js": "dec5f6ecaa466eaf", + "test/unit/db_credential_drift.test/04_find_hub_db_credential_drift.test.js": "da1b432659303a4d", + "test/unit/db_credential_drift.test/05_assert_no_hub_db_credential_drift.test.js": "01ce537bf318afb2", + "test/unit/db_credential_drift.test/06_is_db_credential_drift_error.test.js": "6f0b8ea924793400", + "test/unit/deploy_source_identity.test.js": "a04c8cc3113456d6", + "test/unit/discovery_service.test.js": "14bd28e1e4b711a9", + "test/unit/docker_mariadb.test.js": "139b1b28aef36fe3", + "test/unit/docker_service.test.js": "581f74e2d7477acf", + "test/unit/docker_service.test/01_check_containerd_data_root_relocation.test.js": "072c4edd724004b8", + "test/unit/docker_service.test/02_stop_container.test.js": "1b2bb6c7e95e88ac", + "test/unit/docker_service.test/03_restart_container.test.js": "78a3bf2e7afee8ba", + "test/unit/docker_service.test/04_remove_container.test.js": "58f277bb14951850", + "test/unit/docker_service.test/05_get_status_from_container.test.js": "1d8efbe793503797", + "test/unit/docker_service.test/06_get_docker_network_inspect.test.js": "6d01b9f2dc998281", + "test/unit/docker_service.test/07_shell_container.test.js": "8656c9fadc3daefb", + "test/unit/docker_service.test/08_get_docker_container_file_data.test.js": "b469f566dbc11f8a", + "test/unit/docker_service.test/09_start_docker_monitor.test.js": "df94a025d34dc919", + "test/unit/docker_service.test/10_get_published_host_ports.test.js": "af0eab4698ebf59a", + "test/unit/docker_service.test/11_string_to_docker_container_file.test.js": "45392e97fad62cd2", + "test/unit/docker_service.test/12_restart_container_error_branches.test.js": "c9cce451d2af9893", + "test/unit/docker_service.test/13_exec_container_error_branch.test.js": "fbf3b6df4c4526ff", + "test/unit/docker_service.test/14_get_docker_container_file_data_error_branch.test.js": "4c6708564426099f", + "test/unit/docker_service.test/15_get_docker_container_file_cat_error_branch.test.js": "fce889588f77cf3d", + "test/unit/docker_service.test/16_create_docker_network_network_create_failure.test.js": "f2f06b67817b2eb1", + "test/unit/docker_service.test/17_get_docker_network_inspect_error_branch.test.js": "a21838fb4a8d1697", + "test/unit/docker_service.test/18_stop_container_error_branches.test.js": "73eee5e34f3d25a8", + "test/unit/docker_service.test/19_log_container_tty_keypress_branches.test.js": "2e8e24675a738be3", + "test/unit/docker_service.test/20_start_docker_monitor_with_containers.test.js": "62a678730fd646c7", + "test/unit/docker_service.test/stop_settings/get_container_stop_settings.test.js": "50198cfa94230b48", + "test/unit/encoder_maintenance_window.test.js": "5125fdb96876c007", + "test/unit/explorer_connector.test.js": "b9b75c9f2ae1b837", + "test/unit/explorer_service.test.js": "5066451bdf737206", + "test/unit/explorer_service.test/01_already_running.test.js": "61b150a62d838628", + "test/unit/explorer_service.test/02_honours_install_ref.test.js": "cade957f6640118b", + "test/unit/explorer_service.test/03_no_coins_yet.test.js": "a11cf22041537e1c", + "test/unit/explorer_service.test/04_force_rebuild.test.js": "86b6926a26306f8a", + "test/unit/explorer_service.test/05_exhausts_retries.test.js": "e34f0beb0ebd6970", + "test/unit/explorer_service.test/06_update_error_retry.test.js": "28c7e0d3022480dd", + "test/unit/explorer_service.test/07_persistent_attach_failure.test.js": "9e93b855fedba971", + "test/unit/explorer_service.test/08_happy_path.test.js": "3637a8c63a1fa122", + "test/unit/explorer_service.test/09_wait_for_ready.test.js": "b7758e1ca876051c", + "test/unit/gated_file_field_scan.test.js": "82171a751987af6e", + "test/unit/github_downloader.test.js": "d920ceea68258388", + "test/unit/github_downloader.test/01_verify_file_hash.test.js": "dad3a4c39a65e9b4", + "test/unit/github_downloader.test/02_get_hash_for_arch.test.js": "252355e9bf94d77b", + "test/unit/github_downloader.test/03_has_hash_arch_specific.test.js": "11c6854d9b4d1eb2", + "test/unit/github_downloader.test/04_get_releases_generic_error.test.js": "9b540951387cb2cb", + "test/unit/github_downloader.test/05_get_release_by_tag_error.test.js": "a96906ed1c35e555", + "test/unit/github_downloader.test/06_download_repo_version.test.js": "c137030db07be9d1", + "test/unit/github_downloader.test/07_download_release_asset.test.js": "97f5eba9d682a101", + "test/unit/github_downloader.test/08_download_release_asset_2.test.js": "bc521f9dd0954bf6", + "test/unit/github_downloader.test/09_get_all_files_directory_traversal.test.js": "bc3f855b95f524d5", + "test/unit/github_downloader.test/10_load_hashes_file_invalid_hash_in_object_entry.test.js": "e6fde6f428c1cad7", + "test/unit/github_downloader.test/11_download_release_asset_verify_hash_true.test.js": "1b74d54e11533ef4", + "test/unit/github_downloader.test/12_verify_repository_hash.test.js": "d8afb9f19d929d01", + "test/unit/github_downloader.test/13_hash_gate_host_arch.test.js": "41f655e5214c0500", + "test/unit/go_live_gate.test.js": "a67cabacd0112216", "test/unit/helpers.test.js": "152f5f77b38555af", - "test/unit/identifyDeployedRef.test.js": "9b210872d17a3ffa", - "test/unit/moduleOperations.test.js": "a88dc187e71ed005", - "test/unit/mutatingLockWait.test.js": "1ea3450bb4fcc6a7", - "test/unit/precheck.test.js": "bb7e5453878cf8dd", - "test/unit/publishBootstrapsForcedRepublish.test.js": "e2d431011abfd746", - "test/unit/scanNineFieldFiles.test.js": "d1b47eeb57e49143", - "test/unit/secret-env.test.js": "37d41880660612cd", - "test/unit/security/configuration/dependency-advisories.test.js": "5cc2b8143bdd5808", + "test/unit/hub_connector.test.js": "f99c9123540ed4aa", + "test/unit/hub_connector_failures.test.js": "2c16774921b9b66c", + "test/unit/hub_consensus_env_guard.test.js": "bdea07439a6b5cec", + "test/unit/hub_install_pinning.test.js": "13499f2ff51c6f3c", + "test/unit/hub_service.test.js": "ff1fb64b6e8edf8f", + "test/unit/hub_service_failover.test.js": "dfc874d437cc3eda", + "test/unit/identify_deployed_ref.test.js": "9b210872d17a3ffa", + "test/unit/install_target_service.test.js": "3b04cc05990c4f9a", + "test/unit/logging/adapter_contract.test.js": "5e4c10931f801551", + "test/unit/maria_db_store.test.js": "2957ae7991de09d4", + "test/unit/maria_db_store.test/01_get_module_count.test.js": "695dc8c07f6e66ae", + "test/unit/maria_db_store.test/02_registry_scoping_by_node_prefix.test.js": "c9fc151f2f957a53", + "test/unit/memory_limit_service.test.js": "4b546ae8078d0aae", + "test/unit/migration_precondition_service.test.js": "a55e0292bbecf0f6", + "test/unit/module_operations.test.js": "8ce1d908dd38ef02", + "test/unit/module_operations.test/01_update_modules.test.js": "5d1c28de2f41c7b1", + "test/unit/module_operations.test/02_recreate_modules.test.js": "999b6d7dbb8afa0a", + "test/unit/module_operations.test/03_module_controls.test.js": "9e678ad6dc8a9808", + "test/unit/module_operations.test/04_module_shell_log_monitor.test.js": "f23ecd153dac2a33", + "test/unit/module_operations.test/05_update_modules_branch_handling.test.js": "92ecc535e12a2126", + "test/unit/module_operations.test/06_update_modules_no_ref_target_resolution.test.js": "f2875f51564f4312", + "test/unit/module_operations.test/07_update_modules_all_expansion.test.js": "718e1e0ef8441e60", + "test/unit/module_operations.test/08_uninstall_and_e2e.test.js": "7f8472425104dee9", + "test/unit/module_operations.test/09_reset_modules.test.js": "2e555b11d8f2b782", + "test/unit/module_operations.test/10_reset_node_datadir_resolution.test.js": "429a966c6057bfb5", + "test/unit/module_operations.test/11_reset_external_db_reachability.test.js": "23b229f4464bf168", + "test/unit/module_operations.test/12_reset_bootstrap_republish.test.js": "c1b49a38cfcc3041", + "test/unit/module_operations.test/13_reset_registry_read.test.js": "0a574baed3231d6e", + "test/unit/module_operations.test/14_reset_utxo_tracker_volume.test.js": "57b561a8898acb97", + "test/unit/module_operations.test/15_reset_regtest_hub_purge.test.js": "30dce11a237ad0fd", + "test/unit/module_operations.test/16_reset_coupled_decoder_indexer.test.js": "d19943f6efeadea6", + "test/unit/module_operations.test/17_module_error_paths.test.js": "eec7cda19aee9b9b", + "test/unit/module_operations.test/18_sync_shared_services_after_install.test.js": "b874404951e97c8a", + "test/unit/module_operations.test/19_e2e_image_siblings.test.js": "0b7a214d72e8bdcc", + "test/unit/module_operations.test/20_update_partial_outcome.test.js": "0257e547c2aa2e36", + "test/unit/module_service.test.js": "bba74ea3b5ec4339", + "test/unit/module_service.test/01_buildandup.test.js": "78db390dade7aa08", + "test/unit/module_service.test/02_buildandup_hub_credential_ordering.test.js": "63034e15e659f6bb", + "test/unit/module_service.test/03_buildandup_healthcheck_args.test.js": "501f38adb1b23b15", + "test/unit/module_service.test/04_installmodule_db_module.test.js": "3e5e1395ce2262f2", + "test/unit/module_service.test/05_buildandup_module_specific_port_volume_branches.test.js": "29583491ff7dbbc4", + "test/unit/module_service.test/06_buildandup_module_specific_port_volume_branches.test.js": "7fcd70cf0e0cd046", + "test/unit/module_service.test/07_installmodule_node_module.test.js": "efa6cc2e3df55d82", + "test/unit/module_service.test/08_installmodule_node_checkremotenodeversion_called_when_coin_not_in_remoteversions.test.js": "1535ee8397f03ca7", + "test/unit/module_service.test/09_clonegit_rewrite_over_an_existing_checkout.test.js": "13616050e0170f89", + "test/unit/module_service.test/10_buildandup_rollcall_wiring_guard.test.js": "38b8c58e2f7907b5", + "test/unit/module_service.test/11_port_spec_grammar.test.js": "480937c8f65a1475", + "test/unit/module_service.test/12_buildandup_shutdown_timeout_env.test.js": "e269ee035ad3b399", + "test/unit/module_service.test/13_buildandup_buildkit_precedes_clone_and_db.test.js": "846dd1095f28252d", + "test/unit/module_service.test/13_buildandup_init_and_wget_timeout.test.js": "462a874ef1e65e98", + "test/unit/module_service.test/15_installmodule_node_missing_remote_release.test.js": "9edf66b2734f0658", + "test/unit/mutating_lock_wait.test.js": "37a925183bf82ad0", + "test/unit/nightly_e2e_workflow.test/sharding.test.js": "54441a6949465539", + "test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js": "1fcd3f9e733e968a", + "test/unit/node_service.test.js": "6fbe6a70deeed73b", + "test/unit/node_service.test/blocks_and_txindex.test.js": "89a860ec2e7d06b3", + "test/unit/node_service.test/blocks_dir_persistence.test.js": "73eac28b1927f020", + "test/unit/node_service.test/broken_mirror.test.js": "c5bcb58acc73d969", + "test/unit/node_service.test/build_crypto_node.test.js": "e9444247682f4e2d", + "test/unit/node_service.test/install_node.test.js": "ce97a31910d4f041", + "test/unit/node_service.test/node_version_pin.test.js": "7a9a45411521e34f", + "test/unit/node_service.test/placed_tarball.test.js": "29d03dcc1c784287", + "test/unit/node_service.test/update_datadir_unset.test.js": "a543adaaff87951c", + "test/unit/peer_services.test.js": "e5fb8ceb0cbe29f3", + "test/unit/precheck.test.js": "f5203ce98b5b6d9c", + "test/unit/precheck.test/01_a_hub_that_is_not_answering_does_not_wedge_its_own_repair.test.js": "3be16671d270b423", + "test/unit/precheck.test/02_the_hub_is_staged_at_the_ref_the_command_named.test.js": "1192154391900a7e", + "test/unit/precheck.test/03_the_cli_presents_the_sidecar_hub_api_key_to_the_hub.test.js": "645789d933f8115c", + "test/unit/prototype_method_descriptors.test.js": "3fec2f60b896a660", + "test/unit/publish_bootstraps_forced_republish.test.js": "e2d431011abfd746", + "test/unit/rail_leg_workflow.test/anchor_arm_heights.test.js": "9a4ded303b89d06c", + "test/unit/rail_leg_workflow.test/doge_follower_compare.test.js": "a8b71b9c9e21302c", + "test/unit/rail_leg_workflow.test/drive_env_coin.test.js": "f1ea17eadb3df859", + "test/unit/rail_leg_workflow.test/drive_env_urls.test.js": "935687035bb6ce2b", + "test/unit/rail_leg_workflow.test/follower_clone_plan.test.js": "0bc33761807f31ad", + "test/unit/rail_leg_workflow.test/follower_hash_compare.test.js": "6befe4bcc41421e4", + "test/unit/rail_leg_workflow.test/follower_rows_read.test.js": "f273c6b0660c6561", + "test/unit/rail_leg_workflow.test/hub_anchor_pipeline.test.js": "fdd338e64863927b", + "test/unit/rail_leg_workflow.test/hub_llm_credential.test.js": "f8e8f21532c2e785", + "test/unit/rail_leg_workflow.test/hub_llm_probe.test.js": "87bbeeec71aa7b85", + "test/unit/rail_leg_workflow.test/leg_expect_values.test.js": "5b0b3cfef75bc4fd", + "test/unit/rail_leg_workflow.test/ltc_stack.test.js": "fe4485b6751ffb56", + "test/unit/rail_leg_workflow.test/mirror_admission.test.js": "ea0325c156538f37", + "test/unit/rail_leg_workflow.test/publisher_funding.test.js": "c60a5a54c1d26404", + "test/unit/rail_leg_workflow.test/publisher_funding_wiring.test.js": "68322c4def9bb3be", + "test/unit/rail_leg_workflow.test/r2_arm_values.test.js": "998d59a6710ceaa1", + "test/unit/rebase_completeness_sweep.test.js": "f3c42962f4a597a9", + "test/unit/release_manifest_service.test.js": "7cccd4dc81f793a1", + "test/unit/release_manifest_service.test/resolve_install_target.test.js": "53a93d0ea7bffc56", + "test/unit/release_manifest_service.test/the_provenance_gate.test.js": "0e15713d36178e5f", + "test/unit/release_manifest_service.test/the_shipped_manifest.test.js": "f0e58110dbd1940b", + "test/unit/release_signature_service.test.js": "0b0e062a08751d6c", + "test/unit/rollcall_wiring.test/01_which_deploys_read_roll_calls.test.js": "dfb14232aba06767", + "test/unit/rollcall_wiring.test/02_the_verdict.test.js": "fec0a74d17f9be6a", + "test/unit/rollcall_wiring.test/03_arming_mirror.test.js": "51093ddbe0fee12c", + "test/unit/scan_nine_field_files.test.js": "d1b47eeb57e49143", + "test/unit/secret_env.test.js": "37d41880660612cd", + "test/unit/security/configuration/dependency_advisories.test.js": "5cc2b8143bdd5808", + "test/unit/self_update_service.test.js": "33a5dd7bc655d47e", + "test/unit/service_registry.test.js": "31f99897a525a280", + "test/unit/services/bootstrap_service/tracker_height.test.js": "cb937309157fd5ad", + "test/unit/services/github_downloader/host_arch.test.js": "20b64a17eccbdd28", + "test/unit/services/release_signature_service/release_assets.test.js": "b23bbb069f1bf215", + "test/unit/services/validator_stake_service/send_lock.test.js": "3fa5333e295302e7", + "test/unit/services/validator_stake_service/token_balance_read.test.js": "33e59b9e09cfcd60", + "test/unit/skew_guard_service.test.js": "66a744498ed159be", "test/unit/state.test.js": "a40634de4e012c02", - "test/unit/statusReorgHalt.test.js": "4fed77ea7d38ca97", - "test/unit/ui-menu.test.js": "113421dff9a3e19e" + "test/unit/status_reorg_halt.test.js": "8159641021bab8a6", + "test/unit/status_service.test.js": "148e787f79bb3562", + "test/unit/status_service.test/advisory_remote_version_check.test.js": "c29f3f159edfeb5d", + "test/unit/status_service.test/get_installed_coins_and_networks.test.js": "81304df51b8b5a6e", + "test/unit/status_service.test/get_status_branch_column.test.js": "0d34c41e6c892a40", + "test/unit/status_service.test/get_status_commit_column.test.js": "d408c4bf9caedafb", + "test/unit/status_service.test/get_status_stall_and_halt_columns.test.js": "5486dc9f5b20dac7", + "test/unit/status_service.test/get_status_with_installed_modules.test.js": "a21fd2dad9b609f9", + "test/unit/status_service.test/get_status_with_installed_modules_versions_and_output.test.js": "39ba977a5cb2416a", + "test/unit/status_service.test/load_installed_modules.test.js": "1a6e2ea5d2022758", + "test/unit/stop_budget_service.test.js": "4168e4dba2ad4bbb", + "test/unit/telemetry_connector.test.js": "31b05a9177135645", + "test/unit/telemetry_service.test.js": "9dc7ea4f45a7ab68", + "test/unit/ui/menu/module_choices.test.js": "312597dee63fa214", + "test/unit/ui/menu/node_version_order.test.js": "426085f91f8b7f22", + "test/unit/ui_menu.test.js": "b160cd15b0db200d", + "test/unit/utils/indexer_migrate_cli.test.js": "c0a179d054a8c1ef", + "test/unit/utils/migrate_cli_status_guard.test.js": "41f7f97eefd13478", + "test/unit/utils/migration_files.test.js": "bbdb0aa3f3d1d42e", + "test/unit/utils/sql_safety.test.js": "582d3f8bc57f4d02", + "test/unit/validator_sdk_encoder_compat.test.js": "4ea2fe0c5595ff3a", + "test/unit/validator_service.test.js": "dd1b100fce46a627", + "test/unit/validator_service.test/01_init_validator.test.js": "8f5d3a3ee1b75f78", + "test/unit/validator_service.test/02_init_validator_options_and_files.test.js": "449638b7f5819a93", + "test/unit/validator_service.test/03_init_validator_hub_api_key.test.js": "dac87b5c804f217b", + "test/unit/validator_service.test/04_get_validator_settings.test.js": "6c7dbd2ae98e853b", + "test/unit/validator_service.test/05_get_validator_env.test.js": "fd296203c4043515", + "test/unit/validator_service.test/06_validator_mode_report.test.js": "57d2979882e18339", + "test/unit/validator_service.test/07_get_capability_config_host_path.test.js": "8602a614e6adc857", + "test/unit/validator_service.test/08_get_capability_config_mount_dir.test.js": "1ad790e7ec7606c5", + "test/unit/validator_service.test/09_capability_config_layout_migration.test.js": "fb57dd82dda75a00", + "test/unit/validator_service.test/10_container_mount_points.test.js": "fcfa96254df2c351", + "test/unit/validator_service.test/11_validator_dir.test.js": "26f27dbd3b14ec37", + "test/unit/validator_service.test/12_coin_wallets.test.js": "6d7114a33630b804", + "test/unit/validator_service.test/13_coin_wallet_options_and_mounts.test.js": "3670f7eb60a0bd6e", + "test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js": "332b1a20f0f85580", + "test/unit/validator_service.test/15_hub_env_wiring.test.js": "1dc33a33679f1947", + "test/unit/validator_service.test/16_rollcall_status_reporting.test.js": "cb99d238d302d2c2", + "test/unit/validator_service.test/17_capability_drift_probe.test.js": "1ee619322512f5f5", + "test/unit/validator_stake_service.test.js": "11478527623fc361", + "test/unit/validator_stake_service.test/free_key.test.js": "32b1e008856a0902", + "test/unit/validator_stake_service.test/send_lock.test.js": "f228d9772b7c14c7", + "test/unit/validator_stake_service.test/stake_amount_option.test.js": "c2ef0de1942c90ef", + "test/unit/validator_stake_service.test/stake_free_key_check.test.js": "399f8b388cacf9f5", + "test/unit/validator_stake_service.test/stake_validator.test.js": "986d6e1069c073c2", + "test/unit/validator_stake_service.test/unstake_pending_top_up.test.js": "c8569cbfc2ecad03", + "test/unit/validator_stake_service.test/unstake_validator.test.js": "b81d9b9c07cd6df9", + "test/unit/validator_stake_service.test/upstream_reads.test.js": "b7114a10b0030562", + "test/unit/validator_stake_service.test/validator_set_paging.test.js": "8403c6ea9dad5fe3", + "test/unit/version_service.test.js": "0586050116b0dd94" + } + }, + "test:all": { + "fileCount": 418, + "titleCount": 3702, + "files": { + "test/chaos/config_resilience_chaos.test.js": "e187ad3068ae0375", + "test/chaos/config_resilience_chaos.test/argument_parsing_resilience_chaos.test.js": "24bd240f4198a15b", + "test/chaos/config_resilience_chaos.test/config_path_traversal_chaos.test.js": "222cf77585f733f4", + "test/chaos/config_resilience_chaos.test/malformed_config_content_chaos.test.js": "f393f541d19a1e13", + "test/chaos/config_resilience_chaos.test/port_validation_under_chaos.test.js": "6e05f9dad9987bd7", + "test/chaos/config_resilience_chaos.test/shared_module_config_chaos.test.js": "f980e859b8476bb3", + "test/chaos/docker_resilience_chaos.test.js": "a856fcb5d9d5fe15", + "test/chaos/docker_resilience_chaos.test/docker_build_failure_chaos.test.js": "5db8daf74cc46607", + "test/chaos/docker_resilience_chaos.test/docker_exec_failure_chaos.test.js": "baf6a5649ec3a388", + "test/chaos/docker_resilience_chaos.test/docker_inspect_with_invalid_json_chaos.test.js": "ba43f3b4dd958f04", + "test/chaos/docker_resilience_chaos.test/docker_network_creation_failure_chaos.test.js": "e2ced651370bfd16", + "test/chaos/docker_resilience_chaos.test/docker_run_failure_chaos.test.js": "c43e02d1acf524b0", + "test/chaos/docker_resilience_chaos.test/file_write_to_container_failure_chaos.test.js": "8882bef46ee2ac3f", + "test/chaos/docker_resilience_chaos.test/invalid_port_in_config_during_build_and_up_chaos.test.js": "30f77b585ec67a07", + "test/chaos/docker_resilience_chaos.test/operations_on_non_existent_containers_chaos.test.js": "6cf7aae9519b9d5c", + "test/chaos/docker_resilience_chaos.test/state_storage_failure_chaos.test.js": "011e3638fa7bd665", + "test/chaos/download_resilience_chaos.test.js": "34cab5185dd9084a", + "test/chaos/download_resilience_chaos.test/archive_extraction_failure_chaos.test.js": "fedac1cc56d1106d", + "test/chaos/download_resilience_chaos.test/download_cleanup_on_failure_chaos.test.js": "67acfa55737fd29a", + "test/chaos/download_resilience_chaos.test/github_api_unreachable_timeout_chaos.test.js": "0df0aabf6e94707f", + "test/chaos/download_resilience_chaos.test/github_rate_limiting_chaos.test.js": "d5cad7504894bd19", + "test/chaos/download_resilience_chaos.test/github_rate_limiting_server_error_chaos.test.js": "9d1f2fd0b2ea4240", + "test/chaos/download_resilience_chaos.test/hashes_file_corruption_format_chaos.test.js": "77345bb5d35c1fac", + "test/chaos/download_resilience_chaos.test/hashes_file_corruption_shape_chaos.test.js": "f7bc2ba90d800fa1", + "test/chaos/download_resilience_chaos.test/no_compatible_version_available_chaos.test.js": "3ad798643f338e59", + "test/chaos/download_resilience_chaos.test/release_tag_not_found_chaos.test.js": "8736dac4ae50eb08", + "test/chaos/download_resilience_chaos.test/repository_not_found_chaos.test.js": "d5a94f679740e3de", + "test/chaos/download_resilience_chaos.test/sha_256_hash_mismatch_chaos.test.js": "56897dc4174a8788", + "test/chaos/git_clone_resilience_chaos.test.js": "cbdbc1d46a4e8f22", + "test/chaos/network_resilience_chaos.test.js": "2828c04ca92985e6", + "test/chaos/process_resilience_chaos.test.js": "4a50c1aca5c12ab0", + "test/chaos/process_resilience_chaos.test/async_error_propagation_overwrite_kill_chaos.test.js": "f033f9a92030563f", + "test/chaos/process_resilience_chaos.test/async_error_propagation_overwrite_remove_chaos.test.js": "2d5951fd05d23617", + "test/chaos/process_resilience_chaos.test/async_error_propagation_run_chaos.test.js": "841397b0f12a1dbd", + "test/chaos/process_resilience_chaos.test/async_error_propagation_status_chaos.test.js": "9b06c8565cfe1f81", + "test/chaos/process_resilience_chaos.test/empty_container_id_non_hex_chaos.test.js": "c30fc0c4dd2e1ee9", + "test/chaos/process_resilience_chaos.test/empty_container_id_valid_chaos.test.js": "f689c0ae4d32f20c", + "test/chaos/process_resilience_chaos.test/empty_container_id_whitespace_chaos.test.js": "b15d571802c13994", + "test/chaos/process_resilience_chaos.test/multi_step_clone_error_chaos.test.js": "7705d14ae3ffca27", + "test/chaos/process_resilience_chaos.test/multi_step_module_missing_chaos.test.js": "d0cd9d15c583ff81", + "test/chaos/process_resilience_chaos.test/precheck_directory_creation_chaos.test.js": "b79e9f220b2c5589", + "test/chaos/process_resilience_chaos.test/precheck_docker_failure_chaos.test.js": "1b873fc16e610f8e", + "test/chaos/process_resilience_chaos.test/precheck_hub_failure_chaos.test.js": "28f3c87021ea9588", + "test/chaos/process_resilience_chaos.test/precheck_network_failure_chaos.test.js": "8e25d0290c1bd265", + "test/chaos/process_resilience_chaos.test/uninstall_resilience_chaos.test.js": "e2fdc331e1ae4b01", + "test/e2e/scenarios/config_overrides.test.js": "61281c3139eea8a1", + "test/e2e/scenarios/error_handling.test.js": "c8534fb477afedec", + "test/e2e/scenarios/exec_logs.test.js": "fb89a5fa8f5a55ca", + "test/e2e/scenarios/install_lifecycle.test.js": "6bd3d7c12a20c4eb", + "test/e2e/scenarios/multi_coin.test.js": "bf75da756c4b378c", + "test/e2e/scenarios/precheck.test.js": "7bb2fe8765cc4426", + "test/e2e/scenarios/reset.test.js": "08758d91b3b5705b", + "test/e2e/scenarios/update_flow.test.js": "1f9845108589a523", + "test/fuzz/branch_validation.fuzz.js": "36964ba24beb3edb", + "test/fuzz/command_construction.fuzz.js": "3111978f0fd33252", + "test/fuzz/config_parsing.fuzz.js": "de7155905abb418f", + "test/fuzz/container_id.fuzz.js": "83351766649e0c73", + "test/fuzz/env_escaping.fuzz.js": "c41ce4e4afc4896e", + "test/fuzz/filter_params.fuzz.js": "a4a05cec47b48b65", + "test/fuzz/port_validation.fuzz.js": "a3960f64f6ba6129", + "test/fuzz/resolve_args.fuzz.js": "efb0052f155d4455", + "test/integration/config_pipeline.test.js": "a5a5aa45a05118df", + "test/integration/database_setup.test.js": "34be82a1cf77dc44", + "test/integration/database_setup.test/add_user_password_to_database.test.js": "616adeca1c559bb1", + "test/integration/database_setup.test/build_database_module_already_exists.test.js": "b15f63c78bbeaa42", + "test/integration/database_setup.test/check_if_database_is_ready.test.js": "ff91bbaf1f49c8c2", + "test/integration/database_setup.test/set_database_parameters.test.js": "701a6f07eaa918d0", + "test/integration/docker_commands.test.js": "b2b6dd472622839b", + "test/integration/docker_commands.test/build_and_up_with_overwrite_container_id.test.js": "af0367fb70381488", + "test/integration/docker_commands.test/docker_build_working_directory.test.js": "8927121fa98c797e", + "test/integration/docker_commands.test/every_coin_network_combo_produces_valid_docker_run_commands.test.js": "448f1a53a6792be5", + "test/integration/docker_commands.test/xchain_decoder_docker_run_command.test.js": "d89bcbe0b2036003", + "test/integration/docker_commands.test/xchain_explorer_docker_run_command.test.js": "8e826477c40148d2", + "test/integration/docker_commands.test/xchain_hub_docker_run_command.test.js": "8df8514bdcae5e67", + "test/integration/docker_commands.test/xchain_indexer_docker_run_command.test.js": "3aa22b5c17bf0837", + "test/integration/docker_commands.test/xchain_regtest_miner_docker_run_command.test.js": "e85f9fd995faa7fc", + "test/integration/docker_commands.test/xchain_utxo_tracker_docker_run_command.test.js": "3fa86dc751918535", + "test/integration/hub_config_update.test.js": "bbd3a050c99fdc1f", + "test/integration/hub_config_update.test/01_hub_update_retry_logic.test.js": "cacb2b17a1b6db97", + "test/integration/hub_config_update.test/02_update_hub_or_explorer_for_explorer.test.js": "62f79f2877e9a543", + "test/integration/hub_config_update.test/03_update_hub_network_connectivity.test.js": "428aec81edc63b3f", + "test/integration/hub_config_update.test/04_checkpoint_self_sync_opt_in_survives_the_shell_that_set_it.test.js": "81888023f59d00e3", + "test/integration/module_lifecycle.test.js": "6442e5713907c431", + "test/integration/multi_module_orchestration.test.js": "2229616699655894", + "test/integration/multi_module_orchestration.test/01_update_modules_orchestration.test.js": "25498cac11aac073", + "test/integration/multi_module_orchestration.test/02_uninstall_modules_orchestration.test.js": "5756d1c8d96ada07", + "test/integration/multi_module_orchestration.test/03_filter_command_parameters_install_modules_end_to_end.test.js": "80fa21ba0e27730f", + "test/integration/network_management.test.js": "f91eef5ac7acac7f", + "test/integration/status_query.test.js": "8b660477cd3be877", + "test/regression/clone_rollback.test.js": "58089c71dd0e074e", + "test/regression/regression.test.js": "cc0e44f047ba96c0", + "test/regression/regression.test/01_configuration_generation.test.js": "4fc4333ff26b31fa", + "test/regression/regression.test/02_docker_command_construction.test.js": "5431f5284081e044", + "test/regression/regression.test/03_security.test.js": "332bea463a49f99c", + "test/regression/regression.test/04_service_lifecycle.test.js": "d5b9066c5de4f491", + "test/regression/regression.test/05_state_data_integrity.test.js": "fbec8ac2d15abf8d", + "test/regression/regression.test/06_e2e_lifecycle_workflows.test.js": "f1b427ee16f7a8b8", + "test/regression/regression.test/07_config_parsing_boundaries.test.js": "a38c76fb41c7130a", + "test/regression/regression.test/08_precheck_error_handling.test.js": "b2439743a81a59fe", + "test/regression/regression.test/09_utility_functions.test.js": "14d2f05367245fea", + "test/smoke/smoke.test.js": "946b75a6f83d1f21", + "test/smoke/smoke.test/s_smoke_002_commander_cli_registration.test.js": "9f5847124ddc28f6", + "test/smoke/smoke.test/s_smoke_003_global_options_registration.test.js": "38eae73b5e2c3897", + "test/smoke/smoke.test/s_smoke_004_constants_and_enum_integrity.test.js": "fd287b2db335a349", + "test/smoke/smoke.test/s_smoke_005_config_template_file_integrity.test.js": "90560b45328159e3", + "test/smoke/smoke.test/s_smoke_006_config_composition.test.js": "d94222300a70d805", + "test/smoke/smoke.test/s_smoke_007_docker_command_construction.test.js": "13ff9670bc05bf2e", + "test/smoke/smoke.test/s_smoke_008_parameter_expansion.test.js": "f799873bf537e087", + "test/smoke/smoke.test/s_smoke_009_state_module_initialization.test.js": "568cce545e5bfbec", + "test/smoke/smoke.test/s_smoke_010_crypto_node_dockerfile_existence.test.js": "dad38ac028223802", + "test/unit/00_config_directory_isolation.test.js": "f13ced249dae415e", + "test/unit/autoheal_service.test.js": "eae1a818e5f6a1fe", + "test/unit/autoheal_service.test/01_health_state_guards.test.js": "5b79af81ded16b09", + "test/unit/autoheal_service.test/02_fresh_grace_after_stop.test.js": "7e3664b747eb33a1", + "test/unit/autoheal_service.test/03_restart_cooldown.test.js": "d0ae5ef1702237dd", + "test/unit/autoheal_service.test/04_exponential_backoff.test.js": "d1c95662bcbee756", + "test/unit/autoheal_service.test/05_recovery_resets_backoff.test.js": "d306427bee129d7f", + "test/unit/autoheal_service.test/06_starting_probation.test.js": "9c20d50b97776974", + "test/unit/autoheal_service.test/07_stopped_container_backoff.test.js": "7b146e00b4342dfd", + "test/unit/autoheal_service.test/08_recovery_past_probation.test.js": "7cebdb1d5a480c84", + "test/unit/autoheal_service.test/09_pass_inside_probation.test.js": "d52f0bd380eb717c", + "test/unit/autoheal_service.test/10_backoff_calculations.test.js": "326c83a2aeae717d", + "test/unit/autoheal_service.test/11_dry_run_and_missing_timing.test.js": "39370a6135d33aff", + "test/unit/autoheal_service.test/12_restart_and_inspection_errors.test.js": "9fbe6ba46e39d44d", + "test/unit/autoheal_service.test/13_ring_buffer_restart.test.js": "ced71ed191802081", + "test/unit/autoheal_service.test/14_recovery_clears_onset.test.js": "45106b7980f98941", + "test/unit/autoheal_service.test/15_retained_probe_recovery.test.js": "7141a5716344fb9c", + "test/unit/autoheal_service.test/16_registry_state.test.js": "f2ac848c204021bd", + "test/unit/autoheal_service.test/17_grace_override.test.js": "678cb9bc8dc99e4d", + "test/unit/autoheal_service.test/18_health_log_helpers.test.js": "0d73a5716f9c6909", + "test/unit/autoheal_service.test/19_service_healthcheck_flags.test.js": "d5d6b83f530657fc", + "test/unit/bootstrap_archive_meta.test.js": "f7ddf89f1e712c2f", + "test/unit/bootstrap_health_gate.test.js": "52555b17f82ac069", + "test/unit/bootstrap_health_gate.test/container_state.test.js": "ed8016ccb0fe8623", + "test/unit/bootstrap_health_gate.test/durable_halt_markers.test.js": "1f2aa9f88ca90972", + "test/unit/bootstrap_health_gate.test/evaluate_container_state.test.js": "c9f879a004276208", + "test/unit/bootstrap_health_gate.test/evaluate_status_payload.test.js": "33623e8a12ec8dfa", + "test/unit/bootstrap_health_gate.test/make_bootstrap_consults_the_source_health_gate.test.js": "54f2031de8d7e024", + "test/unit/bootstrap_health_gate.test/parse_count_tokens.test.js": "33a411988d29bb7b", + "test/unit/bootstrap_health_gate.test/service_health_probe.test.js": "9806bd4412e5d53d", + "test/unit/bootstrap_node_tip_guard.test.js": "3916386139147a8b", + "test/unit/bootstrap_republish_ledger.test.js": "e7b8ff5f97f1f7f3", + "test/unit/bootstrap_service.test.js": "ba304d2c23bb7a06", + "test/unit/bootstrap_service.test/restore_checks_archive_identity_and_refusal_signature.test.js": "2f3edfcd7987a004", + "test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js": "7f55fc09c93c9a4c", + "test/unit/bootstrap_signing.test.js": "2dca3358a936b011", + "test/unit/check_rebase_completeness.test.js": "dd6e4f59d7ec2e9a", + "test/unit/ci_full_sibling_roster.test/sibling_roster.test.js": "8ef5fabc3ebb1554", + "test/unit/cli/dispatch_lock.test.js": "3f5ce8defd4fbaec", + "test/unit/cli/validator_send_lock.test.js": "4229d76191f1dae4", + "test/unit/cli_error_handling.test.js": "dd3e260439472a75", + "test/unit/cli_ref_for_pre_check.test.js": "e12b9a311ad07cac", + "test/unit/cli_rollback.test.js": "4e86adb287c39e1d", + "test/unit/cli_self_update.test.js": "70708eb35b723d7a", + "test/unit/clone_integrity.test.js": "2e8ada203563571b", + "test/unit/coins_conformance.test.js": "2442df610ebb6911", + "test/unit/command_lock.test.js": "da1b4dbe85ee8518", + "test/unit/config/anchor_arm_passthrough.test.js": "10f7c66de1d99f6d", + "test/unit/config/module_names.test.js": "c148734ae2eb7b02", + "test/unit/config_service.test.js": "e1061677e9809ee2", + "test/unit/config_service.test/anchor_fold_passthrough.test.js": "b53dc5b9aa6da7b0", + "test/unit/config_service.test/hub_seed_suppresses_api_url.test.js": "3c997331d0dba970", + "test/unit/config_service.test/indexer_origin_urls.test.js": "831f3db33b66159c", + "test/unit/config_service.test/preferred_sidecar_names.test.js": "a2020242e0b8d026", + "test/unit/config_service_hub_network.test.js": "6e3e97a6bec46f56", + "test/unit/coverage_thresholds_sync.test.js": "06cdae1616e269c6", + "test/unit/credentials_service.test.js": "57f98eb33f7521ab", + "test/unit/credentials_service.test/01_save_credentials.test.js": "c127af1e131a631f", + "test/unit/credentials_service.test/02_load_db_root_password_save_db_root_password.test.js": "ca7bc42c94112709", + "test/unit/credentials_service.test/03_has_external_db_config.test.js": "36c5f071500439b1", + "test/unit/credentials_service.test/04_load_external_db_config.test.js": "1d4f5ea539108f42", + "test/unit/credentials_service.test/05_save_external_db_config.test.js": "576e1ff9362cace7", + "test/unit/credentials_service.test/06_xchain_node_db.test.js": "910cade45cc50889", + "test/unit/cross_chain_networks.test.js": "2ae188b9157fab7b", + "test/unit/crypto_node_build_scaffold.test.js": "12558605c0d0415c", + "test/unit/crypto_node_conf_placeholders.test.js": "f23059ddff16822e", + "test/unit/database_service.test.js": "7709742b49b662fb", + "test/unit/database_service.test/add_user_password_to_database.test.js": "d90f6b0a3738dda4", + "test/unit/database_service.test/ask_mariadb_root_password.test.js": "39d0192fdb2e885d", + "test/unit/database_service.test/build_database_module.test.js": "7d9da93b27a0235d", + "test/unit/database_service.test/check_if_database_is_ready.test.js": "f97bee5a65ae0f08", + "test/unit/database_service.test/clear_hub_price_ingest_watermark.test.js": "6e1a01cbd58d312a", + "test/unit/database_service.test/ensure_database_pool.test.js": "c44142a8c149e3f8", + "test/unit/database_service.test/ensure_xchain_node_access.test.js": "b85bf0e6b4e19b10", + "test/unit/database_service.test/execute_native_maria_db_command.test.js": "cdfbd81e37e90588", + "test/unit/database_service.test/external_db_add_user_password.test.js": "2cda429c0af356d2", + "test/unit/database_service.test/external_db_build_database_module.test.js": "396cc4554bb16f87", + "test/unit/database_service.test/external_db_ensure_xchain_node_access.test.js": "78efb8bd8fd832cc", + "test/unit/database_service.test/get_database_container_id.test.js": "0cb0374496e1a1a8", + "test/unit/database_service.test/get_database_host_port.test.js": "cb42ff6da97669c6", + "test/unit/database_service.test/get_external_db_config.test.js": "312914fcdff3183e", + "test/unit/database_service.test/no_coin_network_build_database_module.test.js": "b68740453ee65cdd", + "test/unit/database_service.test/pre_network_column_clear_hub_price_ingest_watermark.test.js": "d539749001580fad", + "test/unit/database_service.test/provisioning_sql_safety.test.js": "8bafabf4238fc37a", + "test/unit/database_service.test/purge_hub_cross_chain_rows.test.js": "fb4eaf65d15059af", + "test/unit/database_service.test/reset_databases.test.js": "16b722b1d1f9fa39", + "test/unit/database_service.test/set_database_parameters.test.js": "eefea4f8da594893", + "test/unit/db/halt_markers.test.js": "7167d0becc0b4237", + "test/unit/db/information_schema.test.js": "1bac4d9ab7be0cd2", + "test/unit/db/price_fence.test.js": "33bb86ffb68d924e", + "test/unit/db_credential_drift.test.js": "f832d89d8f2cdb21", + "test/unit/db_credential_drift.test/01_format_db_credential_drift_error.test.js": "3ab21fe7dc6e1bf4", + "test/unit/db_credential_drift.test/02_read_container_env.test.js": "ea9aeeb75660fd91", + "test/unit/db_credential_drift.test/03_assert_no_db_credential_drift.test.js": "dec5f6ecaa466eaf", + "test/unit/db_credential_drift.test/04_find_hub_db_credential_drift.test.js": "da1b432659303a4d", + "test/unit/db_credential_drift.test/05_assert_no_hub_db_credential_drift.test.js": "01ce537bf318afb2", + "test/unit/db_credential_drift.test/06_is_db_credential_drift_error.test.js": "6f0b8ea924793400", + "test/unit/deploy_source_identity.test.js": "a04c8cc3113456d6", + "test/unit/discovery_service.test.js": "14bd28e1e4b711a9", + "test/unit/docker_mariadb.test.js": "139b1b28aef36fe3", + "test/unit/docker_service.test.js": "581f74e2d7477acf", + "test/unit/docker_service.test/01_check_containerd_data_root_relocation.test.js": "072c4edd724004b8", + "test/unit/docker_service.test/02_stop_container.test.js": "1b2bb6c7e95e88ac", + "test/unit/docker_service.test/03_restart_container.test.js": "78a3bf2e7afee8ba", + "test/unit/docker_service.test/04_remove_container.test.js": "58f277bb14951850", + "test/unit/docker_service.test/05_get_status_from_container.test.js": "1d8efbe793503797", + "test/unit/docker_service.test/06_get_docker_network_inspect.test.js": "6d01b9f2dc998281", + "test/unit/docker_service.test/07_shell_container.test.js": "8656c9fadc3daefb", + "test/unit/docker_service.test/08_get_docker_container_file_data.test.js": "b469f566dbc11f8a", + "test/unit/docker_service.test/09_start_docker_monitor.test.js": "df94a025d34dc919", + "test/unit/docker_service.test/10_get_published_host_ports.test.js": "af0eab4698ebf59a", + "test/unit/docker_service.test/11_string_to_docker_container_file.test.js": "45392e97fad62cd2", + "test/unit/docker_service.test/12_restart_container_error_branches.test.js": "c9cce451d2af9893", + "test/unit/docker_service.test/13_exec_container_error_branch.test.js": "fbf3b6df4c4526ff", + "test/unit/docker_service.test/14_get_docker_container_file_data_error_branch.test.js": "4c6708564426099f", + "test/unit/docker_service.test/15_get_docker_container_file_cat_error_branch.test.js": "fce889588f77cf3d", + "test/unit/docker_service.test/16_create_docker_network_network_create_failure.test.js": "f2f06b67817b2eb1", + "test/unit/docker_service.test/17_get_docker_network_inspect_error_branch.test.js": "a21838fb4a8d1697", + "test/unit/docker_service.test/18_stop_container_error_branches.test.js": "73eee5e34f3d25a8", + "test/unit/docker_service.test/19_log_container_tty_keypress_branches.test.js": "2e8e24675a738be3", + "test/unit/docker_service.test/20_start_docker_monitor_with_containers.test.js": "62a678730fd646c7", + "test/unit/docker_service.test/stop_settings/get_container_stop_settings.test.js": "50198cfa94230b48", + "test/unit/encoder_maintenance_window.test.js": "5125fdb96876c007", + "test/unit/explorer_connector.test.js": "b9b75c9f2ae1b837", + "test/unit/explorer_service.test.js": "5066451bdf737206", + "test/unit/explorer_service.test/01_already_running.test.js": "61b150a62d838628", + "test/unit/explorer_service.test/02_honours_install_ref.test.js": "cade957f6640118b", + "test/unit/explorer_service.test/03_no_coins_yet.test.js": "a11cf22041537e1c", + "test/unit/explorer_service.test/04_force_rebuild.test.js": "86b6926a26306f8a", + "test/unit/explorer_service.test/05_exhausts_retries.test.js": "e34f0beb0ebd6970", + "test/unit/explorer_service.test/06_update_error_retry.test.js": "28c7e0d3022480dd", + "test/unit/explorer_service.test/07_persistent_attach_failure.test.js": "9e93b855fedba971", + "test/unit/explorer_service.test/08_happy_path.test.js": "3637a8c63a1fa122", + "test/unit/explorer_service.test/09_wait_for_ready.test.js": "b7758e1ca876051c", + "test/unit/gated_file_field_scan.test.js": "82171a751987af6e", + "test/unit/github_downloader.test.js": "d920ceea68258388", + "test/unit/github_downloader.test/01_verify_file_hash.test.js": "dad3a4c39a65e9b4", + "test/unit/github_downloader.test/02_get_hash_for_arch.test.js": "252355e9bf94d77b", + "test/unit/github_downloader.test/03_has_hash_arch_specific.test.js": "11c6854d9b4d1eb2", + "test/unit/github_downloader.test/04_get_releases_generic_error.test.js": "9b540951387cb2cb", + "test/unit/github_downloader.test/05_get_release_by_tag_error.test.js": "a96906ed1c35e555", + "test/unit/github_downloader.test/06_download_repo_version.test.js": "c137030db07be9d1", + "test/unit/github_downloader.test/07_download_release_asset.test.js": "97f5eba9d682a101", + "test/unit/github_downloader.test/08_download_release_asset_2.test.js": "bc521f9dd0954bf6", + "test/unit/github_downloader.test/09_get_all_files_directory_traversal.test.js": "bc3f855b95f524d5", + "test/unit/github_downloader.test/10_load_hashes_file_invalid_hash_in_object_entry.test.js": "e6fde6f428c1cad7", + "test/unit/github_downloader.test/11_download_release_asset_verify_hash_true.test.js": "1b74d54e11533ef4", + "test/unit/github_downloader.test/12_verify_repository_hash.test.js": "d8afb9f19d929d01", + "test/unit/github_downloader.test/13_hash_gate_host_arch.test.js": "41f655e5214c0500", + "test/unit/go_live_gate.test.js": "a67cabacd0112216", + "test/unit/helpers.test.js": "152f5f77b38555af", + "test/unit/hub_connector.test.js": "f99c9123540ed4aa", + "test/unit/hub_connector_failures.test.js": "2c16774921b9b66c", + "test/unit/hub_consensus_env_guard.test.js": "bdea07439a6b5cec", + "test/unit/hub_install_pinning.test.js": "13499f2ff51c6f3c", + "test/unit/hub_service.test.js": "ff1fb64b6e8edf8f", + "test/unit/hub_service_failover.test.js": "dfc874d437cc3eda", + "test/unit/identify_deployed_ref.test.js": "9b210872d17a3ffa", + "test/unit/install_target_service.test.js": "3b04cc05990c4f9a", + "test/unit/logging/adapter_contract.test.js": "5e4c10931f801551", + "test/unit/maria_db_store.test.js": "2957ae7991de09d4", + "test/unit/maria_db_store.test/01_get_module_count.test.js": "695dc8c07f6e66ae", + "test/unit/maria_db_store.test/02_registry_scoping_by_node_prefix.test.js": "c9fc151f2f957a53", + "test/unit/memory_limit_service.test.js": "4b546ae8078d0aae", + "test/unit/migration_precondition_service.test.js": "a55e0292bbecf0f6", + "test/unit/module_operations.test.js": "8ce1d908dd38ef02", + "test/unit/module_operations.test/01_update_modules.test.js": "5d1c28de2f41c7b1", + "test/unit/module_operations.test/02_recreate_modules.test.js": "999b6d7dbb8afa0a", + "test/unit/module_operations.test/03_module_controls.test.js": "9e678ad6dc8a9808", + "test/unit/module_operations.test/04_module_shell_log_monitor.test.js": "f23ecd153dac2a33", + "test/unit/module_operations.test/05_update_modules_branch_handling.test.js": "92ecc535e12a2126", + "test/unit/module_operations.test/06_update_modules_no_ref_target_resolution.test.js": "f2875f51564f4312", + "test/unit/module_operations.test/07_update_modules_all_expansion.test.js": "718e1e0ef8441e60", + "test/unit/module_operations.test/08_uninstall_and_e2e.test.js": "7f8472425104dee9", + "test/unit/module_operations.test/09_reset_modules.test.js": "2e555b11d8f2b782", + "test/unit/module_operations.test/10_reset_node_datadir_resolution.test.js": "429a966c6057bfb5", + "test/unit/module_operations.test/11_reset_external_db_reachability.test.js": "23b229f4464bf168", + "test/unit/module_operations.test/12_reset_bootstrap_republish.test.js": "c1b49a38cfcc3041", + "test/unit/module_operations.test/13_reset_registry_read.test.js": "0a574baed3231d6e", + "test/unit/module_operations.test/14_reset_utxo_tracker_volume.test.js": "57b561a8898acb97", + "test/unit/module_operations.test/15_reset_regtest_hub_purge.test.js": "30dce11a237ad0fd", + "test/unit/module_operations.test/16_reset_coupled_decoder_indexer.test.js": "d19943f6efeadea6", + "test/unit/module_operations.test/17_module_error_paths.test.js": "eec7cda19aee9b9b", + "test/unit/module_operations.test/18_sync_shared_services_after_install.test.js": "b874404951e97c8a", + "test/unit/module_operations.test/19_e2e_image_siblings.test.js": "0b7a214d72e8bdcc", + "test/unit/module_operations.test/20_update_partial_outcome.test.js": "0257e547c2aa2e36", + "test/unit/module_service.test.js": "bba74ea3b5ec4339", + "test/unit/module_service.test/01_buildandup.test.js": "78db390dade7aa08", + "test/unit/module_service.test/02_buildandup_hub_credential_ordering.test.js": "63034e15e659f6bb", + "test/unit/module_service.test/03_buildandup_healthcheck_args.test.js": "501f38adb1b23b15", + "test/unit/module_service.test/04_installmodule_db_module.test.js": "3e5e1395ce2262f2", + "test/unit/module_service.test/05_buildandup_module_specific_port_volume_branches.test.js": "29583491ff7dbbc4", + "test/unit/module_service.test/06_buildandup_module_specific_port_volume_branches.test.js": "7fcd70cf0e0cd046", + "test/unit/module_service.test/07_installmodule_node_module.test.js": "efa6cc2e3df55d82", + "test/unit/module_service.test/08_installmodule_node_checkremotenodeversion_called_when_coin_not_in_remoteversions.test.js": "1535ee8397f03ca7", + "test/unit/module_service.test/09_clonegit_rewrite_over_an_existing_checkout.test.js": "13616050e0170f89", + "test/unit/module_service.test/10_buildandup_rollcall_wiring_guard.test.js": "38b8c58e2f7907b5", + "test/unit/module_service.test/11_port_spec_grammar.test.js": "480937c8f65a1475", + "test/unit/module_service.test/12_buildandup_shutdown_timeout_env.test.js": "e269ee035ad3b399", + "test/unit/module_service.test/13_buildandup_buildkit_precedes_clone_and_db.test.js": "846dd1095f28252d", + "test/unit/module_service.test/13_buildandup_init_and_wget_timeout.test.js": "462a874ef1e65e98", + "test/unit/module_service.test/15_installmodule_node_missing_remote_release.test.js": "9edf66b2734f0658", + "test/unit/mutating_lock_wait.test.js": "37a925183bf82ad0", + "test/unit/nightly_e2e_workflow.test/sharding.test.js": "54441a6949465539", + "test/unit/nightly_e2e_workflow.test/two_stack_legs.test.js": "1fcd3f9e733e968a", + "test/unit/node_service.test.js": "6fbe6a70deeed73b", + "test/unit/node_service.test/blocks_and_txindex.test.js": "89a860ec2e7d06b3", + "test/unit/node_service.test/blocks_dir_persistence.test.js": "73eac28b1927f020", + "test/unit/node_service.test/broken_mirror.test.js": "c5bcb58acc73d969", + "test/unit/node_service.test/build_crypto_node.test.js": "e9444247682f4e2d", + "test/unit/node_service.test/install_node.test.js": "ce97a31910d4f041", + "test/unit/node_service.test/node_version_pin.test.js": "7a9a45411521e34f", + "test/unit/node_service.test/placed_tarball.test.js": "29d03dcc1c784287", + "test/unit/node_service.test/update_datadir_unset.test.js": "a543adaaff87951c", + "test/unit/peer_services.test.js": "e5fb8ceb0cbe29f3", + "test/unit/precheck.test.js": "f5203ce98b5b6d9c", + "test/unit/precheck.test/01_a_hub_that_is_not_answering_does_not_wedge_its_own_repair.test.js": "3be16671d270b423", + "test/unit/precheck.test/02_the_hub_is_staged_at_the_ref_the_command_named.test.js": "1192154391900a7e", + "test/unit/precheck.test/03_the_cli_presents_the_sidecar_hub_api_key_to_the_hub.test.js": "645789d933f8115c", + "test/unit/prototype_method_descriptors.test.js": "3fec2f60b896a660", + "test/unit/publish_bootstraps_forced_republish.test.js": "e2d431011abfd746", + "test/unit/rail_leg_workflow.test/anchor_arm_heights.test.js": "9a4ded303b89d06c", + "test/unit/rail_leg_workflow.test/doge_follower_compare.test.js": "a8b71b9c9e21302c", + "test/unit/rail_leg_workflow.test/drive_env_coin.test.js": "f1ea17eadb3df859", + "test/unit/rail_leg_workflow.test/drive_env_urls.test.js": "935687035bb6ce2b", + "test/unit/rail_leg_workflow.test/follower_clone_plan.test.js": "0bc33761807f31ad", + "test/unit/rail_leg_workflow.test/follower_hash_compare.test.js": "6befe4bcc41421e4", + "test/unit/rail_leg_workflow.test/follower_rows_read.test.js": "f273c6b0660c6561", + "test/unit/rail_leg_workflow.test/hub_anchor_pipeline.test.js": "fdd338e64863927b", + "test/unit/rail_leg_workflow.test/hub_llm_credential.test.js": "f8e8f21532c2e785", + "test/unit/rail_leg_workflow.test/hub_llm_probe.test.js": "87bbeeec71aa7b85", + "test/unit/rail_leg_workflow.test/leg_expect_values.test.js": "5b0b3cfef75bc4fd", + "test/unit/rail_leg_workflow.test/ltc_stack.test.js": "fe4485b6751ffb56", + "test/unit/rail_leg_workflow.test/mirror_admission.test.js": "ea0325c156538f37", + "test/unit/rail_leg_workflow.test/publisher_funding.test.js": "c60a5a54c1d26404", + "test/unit/rail_leg_workflow.test/publisher_funding_wiring.test.js": "68322c4def9bb3be", + "test/unit/rail_leg_workflow.test/r2_arm_values.test.js": "998d59a6710ceaa1", + "test/unit/rebase_completeness_sweep.test.js": "f3c42962f4a597a9", + "test/unit/release_manifest_service.test.js": "7cccd4dc81f793a1", + "test/unit/release_manifest_service.test/resolve_install_target.test.js": "53a93d0ea7bffc56", + "test/unit/release_manifest_service.test/the_provenance_gate.test.js": "0e15713d36178e5f", + "test/unit/release_manifest_service.test/the_shipped_manifest.test.js": "f0e58110dbd1940b", + "test/unit/release_signature_service.test.js": "0b0e062a08751d6c", + "test/unit/rollcall_wiring.test/01_which_deploys_read_roll_calls.test.js": "dfb14232aba06767", + "test/unit/rollcall_wiring.test/02_the_verdict.test.js": "fec0a74d17f9be6a", + "test/unit/rollcall_wiring.test/03_arming_mirror.test.js": "51093ddbe0fee12c", + "test/unit/scan_nine_field_files.test.js": "d1b47eeb57e49143", + "test/unit/secret_env.test.js": "37d41880660612cd", + "test/unit/security/configuration/dependency_advisories.test.js": "5cc2b8143bdd5808", + "test/unit/self_update_service.test.js": "33a5dd7bc655d47e", + "test/unit/service_registry.test.js": "31f99897a525a280", + "test/unit/services/bootstrap_service/tracker_height.test.js": "cb937309157fd5ad", + "test/unit/services/github_downloader/host_arch.test.js": "20b64a17eccbdd28", + "test/unit/services/release_signature_service/release_assets.test.js": "b23bbb069f1bf215", + "test/unit/services/validator_stake_service/send_lock.test.js": "3fa5333e295302e7", + "test/unit/services/validator_stake_service/token_balance_read.test.js": "33e59b9e09cfcd60", + "test/unit/skew_guard_service.test.js": "66a744498ed159be", + "test/unit/state.test.js": "a40634de4e012c02", + "test/unit/status_reorg_halt.test.js": "8159641021bab8a6", + "test/unit/status_service.test.js": "148e787f79bb3562", + "test/unit/status_service.test/advisory_remote_version_check.test.js": "c29f3f159edfeb5d", + "test/unit/status_service.test/get_installed_coins_and_networks.test.js": "81304df51b8b5a6e", + "test/unit/status_service.test/get_status_branch_column.test.js": "0d34c41e6c892a40", + "test/unit/status_service.test/get_status_commit_column.test.js": "d408c4bf9caedafb", + "test/unit/status_service.test/get_status_stall_and_halt_columns.test.js": "5486dc9f5b20dac7", + "test/unit/status_service.test/get_status_with_installed_modules.test.js": "a21fd2dad9b609f9", + "test/unit/status_service.test/get_status_with_installed_modules_versions_and_output.test.js": "39ba977a5cb2416a", + "test/unit/status_service.test/load_installed_modules.test.js": "1a6e2ea5d2022758", + "test/unit/stop_budget_service.test.js": "4168e4dba2ad4bbb", + "test/unit/telemetry_connector.test.js": "31b05a9177135645", + "test/unit/telemetry_service.test.js": "9dc7ea4f45a7ab68", + "test/unit/ui/menu/module_choices.test.js": "312597dee63fa214", + "test/unit/ui/menu/node_version_order.test.js": "426085f91f8b7f22", + "test/unit/ui_menu.test.js": "b160cd15b0db200d", + "test/unit/utils/indexer_migrate_cli.test.js": "c0a179d054a8c1ef", + "test/unit/utils/migrate_cli_status_guard.test.js": "41f7f97eefd13478", + "test/unit/utils/migration_files.test.js": "bbdb0aa3f3d1d42e", + "test/unit/utils/sql_safety.test.js": "582d3f8bc57f4d02", + "test/unit/validator_sdk_encoder_compat.test.js": "4ea2fe0c5595ff3a", + "test/unit/validator_service.test.js": "dd1b100fce46a627", + "test/unit/validator_service.test/01_init_validator.test.js": "8f5d3a3ee1b75f78", + "test/unit/validator_service.test/02_init_validator_options_and_files.test.js": "449638b7f5819a93", + "test/unit/validator_service.test/03_init_validator_hub_api_key.test.js": "dac87b5c804f217b", + "test/unit/validator_service.test/04_get_validator_settings.test.js": "6c7dbd2ae98e853b", + "test/unit/validator_service.test/05_get_validator_env.test.js": "fd296203c4043515", + "test/unit/validator_service.test/06_validator_mode_report.test.js": "57d2979882e18339", + "test/unit/validator_service.test/07_get_capability_config_host_path.test.js": "8602a614e6adc857", + "test/unit/validator_service.test/08_get_capability_config_mount_dir.test.js": "1ad790e7ec7606c5", + "test/unit/validator_service.test/09_capability_config_layout_migration.test.js": "fb57dd82dda75a00", + "test/unit/validator_service.test/10_container_mount_points.test.js": "fcfa96254df2c351", + "test/unit/validator_service.test/11_validator_dir.test.js": "26f27dbd3b14ec37", + "test/unit/validator_service.test/12_coin_wallets.test.js": "6d7114a33630b804", + "test/unit/validator_service.test/13_coin_wallet_options_and_mounts.test.js": "3670f7eb60a0bd6e", + "test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js": "332b1a20f0f85580", + "test/unit/validator_service.test/15_hub_env_wiring.test.js": "1dc33a33679f1947", + "test/unit/validator_service.test/16_rollcall_status_reporting.test.js": "cb99d238d302d2c2", + "test/unit/validator_service.test/17_capability_drift_probe.test.js": "1ee619322512f5f5", + "test/unit/validator_stake_service.test.js": "11478527623fc361", + "test/unit/validator_stake_service.test/free_key.test.js": "32b1e008856a0902", + "test/unit/validator_stake_service.test/send_lock.test.js": "f228d9772b7c14c7", + "test/unit/validator_stake_service.test/stake_amount_option.test.js": "c2ef0de1942c90ef", + "test/unit/validator_stake_service.test/stake_free_key_check.test.js": "399f8b388cacf9f5", + "test/unit/validator_stake_service.test/stake_validator.test.js": "986d6e1069c073c2", + "test/unit/validator_stake_service.test/unstake_pending_top_up.test.js": "c8569cbfc2ecad03", + "test/unit/validator_stake_service.test/unstake_validator.test.js": "b81d9b9c07cd6df9", + "test/unit/validator_stake_service.test/upstream_reads.test.js": "b7114a10b0030562", + "test/unit/validator_stake_service.test/validator_set_paging.test.js": "8403c6ea9dad5fe3", + "test/unit/version_service.test.js": "0586050116b0dd94" + } + }, + "test:boundary": { + "fileCount": 7, + "titleCount": 57, + "files": { + "test/boundary/boundary.test.js": "a64692a5a21e15d1", + "test/boundary/boundary.test/branch_name_validation.test.js": "85a3edce75ca5fc0", + "test/boundary/boundary.test/docker_env_var_passing.test.js": "b01987f449c4e2d6", + "test/boundary/boundary.test/filter_command_parameters_boundaries.test.js": "49e4d868739931f2", + "test/boundary/boundary.test/grep_test_name_handling.test.js": "d6525b1aa23b05bc", + "test/boundary/boundary.test/naming_helper_boundaries.test.js": "277a78225d926189", + "test/boundary/boundary.test/resolve_args_boundaries.test.js": "5391d29ad9735760" + } + }, + "test:chaos": { + "fileCount": 45, + "titleCount": 121, + "files": { + "test/chaos/config_resilience_chaos.test.js": "e187ad3068ae0375", + "test/chaos/config_resilience_chaos.test/argument_parsing_resilience_chaos.test.js": "24bd240f4198a15b", + "test/chaos/config_resilience_chaos.test/config_path_traversal_chaos.test.js": "222cf77585f733f4", + "test/chaos/config_resilience_chaos.test/malformed_config_content_chaos.test.js": "f393f541d19a1e13", + "test/chaos/config_resilience_chaos.test/port_validation_under_chaos.test.js": "6e05f9dad9987bd7", + "test/chaos/config_resilience_chaos.test/shared_module_config_chaos.test.js": "f980e859b8476bb3", + "test/chaos/docker_resilience_chaos.test.js": "a856fcb5d9d5fe15", + "test/chaos/docker_resilience_chaos.test/docker_build_failure_chaos.test.js": "5db8daf74cc46607", + "test/chaos/docker_resilience_chaos.test/docker_exec_failure_chaos.test.js": "baf6a5649ec3a388", + "test/chaos/docker_resilience_chaos.test/docker_inspect_with_invalid_json_chaos.test.js": "ba43f3b4dd958f04", + "test/chaos/docker_resilience_chaos.test/docker_network_creation_failure_chaos.test.js": "e2ced651370bfd16", + "test/chaos/docker_resilience_chaos.test/docker_run_failure_chaos.test.js": "c43e02d1acf524b0", + "test/chaos/docker_resilience_chaos.test/file_write_to_container_failure_chaos.test.js": "8882bef46ee2ac3f", + "test/chaos/docker_resilience_chaos.test/invalid_port_in_config_during_build_and_up_chaos.test.js": "30f77b585ec67a07", + "test/chaos/docker_resilience_chaos.test/operations_on_non_existent_containers_chaos.test.js": "6cf7aae9519b9d5c", + "test/chaos/docker_resilience_chaos.test/state_storage_failure_chaos.test.js": "011e3638fa7bd665", + "test/chaos/download_resilience_chaos.test.js": "34cab5185dd9084a", + "test/chaos/download_resilience_chaos.test/archive_extraction_failure_chaos.test.js": "fedac1cc56d1106d", + "test/chaos/download_resilience_chaos.test/download_cleanup_on_failure_chaos.test.js": "67acfa55737fd29a", + "test/chaos/download_resilience_chaos.test/github_api_unreachable_timeout_chaos.test.js": "0df0aabf6e94707f", + "test/chaos/download_resilience_chaos.test/github_rate_limiting_chaos.test.js": "d5cad7504894bd19", + "test/chaos/download_resilience_chaos.test/github_rate_limiting_server_error_chaos.test.js": "9d1f2fd0b2ea4240", + "test/chaos/download_resilience_chaos.test/hashes_file_corruption_format_chaos.test.js": "77345bb5d35c1fac", + "test/chaos/download_resilience_chaos.test/hashes_file_corruption_shape_chaos.test.js": "f7bc2ba90d800fa1", + "test/chaos/download_resilience_chaos.test/no_compatible_version_available_chaos.test.js": "3ad798643f338e59", + "test/chaos/download_resilience_chaos.test/release_tag_not_found_chaos.test.js": "8736dac4ae50eb08", + "test/chaos/download_resilience_chaos.test/repository_not_found_chaos.test.js": "d5a94f679740e3de", + "test/chaos/download_resilience_chaos.test/sha_256_hash_mismatch_chaos.test.js": "56897dc4174a8788", + "test/chaos/git_clone_resilience_chaos.test.js": "cbdbc1d46a4e8f22", + "test/chaos/network_resilience_chaos.test.js": "2828c04ca92985e6", + "test/chaos/process_resilience_chaos.test.js": "4a50c1aca5c12ab0", + "test/chaos/process_resilience_chaos.test/async_error_propagation_overwrite_kill_chaos.test.js": "f033f9a92030563f", + "test/chaos/process_resilience_chaos.test/async_error_propagation_overwrite_remove_chaos.test.js": "2d5951fd05d23617", + "test/chaos/process_resilience_chaos.test/async_error_propagation_run_chaos.test.js": "841397b0f12a1dbd", + "test/chaos/process_resilience_chaos.test/async_error_propagation_status_chaos.test.js": "9b06c8565cfe1f81", + "test/chaos/process_resilience_chaos.test/empty_container_id_non_hex_chaos.test.js": "c30fc0c4dd2e1ee9", + "test/chaos/process_resilience_chaos.test/empty_container_id_valid_chaos.test.js": "f689c0ae4d32f20c", + "test/chaos/process_resilience_chaos.test/empty_container_id_whitespace_chaos.test.js": "b15d571802c13994", + "test/chaos/process_resilience_chaos.test/multi_step_clone_error_chaos.test.js": "7705d14ae3ffca27", + "test/chaos/process_resilience_chaos.test/multi_step_module_missing_chaos.test.js": "d0cd9d15c583ff81", + "test/chaos/process_resilience_chaos.test/precheck_directory_creation_chaos.test.js": "b79e9f220b2c5589", + "test/chaos/process_resilience_chaos.test/precheck_docker_failure_chaos.test.js": "1b873fc16e610f8e", + "test/chaos/process_resilience_chaos.test/precheck_hub_failure_chaos.test.js": "28f3c87021ea9588", + "test/chaos/process_resilience_chaos.test/precheck_network_failure_chaos.test.js": "8e25d0290c1bd265", + "test/chaos/process_resilience_chaos.test/uninstall_resilience_chaos.test.js": "e2fdc331e1ae4b01" + } + }, + "test:e2e": { + "fileCount": 8, + "titleCount": 57, + "files": { + "test/e2e/scenarios/config_overrides.test.js": "61281c3139eea8a1", + "test/e2e/scenarios/error_handling.test.js": "c8534fb477afedec", + "test/e2e/scenarios/exec_logs.test.js": "fb89a5fa8f5a55ca", + "test/e2e/scenarios/install_lifecycle.test.js": "6bd3d7c12a20c4eb", + "test/e2e/scenarios/multi_coin.test.js": "bf75da756c4b378c", + "test/e2e/scenarios/precheck.test.js": "7bb2fe8765cc4426", + "test/e2e/scenarios/reset.test.js": "08758d91b3b5705b", + "test/e2e/scenarios/update_flow.test.js": "1f9845108589a523" + } + }, + "test:fuzz": { + "fileCount": 8, + "titleCount": 264, + "files": { + "test/fuzz/branch_validation.fuzz.js": "36964ba24beb3edb", + "test/fuzz/command_construction.fuzz.js": "3111978f0fd33252", + "test/fuzz/config_parsing.fuzz.js": "de7155905abb418f", + "test/fuzz/container_id.fuzz.js": "83351766649e0c73", + "test/fuzz/env_escaping.fuzz.js": "c41ce4e4afc4896e", + "test/fuzz/filter_params.fuzz.js": "a4a05cec47b48b65", + "test/fuzz/port_validation.fuzz.js": "a3960f64f6ba6129", + "test/fuzz/resolve_args.fuzz.js": "efb0052f155d4455" + } + }, + "test:integration": { + "fileCount": 28, + "titleCount": 106, + "files": { + "test/integration/config_pipeline.test.js": "a5a5aa45a05118df", + "test/integration/database_setup.test.js": "34be82a1cf77dc44", + "test/integration/database_setup.test/add_user_password_to_database.test.js": "616adeca1c559bb1", + "test/integration/database_setup.test/build_database_module_already_exists.test.js": "b15f63c78bbeaa42", + "test/integration/database_setup.test/check_if_database_is_ready.test.js": "ff91bbaf1f49c8c2", + "test/integration/database_setup.test/set_database_parameters.test.js": "701a6f07eaa918d0", + "test/integration/docker_commands.test.js": "b2b6dd472622839b", + "test/integration/docker_commands.test/build_and_up_with_overwrite_container_id.test.js": "af0367fb70381488", + "test/integration/docker_commands.test/docker_build_working_directory.test.js": "8927121fa98c797e", + "test/integration/docker_commands.test/every_coin_network_combo_produces_valid_docker_run_commands.test.js": "448f1a53a6792be5", + "test/integration/docker_commands.test/xchain_decoder_docker_run_command.test.js": "d89bcbe0b2036003", + "test/integration/docker_commands.test/xchain_explorer_docker_run_command.test.js": "8e826477c40148d2", + "test/integration/docker_commands.test/xchain_hub_docker_run_command.test.js": "8df8514bdcae5e67", + "test/integration/docker_commands.test/xchain_indexer_docker_run_command.test.js": "3aa22b5c17bf0837", + "test/integration/docker_commands.test/xchain_regtest_miner_docker_run_command.test.js": "e85f9fd995faa7fc", + "test/integration/docker_commands.test/xchain_utxo_tracker_docker_run_command.test.js": "3fa86dc751918535", + "test/integration/hub_config_update.test.js": "bbd3a050c99fdc1f", + "test/integration/hub_config_update.test/01_hub_update_retry_logic.test.js": "cacb2b17a1b6db97", + "test/integration/hub_config_update.test/02_update_hub_or_explorer_for_explorer.test.js": "62f79f2877e9a543", + "test/integration/hub_config_update.test/03_update_hub_network_connectivity.test.js": "428aec81edc63b3f", + "test/integration/hub_config_update.test/04_checkpoint_self_sync_opt_in_survives_the_shell_that_set_it.test.js": "81888023f59d00e3", + "test/integration/module_lifecycle.test.js": "6442e5713907c431", + "test/integration/multi_module_orchestration.test.js": "2229616699655894", + "test/integration/multi_module_orchestration.test/01_update_modules_orchestration.test.js": "25498cac11aac073", + "test/integration/multi_module_orchestration.test/02_uninstall_modules_orchestration.test.js": "5756d1c8d96ada07", + "test/integration/multi_module_orchestration.test/03_filter_command_parameters_install_modules_end_to_end.test.js": "80fa21ba0e27730f", + "test/integration/network_management.test.js": "f91eef5ac7acac7f", + "test/integration/status_query.test.js": "8b660477cd3be877" + } + }, + "test:mutation": { + "skipped": "not a mocha command (runs npx)" + }, + "test:mutation:config": { + "skipped": "not a mocha command (runs npx)" + }, + "test:regression": { + "fileCount": 11, + "titleCount": 59, + "files": { + "test/regression/clone_rollback.test.js": "58089c71dd0e074e", + "test/regression/regression.test.js": "cc0e44f047ba96c0", + "test/regression/regression.test/01_configuration_generation.test.js": "4fc4333ff26b31fa", + "test/regression/regression.test/02_docker_command_construction.test.js": "5431f5284081e044", + "test/regression/regression.test/03_security.test.js": "332bea463a49f99c", + "test/regression/regression.test/04_service_lifecycle.test.js": "d5b9066c5de4f491", + "test/regression/regression.test/05_state_data_integrity.test.js": "fbec8ac2d15abf8d", + "test/regression/regression.test/06_e2e_lifecycle_workflows.test.js": "f1b427ee16f7a8b8", + "test/regression/regression.test/07_config_parsing_boundaries.test.js": "a38c76fb41c7130a", + "test/regression/regression.test/08_precheck_error_handling.test.js": "b2439743a81a59fe", + "test/regression/regression.test/09_utility_functions.test.js": "14d2f05367245fea" + } + }, + "test:regression:p0": { + "fileCount": 5, + "titleCount": 33, + "files": { + "test/regression/clone_rollback.test.js": "58089c71dd0e074e", + "test/regression/regression.test.js": "cc0e44f047ba96c0", + "test/regression/regression.test/01_configuration_generation.test.js": "4fc4333ff26b31fa", + "test/regression/regression.test/02_docker_command_construction.test.js": "5431f5284081e044", + "test/regression/regression.test/03_security.test.js": "332bea463a49f99c" + } + }, + "test:regression:p0p1": { + "fileCount": 9, + "titleCount": 52, + "files": { + "test/regression/clone_rollback.test.js": "58089c71dd0e074e", + "test/regression/regression.test.js": "cc0e44f047ba96c0", + "test/regression/regression.test/01_configuration_generation.test.js": "4fc4333ff26b31fa", + "test/regression/regression.test/02_docker_command_construction.test.js": "5431f5284081e044", + "test/regression/regression.test/03_security.test.js": "332bea463a49f99c", + "test/regression/regression.test/04_service_lifecycle.test.js": "d5b9066c5de4f491", + "test/regression/regression.test/05_state_data_integrity.test.js": "fbec8ac2d15abf8d", + "test/regression/regression.test/06_e2e_lifecycle_workflows.test.js": "f1b427ee16f7a8b8", + "test/regression/regression.test/07_config_parsing_boundaries.test.js": "a38c76fb41c7130a" + } + }, + "test:security": { + "fileCount": 11, + "titleCount": 180, + "files": { + "test/security/security.test.js": "f7a34d9505872f33", + "test/security/security.test/01_container_id_validation.test.js": "c05d3d101b7b8d94", + "test/security/security.test/02_node_prefix_validation.test.js": "15b1667f5500cf45", + "test/security/security.test/03_branch_name_validation.test.js": "8ec32726c4949c89", + "test/security/security.test/04_config_path_traversal_prevention.test.js": "cdb0083f856567ee", + "test/security/security.test/05_database_command_safety.test.js": "35f2baf0b0932dcf", + "test/security/security.test/06_github_downloader_archive_extraction_safety.test.js": "8d417dad40169b50", + "test/security/security.test/07_helpers_js_decompress_tar_gz_safety.test.js": "95e6da4b094313a0", + "test/security/security.test/08_string_to_docker_container_file_safety.test.js": "1ce50380a1677475", + "test/security/security.test/09_bootstrap_directory_permissions.test.js": "08508bce5c7e9e87", + "test/security/security.test/10_no_remaining_exec_calls_in_source_files.test.js": "e1e49bbe50482377" + } + }, + "test:smoke": { + "fileCount": 10, + "titleCount": 159, + "files": { + "test/smoke/smoke.test.js": "946b75a6f83d1f21", + "test/smoke/smoke.test/s_smoke_002_commander_cli_registration.test.js": "9f5847124ddc28f6", + "test/smoke/smoke.test/s_smoke_003_global_options_registration.test.js": "38eae73b5e2c3897", + "test/smoke/smoke.test/s_smoke_004_constants_and_enum_integrity.test.js": "fd287b2db335a349", + "test/smoke/smoke.test/s_smoke_005_config_template_file_integrity.test.js": "90560b45328159e3", + "test/smoke/smoke.test/s_smoke_006_config_composition.test.js": "d94222300a70d805", + "test/smoke/smoke.test/s_smoke_007_docker_command_construction.test.js": "13ff9670bc05bf2e", + "test/smoke/smoke.test/s_smoke_008_parameter_expansion.test.js": "f799873bf537e087", + "test/smoke/smoke.test/s_smoke_009_state_module_initialization.test.js": "568cce545e5bfbec", + "test/smoke/smoke.test/s_smoke_010_crypto_node_dockerfile_existence.test.js": "dad38ac028223802" } } } diff --git a/bin/pins/identity.json b/bin/pins/identity.json index a6e1b55f..a6bde403 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -2,9 +2,9 @@ "repo": "xchain-node", "vendored": "src/coins (sync-coins.sh)", "files": { - "src/coins/BTC.js": "92f1e7d143f844886700f40deb53cb02bb7c6c270ab4bb837488f5d8535629b6", - "src/coins/DOGE.js": "3b1aac3d95ca96fd6a7f53c4af169e4e845effb77d00058473bda4d4786e397b", - "src/coins/LTC.js": "d7cf410caea44a18c3c5cac07ab7f47507af8680ec3cbbec4f54ab8cd09fc0b9", + "src/coins/BTC.js": "182baf5173e3846b94a5edc1ae15913a54fb266addfc45a3cf9a013bd9ec16c6", + "src/coins/DOGE.js": "4659b45fb7a5c959ce2777ab9d5e7c193f3515f03cdd8cd23bb6b9c4d012fc11", + "src/coins/LTC.js": "6155195bfa5fdfeab77dec3802ce67b5cc47a77695e81a4a5545d980e4c421dd", "src/coins/consensus_pin.js": "342bf7af224a19ea0a98ff9cfb2c9a70b3fd5be75de7ecef2db05936f82baa63", "src/coins/index.js": "08b678d934fa647d3c7d14dc5ccdaeb86722ee32a3662de69e4a31cd7b09234c" } diff --git a/bin/pins/suite-title-renames.json b/bin/pins/suite-title-renames.json index d98067a4..682a0506 100644 --- a/bin/pins/suite-title-renames.json +++ b/bin/pins/suite-title-renames.json @@ -125,6 +125,14 @@ "test/unit/ValidatorService.test.js": "test/unit/validator_service.test.js", "test/unit/ValidatorStakeService.test.js": "test/unit/validator_stake_service.test.js", "test/unit/VersionService.test.js": "test/unit/version_service.test.js", + "test/unit/autoheal_service.test/03_restart_cooldown.test.js": "test/unit/autoheal_service.test/backoff/03_restart_cooldown.test.js", + "test/unit/autoheal_service.test/04_exponential_backoff.test.js": "test/unit/autoheal_service.test/backoff/04_exponential_backoff.test.js", + "test/unit/autoheal_service.test/05_recovery_resets_backoff.test.js": "test/unit/autoheal_service.test/backoff/05_recovery_resets_backoff.test.js", + "test/unit/autoheal_service.test/06_starting_probation.test.js": "test/unit/autoheal_service.test/probation/06_starting_probation.test.js", + "test/unit/autoheal_service.test/07_stopped_container_backoff.test.js": "test/unit/autoheal_service.test/backoff/07_stopped_container_backoff.test.js", + "test/unit/autoheal_service.test/08_recovery_past_probation.test.js": "test/unit/autoheal_service.test/probation/08_recovery_past_probation.test.js", + "test/unit/autoheal_service.test/09_pass_inside_probation.test.js": "test/unit/autoheal_service.test/probation/09_pass_inside_probation.test.js", + "test/unit/autoheal_service.test/10_backoff_calculations.test.js": "test/unit/autoheal_service.test/backoff/10_backoff_calculations.test.js", "test/unit/checkRebaseCompleteness.test.js": "test/unit/check_rebase_completeness.test.js", "test/unit/cli-error-handling.test.js": "test/unit/cli_error_handling.test.js", "test/unit/cli-rollback.test.js": "test/unit/cli_rollback.test.js", @@ -147,9 +155,27 @@ "test/unit/secret-env.test.js": "test/unit/secret_env.test.js", "test/unit/security/configuration/dependency-advisories.test.js": "test/unit/security/configuration/dependency_advisories.test.js", "test/unit/statusReorgHalt.test.js": "test/unit/status_reorg_halt.test.js", - "test/unit/ui-menu.test.js": "test/unit/ui_menu.test.js" + "test/unit/ui-menu.test.js": "test/unit/ui_menu.test.js", + "test/unit/module_operations.test/09_reset_modules.test.js": "test/unit/module_operations.test/reset/09_reset_modules.test.js", + "test/unit/module_operations.test/10_reset_node_datadir_resolution.test.js": "test/unit/module_operations.test/reset/10_reset_node_datadir_resolution.test.js", + "test/unit/module_operations.test/11_reset_external_db_reachability.test.js": "test/unit/module_operations.test/reset/11_reset_external_db_reachability.test.js", + "test/unit/module_operations.test/12_reset_bootstrap_republish.test.js": "test/unit/module_operations.test/reset/12_reset_bootstrap_republish.test.js", + "test/unit/module_operations.test/13_reset_registry_read.test.js": "test/unit/module_operations.test/reset/13_reset_registry_read.test.js", + "test/unit/module_operations.test/14_reset_utxo_tracker_volume.test.js": "test/unit/module_operations.test/reset/14_reset_utxo_tracker_volume.test.js", + "test/unit/module_operations.test/15_reset_regtest_hub_purge.test.js": "test/unit/module_operations.test/reset/15_reset_regtest_hub_purge.test.js", + "test/unit/module_operations.test/16_reset_coupled_decoder_indexer.test.js": "test/unit/module_operations.test/reset/16_reset_coupled_decoder_indexer.test.js", + "test/unit/module_operations.test/05_update_modules_branch_handling.test.js": "test/unit/module_operations.test/update/05_update_modules_branch_handling.test.js", + "test/unit/module_operations.test/06_update_modules_no_ref_target_resolution.test.js": "test/unit/module_operations.test/update/06_update_modules_no_ref_target_resolution.test.js", + "test/unit/module_operations.test/07_update_modules_all_expansion.test.js": "test/unit/module_operations.test/update/07_update_modules_all_expansion.test.js", + "test/unit/module_operations.test/20_update_partial_outcome.test.js": "test/unit/module_operations.test/update/20_update_partial_outcome.test.js" }, "titles": { + "test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js": { + "BootstrapService tracker archive height helpers chooseArchiveHeight never records below either reading and keeps the floor when the post-read fails": "BootstrapService tracker archive height helpers chooseArchiveHeight never records below either reading and refuses when the post-read fails" + }, + "test/unit/services/bootstrap_service/tracker_height.test.js": { + "tracker_height keeps the tracker height when the archive height is missing": "tracker_height refuses when only the tracker height before the stop was read" + }, "test/unit/docker_mariadb.test.js": { "utils/dockerMariadb dockerMariadbArgs() builds a docker exec invocation forwarding MYSQL_PWD by name only": "utils/docker_mariadb dockerMariadbArgs() builds a docker exec invocation forwarding MYSQL_PWD by name only", "utils/dockerMariadb dockerMariadbArgs() inserts -i before the env forward when interactive": "utils/docker_mariadb dockerMariadbArgs() inserts -i before the env forward when interactive", @@ -191,12 +217,18 @@ "test/unit/migration_precondition_service.test.js": { "MigrationPreconditionService migrationMode returns null when no mode is declared": "MigrationPreconditionService migrationMode defaults to manual when no recognized mode is declared" }, + "test/unit/module_operations.test/14_reset_utxo_tracker_volume.test.js": { + "moduleOperations resetModules() the utxo-tracker volume wipe aborts and restores the stack when the wipe fails with nothing else touched": "moduleOperations resetModules() the utxo-tracker volume wipe leaves the stack down and reports a possibly partial wipe when the wipe fails with nothing else touched" + }, "test/unit/module_service.test.js": { "ModuleService buildAndUp(): module-specific port/volume branches rejects when insertModuleContainer returns false": "ModuleService buildAndUp(): module-specific port/volume branches rejects when setModuleContainer returns false", "ModuleService uninstallModule(): removeModuleContainer returns false throws when removeModuleContainer returns false after successful container removal": "ModuleService uninstallModule(): deleteModuleContainer returns false throws when deleteModuleContainer returns false after successful container removal" }, "test/unit/node_service.test.js": { "NodeService: buildCryptoNode() rejects when db.insertModuleContainer returns false": "NodeService: buildCryptoNode() rejects when db.setModuleContainer returns false" + }, + "test/unit/release_manifest_service.test/resolve_install_target.test.js": { + "ReleaseManifestService resolveInstallTarget() falls back to the default branch when the lookup fails outright": "ReleaseManifestService resolveInstallTarget() fails closed on a thrown lookup error even when a branch fallback is allowed" } } } diff --git a/bin/pins/suite-title-splits.json b/bin/pins/suite-title-splits.json index f0c035a4..72551ad8 100644 --- a/bin/pins/suite-title-splits.json +++ b/bin/pins/suite-title-splits.json @@ -2,7 +2,7 @@ "note": "The declared test-file splits, read by bin/suite-title-map.js --split-map against bin/pins/at1-suite-titles.json. splits maps each pinned test file that became several files to the full list of files its tests now live in, keyed by the path the compare sees after the rename map is applied. For every npm test script that pinned the old file, the titles those files collect together must equal the old file's titles exactly: none lost, none added, none carried by two parts, and none moved into a file the list does not name. A part may keep the old path; a single destination is a rename and belongs in the rename map. A split that also changes a title declares that title in the rename map's titles, under the path the old file had just before the split, so a difference that is not declared is a regression rather than a restructure.", "date": "2026-09-14", "pin": "bin/pins/at1-suite-titles.json", - "pin_before_sha256": "5e681fbabe97843c13a02e41258cf3666d89be0af16befb1e80cf1128728e3fd", + "pin_before_sha256": "4be2216a2df11c318a2a99e895475b638fb8714259720bf270a7af214885e365", "compare": "node bin/suite-title-map.js --compare bin/pins/at1-suite-titles.json --rename-map bin/pins/suite-title-renames.json --split-map bin/pins/suite-title-splits.json", "example": { "test/unit/big_suite.test.js": [ @@ -10,401 +10,5 @@ "test/unit/big_suite/writes.test.js" ] }, - "splits": { - "test/boundary/boundary.test.js": [ - "test/boundary/boundary.test.js", - "test/boundary/boundary.test/resolve_args_boundaries.test.js", - "test/boundary/boundary.test/filter_command_parameters_boundaries.test.js", - "test/boundary/boundary.test/docker_env_var_passing.test.js", - "test/boundary/boundary.test/branch_name_validation.test.js", - "test/boundary/boundary.test/grep_test_name_handling.test.js", - "test/boundary/boundary.test/naming_helper_boundaries.test.js" - ], - "test/chaos/config_resilience_chaos.test.js": [ - "test/chaos/config_resilience_chaos.test.js", - "test/chaos/config_resilience_chaos.test/malformed_config_content_chaos.test.js", - "test/chaos/config_resilience_chaos.test/config_path_traversal_chaos.test.js", - "test/chaos/config_resilience_chaos.test/argument_parsing_resilience_chaos.test.js", - "test/chaos/config_resilience_chaos.test/port_validation_under_chaos.test.js", - "test/chaos/config_resilience_chaos.test/shared_module_config_chaos.test.js" - ], - "test/chaos/docker_resilience_chaos.test.js": [ - "test/chaos/docker_resilience_chaos.test.js", - "test/chaos/docker_resilience_chaos.test/docker_build_failure_chaos.test.js", - "test/chaos/docker_resilience_chaos.test/docker_run_failure_chaos.test.js", - "test/chaos/docker_resilience_chaos.test/state_storage_failure_chaos.test.js", - "test/chaos/docker_resilience_chaos.test/docker_network_creation_failure_chaos.test.js", - "test/chaos/docker_resilience_chaos.test/docker_inspect_with_invalid_json_chaos.test.js", - "test/chaos/docker_resilience_chaos.test/operations_on_non_existent_containers_chaos.test.js", - "test/chaos/docker_resilience_chaos.test/docker_exec_failure_chaos.test.js", - "test/chaos/docker_resilience_chaos.test/file_write_to_container_failure_chaos.test.js", - "test/chaos/docker_resilience_chaos.test/invalid_port_in_config_during_build_and_up_chaos.test.js" - ], - "test/chaos/download_resilience_chaos.test.js": [ - "test/chaos/download_resilience_chaos.test.js", - "test/chaos/download_resilience_chaos.test/archive_extraction_failure_chaos.test.js", - "test/chaos/download_resilience_chaos.test/download_cleanup_on_failure_chaos.test.js", - "test/chaos/download_resilience_chaos.test/github_api_unreachable_timeout_chaos.test.js", - "test/chaos/download_resilience_chaos.test/github_rate_limiting_chaos.test.js", - "test/chaos/download_resilience_chaos.test/github_rate_limiting_server_error_chaos.test.js", - "test/chaos/download_resilience_chaos.test/hashes_file_corruption_format_chaos.test.js", - "test/chaos/download_resilience_chaos.test/hashes_file_corruption_shape_chaos.test.js", - "test/chaos/download_resilience_chaos.test/no_compatible_version_available_chaos.test.js", - "test/chaos/download_resilience_chaos.test/release_tag_not_found_chaos.test.js", - "test/chaos/download_resilience_chaos.test/repository_not_found_chaos.test.js", - "test/chaos/download_resilience_chaos.test/sha_256_hash_mismatch_chaos.test.js" - ], - "test/chaos/process_resilience_chaos.test.js": [ - "test/chaos/process_resilience_chaos.test.js", - "test/chaos/process_resilience_chaos.test/async_error_propagation_overwrite_kill_chaos.test.js", - "test/chaos/process_resilience_chaos.test/async_error_propagation_overwrite_remove_chaos.test.js", - "test/chaos/process_resilience_chaos.test/async_error_propagation_run_chaos.test.js", - "test/chaos/process_resilience_chaos.test/async_error_propagation_status_chaos.test.js", - "test/chaos/process_resilience_chaos.test/empty_container_id_non_hex_chaos.test.js", - "test/chaos/process_resilience_chaos.test/empty_container_id_valid_chaos.test.js", - "test/chaos/process_resilience_chaos.test/empty_container_id_whitespace_chaos.test.js", - "test/chaos/process_resilience_chaos.test/multi_step_clone_error_chaos.test.js", - "test/chaos/process_resilience_chaos.test/multi_step_module_missing_chaos.test.js", - "test/chaos/process_resilience_chaos.test/precheck_directory_creation_chaos.test.js", - "test/chaos/process_resilience_chaos.test/precheck_docker_failure_chaos.test.js", - "test/chaos/process_resilience_chaos.test/precheck_hub_failure_chaos.test.js", - "test/chaos/process_resilience_chaos.test/precheck_network_failure_chaos.test.js", - "test/chaos/process_resilience_chaos.test/uninstall_resilience_chaos.test.js" - ], - "test/integration/database_setup.test.js": [ - "test/integration/database_setup.test.js", - "test/integration/database_setup.test/add_user_password_to_database.test.js", - "test/integration/database_setup.test/build_database_module_already_exists.test.js", - "test/integration/database_setup.test/check_if_database_is_ready.test.js", - "test/integration/database_setup.test/set_database_parameters.test.js" - ], - "test/integration/docker_commands.test.js": [ - "test/integration/docker_commands.test.js", - "test/integration/docker_commands.test/build_and_up_with_overwrite_container_id.test.js", - "test/integration/docker_commands.test/docker_build_working_directory.test.js", - "test/integration/docker_commands.test/every_coin_network_combo_produces_valid_docker_run_commands.test.js", - "test/integration/docker_commands.test/xchain_decoder_docker_run_command.test.js", - "test/integration/docker_commands.test/xchain_explorer_docker_run_command.test.js", - "test/integration/docker_commands.test/xchain_hub_docker_run_command.test.js", - "test/integration/docker_commands.test/xchain_indexer_docker_run_command.test.js", - "test/integration/docker_commands.test/xchain_regtest_miner_docker_run_command.test.js", - "test/integration/docker_commands.test/xchain_utxo_tracker_docker_run_command.test.js" - ], - "test/unit/bootstrap_health_gate.test.js": [ - "test/unit/bootstrap_health_gate.test.js", - "test/unit/bootstrap_health_gate.test/container_state.test.js", - "test/unit/bootstrap_health_gate.test/durable_halt_markers.test.js", - "test/unit/bootstrap_health_gate.test/evaluate_container_state.test.js", - "test/unit/bootstrap_health_gate.test/evaluate_status_payload.test.js", - "test/unit/bootstrap_health_gate.test/make_bootstrap_consults_the_source_health_gate.test.js", - "test/unit/bootstrap_health_gate.test/parse_count_tokens.test.js", - "test/unit/bootstrap_health_gate.test/service_health_probe.test.js" - ], - "test/unit/database_service.test.js": [ - "test/unit/database_service.test.js", - "test/unit/database_service.test/add_user_password_to_database.test.js", - "test/unit/database_service.test/ask_mariadb_root_password.test.js", - "test/unit/database_service.test/build_database_module.test.js", - "test/unit/database_service.test/check_if_database_is_ready.test.js", - "test/unit/database_service.test/clear_hub_price_ingest_watermark.test.js", - "test/unit/database_service.test/ensure_database_pool.test.js", - "test/unit/database_service.test/ensure_xchain_node_access.test.js", - "test/unit/database_service.test/execute_native_maria_db_command.test.js", - "test/unit/database_service.test/external_db_add_user_password.test.js", - "test/unit/database_service.test/external_db_build_database_module.test.js", - "test/unit/database_service.test/external_db_ensure_xchain_node_access.test.js", - "test/unit/database_service.test/get_database_container_id.test.js", - "test/unit/database_service.test/get_database_host_port.test.js", - "test/unit/database_service.test/get_external_db_config.test.js", - "test/unit/database_service.test/no_coin_network_build_database_module.test.js", - "test/unit/database_service.test/pre_network_column_clear_hub_price_ingest_watermark.test.js", - "test/unit/database_service.test/provisioning_sql_safety.test.js", - "test/unit/database_service.test/purge_hub_cross_chain_rows.test.js", - "test/unit/database_service.test/reset_databases.test.js", - "test/unit/database_service.test/set_database_parameters.test.js" - ], - "test/unit/explorer_service.test.js": [ - "test/unit/explorer_service.test.js", - "test/unit/explorer_service.test/01_already_running.test.js", - "test/unit/explorer_service.test/02_honours_install_ref.test.js", - "test/unit/explorer_service.test/03_no_coins_yet.test.js", - "test/unit/explorer_service.test/04_force_rebuild.test.js", - "test/unit/explorer_service.test/05_exhausts_retries.test.js", - "test/unit/explorer_service.test/06_update_error_retry.test.js", - "test/unit/explorer_service.test/07_persistent_attach_failure.test.js", - "test/unit/explorer_service.test/08_happy_path.test.js", - "test/unit/explorer_service.test/09_wait_for_ready.test.js" - ], - "test/unit/node_service.test.js": [ - "test/unit/node_service.test.js", - "test/unit/node_service.test/broken_mirror.test.js", - "test/unit/node_service.test/placed_tarball.test.js", - "test/unit/node_service.test/build_crypto_node.test.js", - "test/unit/node_service.test/blocks_and_txindex.test.js", - "test/unit/node_service.test/blocks_dir_persistence.test.js", - "test/unit/node_service.test/install_node.test.js", - "test/unit/node_service.test/node_version_pin.test.js" - ], - "test/unit/status_service.test.js": [ - "test/unit/status_service.test.js", - "test/unit/status_service.test/get_status_with_installed_modules.test.js", - "test/unit/status_service.test/get_status_with_installed_modules_versions_and_output.test.js", - "test/unit/status_service.test/get_installed_coins_and_networks.test.js", - "test/unit/status_service.test/load_installed_modules.test.js", - "test/unit/status_service.test/get_status_branch_column.test.js", - "test/unit/status_service.test/get_status_commit_column.test.js", - "test/unit/status_service.test/advisory_remote_version_check.test.js" - ], - "test/smoke/smoke.test.js": [ - "test/smoke/smoke.test.js", - "test/smoke/smoke.test/s_smoke_002_commander_cli_registration.test.js", - "test/smoke/smoke.test/s_smoke_003_global_options_registration.test.js", - "test/smoke/smoke.test/s_smoke_004_constants_and_enum_integrity.test.js", - "test/smoke/smoke.test/s_smoke_005_config_template_file_integrity.test.js", - "test/smoke/smoke.test/s_smoke_006_config_composition.test.js", - "test/smoke/smoke.test/s_smoke_007_docker_command_construction.test.js", - "test/smoke/smoke.test/s_smoke_008_parameter_expansion.test.js", - "test/smoke/smoke.test/s_smoke_009_state_module_initialization.test.js", - "test/smoke/smoke.test/s_smoke_010_crypto_node_dockerfile_existence.test.js" - ], - "test/unit/bootstrap_service.test.js": [ - "test/unit/bootstrap_service.test.js", - "test/unit/bootstrap_service.test/download_bootstrap.test.js", - "test/unit/bootstrap_service.test/download_bootstrap_2.test.js", - "test/unit/bootstrap_service.test/bootstrap_restore_is_reported_not_just_logged.test.js", - "test/unit/bootstrap_service.test/ensure_bootstrap_maria_db.test.js", - "test/unit/bootstrap_service.test/bootstrap_archive_cleanup_after_a_restore_attempt.test.js", - "test/unit/bootstrap_service.test/maria_db_module_freshness.test.js", - "test/unit/bootstrap_service.test/restore_bootstrap_utxo_tracker_fresh_extract.test.js", - "test/unit/bootstrap_service.test/restore_bootstrap_maria_db_happy_path.test.js", - "test/unit/bootstrap_service.test/ensure_dir_writable_docker_fallback_dir_exists_not_wri.test.js", - "test/unit/bootstrap_service.test/make_bootstrap_utxo_tracker_happy_path.test.js", - "test/unit/bootstrap_service.test/make_bootstrap_utxo_tracker_happy_path_2.test.js", - "test/unit/bootstrap_service.test/make_bootstrap_maria_db_happy_path.test.js", - "test/unit/bootstrap_service.test/bootstrap_listing_and_staging_safety.test.js" - ], - "test/unit/config_service.test.js": [ - "test/unit/config_service.test.js", - "test/unit/config_service.test/helpers.test.js", - "test/unit/config_service.test/get_default_config_regtest.test.js", - "test/unit/config_service.test/with_coin_and_network.test.js", - "test/unit/config_service.test/database_credentials.test.js", - "test/unit/config_service.test/service_routes.test.js", - "test/unit/config_service.test/sidecar_and_bootstrap.test.js", - "test/unit/config_service.test/rollcall_and_mirror.test.js", - "test/unit/config_service.test/encoder_and_leveldb.test.js", - "test/unit/config_service.test/shared_service_config.test.js", - "test/unit/config_service.test/shared_service_guards.test.js", - "test/unit/config_service.test/ensure_hub_api_key.test.js", - "test/unit/config_service.test/filter_command_parameters.test.js", - "test/unit/config_service.test/resolve_args.test.js" - ], - "test/unit/release_manifest_service.test.js": [ - "test/unit/release_manifest_service.test.js", - "test/unit/release_manifest_service.test/resolve_install_target.test.js", - "test/unit/release_manifest_service.test/the_provenance_gate.test.js", - "test/unit/release_manifest_service.test/the_shipped_manifest.test.js" - ], - "test/unit/release_signature_service.test.js": [ - "test/unit/release_signature_service.test.js", - "test/unit/release_signature_service.test/assert_digest_matches.test.js", - "test/unit/release_signature_service.test/parse_sha256sums.test.js", - "test/unit/release_signature_service.test/verify_detached_signature.test.js", - "test/unit/release_signature_service.test/verify_git_tag_signature.test.js", - "test/unit/release_signature_service.test/verify_manifest_for_tag.test.js" - ], - "test/unit/validator_stake_service.test.js": [ - "test/unit/validator_stake_service.test.js", - "test/unit/validator_stake_service.test/stake_validator.test.js", - "test/unit/validator_stake_service.test/unstake_validator.test.js" - ], - "test/unit/maria_db_store.test.js": [ - "test/unit/maria_db_store.test.js", - "test/unit/maria_db_store.test/01_get_module_count.test.js", - "test/unit/maria_db_store.test/02_registry_scoping_by_node_prefix.test.js" - ], - "test/unit/db_credential_drift.test.js": [ - "test/unit/db_credential_drift.test.js", - "test/unit/db_credential_drift.test/01_format_db_credential_drift_error.test.js", - "test/unit/db_credential_drift.test/02_read_container_env.test.js", - "test/unit/db_credential_drift.test/03_assert_no_db_credential_drift.test.js", - "test/unit/db_credential_drift.test/04_find_hub_db_credential_drift.test.js", - "test/unit/db_credential_drift.test/05_assert_no_hub_db_credential_drift.test.js", - "test/unit/db_credential_drift.test/06_is_db_credential_drift_error.test.js" - ], - "test/integration/hub_config_update.test.js": [ - "test/integration/hub_config_update.test.js", - "test/integration/hub_config_update.test/01_hub_update_retry_logic.test.js", - "test/integration/hub_config_update.test/02_update_hub_or_explorer_for_explorer.test.js", - "test/integration/hub_config_update.test/03_update_hub_network_connectivity.test.js", - "test/integration/hub_config_update.test/04_checkpoint_self_sync_opt_in_survives_the_shell_that_set_it.test.js" - ], - "test/integration/multi_module_orchestration.test.js": [ - "test/integration/multi_module_orchestration.test.js", - "test/integration/multi_module_orchestration.test/01_update_modules_orchestration.test.js", - "test/integration/multi_module_orchestration.test/02_uninstall_modules_orchestration.test.js", - "test/integration/multi_module_orchestration.test/03_filter_command_parameters_install_modules_end_to_end.test.js" - ], - "test/unit/github_downloader.test.js": [ - "test/unit/github_downloader.test.js", - "test/unit/github_downloader.test/01_verify_file_hash.test.js", - "test/unit/github_downloader.test/02_get_hash_for_arch.test.js", - "test/unit/github_downloader.test/03_has_hash_arch_specific.test.js", - "test/unit/github_downloader.test/04_get_releases_generic_error.test.js", - "test/unit/github_downloader.test/05_get_release_by_tag_error.test.js", - "test/unit/github_downloader.test/06_download_repo_version.test.js", - "test/unit/github_downloader.test/07_download_release_asset.test.js", - "test/unit/github_downloader.test/08_download_release_asset_2.test.js", - "test/unit/github_downloader.test/09_get_all_files_directory_traversal.test.js", - "test/unit/github_downloader.test/10_load_hashes_file_invalid_hash_in_object_entry.test.js", - "test/unit/github_downloader.test/11_download_release_asset_verify_hash_true.test.js", - "test/unit/github_downloader.test/12_verify_repository_hash.test.js" - ], - "test/unit/autoheal_service.test.js": [ - "test/unit/autoheal_service.test.js", - "test/unit/autoheal_service.test/01_health_state_guards.test.js", - "test/unit/autoheal_service.test/02_fresh_grace_after_stop.test.js", - "test/unit/autoheal_service.test/03_restart_cooldown.test.js", - "test/unit/autoheal_service.test/04_exponential_backoff.test.js", - "test/unit/autoheal_service.test/05_recovery_resets_backoff.test.js", - "test/unit/autoheal_service.test/06_starting_probation.test.js", - "test/unit/autoheal_service.test/07_stopped_container_backoff.test.js", - "test/unit/autoheal_service.test/08_recovery_past_probation.test.js", - "test/unit/autoheal_service.test/09_pass_inside_probation.test.js", - "test/unit/autoheal_service.test/10_backoff_calculations.test.js", - "test/unit/autoheal_service.test/11_dry_run_and_missing_timing.test.js", - "test/unit/autoheal_service.test/12_restart_and_inspection_errors.test.js", - "test/unit/autoheal_service.test/13_ring_buffer_restart.test.js", - "test/unit/autoheal_service.test/14_recovery_clears_onset.test.js", - "test/unit/autoheal_service.test/15_retained_probe_recovery.test.js", - "test/unit/autoheal_service.test/16_registry_state.test.js", - "test/unit/autoheal_service.test/17_grace_override.test.js", - "test/unit/autoheal_service.test/18_health_log_helpers.test.js", - "test/unit/autoheal_service.test/19_service_healthcheck_flags.test.js" - ], - "test/unit/credentials_service.test.js": [ - "test/unit/credentials_service.test.js", - "test/unit/credentials_service.test/01_save_credentials.test.js", - "test/unit/credentials_service.test/02_load_db_root_password_save_db_root_password.test.js", - "test/unit/credentials_service.test/03_has_external_db_config.test.js", - "test/unit/credentials_service.test/04_load_external_db_config.test.js", - "test/unit/credentials_service.test/05_save_external_db_config.test.js", - "test/unit/credentials_service.test/06_xchain_node_db.test.js" - ], - "test/security/security.test.js": [ - "test/security/security.test.js", - "test/security/security.test/01_container_id_validation.test.js", - "test/security/security.test/02_node_prefix_validation.test.js", - "test/security/security.test/03_branch_name_validation.test.js", - "test/security/security.test/04_config_path_traversal_prevention.test.js", - "test/security/security.test/05_database_command_safety.test.js", - "test/security/security.test/06_github_downloader_archive_extraction_safety.test.js", - "test/security/security.test/07_helpers_js_decompress_tar_gz_safety.test.js", - "test/security/security.test/08_string_to_docker_container_file_safety.test.js", - "test/security/security.test/09_bootstrap_directory_permissions.test.js", - "test/security/security.test/10_no_remaining_exec_calls_in_source_files.test.js" - ], - "test/unit/docker_service.test.js": [ - "test/unit/docker_service.test.js", - "test/unit/docker_service.test/01_check_containerd_data_root_relocation.test.js", - "test/unit/docker_service.test/02_stop_container.test.js", - "test/unit/docker_service.test/03_restart_container.test.js", - "test/unit/docker_service.test/04_remove_container.test.js", - "test/unit/docker_service.test/05_get_status_from_container.test.js", - "test/unit/docker_service.test/06_get_docker_network_inspect.test.js", - "test/unit/docker_service.test/07_shell_container.test.js", - "test/unit/docker_service.test/08_get_docker_container_file_data.test.js", - "test/unit/docker_service.test/09_start_docker_monitor.test.js", - "test/unit/docker_service.test/10_get_published_host_ports.test.js", - "test/unit/docker_service.test/11_string_to_docker_container_file.test.js", - "test/unit/docker_service.test/12_restart_container_error_branches.test.js", - "test/unit/docker_service.test/13_exec_container_error_branch.test.js", - "test/unit/docker_service.test/14_get_docker_container_file_data_error_branch.test.js", - "test/unit/docker_service.test/15_get_docker_container_file_cat_error_branch.test.js", - "test/unit/docker_service.test/16_create_docker_network_network_create_failure.test.js", - "test/unit/docker_service.test/17_get_docker_network_inspect_error_branch.test.js", - "test/unit/docker_service.test/18_stop_container_error_branches.test.js", - "test/unit/docker_service.test/19_log_container_tty_keypress_branches.test.js", - "test/unit/docker_service.test/20_start_docker_monitor_with_containers.test.js" - ], - "test/unit/module_service.test.js": [ - "test/unit/module_service.test.js", - "test/unit/module_service.test/01_buildandup.test.js", - "test/unit/module_service.test/02_buildandup_hub_credential_ordering.test.js", - "test/unit/module_service.test/03_buildandup_healthcheck_args.test.js", - "test/unit/module_service.test/04_installmodule_db_module.test.js", - "test/unit/module_service.test/05_buildandup_module_specific_port_volume_branches.test.js", - "test/unit/module_service.test/06_buildandup_module_specific_port_volume_branches.test.js", - "test/unit/module_service.test/07_installmodule_node_module.test.js", - "test/unit/module_service.test/08_installmodule_node_checkremotenodeversion_called_when_coin_not_in_remoteversions.test.js", - "test/unit/module_service.test/09_clonegit_rewrite_over_an_existing_checkout.test.js" - ], - "test/unit/precheck.test.js": [ - "test/unit/precheck.test.js", - "test/unit/precheck.test/01_a_hub_that_is_not_answering_does_not_wedge_its_own_repair.test.js", - "test/unit/precheck.test/02_the_hub_is_staged_at_the_ref_the_command_named.test.js", - "test/unit/precheck.test/03_the_cli_presents_the_sidecar_hub_api_key_to_the_hub.test.js" - ], - "test/unit/validator_service.test.js": [ - "test/unit/validator_service.test.js", - "test/unit/validator_service.test/01_init_validator.test.js", - "test/unit/validator_service.test/02_init_validator_options_and_files.test.js", - "test/unit/validator_service.test/03_init_validator_hub_api_key.test.js", - "test/unit/validator_service.test/04_get_validator_settings.test.js", - "test/unit/validator_service.test/05_get_validator_env.test.js", - "test/unit/validator_service.test/06_validator_mode_report.test.js", - "test/unit/validator_service.test/07_get_capability_config_host_path.test.js", - "test/unit/validator_service.test/08_get_capability_config_mount_dir.test.js", - "test/unit/validator_service.test/09_capability_config_layout_migration.test.js", - "test/unit/validator_service.test/10_container_mount_points.test.js", - "test/unit/validator_service.test/11_validator_dir.test.js", - "test/unit/validator_service.test/12_coin_wallets.test.js", - "test/unit/validator_service.test/13_coin_wallet_options_and_mounts.test.js", - "test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js", - "test/unit/validator_service.test/15_hub_env_wiring.test.js", - "test/unit/validator_service.test/16_rollcall_status_reporting.test.js", - "test/unit/validator_service.test/17_capability_drift_probe.test.js" - ], - "test/regression/regression.test.js": [ - "test/regression/regression.test.js", - "test/regression/regression.test/01_configuration_generation.test.js", - "test/regression/regression.test/02_docker_command_construction.test.js", - "test/regression/regression.test/03_security.test.js", - "test/regression/regression.test/04_service_lifecycle.test.js", - "test/regression/regression.test/05_state_data_integrity.test.js", - "test/regression/regression.test/06_e2e_lifecycle_workflows.test.js", - "test/regression/regression.test/07_config_parsing_boundaries.test.js", - "test/regression/regression.test/08_precheck_error_handling.test.js", - "test/regression/regression.test/09_utility_functions.test.js" - ], - "test/unit/module_operations.test.js": [ - "test/unit/module_operations.test.js", - "test/unit/module_operations.test/01_update_modules.test.js", - "test/unit/module_operations.test/02_recreate_modules.test.js", - "test/unit/module_operations.test/03_module_controls.test.js", - "test/unit/module_operations.test/04_module_shell_log_monitor.test.js", - "test/unit/module_operations.test/05_update_modules_branch_handling.test.js", - "test/unit/module_operations.test/06_update_modules_no_ref_target_resolution.test.js", - "test/unit/module_operations.test/07_update_modules_all_expansion.test.js", - "test/unit/module_operations.test/08_uninstall_and_e2e.test.js", - "test/unit/module_operations.test/09_reset_modules.test.js", - "test/unit/module_operations.test/10_reset_node_datadir_resolution.test.js", - "test/unit/module_operations.test/11_reset_external_db_reachability.test.js", - "test/unit/module_operations.test/12_reset_bootstrap_republish.test.js", - "test/unit/module_operations.test/13_reset_registry_read.test.js", - "test/unit/module_operations.test/14_reset_utxo_tracker_volume.test.js", - "test/unit/module_operations.test/15_reset_regtest_hub_purge.test.js", - "test/unit/module_operations.test/16_reset_coupled_decoder_indexer.test.js", - "test/unit/module_operations.test/17_module_error_paths.test.js", - "test/unit/module_operations.test/18_sync_shared_services_after_install.test.js", - "test/unit/module_operations.test/19_e2e_image_siblings.test.js" - ], - "test/unit/migration_precondition_service.test.js": [ - "test/unit/migration_precondition_service.test.js", - "test/unit/migration_precondition_service.test/01_migration_metadata.test.js", - "test/unit/migration_precondition_service.test/02_migration_inventory.test.js", - "test/unit/migration_precondition_service.test/03_applied_ledger.test.js", - "test/unit/migration_precondition_service.test/04_guard_outcomes.test.js" - ] - } + "splits": {} } diff --git a/bin/suite-title-map.js b/bin/suite-title-map.js index 3e5e3a8c..e1191caa 100644 --- a/bin/suite-title-map.js +++ b/bin/suite-title-map.js @@ -46,7 +46,7 @@ * node bin/suite-title-map.js --out write the map as JSON * node bin/suite-title-map.js --script test one script only * node bin/suite-title-map.js --compare diff the tree against a pin, - * exit 1 on any difference + * exit 1 on non-additive change * node bin/suite-title-map.js --compare --rename-map * the same, with the declared * renames (flat {old: new} @@ -70,6 +70,7 @@ const { loadSplits, compareWithSplits } = require('./suite_title_map/split_map.j const REPO_ROOT = path.resolve(__dirname, '..'); const MOCHA_BIN = path.join(REPO_ROOT, 'node_modules', '.bin', 'mocha'); +const GROWTH_KINDS = new Set(['file_added', 'title_added']); /** * A shell-ish split that keeps quoted globs whole. The scripts are plain @@ -268,6 +269,32 @@ function compare(pin, fresh, renames, only) { return differences; } +function isGrowth(difference) { + return GROWTH_KINDS.has(difference.kind); +} + +function compareAgainst(opts, map) { + const pin = JSON.parse(fs.readFileSync(opts.compare, 'utf8')); + const renames = opts.renameMap ? JSON.parse(fs.readFileSync(opts.renameMap, 'utf8')) : {}; + const splits = opts.splitMap ? loadSplits(opts.splitMap) : {}; + const differences = compareWithSplits({ pin, fresh: map, renames, splits, only: opts.script, compare }); + if (!differences.length) { + console.log(`suite identity holds against ${path.relative(REPO_ROOT, opts.compare)}` + + `${opts.renameMap ? ' through the declared rename map' : ''}` + + `${opts.splitMap ? ' through the declared split map' : ''}`); + return; + } + const blocking = differences.filter((difference) => !isGrowth(difference)); + const growthOnly = blocking.length === 0 ? ' (additive growth only)' : ''; + console.log(`${differences.length} difference(s) against ${path.relative(REPO_ROOT, opts.compare)}${growthOnly}:`); + for (const d of differences.slice(0, 200)) { + const growth = isGrowth(d) ? '[growth] ' : ''; + console.log(` ${growth}[${d.script}] ${d.kind} ${d.file || ''} ${d.title ? `:: ${d.title}` : d.detail || ''}`); + } + if (differences.length > 200) console.log(` ... and ${differences.length - 200} more`); + if (blocking.length) process.exitCode = 1; +} + function parseArgs(argv) { const opts = { json: false }; for (let i = 0; i < argv.length; i += 1) { @@ -291,22 +318,7 @@ function main() { const map = buildMap(opts.script); if (opts.compare) { - const pin = JSON.parse(fs.readFileSync(opts.compare, 'utf8')); - const renames = opts.renameMap ? JSON.parse(fs.readFileSync(opts.renameMap, 'utf8')) : {}; - const splits = opts.splitMap ? loadSplits(opts.splitMap) : {}; - const differences = compareWithSplits({ pin, fresh: map, renames, splits, only: opts.script, compare }); - if (!differences.length) { - console.log(`suite identity holds against ${path.relative(REPO_ROOT, opts.compare)}` - + `${opts.renameMap ? ' through the declared rename map' : ''}` - + `${opts.splitMap ? ' through the declared split map' : ''}`); - return; - } - console.log(`${differences.length} difference(s) against ${path.relative(REPO_ROOT, opts.compare)}:`); - for (const d of differences.slice(0, 200)) { - console.log(` [${d.script}] ${d.kind} ${d.file || ''} ${d.title ? `:: ${d.title}` : d.detail || ''}`); - } - if (differences.length > 200) console.log(` ... and ${differences.length - 200} more`); - process.exitCode = 1; + compareAgainst(opts, map); return; } diff --git a/bin/test/suite_title_additive.test.js b/bin/test/suite_title_additive.test.js new file mode 100644 index 00000000..91c2746d --- /dev/null +++ b/bin/test/suite_title_additive.test.js @@ -0,0 +1,194 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************/ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const { buildMap, compare, expand } = require('../suite-title-map.js'); + +const ROOT = path.resolve(__dirname, '../..'); +const CLI = path.join(ROOT, 'bin', 'suite-title-map.js'); +const SCRIPT = 'test:boundary'; +const FILE = 'test/unit/example.test.js'; + +function mapOf(files, script = 'test') { + const titleSets = {}; + const refs = {}; + let index = 0; + for (const [file, titles] of Object.entries(files)) { + const key = `set${index += 1}`; + titleSets[key] = titles; + refs[file] = key; + } + return { titleSets, scripts: { [script]: { files: refs } } }; +} + +function cloneFiles(files) { + return Object.fromEntries(Object.entries(files).map(([file, titles]) => [file, titles.slice()])); +} + +function runCli(dir, name, files, extraArgs = []) { + const pin = path.join(dir, `${name}.json`); + fs.writeFileSync(pin, JSON.stringify(mapOf(files, SCRIPT))); + return spawnSync(process.execPath, [CLI, '--compare', pin, '--script', SCRIPT, ...extraArgs], { + cwd: ROOT, + encoding: 'utf8', + }); +} + +function writeSplit(dir, name, old, parts, pinBefore) { + const split = path.join(dir, `${name}.json`); + const record = { splits: { [old]: parts } }; + if (pinBefore) record.pin_before_sha256 = pinBefore; + fs.writeFileSync(split, JSON.stringify(record)); + return split; +} + +describe('suite title comparison classifies additive and subtractive changes', () => { + it('reports an added file', () => { + const diffs = compare(mapOf({}), mapOf({ [FILE]: ['one'] }), {}, undefined); + assert.deepStrictEqual(diffs.map((diff) => diff.kind), ['file_added']); + }); + + it('reports an added title', () => { + const diffs = compare(mapOf({ [FILE]: ['one'] }), mapOf({ [FILE]: ['one', 'two'] }), {}, undefined); + assert.deepStrictEqual(diffs.map((diff) => diff.kind), ['title_added']); + }); + + it('reports a dropped file', () => { + const diffs = compare(mapOf({ [FILE]: ['one'] }), mapOf({}), {}, undefined); + assert.deepStrictEqual(diffs.map((diff) => diff.kind), ['file_dropped']); + }); + + it('reports a dropped title', () => { + const diffs = compare(mapOf({ [FILE]: ['one', 'two'] }), mapOf({ [FILE]: ['one'] }), {}, undefined); + assert.deepStrictEqual(diffs.map((diff) => diff.kind), ['title_dropped']); + }); +}); + +describe('suite title comparison CLI keeps structural changes blocking', () => { + let dir; + let current; + + before(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'suite-title-blocking-')); + current = expand(buildMap(SCRIPT), SCRIPT); + }); + + after(() => fs.rmSync(dir, { recursive: true, force: true })); + + it('rejects script and split record differences', () => { + const scriptPin = path.join(dir, 'script.json'); + fs.writeFileSync(scriptPin, JSON.stringify(mapOf(current))); + const scriptResult = spawnSync(process.execPath, [CLI, '--compare', scriptPin, '--script', SCRIPT], { + cwd: ROOT, encoding: 'utf8', + }); + assert.strictEqual(scriptResult.status, 1, scriptResult.stderr); + assert.match(scriptResult.stdout, /script added/); + + const split = path.join(dir, 'invalid-split.json'); + fs.writeFileSync(split, JSON.stringify({ splits: { [FILE]: ['one-part'] } })); + const splitResult = runCli(dir, 'split-record', current, ['--split-map', split]); + assert.strictEqual(splitResult.status, 1, splitResult.stderr); + assert.match(splitResult.stdout, /split_record/); + }); + + it('rejects a pending split that collides with a pinned file', () => { + const pin = cloneFiles(current); + const [old, collision, newPart] = Object.keys(pin); + delete pin[newPart]; + const split = writeSplit(dir, 'partial-split', old, [old, collision, newPart]); + const result = runCli(dir, 'partial-split-pin', pin, ['--split-map', split]); + assert.strictEqual(result.status, 1, result.stderr); + assert.match(result.stdout, /split_part_collides/); + }); +}); + +describe('suite title comparison CLI always grades split records', () => { + let dir; + let current; + + before(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'suite-title-splits-')); + current = expand(buildMap(SCRIPT), SCRIPT); + }); + + after(() => fs.rmSync(dir, { recursive: true, force: true })); + + it('rejects an absorbed split record', () => { + const [old, part] = Object.keys(current); + const split = writeSplit(dir, 'absorbed-split', old, [old, part]); + const result = runCli(dir, 'absorbed-split-pin', current, ['--split-map', split]); + assert.strictEqual(result.status, 1, result.stderr); + assert.match(result.stdout, /split_part_collides/); + }); + + it('rejects a stale-digest split record', () => { + const [old, part] = Object.keys(current); + const split = writeSplit(dir, 'earlier-split', old, [old, part], '0'.repeat(64)); + const result = runCli(dir, 'retaken-pin', current, ['--split-map', split]); + assert.strictEqual(result.status, 1, result.stderr); + assert.match(result.stdout, /split_part_collides/); + }); +}); + +describe('suite title comparison CLI exit status', () => { + let dir; + let current; + + before(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'suite-title-additive-')); + current = expand(buildMap(SCRIPT), SCRIPT); + }); + + after(() => fs.rmSync(dir, { recursive: true, force: true })); + + it('accepts and tags added files and titles as growth', () => { + const pin = cloneFiles(current); + const [addedFile, addedTitleFile] = Object.keys(pin); + delete pin[addedFile]; + pin[addedTitleFile] = pin[addedTitleFile].slice(0, -1); + const result = runCli(dir, 'growth', pin); + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stdout, /additive growth only/); + assert.match(result.stdout, /\[growth\].*file_added/); + assert.match(result.stdout, /\[growth\].*title_added/); + }); + + it('rejects dropped files and titles', () => { + const pin = cloneFiles(current); + const [titleFile] = Object.keys(pin); + pin[titleFile].push('suite title that was dropped'); + pin['test/boundary/dropped.test.js'] = ['dropped file title']; + const result = runCli(dir, 'drops', pin); + assert.strictEqual(result.status, 1, result.stderr); + assert.match(result.stdout, /file_dropped/); + assert.match(result.stdout, /title_dropped/); + }); + + it('keeps drops blocking when the same run also has growth', () => { + const pin = cloneFiles(current); + const [addedFile, titleFile] = Object.keys(pin); + delete pin[addedFile]; + pin[titleFile].push('suite title that was dropped'); + const result = runCli(dir, 'mixed', pin); + assert.strictEqual(result.status, 1, result.stderr); + assert.match(result.stdout, /\[growth\].*file_added/); + assert.match(result.stdout, /title_dropped/); + assert.doesNotMatch(result.stdout, /additive growth only/); + }); +}); diff --git a/package-lock.json b/package-lock.json index 33fe71f4..738b327f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-node", - "version": "0.22.7", + "version": "0.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-node", - "version": "0.22.7", + "version": "0.23.0", "license": "AGPL-3.0-or-later", "dependencies": { "@dankest-llc/xchain-sdk": "^0.18.0", diff --git a/package.json b/package.json index 3ae69bff..f71ad457 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "xchain-node", - "version": "0.22.7", + "version": "0.23.0", + "private": true, "description": "xchain-node allows users to install, configure and run XChain platform nodes.", "license": "AGPL-3.0-or-later", "repository": { @@ -24,7 +25,8 @@ "lint": "eslint src test bin scripts", "coverage": "c8 --all --reporter=text --reporter=html --include 'src/**/*.js' mocha 'test/unit/**/*.test.js' --timeout 2000 --recursive --exit", "coverage:check": "c8 --all --check-coverage --lines 93.5 --statements 93.5 --branches 88 --functions 92.5 --reporter=text-summary --include 'src/**/*.js' mocha 'test/unit/**/*.test.js' --timeout 2000 --recursive --exit", - "ci": "mocha 'test/unit/**/*.test.js' --timeout 2000 --recursive --exit && npm run ci:security && npm run ci:regression", + "ci": "mocha 'test/unit/**/*.test.js' --timeout 2000 --recursive --exit && npm run ci:guards && npm run ci:security && npm run ci:regression && npm run test:boundary && npm run test:integration -- --exit && npm run test:fuzz -- --exit && npm run test:chaos -- --exit && npm run test:e2e", + "ci:guards": "mocha --no-config 'bin/test/**/*.test.js' --timeout 30000 --exit", "ci:security": "mocha 'test/security/**/*.test.js' --timeout 10000 --recursive --exit", "ci:regression": "mocha 'test/regression/**/*.test.js' --timeout 30000 --recursive --exit", "ci:full": "bash bin/ci-full.sh", diff --git a/scripts/publish-bootstraps.sh b/scripts/publish-bootstraps.sh index 4333a99f..ac87919a 100755 --- a/scripts/publish-bootstraps.sh +++ b/scripts/publish-bootstraps.sh @@ -53,6 +53,8 @@ # deferred to a --with-trackers run by default (their create means # downtime); --force-due-trackers overrides that, --no-forced-due disables # the whole mechanism. +# - Skips the whole run (exit 0, SKIP line) while $ROLL_HOLD_FILE exists, so a +# scheduled run cannot take the xchain-node command lock during a roll. # - flock guard so overlapping cron runs cannot collide. # # ┌─ DOWNTIME WARNING ──────────────────────────────────────────────────────┐ @@ -104,6 +106,7 @@ SYNC_HOST="${SYNC_HOST:-user@your-sync-host}" SYNC_DIR="${SYNC_DIR:-/misc/backups/bootstraps}" KEEP="${KEEP:-2}" # archives to retain per combo (local + remote) LOCK_FILE="${LOCK_FILE:-/tmp/publish-bootstraps.lock}" +ROLL_HOLD_FILE="${ROLL_HOLD_FILE:-$HOME/.xchain-roll-hold}" # exists while a roll is in progress LOCK_WAIT_MIN="${LOCK_WAIT_MIN:-30}" # minutes to wait out a concurrent xchain-node command (0 = refuse at once) LOCK_POLL_SEC="${LOCK_POLL_SEC:-30}" # seconds between those attempts TRACKER_SVC="xchain-utxo-tracker" @@ -130,7 +133,7 @@ while [ $# -gt 0 ]; do --force-due-trackers) FORCE_DUE_TRACKERS=1 ;; --dry-run) DRY_RUN=1 ;; --keep) KEEP="$2"; shift ;; - -h|--help) sed -n '2,94p' "$0"; exit 0 ;; + -h|--help) sed -n '2,96p' "$0"; exit 0 ;; -*) echo "unknown flag: $1" >&2; exit 2 ;; *) COMBOS+=("$1") ;; esac @@ -204,6 +207,13 @@ fi PRUNE_EOF ) +# A roll holds the xchain-node command lock for long stretches; a scheduled run +# that started inside one would only burn its lock-wait budget and then fail. +if [ -e "$ROLL_HOLD_FILE" ]; then + log "SKIP: roll in progress ($ROLL_HOLD_FILE exists); nothing created or published." + exit 0 +fi + # ── Preconditions ───────────────────────────────────────────────────────── command -v "$XCHAIN_NODE_BIN" >/dev/null || die "xchain-node CLI not found ($XCHAIN_NODE_BIN)" if [ ! -f "$SIGNING_KEY" ]; then @@ -385,8 +395,18 @@ for c in "${SELECTED[@]}"; do log " LOCKED: $c - another xchain-node command still held the command lock after ${LOCK_WAIT_MIN}m. Nothing published; the previous archive stays newest." SUMMARY+=("$c: LOCKED") elif grep -q 'Refusing to create a bootstrap' "$create_log"; then - log " REFUSED: $c source is not known-good (reasons above). Nothing published; the previous archive stays newest." - SUMMARY+=("$c: SOURCE-UNHEALTHY") + refusal_reason="$(awk ' + seen && sub(/^[[:space:]]*-[[:space:]]*/, "") { + reasons = reasons (reasons ? "; " : "") $0 + next + } + seen && reasons { exit } + /Refusing to create a bootstrap/ { seen = 1 } + END { print reasons } + ' "$create_log")" + [ -n "$refusal_reason" ] || refusal_reason="reason unavailable; inspect create output above" + log " REFUSED: $c source is not known-good ($refusal_reason). Nothing published; the previous archive stays newest." + SUMMARY+=("$c: SOURCE-UNHEALTHY ($refusal_reason)") else log " create FAILED for $c" SUMMARY+=("$c: CREATE-FAIL") diff --git a/src/cli.js b/src/cli.js index 0900632a..7740cb51 100644 --- a/src/cli.js +++ b/src/cli.js @@ -165,8 +165,8 @@ async function maybeSelfUpdateBeforeUpdate(args, deps = {}) { } // Serialized like every mutator, so two concurrent updates cannot both - // move the checkout. The lock is handed back right before the re-exec, and the - // child waits a bounded time for a mutator that takes it in between (lockSettingsFor). + // move the checkout. The parent releases before spawning; the re-exec child + // takes the normal command lock and refuses if another mutator gets there first. const lock = deps.acquireCommandLock || acquireCommandLock const release = lock({ command: 'update (self-update)', waitMs: dispatchSettings(config).MUTATING_LOCK_WAIT_MS }) let outcome @@ -174,7 +174,7 @@ async function maybeSelfUpdateBeforeUpdate(args, deps = {}) { outcome = await selfUpdate.selfUpdateAndReexec({ tag, childArgs: selfUpdate.explicitUpdateArgs(resolved, tag), - deps: { ...(deps.selfUpdateDeps || {}), beforeSpawn: release } + deps: { ...(deps.selfUpdateDeps || {}), commandLock: release } }) } finally { release() diff --git a/src/cli/commands.js b/src/cli/commands.js index 2be4374e..6565491b 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -14,6 +14,160 @@ * XChain Node - CLI * Commander setup and command definitions ********************************************************************/ +const fs = require('fs') +const path = require('path') +const zlib = require('zlib') +const { execFile, spawn } = require('child_process') +const { promisify } = require('util') +const { pipeline } = require('stream/promises') +const config = require('../config') +const bootstrap = require('../services/bootstrap_service') +const { assertArchiveIdentity } = require('../services/bootstrap_service/restore_archive') +const { getWorkDir } = require('../services/bootstrap_service/workspace') +const database = require('../services/database_service') +const { getDefaultConfig, getModuleDatabaseName } = require('../services/config_service') +const { dockerMariadbArgs, mariadbEnv } = require('../utils/docker_mariadb') +const { getLogger } = require('../observability/logger') + +const execFileAsync = promisify(execFile) +const rehearsalDatabase = 'xchain_restore_rehearsal' +const logger = getLogger() +function mariaTool(context, tool, args, interactive = false) { + if (context.external) { + return { + file: tool, + args: ['-h', context.external.host, '-P', String(context.external.port), + '-u', context.external.root_user].concat(args), + env: mariadbEnv(context.external.root_password) + } + } + return { + file: 'docker', + args: dockerMariadbArgs(context.containerId, + [tool, '-u', 'root'].concat(args), { interactive }), + env: mariadbEnv(context.rootPassword) + } +} +async function runMariaTool(context, tool, args) { + const command = mariaTool(context, tool, args) + return execFileAsync(command.file, command.args, { env: command.env }) +} +async function rehearsalContext(coin, network) { + if (config.EXTERNAL_DB) return { external: await database.getExternalDbConfig() } + const containerId = await database.getDatabaseContainerId() + if (!containerId) throw new Error('MariaDB container not found') + return { containerId, rootPassword: await database.askMariadbRootPassword(coin, network) } +} +async function rehearsalArchive(coin, network, fileName) { + const module = config.XChainService.XCHAIN_INDEXER + const files = await bootstrap.getBootstrapFilesList(coin, network, module) + const selected = fileName || files[0] + if (!selected) throw new Error(`No bootstrap archives found for ${coin}/${network} ${module}`) + if (!files.includes(selected)) throw new Error(`Bootstrap '${selected}' not found`) + const moduleConfig = await getDefaultConfig(module, coin, network) + const archivePath = path.join(moduleConfig.INDEXER_BOOTSTRAP_VOLUME, selected) + await bootstrap.checkBootstrapSignature(archivePath) + await assertArchiveIdentity(archivePath, { module, coin, network }) + const workDir = getWorkDir(coin, network, `${module}-rehearsal`) + const innerArchive = await bootstrap.ensureVerifiedInnerArchive( + archivePath, workDir, 'dump.sql.gz', 'dump.sha256' + ) + return { innerArchive, selected, workDir } +} +function showRowCount(stdout, table) { + const rows = String(stdout).split('\n') + .filter(line => line.startsWith('|')) + .map(line => line.split('|').slice(1, -1).map(value => value.trim())) + const rowColumn = rows[0] ? rows[0].indexOf('Rows') : -1 + const count = rowColumn >= 0 && rows[1] ? Number(rows[1][rowColumn]) : NaN + if (!Number.isSafeInteger(count) || count < 0) { + throw new Error(`Could not read ${table} count from mariadb-show`) + } + return count +} + +async function databaseCounts(context, databaseName) { + const count = async table => { + const { stdout } = await runMariaTool(context, 'mariadb-show', ['--count', databaseName, table]) + return showRowCount(stdout, table) + } + return { + rows: await count('blocks'), + tokens: await count('tokens'), + issuances: await count('issues') + } +} + +async function importRehearsal(context, innerArchive) { + const command = mariaTool(context, 'mariadb', [rehearsalDatabase], true) + const maria = spawn(command.file, command.args, + { stdio: ['pipe', 'inherit', 'inherit'], env: command.env }) + const closed = new Promise((resolve, reject) => { + maria.once('error', reject) + maria.once('close', code => code === 0 + ? resolve() + : reject(new Error(`MariaDB rehearsal restore exited with code ${code}`))) + }) + await Promise.all([ + pipeline(fs.createReadStream(innerArchive), zlib.createGunzip(), maria.stdin), + closed + ]) +} + +function countsMatch(source, restored) { + return source.rows === restored.rows && source.tokens === restored.tokens && + source.issuances === restored.issuances +} + +async function rehearseBootstrapRestore(coin, network, options) { + if (network !== 'mainnet') throw new Error('Bootstrap restore rehearsal requires mainnet') + const sourceName = getModuleDatabaseName(config.XChainService.XCHAIN_INDEXER, coin, network) + const archive = await rehearsalArchive(coin, network, options.file) + const context = await rehearsalContext(coin, network) + let created = false + try { + const source = await databaseCounts(context, sourceName) + await runMariaTool(context, 'mariadb-admin', ['create', rehearsalDatabase]) + created = true + logger.info(`Restoring ${archive.selected} into ${rehearsalDatabase}...`) + await importRehearsal(context, archive.innerArchive) + const restored = await databaseCounts(context, rehearsalDatabase) + logger.info(`source: rows=${source.rows} tokens=${source.tokens} issuances=${source.issuances}`) + logger.info(`${rehearsalDatabase}: rows=${restored.rows} tokens=${restored.tokens} issuances=${restored.issuances}`) + return countsMatch(source, restored) + } finally { + fs.rmSync(archive.workDir, { recursive: true, force: true }) + if (created) { + await runMariaTool(context, 'mariadb-admin', ['--force', 'drop', rehearsalDatabase]) + logger.info(`Removed disposable database ${rehearsalDatabase}`) + } + } +} + +function registerBootstrapRestoreRehearsal(program, deps) { + program.command('bootstrap-restore-rehearsal') + .description('Restore a mainnet indexer bootstrap into a disposable database and compare counts') + .argument('', '(bitcoin, litecoin, dogecoin)') + .argument('', '(mainnet)') + .option('--file ', 'restore this exact local archive instead of the newest one') + .action(async (chain, network, options) => { + let release = null + let exitCode = 1 + try { + const waitMs = parseInt(config.XCHAIN_NODE_MUTATING_LOCK_WAIT_MS || '0', 10) || 0 + release = deps.acquireCommandLock({ command: 'bootstrap-restore-rehearsal', waitMs }) + const matches = await rehearseBootstrapRestore(chain, network, options) + if (!matches) logger.error('Bootstrap restore rehearsal count mismatch') + exitCode = matches ? 0 : 1 + } catch (err) { + logger.error('Bootstrap restore rehearsal failed: ' + + deps.redactSecrets(err && err.message ? err.message : err)) + } finally { + if (release) release() + } + return process.exit(exitCode) + }) +} function registerInstall(program, deps) { const { filterCommandParameters, resolveArgs, installModules, syncSharedServicesAfterInstall, config } = deps program @@ -58,10 +212,10 @@ function registerUninstall(program, deps) { program .command('uninstall') .description('Uninstall XChain services') - .argument('', '(node, xchain-encoder, xchain-decoder, xchain-utxo-tracker, xchain-indexer, xchain-explorer, all)') + .argument('', '(node, xchain-encoder, xchain-decoder, xchain-utxo-tracker, xchain-indexer, xchain-explorer, xchain-hub, xchain-sync, all)') .argument('[chain]', '(bitcoin, litecoin, dogecoin, all)') .argument('[network]', '(mainnet, testnet, regtest, all)') - .option('--include-shared', 'Also uninstall shared services (database, xchain-hub, xchain-explorer, xchain-sync)') + .option('--include-shared', 'Also uninstall shared services (xchain-explorer, xchain-sync, xchain-hub) once no chain/network is still installed; the database is never removed and must be removed manually') .action(async (service, chain, network, options) => { const serviceList = filterCommandParameters(null, service, chain, network) // A module that failed to uninstall must not be printed and forgotten, @@ -69,7 +223,7 @@ function registerUninstall(program, deps) { // remaining modules are still attempted (uninstallModules finishes the // list first); only the exit status changes. try { - await uninstallModules(serviceList, options.includeShared) + await uninstallModules(serviceList, options.includeShared, { all: service === 'all' }) } catch (err) { console.error('uninstall failed: ' + redactSecrets(err && err.message ? err.message : err)) return process.exit(1) @@ -239,6 +393,7 @@ function registerToolCommands(program, deps) { registerExec(program, deps) registerClearReorgHalt(program, deps) registerShell(program, deps) + registerBootstrapRestoreRehearsal(program, deps) } -module.exports = { registerPrimaryCommands, registerToolCommands } +module.exports = { registerPrimaryCommands, registerToolCommands, showRowCount, countsMatch } diff --git a/src/coins/BTC.js b/src/coins/BTC.js index 31f7e010..b50f3d4a 100644 --- a/src/coins/BTC.js +++ b/src/coins/BTC.js @@ -301,7 +301,7 @@ module.exports = { GAS_PRICE: '0.00001', // XCHAIN per gas unit UNIFIED_EXPIRATION_FEE_FREE_DAYS: 90, - FEE_PAYMENT_MODE: 'xchain', // informational only; not read at runtime (see detectFeePaymentMode) + FEE_PAYMENT_MODE: 'xchain', // declared mode; the indexer classifies by coin (BTC xchain) and a registry test pins the two together FEE_TOLERANCE_MIN: '0.95', FEE_TOLERANCE_MAX: '1.10', ORACLE_MAX_PRICE_AGE_SECONDS: 1800, diff --git a/src/coins/DOGE.js b/src/coins/DOGE.js index 3cd041c6..787327f3 100644 --- a/src/coins/DOGE.js +++ b/src/coins/DOGE.js @@ -218,7 +218,7 @@ module.exports = { GAS_PRICE: '0.00001', UNIFIED_EXPIRATION_FEE_FREE_DAYS: 90, - FEE_PAYMENT_MODE: 'native', // DOGE: native-only; informational, not read at runtime + FEE_PAYMENT_MODE: 'native', // DOGE: native-only; declared mode, classified by coin in the indexer and pinned by a registry test FEE_TOLERANCE_MIN: '0.95', FEE_TOLERANCE_MAX: '1.10', ORACLE_MAX_PRICE_AGE_SECONDS: 1800, diff --git a/src/coins/LTC.js b/src/coins/LTC.js index 1dcbab46..2715fc6a 100644 --- a/src/coins/LTC.js +++ b/src/coins/LTC.js @@ -212,7 +212,7 @@ module.exports = { GAS_PRICE: '0.00001', UNIFIED_EXPIRATION_FEE_FREE_DAYS: 90, - FEE_PAYMENT_MODE: 'native', // LTC: native-only; informational, not read at runtime + FEE_PAYMENT_MODE: 'native', // LTC: native-only; declared mode, classified by coin in the indexer and pinned by a registry test FEE_TOLERANCE_MIN: '0.95', FEE_TOLERANCE_MAX: '1.10', ORACLE_MAX_PRICE_AGE_SECONDS: 1800, diff --git a/src/db/migrations.js b/src/db/migrations.js index f436c14c..41f484f4 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -14,19 +14,387 @@ * * XChain Node - a service's migration ledger * - * One read, against the `schema_migrations` table every XChain service keeps. - * It answers which migrations a database has already applied, which is what the - * precondition guard compares a new build's migration set against. + * One read against the `schema_migrations` table every XChain service keeps and, + * for indexers, the live schema and data facts represented by historical manual + * migrations. It answers which migrations a database has applied or already + * embodies without rewriting migration history. * ********************************************************************/ -/** - * Every applied migration name, one per output line. The database is - * back-quoted rather than passed as a literal because it is an identifier here, - * not a value, and the caller has already proved it safe. - */ +// Derive satisfied indexer rows without mutating the ledger; decoders keep their plain query. function appliedMigrationsSql(database, ledgerTable) { - return 'SELECT name FROM `' + database + '`.' + ledgerTable + const ledger = 'SELECT name FROM `' + database + '`.' + ledgerTable + if (!/_Indexer$/.test(database)) return ledger + return ledger + ' UNION ' + indexerSatisfiedMigrationsSql(database).join(' UNION ') +} +function exactIndexSql(database, table, index, columns, nonUnique) { + const members = columns.map((column, offset) => + '(seq_in_index = ' + (offset + 1) + " AND column_name = '" + column + "' AND sub_part IS NULL)" + ).join(' OR ') + return '(SELECT COUNT(1) = ' + columns.length + + ' AND COALESCE(SUM(CASE WHEN ' + members + ' THEN 1 ELSE 0 END), 0) = ' + columns.length + + ' FROM information_schema.statistics' + + " WHERE table_schema = '" + database + "'" + + " AND table_name = '" + table + "'" + + " AND index_name = '" + index + "'" + + " AND index_type = 'BTREE'" + + ' AND non_unique = ' + nonUnique + ')' +} +const CHARSET_TYPES = new Set(['char', 'varchar', 'text', 'mediumtext']) +const CHARSET_UTF8_ALIASES = ['utf8', 'utf8mb3'] +const COLLATION_UTF8_ALIASES = ['utf8_general_ci', 'utf8mb3_general_ci'] +function inSql(column, value) { + const values = Array.isArray(value) ? value : [value] + return column + ' IN (' + values.map(v => "'" + v + "'").join(', ') + ')' +} +function columnDefaultSql(value) { + if (value === 'CURRENT_TIMESTAMP') return "REPLACE(LOWER(column_default), '()', '') = 'current_timestamp'" + return "TRIM(BOTH '''' FROM column_default) = '" + value + "'" +} +function noDefaultSql(nullable) { return nullable ? "(column_default IS NULL OR column_default = 'NULL')" : 'column_default IS NULL' } + +// Match every column fact, including absent defaults and the exact EXTRA value. +function columnSql(database, table, column, dataType, opts = {}) { + const conditions = [ + "table_schema = '" + database + "'", + "table_name = '" + table + "'", + "column_name = '" + column + "'", + "data_type = '" + dataType + "'", + "is_nullable = '" + (opts.nullable ? 'YES' : 'NO') + "'", + opts.default !== undefined ? columnDefaultSql(opts.default) : noDefaultSql(opts.nullable), + "extra = '" + (opts.autoIncrement ? 'auto_increment' : '') + "'" + ] + if (opts.length !== undefined) conditions.push('character_maximum_length = ' + opts.length) + if (opts.unsigned === true) conditions.push("column_type LIKE '%unsigned%'") + if (opts.unsigned === false) conditions.push("column_type NOT LIKE '%unsigned%'") + if (opts.ordinal !== undefined) conditions.push('ordinal_position = ' + opts.ordinal) + const charset = opts.charset || (CHARSET_TYPES.has(dataType) ? CHARSET_UTF8_ALIASES : null) + const collation = opts.collation || (CHARSET_TYPES.has(dataType) ? COLLATION_UTF8_ALIASES : null) + if (charset) conditions.push(inSql('character_set_name', charset)) + if (collation) conditions.push(inSql('collation_name', collation)) + return '(SELECT COUNT(1) FROM information_schema.columns WHERE ' + conditions.join(' AND ') + ') = 1' +} +function tableShapeSql(database, table, engine, collation) { + return "(SELECT COUNT(1) FROM information_schema.tables" + + " WHERE table_schema = '" + database + "' AND table_name = '" + table + "'" + + " AND engine = '" + engine + "' AND " + inSql('table_collation', collation) + ') = 1' +} +function tableColumnsSql(database, table, columns) { + const perColumn = columns.map(([name, dataType, opts], index) => + columnSql(database, table, name, dataType, Object.assign({ ordinal: index + 1 }, opts)) + ).join(' AND ') + const total = "(SELECT COUNT(1) FROM information_schema.columns" + + " WHERE table_schema = '" + database + "' AND table_name = '" + table + + "') = " + columns.length + return perColumn + ' AND ' + total +} +function tableIndexesSql(database, table, indexes) { + const perIndex = indexes.map(([name, columns, nonUnique]) => exactIndexSql(database, table, name, columns, nonUnique)).join(' AND ') + const total = "(SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics" + + " WHERE table_schema = '" + database + "' AND table_name = '" + table + + "') = " + indexes.length + return perIndex + ' AND ' + total +} +function migrationFactSql(name, predicate) { + return "SELECT '" + name + "' AS name WHERE " + predicate +} +function stateKeyBinSatisfiedSql(database) { + const column = 'EXISTS (SELECT 1 FROM information_schema.columns child' + + ' JOIN information_schema.columns parent' + + ' ON parent.table_schema = child.table_schema' + + ' AND parent.table_name = child.table_name' + + " AND parent.column_name = 'state_key'" + + ' AND parent.ordinal_position + 1 = child.ordinal_position' + + " WHERE child.table_schema = '" + database + "'" + + " AND child.table_name = 'contract_state'" + + " AND child.column_name = 'state_key_bin'" + + " AND child.data_type = 'varchar'" + + ' AND child.character_maximum_length = 256' + + " AND child.character_set_name IN ('utf8', 'utf8mb3')" + + " AND child.collation_name IN ('utf8_bin', 'utf8mb3_bin')" + + " AND child.extra LIKE '%VIRTUAL GENERATED%'" + + " AND child.generation_expression = '`state_key`')" + const index = exactIndexSql(database, 'contract_state', 'idx_latest_bin', + ['contract_index', 'state_key_bin', 'id'], 1) + return column + ' AND ' + index +} +// Accept signed or unsigned cursor ids because the later width migration only changes signedness. +function mirrorIdAutoIncrementSatisfiedSql(database) { + return `(SELECT COUNT(1) FROM information_schema.columns WHERE table_schema = '${database}' AND column_name = 'id'` + + " AND data_type = 'bigint' AND extra = 'auto_increment' AND table_name IN ('price_snapshots', 'cross_chain_matches', 'capability_snapshots', 'state_checkpoints')) = 4" +} +function mirrorTwinWidthsSatisfiedSql(database) { + return '(SELECT COUNT(1) FROM information_schema.columns' + + " WHERE table_schema = '" + database + "'" + + " AND data_type = 'bigint'" + + " AND column_type LIKE '%unsigned%'" + + " AND is_nullable = 'NO'" + + " AND (table_name, column_name) IN (" + + "('cross_chain_calls', 'target_contract_index'),('cross_chain_calls', 'gas_limit')," + + "('cross_chain_calls', 'effective_time'),('cross_chain_matches', 'snapshot_block')," + + "('cross_chain_matches', 'a_action_index'),('cross_chain_matches', 'b_action_index')," + + "('cross_chain_matches', 'effective_time')," + + "('capability_snapshots', 'snapshot_block'))) = 8" +} +function tokenLockBackfillSatisfiedSql(database) { + return 'NOT EXISTS (SELECT 1 FROM `' + database + '`.tokens t' + + ' WHERE t.lock_mint_supply <> 1' + + ' AND EXISTS (SELECT 1 FROM `' + database + '`.issues i' + + ' INNER JOIN `' + database + '`.index_statuses s ON s.id = i.status_id' + + ' WHERE i.tick_id = t.tick_id' + + " AND s.status = 'valid' AND i.lock_mint_supply = '1'))" +} +function issueTransferSupplyBackfillSatisfiedSql(database) { + return 'NOT EXISTS (SELECT 1 FROM `' + database + '`.issues i' + + ' WHERE i.transfer_supply_id IS NULL AND i.tick_id IS NOT NULL' + + ' AND EXISTS (SELECT 1 FROM `' + database + '`.debits d' + + ' WHERE d.action_index = i.action_index AND d.tick_id = i.tick_id)' + + ' AND (SELECT COUNT(1) FROM `' + database + '`.credits c' + + ' WHERE c.action_index = i.action_index AND c.tick_id = i.tick_id' + + ' AND c.address_id IS NOT NULL' + + ' AND NOT EXISTS (SELECT 1 FROM `' + database + '`.debits d2' + + ' WHERE d2.action_index = i.action_index AND d2.tick_id = i.tick_id' + + ' AND d2.address_id = c.address_id)' + + ' AND NOT EXISTS (SELECT 1 FROM `' + database + '`.fees f' + + ' WHERE f.action_index = i.action_index AND f.destination_id = c.address_id)) = 1)' +} +function rawWireFieldsSatisfiedSql(database) { + return '(SELECT COUNT(1) FROM information_schema.columns' + + " WHERE table_schema = '" + database + "'" + + " AND character_set_name = 'utf8mb4'" + + " AND collation_name = 'utf8mb4_general_ci'" + + " AND is_nullable = 'NO' AND (" + + "((table_name, column_name) IN (('contracts', 'code'), ('deploy_chunks', 'code_part'))" + + " AND data_type = 'mediumtext') OR " + + "((table_name, column_name) IN (('deposits', 'amount'), ('withdrawals', 'amount')," + + "('stakes', 'amount'), ('unstakes', 'amount'), ('contract_stakes', 'amount')," + + "('contract_unstakes', 'amount'), ('reward_claims', 'amount'), ('gated_files', 'gate_ticker'))" + + " AND data_type = 'varchar' AND character_maximum_length = 250) OR " + + "(table_name = 'attests' AND column_name = 'provider_id'" + + " AND data_type = 'varchar' AND character_maximum_length = 32))) = 11" +} +const ADMIT_BLOCK_COLUMNS = ['btc', 'ltc', 'doge'].map(chain => + ['admit_block_' + chain, 'bigint', { unsigned: true, nullable: true }]) +const SIGNED_ROW_TAIL_COLUMNS = [ + ['finalizing_view', 'int', { unsigned: false, default: '0' }], ['validator_signatures', 'text', {}], + ['status', 'varchar', { length: 20, default: 'finalized' }], ['push_generation', 'bigint', { unsigned: false, default: '0' }], + ['btc_chain_id', 'char', { length: 64, nullable: true }], + ['created_at', 'timestamp', { nullable: true, default: 'CURRENT_TIMESTAMP' }] +] +// Every column and index in 2026-09-12-bridge-tables.sql's end state. +const BRIDGE_TABLE_SHAPES = { + bridge_transfers: { + admitBlockColumns: true, + columns: [ + ['id', 'bigint', { unsigned: true, autoIncrement: true }], ['transfer_id', 'char', { length: 64 }], + ['snapshot_block', 'bigint', { unsigned: true }], ['network', 'varchar', { length: 20 }], + ['src_chain', 'varchar', { length: 10 }], ['src_action_index', 'bigint', { unsigned: true }], + ['src_address', 'varchar', { length: 255 }], ['dest_chain', 'varchar', { length: 10 }], + ['dest_address', 'varchar', { length: 255 }], ['tick', 'varchar', { length: 250 }], + ['decimals', 'tinyint', { unsigned: true }], ['amount', 'varchar', { length: 250 }], + ['effective_time', 'bigint', { unsigned: true }], + ...SIGNED_ROW_TAIL_COLUMNS + ], + indexes: [ + ['PRIMARY', ['id'], 0], ['uq_transfer_id', ['transfer_id'], 0], + ['idx_snapshot_block', ['snapshot_block'], 1], ['idx_src_ref', ['src_chain', 'src_action_index'], 1], + ['idx_dest_chain', ['dest_chain'], 1], ['idx_effective', ['effective_time'], 1], + ['idx_status', ['status'], 1], ['idx_tick', ['tick'], 1] + ] + }, + bridge_settlements: { + columns: [ + ['action_index', 'bigint', { unsigned: true }], ['transfer_id', 'char', { length: 64 }], + ['kind', 'varchar', { length: 10, default: 'transfer' }], ['block_index', 'bigint', { unsigned: true }], + ['src_chain', 'varchar', { length: 10, nullable: true }], ['src_action_index', 'bigint', { unsigned: true, nullable: true }], + ['dest_chain', 'varchar', { length: 10, nullable: true }], ['dest_address', 'varchar', { length: 255, nullable: true }], + ['tick', 'varchar', { length: 250, nullable: true }] + ], + indexes: [ + ['uq_transfer_kind', ['transfer_id', 'kind'], 0], ['idx_action_index', ['action_index'], 1], + ['idx_block_index', ['block_index'], 1], + ['idx_src_ref', ['src_chain', 'src_action_index'], 1] + ] + }, + policy_snapshots: { + admitBlockColumns: true, + columns: [ + ['id', 'bigint', { unsigned: true, autoIncrement: true }], ['snapshot_id', 'char', { length: 64 }], + ['snapshot_block', 'bigint', { unsigned: true }], ['origin_chain', 'varchar', { length: 10 }], + ['tick', 'varchar', { length: 250 }], ['policy_seq', 'bigint', { unsigned: true }], + ['origin_block', 'bigint', { unsigned: true }], ['policy_hash', 'char', { length: 64 }], + ['allow_list', 'mediumtext', { nullable: true }], ['block_list', 'mediumtext', { nullable: true }], + ['sleeping', 'tinyint', { unsigned: false, default: '0' }], ['effective_time', 'bigint', { unsigned: true }], + ['network', 'varchar', { length: 20 }], + ...SIGNED_ROW_TAIL_COLUMNS + ], + indexes: [ + ['PRIMARY', ['id'], 0], ['uq_policy_seq', ['network', 'origin_chain', 'tick', 'policy_seq'], 0], + ['uq_snapshot_id', ['snapshot_id'], 0], ['idx_effective', ['effective_time'], 1], + ['idx_origin_tick', ['origin_chain', 'tick'], 1] + ] + }, + xbridges: { + columns: [ + ['action_index', 'bigint', { unsigned: true }], ['version', 'tinyint', { unsigned: true, nullable: true }], + ['tick_id', 'bigint', { unsigned: true, nullable: true }], ['dest_chain', 'varchar', { length: 10, nullable: true }], + ['dest_address_id', 'bigint', { unsigned: true, nullable: true }], + ['amount', 'varchar', { length: 250, nullable: true, charset: 'utf8mb4', collation: 'utf8mb4_general_ci' }], + ['decimals', 'tinyint', { unsigned: true, nullable: true }], ['min_depth', 'bigint', { unsigned: true, nullable: true }], + ['memo_id', 'bigint', { unsigned: true, nullable: true }], ['status_id', 'bigint', { unsigned: true, nullable: true }], + ['block_index', 'bigint', { unsigned: true }] + ], + indexes: [ + ['uq_action_index', ['action_index'], 0], ['idx_tick_id', ['tick_id'], 1], + ['idx_dest_address_id', ['dest_address_id'], 1], ['idx_status_id', ['status_id'], 1], + ['idx_block_index', ['block_index'], 1], + ['idx_version_block', ['version', 'block_index'], 1] + ] + } +} +function bridgeTableColumnsSql(database, table, { columns, admitBlockColumns }) { + const base = tableColumnsSql(database, table, columns) + if (!admitBlockColumns) return base + const tailStart = columns.length - SIGNED_ROW_TAIL_COLUMNS.length + const withAdmit = columns.slice(0, tailStart).concat(ADMIT_BLOCK_COLUMNS, columns.slice(tailStart)) + return '((' + base + ') OR (' + tableColumnsSql(database, table, withAdmit) + '))' +} +function bridgeTablesSatisfiedSql(database) { + return Object.entries(BRIDGE_TABLE_SHAPES).map(([table, shape]) => [ + bridgeTableColumnsSql(database, table, shape), + tableIndexesSql(database, table, shape.indexes), + tableShapeSql(database, table, 'InnoDB', COLLATION_UTF8_ALIASES) + ].join(' AND ')).join(' AND ') +} +// The indexer's own baseline test: the file is CREATE TABLE IF NOT EXISTS only, so with both +// tables present running it changes nothing and no shape check could be satisfied by applying it. +function listShareTablesSatisfiedSql(database) { + return "(SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = '" + database + "'" + + " AND table_name IN ('list_snapshots', 'list_share_mirrors')) = 2" +} +// Width alone, as the indexer baselines it: the file only widens oracle_prices.tick to 250. +function oraclePricesTickWidthSatisfiedSql(database) { + return "EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = '" + database + "'" + + " AND table_name = 'oracle_prices' AND column_name = 'tick' AND character_maximum_length >= 250)" +} +function pubkeyWidthSatisfiedSql(database) { return columnSql(database, 'pubkeys', 'pubkey', 'varchar', { length: 130, ordinal: 2 }) } + +function validatorRewardsDeriveBlockIndexSatisfiedSql(database) { + return [ + columnSql(database, 'validator_rewards', 'derive_block_index', 'bigint', { unsigned: true, nullable: true, ordinal: 9 }), + exactIndexSql(database, 'validator_rewards', 'derive_block_index', ['derive_block_index'], 1), + columnSql(database, 'anchor_reward_reconcile_log', 'reward_derive_block_index', 'bigint', { unsigned: true, nullable: true, ordinal: 10 }) + ].join(' AND ') +} + +function validatorRewardsRoundQualifierSatisfiedSql(database) { + return [ + columnSql(database, 'validator_rewards', 'round_qualifier', 'bigint', { unsigned: true, default: '0', ordinal: 6 }), + exactIndexSql(database, 'validator_rewards', 'reward_unique', + ['source_id', 'signing_pubkey_id', 'reward_type', 'round_reference', 'round_qualifier'], 0), + columnSql(database, 'anchor_reward_reconcile_log', 'round_qualifier', 'bigint', { unsigned: true, default: '0', ordinal: 5 }) + ].join(' AND ') +} + +function marketsNativeCoinSideSatisfiedSql(database) { + const db = '`' + database + '`.' + const pairKey = (a, b) => + 'LEAST(COALESCE(' + a + ', 0), COALESCE(' + b + ', 0)) AS lo, ' + + 'GREATEST(COALESCE(' + a + ', 0), COALESCE(' + b + ', 0)) AS hi' + const nativeSide = (table, alias) => + 'SELECT ' + alias + '.action_index * 2' + (table === 'order_matches' ? ' + 1' : '') + ' AS ord_key, ' + + 'COALESCE(' + alias + '.get_tick_id, 0) AS tick1_id, ' + + 'COALESCE(' + alias + '.give_tick_id, 0) AS tick2_id, ' + + alias + '.get_coin_id AS coin1_id, ' + alias + '.give_coin_id AS coin2_id, ' + + pairKey(alias + '.get_tick_id', alias + '.give_tick_id') + + ' FROM ' + db + table + ' ' + alias + + ' WHERE ' + alias + '.give_coin_id = ' + alias + '.get_coin_id' + + ' AND (' + alias + '.get_tick_id IS NULL OR ' + alias + '.give_tick_id IS NULL)' + + const markets = '(SELECT m.id, m.tick1_id, m.tick2_id, coin1_id, coin2_id' + + ' FROM (SELECT NULL AS coin1_id, NULL AS coin2_id) fallback' + + ' NATURAL RIGHT JOIN ' + db + 'markets m)' + + const columns = [ + columnSql(database, 'markets', 'coin1_id', 'bigint', { unsigned: true, default: '0', ordinal: 21 }), + columnSql(database, 'markets', 'coin2_id', 'bigint', { unsigned: true, default: '0', ordinal: 22 }) + ].join(' AND ') + + const noNullSide = 'NOT EXISTS (SELECT 1 FROM ' + db + 'markets' + + ' WHERE tick1_id IS NULL OR tick2_id IS NULL)' + + const noDuplicatePair = 'NOT EXISTS (SELECT 1 FROM ' + db + 'markets' + + ' GROUP BY LEAST(COALESCE(tick1_id, 0), COALESCE(tick2_id, 0)),' + + ' GREATEST(COALESCE(tick1_id, 0), COALESCE(tick2_id, 0))' + + ' HAVING COUNT(1) > 1)' + + const nativeRows = nativeSide('orders', 'o') + ' UNION ALL ' + nativeSide('order_matches', 'om') + const everyNativePairListed = 'NOT EXISTS (SELECT 1 FROM (' + + 'SELECT n.*, ROW_NUMBER() OVER (PARTITION BY n.lo, n.hi ORDER BY n.ord_key) AS pair_order' + + ' FROM (' + nativeRows + ') n) f WHERE f.pair_order = 1' + + ' AND NOT EXISTS (SELECT 1 FROM ' + markets + ' m' + + ' WHERE m.tick1_id = f.tick1_id AND m.tick2_id = f.tick2_id' + + ' AND m.coin1_id <=> f.coin1_id AND m.coin2_id <=> f.coin2_id))' + + const orderLabels = 'NOT EXISTS (SELECT 1 FROM ' + markets + ' m JOIN (' + + 'SELECT ' + pairKey('o.get_tick_id', 'o.give_tick_id') + + ', MIN(o.get_coin_id) AS coin1_id, MIN(o.give_coin_id) AS coin2_id' + + ' FROM ' + db + 'orders o WHERE o.give_coin_id = o.get_coin_id' + + ' GROUP BY LEAST(COALESCE(o.get_tick_id, 0), COALESCE(o.give_tick_id, 0)),' + + ' GREATEST(COALESCE(o.get_tick_id, 0), COALESCE(o.give_tick_id, 0))) labels' + + ' ON (m.tick1_id = labels.lo AND m.tick2_id = labels.hi)' + + ' OR (m.tick1_id = labels.hi AND m.tick2_id = labels.lo)' + + ' WHERE NOT (m.coin1_id <=> labels.coin1_id)' + + ' OR NOT (m.coin2_id <=> labels.coin2_id))' + + return [columns, noNullSide, noDuplicatePair, everyNativePairListed, orderLabels].join(' AND ') +} + +function indexerSatisfiedMigrationsSql(database) { + const stateKey = stateKeyBinSatisfiedSql(database) + return [ + migrationFactSql('2026-05-30-balances-composite-index.sql', + exactIndexSql(database, 'balances', 'addr_tick', ['address_id', 'tick_id'], 0) + + " AND NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = '" + database + + "' AND table_name = 'balances' AND index_name = 'address_id')"), + migrationFactSql('2026-06-03-unique-full-column-index-addresses.sql', + exactIndexSql(database, 'index_addresses', 'address', ['address'], 0)), + migrationFactSql('2026-06-10-mirror-id-autoincrement-repair.sql', + mirrorIdAutoIncrementSatisfiedSql(database)), + migrationFactSql('2026-06-16-drop-orphaned-contract-balances.sql', + "NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = '" + database + + "' AND table_name = 'contract_balances')"), + migrationFactSql('2026-07-10-contract-state-bin-key-index.sql', stateKey), + migrationFactSql('2026-07-15-markets-dedup-unique-pair.sql', + exactIndexSql(database, 'markets', 'uq_markets_pair', ['tick1_id', 'tick2_id'], 0)), + migrationFactSql('2026-07-15-sweeps-drop-legacy-escrows-column.sql', + "EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = '" + database + + "' AND table_name = 'sweeps' AND table_type = 'BASE TABLE')" + + " AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = '" + database + + "' AND table_name = 'sweeps' AND column_name = 'escrows')"), + migrationFactSql('2026-07-16-mirror-twin-bigint-unsigned-align.sql', mirrorTwinWidthsSatisfiedSql(database)), + migrationFactSql('2026-07-16-reposition-state-key-bin.sql', stateKey), + migrationFactSql('2026-07-24-pubkeys-widen-uncompressed.sql', pubkeyWidthSatisfiedSql(database)), + migrationFactSql('2026-07-26-tokens-backfill-lock-mint-supply.sql', tokenLockBackfillSatisfiedSql(database)), + migrationFactSql('2026-07-29-state-checkpoints-uq-chain-seq.sql', + exactIndexSql(database, 'state_checkpoints', 'uq_chain_seq', + ['chain', 'network', 'checkpoint_seq'], 0) + + " AND NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = '" + database + + "' AND table_name = 'state_checkpoints' AND index_name = 'uq_chain_block_seq')"), + migrationFactSql('2026-08-12-validator-rewards-derive-block-index.sql', validatorRewardsDeriveBlockIndexSatisfiedSql(database)), + migrationFactSql('2026-08-19-utf8mb4-index-memos-memo.sql', + "EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = '" + database + + "' AND table_name = 'index_memos' AND column_name = 'memo' AND data_type = 'varchar'" + + " AND character_maximum_length = 250 AND character_set_name = 'utf8mb4'" + + " AND collation_name = 'utf8mb4_general_ci' AND is_nullable = 'NO')"), + migrationFactSql('2026-08-24-validator-rewards-round-qualifier.sql', validatorRewardsRoundQualifierSatisfiedSql(database)), + migrationFactSql('2026-09-02-issues-backfill-transfer-supply-id.sql', issueTransferSupplyBackfillSatisfiedSql(database)), + migrationFactSql('2026-09-02-utf8mb4-raw-wire-fields-not-null.sql', rawWireFieldsSatisfiedSql(database)), + migrationFactSql('2026-09-10-markets-native-coin-side.sql', marketsNativeCoinSideSatisfiedSql(database)), + migrationFactSql('2026-09-12-bridge-tables.sql', bridgeTablesSatisfiedSql(database)), + migrationFactSql('2026-09-22-oracle-prices-widen-tick.sql', oraclePricesTickWidthSatisfiedSql(database)), + migrationFactSql('2026-09-30-list-share-tables.sql', listShareTablesSatisfiedSql(database)) + ] } module.exports = { appliedMigrationsSql } diff --git a/src/operations/module_operations/repo_refs.js b/src/operations/module_operations/repo_refs.js index 9e5d1f13..43ddefaf 100644 --- a/src/operations/module_operations/repo_refs.js +++ b/src/operations/module_operations/repo_refs.js @@ -45,9 +45,10 @@ async function runE2ETest(coin, network, testName = null, grep = null, script = // precedence over testName; the e2e-test image carries these scripts. dockerCmdArgs = ['npm', 'run', script] } else if (testName) { - dockerCmdArgs = ['npx', 'mocha', '--timeout', '0', '--exit', - '--require', './test/initialCheck.test.js', - `test/actions/${testName}.test.js`] + // The suite renamed its preflight file; a release ref still carries the old name. + const shellCmd = 'f=./test/initial_check.test.js; [ -f "$f" ] || f=./test/initialCheck.test.js; ' + + 'exec npx mocha --timeout 0 --exit --require "$f" "$@"' + dockerCmdArgs = ['sh', '-c', shellCmd, 'sh', `test/actions/${testName}.test.js`] if (grep) dockerCmdArgs.push('--grep', grep) } const containerId = await installModule(XChainService.XCHAIN_E2E_TEST, coin, network, true, null, true, ref, dockerCmdArgs) diff --git a/src/operations/module_operations/reset_modules.js b/src/operations/module_operations/reset_modules.js index ef7d48b2..69f063e3 100644 --- a/src/operations/module_operations/reset_modules.js +++ b/src/operations/module_operations/reset_modules.js @@ -2,6 +2,7 @@ let askMariadbRootPassword, confirmDestructiveReset, failureReason, isNoSuchContainerError, isNoSuchVolumeError, resolveNodeDataPath, restartResetModules, restartStoppedModules, Coin, CoinTickerSymbol, EXTERNAL_DB, HUB_MODULE_NAME, Network, NODE_MODULE_NAME, XChainService, clearHubPriceIngestWatermark, config, dataDir, db, execFileAsync, fs, getContainerBindMounts, getDatabaseContainerId, getDockerContainerImageName, getUtxoTrackerVolumeName, manualHubCrossChainPurgeStatements, nodeService, path, pingExternalDatabase, purgeHubCrossChainRows, readline, recordReindex, reindexAffectedModules, resetDatabases, restartContainer, sleep, startContainer, statusChanged, stopContainer, stopModuleContainer, getContainerStopSettings let RESETTABLE_SERVICES +const { WIPE_IMAGE, ensureWipeImage, reportPartialWipe } = require('./reset_wipe_safety') function configure(dependencies) { ({ askMariadbRootPassword, confirmDestructiveReset, failureReason, isNoSuchContainerError, isNoSuchVolumeError, resolveNodeDataPath, restartResetModules, restartStoppedModules, Coin, CoinTickerSymbol, EXTERNAL_DB, HUB_MODULE_NAME, Network, NODE_MODULE_NAME, XChainService, clearHubPriceIngestWatermark, config, dataDir, db, execFileAsync, fs, getContainerBindMounts, getDatabaseContainerId, getDockerContainerImageName, getUtxoTrackerVolumeName, manualHubCrossChainPurgeStatements, nodeService, path, pingExternalDatabase, purgeHubCrossChainRows, readline, recordReindex, reindexAffectedModules, resetDatabases, restartContainer, sleep, startContainer, statusChanged, stopContainer, stopModuleContainer, getContainerStopSettings } = dependencies) @@ -187,6 +188,10 @@ async function checkResetDatabase(context) { } } + // The wipe image is checked here, ahead of the stop loop like the probes above, so its one untouched failure refuses while nothing is down. + const wipesData = (resetNode && (context.nodeDataPath || context.blocksHostPath || context.txindexHostPath)) || resetUtxoTracker + if (wipesData && !(await ensureWipeImage(execFileAsync, failureReason))) return false + const modulesToStop = [] if (resetNode) modulesToStop.push(NODE_MODULE_NAME) if (resetUtxoTracker) modulesToStop.push(XChainService.XCHAIN_UTXO_TRACKER) @@ -268,46 +273,39 @@ async function classifyResetVolume(context) { } async function wipeResetStores(context) { - const { abortBeforeAnyWipe, blocksHostPath, coin, network, nodeDataPath, resetDecoder, resetIndexer, resetNode, resetUtxoTracker, txindexHostPath, utxoVolumeName, utxoVolumePresent } = context - // Tracks whether anything irreversible has happened yet, so a later abort reports the stack's real state instead of promising an untouched one. - let nodeDataWiped = false + const { blocksHostPath, coin, network, nodeDataPath, resetDecoder, resetIndexer, resetNode, resetUtxoTracker, txindexHostPath, utxoVolumeName, utxoVolumePresent } = context + // The node paths already cleared, so a later abort reports the stack's real state instead of promising an untouched one. + const nodeCleared = [] + // A failed node wipe may have deleted part of its target, so it is reported as possibly partial with services left down, never sent to abortBeforeAnyWipe (see reportPartialWipe). + const clearNodePath = async (target, label) => { + console.log(`Clearing ${label} at ${target}...`) + try { + await execFileAsync('docker', ['run', '--rm', '-v', `${target}:/data`, WIPE_IMAGE, 'sh', '-c', 'find /data -mindepth 1 -delete']) + } catch (err) { + return reportPartialWipe(context, { target: `${label} at ${target}`, err, failureReason, nodeCleared, trackerUntouched: Boolean(resetUtxoTracker && utxoVolumePresent) }) + } + nodeCleared.push(target) + return true + } if (resetNode) { // No existsSync guard here any more: the path was resolved (and the reset refused, or the "not installed" skip announced) up top, so an unresolvable datadir can no longer read as a silent // no-op. - if (nodeDataPath) { - console.log(`Clearing node data at ${nodeDataPath}...`) - await execFileAsync('docker', ['run', '--rm', '-v', `${nodeDataPath}:/data`, 'alpine', 'sh', '-c', 'find /data -mindepth 1 -delete']) - nodeDataWiped = true - } + if (nodeDataPath && !(await clearNodePath(nodeDataPath, 'node data'))) return false // Relocated blocks/txindex (XCHAIN_NODE_BLOCKS_DIR) live outside the datadir, so wipe them here too or the daemon restarts over stale chain data (uuid:90630038). for (const relocated of [blocksHostPath, txindexHostPath]) { - if (relocated && fs.existsSync(relocated)) { - console.log(`Clearing relocated node data at ${relocated}...`) - await execFileAsync('docker', ['run', '--rm', '-v', `${relocated}:/data`, 'alpine', 'sh', '-c', 'find /data -mindepth 1 -delete']) - nodeDataWiped = true - } + if (relocated && fs.existsSync(relocated) && !(await clearNodePath(relocated, 'relocated node data'))) return false } } if (resetUtxoTracker && utxoVolumePresent) { try { console.log(`Clearing Docker volume ${utxoVolumeName}...`) - await execFileAsync('docker', ['run', '--rm', '-v', `${utxoVolumeName}:/data`, 'alpine', 'sh', '-c', 'find /data -mindepth 1 -delete']) + await execFileAsync('docker', ['run', '--rm', '-v', `${utxoVolumeName}:/data`, WIPE_IMAGE, 'sh', '-c', 'find /data -mindepth 1 -delete']) } catch (err) { - // The volume exists and the wipe failed, so the tracker still holds its old store. Falling through would drop the decoder/indexer databases around retained tracker data and still return - // true. - const reason = `clearing the Docker volume ${utxoVolumeName} failed (${failureReason(err)})` - if (nodeDataWiped) { - // Node data is already gone, so this reset is half done and cannot claim otherwise. Starting the services again would run a resynced chain under decoder and indexer stores that still - // describe the old one, so they stay down until the operator re-runs the same reset. - console.log(`Aborted: ${reason}.`) - console.log(' The node data for this stack WAS already cleared; the decoder/indexer') - console.log(' databases were NOT touched, and the stopped services are left down.') - console.log(' Fix the volume problem and re-run the same reset command.') - return false - } - return await abortBeforeAnyWipe(reason) + // The wipe STARTED, so the volume may be partly cleared whether or not node data went first. Falling through would drop the decoder/indexer databases around a damaged tracker store, + // and abortBeforeAnyWipe would restart the tracker over it while claiming nothing was touched, so it is reported as possibly partial with every stopped service left down. + return reportPartialWipe(context, { target: `the Docker volume ${utxoVolumeName}`, err, failureReason, nodeCleared }) } } diff --git a/src/operations/module_operations/reset_wipe_safety.js b/src/operations/module_operations/reset_wipe_safety.js new file mode 100644 index 00000000..a504e198 --- /dev/null +++ b/src/operations/module_operations/reset_wipe_safety.js @@ -0,0 +1,60 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * XChain Node - Reset wipe safety + * The wipe-image pre-flight and the report for a wipe that started and failed + ********************************************************************/ + +'use strict' + +// The image every reset wipe runs `find /data -mindepth 1 -delete` in. +const WIPE_IMAGE = 'alpine' + +// Make sure the wipe image is on this host BEFORE anything is stopped. A missing image (an offline host that cannot pull) is the one wipe failure that provably deletes nothing, so it is refused +// here, while the stack is whole and "No data was touched" is still true. Every failure after this point is a wipe that STARTED, which reportPartialWipe handles. Never throws. +async function ensureWipeImage(execFileAsync, failureReason) { + try { + await execFileAsync('docker', ['image', 'inspect', WIPE_IMAGE]) + return true + } catch { /* not cached on this host; try to pull it */ } + try { + console.log(`Pulling the ${WIPE_IMAGE} image used to clear the data...`) + await execFileAsync('docker', ['pull', WIPE_IMAGE]) + return true + } catch (err) { + console.log(`Aborted: cannot obtain the ${WIPE_IMAGE} image used to clear the data (${failureReason(err)}). No data was touched.`) + return false + } +} + +// Report a wipe that started and failed, then end the reset with every stopped service LEFT DOWN. A non-zero exit does not prove nothing was deleted: `find -delete` removes every entry it can +// before it exits non-zero on a per-entry error, and a daemon can die mid-run, so the target may be partly cleared. Restarting a service over it, or dropping the decoder/indexer databases around +// it, would run a store that no longer describes the chain, so nothing restarts and nothing else is wiped until the operator re-runs the same reset. Returns false for the caller to return. +function reportPartialWipe(context, { target, err, failureReason, nodeCleared = [], trackerUntouched = false }) { + const { resetDecoder, resetIndexer, stoppedModules = [], utxoVolumeName } = context + console.log(`Aborted: clearing ${target} failed (${failureReason(err)}).`) + console.log(` ${target} may be PARTLY cleared.`) + if (nodeCleared.length > 0) { + console.log(` The node data for this stack WAS already cleared: ${nodeCleared.join(', ')}.`) + } + // Name only the stores this reset was going to wipe and never reached, so the operator knows what still holds its old data. + const untouched = [] + if (trackerUntouched) untouched.push(`the Docker volume ${utxoVolumeName}`) + if (resetDecoder || resetIndexer) untouched.push('the decoder/indexer databases') + if (untouched.length > 0) console.log(` NOT touched: ${untouched.join(', ')}.`) + console.log(` The stopped services are left down: ${stoppedModules.length > 0 ? stoppedModules.join(', ') : 'none'}.`) + console.log(' Fix the problem and re-run the same reset command.') + return false +} + +module.exports = { WIPE_IMAGE, ensureWipeImage, reportPartialWipe } diff --git a/src/operations/module_operations/uninstall_modules.js b/src/operations/module_operations/uninstall_modules.js index 1d521278..eb3d66de 100644 --- a/src/operations/module_operations/uninstall_modules.js +++ b/src/operations/module_operations/uninstall_modules.js @@ -24,6 +24,12 @@ function configure(dependencies) { * per-coin pass (so a genuine full teardown still reaches them, the remaining set * being empty by then) and skips with a reason naming what is still installed. * + * filterCommandParameters leaves the hub and sync out of `all` (the same + * expansion serves install/start/stop), so an `all` teardown with + * `--include-shared` adds them here, as update does, removing the hub last. + * The database is never removed by this command: it is reported as kept. + * + * @param {object} [opts] `opts.all` marks a run that expanded from `all` * @returns {Promise<{uninstalled: Array, skipped: Array}>} on full success * @throws {Error} listing every module that failed, after all were attempted */ @@ -53,7 +59,48 @@ function createUninstallOne(outcome, failures) { } } -async function uninstallModules(servicesList, includeShared = false) { +// Rebuild the shared bucket as explorer (and anything else asked), then sync, then hub. +function includeSharedServicesForUninstall(servicesList) { + const shared = (servicesList[""] && servicesList[""][""]) || [] + const ordered = [...shared.filter(m => m !== HUB_MODULE_NAME && m !== SYNC_MODULE_NAME), SYNC_MODULE_NAME, HUB_MODULE_NAME] + return { ...servicesList, "": { "": ordered } } +} + +// Remove the deferred shared services unless a coin/network is still being served. +async function runSharedPass(deferredShared, outcome, uninstallOne) { + let remaining = [] + try { + remaining = (await db.getAllModuleContainers(null, null)).filter(r => r.coin) + } catch (err) { + // The registry is the only thing that can answer "is anything still being served". Unreadable, we refuse rather than guess: leaving a shared service up costs an operator one more command, tearing it down under a live coin costs every other coin its explorer/hub. + const why = (err && err.message) ? err.message : String(err) + for (const s of deferredShared) + outcome.skipped.push({ ...s, reason: `shared, module registry unreadable (${why})` }) + return null + } + const stillServed = [...new Set(remaining.map(r => `${r.coin} ${r.network}`))].sort() + for (const s of deferredShared) { + if (stillServed.length > 0) { + const reason = `shared, still serving ${stillServed.join(', ')}` + console.warn(`uninstall: keeping ${s.module}; it is ${reason}.`) + outcome.skipped.push({ ...s, reason }) + continue + } + await uninstallOne(s.module, s.coin, s.network) + } + return stillServed +} + +// Report the database as kept on a full teardown, since uninstallModule refuses it. +function noteDatabaseKept(outcome, stillServed) { + outcome.skipped.push({ module: DB_MODULE_NAME, coin: '', network: '', reason: 'shared, database must be removed manually' }) + if (stillServed && stillServed.length === 0) + console.warn(`uninstall: ${DB_MODULE_NAME} is not removed by this command; remove it manually.`) +} + +async function uninstallModules(servicesList, includeShared = false, opts = {}) { + const fullTeardown = includeShared && !!opts.all + if (fullTeardown) servicesList = includeSharedServicesForUninstall(servicesList) const sharedModules = [DB_MODULE_NAME, HUB_MODULE_NAME, EXPLORER_MODULE_NAME, SYNC_MODULE_NAME] const outcome = { uninstalled: [], skipped: [] } const failures = [] @@ -77,28 +124,8 @@ async function uninstallModules(servicesList, includeShared = false) { } // Shared pass. `remaining` is read AFTER the per-coin pass above, so a full teardown finds it empty and still removes them. A coin/network module is any registry row carrying a coin; shared services are registered under ''/''. - if (deferredShared.length > 0) { - let remaining = [] - try { - remaining = (await db.getAllModuleContainers(null, null)).filter(r => r.coin) - } catch (err) { - // The registry is the only thing that can answer "is anything still being served". Unreadable, we refuse rather than guess: leaving a shared service up costs an operator one more command, tearing it down under a live coin costs every other coin its explorer/hub. - const why = (err && err.message) ? err.message : String(err) - for (const s of deferredShared) - outcome.skipped.push({ ...s, reason: `shared, module registry unreadable (${why})` }) - deferredShared.length = 0 - } - const stillServed = [...new Set(remaining.map(r => `${r.coin} ${r.network}`))].sort() - for (const s of deferredShared) { - if (stillServed.length > 0) { - const reason = `shared, still serving ${stillServed.join(', ')}` - console.warn(`uninstall: keeping ${s.module}; it is ${reason}.`) - outcome.skipped.push({ ...s, reason }) - continue - } - await uninstallOne(s.module, s.coin, s.network) - } - } + const stillServed = deferredShared.length > 0 ? await runSharedPass(deferredShared, outcome, uninstallOne) : null + if (fullTeardown) noteDatabaseKept(outcome, stillServed) if (failures.length > 0) { const detail = failures.map(f => `${f.module} (${f.coin} ${f.network}): ${f.reason}`).join('; ') diff --git a/src/operations/module_operations/update_modules.js b/src/operations/module_operations/update_modules.js index 49ff850a..cdb23b0e 100644 --- a/src/operations/module_operations/update_modules.js +++ b/src/operations/module_operations/update_modules.js @@ -1,5 +1,7 @@ 'use strict' +const { resolveNodeVersionPin, normalizeNodeVersion } = require('../../services/node_service/node_version_pin.js') + let DB_MODULE_NAME, HUB_MODULE_NAME, NODE_MODULE_NAME, SEP, SYNC_MODULE_NAME, assertHubNotBehind, assertRequiredMigrationsApplied, config, db, getModuleBranch, installModule, installTargetService, releaseManifestService, stateModule, validatorService, versionService, withInstallTarget function configure(dependencies) { @@ -32,7 +34,7 @@ async function updateModules(servicesList, ref = null, opts = {}) { const { recordInstallTarget, resolveUpdateTarget } = installTargetService const list = opts.all ? includeSharedServicesForUpdate(servicesList) : servicesList - const runOpts = { skipCurrentNode: !!opts.all, quietNotInstalled: !!opts.all } + const runOpts = { skipCurrentNode: !!opts.all, quietNotInstalled: !!opts.all, continueOnRefusal: !!opts.all } assertNodeDataDirSetForUpdate(list) @@ -164,10 +166,10 @@ async function recordCredentialPartialOutcome(outcome, err, module, coin, networ outcome.failed.push({ module, coin: driftScope.coin, network: driftScope.network, reason }) } -async function updateModulesOnBranch(servicesList, branch = null, { skipCurrentNode = false, quietNotInstalled = false } = {}) { +async function updateModulesOnBranch(servicesList, branch = null, { skipCurrentNode = false, quietNotInstalled = false, continueOnRefusal = false } = {}) { const outcome = { updated: [], skipped: [], failed: [] } try { - await updateModulesInto(outcome, servicesList, branch, { skipCurrentNode, quietNotInstalled }) + await updateModulesInto(outcome, servicesList, branch, { skipCurrentNode, quietNotInstalled, continueOnRefusal }) } finally { // Under `all`, a service that is not installed is the ordinary case (a validator has a hub and nothing else), so the per-service warnings collapse into one line; the outcome still lists every one of them. const absent = outcome.skipped.filter(s => s.reason === 'not-installed') @@ -178,7 +180,7 @@ async function updateModulesOnBranch(servicesList, branch = null, { skipCurrentN return outcome } -async function updateModulesInto(outcome, servicesList, branch, { skipCurrentNode, quietNotInstalled }) { +async function updateModulesInto(outcome, servicesList, branch, { skipCurrentNode, quietNotInstalled, continueOnRefusal }) { for (const nextCoin in servicesList) { for (const nextNetwork in servicesList[nextCoin]) { for (const nextModule of servicesList[nextCoin][nextNetwork]) { @@ -222,8 +224,7 @@ async function updateModulesInto(outcome, servicesList, branch, { skipCurrentNod // remoteUpdate=true so installModule actually rebuilds the container. Without it, the `if (!containerNodeVersion || remoteUpdate)` guard short-circuits for any already-installed service and `update` becomes a silent no-op. Version-skew guard: a hub-dependent service whose new source declares `xchainRequiresHub` in its package.json is REFUSED when the installed hub is behind that version, before anything is torn down. Under a pinned update the guard must read the PINNED source's package.json, not the branch tip: it clones into a tmp tree to find `xchainRequiresHub`, and reading that from a different ref than the one about to be installed is how a skew guard blesses a version it never saw. const { resolveComponentRef } = releaseManifestService const pin = resolveComponentRef(nextModule, moduleBranch) - await assertHubNotBehind(nextModule, pin.ref) - await assertRequiredMigrationsApplied(nextModule, nextCoin, nextNetwork, pin.ref) + if (!await passesUpdateGuards(outcome, continueOnRefusal, nextModule, nextCoin, nextNetwork, pin.ref)) continue // moduleBranch MUST be threaded through: installModule re-clones the module on the remoteUpdate path (cloneGit with this `branch`), so a null branch here re-clones the default branch and clobbers the branch the operator asked for (the cause of `update ` silently deploying master). installModule does the clone, so no separate cloneGit is needed here. const rebuilt = await installModule(nextModule, nextCoin, nextNetwork, true, moduleContainerId, false, moduleBranch) recordInstallOutcome(outcome, rebuilt, nextModule, nextCoin, nextNetwork) @@ -237,6 +238,26 @@ async function updateModulesInto(outcome, servicesList, branch, { skipCurrentNod return outcome } +/** + * Run the pre-update guards for one service. A refusal stops a targeted + * update. Under `all` it is recorded against that service and the run moves + * on, so one refused service (the hub first among them) does not leave every + * later service unmoved. + */ +async function passesUpdateGuards(outcome, continueOnRefusal, module, coin, network, ref) { + try { + await assertHubNotBehind(module, ref) + await assertRequiredMigrationsApplied(module, coin, network, ref) + return true + } catch (err) { + if (!continueOnRefusal) throw err + const reason = err && err.message ? err.message : String(err) + console.warn(`update: ${module} (${coin} ${network}) was refused and left unchanged: ${reason}`) + outcome.failed.push({ module, coin, network, reason }) + return false + } +} + /** * Does the running coin node already carry the daemon version an update * would install? The container writes its version file at build time (a @@ -249,13 +270,15 @@ async function coinNodeIsCurrent(coin, network) { const { checkRemoteNodeVersion } = versionService const running = getLastStatus()?.[coin]?.[network]?.[NODE_MODULE_NAME]?.["container_version"] if (!running) return false + // An env pin names the daemon the update would install, so it wins over the latest release. + const envPin = resolveNodeVersionPin(coin) + if (envPin) return normalizeNodeVersion(running) === normalizeNodeVersion(envPin) if (!(NODE_MODULE_NAME + SEP + coin in getRemoteModuleVersions())) { await checkRemoteNodeVersion(coin) } const pinned = getRemoteModuleVersions()[NODE_MODULE_NAME + SEP + coin]?.["tag_name"] if (!pinned) return false - const strip = v => String(v).trim().replace(/^v/, '') - return strip(running) === strip(pinned) + return normalizeNodeVersion(running) === normalizeNodeVersion(pinned) } catch { return false } diff --git a/src/release-manifest.json b/src/release-manifest.json index 82a13349..a8272d02 100644 --- a/src/release-manifest.json +++ b/src/release-manifest.json @@ -1,9 +1,9 @@ { "_comment": [ - "Pinned component set for XChain Platform v0.22.7.", + "Pinned component set for XChain Platform v0.23.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-hub.", + "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 and xchain-sdk.", "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,44 +12,44 @@ "repo's origin/master, and is a GPG-signed annotated tag verifying against the", "platform release key (fingerprint 1DA7C4896F56EA22CF491EDF4361611A82F90B70)." ], - "platform_version": "0.22.7", - "released": "2026-10-06", + "platform_version": "0.23.0", + "released": "2026-10-12", "components": { "xchain-vm": { - "tag": "v0.21.3", - "commit": "be163a2215fb9885a70de149bfc7c973f2a728c6" + "tag": "v0.23.0", + "commit": "f8c0e67cd71d57b048fd6e0f3692cbef336d69f9" }, "xchain-decoder": { - "tag": "v0.21.0", - "commit": "cc2c0802f1674e5aee397b0b9c4c4fce5dc2e551" + "tag": "v0.23.0", + "commit": "4c2842a1e41912955b8ab7ade1fe41179916d740" }, "xchain-indexer": { - "tag": "v0.22.4", - "commit": "4ac2c37375d4b1e55d13ed081d723e1ef17537f2" + "tag": "v0.23.0", + "commit": "b51575ed86f9dc0bd1fcb95a871df723279a2486" }, "xchain-hub": { - "tag": "v0.22.7", - "commit": "52d1067d556f3d7590185872a1497796707359bb" + "tag": "v0.23.0", + "commit": "ef8d7522f536382d1e71873409ccd63dbf2b08a3" }, "xchain-sync": { - "tag": "v0.22.4", - "commit": "2196651b43dd82ba0771d911bfae44e7baad2f38" + "tag": "v0.23.0", + "commit": "ce9035cedc40d39781080542d32d95bb9eaad050" }, "xchain-encoder": { - "tag": "v0.21.0", - "commit": "3083725d7a9624bf13bdc5e59eac7fd79b35dd32" + "tag": "v0.23.0", + "commit": "57b0bd249865391970e3ee6a492f49b2e9e8fbbe" }, "xchain-utxo-tracker": { - "tag": "v0.21.0", - "commit": "8b2092686b6d5eb26117df5625a6899839d3caf4" + "tag": "v0.23.0", + "commit": "f6709d6f0fedb387b993ad2d48ddb3031f0e09cc" }, "xchain-explorer": { - "tag": "v0.22.4", - "commit": "17d08f7c6dc8852fcc1b1b3b9f4d4aecf2c6a2e3" + "tag": "v0.23.0", + "commit": "acd1d7bdaaaea551d322138eeb8288c6ac37f97d" }, "xchain-sdk": { - "tag": "v0.22.4", - "commit": "8822b5e46b307d141eb97525b386cc8b7a27d15e" + "tag": "v0.23.0", + "commit": "71590d829b7ebafda6c78a666e9e59cbb896a065" }, "xchain-e2e-test": { "tag": "v0.21.0", diff --git a/src/services/autoheal_service/restart_state.js b/src/services/autoheal_service/restart_state.js index 06090286..228dcf5d 100644 --- a/src/services/autoheal_service/restart_state.js +++ b/src/services/autoheal_service/restart_state.js @@ -31,7 +31,7 @@ const DEFAULT_COOLDOWN_MS = 10 * 60 * 1000 const DEFAULT_COOLDOWN_CEILING_MS = 6 * 60 * 60 * 1000 // A passing probe landing inside this window after an autoheal restart is that // restart's own artifact, not evidence the wedge cleared. Every service opted -// into autoheal in SERVICE_HEALTHCHECK (decoder, encoder, indexer) probes at a +// into autoheal in SERVICE_HEALTHCHECK (decoder, indexer) probes at a // 15s interval with 3 retries behind a 60s start period, so Docker can // legitimately report `starting` or a first fresh pass for ~105s after a // restart; 150s leaves margin. An operator who widens a service's start period diff --git a/src/services/bootstrap_health_gate.js b/src/services/bootstrap_health_gate.js index c678245e..f1df3bf7 100644 --- a/src/services/bootstrap_health_gate.js +++ b/src/services/bootstrap_health_gate.js @@ -98,8 +98,10 @@ class BootstrapSourceUnhealthyError extends Error { `Refusing to create a bootstrap from ${label}: the source is not known-good.\n` + reasons.map(r => ` - ${r}`).join('\n') + '\n' + 'A bootstrap archive becomes the newest (and therefore default) recovery source for the whole ' + - 'fleet, so publishing an unverified one is worse than publishing nothing. Fix the service (for a ' + - 'durable halt marker that means a full resync from a known-good snapshot), then re-run. To snapshot ' + + 'fleet, so publishing an unverified one is worse than publishing nothing. Fix the service, then ' + + 're-run: a durable halt marker takes the recovery its line above names (a decoder REORG_HALT ' + + 'also clears in place with `xchain-node clear-reorg-halt` once verified; any other marker means ' + + 'a full resync from a known-good snapshot). To snapshot ' + 'a known-bad database deliberately, set XCHAIN_NODE_BOOTSTRAP_SKIP_HEALTH_GATE=1.' ) this.name = 'BootstrapSourceUnhealthyError' @@ -308,8 +310,11 @@ async function haltMarkerReasons(coin, network, module, dbDeps, since) { reasons.push(`the paired decoder database ${markers.upstream.dbName} carries a durable REORG_HALT ` + "marker (events.code='REORG_HALT'), so this indexer is frozen behind a decoder that aborted " + 'mid-rollback. Its own health surface reports lag 0 only because that lag is measured against ' + - 'the frozen decoder height. Recovery is a full resync of the decoder and this indexer from a ' + - 'known-good snapshot.') + 'the frozen decoder height. Recovery happens on the decoder: once its rolled-back range is ' + + 're-parsed and its database is verified intact, `xchain-node clear-reorg-halt ' + + ' --reason "..."` clears the halt in place, and this publish can be re-run when the ' + + 'indexer reports healthy again; otherwise, a full resync of the decoder and this indexer from ' + + 'a known-good snapshot.') if (markers.upstream && markers.upstream.syncHalt > 0) reasons.push(`the paired decoder database ${markers.upstream.dbName} carries an uncleared ` + 'xchain-sync divergence halt (sync_halt with cleared_at IS NULL), so the rows this indexer ' + diff --git a/src/services/bootstrap_health_gate/halt_markers.js b/src/services/bootstrap_health_gate/halt_markers.js index 58efb946..c74112c7 100644 --- a/src/services/bootstrap_health_gate/halt_markers.js +++ b/src/services/bootstrap_health_gate/halt_markers.js @@ -23,8 +23,9 @@ ********************************************************************/ const { XChainService, EXTERNAL_DB } = require('../../config') -const { getModuleDatabaseName } = require('../config_service') +const { getDefaultConfig, getModuleDatabaseName } = require('../config_service') const { dockerMariadbArgs, mariadbEnv } = require('../../utils/docker_mariadb') +const { configuredDatabaseName } = require('../../utils/module_database_name') const { markerTablesSql, liveReorgHaltCountSql, liveSyncHaltCountSql, eventsWatermarkSql, syncHaltWatermarkSql, reorgHaltsSinceSql, syncHaltsSinceSql @@ -73,6 +74,12 @@ function assertDbName(name) { return String(name) } +// Probe the database the service reads: the configured name, else the derived default +// (one coin/network config file carries both the decoder and the indexer name). +function markerDatabaseName(module, cfg, coin, network) { + return configuredDatabaseName(module, cfg, getModuleDatabaseName(module, coin, network)) +} + // Run one SQL statement against the MariaDB holding coin/network's databases and // return its raw `-BN` output: over the native client in external-DB mode, // otherwise through `docker exec` into the database container. @@ -227,7 +234,8 @@ async function readRaisedInWindow(readCount, name, since, found, hasSyncHalt) { async function readHaltMarkers(coin, network, module, deps, since) { const run = sqlRunner(coin, network, deps) const readCount = countReader(run) - const dbName = assertDbName(getModuleDatabaseName(module, coin, network)) + const cfg = await getDefaultConfig(module, coin, network) + const dbName = assertDbName(markerDatabaseName(module, cfg, coin, network)) const markers = await probeDatabase(run, readCount, dbName, since && since.own) @@ -243,7 +251,7 @@ async function readHaltMarkers(coin, network, module, deps, since) { // absent database, an absent events table and an unreadable count alike, and every // one of those means we could not tell. if (module === XChainService.XCHAIN_INDEXER) { - const decoderDbName = assertDbName(getModuleDatabaseName(XChainService.XCHAIN_DECODER, coin, network)) + const decoderDbName = assertDbName(markerDatabaseName(XChainService.XCHAIN_DECODER, cfg, coin, network)) let upstream try { upstream = await probeDatabase(run, readCount, decoderDbName, since && since.upstream) diff --git a/src/services/bootstrap_node_tip_guard.js b/src/services/bootstrap_node_tip_guard.js index 4bd4b637..c85b5b89 100644 --- a/src/services/bootstrap_node_tip_guard.js +++ b/src/services/bootstrap_node_tip_guard.js @@ -27,15 +27,20 @@ * store and gives one of four verdicts: * * ok the node is at or past the archive; nothing to say - * behind-wait the node is below the archive and the service waits out a - * catching-up node: restore, and say what the wait means - * behind-refuse the node is below the archive and the service does NOT - * wait (an image older than that fix): refuse the - * restore, the service syncs forward from its start height - * as the node catches up, and the summary says how to take - * the restore later - * unknown the archive carries no height, or the node cannot be - * asked: restore as before, one line says why + * behind-wait the node is below the archive, in initial block download, + * and the service waits that out: restore, and say what the + * wait means + * behind-refuse the node is below the archive and the service would read + * its lower tip as a reorg, either because its image does + * not wait (older than that fix) or because the node is not + * in initial block download (the decoder and tracker wait + * only then): refuse the restore, the service syncs forward + * from its start height as the node catches up, and the + * summary says how to take the restore later + * unknown the archive carries no height, its bootstrap.json claims + * another module, coin or network, or the node cannot be + * asked: restore as before (the restore's own signature and + * identity checks still decide), one line says why * * Whether the service waits is read from the service itself: an image with * the fix publishes `node_catching_up` on its status surface (null when not @@ -54,6 +59,7 @@ const execFileAsync = promisify(execFile) const { XChainService, NODE_MODULE_NAME } = require('../config') const gate = require('./bootstrap_health_gate'); +const archiveMeta = require('./bootstrap_archive_meta'); const config = require('../config'); const { getLogger } = require('../observability/logger'); const logger = getLogger(); @@ -156,7 +162,17 @@ function evaluateNodeTipAgainstArchive({ archiveHeight, chainInfo, serviceWaits, const gap = archiveHeight - nodeHeight const ibd = chainInfo.initialblockdownload === true const position = `the coin node is at ${nodeHeight}${ibd ? ' (initial block download)' : ''}, ${gap} blocks below the archive's ${archiveHeight}` - if (serviceWaits) { + const later = `it now syncs forward from its start height as the node catches up. To take the restore later, wait for the node to pass ${archiveHeight} ` + + `and re-run install with XCHAIN_NODE_FORCE_BOOTSTRAP=1, or pass --no-bootstrap to install to stop it trying` + if (!serviceWaits) { + return { + verdict: VERDICT.BEHIND_REFUSE, archiveHeight, nodeHeight, gap, ibd, + detail: `${position}, and this ${module} image does not wait out a catching-up node (it would read the node's lower tip as a reorg and halt); ${later}` + } + } + // The decoder and tracker wait only while the node reports initial block + // download; the indexer follows the decoder, so the node's flag is not its concern. + if (ibd || module === XChainService.XCHAIN_INDEXER) { return { verdict: VERDICT.BEHIND_WAIT, archiveHeight, nodeHeight, gap, ibd, detail: `${position}; the ${module} waits until the node passes ${archiveHeight} and then continues, which \`xchain-node ps\` shows as WAITING FOR NODE` @@ -164,9 +180,7 @@ function evaluateNodeTipAgainstArchive({ archiveHeight, chainInfo, serviceWaits, } return { verdict: VERDICT.BEHIND_REFUSE, archiveHeight, nodeHeight, gap, ibd, - detail: `${position}, and this ${module} image does not wait out a catching-up node (it would read the node's lower tip as a reorg and halt); ` + - `it now syncs forward from its start height as the node catches up. To take the restore later, wait for the node to pass ${archiveHeight} ` + - `and re-run install with XCHAIN_NODE_FORCE_BOOTSTRAP=1, or pass --no-bootstrap to install to stop it trying` + detail: `${position}, and the node is not in initial block download, so the ${module} would read its lower tip as a rollback rather than wait; ${later}` } } @@ -202,20 +216,57 @@ async function probeServiceCapability(module, coin, network, { runner = defaultR return null } +// The archive's claimed end height, or a detail naming the target its claimed +// identity contradicts. Both come from the unsigned bootstrap.json, so a +// mismatch only ever stops the comparison; it never refuses or retires anything. +async function readArchiveClaim(archivePath, target, deps) { + const meta = deps.readBootstrapArchiveMeta || archiveMeta.readBootstrapArchiveMeta + let parsed = null + try { + parsed = await meta(archivePath) + } catch { /* no metadata is "unknown", handled by the caller */ } + let mismatch = null + try { + const identity = (deps.compareArchiveIdentity || archiveMeta.compareArchiveIdentity)(parsed, target) + if (identity && identity.status === 'mismatch') { + const fields = identity.mismatches.map((m) => `${m.field} ${m.archive}`).join(', ') + mismatch = `the archive's bootstrap.json declares ${fields} (unverified), not this ${target.module} ${target.coin}/${target.network} restore, ` + + `so its height is not compared with the coin node; the restore's signature and identity checks decide whether it is used` + } + } catch { /* an unreadable identity compares the height as before */ } + return { archiveHeight: parsed ? parsed.height : null, mismatch } +} + +// Whether the service waits out this node. Asked only when the answer can +// change the verdict: the indexer always passes, and a node out of initial +// block download is refused whatever the image can do. +async function readServiceWaits({ coin, network, module }, archiveHeight, chainInfo, deps) { + if (module === XChainService.XCHAIN_INDEXER || !Number.isInteger(archiveHeight) || !chainInfo) return true + if (chainInfo.blocks >= archiveHeight || chainInfo.initialblockdownload !== true) return true + const payload = await (deps.probeServiceCapability || probeServiceCapability)(module, coin, network, deps) + let version = null + if (!payload) { + try { + version = await (deps.getLocalModuleVersion || require('./version_service').getLocalModuleVersion)(module) + } catch { /* unknown version reads as "does not wait" */ } + } + return serviceWaitsOutCatchUp(module, { statusPayload: payload, version }) +} + // The whole assessment for one restore, never throwing: reads the archive -// height, asks the node, asks the service, compares, prints the verdict. +// height, asks the node, asks the service, compares, prints the verdict +// (all but a refusal, which the caller prints once the archive is verified). // Returns { verdict, refuse, detail, archiveHeight, nodeHeight, gap, ibd }. async function assessNodeTipForRestore({ coin, network, module, archivePath }, deps = {}) { if (guardSkipped()) { logger.info('WARNING: XCHAIN_NODE_SKIP_NODE_TIP_GUARD is set: the archive height is NOT compared with the coin node tip.') return { verdict: VERDICT.SKIPPED, refuse: false, detail: 'node tip guard skipped by XCHAIN_NODE_SKIP_NODE_TIP_GUARD' } } - const meta = deps.readBootstrapArchiveMeta || require('./bootstrap_archive_meta').readBootstrapArchiveMeta - let archiveHeight = null - try { - const parsed = await meta(archivePath) - archiveHeight = parsed ? parsed.height : null - } catch { /* no metadata is "unknown", handled below */ } + const { archiveHeight, mismatch } = await readArchiveClaim(archivePath, { module, coin, network }, deps) + if (mismatch) { + logger.info(`Note: ${mismatch}.`) + return { verdict: VERDICT.UNKNOWN, refuse: false, detail: mismatch } + } let chainInfo = null let nodeProblem = null @@ -227,18 +278,7 @@ async function assessNodeTipForRestore({ coin, network, module, archivePath }, d } } - let serviceWaits = true - if (Number.isInteger(archiveHeight) && chainInfo && chainInfo.blocks < archiveHeight && module !== XChainService.XCHAIN_INDEXER) { - const payload = await (deps.probeServiceCapability || probeServiceCapability)(module, coin, network, deps) - let version = null - if (!payload) { - try { - version = await (deps.getLocalModuleVersion || require('./version_service').getLocalModuleVersion)(module) - } catch { /* unknown version reads as "does not wait" */ } - } - serviceWaits = serviceWaitsOutCatchUp(module, { statusPayload: payload, version }) - } - + const serviceWaits = await readServiceWaits({ coin, network, module }, archiveHeight, chainInfo, deps) const result = evaluateNodeTipAgainstArchive({ archiveHeight, chainInfo, serviceWaits, module }) if (result.verdict === VERDICT.UNKNOWN && nodeProblem) result.detail = `${result.detail} (${nodeProblem})` result.refuse = result.verdict === VERDICT.BEHIND_REFUSE @@ -251,7 +291,7 @@ async function assessNodeTipForRestore({ coin, network, module, archivePath }, d logger.info(`WARNING: ${result.detail}.`) break case VERDICT.BEHIND_REFUSE: - logger.info(`REFUSING the ${module} bootstrap restore: ${result.detail}.`) + // Printed by the caller after the signature check: the archive height is only a claim here. break default: logger.info(`Note: ${result.detail}.`) diff --git a/src/services/bootstrap_service/ensure_restored.js b/src/services/bootstrap_service/ensure_restored.js index db6dc065..cdbd99c6 100644 --- a/src/services/bootstrap_service/ensure_restored.js +++ b/src/services/bootstrap_service/ensure_restored.js @@ -91,11 +91,12 @@ function reportBootstrapOutcomes() { } if (wipedDown.length > 0) { logger.info( - '\nThose services had their data directory wiped by a restore that then failed, so\n' + - 'their containers were deliberately left STOPPED rather than restarted over an\n' + - 'incomplete store that would report itself caught up. Re-run install with\n' + - 'XCHAIN_NODE_FORCE_BOOTSTRAP=1 to take the restore again, or clear the volume and\n' + - 'start the service to resync from block 0.\n' + '\nThose services had their data (the tracker\'s LevelDB volume, or the decoder\'s or\n' + + 'indexer\'s MariaDB database) wiped by a restore that then failed, so their containers\n' + + 'were deliberately left STOPPED rather than restarted over an incomplete store that\n' + + 'would resume as if it were whole. Re-run install with XCHAIN_NODE_FORCE_BOOTSTRAP=1\n' + + 'to take the restore again, or clear the volume or drop the database and start the\n' + + 'service to resync from block 0.\n' ) } if (failed.length > 0) { @@ -222,10 +223,11 @@ async function ensureBootstrapUtxoTracker(coin, network) { archivePath = path.join(bootstrapDir, fileName) const tip = await assessNodeTipBeforeRestore(coin, network, XChainService.XCHAIN_UTXO_TRACKER, archivePath) if (tip.refuse) { - // Refused before any restore was attempted, and the archive is - // signature-verified first, so retiring it destroys no evidence; - // see retireBootstrapArchive for why it goes anyway. + // The refusal rests on the archive's own bootstrap.json: verify signature and identity before + // announcing it or retiring the archive (see retireBootstrapArchive for why a verified one goes). + // A tampered archive throws into the catch, which keeps it and records the integrity failure. await verifyRefusedArchive(archivePath, { module: XChainService.XCHAIN_UTXO_TRACKER, coin, network }) + logger.info(`REFUSING the ${XChainService.XCHAIN_UTXO_TRACKER} bootstrap restore: ${tip.detail}.`) const bytes = await statBootstrapArchiveBytes(archivePath) const archive = retireBootstrapArchive(archivePath, bytes, 'restore refused, node behind; a re-run downloads a fresh copy anyway') recordBootstrapOutcome(XChainService.XCHAIN_UTXO_TRACKER, 'node-behind', tip.detail, archive) @@ -287,10 +289,11 @@ async function ensureBootstrapMariaDb(coin, network, module) { archivePath = path.join(bootstrapDir, fileName) const tip = await assessNodeTipBeforeRestore(coin, network, module, archivePath) if (tip.refuse) { - // Refused before any restore was attempted, and the archive is - // signature-verified first, so retiring it destroys no evidence; - // see retireBootstrapArchive for why it goes anyway. + // The refusal rests on the archive's own bootstrap.json: verify signature and identity before + // announcing it or retiring the archive (see retireBootstrapArchive for why a verified one goes). + // A tampered archive throws into the catch, which keeps it and records the integrity failure. await verifyRefusedArchive(archivePath, { module, coin, network }) + logger.info(`REFUSING the ${module} bootstrap restore: ${tip.detail}.`) const bytes = await statBootstrapArchiveBytes(archivePath) const archive = retireBootstrapArchive(archivePath, bytes, 'restore refused, node behind; a re-run downloads a fresh copy anyway') recordBootstrapOutcome(module, 'node-behind', tip.detail, archive) @@ -305,6 +308,16 @@ async function ensureBootstrapMariaDb(coin, network, module) { } catch (err) { const reason = redactSecrets(err.message) const archive = archivePath ? reportKeptBootstrapArchive(archivePath, await statBootstrapArchiveBytes(archivePath)) : null + // Past the DROP the database is partly imported and the container stays + // stopped, so "will sync from scratch" would describe a state not on disk. + if (err.postWipe) { + logger.info( + `WARNING: bootstrap auto-restore failed (${reason}) AFTER the ${module} database was dropped: ` + + `the database is incomplete and its container was left stopped, not syncing.` + ) + recordBootstrapOutcome(module, 'wiped-left-down', reason, archive) + return false + } logger.info(`WARNING: bootstrap auto-restore failed (${reason}): the service will sync from scratch`) recordBootstrapOutcome(module, 'failed', reason, archive) return false diff --git a/src/services/bootstrap_service/freshness.js b/src/services/bootstrap_service/freshness.js index 5c77acb2..fb6a4887 100644 --- a/src/services/bootstrap_service/freshness.js +++ b/src/services/bootstrap_service/freshness.js @@ -23,10 +23,11 @@ let execFileAsync = promisify(execFile) let { XChainService, EXTERNAL_DB } = require('../../config') const { tableExistsSql } = require('../../db/information_schema') const { rowCountSql } = require('../../db/blocks') -let { getModuleDatabaseName, getUtxoTrackerVolumeName } = require('../config_service') +let { getDefaultConfig, getModuleDatabaseName, getUtxoTrackerVolumeName } = require('../config_service') let { getDatabaseContainerId, getDatabaseContainerPresence, getExternalDbConfig, executeNativeMariaDbCommand } = require('../database_service') let databaseService = require('../database_service') const { dockerMariadbArgs, mariadbEnv } = require('../../utils/docker_mariadb') +const { configuredDatabaseName } = require('../../utils/module_database_name') const { redactSecrets } = require('../../utils/helpers') const { getLogger } = require('../../observability/logger') let logger = getLogger() @@ -35,7 +36,7 @@ function configureDependencies(dependencies) { execFile = dependencies.childProcess.execFile execFileAsync = promisify(execFile) ;({ XChainService, EXTERNAL_DB } = dependencies.config) - ;({ getModuleDatabaseName, getUtxoTrackerVolumeName } = dependencies.configService) + ;({ getDefaultConfig, getModuleDatabaseName, getUtxoTrackerVolumeName } = dependencies.configService) databaseService = dependencies.databaseService ;({ getDatabaseContainerId, getDatabaseContainerPresence, getExternalDbConfig, executeNativeMariaDbCommand } = databaseService) logger = dependencies.logger @@ -116,8 +117,21 @@ async function utxoTrackerVolumeFreshness(coin, network) { // not parse: a lookup error says nothing about how much data the database holds, // and reading it as EMPTY is what let a rolling-update blip authorise // DROP DATABASE over a populated store. +// Name the database the service actually uses; null (read as UNKNOWN) when the +// config cannot be read or names an unsafe database. +async function freshnessDatabaseName(coin, network, module) { + const defaultName = getModuleDatabaseName(module, coin, network) + try { + return configuredDatabaseName(module, await getDefaultConfig(module, coin, network), defaultName) + } catch (err) { + reportUnknownFreshness(`the ${module} database`, err) + return null + } +} + async function mariaDbModuleFreshness(coin, network, module) { - const dbName = getModuleDatabaseName(module, coin, network) + const dbName = await freshnessDatabaseName(coin, network, module) + if (dbName === null) return FRESHNESS_UNKNOWN if (EXTERNAL_DB) return externalMariaFreshness(dbName) const local = await prepareLocalMariaFreshness(coin, network, dbName) if (Object.prototype.hasOwnProperty.call(local, 'freshness')) return local.freshness diff --git a/src/services/bootstrap_service/mariadb_archive.js b/src/services/bootstrap_service/mariadb_archive.js index cf87dbf3..27a2d268 100644 --- a/src/services/bootstrap_service/mariadb_archive.js +++ b/src/services/bootstrap_service/mariadb_archive.js @@ -33,6 +33,7 @@ let databaseService = require('../database_service') let { assertBootstrapSourceHealthy } = require('../bootstrap_health_gate') let { buildBootstrapMeta, writeBootstrapMeta } = require('../bootstrap_archive_meta') const { dockerMariadbArgs, mariadbEnv } = require('../../utils/docker_mariadb') +const { configuredDatabaseName } = require('../../utils/module_database_name') const { redactSecrets } = require('../../utils/helpers') let { maybeSignBootstrap, computeSha256 } = require('./archive_signing') let { startProgress, buildDateTimeString, getWorkDir, ensureDir, ensureDirWritable } = require('./workspace') @@ -78,7 +79,7 @@ async function prepareMariaArchive(coin, network, module) { const outputDir = module === XChainService.XCHAIN_DECODER ? defaultConfig["DECODER_BOOTSTRAP_VOLUME"] : defaultConfig["INDEXER_BOOTSTRAP_VOLUME"] - const dbName = getModuleDatabaseName(module, coin, network) + const dbName = configuredDatabaseName(module, defaultConfig, getModuleDatabaseName(module, coin, network)) const archiveName = `${network}${SEP}${module}${SEP}${buildDateTimeString()}.tar.gz` const workDir = getWorkDir(coin, network, module) const innerArchive = path.join(workDir, 'dump.sql.gz') diff --git a/src/services/bootstrap_service/restore_archive.js b/src/services/bootstrap_service/restore_archive.js index 3633928e..38e7c0df 100644 --- a/src/services/bootstrap_service/restore_archive.js +++ b/src/services/bootstrap_service/restore_archive.js @@ -32,6 +32,7 @@ const { stopModuleContainer } = require('../stop_budget_service') let { getDatabaseContainerId, ensureDatabasePool, getExternalDbConfig, executeNativeMariaDbCommand } = require('../database_service') let databaseService = require('../database_service') const { dockerMariadbArgs, mariadbEnv } = require('../../utils/docker_mariadb') +const { configuredDatabaseName } = require('../../utils/module_database_name') let { checkBootstrapSignature, ensureVerifiedInnerArchive } = require('./archive_signing') const { BootstrapIntegrityError } = require('./archive_signing') let { readBootstrapArchiveMeta, compareArchiveIdentity } = require('../bootstrap_archive_meta') @@ -191,17 +192,34 @@ async function restoreTrackerVolume({ containerId, innerArchive, volumeName, wor } } +async function startMariaService(context, module) { + if (!context.serviceContainerId) return + logger.info(`Starting ${module} container...`) + await startContainer(context.serviceContainerId) +} + async function restoreBootstrapMariaDb(coin, network, module, fileName) { const context = await prepareMariaRestore(coin, network, module, fileName) try { - return await restoreMariaDatabase(context) - } finally { - // Always restart the service so a failed restore doesn't leave it down. - if (context.serviceContainerId) { - logger.info(`Starting ${module} container...`) - await startContainer(context.serviceContainerId) + await restoreMariaDatabase(context) + } catch (err) { + // Restart after a failure only while the database is untouched. Past the + // DROP it is gone or partly imported, and the service would resume over + // it, so it stays stopped, as restoreTrackerVolume leaves the tracker. + if (err.postWipe) { + logger.info( + `[fatal] ${module} bootstrap restore failed AFTER database ${context.dbName} was dropped;\n` + + `it is only partly re-imported and the container has been left STOPPED rather than\n` + + `restarted over it. Re-run the restore with XCHAIN_NODE_FORCE_BOOTSTRAP=1, or drop the\n` + + `database and start the service to resync from block 0.` + ) + throw err } + await startMariaService(context, module) + throw err } + await startMariaService(context, module) + return true } async function prepareMariaRestore(coin, network, module, fileName) { @@ -212,7 +230,8 @@ async function prepareMariaRestore(coin, network, module, fileName) { ? defaultConfig["DECODER_BOOTSTRAP_VOLUME"] : defaultConfig["INDEXER_BOOTSTRAP_VOLUME"] const archivePath = path.join(bootstrapDir, fileName) - const dbName = getModuleDatabaseName(module, coin, network) + // Resolve before the service stop and the DROP, so an unsafe configured name touches nothing. + const dbName = configuredDatabaseName(module, defaultConfig, getModuleDatabaseName(module, coin, network)) const workDir = getWorkDir(coin, network, `${module}-restore`) if (!fs.existsSync(archivePath)) throw new Error(`Bootstrap file not found: ${archivePath}`) @@ -242,14 +261,12 @@ async function prepareMariaRestore(coin, network, module, fileName) { // Stop the service that owns this DB so it isn't writing rows or holding // connections while we DROP and reimport (mirrors the utxo-tracker path, - // which stops the tracker before clearing its volume). Best-effort: a - // manual restore run before the service is installed has no container to - // stop. Open the DB pool first so getModuleContainer can resolve the row. + // which stops the tracker before clearing its volume). Only a confirmed + // zero-row registry answer means "not installed"; a failed lookup aborts + // the restore rather than wiping the DB under a running service. Open the + // DB pool first so the lookup can resolve the row. await ensureDatabasePool() - let serviceContainerId = null - try { - serviceContainerId = await db.getModuleContainer(module, coin, network) - } catch { /* service not installed yet, proceed without stopping */ } + const serviceContainerId = await db.getModuleContainerStrict(module, coin, network) if (serviceContainerId) { logger.info(`Stopping ${module} container...`) await stopModuleContainer(stopContainer, module, coin, network, serviceContainerId, undefined, getContainerStopSettings) @@ -258,7 +275,26 @@ async function prepareMariaRestore(coin, network, module, fileName) { return { dbContainerId, dbName, externalCfg, innerArchive, module, rootPassword, serviceContainerId, workDir } } -async function restoreMariaDatabase({ dbContainerId, dbName, externalCfg, innerArchive, rootPassword, workDir }) { +async function restoreMariaDatabase(context) { + let progress + try { + await recreateMariaDatabase(context) + progress = await importMariaDump(context) + } catch (err) { + // Everything above runs from the DROP on (a failed DROP call may still + // have dropped), so the database is no longer the one the service had. + err.postWipe = true + throw err + } + progress.stop(`${context.dbName} restored`) + + fs.rmSync(context.workDir, { recursive: true }) + logger.info('Bootstrap restore complete') + + return true +} + +async function recreateMariaDatabase({ dbContainerId, dbName, externalCfg, rootPassword }) { logger.info(`Recreating database ${dbName}...`) if (EXTERNAL_DB) { // Driver-based path: DROP and CREATE as separate statements (the @@ -271,7 +307,11 @@ async function restoreMariaDatabase({ dbContainerId, dbName, externalCfg, innerA { env: mariadbEnv(rootPassword) } ) } +} +// Streams the verified dump into the recreated database; returns the progress +// bar for the caller to close once the import has exited clean. +async function importMariaDump({ dbContainerId, dbName, externalCfg, innerArchive, rootPassword }) { const stats = await fs.promises.stat(innerArchive) const totalBytes = stats.size const progress = startProgress(`Restoring ${dbName}...`, totalBytes) @@ -305,12 +345,7 @@ async function restoreMariaDatabase({ dbContainerId, dbName, externalCfg, innerA else reject(new Error(`mariadb restore exited with code ${code}`)) }) }) - progress.stop(`${dbName} restored`) - - fs.rmSync(workDir, { recursive: true }) - logger.info('Bootstrap restore complete') - - return true + return progress } module.exports = { diff --git a/src/services/bootstrap_service/tracker_archive.js b/src/services/bootstrap_service/tracker_archive.js index ab5939cc..9f2617b3 100644 --- a/src/services/bootstrap_service/tracker_archive.js +++ b/src/services/bootstrap_service/tracker_archive.js @@ -38,7 +38,7 @@ const { redactSecrets } = require('../../utils/helpers') let { maybeSignBootstrap } = require('./archive_signing') let { startProgress, buildDateTimeString, getWorkDir, assertBootstrapCapacity, ensureDir, ensureDirWritable } = require('./workspace') const trackerHeight = require('./tracker_height') -const { readTrackerCommittedHeight, readTrackerHeightAfterRestart, chooseArchiveHeight } = trackerHeight +const { readTrackerCommittedHeight, readTrackerHeightAfterRestart, chooseArchiveHeight, refusePreStopOnlyHeight } = trackerHeight const { getLogger } = require('../../observability/logger') let logger = getLogger() @@ -217,8 +217,8 @@ async function prepareTrackerArchive(coin, network) { if (!context.containerId) throw new Error(`utxo-tracker container not found for ${coin}/${network}`) // A floor for the archive height, read while the tracker still runs: blocks it commits before the stop completes are in the archive - // but not in this reading, so the recorded height is taken again after the restart (chooseArchiveHeight). Best-effort: an archive - // without a height still restores, it just cannot be compared with the coin node at restore time (BootstrapNodeTipGuard). + // but not in this reading, so the recorded height is taken again after the restart, and a floor with no post-restart reading + // refuses the publish (chooseArchiveHeight). With neither reading the archive carries no height (BootstrapNodeTipGuard: unknown). context.preStopHeight = await readTrackerCommittedHeight(coin, network, context.containerId) context.postRestartHeight = null @@ -317,6 +317,8 @@ async function restartTrackerAndReadHeight(context, endMaintenanceWindow) { context.containerRestored = true await endMaintenanceWindow() context.postRestartHeight = await readTrackerHeightAfterRestart(context.coin, context.network, context.containerId) + // Refuse here, before the compress on the snapshot path: a pre-stop reading alone may understate the archived data. + refusePreStopOnlyHeight(context) } async function compressTrackerArchive(context, snapshotTaken) { diff --git a/src/services/bootstrap_service/tracker_height.js b/src/services/bootstrap_service/tracker_height.js index d87f68c8..b82b2517 100644 --- a/src/services/bootstrap_service/tracker_height.js +++ b/src/services/bootstrap_service/tracker_height.js @@ -39,8 +39,9 @@ function configureDependencies(dependencies) { logger = dependencies.logger } -// How long a just-restarted tracker gets to answer its status surface before the pre-stop floor is used instead. -const POST_RESTART_PROBE_ATTEMPTS = 12 +// How long a just-restarted tracker gets to answer its status surface (5 min) before the publish refuses. +// The tracker is serving again by then, so the wait costs publish wall-clock time only. +const POST_RESTART_PROBE_ATTEMPTS = 60 const POST_RESTART_PROBE_DELAY_MS = 5000 // The tracker's committed height from its status surface, or null. Asked @@ -79,24 +80,32 @@ async function readTrackerHeightAfterRestart(coin, network, containerId, { attem } // Pick the height the archive records: never below the data it holds, since the restore guard treats nodeHeight >= archiveHeight as safe. -// max() keeps a rollback during the restart from lowering it. With no post-restart reading, the pre-stop floor is kept (a height off by -// the stop window still guards more than none, which the guard reads as "unknown" and restores unchecked). +// max() keeps a rollback during the restart from lowering it. A pre-stop reading alone can miss blocks committed while the tracker +// stopped, so that case throws rather than sign an understated height; with no reading at all the archive honestly carries none. function chooseArchiveHeight(preStopHeight, postRestartHeight) { const pre = Number.isInteger(preStopHeight) ? preStopHeight : null const post = Number.isInteger(postRestartHeight) ? postRestartHeight : null - const chosen = post === null ? pre : pre === null ? post : Math.max(pre, post) if (post === null && pre !== null) { - logger.info(`WARNING: could not read the tracker height after the restart; the archive records the pre-stop height ${pre}, ` + - 'which may understate the archived data by the blocks committed while the tracker was stopping.') - } else { - logger.info(`Archive height: ${chosen === null ? 'unknown' : chosen} (pre-stop ${pre === null ? 'unknown' : pre}, after restart ${post === null ? 'unknown' : post}).`) + throw new Error(`the tracker height could not be read after the restart; refusing to record the pre-stop height ${pre}, ` + + 'which may understate the archived data by the blocks committed while the tracker was stopping') } + const chosen = post === null ? pre : pre === null ? post : Math.max(pre, post) + logger.info(`Archive height: ${chosen === null ? 'unknown' : chosen} (pre-stop ${pre === null ? 'unknown' : pre}, after restart ${post === null ? 'unknown' : post}).`) return chosen } +// Throw when only the pre-stop reading exists, naming the run, so the publish stops before any compress or signing. +function refusePreStopOnlyHeight({ coin, network, preStopHeight, postRestartHeight }) { + if (!Number.isInteger(preStopHeight) || Number.isInteger(postRestartHeight)) return + throw new Error(`utxo-tracker ${coin}/${network} did not report its committed height after the restart; ` + + `refusing to sign an archive whose recorded height could understate its data (pre-stop reading ${preStopHeight}). ` + + 'No archive was built or signed; the previously published archive stays in place, so rerun the publish.') +} + module.exports = { configureDependencies, readTrackerCommittedHeight, readTrackerHeightAfterRestart, - chooseArchiveHeight + chooseArchiveHeight, + refusePreStopOnlyHeight } diff --git a/src/services/config_service/coins.js b/src/services/config_service/coins.js index dcce77c0..69216f34 100644 --- a/src/services/config_service/coins.js +++ b/src/services/config_service/coins.js @@ -104,7 +104,9 @@ function applyCoinDefaults(defaultValues, module, coin, network) { defaultValues['FEE_DESTINATION'] = feeDestination defaultValues[feeDestEnvName] = feeDestination } - if (module === XChainService.XCHAIN_ENCODER || module === XChainService.XCHAIN_DECODER || module === XChainService.XCHAIN_UTXO_TRACKER) { + // The regtest miner reads its coin from this prefix (fee mode, dust floor), so a bare network would run every rail as bitcoin. + if (module === XChainService.XCHAIN_ENCODER || module === XChainService.XCHAIN_DECODER || module === XChainService.XCHAIN_UTXO_TRACKER || + module === XChainService.XCHAIN_REGTEST_MINER) { defaultValues["NETWORK"] = coin + "-" + network } if (module === XChainService.XCHAIN_UTXO_TRACKER) { diff --git a/src/services/config_service/database.js b/src/services/config_service/database.js index d1e528f4..e7835e3e 100644 --- a/src/services/config_service/database.js +++ b/src/services/config_service/database.js @@ -16,89 +16,26 @@ 'use strict' -let crypto, EXTERNAL_DB, SEP, XChainService, peers, sidecars +let crypto, XChainService, sidecars function configure(dependencies) { - ({ crypto, EXTERNAL_DB, SEP, XChainService, peers, sidecars } = dependencies) + ({ crypto, XChainService, sidecars } = dependencies) } -// Whether a per-install random DB password can actually be APPLIED to the live MariaDB -// account on the next provision. Rotation runs either via the external-DB path (EXTERNAL_DB) -// or by exec-ing into a local MariaDB container; on a native, non-container host neither -// runs, so a generated password would never reach the DB and would desync the sidecar from a -// DB still on the old password (the 2026-06-26 indexer outage). Returns false on any error -// (e.g. docker absent), the safe direction: prefer the static default over a password we -// cannot apply. DatabaseService requires this file at load, so it is read through peers. async function dbPasswordCanRotate() { - if (EXTERNAL_DB) return true - try { - const { getDatabaseContainerId } = peers.databaseService - return !!(await getDatabaseContainerId()) - } catch { - return false - } + return true } -// HUB_DB_PASS is a SHARED-service credential: the hub and every coin/network stack that -// connects to the hub DB must present the SAME password, so it cannot be generated per -// coin/network. Resolve it once from a shared 0600 sidecar (config/hub.local), generating -// and persisting it on first use. getDefaultConfig() calls are sequential in the installer, -// so the read-or-generate is not racy in practice. async function getOrCreateHubDbPass() { const hubLocalPath = sidecars.hubSidecarPath() let pass = await sidecars.readSidecarValue(hubLocalPath, "HUB_DB_PASS") if (!pass) { - // Only mint a random shared password where the rotation can apply it; otherwise use - // the static default so the sidecar never diverges from a DB the rotation cannot reach. - if (await dbPasswordCanRotate()) { - pass = crypto.randomBytes(24).toString('hex') - sidecars.upsertSidecarValues(hubLocalPath, { HUB_DB_PASS: pass }) - } else { - pass = "xchain" + SEP + "password" - } + pass = crypto.randomBytes(24).toString('hex') + sidecars.upsertSidecarValues(hubLocalPath, { HUB_DB_PASS: pass }) } return pass } -// Generate and persist a per-install password for each per-coin/network DB account -// (decoder, indexer) on first provision, so installs no longer share the static -// default. An operator override in the main config file or the sidecar wins (the merge -// above already loaded those into defaultConfig). Only generate where the next provision -// can rotate the live account to the new value (EXTERNAL_DB or a DB container); on a -// native, non-container host the rotation no-ops, so generating would desync the sidecar -// from the DB and lock the service out (the 2026-06-26 indexer outage). There these fall -// through to the static default in defaultValues, matching the un-rotatable live account. -// The indexer's hub-DB connection reuses its OWN DB account (HUB_DB_NAME/USER are set -// to the indexer's in the indexer block above, on every network including regtest -// since the regtest mirror is armed too), so its hub-DB password must be the -// INDEXER_DB_PASS the container will actually get, not the shared hub password. Set -// it here, before the shared HUB_DB_PASS fallback below, so that fallback sees the -// key already present and skips. An operator override (already in defaultConfig) -// wins. On the non-rotatable path (dbPasswordCanRotate() false, the 2026-06-26 -// outage fallback) INDEXER_DB_PASS is still absent here and only lands via the -// static-defaults merge below; mirror that same static default instead of copying -// `undefined`, which would both mismatch the account AND occupy the key so the -// fallback/merge never repaired it (HubDbSync ER_ACCESS_DENIED lockout, #2246). Not -// network-gated: leaving regtest out here while HUB_DB_NAME/USER above point at the -// indexer's own account would hand the armed mirror the WRONG password (the shared -// hub password against the indexer's own DB user), so the mirror this row arms would -// never actually connect. -// HUB_DB_PASS is shared across the hub and every coin/network stack (decoder/indexer -// connect to the hub DB). Resolve it from the shared sidecar (generate on first use) -// unless an operator override already supplied it. Applies to both the coin/network -// callers (HUB_DB_PASS in their defaults) and the shared-service hub caller. -// EXTERNAL_DB: rewrite the DB host/port keys so containerized services -// reach the host-native MariaDB via the bridge gateway instead of the -// docker DNS name "mariadb" (which no longer resolves once the bundled -// container is decommissioned). The DB name/user/pass keys stay as-is; -// those are about the credentials, not the network location. -// Resolve the real external host/port via getExternalDbConfig() (env → -// saved credentials.json → prompt) rather than the load-time -// EXTERNAL_DB_HOST/PORT constants, which only reflect env vars or the -// 127.0.0.1:3306 defaults. Otherwise a host/port saved at the first-run -// prompt is ignored and provisioned containers get *_DB_HOST=127.0.0.1 -// (their own loopback), unreachable to the real DB (uuid:52c5b5f1). -// DatabaseService requires this file at load, so it is read through peers. function generateDatabaseCredentials(defaultConfig, localFilePath) { const freshDbCreds = {} for (const key of ["DECODER_DB_PASS", "INDEXER_DB_PASS"]) { @@ -113,14 +50,16 @@ function generateDatabaseCredentials(defaultConfig, localFilePath) { function setIndexerHubPassword(defaultConfig, defaultValues, module) { if (module === XChainService.XCHAIN_INDEXER && !("HUB_DB_PASS" in defaultConfig)) { - defaultConfig["HUB_DB_PASS"] = defaultConfig["INDEXER_DB_PASS"] !== undefined - ? defaultConfig["INDEXER_DB_PASS"] - : defaultValues["INDEXER_DB_PASS"] + if (!defaultConfig["INDEXER_DB_PASS"]) throw new Error("INDEXER_DB_PASS was not generated") + defaultConfig["HUB_DB_PASS"] = defaultConfig["INDEXER_DB_PASS"] } } function mergeDefaults(defaultConfig, defaultValues) { - const hasHubSeedUrls = defaultConfig.HUB_SEED_URLS || defaultValues.HUB_SEED_URLS + const hubSeedUrls = "HUB_SEED_URLS" in defaultConfig + ? defaultConfig.HUB_SEED_URLS + : defaultValues.HUB_SEED_URLS + const hasHubSeedUrls = Boolean(hubSeedUrls) if (hasHubSeedUrls) delete defaultConfig.HUB_API_URL for (const key in defaultValues) { if (!(key in defaultConfig) && !(hasHubSeedUrls && key === "HUB_API_URL")) { diff --git a/src/services/config_service/defaults.js b/src/services/config_service/defaults.js index 8a5e8b5d..b51b4361 100644 --- a/src/services/config_service/defaults.js +++ b/src/services/config_service/defaults.js @@ -16,12 +16,12 @@ 'use strict' -let config, Network, Coin, CoinTickerSymbol, XChainService, DB_SEP, SEP, bootstrapDir +let config, Network, Coin, CoinTickerSymbol, XChainService, DB_SEP, bootstrapDir let NODE_MODULE_NAME, HUB_MODULE_NAME, EXPLORER_MODULE_NAME, SYNC_MODULE_NAME let getDockerContainerImageName, getModuleDatabaseName function configure(dependencies) { - ({ config, Network, Coin, CoinTickerSymbol, XChainService, DB_SEP, SEP, bootstrapDir, + ({ config, Network, Coin, CoinTickerSymbol, XChainService, DB_SEP, bootstrapDir, NODE_MODULE_NAME, HUB_MODULE_NAME, EXPLORER_MODULE_NAME, SYNC_MODULE_NAME, getDockerContainerImageName, getModuleDatabaseName } = dependencies) } @@ -79,7 +79,6 @@ function createCoinDefaults(module, coin, network) { "DECODER_DB_HOST": "mariadb", "DECODER_DB_PORT": 3306, "DECODER_DB_USER": "xchain" + DB_SEP + "decoder" + DB_SEP + coin + DB_SEP + network, - "DECODER_DB_PASS": "xchain" + SEP + "password", "DECODER_URL": getDockerContainerImageName(XChainService.XCHAIN_DECODER, coin, network), "DECODER_API_PORT": 3002, "DECODER_PORT": 3002, @@ -97,7 +96,6 @@ function createCoinDefaults(module, coin, network) { "INDEXER_DB_PORT": 3306, "INDEXER_DB_NAME": getModuleDatabaseName(XChainService.XCHAIN_INDEXER, coin, network), "INDEXER_DB_USER": "xchain" + DB_SEP + "indexer" + DB_SEP + coin + DB_SEP + network, - "INDEXER_DB_PASS": "xchain" + SEP + "password", "DATABASE_URL": "mariadb", "DATABASE_PORT": 3306, "HUB_HOST": "0.0.0.0", @@ -108,7 +106,7 @@ function createCoinDefaults(module, coin, network) { "HUB_DB_HOST": "mariadb", "HUB_DB_PORT": 3306, "HUB_DB_USER": "xchain" + DB_SEP + "hub", - "HUB_DB_PASS": "xchain" + SEP + "password", + "HUB_DB_PASS": undefined, "EXPLORER_URL": getDockerContainerImageName(EXPLORER_MODULE_NAME, "", ""), "EXPLORER_API_PORT": 8080, "EXPLORER_PORT": 8080 @@ -125,7 +123,7 @@ function createSharedDefaults() { "HUB_DB_PORT": 3306, "HUB_DB_NAME": "XChain" + DB_SEP + "Hub", "HUB_DB_USER": "xchain" + DB_SEP + "hub", - "HUB_DB_PASS": "xchain" + SEP + "password", + "HUB_DB_PASS": undefined, "EXPLORER_HOST": "127.0.0.1", "EXPLORER_PORT": 18080, "EXPLORER_API_HOST": getDockerContainerImageName(EXPLORER_MODULE_NAME, "", ""), diff --git a/src/services/config_service/networks.js b/src/services/config_service/networks.js index 5899e8c7..d1596118 100644 --- a/src/services/config_service/networks.js +++ b/src/services/config_service/networks.js @@ -19,11 +19,13 @@ let stateModule, Coin, Network, XChainService, logger, config, peers, readSecretHostEnv let HUB_MODULE_NAME, getDockerContainerImageName let warnedHubConfigKeys = new Set() +let hubDefaults = null, configuredHubNetwork = null function configure(dependencies) { ({ stateModule, Coin, Network, XChainService, logger, config, peers, readSecretHostEnv, HUB_MODULE_NAME, getDockerContainerImageName } = dependencies) warnedHubConfigKeys = new Set() + hubDefaults = configuredHubNetwork = null } // A command composes the shared hub's config many times, so each deploy-time warning @@ -63,9 +65,15 @@ function getCommandCoinsAndNetworks() { // runs on. Ambiguous (several networks, or none named) leaves it unset, because one // hub declaring the wrong network mis-gates the ingest rules it is being set for. async function resolveDeploymentHubNetwork() { - const registered = new Set((await getRegisteredCoinStacks()).map(r => r.network)) - const networks = registered.size > 0 ? registered : new Set(getCommandCoinsAndNetworks().networks) - if (networks.size === 1) return [...networks][0] + const registeredStacks = await getRegisteredCoinStacks() + const registered = new Set(registeredStacks.map(r => r.network)) + const networks = configuredHubNetwork ? new Set([configuredHubNetwork]) + : registered.size > 0 ? registered : new Set(getCommandCoinsAndNetworks().networks) + if (networks.size === 1) { + const network = [...networks][0] + await composeColocatedIndexerUrls(hubDefaults, network, registeredStacks) + return network + } if (networks.size > 1) { warnHubConfigOnce("HUB_NETWORK_AMBIGUOUS", "WARNING: HUB_NETWORK is not set and this deployment runs stacks on " + @@ -81,54 +89,47 @@ async function resolveDeploymentHubNetwork() { // the composed URL names the container that install creates. async function hasBitcoinIndexer(network) { const registered = await getRegisteredCoinStacks() + await composeColocatedIndexerUrls(hubDefaults, network, registered) if (registered.some(r => r.coin === Coin.BITCOIN && r.network === network && r.module === XChainService.XCHAIN_INDEXER)) return true const command = getCommandCoinsAndNetworks() return command.coins.includes(Coin.BITCOIN) && command.networks.includes(network) } -// Usage-telemetry env is only meaningful to the hub (the telemetry collector). -// TELEMETRY_IP_SALT is read from the host environment (e.g. xchain-node's .env) so -// the IP-hash salt stays out of source and config files; without it the hub records -// country/region but leaves ip_hash null. The hub is a shared service (no per -// coin/network config file), so the host env is the injection point. -// Gate for the per-install detail endpoint (GET /telemetry/operators). Like the -// salt, sourced from host env so the secret stays out of source/config files; -// unset leaves the endpoint fail-closed (401 for everyone). -// BTC indexer JSON-RPC URL for the validator-mode price oracle's block-height -// anchor (hub.getlatestblock). Sourced from host env so a hub NOT co-located with -// a BTC indexer (e.g. the master hub box, where the BTC stack lives elsewhere) can -// point at a reachable indexer. Empty default ⇒ the hub falls back to its configs -// table, so co-located standalone/validator installs are unaffected. Left empty -// here, it is composed from the co-located BTC indexer further down. -// State-checkpoint engine + ANCHOR publisher (validator mode). The hub is a -// shared service (no per coin/network config file), so like the telemetry -// salt and BTC_INDEXER_API_URL above, the host env is the injection point. +// Co-located LTC and DOGE indexers use Docker DNS; host URLs remain for remote stacks. +const INDEXER_URL_VARS = { litecoin: "LTC_INDEXER_URL", dogecoin: "DOGE_INDEXER_URL" } +async function composeColocatedIndexerUrls(defaultValues, network, registeredStacks = null) { + if (!defaultValues || !network) return + const registered = registeredStacks || await getRegisteredCoinStacks() + const command = getCommandCoinsAndNetworks() + for (const [coin, name] of Object.entries(INDEXER_URL_VARS)) { + const here = registered.some(r => r.coin === coin && r.network === network && r.module === XChainService.XCHAIN_INDEXER) + || (command.coins.includes(coin) && command.networks.includes(network)) + if (here) defaultValues[name] = "http://" + + getDockerContainerImageName(XChainService.XCHAIN_INDEXER, coin, network) + ":3004" + } +} + +// Hub telemetry secrets come from host env; unset, ip_hash stays null and admin is 401. +// BTC indexer JSON-RPC URL comes from host env for a hub not co-located with one. Left +// empty, it is composed from the co-located indexer, else the hub uses its configs table. +// State-checkpoint and ANCHOR config also enters the shared hub through host env. // Per-coin _INDEXER_URLs feed getblockhashes (checkpoint state reads); // DOGE_* configures the on-chain ANCHOR/price publisher signer pipeline; // XDEX_* are the shared single-validator/regtest seams. Only set values are // injected, so unset host env leaves the hub's own defaults untouched. -// HUB_API_KEY gates the hub's consensus-affecting write methods. Sourced from -// host env (.env) so it persists across `update` (a hand-set container value is -// dropped on rebuild). Set it on the publicly-fronted master hub so writes are -// authenticated; unset leaves the hub keyless (the prior default). -// ANCHOR_CHUNK_RETRY_MS must outlast the utxo-tracker's mempool poll -// (60s on mainnet) or back-to-back same-wallet anchor broadcasts -// exhaust their retries on a stale UTXO view (txn-mempool-conflict). -// Anchor every Nth checkpoint_seq on-chain (off-multiples stay in the -// free off-chain mirror); decouples DOGE spend from checkpoint cadence. -// 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. -// 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, -// so an operator fronting the container with a proxy sets this. +// HUB_API_KEY gates the hub's consensus-affecting write methods. From host env (.env) so +// it survives `update`; unset leaves the hub keyless. +// ANCHOR_CHUNK_RETRY_MS must outlast the utxo-tracker mempool poll (60s on mainnet) or +// back-to-back same-wallet anchor broadcasts exhaust their retries on a stale UTXO view. +// Anchor every Nth checkpoint_seq on-chain (off-multiples stay in the free off-chain +// mirror); decouples DOGE spend from checkpoint cadence. +// Per-coin confirmation depth the hub waits 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; off regtest the hub clamps a lower value UP to the +// per-coin default, so this can only raise the depth on a real network. +// Reverse-proxy trust for the hub express API (rate-limiter IP keying). Default +// loopback suits Apache on the same host; a proxy in front of the container sets this. // Deployment network for the hub's consensus gates (notably // STAKE_WEIGHTED_QUORUM, whose activation height is per-network). // REQUIRED by the hub in validator mode (it fails loud on a @@ -251,6 +252,7 @@ async function hasBitcoinIndexer(network) { // everything else resolves to a plain process.env read. function configureHubBeforeKey(defaultValues, module) { if (module === HUB_MODULE_NAME) { + hubDefaults = defaultValues defaultValues["TELEMETRY_ENABLED"] = config.TELEMETRY_ENABLED defaultValues["TELEMETRY_RETENTION_DAYS"] = config.TELEMETRY_RETENTION_DAYS defaultValues["TELEMETRY_IP_SALT"] = config.TELEMETRY_IP_SALT @@ -264,8 +266,7 @@ function configureHubBeforeKey(defaultValues, module) { "CHECKPOINT_POLL_MS", "CHECKPOINT_ROUND_TIMEOUT_MS", "CHECKPOINT_CHAINS", "ANCHOR_ENABLED", "ANCHOR_INTERVAL_MS", "ANCHOR_MATCH_BATCH_SIZE", "ANCHOR_MAX_BATCH", "ANCHOR_CHUNK_MAX_BYTES", "ANCHOR_ROUND_TIMEOUT_MS", - "ANCHOR_CHUNK_RETRY_MS", - "ANCHOR_ELECTION_TOLERANCE_BLOCKS", "ANCHOR_REWARD_PER_PUBLISH", + "ANCHOR_CHUNK_RETRY_MS", "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", @@ -340,6 +341,8 @@ function configureHubAccess(defaultValues) { "updateconfig / registervalidator / reportreorg. Set HUB_API_KEY in the host env before " + "exposing this hub beyond a trusted network.") } + configuredHubNetwork = String(defaultValues["HUB_NETWORK"] || "").toLowerCase() || null + if (configuredHubNetwork) delete defaultValues["HUB_NETWORK"] } // Operator signer for the on-chain DOGE publishers: when the host sets @@ -382,16 +385,13 @@ function configureHubValidator(defaultValues) { } module.exports = { - // A hub with no HUB_NETWORK resolves its network to '', which fails every - // network-keyed ingest gate closed, so a non-validator install can never - // validate an on-chain PRICE batch. Host env still wins; unresolved stays unset. - // Capability snapshots are read off a BTC indexer, and with none reachable the - // hub refuses every on-chain PRICE batch for insufficient signer stake. Compose - // the co-located one; say so when this deployment has none to compose. + // Host env wins for the hub network and the BTC indexer URL; an unresolved network stays + // unset, and a deployment with no BTC indexer to compose is warned about. configure, warnHubConfigOnce, resolveDeploymentHubNetwork, hasBitcoinIndexer, + composeColocatedIndexerUrls, configureHubBeforeKey, configureHubAccess, configureHubValidator diff --git a/src/services/config_service/services.js b/src/services/config_service/services.js index 932037c8..1e70e340 100644 --- a/src/services/config_service/services.js +++ b/src/services/config_service/services.js @@ -234,7 +234,6 @@ function configureIndexerBeforeHubKey(defaultValues, module, network) { } if (config.HUB_SEED_URLS !== undefined && config.HUB_SEED_URLS !== "") { defaultValues.HUB_SEED_URLS = config.HUB_SEED_URLS - delete defaultValues.HUB_API_URL } } } diff --git a/src/services/database_service/container_access.js b/src/services/database_service/container_access.js index 758ffdca..c386ccd0 100644 --- a/src/services/database_service/container_access.js +++ b/src/services/database_service/container_access.js @@ -23,13 +23,14 @@ let { DB_MODULE_NAME } = require('../../config') let { sleep } = require('../../utils/helpers') let { dockerMariadbArgs, mariadbEnv } = require('../../utils/docker_mariadb') let { PING_SQL } = require('../../db/connectivity') -let { getDockerContainerImageName } = require('../config_service') +let { getDockerContainerImageName, validatePort } = require('../config_service') let { getStatusFromContainer, probeContainerPresenceByName } = require('../docker_service') +let config = require('../../config') const nativeExecFile = execFile function configureDependencies(dependencies) { if (dependencies.execFile === nativeExecFile) return - ;({ execFile, execFileAsync, DB_MODULE_NAME, sleep, dockerMariadbArgs, mariadbEnv, PING_SQL, getDockerContainerImageName, getStatusFromContainer, probeContainerPresenceByName } = dependencies) + ;({ execFile, execFileAsync, DB_MODULE_NAME, sleep, dockerMariadbArgs, mariadbEnv, PING_SQL, getDockerContainerImageName, validatePort, getStatusFromContainer, probeContainerPresenceByName, config } = dependencies) } @@ -58,6 +59,10 @@ async function getDatabaseContainerPresence() { } async function getDatabaseHostPort() { + const configuredPort = config.hostEnv().XCHAIN_NODE_DB_HOST_PORT + if (configuredPort && !validatePort(configuredPort)) { + throw new Error("Invalid port value in configuration: XCHAIN_NODE_DB_HOST_PORT=" + configuredPort) + } try { const containerName = getDockerContainerImageName(DB_MODULE_NAME, "", "") const { stdout } = await execFileAsync('docker', ['port', containerName, '3306/tcp']) @@ -66,9 +71,9 @@ async function getDatabaseHostPort() { const match = line.match(/:(\d+)$/) if (match) return parseInt(match[1], 10) } - return XCHAIN_NODE_DB_DEFAULT_PORT + return configuredPort ? parseInt(configuredPort, 10) : XCHAIN_NODE_DB_DEFAULT_PORT } catch { - return XCHAIN_NODE_DB_DEFAULT_PORT + return configuredPort ? parseInt(configuredPort, 10) : XCHAIN_NODE_DB_DEFAULT_PORT } } async function checkIfDatabaseModuleExists(coin, network) { diff --git a/src/services/database_service/container_run_args.js b/src/services/database_service/container_run_args.js new file mode 100644 index 00000000..d364aa3d --- /dev/null +++ b/src/services/database_service/container_run_args.js @@ -0,0 +1,27 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { DB_MODULE_NAME } = require('../../config') +const { memoryArgsFor } = require('../memory_limit_service') +const { stopTimeoutArgs } = require('../stop_budget_service') + +function createDatabaseContainerRunArgs(containerPrefix, logger) { + const memory = memoryArgsFor(DB_MODULE_NAME) + if (memory.note) logger.info(memory.note) + return [ + 'run', '-d', '--restart', 'unless-stopped', ...stopTimeoutArgs(DB_MODULE_NAME), + '--name', containerPrefix, '--hostname', 'mariadb', + '--log-opt', 'max-size=50m', '--log-opt', 'max-file=4', ...memory.args + ] +} + +module.exports = { createDatabaseContainerRunArgs } diff --git a/src/services/database_service/database_module.js b/src/services/database_service/database_module.js index d9326599..74b80eec 100644 --- a/src/services/database_service/database_module.js +++ b/src/services/database_service/database_module.js @@ -29,6 +29,7 @@ let { PING_SQL } = require('../../db/connectivity') let { getDefaultConfig, getDockerContainerImageName, getDockerNetwork, validatePort } = require('../config_service') let { getStatusFromContainer, addContainerToNetwork, forceRemoveContainerByName, probeContainerPresenceByName } = require('../docker_service') let { statusChanged } = require('../status_service') +const { createDatabaseContainerRunArgs } = require('./container_run_args') let config = require('../../config'); let peers = require('../peer_services').bindPeerServices((file) => require(path.join('..', file))) let { getLogger } = require('../../observability/logger'); @@ -78,7 +79,7 @@ async function verifyExternalDatabase() { // it. An already-installed DB keeps 10m x 3 until an operator tears the // container down and reinstalls it inside a maintenance window. function createDatabaseRunArgs(containerPrefix, environmentVariables, coin, network) { - const runArgs = ['run', '-d', '--restart', 'unless-stopped', '--name', containerPrefix, '--hostname', 'mariadb', '--log-opt', 'max-size=50m', '--log-opt', 'max-file=4'] + const runArgs = createDatabaseContainerRunArgs(containerPrefix, logger) // A visibility-only probe makes a stalled-but-alive mariadbd observable // to `docker ps` and to anything reading container health, while // --restart unless-stopped only ever fires on process EXIT. Deliberately @@ -92,14 +93,16 @@ function createDatabaseRunArgs(containerPrefix, environmentVariables, coin, netw // leaving them judging a DB that is still starting. runArgs.push('--health-interval', '15s', '--health-timeout', '5s', '--health-retries', '5', '--health-start-period', DEPENDENCY_HEALTH_START_PERIOD) runArgs.push('--network', getDockerNetwork(coin, network)) - const dbHostPort = environmentVariables["DB_PORT"] || XCHAIN_NODE_DB_DEFAULT_PORT + const dbHostPortOverride = config.hostEnv().XCHAIN_NODE_DB_HOST_PORT + const dbHostPort = dbHostPortOverride || environmentVariables["DB_PORT"] || XCHAIN_NODE_DB_DEFAULT_PORT // Every docker run port in this file is validated before reaching // execFile (see buildAndUp's portArgs loop). No shell-injection risk // applies either way (house execFile- - // array convention), but an out-of-contract DB_PORT should fail loud + // array convention), but an out-of-contract host port should fail loud // here instead of surfacing as a cryptic docker argument-parse error. if (!validatePort(dbHostPort)) { - throw new Error("Invalid port value in configuration: DB_PORT=" + dbHostPort) + const source = dbHostPortOverride ? 'XCHAIN_NODE_DB_HOST_PORT' : 'DB_PORT' + throw new Error("Invalid port value in configuration: " + source + "=" + dbHostPort) } runArgs.push('-p', `${XCHAIN_NODE_DB_HOST}:${dbHostPort}:3306`) // Optional: pin the MariaDB datadir to a host path (e.g. a fast NVMe diff --git a/src/services/database_service/db_parameters.js b/src/services/database_service/db_parameters.js index 293b604d..1fd245c2 100644 --- a/src/services/database_service/db_parameters.js +++ b/src/services/database_service/db_parameters.js @@ -55,18 +55,19 @@ async function provisionDatabaseAccount(nextCoin, nextNetwork, dbContainerId) { indexer: driftCfg["INDEXER_DB_PASS"] }) - let containerId = await db.getModuleContainer(XChainService.XCHAIN_DECODER, nextCoin, nextNetwork) + let containerId = await db.getModuleContainerStrict(XChainService.XCHAIN_DECODER, nextCoin, nextNetwork) if (containerId) { const cfg = await getDefaultConfig(XChainService.XCHAIN_DECODER, nextCoin, nextNetwork) await addUserPasswordToDatabase(XChainService.XCHAIN_DECODER, nextCoin, nextNetwork, cfg["DECODER_DB_NAME"], cfg["DECODER_DB_USER"], cfg["DECODER_DB_PASS"]) accountsProvisioned++ } - containerId = await db.getModuleContainer(XChainService.XCHAIN_INDEXER, nextCoin, nextNetwork) + containerId = await db.getModuleContainerStrict(XChainService.XCHAIN_INDEXER, nextCoin, nextNetwork) if (containerId) { const cfg = await getDefaultConfig(XChainService.XCHAIN_INDEXER, nextCoin, nextNetwork) await addUserPasswordToDatabase(XChainService.XCHAIN_INDEXER, nextCoin, nextNetwork, cfg["INDEXER_DB_NAME"], cfg["INDEXER_DB_USER"], cfg["INDEXER_DB_PASS"]) - await addUserPasswordToDatabase(XChainService.XCHAIN_INDEXER, nextCoin, nextNetwork, cfg["DECODER_DB_NAME"], cfg["DECODER_DB_USER"], cfg["DECODER_DB_PASS"]) + // The decoder account the indexer reads keeps its own module, so it never takes the indexer-only DrillB/HubMirror grants. + await addUserPasswordToDatabase(XChainService.XCHAIN_DECODER, nextCoin, nextNetwork, cfg["DECODER_DB_NAME"], cfg["DECODER_DB_USER"], cfg["DECODER_DB_PASS"]) accountsProvisioned += 2 } } catch (err) { diff --git a/src/services/database_service/mariadb_exec.js b/src/services/database_service/mariadb_exec.js index a042dd4e..d99e0657 100644 --- a/src/services/database_service/mariadb_exec.js +++ b/src/services/database_service/mariadb_exec.js @@ -28,7 +28,7 @@ let config = require('../../config') let { getLogger } = require('../../observability/logger') let logger = getLogger() let { loadDbRootPassword, saveDbRootPassword } = require('../credentials_service') -let { checkIfDatabaseModuleExists } = require('./container_access') +let { checkIfDatabaseModuleExists, getDatabaseContainerPresence } = require('./container_access') let { getExternalDbConfig } = require('./external_db') const nativeExecFile = execFile @@ -126,7 +126,7 @@ async function rootPasswordFromConfig(dbContainerId) { if (config.XCHAIN_NODE_DB_ROOT_PASSWORD) { const envPassword = config.XCHAIN_NODE_DB_ROOT_PASSWORD if (!dbContainerId) { - // No running container to verify against yet (fresh install): the + // Docker has confirmed no container exists (fresh install): the // env override becomes the password the container is created with, // so there is nothing to ping. Accept as-is, same as before. setDbRootPassword(envPassword) @@ -248,6 +248,20 @@ async function promptForRootPassword(dbContainerId) { } } +// A null container id means "no container" OR "the inspect failed". Only docker SAYING the +// container is gone makes this a fresh install; anything else would save an unverified root +// password over the stored copy a live container still depends on, so it refuses instead. +async function requireConfirmedDbAbsence() { + let presence + try { presence = await getDatabaseContainerPresence() } catch { presence = 'unknown' } + if (presence === 'gone') return + throw new Error( + `Docker reported '${presence}' for the MariaDB container: neither a container whose root ` + + 'password can be verified nor a confirmed absence, so no root password was set or saved. ' + + 'Check `docker ps -a` / `docker inspect` and re-run once docker answers cleanly.' + ) +} + async function askMariadbRootPassword(coin, network) { const cached = getDbRootPassword() if (cached) return cached @@ -261,6 +275,7 @@ async function askMariadbRootPassword(coin, network) { } const dbContainerId = await checkIfDatabaseModuleExists(coin, network) + if (!dbContainerId) await requireConfirmedDbAbsence() const configured = await rootPasswordFromConfig(dbContainerId) if (configured) return configured const fromContainer = await rootPasswordFromContainer(dbContainerId) diff --git a/src/services/database_service/user_provisioning.js b/src/services/database_service/user_provisioning.js index e027cfbc..5fa3108d 100644 --- a/src/services/database_service/user_provisioning.js +++ b/src/services/database_service/user_provisioning.js @@ -163,22 +163,17 @@ async function grantDrillDatabaseAccess(run, module, network, mariadbUser) { } } -// The self-synced checkpoint mirror uses the same shape: -// HubService.buildCheckpointConfig names the schema -// `_HubMirror`, and the explorer's own -// HubMirrorSyncManager/HubMirrorPool.ensureDatabase() runs `CREATE -// DATABASE IF NOT EXISTS` on it under THIS SAME indexer account -// (db.js's _checkpointSource only honours a checkpoint entry whose -// host/port/user/pass exactly match the indexer DB, so the mirror -// writer has no separate credential to hold a separate grant). -// Escaped underscores, so the pattern matches nothing but a -// `_HubMirror` schema; gated to non-mainnet like DrillB, since -// self-sync is currently an opt-in for deployments with no -// externally-maintained hub schema colocated with the explorer. -async function grantHubMirrorDatabaseAccess(run, module, network, mariadbUser) { - if (module === XChainService.XCHAIN_INDEXER && network && network !== "mainnet") { +// The checkpoint mirror writer shares the indexer account and creates +// `_HubMirror` itself, so that account needs schema access. +// Non-mainnet indexers retain the escaped wildcard used by self-sync +// deployments. Mainnet indexers receive only their exact mirror schema. +async function grantHubMirrorDatabaseAccess(run, module, network, databaseName, mariadbUser) { + if (module === XChainService.XCHAIN_INDEXER && network) { + const grantDatabase = network === "mainnet" + ? "`" + databaseName.replaceAll("_", "\\_") + "\\_HubMirror`" + : "`XChain\\_%\\_HubMirror`" await run( - "GRANT ALL PRIVILEGES ON `XChain\\_%\\_HubMirror`.* TO " + mariadbUser + "GRANT ALL PRIVILEGES ON " + grantDatabase + ".* TO " + mariadbUser ) await run("FLUSH PRIVILEGES") logger.info(redactSecrets("Checkpoint hub-mirror database permissions granted to " + mariadbUser + "!")) @@ -190,7 +185,7 @@ async function provisionDatabaseUser(run, module, network, databaseName, mariadb await grantHubTestDatabaseAccess(run, module, mariadbUser) await grantReplicationStatusAccess(run, module, mariadbUser) await grantDrillDatabaseAccess(run, module, network, mariadbUser) - await grantHubMirrorDatabaseAccess(run, module, network, mariadbUser) + await grantHubMirrorDatabaseAccess(run, module, network, databaseName, mariadbUser) } async function addUserPasswordToDatabase(module, coin, network, databaseName, user, userPassword, inDocker = true) { diff --git a/src/services/db_credential_drift.js b/src/services/db_credential_drift.js index aa55dde4..e712c9ba 100644 --- a/src/services/db_credential_drift.js +++ b/src/services/db_credential_drift.js @@ -36,6 +36,7 @@ const execFileAsync = promisify(execFile) const { XChainService, HUB_MODULE_NAME, DB_CREDENTIAL_DRIFT_ENV } = require('../config') const { getDockerContainerImageName } = require('./config_service') +const { readContainerEnv: readContainerEnvFromDocker } = require('./module_service/carry_container_env') const { getLogger } = require('../observability/logger'); const logger = getLogger(); @@ -48,6 +49,13 @@ const DRIFT_OVERRIDE_ENV = 'XCHAIN_NODE_ALLOW_DB_CREDENTIAL_DRIFT' // Tags the refusal so callers can tell it apart from a docker/network failure. const DRIFT_ERROR_CODE = 'DB_CREDENTIAL_DRIFT' +async function readContainerEnv(name, deps = {}) { + return readContainerEnvFromDocker(name, { + ...deps, + execFileAsync: deps.execFileAsync || execFileAsync + }) +} + // Which container env key authenticates with which per-coin/network account. // The indexer appears twice on purpose: it opens its own DB with // INDEXER_DB_PASS and the decoder's DB with DECODER_DB_PASS, so a decoder-account @@ -229,38 +237,6 @@ async function assertNoHubDbCredentialDrift(intended, deps = {}) { throw error } -/** - * Read a running container's env as a plain object, or null when the container - * does not exist. Tolerant by design: a missing container is not drift. - * - * @param {string} name - * @param {{execFileAsync?: Function}} [deps] - * @returns {Promise|null>} - */ -async function readContainerEnv(name, deps = {}) { - const runDocker = deps.execFileAsync || execFileAsync - let stdout - try { - ({ stdout } = await runDocker('docker', ['inspect', '--type', 'container', '--format', '{{json .Config.Env}}', name])) - } catch { - return null - } - let parsed - try { - parsed = JSON.parse(String(stdout).trim()) - } catch { - return null - } - if (!Array.isArray(parsed)) return null - const env = {} - for (const entry of parsed) { - const eqIndex = String(entry).indexOf('=') - if (eqIndex <= 0) continue - env[String(entry).substring(0, eqIndex)] = String(entry).substring(eqIndex + 1) - } - return env -} - /** * Fail closed when a running decoder/indexer container would be locked out by the * passwords this install is about to provision. diff --git a/src/services/github_downloader.js b/src/services/github_downloader.js index d70b1ada..35e6695d 100644 --- a/src/services/github_downloader.js +++ b/src/services/github_downloader.js @@ -46,7 +46,7 @@ const { assertSafeArchiveMemberNames } = require('../utils/helpers'); const util = require('util'); const stream = require('stream'); const config = require('../config'); -const { githubApiHeaders, githubRateLimitError } = require('../utils/github_api'); +const { githubApiHeaders, githubRateLimitError, GITHUB_API_TIMEOUT_MS, GITHUB_DOWNLOAD_TIMEOUT_MS } = require('../utils/github_api'); const { getLogger } = require('../observability/logger'); const logger = getLogger(); const pipeline = util.promisify(stream.pipeline); @@ -171,7 +171,7 @@ class GitHubDownloader { try { const response = await axios.get( `https://api.github.com/repos/${owner}/${repoName}/releases`, - { headers: githubApiHeaders() } + { headers: githubApiHeaders(), timeout: GITHUB_API_TIMEOUT_MS } ); return response.data; } catch (error) { @@ -183,7 +183,8 @@ class GitHubDownloader { } // Returns the most recent release. With verifyHash=true (default), skips releases that have - // no entry in the hashes file, since an unverified release cannot be installed safely. + // no hash for THIS host's architecture, since the download verifies against that arch's hash + // and a release without one cannot be installed here. async getLatestCompatibleVersion(owner, repoName, verifyHash = true) { const releases = await this.getReleases(owner, repoName); const repoKey = `${owner}/${repoName}`; @@ -192,13 +193,14 @@ class GitHubDownloader { releases.sort((a, b) => new Date(b.published_at) - new Date(a.published_at)); if (verifyHash) { - // Gets the most recent version with an entry in the hashes file + // Gets the most recent version with a host-arch entry in the hashes file + const arch = getHostArch(); for (const release of releases) { - if (this.hasHash(repoKey, release.tag_name)) { + if (this.hasHash(repoKey, release.tag_name, arch)) { return release; } } - throw new Error(`Couldn't find a version of ${repoKey} with an entry in the hashes file`); + throw new Error(`Couldn't find a version of ${repoKey} with an entry in the hashes file for ${arch}`); } // If verifyHash is false, then just return the first one (most recent) @@ -230,8 +232,10 @@ class GitHubDownloader { // Gets the specific release info const release = await this.getReleaseByTag(owner, repoName, version); - if (verifyHash && !this.hasHash(repoKey, version)) { - throw new Error( `Required SHA-256 hash not found for ${repoKey}@${version}`); + // Refuse before downloading when this host's arch has no pinned hash: the + // post-download verification checks that arch only, so any-arch is not enough. + if (verifyHash && !this.hasHash(repoKey, version, getHostArch())) { + throw new Error( `Required SHA-256 hash not found for ${repoKey}@${version} on ${getHostArch()}`); } try { @@ -261,7 +265,7 @@ class GitHubDownloader { try { const response = await axios.get( `https://api.github.com/repos/${owner}/${repoName}/releases/tags/${tag}`, - { headers: githubApiHeaders() } + { headers: githubApiHeaders(), timeout: GITHUB_API_TIMEOUT_MS } ); return response.data; } catch (error) { @@ -286,6 +290,7 @@ class GitHubDownloader { method: 'get', url: asset.browser_download_url, responseType: 'stream', + timeout: GITHUB_DOWNLOAD_TIMEOUT_MS, headers: { 'Accept': 'application/octet-stream', 'User-Agent': 'GitHubDownloader' diff --git a/src/services/go_live_gate.js b/src/services/go_live_gate.js index 0966de4e..0262e2e5 100644 --- a/src/services/go_live_gate.js +++ b/src/services/go_live_gate.js @@ -35,18 +35,17 @@ const { // when a deploy asks, not values taken when this file loads. const config = require('../config') -// An un-armed mainnet activation carries one of two sentinels: 9999999999 (the -// year-2286 time sentinel) or 999999999 (the height sentinel). Shipping either -// to a live mainnet silently leaves the gated behavior off for good. The gate -// reads them only where an activation VALUE lives (an addChange() mainnet -// argument, a `mainnet:` map entry, or a *MAINNET* constant), never as a bare -// substring: 999999999 is also an ordinary "no upper bound" in query code. +// An un-armed mainnet activation carries 9999999999 (the year-2286 time +// sentinel), 999999999 (the height sentinel), or, in a mainnet map entry or +// *MAINNET* constant, the UNARMED identifier. The gate reads them only where +// an activation VALUE lives, never as a bare substring: 999999999 is also an +// ordinary "no upper bound" in query code. // // 1798761600 (2026-12-31T22:00:00Z) is NOT a sentinel. It was the shared // placeholder for the un-decided flag days until the operator ruled it ARMED // on 2026-09-09 (CROSS_CHAIN_ROYALTY's mainnet instant); reading it // as un-armed was what made this gate refuse a correctly armed tree. -const UNARMED_SENTINELS = ['9999999999', '999999999'] +const UNARMED_SENTINELS = ['9999999999', '999999999', 'UNARMED'] const FLAG_DAY_PLACEHOLDER = UNARMED_SENTINELS[0] // Services whose mainnet deployment accepts writes or feeds consensus, and @@ -84,20 +83,21 @@ function stripComments(source) { function hasUnarmedMainnetActivation(source) { const code = stripComments(source) const isSentinel = (v) => UNARMED_SENTINELS.includes(String(v).trim()) + const isNumericSentinel = (v) => /^\d+$/.test(String(v).trim()) && isSentinel(v) const constants = {} - for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(\d+)\b/g)) constants[m[1]] = m[2] + for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(\d+|UNARMED)\b/g)) constants[m[1]] = m[2] const resolves = (arg) => { const a = arg.trim() - if (/^\d+$/.test(a)) return isSentinel(a) - return Object.prototype.hasOwnProperty.call(constants, a) && isSentinel(constants[a]) + if (isNumericSentinel(a)) return true + return Object.prototype.hasOwnProperty.call(constants, a) && isNumericSentinel(constants[a]) } for (const m of code.matchAll(/addChange\s*\(([^)]*)\)/g)) { const args = m[1].split(',') if ((args[2] !== undefined && resolves(args[2])) || (args[5] !== undefined && resolves(args[5]))) return true } - for (const m of code.matchAll(/(?:\bmainnet\b|['"][A-Za-z]+:mainnet['"])\s*:\s*(\d+)\b/g)) { + for (const m of code.matchAll(/(?:\bmainnet\b|['"][A-Za-z]+:mainnet['"])\s*:\s*(\d+|UNARMED)\b/g)) { if (isSentinel(m[1])) return true } for (const [name, value] of Object.entries(constants)) { diff --git a/src/services/hub_service.js b/src/services/hub_service.js index 788375d9..e11a2fd3 100644 --- a/src/services/hub_service.js +++ b/src/services/hub_service.js @@ -57,26 +57,68 @@ configureUpdateHubOrExplorer({ // costs time on a real failure and absorbs the docker race that causes most // of them; the failures it collects are what updateHub reports at the end // instead of the discarded error that made a disconnected hub look installed. -async function attachSharedContainer(moduleLabel, containerId, installedCoinsAndNetworks, failures) { +function isMissingContainerError(err) { + const text = String((err && err.stderr) || '') + ' ' + String((err && err.message) || err || '') + return /no such (object|container)/i.test(text) +} + +async function attachToNetwork(moduleLabel, containerId, coin, network, failures) { + const dockerNetwork = getDockerNetwork(coin, network) + try { + await addContainerToNetwork(containerId, dockerNetwork) + } catch (firstErr) { + if (isMissingContainerError(firstErr)) throw firstErr + logger.info("There was an error trying to connect " + moduleLabel + " to the " + + coin + "/" + network + " network (" + redactSecrets(firstErr) + "). Trying again in 3 seconds...") + await sleep(3000) + try { + await addContainerToNetwork(containerId, dockerNetwork) + } catch (retryErr) { + if (isMissingContainerError(retryErr)) throw retryErr + failures.push({ label: moduleLabel + " -> " + coin + "/" + network, error: retryErr }) + } + } +} + +async function attachSharedContainer(moduleLabel, containerId, installedCoinsAndNetworks, failures, { hubContainerName } = {}) { + if (!hubContainerName) { + for (const nextCoin in installedCoinsAndNetworks) { + for (const nextNetwork of installedCoinsAndNetworks[nextCoin]) { + try { + await addContainerToNetwork(containerId, getDockerNetwork(nextCoin, nextNetwork)) + } catch (firstErr) { + logger.info("There was an error trying to connect " + moduleLabel + " to the " + + nextCoin + "/" + nextNetwork + " network (" + redactSecrets(firstErr) + "). Trying again in 3 seconds...") + await sleep(3000) + try { + await addContainerToNetwork(containerId, getDockerNetwork(nextCoin, nextNetwork)) + } catch (retryErr) { + failures.push({ + label: moduleLabel + " -> " + nextCoin + "/" + nextNetwork, + error: retryErr + }) + } + } + } + } + return + } + + let activeContainer = containerId for (const nextCoin in installedCoinsAndNetworks) { for (const nextNetwork of installedCoinsAndNetworks[nextCoin]) { try { - await addContainerToNetwork(containerId, getDockerNetwork(nextCoin, nextNetwork)) - } catch (firstErr) { - logger.info("There was an error trying to connect " + moduleLabel + " to the " + - nextCoin + "/" + nextNetwork + " network (" + redactSecrets(firstErr) + "). Trying again in 3 seconds...") - await sleep(3000) + await attachToNetwork(moduleLabel, activeContainer, nextCoin, nextNetwork, failures) + } catch { + if (activeContainer === hubContainerName) return false + activeContainer = hubContainerName try { - await addContainerToNetwork(containerId, getDockerNetwork(nextCoin, nextNetwork)) - } catch (retryErr) { - failures.push({ - label: moduleLabel + " -> " + nextCoin + "/" + nextNetwork, - error: retryErr - }) - } + await attachToNetwork(moduleLabel, activeContainer, nextCoin, nextNetwork, failures) + } catch { return false } } } } + return true } // Does the hub answer right now? One request, no retries, no restart attempt. @@ -104,8 +146,11 @@ async function updateHub({ skipConfigPush = false } = {}) { const failures = [] if (hubContainerId) { - await attachSharedContainer("xchain-hub", hubContainerId, installedCoinsAndNetworks, failures) - if (!skipConfigPush) await updateHubOrExplorer(HUB_MODULE_NAME) + const hubIsPresent = await attachSharedContainer( + "xchain-hub", hubContainerId, installedCoinsAndNetworks, failures, + { hubContainerName: getDockerContainerImageName(HUB_MODULE_NAME, "", "") } + ) + if (hubIsPresent && !skipConfigPush) await updateHubOrExplorer(HUB_MODULE_NAME) } // Connect xchain-sync container to all chain/network Docker networks (same pattern as hub) diff --git a/src/services/hub_service/update_hub_or_explorer.js b/src/services/hub_service/update_hub_or_explorer.js index e6c208ba..574db073 100644 --- a/src/services/hub_service/update_hub_or_explorer.js +++ b/src/services/hub_service/update_hub_or_explorer.js @@ -67,8 +67,18 @@ function createModuleConnector(module, defaultConfig, dependencies) { return new ExplorerConnector("127.0.0.1", defaultConfig["EXPLORER_PORT"]) } +// Name the manual GRANT a mainnet mirror needs: provisioning grants the +// HubMirror schema only off mainnet, so the explorer's CREATE DATABASE fails. +function warnMainnetMirrorGrant(coin, checkpointConfig, logger) { + const escapedSchema = String(checkpointConfig.name).replace(/_/g, "\\_") + logger.warn("WARNING: checkpoint self-sync is on for " + coin + " mainnet, but provisioning does not grant a mainnet " + + "indexer account the mirror schema " + checkpointConfig.name + ". Unless it was granted by hand, the explorer cannot " + + "create it and serves a frozen checkpoint mirror for this coin. Grant it as the MariaDB root user: " + + "GRANT ALL PRIVILEGES ON `" + escapedSchema + "`.* TO '" + checkpointConfig.user + "'@'%'; then restart the explorer.") +} + async function buildConfigPayload(module, lastStatus, context, dependencies) { - const { EXTERNAL_DB, XChainService, getDefaultConfig, buildHubModuleConfig, buildCheckpointConfig } = dependencies + const { EXTERNAL_DB, XChainService, getDefaultConfig, buildHubModuleConfig, buildCheckpointConfig, logger } = dependencies const { externalDbCfg, checkpointSelfSync } = context let jsonConfig = {} @@ -108,6 +118,7 @@ async function buildConfigPayload(module, lastStatus, context, dependencies) { // life: see isCheckpointSelfSyncEnabled). See buildCheckpointConfig above. if (checkpointSelfSync && XChainService.XCHAIN_INDEXER in lastStatus[nextCoin][nextNetwork]) { const checkpointConfig = buildCheckpointConfig(defaultConfigCoinNetwork) + if (nextNetwork === "mainnet") warnMainnetMirrorGrant(nextCoin, checkpointConfig, logger) if (module === "xchain-explorer") { nextConfigObject.checkpoint = checkpointConfig } else { diff --git a/src/services/migration_precondition_service.js b/src/services/migration_precondition_service.js index 945d2206..d2dcaf4f 100644 --- a/src/services/migration_precondition_service.js +++ b/src/services/migration_precondition_service.js @@ -63,8 +63,12 @@ const { migrationMode, pendingManualMigrations, runningBuildSupportsPerFileMigrations, + readRunningBuildMigrationStatus, + runningBuildPendingManual, listDeployPreconditionMigrations, readAppliedMigrations, + readLedgerRenames, + rekeyApplied, refusalMessage } = require('./migration_precondition_service/migration_scan') @@ -107,6 +111,46 @@ function assertMigrationStateReadable(result, module, dbName, required) { ) } +// Read the target tree's ledger rename map; an unparseable map re-keys nothing, +// which is the guard's behaviour without one, so it can never loosen the check. +function readTargetLedgerRenames(module, deps) { + const read = deps.readLedgerRenames || readLedgerRenames + let res + try { + res = read(getModuleTmpDir(module)) + } catch (err) { + res = { state: 'unparseable', renames: {}, reason: (err && err.message) || String(err) } + } + if (res.state === 'unparseable') { + logger.warn(`Migration precondition guard: ${module}'s ledger rename map was not used (${res.reason}); ` + + 'comparing raw ledger names.') + } + return res.state === 'parsed' ? res.renames : {} +} + +// The refusal's remedy runs on the build being replaced, so that build's own file +// list beats the target tree's; every failure degrades to the cautious branch. +async function readRefusalRemedy(module, coin, network, applied, deps) { + const container = getDockerContainerImageName(module, coin, network) + const readStatus = deps.readRunningBuildMigrationStatus || (deps.runningBuildSupportsPerFileMigrations + ? async (c, d) => ({ supportsPerFile: await deps.runningBuildSupportsPerFileMigrations(c, d), rows: null }) + : readRunningBuildMigrationStatus) + const listPending = deps.pendingManualMigrations || pendingManualMigrations + try { + const status = await readStatus(container, deps, module) + const rows = status && Array.isArray(status.rows) ? status.rows : null + return { + supportsPerFile: status ? status.supportsPerFile : null, + runningFiles: rows ? rows.map(row => row.file) : null, + pendingManual: rows + ? runningBuildPendingManual(rows, applied) + : listPending(migrationsDirOf(getModuleTmpDir(module)), applied) + } + } catch { + return { supportsPerFile: null, runningFiles: null, pendingManual: [] } + } +} + /** * Refuses (throws) when the source tree about to be deployed declares a migration * as a startup precondition that the target database has not applied. @@ -156,23 +200,16 @@ async function assertRequiredMigrationsApplied(module, coin, network, branch = n if (result.state === 'empty-database') return emptyDatabaseResult(module, dbName, required) assertMigrationStateReadable(result, module, dbName, required) - const missing = required.filter(f => !result.applied.has(f)) + // See the ledger the way the target's runner will after its boot re-key, so a + // renamed file recorded under its old name is not refused as missing. + const applied = rekeyApplied(result.applied, readTargetLedgerRenames(module, deps)) + const missing = required.filter(f => !applied.has(f)) if (missing.length) { // Only reached on the refusal path, so the probe costs a healthy deploy // nothing and cannot introduce a new way for one to fail: both the probe // and the pending-scan degrade to the cautious branch of the message. - const container = getDockerContainerImageName(module, coin, network) - const probe = deps.runningBuildSupportsPerFileMigrations || runningBuildSupportsPerFileMigrations - const listPending = deps.pendingManualMigrations || pendingManualMigrations - let supportsPerFile = null - let pendingManual = [] - try { - supportsPerFile = await probe(container, deps) - pendingManual = listPending(migrationsDirOf(getModuleTmpDir(module)), result.applied) - } catch { - supportsPerFile = null - } - throw new Error(refusalMessage(module, coin, network, dbName, missing, { supportsPerFile, pendingManual })) + const remedy = await readRefusalRemedy(module, coin, network, applied, deps) + throw new Error(refusalMessage(module, coin, network, dbName, missing, remedy)) } return { checked: true, required, missing: [], ok: true } @@ -186,6 +223,7 @@ module.exports = { listDeployPreconditionMigrations, pendingManualMigrations, runningBuildSupportsPerFileMigrations, + readRunningBuildMigrationStatus, // Exported for the unit suite: the refusal path hinges on an unreachable // database returning `unreadable` rather than throwing past the guard, and // that is a property of the real driver call, not of a stub. diff --git a/src/services/migration_precondition_service/migration_scan.js b/src/services/migration_precondition_service/migration_scan.js index fc948669..68c0fe52 100644 --- a/src/services/migration_precondition_service/migration_scan.js +++ b/src/services/migration_precondition_service/migration_scan.js @@ -13,13 +13,14 @@ ********************************************************************/ const fs = require('fs') +const path = require('path') -const { EXTERNAL_DB } = require('../../config') +const { EXTERNAL_DB, XChainService } = require('../../config') const { tableCountSql, tableExistsSql } = require('../../db/information_schema') const { appliedMigrationsSql } = require('../../db/migrations') const { migrationFiles } = require('../../utils/migration_files') const { getDockerContainerImageName } = require('../config_service') -const { readMigrateCli, migrateCliPathFor } = require('../../utils/indexer_migrate_cli') +const { readMigrateCli, migrateCliPathFor, migrateCliPathsFor, decoderStatusRows } = require('../../utils/indexer_migrate_cli') const SKIP_ENV = 'XCHAIN_NODE_SKIP_MIGRATION_PRECONDITION' const LEDGER_TABLE = 'schema_migrations' @@ -68,6 +69,63 @@ function migrationMode(raw) { return m ? m[1].toLowerCase() : 'manual' } +const LEDGER_RENAMES_FILE = 'src/db/database/migration_registry.js' +const LEDGER_RENAMES_OPEN = /^\s*const MIGRATION_LEDGER_RENAMES = \{\s*$/ +const LEDGER_RENAMES_PAIR = /^\s*'([A-Za-z0-9._-]+\.sql)'\s*:\s*'([A-Za-z0-9._-]+\.sql)'\s*,?\s*$/ + +/** + * The ledger rename map (old filename -> new filename) the target tree's runner will + * apply at boot, read from that tree so a rename shipping in this release counts + * without an xchain-node release. + * + * All or nothing: every line inside the literal must be blank, a full-line comment, + * or one quoted 'old.sql': 'new.sql' pair, else the result is 'unparseable' with an + * empty map, so a misread can never re-key more than the runner itself would. + * A tree with no registry file (the decoder) is 'absent', also with an empty map. + * + * Twin of MIGRATION_LEDGER_RENAMES in xchain-indexer src/db/database/migration_registry.js, + * read as text for the reason given above; keep that literal a flat string-to-string object. + */ +function readLedgerRenames(root) { + const file = path.join(root, LEDGER_RENAMES_FILE) + const none = (state, reason) => ({ state, renames: {}, reason }) + let lines + try { + lines = fs.readFileSync(file, 'utf8').split('\n') + } catch (err) { + if (err && err.code === 'ENOENT') return none('absent', file + ' does not exist') + return none('unparseable', 'could not read ' + file + ': ' + (err && err.message)) + } + const opens = lines.map((line, i) => LEDGER_RENAMES_OPEN.test(line) ? i : -1).filter(i => i >= 0) + if (opens.length !== 1) return none('unparseable', 'expected one MIGRATION_LEDGER_RENAMES literal, found ' + opens.length) + const renames = {} + for (let i = opens[0] + 1; i < lines.length; i++) { + const trimmed = lines[i].trim() + if (trimmed === '};') return { state: 'parsed', renames, reason: null } + if (trimmed === '' || trimmed.startsWith('//')) continue + const m = lines[i].match(LEDGER_RENAMES_PAIR) + if (!m) return none('unparseable', 'line ' + (i + 1) + ' is not a quoted filename pair') + if (m[1] === m[2] || Object.prototype.hasOwnProperty.call(renames, m[1])) { + return none('unparseable', 'line ' + (i + 1) + ' repeats or self-maps ' + m[1]) + } + renames[m[1]] = m[2] + } + return none('unparseable', 'MIGRATION_LEDGER_RENAMES literal is never closed') +} + +/** + * A new applied set with each recorded old name re-keyed to its new name, in memory + * only, planned against the input set exactly as Database.planLedgerRenames does: + * a pair moves only when the old name is recorded and the new one is not. + * Twin of planLedgerRenames in xchain-indexer src/db/database/migration_registry.js; keep the rule in step. + */ +function rekeyApplied(applied, renames) { + const ops = Object.entries(renames || {}).filter(([from, to]) => applied.has(from) && !applied.has(to)) + const out = new Set(applied) + for (const [from, to] of ops) { out.delete(from); out.add(to) } + return out +} + /** * Every gated (not mode=auto) migration in `dir` that the ledger has not recorded, * sorted. This is the blast radius of an UNSCOPED migrate run against that @@ -86,47 +144,78 @@ function pendingManualMigrations(dir, applied) { }).map(([f]) => f) } -/** Verify the running container's migrate CLI through its read-only status contract. */ -async function runningBuildSupportsPerFileMigrations(container, deps = {}) { +/** + * Read the running container's migrate CLI through its read-only status contract. + * `rows` is the file list THAT build carries, kept only when the response passed + * every check, because the refusal's remedy runs on that build and not on the + * source being deployed. + */ +async function readRunningBuildMigrationStatus(container, deps = {}, module) { + const refused = { supportsPerFile: false, rows: null } try { const cat = deps.getDockerContainerFileCat || require('../docker_service').getDockerContainerFileCat - const found = await readMigrateCli(cat, container) - if (!found) return null - if (!/(['"])--status\1/.test(found.source)) return false + const found = await readMigrateCli(cat, container, migrateCliPathsFor(module)) + if (!found) return { supportsPerFile: null, rows: null } + if (!/(['"])--status\1/.test(found.source)) return refused const execContainer = deps.execContainer || require('../docker_service').execContainer let raw try { raw = await execContainer(container, ['node', found.cliPath, '--status', '--json']) } catch { - return false + return refused } let status try { status = JSON.parse(String(raw)) } catch { - return false + return refused + } + if (!status || Array.isArray(status) || typeof status !== 'object') return refused + // The decoder CLI lists names, not counted rows; its checks live beside its paths. + if (module === XChainService.XCHAIN_DECODER && Array.isArray(status.applied)) { + const rows = decoderStatusRows(status, found.source) + return rows ? { supportsPerFile: true, rows } : refused } - if (!status || Array.isArray(status) || typeof status !== 'object') return false - if (typeof status.database !== 'string' || status.database.length === 0) return false - if (!Array.isArray(status.migrations)) return false + if (typeof status.database !== 'string' || status.database.length === 0) return refused + if (!Array.isArray(status.migrations)) return refused for (const row of status.migrations) { - if (!row || Array.isArray(row) || typeof row !== 'object') return false - if (typeof row.file !== 'string' || row.file.length === 0) return false - if (typeof row.applied !== 'boolean') return false + if (!row || Array.isArray(row) || typeof row !== 'object') return refused + if (typeof row.file !== 'string' || row.file.length === 0) return refused + if (typeof row.applied !== 'boolean') return refused } const counts = ['total', 'applied', 'pending'] - if (!counts.every(k => Number.isInteger(status[k]) && status[k] >= 0)) return false + if (!counts.every(k => Number.isInteger(status[k]) && status[k] >= 0)) return refused const applied = status.migrations.filter(row => row.applied).length - return status.total === status.migrations.length && + const consistent = status.total === status.migrations.length && status.applied === applied && status.pending === status.total - applied + if (!consistent) return refused + return { + supportsPerFile: true, + rows: status.migrations.map(row => ({ file: row.file, applied: row.applied, mode: row.mode })) + } } catch { - return null + return { supportsPerFile: null, rows: null } } } +/** Verify the running container's migrate CLI through its read-only status contract. */ +async function runningBuildSupportsPerFileMigrations(container, deps = {}, module) { + return (await readRunningBuildMigrationStatus(container, deps, module)).supportsPerFile +} + +/** + * The gated files an unscoped migrate run inside the running build would apply: + * its own pending rows (a row with no mode is the runners' manual default), + * minus any whose end state the database already embodies. + */ +function runningBuildPendingManual(rows, applied) { + return rows.filter(row => !row.applied && (row.mode == null || row.mode === 'manual') && + !(applied && applied.has(row.file))).map(row => row.file) +} + /** * Every migration filename in `dir` whose header declares a deploy precondition, * sorted. A missing directory yields [] - a module (or a ref) with no migrations @@ -232,6 +321,7 @@ async function readAppliedMigrations({ database, coin, network }, deps = {}) { function refusalMessage(module, coin, network, dbName, missing, remedy = {}) { const container = getDockerContainerImageName(module, coin, network) + const cliPath = migrateCliPathFor(container, migrateCliPathsFor(module)) const files = missing.join(', ') const plural = missing.length > 1 @@ -239,24 +329,44 @@ function refusalMessage(module, coin, network, dbName, missing, remedy = {}) { // REPLACED. Only name the scoped command when that build was confirmed to // honour --file; otherwise the command would quietly widen to every pending // manual migration, so state that instead of printing it. + // With the running build's own file list in hand, a file it does not carry + // (one first shipping in this release) can neither be named to --file there + // nor be applied by an unscoped run there. + const carriedSet = Array.isArray(remedy.runningFiles) ? new Set(remedy.runningFiles) : null + const carried = carriedSet ? missing.filter(f => carriedSet.has(f)) : missing + const notCarried = carriedSet ? missing.filter(f => !carriedSet.has(f)) : [] + const notCarriedNote = notCarried.length + ? '\n The running build in ' + container + ' does not carry ' + notCarried.join(', ') + + ' (it first ships in the release being deployed), so no migrate run inside that container can apply ' + + (notCarried.length > 1 ? 'them' : 'it') + '. Apply the statements in ' + + (notCarried.length > 1 ? 'those files' : 'that file') + ' from the release being deployed by hand, ' + + 'with the writer quiesced, then re-run the update; if this check still refuses because no ledger ' + + 'row records the hand-applied file, set ' + SKIP_ENV + '=1 for that one deploy.' + : '' + let instructions if (remedy.supportsPerFile === true) { - instructions = 'apply ' + (plural ? 'them' : 'it') + - ' deliberately, with the writer quiesced, then re-run the update:\n' + - missing.map(f => ' docker exec -i ' + container + ' node ' + migrateCliPathFor(container) + ' --file ' + f).join('\n') + instructions = (carried.length + ? 'apply ' + (carried.length > 1 ? 'them' : 'it') + + ' deliberately, with the writer quiesced, then re-run the update:\n' + + carried.map(f => ' docker exec -i ' + container + ' node ' + cliPath + ' --file ' + f).join('\n') + : 'none of ' + (plural ? 'them' : 'it') + ' can be applied by the migrate CLI inside ' + container + '.') + + notCarriedNote } else { const wouldApply = (remedy.pendingManual && remedy.pendingManual.length) ? remedy.pendingManual - : missing - instructions = 'DO NOT run `node ' + migrateCliPathFor(container) + '` inside ' + container + '. ' + + : carried + const source = carriedSet ? '' : ' (listed from the source being deployed; that build may differ)' + instructions = 'DO NOT run `node ' + cliPath + '` inside ' + container + '. ' + (remedy.supportsPerFile === false ? 'That container did not return a valid --status --json response, so --file support is not verified' : 'Whether that container\'s build honours --file could not be read, and an unverified capability is not one: it may ignore --file') + ' and apply EVERY pending manual migration on ' + dbName + ', which is ' + - wouldApply.length + ' file(s):\n' + - wouldApply.map(f => ' ' + f + (missing.includes(f) ? ' (the one you need)' : '')).join('\n') + '\n' + + wouldApply.length + ' file(s)' + source + ':\n' + + wouldApply.map(f => ' ' + f + (carried.includes(f) ? ' (the one you need)' : '')).join('\n') + '\n' + ' Apply ' + (plural ? 'the needed files' : 'the needed file') + ' with a build that supports ' + - '--file, or apply the statement by hand with the writer quiesced, then re-run the update.' + '--file, or apply the statement by hand with the writer quiesced, then re-run the update.' + + notCarriedNote } return 'update refused: the ' + module + ' source about to be deployed asserts migration' + @@ -275,7 +385,11 @@ module.exports = { migrationMode, pendingManualMigrations, runningBuildSupportsPerFileMigrations, + readRunningBuildMigrationStatus, + runningBuildPendingManual, listDeployPreconditionMigrations, readAppliedMigrations, + readLedgerRenames, + rekeyApplied, refusalMessage } diff --git a/src/services/module_service.js b/src/services/module_service.js index 434dcb0a..b826c0a8 100644 --- a/src/services/module_service.js +++ b/src/services/module_service.js @@ -50,6 +50,7 @@ const nodeService = require('./node_service') const databaseService = require('./database_service') const dbCredentialDrift = require('./db_credential_drift') const bootstrapService = require('./bootstrap_service') +const nodeVersionPin = require('./node_service/node_version_pin.js') const peers = require('./peer_services').bindPeerServices(require) const { getLogger } = require('../observability/logger'); const logger = getLogger(); @@ -89,21 +90,35 @@ async function containerExistsByName(name) { } } -async function installNodeModule(coin, network, remoteUpdate) { +async function installNodeModule(coin, network, remoteUpdate, overwriteContainerId = null) { const { getLocalNodeVersion, checkRemoteNodeVersion } = versionService const { getRemoteModuleVersions, getLastStatus } = stateModule const { buildCryptoNode, getCryptoNode } = nodeService const containerVersion = getLastStatus()?.[coin ?? ""]?.[network ?? ""]?.[NODE_MODULE_NAME]?.["container_version"] ?? null - if (containerVersion && !remoteUpdate) return false + if (containerVersion && !remoteUpdate && !overwriteContainerId) return false let localNodeVersion = null try { localNodeVersion = await getLocalNodeVersion(coin, network) } catch { /* not installed yet */ } + // The daemon pin binds the CLI install and update too, not only the menu's installNode. + const { resolveNodeVersionPin, assertNodeVersionPin } = nodeVersionPin + const versionPin = resolveNodeVersionPin(coin) + // Only a reused cached daemon can defeat the pin; an update replaces it with the pinned download. + if (!remoteUpdate) assertNodeVersionPin(coin, network, localNodeVersion, versionPin) if (localNodeVersion == null || remoteUpdate) { - const remoteVersions = getRemoteModuleVersions() - if (!(NODE_MODULE_NAME + SEP + coin in remoteVersions)) await checkRemoteNodeVersion(coin) - const remoteNodeVersion = getRemoteModuleVersions()[NODE_MODULE_NAME + SEP + coin]["tag_name"] - await getCryptoNode(coin, network, remoteNodeVersion) + if (versionPin) { + logger.info("Node version pinned via env: installing " + coin + " " + versionPin) + await getCryptoNode(coin, network, versionPin) + } else { + const remoteVersions = getRemoteModuleVersions() + if (!(NODE_MODULE_NAME + SEP + coin in remoteVersions)) await checkRemoteNodeVersion(coin) + const remoteNodeVersion = getRemoteModuleVersions()[NODE_MODULE_NAME + SEP + coin]?.["tag_name"] + // Name the missing release rather than handing an undefined tag to the download. + if (remoteNodeVersion == null) { + throw new Error("There is no valid version to download for the " + coin + "/" + network + " node") + } + await getCryptoNode(coin, network, remoteNodeVersion) + } } await buildCryptoNode(coin, network) await statusChanged() @@ -234,7 +249,7 @@ async function installServiceModule(context) { async function installModule(module, coin, network, remoteUpdate = false, overwriteContainerId = null, onlyExecution = false, branch = null, dockerCmdArgs = null) { if (coin === "") coin = null if (network === "") network = null - if (module === NODE_MODULE_NAME) return installNodeModule(coin, network, remoteUpdate) + if (module === NODE_MODULE_NAME) return installNodeModule(coin, network, remoteUpdate, overwriteContainerId) if (module === DB_MODULE_NAME) return installDatabaseModule(coin, network) if (module === EXPLORER_MODULE_NAME) return installExplorer(branch, remoteUpdate) return installServiceModule({ module, coin, network, remoteUpdate, overwriteContainerId, onlyExecution, branch, dockerCmdArgs }) diff --git a/src/services/module_service/build_and_up.js b/src/services/module_service/build_and_up.js index a30a9d2b..0ad24457 100644 --- a/src/services/module_service/build_and_up.js +++ b/src/services/module_service/build_and_up.js @@ -38,6 +38,7 @@ let { cloneGit, resolveBundledLibRef } = require('./clone_and_refs') let { assertNoHostPortConflicts, resolveObservabilityEnv, buildHealthcheckArgs, buildModuleDockerArgs } = require('./docker_args') const { parsePortSpec } = require('./docker_args') let { attachCrossChainNetworks, verifyContainerMemoryLimit, logDockerCreateWarnings } = require('./container_networks') +const { carryContainerEnv } = require('./carry_container_env') const { getLogger } = require('../../observability/logger'); let logger = getLogger(); @@ -56,7 +57,6 @@ function configureDependencies(dependencies) { logDockerCreateWarnings, logger } = dependencies) } - /** * Build the module image and (re)create its container from the current config. * @@ -103,7 +103,6 @@ async function assertDeploymentReady(module, coin, network, environmentVariables const { assertNoHubConsensusEnvDrift } = hubConsensusEnvGuard await assertNoHubConsensusEnvDrift(environmentVariables) } - } // Stage any bundled library modules into this service's build context. // The service's Dockerfile COPYs them in and npm resolves the @@ -191,7 +190,6 @@ function validatePortArgs(portArgs) { } } } - // With no build to make it, the tag has to already exist. Say so here rather // than letting `docker run` fall through to a registry pull for an image name // that was only ever local, which fails with an unrelated auth/not-found error. @@ -377,11 +375,13 @@ async function buildAndUp(module, coin, network, overwriteContainerId = null, on await checkBuildKitAvailable() } catch (err) { throw "Error creating Docker image: " + err } } - const environmentVariables = await getDefaultConfig(module, coin, network) + let environmentVariables = await getDefaultConfig(module, coin, network) const dir = getModuleDir(module) + const containerPrefix = getDockerContainerImageName(module, coin, network) + environmentVariables = await carryContainerEnv(environmentVariables, + { reuseImage, overwriteContainerId, containerName: containerPrefix }, { execFileAsync }) await assertDeploymentReady(module, coin, network, environmentVariables, dir, onlyExecution) await stageBundledLibraries(module, dir, reuseImage) - const containerPrefix = getDockerContainerImageName(module, coin, network) const dockerOptions = resolveDockerOptions(module, environmentVariables, coin, network) ;({ coin, network } = dockerOptions) const memoryOptions = await resolveMemoryOptions(module, coin, network, onlyExecution) @@ -396,5 +396,4 @@ async function buildAndUp(module, coin, network, overwriteContainerId = null, on ...dockerOptions, ...memoryOptions, ...sourceMetadata, ...containerEnvironment }) } - module.exports = { configureDependencies, buildAndUp, validatePortArgs } diff --git a/src/services/module_service/carry_container_env.js b/src/services/module_service/carry_container_env.js new file mode 100644 index 00000000..b097b008 --- /dev/null +++ b/src/services/module_service/carry_container_env.js @@ -0,0 +1,77 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * XChain Node - Recreate Container Environment + ********************************************************************/ + +'use strict' + +const { execFile } = require('child_process') +const { promisify } = require('util') +const execFileAsync = promisify(execFile) + +/** + * Read a container's frozen environment as a plain object. + * + * @param {string} container Container id or name. + * @param {{execFileAsync?: Function}} [deps] + * @returns {Promise|null>} + */ +async function readContainerEnv(container, deps = {}) { + if (!container) return null + const runDocker = deps.execFileAsync || execFileAsync + let stdout + try { + const result = await runDocker( + 'docker', + ['inspect', '--type', 'container', '--format', '{{json .Config.Env}}', container] + ) + stdout = result && typeof result === 'object' && 'stdout' in result ? result.stdout : result + } catch { + return null + } + let parsed + try { + parsed = JSON.parse(String(stdout).trim()) + } catch { + return null + } + if (!Array.isArray(parsed)) return null + const env = {} + for (const entry of parsed) { + const eqIndex = String(entry).indexOf('=') + if (eqIndex <= 0) continue + env[String(entry).substring(0, eqIndex)] = String(entry).substring(eqIndex + 1) + } + return env +} + +/** + * Merge a reused container's environment under the current configuration. + * + * @param {Object} configuredEnv Current getDefaultConfig output. + * @param {{reuseImage?: boolean, overwriteContainerId?: string|null, containerName?: string}} options + * @param {{execFileAsync?: Function}} [deps] + * @returns {Promise>} + */ +async function carryContainerEnv(configuredEnv, options = {}, deps = {}) { + if (options.reuseImage !== true) return configuredEnv + const sources = [...new Set([options.overwriteContainerId, options.containerName].filter(Boolean))] + for (const source of sources) { + const carriedEnv = await readContainerEnv(source, deps) + if (carriedEnv) return { ...carriedEnv, ...configuredEnv } + } + return configuredEnv +} + +module.exports = { readContainerEnv, carryContainerEnv } diff --git a/src/services/module_service/docker_args.js b/src/services/module_service/docker_args.js index fc562ec0..ca08c8bb 100644 --- a/src/services/module_service/docker_args.js +++ b/src/services/module_service/docker_args.js @@ -20,6 +20,9 @@ let path = require('path') let { XChainService, SERVICE_REGISTRY, HUB_MODULE_NAME, EXPLORER_MODULE_NAME, SYNC_MODULE_NAME, DEPENDENCY_HEALTH_START_PERIOD } = require('../../config') let { getUtxoTrackerVolumeName } = require('../config_service') let { getPublishedHostPorts } = require('../docker_service') +const { healthcheckCommand } = require('./healthcheck_command') +const requiredGetUtxoTrackerVolumeName = getUtxoTrackerVolumeName +const requiredGetPublishedHostPorts = getPublishedHostPorts let config = require('../../config'); let validatorService = require('../validator_service') const { getLogger } = require('../../observability/logger'); @@ -29,7 +32,8 @@ function configureDependencies(dependencies) { ({ fs, path, XChainService, SERVICE_REGISTRY, HUB_MODULE_NAME, EXPLORER_MODULE_NAME, SYNC_MODULE_NAME, DEPENDENCY_HEALTH_START_PERIOD, - getUtxoTrackerVolumeName, getPublishedHostPorts, config, validatorService, + getUtxoTrackerVolumeName = requiredGetUtxoTrackerVolumeName, + getPublishedHostPorts = requiredGetPublishedHostPorts, config, validatorService, logger } = dependencies) } @@ -144,9 +148,10 @@ const SERVICE_HEALTHCHECK = { // (xchain-encoder/src/api.js, getServeReadiness). Its window therefore has to // cover the TRACKER's startup, not the encoder's own fast boot: at the former // 30s a simultaneous cold start had the encoder's grace expiring while the - // tracker was still inside the 60s window it declares one line below, and this - // is the one autoheal: true service whose probe judges another container. - [XChainService.XCHAIN_ENCODER]: { portKey: 'ENCODER_API_PORT', probe: 'http_get', interval: '15s', timeout: '5s', retries: 3, startPeriod: DEPENDENCY_HEALTH_START_PERIOD, autoheal: true }, + // tracker was still inside the 60s window it declares one line below. + // Deliberately no autoheal: that same probe 503s on tracker state, a + // deliberate tracker halt included, which restarting the encoder cannot clear. + [XChainService.XCHAIN_ENCODER]: { portKey: 'ENCODER_API_PORT', probe: 'http_get', interval: '15s', timeout: '5s', retries: 3, startPeriod: DEPENDENCY_HEALTH_START_PERIOD }, [XChainService.XCHAIN_UTXO_TRACKER]: { portKey: 'UTXO_TRACKER_API_PORT', probe: 'http_get', interval: '15s', timeout: '5s', retries: 3, startPeriod: '60s' }, [XChainService.XCHAIN_INDEXER]: { portKey: 'INDEXER_API_PORT', probe: 'http_get', interval: '15s', timeout: '5s', retries: 3, startPeriod: '60s', autoheal: true }, // The miner's API is JSON-RPC only (no GET /status route); an http_get probe 500s @@ -261,7 +266,7 @@ function buildHealthcheckArgs(module, environmentVariables) { return [] } - let cmd + let url, postData if (hc.probe === 'jsonrpc_ping' || hc.probe === 'jsonrpc_health') { // JSON-RPC POST; hub, explorer, and the regtest miner all speak this protocol. // `ping` is bare liveness (a SELECT 1, or "the port answers"); `health` is the @@ -270,15 +275,15 @@ function buildHealthcheckArgs(module, environmentVariables) { // dead port. Pick per descriptor: a service whose ping already carries the // real verdict (explorer) stays on ping. const method = hc.probe === 'jsonrpc_health' ? 'health' : 'ping' - cmd = `wget -T ${parseInt(hc.timeout, 10)} -qO- --post-data='{"jsonrpc":"2.0","method":"${method}","id":1}' --header='Content-Type: application/json' http://localhost:${port}/ || exit 1` + url = `http://localhost:${port}/` + postData = `{"jsonrpc":"2.0","method":"${method}","id":1}` } else { // Default: plain HTTP GET on /status; descriptors override via `path` // where /status is too expensive to double as a liveness probe (sync). - cmd = `wget -T ${parseInt(hc.timeout, 10)} -qO- http://localhost:${port}${hc.path || '/status'} || exit 1` + url = `http://localhost:${port}${hc.path || '/status'}` } - return [ - '--health-cmd', cmd, + '--health-cmd', healthcheckCommand({ url, postData, timeoutSeconds: parseInt(hc.timeout, 10) }), '--health-interval', hc.interval, '--health-timeout', hc.timeout, '--health-retries', String(hc.retries), diff --git a/src/services/module_service/healthcheck_command.js b/src/services/module_service/healthcheck_command.js new file mode 100644 index 00000000..752c3d36 --- /dev/null +++ b/src/services/module_service/healthcheck_command.js @@ -0,0 +1,33 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * XChain Node - Healthcheck Command + ********************************************************************/ + +'use strict' + +function shellQuote(value) { + return "'" + String(value).replace(/'/g, "'\\''") + "'" +} + +function healthcheckCommand({ url, postData, timeoutSeconds }) { + const postOptions = postData === undefined + ? '' + : ` --post-data=${shellQuote(postData)} --header='Content-Type: application/json'` + const diagnostic = `const url=process.argv[1],body=process.argv[2],options={signal:AbortSignal.timeout(${timeoutSeconds * 1000})};if(body!==undefined){options.method="POST";options.headers={"Content-Type":"application/json"};options.body=body}fetch(url,options).then(async response=>{const text=(await response.text()).slice(0,1500);process.stdout.write("health probe failed: HTTP "+response.status+" "+text+"\\n")}).catch(error=>process.stdout.write("health probe failed: "+error.message+"\\n"))` + const diagnosticArgs = ` ${shellQuote(url)}` + + (postData === undefined ? '' : ` ${shellQuote(postData)}`) + return `wget -T ${timeoutSeconds} -qO-${postOptions} ${url} || { node -e ${shellQuote(diagnostic)}${diagnosticArgs}; exit 1; }` +} + +module.exports = { healthcheckCommand } diff --git a/src/services/node_service.js b/src/services/node_service.js index baa049d3..aa3ff4e2 100644 --- a/src/services/node_service.js +++ b/src/services/node_service.js @@ -33,7 +33,7 @@ const { } = require('./node_service/crypto_node_build.js') const { statusChanged } = require('./status_service') const { checkRemoteNodeVersion } = require('./version_service') -const config = require('../config'); +const { resolveNodeVersionPin, assertNodeVersionPin } = require('./node_service/node_version_pin.js') // Destructured where they are used, so each call reads the export at that moment. const dockerService = require('./docker_service') const configService = require('./config_service') @@ -43,28 +43,6 @@ const peers = require('./peer_services').bindPeerServices(require) const { getLogger } = require('../observability/logger'); const logger = getLogger(); -// Optional exact-version pin for a coin daemon, read from -// XCHAIN_NODE_NODE_VERSION_ (e.g. XCHAIN_NODE_NODE_VERSION_LITECOIN=v0.21.4). -// Test harnesses (notably the multi-chain parity sweep) set this so the -// installed daemon matches the DEPLOYED fleet image instead of drifting to the -// latest upstream release. Returns null when no pin is set. -function resolveNodeVersionPin(coin) { - const pin = config.NODE_VERSION_PIN_ENV['XCHAIN_NODE_NODE_VERSION_' + String(coin).toUpperCase()] - return pin && pin.trim() !== '' ? pin.trim() : null -} - -// Enforce a version pin against an already-installed local daemon. A silent -// mismatch would defeat the pin (installNode skips the download when a local -// copy exists), so fail loudly with the remediation instead. -function assertNodeVersionPin(coin, network, localNodeVersion, pin) { - if (pin && localNodeVersion != null && localNodeVersion !== pin) { - throw new Error( - `Installed ${coin} node is ${localNodeVersion} but ` + - `XCHAIN_NODE_NODE_VERSION_${String(coin).toUpperCase()} pins ${pin}. ` + - `Remove the ${coin}/${network} stack (or the cached crypto node) and reinstall.`) - } -} - async function installDependentServices(coin, network, cloneGit, buildAndUp) { logger.info("Downloading xchain-encoder...") await cloneGit(XChainService.XCHAIN_ENCODER, true) @@ -144,7 +122,8 @@ async function installNode(coin, network) { if (!(NODE_MODULE_NAME + SEP + coin in getRemoteModuleVersions())) { await checkRemoteNodeVersion(coin) } - const remoteNodeVersion = getRemoteModuleVersions()[NODE_MODULE_NAME + SEP + coin]["tag_name"] + // The entry itself can be null (a coin with no release source), so guard it too. + const remoteNodeVersion = getRemoteModuleVersions()[NODE_MODULE_NAME + SEP + coin]?.["tag_name"] if (remoteNodeVersion != null) { await getCryptoNode(coin, network, remoteNodeVersion) } else { diff --git a/src/services/node_service/container_run_args.js b/src/services/node_service/container_run_args.js new file mode 100644 index 00000000..a2471a0f --- /dev/null +++ b/src/services/node_service/container_run_args.js @@ -0,0 +1,35 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { NODE_MODULE_NAME } = require('../../config') +const { memoryArgsFor } = require('../memory_limit_service') + +function createNodeContainerRunArgs(options) { + const { + coin, network, containerPrefix, stopBudgetSeconds, volumeMounts, + getDockerNetwork, logger + } = options + const memory = memoryArgsFor(NODE_MODULE_NAME, { coin, network }) + if (memory.note) logger.info(memory.note) + const runArgs = [ + 'run', '-d', '--restart', 'unless-stopped', '--name', containerPrefix, + '--stop-timeout', String(stopBudgetSeconds), + '--log-opt', 'max-size=50m', '--log-opt', 'max-file=4', + '--hostname', NODE_MODULE_NAME, '--network-alias', NODE_MODULE_NAME, + '--ulimit', 'nofile=2048:2048', ...memory.args, + '--network', getDockerNetwork(coin, network) + ] + for (const mount of volumeMounts) runArgs.push('-v', mount.spec) + return runArgs +} + +module.exports = { createNodeContainerRunArgs } diff --git a/src/services/node_service/crypto_node_build.js b/src/services/node_service/crypto_node_build.js index 90bf844b..393c8679 100644 --- a/src/services/node_service/crypto_node_build.js +++ b/src/services/node_service/crypto_node_build.js @@ -31,6 +31,7 @@ const { db } = require('../../state') const bundledCryptoNodesDir = path.join(__dirname, '../../../crypto_nodes') const { getDockerContainerImageName, getDockerNetwork, getDefaultConfig, validatePort } = require('../config_service') const { statusChanged } = require('../status_service') +const { createNodeContainerRunArgs } = require('./container_run_args') const config = require('../../config'); // Destructured where they are used, so each call reads the export at that moment. const dockerService = require('../docker_service') @@ -179,11 +180,15 @@ async function resolveStorageMounts(coin, network, reject) { // existing in-datadir txindex would be shadowed and rebuilt. const txindexHostPath = blocksDir ? `${blocksDir}/${coin}/${network}-txindex` : null - // The full bind-mount set of the NEW container, [{ spec, destination }], + // The full bind-mount set of the NEW container, [{ spec, source, destination }], // composed before the old container is touched so the drift guard // below can compare against it. const volumeMounts = [ - { spec: `${dataDir}/${NODE_MODULE_NAME}/${coin}/${network}:/root/.${coin}`, destination: `/root/.${coin}` } + { + spec: `${dataDir}/${NODE_MODULE_NAME}/${coin}/${network}:/root/.${coin}`, + source: `${dataDir}/${NODE_MODULE_NAME}/${coin}/${network}`, + destination: `/root/.${coin}` + } ] if (blocksDir) { const blocksDest = useBlocksdirFlag @@ -191,36 +196,51 @@ async function resolveStorageMounts(coin, network, reject) { // doged ignores -blocksdir: mount straight onto the blocks // dir it actually writes to, under the network subdir. : `/root/.${coin}${netSubdir}/blocks` - volumeMounts.push({ spec: `${blocksHostPath}:${blocksDest}`, destination: blocksDest }) + volumeMounts.push({ spec: `${blocksHostPath}:${blocksDest}`, source: blocksHostPath, destination: blocksDest }) // Relocate txindex for every coin (nested under the network subdir). const txindexDest = `/root/.${coin}${netSubdir}/indexes/txindex` - volumeMounts.push({ spec: `${txindexHostPath}:${txindexDest}`, destination: txindexDest }) + volumeMounts.push({ spec: `${txindexHostPath}:${txindexDest}`, source: txindexHostPath, destination: txindexDest }) } return { blocksDir, useBlocksdirFlag, blocksHostPath, txindexHostPath, volumeMounts } } -async function prepareExistingContainer(containerPrefix, coin, network, storage, reject) { - const { blocksDir, blocksHostPath, txindexHostPath, volumeMounts } = storage - // Mount-drift guard: if the container being replaced has bind mounts - // the new spec lacks, refuse BEFORE removing it. A real crash-loop - // came from exactly this: an env-less rebuild dropped the relocated - // blocks/txindex mounts, so the daemon restarted over an empty - // blocks store with a current chainstate. - const { forceRemoveContainerByName, getContainerBindMounts, stopContainerByName } = dockerService - let existingMounts = [] - try { - existingMounts = await getContainerBindMounts(containerPrefix) - } catch { /* no previous container or docker unreachable: nothing to preserve */ } - const newDestinations = new Set(volumeMounts.map(m => m.destination)) - const droppedMounts = existingMounts.filter(m => !newDestinations.has(m.destination)) +function preserveExistingMounts(containerPrefix, existingMounts, volumeMounts, reject) { + const newMountsByDestination = new Map(volumeMounts.map(m => [m.destination, m])) + const droppedMounts = existingMounts.filter(m => !newMountsByDestination.has(m.destination)) if (droppedMounts.length > 0) { reject(`Refusing to replace container ${containerPrefix}: the new spec would drop bind mount(s) ` + droppedMounts.map(m => `${m.source} -> ${m.destination}`).join(', ') + `. This usually means XCHAIN_NODE_BLOCKS_DIR is missing from this environment ` + `(non-interactive shells do not source the profile). Set it, or persist it in ` + `config/node.local as XCHAIN_NODE_BLOCKS_DIR=, then retry. The existing container was left untouched.`) - return null + return false + } + const changedSources = existingMounts.filter(m => { + const replacement = newMountsByDestination.get(m.destination) + return replacement && replacement.source !== m.source + }) + if (changedSources.length > 0) { + reject(`Refusing to replace container ${containerPrefix}: the new spec would change bind mount source(s) ` + + changedSources.map(m => { + const replacement = newMountsByDestination.get(m.destination) + return `${m.source} -> ${m.destination} to ${replacement.source} -> ${m.destination}` + }).join(', ') + + `. Verify the configured data and blocks directories, then retry. The existing container was left untouched.`) + return false } + return true +} + +async function prepareExistingContainer(containerPrefix, coin, network, storage, reject) { + const { blocksDir, blocksHostPath, txindexHostPath, volumeMounts } = storage + // Mount-drift guard: refuse before replacing a container when its bind + // destinations disappear or begin pointing at different host sources. + const { forceRemoveContainerByName, getContainerBindMounts, stopContainerByName } = dockerService + let existingMounts = [] + try { + existingMounts = await getContainerBindMounts(containerPrefix) + } catch { /* no previous container or docker unreachable: nothing to preserve */ } + if (!preserveExistingMounts(containerPrefix, existingMounts, volumeMounts, reject)) return null // Stop the running daemon cleanly BEFORE the force-remove below: // `docker rm -f` is SIGKILL, and a killed daemon restarts at its last @@ -259,51 +279,23 @@ async function prepareExistingContainer(containerPrefix, coin, network, storage, return stopBudgetSeconds } -function createRunArgs(coin, network, containerPrefix, stopBudgetSeconds, volumeMounts) { - const runArgs = [ - 'run', '-d', '--restart', 'unless-stopped', '--name', containerPrefix, - // Same shutdown budget for an operator's `docker stop`/`restart` - // and for dockerd's own shutdown: the default 10 s is far too - // short for a chain daemon to flush. - '--stop-timeout', String(stopBudgetSeconds), - // Cap json-file log growth so a long-running node cannot fill - // the host disk, at the same 50m x 4 = 200 MB the module - // containers carry (ModuleService.buildAndUp holds the sizing - // arithmetic). Chain nodes - // are the quietest containers measured on regtest (686 B/h BTC, - // 4.4 KB/h DOGE, 2.5 KB/h LTC on 2026-08-30), so this is far - // past the 48 h floor even allowing for a mainnet node's much - // heavier P2P chatter. --tail reads stay inside one rotated file. - '--log-opt', 'max-size=50m', '--log-opt', 'max-file=4', - '--hostname', NODE_MODULE_NAME, '--network-alias', NODE_MODULE_NAME, - '--ulimit', 'nofile=2048:2048', '--network', getDockerNetwork(coin, network) - ] - for (const mount of volumeMounts) runArgs.push('-v', mount.spec) - return runArgs +// Refuse a malformed operator-supplied NODE_EXPOSED_PORT/NODE_PORT before +// anything touches the live node (it once ran after the stop and force-remove). +function assertValidNodePorts(defaultExposedPort, defaultNodePort) { + if (!(defaultExposedPort && defaultNodePort)) return + if (!validatePort(defaultExposedPort)) { + throw new Error("Invalid port value in configuration: NODE_EXPOSED_PORT=" + defaultExposedPort) + } + if (!validatePort(defaultNodePort)) { + throw new Error("Invalid port value in configuration: NODE_PORT=" + defaultNodePort) + } } -function addPortArgs(runArgs, defaultExposedPort, defaultNodePort, reject) { +// Publish the node port; assertValidNodePorts already vetted both values. +function addPortArgs(runArgs, defaultExposedPort, defaultNodePort) { if (defaultExposedPort && defaultNodePort) { - // NODE_EXPOSED_PORT/NODE_PORT come from the operator-supplied - // - config. Validate before the docker run push so - // a malformed value fails loud here instead of surfacing as a - // cryptic docker argument-parse error (matches the DB_PORT guard - // in DatabaseService and the portArgs guard in ModuleService). - // This runs inside the async docker-build callback; a throw here - // escapes the Promise as an uncaught exception and hangs the - // build (same hazard as the mkdir guard above). Reject + return - // so the Promise settles instead. - if (!validatePort(defaultExposedPort)) { - reject(new Error("Invalid port value in configuration: NODE_EXPOSED_PORT=" + defaultExposedPort)) - return false - } - if (!validatePort(defaultNodePort)) { - reject(new Error("Invalid port value in configuration: NODE_PORT=" + defaultNodePort)) - return false - } runArgs.push('-p', `${defaultExposedPort}:${defaultNodePort}`) } - return true } function addDaemonArgs(runArgs, coin, blocksDir, useBlocksdirFlag, containerPrefix) { @@ -358,6 +350,8 @@ async function buildCryptoNode(coin, network) { const defaultConfig = await getDefaultConfig(NODE_MODULE_NAME, coin, network) const defaultExposedPort = defaultConfig["NODE_EXPOSED_PORT"] const defaultNodePort = defaultConfig["NODE_PORT"] + // Async body, not the build callback: a throw here rejects cleanly. + assertValidNodePorts(defaultExposedPort, defaultNodePort) const containerPrefix = getDockerContainerImageName(NODE_MODULE_NAME, coin, network) const nodeDir = cryptoNodesDir + "/" + coin const confFileName = stageBuildScaffold(coin, network, nodeDir, defaultConfig) @@ -385,8 +379,13 @@ async function buildCryptoNode(coin, network) { if (!storage) return const stopBudgetSeconds = await prepareExistingContainer(containerPrefix, coin, network, storage, reject) if (!stopBudgetSeconds) return - const runArgs = createRunArgs(coin, network, containerPrefix, stopBudgetSeconds, storage.volumeMounts) - if (!addPortArgs(runArgs, defaultExposedPort, defaultNodePort, reject)) return + const runArgs = createNodeContainerRunArgs({ + coin, network, containerPrefix, stopBudgetSeconds, + volumeMounts: storage.volumeMounts, + getDockerNetwork, + logger + }) + addPortArgs(runArgs, defaultExposedPort, defaultNodePort) addDaemonArgs(runArgs, coin, storage.blocksDir, storage.useBlocksdirFlag, containerPrefix) runNodeContainer(runArgs, nodeDir, coin, network, resolve, reject) }) diff --git a/src/services/node_service/node_version_pin.js b/src/services/node_service/node_version_pin.js new file mode 100644 index 00000000..a88cc01c --- /dev/null +++ b/src/services/node_service/node_version_pin.js @@ -0,0 +1,48 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * XChain Node - Node Service: coin daemon version pin + * The XCHAIN_NODE_NODE_VERSION_ pin, shared by every install path + ********************************************************************/ + +const config = require('../../config'); + +// Optional exact-version pin for a coin daemon, read from +// XCHAIN_NODE_NODE_VERSION_ (e.g. XCHAIN_NODE_NODE_VERSION_LITECOIN=v0.21.4). +// Test harnesses (notably the multi-chain parity sweep) set this so the +// installed daemon matches the DEPLOYED fleet image instead of drifting to the +// latest upstream release. Returns null when no pin is set. +function resolveNodeVersionPin(coin) { + const pin = config.NODE_VERSION_PIN_ENV['XCHAIN_NODE_NODE_VERSION_' + String(coin).toUpperCase()] + return pin && pin.trim() !== '' ? pin.trim() : null +} + +// One spelling for a daemon version: the bitcoin download writes a bare `28.1` +// to the version file while pins and release tags read `v28.1`. +function normalizeNodeVersion(version) { + return String(version).trim().replace(/^v/, '') +} + +// Enforce a version pin against an already-installed local daemon. A silent +// mismatch would defeat the pin (an install skips the download when a local +// copy exists), so fail loudly with the remediation instead. +function assertNodeVersionPin(coin, network, localNodeVersion, pin) { + if (pin && localNodeVersion != null && normalizeNodeVersion(localNodeVersion) !== normalizeNodeVersion(pin)) { + throw new Error( + `Installed ${coin} node is ${String(localNodeVersion).trim()} but ` + + `XCHAIN_NODE_NODE_VERSION_${String(coin).toUpperCase()} pins ${pin}. ` + + `Remove the ${coin}/${network} stack (or the cached crypto node) and reinstall.`) + } +} + +module.exports = { resolveNodeVersionPin, normalizeNodeVersion, assertNodeVersionPin } diff --git a/src/services/release_manifest_service.js b/src/services/release_manifest_service.js index 28654420..01fba236 100644 --- a/src/services/release_manifest_service.js +++ b/src/services/release_manifest_service.js @@ -30,7 +30,7 @@ const fs = require('fs') const path = require('path') const axios = require('axios') -const { githubApiHeaders, githubRateLimitError } = require('../utils/github_api') +const { githubApiHeaders, githubRateLimitError, GITHUB_API_TIMEOUT_MS, GITHUB_DOWNLOAD_TIMEOUT_MS } = require('../utils/github_api') const { verifyManifestForTag } = require('./release_signature_service') const { getLogger } = require('../observability/logger'); const logger = getLogger(); @@ -113,7 +113,7 @@ async function fetchManifestAtTag(tag) { const url = `https://api.github.com/repos/${MANIFEST_OWNER}/${MANIFEST_REPO}/contents/src/${MANIFEST_FILE}` let result try { - result = await axios.get(url, { headers: githubApiHeaders(), params: { ref: tag } }) + result = await axios.get(url, { headers: githubApiHeaders(), params: { ref: tag }, timeout: GITHUB_API_TIMEOUT_MS }) } catch (error) { const rateLimited = githubRateLimitError(error) if (rateLimited) throw rateLimited @@ -155,7 +155,7 @@ async function fetchReleaseAsset(tag, assetName) { const url = `https://api.github.com/repos/${MANIFEST_OWNER}/${MANIFEST_REPO}/releases/tags/${tag}` let release try { - release = await axios.get(url, { headers: githubApiHeaders() }) + release = await axios.get(url, { headers: githubApiHeaders(), timeout: GITHUB_API_TIMEOUT_MS }) } catch (error) { const rateLimited = githubRateLimitError(error) if (rateLimited) throw rateLimited @@ -171,18 +171,27 @@ async function fetchReleaseAsset(tag, assetName) { // storage REJECTS a request still carrying our Authorization header (the // same trap githubApiHeaders documents for coin-node downloads). So follow // the redirect by hand and drop the credentials on the second hop. - const first = await axios.get(asset.url, { - headers: { ...githubApiHeaders(), Accept: 'application/octet-stream' }, - responseType: 'arraybuffer', - maxRedirects: 0, - validateStatus: status => (status >= 200 && status < 300) || [301, 302, 307, 308].includes(status) - }) + let first + try { + first = await axios.get(asset.url, { + headers: { ...githubApiHeaders(), Accept: 'application/octet-stream' }, + responseType: 'arraybuffer', + timeout: GITHUB_API_TIMEOUT_MS, + maxRedirects: 0, + validateStatus: status => (status >= 200 && status < 300) || [301, 302, 307, 308].includes(status) + }) + } catch (error) { + // The asset endpoint is still api.github.com and spends the same quota, + // so a rate-limited 403 gets the reset time and GITHUB_TOKEN advice too. + throw githubRateLimitError(error) || error + } if (first.status >= 300) { const location = first.headers && first.headers.location if (!location) throw new Error(`Release asset ${assetName} redirected without a location header`) const followed = await axios.get(location, { responseType: 'arraybuffer', + timeout: GITHUB_DOWNLOAD_TIMEOUT_MS, headers: { 'User-Agent': 'GitHubDownloader' } }) return Buffer.from(followed.data) @@ -201,20 +210,20 @@ async function verifyManifestBytes(tag, bytes) { // The ONE latest-release semantic for the platform (spec section 5). // -// Two paths existed and disagreed: VersionService hits `releases/latest`, which -// the GitHub API defines as the newest NON-prerelease, non-draft release; +// Two paths existed and disagreed: a VersionService helper hit `releases/latest`, +// which the GitHub API defines as the newest NON-prerelease, non-draft release; // GitHubDownloader fetches the full list and sorts by published_at, which does // not exclude either. They are reconciled by ruling that train releases are // never flagged as GitHub pre-releases (D1/section 5), which makes // `releases/latest` well-defined, and by routing platform-version resolution -// through this function only. GitHubDownloader keeps its own sort because it -// resolves COIN daemon releases from third-party repos whose flagging this -// project does not control. +// through this function only; the VersionService helper had no caller and is +// gone. GitHubDownloader keeps its own sort because it resolves COIN daemon +// releases from third-party repos whose flagging this project does not control. async function resolveLatestReleaseTag() { const url = `https://api.github.com/repos/${MANIFEST_OWNER}/${MANIFEST_REPO}/releases/latest` let result try { - result = await axios.get(url, { headers: githubApiHeaders() }) + result = await axios.get(url, { headers: githubApiHeaders(), timeout: GITHUB_API_TIMEOUT_MS }) } catch (error) { const rateLimited = githubRateLimitError(error) if (rateLimited) throw rateLimited @@ -239,11 +248,9 @@ async function resolveLatestReleaseTag() { * @param {string} [opts.defaultBranch] branch to fall back to when no * release can be resolved * @param {boolean} [opts.fallbackToBranch] false makes a no-ref call - * THROW when no release can be resolved instead of - * degrading to a branch. An install on a fresh box may - * reasonably fall back to master; an UPDATE of a - * release node must not, because that fallback would - * move every pinned module onto a branch tip. + * THROW when no published release exists instead of + * degrading to a branch. A failed lookup always throws + * because it cannot establish that no release exists. * @returns {Promise<{kind:'release'|'branch', ref:string, tag:string|null, * manifest:object|null, resolvedFrom:string}>} */ @@ -268,15 +275,10 @@ async function resolveInstallTarget(ref, { defaultBranch = 'master', fallbackToB try { tag = await resolveLatestReleaseTag() } catch (err) { - if (!fallbackToBranch) { - throw new Error( - `Could not resolve the latest xchain-node release (${err.message}).` - + ' Nothing was changed. Retry, or name the release explicitly (e.g. `update all v0.15.2`).' - ) - } - logger.warn(`Could not resolve the latest xchain-node release (${err.message}).`) - logger.warn(`Falling back to a tracking install of '${defaultBranch}' (UNRELEASED).`) - return { kind: 'branch', ref: defaultBranch, tag: null, manifest: null, resolvedFrom: 'fallback after lookup failure' } + throw new Error( + `Could not resolve the latest xchain-node release (${err.message}).` + + ' Nothing was changed. Retry, or name the release explicitly (e.g. `update all v0.15.2`).' + ) } if (!tag) { diff --git a/src/services/self_update_service.js b/src/services/self_update_service.js index 818b5012..ae3f3635 100644 --- a/src/services/self_update_service.js +++ b/src/services/self_update_service.js @@ -48,6 +48,7 @@ const execFileAsync = promisify(execFile) const { dataDir, SELF_UPDATE_ENV, childProcessEnv } = require('../config') const { getLogger } = require('../observability/logger') +const { adoptCommandLock } = require('../utils/command_lock') const CARRIER_ROOT = path.join(__dirname, '../..') const TARGET_ENV = 'XCHAIN_NODE_UPDATE_TARGET' @@ -143,6 +144,28 @@ async function installCarrierDependencies(deps) { }) } +async function reexecUpdatedCommand(tag, childArgs, deps, logger) { + if (typeof deps.commandLock === 'function') { + const adopt = deps.adoptCommandLock || adoptCommandLock + adopt(deps.commandLock) + } + const spawnImpl = deps.spawn || spawn + const child = spawnImpl(process.execPath, [process.argv[1], ...childArgs], { + cwd: process.cwd(), + stdio: 'inherit', + env: { ...(deps.env || childProcessEnv()), [TARGET_ENV]: tag } + }) + const exitCode = await new Promise((resolve) => { + child.on('exit', code => resolve(code == null ? 1 : code)) + child.on('error', err => { + logger.error(`Could not re-run xchain-node at ${tag}: ${err.message}`) + resolve(1) + }) + }) + if (deps.exit) return deps.exit(exitCode) + return process.exit(exitCode) +} + /** * Move the carrier checkout to `tag` and re-execute the command there. * @@ -193,26 +216,7 @@ async function selfUpdateAndReexec({ tag, childArgs, deps = {} }) { ) } logger.log(`xchain-node CLI is now ${tag}; continuing the update with it.`) - - // The caller's chance to hand back a lock the child is about to take. - if (typeof deps.beforeSpawn === 'function') { - try { deps.beforeSpawn() } catch { /* the child reports a held lock itself */ } - } - const spawnImpl = deps.spawn || spawn - const child = spawnImpl(process.execPath, [process.argv[1], ...childArgs], { - cwd: process.cwd(), - stdio: 'inherit', - env: { ...(deps.env || childProcessEnv()), [TARGET_ENV]: tag } - }) - const exitCode = await new Promise((resolve) => { - child.on('exit', code => resolve(code == null ? 1 : code)) - child.on('error', err => { - logger.error(`Could not re-run xchain-node at ${tag}: ${err.message}`) - resolve(1) - }) - }) - if (deps.exit) return deps.exit(exitCode) - return process.exit(exitCode) + return reexecUpdatedCommand(tag, childArgs, deps, logger) } /** diff --git a/src/services/status_service/status_reducers.js b/src/services/status_service/status_reducers.js index 179bfd1e..f32350a9 100644 --- a/src/services/status_service/status_reducers.js +++ b/src/services/status_service/status_reducers.js @@ -137,9 +137,12 @@ function describeNodeUnreachableNote(coin, network, module, gap) { } // The REORG_HALT fields of a decoder health payload, or null for anything that -// is not a payload. Strict `=== true` on the flag: an older image without the +// is not a payload. Accepts the plain body or the JSON-RPC `result` envelope, as +// the watchdog does. Strict `=== true` on the flag: an older image without the // field reads as not halted rather than as a halt. -function reduceDecoderReorgHalt(payload) { +function reduceDecoderReorgHalt(body) { + if (!body || typeof body !== 'object') return null + const payload = body.result !== undefined ? body.result : body if (!payload || typeof payload !== 'object') return null return { halted: payload.reorg_halted === true, diff --git a/src/services/stop_budget_service.js b/src/services/stop_budget_service.js index ccf38ca7..21d06bd9 100644 --- a/src/services/stop_budget_service.js +++ b/src/services/stop_budget_service.js @@ -98,34 +98,51 @@ function shutdownTimeoutMsForBudget(module, seconds) { return Math.max(budgetMs - STOP_DRAIN_MARGIN_MS, Math.floor(budgetMs / 2)) } -// A SHUTDOWN_TIMEOUT_MS the service would honour (parsed as the services parse -// it) that is at or above the stop budget, so docker's kill always lands first. -// Null when the value is under the budget or one the service would ignore. +// Whether a service honours this SHUTDOWN_TIMEOUT_MS. The services parse it with +// parseInt and fall back to their own default unless it is a positive number. +function serviceHonoursShutdownMs(value) { + if (value === undefined || value === null) return false + const ms = parseInt(value, 10) + return Number.isFinite(ms) && ms > 0 +} + +// A SHUTDOWN_TIMEOUT_MS the service would honour that is at or above the stop +// budget, so docker's kill always lands first. Null when the value is under the +// budget or one the service would ignore. function overBudgetShutdownMs(value, budgetSeconds) { - if (value === undefined || value === null) return null + if (!serviceHonoursShutdownMs(value)) return null const ms = parseInt(value, 10) - return Number.isFinite(ms) && ms > 0 && ms >= budgetSeconds * 1000 ? ms : null + return ms >= budgetSeconds * 1000 ? ms : null } -// The container env entry that carries the derived drain budget. An explicit -// SHUTDOWN_TIMEOUT_MS in the module config wins and nothing is added, and so -// does a null module (a one-shot run has no stop budget to derive from). -// Warn when that explicit value is not under the budget (docker kills first). +// The container env entry that carries the derived drain budget. A valid explicit +// SHUTDOWN_TIMEOUT_MS wins and nothing is added, as for a null module (a one-shot run +// has no budget). An ignored one counts as unset, since the service's own default only +// fits the default budget. Warn on an explicit value docker could kill first or ignored. function shutdownTimeoutEnv(module, moduleConfig, env = MODULE_STOP_TIMEOUT_ENV) { if (!module) return {} const configured = moduleConfig ? moduleConfig.SHUTDOWN_TIMEOUT_MS : undefined - if (configured !== undefined && configured !== null && String(configured).trim() !== '') { - if (module !== 'node') { - const budget = moduleStopTimeoutSeconds(module, env) - const over = overBudgetShutdownMs(configured, budget) - if (over !== null) logger.warn(overBudgetShutdownLine(module, '', over, budget, 'the module config sets')) - } + const explicit = configured !== undefined && configured !== null && String(configured).trim() !== '' + if (module === 'node') return {} + const budget = moduleStopTimeoutSeconds(module, env) + if (explicit && serviceHonoursShutdownMs(configured)) { + const over = overBudgetShutdownMs(configured, budget) + if (over !== null) logger.warn(overBudgetShutdownLine(module, '', over, budget, 'the module config sets')) return {} } - const derived = moduleShutdownTimeoutMs(module, env) + const derived = shutdownTimeoutMsForBudget(module, budget) + if (explicit) logger.warn(ignoredShutdownLine(module, configured, derived, budget)) return derived === null ? {} : { SHUTDOWN_TIMEOUT_MS: String(derived) } } +// The operator line for an explicit SHUTDOWN_TIMEOUT_MS the service would ignore. +function ignoredShutdownLine(module, raw, derived, budgetSeconds) { + const instead = derived === null ? 'the service keeps its own default' + : `the node is forwarding ${derived}, derived from the ${budgetSeconds} s stop budget, in its place` + return `WARNING: ${module}: the module config sets SHUTDOWN_TIMEOUT_MS=${raw}, which the service ignores ` + + `because it is not a positive number of milliseconds, so ${instead}. Correct or remove it in the module config.` +} + // The operator line for a drain timer at or above its stop budget. function overBudgetShutdownLine(module, where, ms, budgetSeconds, source) { return `WARNING: ${module}${where}: ${source} SHUTDOWN_TIMEOUT_MS=${ms}, at or above its ${budgetSeconds} s stop ` + @@ -141,18 +158,23 @@ function overBudgetShutdownLine(module, where, ms, budgetSeconds, source) { // read, or neither side involves a forwarded drain. function describeStopBudgetDrift(module, coin, network, settings, budgetSeconds) { if (!settings || module === 'node') return null + // A carried value the service ignores leaves its own default timer, the same as none at all + const carried = serviceHonoursShutdownMs(settings.shutdownTimeoutMs) ? settings.shutdownTimeoutMs : null const forwards = shutdownTimeoutMsForBudget(module, budgetSeconds) !== null - if (!forwards && settings.shutdownTimeoutMs === null) return null + if (!forwards && carried === null) return null const where = coin && network ? ` (${coin} ${network})` : '' let created if (settings.stopTimeout !== budgetSeconds) { created = Number.isInteger(settings.stopTimeout) ? `under a ${settings.stopTimeout} s stop budget` : 'without a stop budget' - } else if (settings.shutdownTimeoutMs === null && !isDefaultStopBudget(module, budgetSeconds)) { - created = 'before the node forwarded SHUTDOWN_TIMEOUT_MS' + } else if (carried === null && !isDefaultStopBudget(module, budgetSeconds)) { + const raw = settings.shutdownTimeoutMs + created = raw === null || raw === undefined || String(raw).trim() === '' + ? 'before the node forwarded SHUTDOWN_TIMEOUT_MS' + : `with SHUTDOWN_TIMEOUT_MS=${raw}, which the service ignores,` } else { // Flag a drain timer docker's kill would beat, before the stop rather than after it - const over = overBudgetShutdownMs(settings.shutdownTimeoutMs, budgetSeconds) + const over = overBudgetShutdownMs(carried, budgetSeconds) return over === null ? null : overBudgetShutdownLine(module, where, over, budgetSeconds, 'the container carries') } return `WARNING: ${module}${where} was created ${created}, so its own drain timer does not follow ` + @@ -185,14 +207,14 @@ function describeModuleStopOutcome(module, coin, network, outcome, budgetSeconds return `WARNING: ${module}${where} did not exit within the ${budgetSeconds} s budget and was killed. ` + `Raise ${moduleStopTimeoutEnvName(module)} if this service needs longer to finish its block; the node ` + 'derives the service\'s own SHUTDOWN_TIMEOUT_MS from it when the container is next recreated, unless ' + - 'the module config sets one.' + 'the module config sets a valid one.' } if (stoppedUnclean(outcome)) { return `WARNING: ${module}${where} exited with code ${outcome.exitCode} after ${outcome.seconds} s, inside the ` + `${budgetSeconds} s budget, so its shutdown drain did not complete: it overran the service's own ` + 'hard-exit timer (SHUTDOWN_TIMEOUT_MS where the service reads one) or failed. Check the service log. ' + `Raising ${moduleStopTimeoutEnvName(module)} gives that drain more time only once the container is ` + - 'recreated, and not while the module config sets SHUTDOWN_TIMEOUT_MS.' + 'recreated, and not while the module config sets a valid SHUTDOWN_TIMEOUT_MS.' } return `Stopped ${module}${where} cleanly in ${outcome.seconds} s (budget ${budgetSeconds} s).` } diff --git a/src/services/validator_service/capability_drift.js b/src/services/validator_service/capability_drift.js index 220439dc..0124cf61 100644 --- a/src/services/validator_service/capability_drift.js +++ b/src/services/validator_service/capability_drift.js @@ -152,14 +152,14 @@ async function readAnsweredCapabilitySets(pubkey, network, capabilities, deps = if (block === undefined || block === null) { try { const status = await sdk.explorer.getStatus() - block = Number(status && status.last_block && status.last_block[coins.stake]) + block = Number(status && status.last_block && status.last_block[coins.stakeCoin]) } catch (e) { return { unavailable: true, error: e.message } } } block = Number(block) if (!Number.isInteger(block) || block < 0) - return { unavailable: true, reason: 'the indexer reported no last block for ' + coins.stake } + return { unavailable: true, reason: 'the indexer reported no last block for ' + coins.stakeCoin } const sets = {} for (const capability of capabilities) { diff --git a/src/services/validator_stake_service.js b/src/services/validator_stake_service.js index 5bc8d8a8..d337e54b 100644 --- a/src/services/validator_stake_service.js +++ b/src/services/validator_stake_service.js @@ -36,6 +36,7 @@ const { const { getCoinConfigByFullName } = require('../coins') const config = require('../config'); const { readKeyHolders } = require('./validator_stake_service/free_key') +const { readSleepState } = require('./validator_stake_service/address_sleep') const { readTokenRow } = require('./validator_stake_service/token_balance_read') const STAKE_TICK = 'XCHAIN' @@ -192,9 +193,11 @@ async function readChainState(sdk, address, pubkey, keyCtx) { // a truncated read throws into the same catch rather than reading as absent. // readKeyHolders also applies the reuse gate and reads the key's delegations. const holders = await readKeyHolders(sdk, pubkey, keyCtx) + // Read whether the stake address put itself to sleep: the indexer rejects its MINTs and STAKE then. + const sleepState = await readSleepState(sdk, address, keyCtx.coins) return { coinBal, coinPending, tokenBal, mintMax: num(mints && mints.max), - mintAddressMax: num(mints && mints.address_max), mintUnreadable, ...holders } + mintAddressMax: num(mints && mints.address_max), mintUnreadable, ...holders, ...sleepState } } // Plan the mints: how many transactions, at what amount each, to lift the @@ -315,6 +318,12 @@ function openValidatorSession(opts, deps) { if (!network || !COIN_NETWORKS[network]) throw fail('validator network unknown; re-run `validator init --network testnet|mainnet`.') const coins = COIN_NETWORKS[network] const pubkey = String(settings.pubkey || '').toLowerCase() + // Refuse a pubkey the indexer's SIGNING_PUBKEY rule refuses (64 hex, Ed25519), before the WIF + // prompt or any read: it matches no rows, so it reads as free and --broadcast would pay every MINT. + if (!/^[0-9a-f]{64}$/.test(pubkey)) { + throw fail('the signing pubkey in validator.json is not 64 hex characters (an Ed25519 key), so the ' + + 'indexer would refuse the STAKE after the MINT fees were spent. Restore validator.json. Nothing was sent.') + } const wallets = deps.wallets !== undefined ? deps.wallets : readWallets() const wif = deps.wif || resolveStakeWif(wallets) diff --git a/src/services/validator_stake_service/address_sleep.js b/src/services/validator_stake_service/address_sleep.js new file mode 100644 index 00000000..6d1ca6a1 --- /dev/null +++ b/src/services/validator_stake_service/address_sleep.js @@ -0,0 +1,117 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * XChain Node - Validator Stake Address Sleep Check + * + * Whether the stake address is asleep at the block a transaction sent now + * can land in, judged the way the indexer's isAddressSleeping judges it + * (xchain-indexer/src/db/sleeps/index.js). The indexer rejects every MINT, + * STAKE and UNSTAKE from a sleeping SOURCE only after its fee is spent, so + * the CLI refuses first. The CLI only reads this rule; consensus stays in + * the indexer. + ********************************************************************/ + +const { readTip } = require('./free_key') + +// The explorer's per-method row cap, and the OFFSET ceiling that bounds a sweep. +const PAGE_LIMIT = 100 +const MAX_PAGES = 1001 + +// The sleeps.type value of an ADDRESS sleep (a TICK sleep is 2 and never puts an address to sleep). +const ADDRESS_SLEEP_TYPE = 1 + +/** + * Judge the address's sleep at landBlock from its SLEEP rows: only the newest + * valid ADDRESS sleep from this address decides, and it holds while its + * resume block is -1 (indefinite) or past landBlock. Returns + * { sleeping, resumeBlock, actionIndex }; throws on an unreadable resume block. + */ +function judgeAddressSleep(rows, address, landBlock) { + const own = rows.filter(r => r && String(r.source) === String(address) && + Number(r.type) === ADDRESS_SLEEP_TYPE && r.status === 'valid') + if (!own.length) return { sleeping: false, resumeBlock: null, actionIndex: null } + const newest = own.reduce((a, b) => (Number(b.action_index) > Number(a.action_index) ? b : a)) + const resumeBlock = Number(newest.resume_block) + if (!Number.isInteger(resumeBlock)) { + throw new Error('SLEEP action ' + newest.action_index + ' has no readable resume block') + } + return { sleeping: resumeBlock === -1 || resumeBlock > landBlock, resumeBlock, actionIndex: Number(newest.action_index) } +} + +/** + * Every SLEEP row the explorer's address lane holds for this address, paged + * to the end. Throws on anything short of a complete answer, so a failed + * read never comes back as "not asleep". + */ +async function readSleepRows(sdk, address) { + if (!sdk.explorer || typeof sdk.explorer.getSleeps !== 'function') { + throw new Error('this SDK has no getSleeps') + } + const rows = [] + for (let page = 1; ; page++) { + if (page > MAX_PAGES) throw new Error('sleep read incomplete: more than ' + (MAX_PAGES * PAGE_LIMIT) + ' rows') + const v = await sdk.explorer.getSleeps(address, 'address', { page, limit: PAGE_LIMIT, sortorder: 'ASC' }) + if (!v || !Array.isArray(v.data)) throw new Error('the sleep lookup by address returned no row list') + rows.push(...v.data) + const total = Number(v.total) + const known = v.total !== undefined && v.total !== null && Number.isInteger(total) && total >= 0 + if (known ? rows.length >= total : v.data.length < PAGE_LIMIT) break + if (!v.data.length) throw new Error('sleep read incomplete: saw ' + rows.length + ' of ' + total + ' rows') + } + return rows +} + +// Read and judge the address's sleep at landBlock; throws when it cannot be read. +async function readAddressSleep(sdk, address, landBlock) { + return judgeAddressSleep(await readSleepRows(sdk, address), address, landBlock) +} + +/** + * The stake address's sleep at the explorer tip + 1, as plan state: + * { sleep, sleepUnknown }, where sleepUnknown is the message of a failed + * tip or sleep read, never a silent "awake". + */ +async function readSleepState(sdk, address, coins) { + try { + const tip = await readTip(sdk, coins) + return { sleep: await readAddressSleep(sdk, address, tip + 1), sleepUnknown: null } + } catch (e) { + return { sleep: null, sleepUnknown: e.message } + } +} + +/** + * Why `action` (the operator's words for what would be sent) cannot go out + * from a sleeping address, or null when the address is awake. An address + * slept indefinitely cannot wake itself: the indexer rejects its SLEEP too. + */ +function sleepRefusal(sleep, address, action) { + if (!sleep || !sleep.sleeping) return null + const slept = 'the stake address ' + address + ' is asleep' + const rejects = ' (SLEEP at action ' + sleep.actionIndex + '), and the indexer rejects ' + action + + ' from a sleeping address after the fees are spent.' + if (sleep.resumeBlock === -1) { + return slept + ' indefinitely' + rejects + ' It also rejects a SLEEP from a sleeping address, so this ' + + 'address cannot wake itself; nothing it sends can be accepted.' + } + return slept + ' until block ' + sleep.resumeBlock + rejects + ' Re-run from block ' + sleep.resumeBlock + '.' +} + +// Why a failed sleep read stops `action`, in the operator's terms. +function sleepUnknownRefusal(message, address, action) { + return 'could not confirm the stake address ' + address + ' is not asleep (' + message + '). The indexer ' + + 'rejects ' + action + ' from a sleeping address after the fees are spent, so nothing is sent until this ' + + 'can be read. Retry, or check the explorer.' +} + +module.exports = { judgeAddressSleep, readAddressSleep, readSleepState, sleepRefusal, sleepUnknownRefusal } diff --git a/src/services/validator_stake_service/free_key.js b/src/services/validator_stake_service/free_key.js index 941ec836..4e69b6df 100644 --- a/src/services/validator_stake_service/free_key.js +++ b/src/services/validator_stake_service/free_key.js @@ -136,8 +136,8 @@ function sumAmounts(rows) { // The explorer's indexed tip for the stake chain; throws when it is unreadable. async function readTip(sdk, coins) { const status = await sdk.explorer.getStatus() - const tip = Number(status && status.last_block && status.last_block[coins.stake]) - if (!Number.isInteger(tip) || tip < 0) throw new Error('the explorer reported no last block for ' + coins.stake) + const tip = Number(status && status.last_block && status.last_block[coins.stakeCoin]) + if (!Number.isInteger(tip) || tip < 0) throw new Error('the explorer reported no last block for ' + coins.stakeCoin) return tip } @@ -247,5 +247,6 @@ module.exports = { delegationRowsHoldingKey, sumAmounts, readDelegationsByPubkey, - readKeyHolders + readKeyHolders, + readTip } diff --git a/src/services/validator_stake_service/stake_operations.js b/src/services/validator_stake_service/stake_operations.js index 2e402d54..43c5d98f 100644 --- a/src/services/validator_stake_service/stake_operations.js +++ b/src/services/validator_stake_service/stake_operations.js @@ -16,6 +16,7 @@ const { getLogger } = require('../../observability/logger') const { sendLockFor, releaseOnIndexWait, holdForSend } = require('./send_lock') +const { sleepRefusal, sleepUnknownRefusal } = require('./address_sleep') function defaultLog() { const logger = getLogger() @@ -50,11 +51,13 @@ function logStakeSteps(log, amount, pubkey, plan, timing, STAKE_TICK, paren) { // one that decides whether this XCHAIN is reachable next week. log('') log(' The ' + amount + ' ' + STAKE_TICK + ' is escrowed for as long as you stay staked. Standing down') + // Both exit clocks start at the UNSTAKE block, so the active-set delay runs inside the cooldown. log(' later frees it only after a cooldown of ' + timing.cooldownBlocks + ' blocks' + - paren(timing.cooldownFor) + ', on top of the') - log(' ' + timing.activationBlocks + ' blocks it takes to leave the active set. Do not stake ' + - STAKE_TICK + ' you may') - log(' need before then.') + paren(timing.cooldownFor) + ', counted from the') + log(' block the UNSTAKE lands in. The ' + timing.activationBlocks + ' blocks it takes to leave the active set') + log(' start at that same block and run inside the cooldown, not before it. Do not stake ' + + STAKE_TICK) + log(' you may need before then.') return steps } @@ -83,9 +86,19 @@ function stakeBlockers(state, plan, coins, address) { 'confirm the signing pubkey is not already staked. Nothing is sent until the set can be read; ' + 'retry, or check the explorer.') } + // Refuse a sleeping stake address, or one whose sleep state is unreadable. + const slept = stakeSleepBlocker(state, address) + if (slept) blockers.push(slept) return blockers } +// Why the stake address's sleep stops the MINTs and the STAKE, or null when it does not. +function stakeSleepBlocker(state, address) { + const action = 'every MINT and the STAKE' + if (state.sleepUnknown) return sleepUnknownRefusal(state.sleepUnknown, address, action) + return sleepRefusal(state.sleep, address, action) +} + // Why a key held only by withdrawn stake cannot be staked yet, in the operator's terms. function heldKeyReason(judged, timing) { if (judged.tipError) { diff --git a/src/services/validator_stake_service/unstake_operations.js b/src/services/validator_stake_service/unstake_operations.js index c42bf4b2..78c5a2cc 100644 --- a/src/services/validator_stake_service/unstake_operations.js +++ b/src/services/validator_stake_service/unstake_operations.js @@ -19,6 +19,7 @@ const { getLogger } = require('../../observability/logger') const { readValidatorSet } = require('./validator_set_read') const { sumAmounts } = require('./free_key') const { sendLockFor, releaseOnIndexWait } = require('./send_lock') +const { readAddressSleep, sleepRefusal, sleepUnknownRefusal } = require('./address_sleep') function defaultLog() { const logger = getLogger() @@ -99,13 +100,13 @@ async function readTip(sdk, coins, fail) { let tip try { const status = await sdk.explorer.getStatus() - tip = Number(status && status.last_block && status.last_block[coins.stake]) + tip = Number(status && status.last_block && status.last_block[coins.stakeCoin]) } catch (e) { throw fail('could not read the chain tip (' + e.message + '), so this run cannot tell ' + 'whether the stake is active yet. Nothing was sent.') } if (!Number.isInteger(tip) || tip < 0) { - throw fail('the explorer reported no last block for ' + coins.stake + ', so this run cannot tell ' + + throw fail('the explorer reported no last block for ' + coins.stakeCoin + ', so this run cannot tell ' + 'whether the stake is active yet. Nothing was sent.') } return tip @@ -190,9 +191,23 @@ async function resolveUnstakeTarget({ sdk, coins, pubkey, address, timing, log, } // The indexer's UNSTAKE leaves a pending top-up undeactivated, so it would activate later and keep this key in N. if (pending.length) throw fail(pendingTopUpRefusal(pending, tip, STAKE_TICK)) + await assertOwnerAwake(sdk, address, tip + 1, fail) return { active: withdrawnStake(admissible) } } +// Refuse when the owning address is asleep at landBlock, or its sleep state cannot be read. +async function assertOwnerAwake(sdk, address, landBlock, fail) { + const action = 'the UNSTAKE' + let sleep + try { + sleep = await readAddressSleep(sdk, address, landBlock) + } catch (e) { + throw fail(sleepUnknownRefusal(e.message, address, action) + ' Nothing was sent.') + } + const refusal = sleepRefusal(sleep, address, action) + if (refusal) throw fail(refusal + ' Nothing was sent.') +} + function createUnstakeValidator({ openValidatorSession, stakeTiming, fail, paren, explorerUrl, STAKE_TICK }) { diff --git a/src/services/version_service.js b/src/services/version_service.js index 8c43a803..16cc8599 100644 --- a/src/services/version_service.js +++ b/src/services/version_service.js @@ -16,14 +16,12 @@ ********************************************************************/ const fs = require('fs') -const axios = require('axios') const { NODE_MODULE_NAME, EXPLORER_MODULE_NAME, SEP, XChainService, Coin, NODE_VERSION_FILE_NAME, projectFolders } = require('../config') const { gitHubDownloader, getRemoteModuleVersions, setRemoteModuleVersion } = require('../state') -const { githubApiHeaders, githubRateLimitError } = require('../utils/github_api') const { getModuleDir, getModuleTmpDir, getCryptoNodeDir } = require('./config_service') const { getDockerContainerFileData, getDockerContainerFileCat } = require('./docker_service') // Services on the far side of the require cycle, resolved when first used. @@ -71,22 +69,6 @@ async function readContainerFile(containerId, filePath) { } } -async function getGithubProjectVersion(owner, repoName) { - const url = "https://api.github.com/repos/" + owner + "/" + repoName + "/releases/latest" - let result - try { - result = await axios.get(url, { headers: githubApiHeaders() }) - } catch (error) { - throw githubRateLimitError(error) || error - } - const json = result.data - - let tagName = json["tag_name"] - if (tagName.charAt(0) === 'v') tagName = tagName.substring(1) - - return { version: tagName, id: json["id"] } -} - async function checkRemoteNodeVersion(coin) { let githubProjectVersion = null @@ -207,7 +189,6 @@ async function getContainerModuleVersion(module, coin, network, containerId) { module.exports = { readContainerFile, - getGithubProjectVersion, checkRemoteNodeVersion, checkAllRemoteVersions, getLocalNodeVersion, diff --git a/src/ui/menu.js b/src/ui/menu.js index 6e06ca7d..af005dcd 100644 --- a/src/ui/menu.js +++ b/src/ui/menu.js @@ -35,6 +35,7 @@ const { const { scanAndRegisterModules } = require('../services/discovery_service') const { restoreBootstrapInterface } = require('./menu/restore_bootstrap_prompt.js') const { buildModuleChoices } = require('./menu/module_choices.js') +const { NODE_VERSION_ORDER, remoteNodeVersion } = require('./menu/node_version_order.js') const config = require('../config') const { acquireCommandLock } = require('../utils/command_lock') const { scopedCommandLock } = require('../cli/dispatch') @@ -77,8 +78,9 @@ const ACTION_INSTALL_LOCAL_IN_CONTAINER = "Install Local Version in Container" async function readModuleVersions(selectedValue, remoteModuleVersions, coin, network) { let remoteVersion = "0" try { + // The node's entry is the GitHub release object itself, so its version is the tag remoteVersion = selectedValue === NODE_MODULE_NAME - ? await remoteModuleVersions[selectedValue + SEP + coin]["version"] + ? remoteNodeVersion(remoteModuleVersions[selectedValue + SEP + coin]) : await remoteModuleVersions[selectedValue] } catch { /* not available */ } @@ -91,25 +93,34 @@ async function readModuleVersions(selectedValue, remoteModuleVersions, coin, net return { remoteVersion, localVersion } } +// Module versions come from package.json and compare as semver. +const SEMVER_ORDER = { + valid: version => semver.valid(version) !== null, + gt: (a, b) => semver.gt(a, b), + eq: (a, b) => semver.eq(a, b) +} + // Lists an installed module's actions from its status and version comparisons. function installedModuleActions(selectedStatus, selectedValue, localVersion, remoteVersion, containerVersion) { const moduleActions = [{ name: "Tail logs", value: "tail" }] + // Coin node versions are daemon tags, not semver (see node_version_order.js) + const order = selectedValue === NODE_MODULE_NAME ? NODE_VERSION_ORDER : SEMVER_ORDER if (selectedStatus === "exited") moduleActions.push({ name: "Restart", value: "restart" }) // enquirer's Select resolves to a choice's NAME, so the handler below must // branch on these exact strings, or picking one runs nothing and drops back // to the module list. Shared constants keep both sides renamed together. - if (semver.valid(localVersion)) { - if (semver.valid(remoteVersion)) { - if (semver.gt(remoteVersion, localVersion)) { + if (order.valid(localVersion)) { + if (order.valid(remoteVersion)) { + if (order.gt(remoteVersion, localVersion)) { moduleActions.push({ name: ACTION_UPDATE_LOCAL, value: "update local version" }) - } else if (semver.eq(remoteVersion, localVersion)) { + } else if (order.eq(remoteVersion, localVersion)) { moduleActions.push({ name: ACTION_REINSTALL_REMOTE, value: "reinstall from remote" }) } } - if (semver.valid(containerVersion)) { - if (semver.gt(localVersion, containerVersion)) { + if (order.valid(containerVersion)) { + if (order.gt(localVersion, containerVersion)) { moduleActions.push({ name: ACTION_UPDATE_CONTAINER, value: "update container" }) } else { moduleActions.push({ name: ACTION_REINSTALL_CONTAINER, value: "reinstall container" }) @@ -117,7 +128,7 @@ function installedModuleActions(selectedStatus, selectedValue, localVersion, rem } else { moduleActions.push({ name: ACTION_INSTALL_LOCAL_IN_CONTAINER, value: "install local version in container" }) } - } else if (semver.valid(remoteVersion)) { + } else if (order.valid(remoteVersion)) { moduleActions.push({ name: ACTION_UPDATE_LOCAL, value: "update local version" }) } @@ -161,6 +172,15 @@ async function runInstalledModuleAction(actionAnswer, selected, coin, network) { } catch (err) { console.log(redactSecrets(err)) } + } else if (selectedValue === NODE_MODULE_NAME + && (actionAnswer === ACTION_UPDATE_LOCAL || actionAnswer === ACTION_REINSTALL_REMOTE)) { + // A coin node has no source checkout to clone (cloneGit refuses "node"), so + // both remote actions run `update node`, which keeps its data-dir guard. + try { + await locked.updateModules({ [coin]: { [network]: [selectedValue] } }) + } catch (err) { + console.log(redactSecrets(err)) + } } else if (actionAnswer === ACTION_UPDATE_LOCAL) { await locked.cloneGit(selectedValue, true, false) } else if (actionAnswer === ACTION_UPDATE_CONTAINER @@ -349,6 +369,10 @@ module.exports = { modulesSelectionInterface, restoreBootstrapInterface, startInterface, + // Exported so tests can drive the version reads, the offered actions and their handlers. + readModuleVersions, + installedModuleActions, + runInstalledModuleAction, // Exported so a test can assert every offered label has a handler branch. MODULE_ACTION_LABELS: { ACTION_UPDATE_LOCAL, diff --git a/src/ui/menu/node_version_order.js b/src/ui/menu/node_version_order.js new file mode 100644 index 00000000..2dc4c410 --- /dev/null +++ b/src/ui/menu/node_version_order.js @@ -0,0 +1,49 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * XChain Node - Interactive UI / Coin node version order + ********************************************************************/ + +// Coin daemons tag two-, three- and four-part versions (28.1, 1.14.9, 0.21.5.6), +// and semver rejects the first and the last, so node versions compare by parts. + +// Splits "v0.21.5.6" or "28.1\n" into numbers; null for anything else, "0" included. +function nodeVersionParts(raw) { + if (raw == null) return null + // Require at least two parts, so the menu's "0" (unavailable) never reads as a version + const match = /^v?(\d+(?:\.\d+)+)$/.exec(String(raw).trim()) + return match ? match[1].split('.').map(Number) : null +} + +// Orders two parsed versions; a missing trailing part counts as zero. +function compareParts(a, b) { + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const diff = (a[i] ?? 0) - (b[i] ?? 0) + if (diff !== 0) return Math.sign(diff) + } + return 0 +} + +// The valid / gt / eq questions the module-action menu asks of a version scheme. +const NODE_VERSION_ORDER = { + valid: version => nodeVersionParts(version) !== null, + gt: (a, b) => compareParts(nodeVersionParts(a), nodeVersionParts(b)) > 0, + eq: (a, b) => compareParts(nodeVersionParts(a), nodeVersionParts(b)) === 0 +} + +// Reads the stored GitHub release's tag; "0" when no release has been looked up. +function remoteNodeVersion(release) { + return release?.["tag_name"] ?? "0" +} + +module.exports = { NODE_VERSION_ORDER, nodeVersionParts, remoteNodeVersion } diff --git a/src/utils/command_lock.js b/src/utils/command_lock.js index 45dbaf1d..3a018dad 100644 --- a/src/utils/command_lock.js +++ b/src/utils/command_lock.js @@ -38,6 +38,9 @@ * Each acquisition also stamps a random nonce into the payload. Release * removes the file only when both the pid and the nonce still match, so * neither a stale-lock takeover nor a recycled pid is clobbered on exit. + * A self-update releases its parent-owned lock before starting the re-exec + * child. The child then competes for the lock normally, so another mutator + * that acquires it first causes the child to refuse the update. * * Ops note: clearing a wedged lock by hand may mean deleting both * command.lock and command.lock.reclaim. @@ -101,6 +104,13 @@ function sameFile(a, b) { return !!a && !!b && a.dev === b.dev && a.ino === b.ino } +function adoptCommandLock(release) { + if (typeof release !== 'function') { + throw new TypeError('adoptCommandLock requires a command lock release function') + } + release() +} + // Break a reclaim marker left behind by a crash, and ONLY that: a marker whose // recorded pid is still alive belongs to a contender currently inside the reclaim // section, and removing it would restore the very race the marker prevents. @@ -226,4 +236,4 @@ function releaseCommandLock(lockFile, pid, nonce) { try { fs.unlinkSync(lockFile) } catch { /* already gone */ } } -module.exports = { acquireCommandLock, getLockFilePath, isPidAlive } +module.exports = { acquireCommandLock, adoptCommandLock, getLockFilePath, isPidAlive } diff --git a/src/utils/github_api.js b/src/utils/github_api.js index ad93fa6c..b0d9134e 100644 --- a/src/utils/github_api.js +++ b/src/utils/github_api.js @@ -23,6 +23,12 @@ const config = require('../config'); +// Bound every GitHub request (axios defaults to none) so a stalled route rejects and advisory +// callers degrade instead of hanging; it caps the wait for headers and each idle gap, not a body. +const GITHUB_API_TIMEOUT_MS = 20000; +// Release assets come from object storage behind a redirect; give them the bootstrap download's bound. +const GITHUB_DOWNLOAD_TIMEOUT_MS = 60000; + /** * Headers for an api.github.com call. * @@ -57,4 +63,4 @@ function githubRateLimitError(error) { return new Error(`GitHub API rate limit exhausted for this IP (resets ${resetAt}); set GITHUB_TOKEN to raise the limit`); } -module.exports = { githubApiHeaders, githubRateLimitError }; +module.exports = { githubApiHeaders, githubRateLimitError, GITHUB_API_TIMEOUT_MS, GITHUB_DOWNLOAD_TIMEOUT_MS }; diff --git a/src/utils/indexer_migrate_cli.js b/src/utils/indexer_migrate_cli.js index df4041fe..417d9087 100644 --- a/src/utils/indexer_migrate_cli.js +++ b/src/utils/indexer_migrate_cli.js @@ -14,9 +14,11 @@ * XChain Node - Indexer migrate CLI location * Where xchain-indexer's operator migration CLI sits inside a running * container, and which of its known paths the last read of that container - * found. + * found. The decoder's CLI is read the same way from its own paths. ********************************************************************/ +const { XChainService } = require('../config/module_names') + // Newest first. In v0.19.0 the indexer moved the CLI from the top of src/ 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 either layout, so @@ -24,14 +26,22 @@ // carries them. const MIGRATE_CLI_PATHS = ['src/db/migration/migrate.js', 'src/migrate.js'] +// Newest first. In v0.20.1 the decoder moved its CLI from the top of src/ into src/db/. +const DECODER_MIGRATE_CLI_PATHS = ['src/db/migrate.js', 'src/migrate.js'] + +/** The known CLI paths for a module's builds; every module but the decoder uses the indexer layout. */ +function migrateCliPathsFor(module) { + return module === XChainService.XCHAIN_DECODER ? DECODER_MIGRATE_CLI_PATHS : MIGRATE_CLI_PATHS +} + // 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 // found there rather than the layout of the source about to be deployed. const foundPaths = new Map() /** Locate the first readable migrate CLI path known for this container build. */ -async function readMigrateCli(cat, container) { - for (const cliPath of MIGRATE_CLI_PATHS) { +async function readMigrateCli(cat, container, paths = MIGRATE_CLI_PATHS) { + for (const cliPath of paths) { let source = null try { source = await cat(container, cliPath) @@ -52,12 +62,31 @@ async function readMigrateCli(cat, container) { * found. With no successful read the refusal tells the operator NOT to run the * CLI, and the newest layout is the one to name there. */ -function migrateCliPathFor(container) { - return foundPaths.get(container) || MIGRATE_CLI_PATHS[0] +function migrateCliPathFor(container, paths = MIGRATE_CLI_PATHS) { + return foundPaths.get(container) || paths[0] +} + +/** + * Rows from the decoder CLI's `--status --json`, which names files as + * {applied:[...], pending:[...]} instead of the indexer's counted rows, or null + * when the response is not exactly that shape or the CLI has no --file. It + * names no mode, so every pending file reads as manual: the list errs long. + */ +function decoderStatusRows(status, source) { + if (!/(['"])--file\1/.test(source)) return null + const isNameList = list => Array.isArray(list) && list.every(f => typeof f === 'string' && f.length > 0) + if (!isNameList(status.applied) || !isNameList(status.pending)) return null + const files = [...status.applied, ...status.pending] + if (new Set(files).size !== files.length) return null + const applied = new Set(status.applied) + return files.sort().map(file => ({ file, applied: applied.has(file) })) } module.exports = { MIGRATE_CLI_PATHS, + DECODER_MIGRATE_CLI_PATHS, + migrateCliPathsFor, readMigrateCli, - migrateCliPathFor + migrateCliPathFor, + decoderStatusRows } diff --git a/src/utils/module_database_name.js b/src/utils/module_database_name.js new file mode 100644 index 00000000..ad141b19 --- /dev/null +++ b/src/utils/module_database_name.js @@ -0,0 +1,38 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * XChain Node - Configured module database name + ********************************************************************/ + +'use strict' + +const { XChainService } = require('../config/module_names') +const { assertSafeDbIdentifier } = require('./sql_safety') + +// Map each MariaDB-backed module to the config key that names its database. +const DATABASE_NAME_KEY = { + [XChainService.XCHAIN_DECODER]: 'DECODER_DB_NAME', + [XChainService.XCHAIN_INDEXER]: 'INDEXER_DB_NAME' +} + +// Resolve the database a module's service actually uses: the operator-overridable +// config key provisioning grants on, else the derived default name. Bootstrap paths +// that read the default instead restore, dump or probe a database the service never reads. +function configuredDatabaseName(module, cfg, defaultName) { + const key = DATABASE_NAME_KEY[module] + const configured = key && cfg ? cfg[key] : undefined + const name = typeof configured === 'string' && configured.trim() !== '' ? configured.trim() : defaultName + return assertSafeDbIdentifier(name, 'database name') +} + +module.exports = { DATABASE_NAME_KEY, configuredDatabaseName } diff --git a/stryker.config.json b/stryker.config.json index bfd4ff92..57c0f677 100644 --- a/stryker.config.json +++ b/stryker.config.json @@ -6,10 +6,15 @@ "src/services/config_service.js", "src/services/config_service/**", "src/services/module_service.js", + "src/services/module_service/**", "src/services/docker_service.js", + "src/services/docker_service/**", "src/services/database_service.js", + "src/services/database_service/**", "src/services/bootstrap_service.js", - "src/services/status_service.js" + "src/services/bootstrap_service/**", + "src/services/status_service.js", + "src/services/status_service/**" ], "testRunner": "mocha", "testRunnerNodeArgs": [ diff --git a/test/boundary/boundary.test.js b/test/boundary/boundary.test.js index aabed866..0b564a9f 100644 --- a/test/boundary/boundary.test.js +++ b/test/boundary/boundary.test.js @@ -25,9 +25,22 @@ function streamFromString(str) { return s } +// Fresh-credential generation reads, rewrites and chmods the sidecar, so every +// stub handed to proxyquire carries inert versions of those calls unless a test +// supplies its own. +function withInertFsWrites(fsStub) { + return { + readFileSync: sinon.stub().returns(''), + writeFileSync: sinon.stub(), + appendFileSync: sinon.stub(), + chmodSync: sinon.stub(), + ...fsStub + } +} + function makeConfigService(fsStub) { return proxyquire('../../src/services/config_service', { - 'fs': fsStub || require('fs') + 'fs': fsStub ? withInertFsWrites(fsStub) : require('fs') }) } diff --git a/test/chaos/config_resilience_chaos.test.js b/test/chaos/config_resilience_chaos.test.js index 989b2337..a1a6a6a4 100644 --- a/test/chaos/config_resilience_chaos.test.js +++ b/test/chaos/config_resilience_chaos.test.js @@ -21,10 +21,12 @@ function makeConfigService(fsStub, readlineOverride) { const constants = require('../../src/config/index') const stubs = { 'fs': fsStub || require('fs'), - '../config/constants': { + '../config': { ...constants, - configDir: '/test/config' + configDir: '/test/config', + EXTERNAL_DB: false }, + './database_service': { getDatabaseContainerId: async () => null }, '../utils/helpers': { stringToCoin: (s) => s } } if (readlineOverride) { diff --git a/test/chaos/config_resilience_chaos.test/argument_parsing_resilience_chaos.test.js b/test/chaos/config_resilience_chaos.test/argument_parsing_resilience_chaos.test.js index 46abc5ae..7a455e6b 100644 --- a/test/chaos/config_resilience_chaos.test/argument_parsing_resilience_chaos.test.js +++ b/test/chaos/config_resilience_chaos.test/argument_parsing_resilience_chaos.test.js @@ -19,10 +19,12 @@ function makeConfigService(fsStub, readlineOverride) { const constants = require('../../../src/config/index') const stubs = { 'fs': fsStub || require('fs'), - '../config/constants': { + '../config': { ...constants, - configDir: '/test/config' + configDir: '/test/config', + EXTERNAL_DB: false }, + './database_service': { getDatabaseContainerId: async () => null }, '../utils/helpers': { stringToCoin: (s) => s } } if (readlineOverride) { diff --git a/test/chaos/config_resilience_chaos.test/config_path_traversal_chaos.test.js b/test/chaos/config_resilience_chaos.test/config_path_traversal_chaos.test.js index 2c83a3bc..2d8c539c 100644 --- a/test/chaos/config_resilience_chaos.test/config_path_traversal_chaos.test.js +++ b/test/chaos/config_resilience_chaos.test/config_path_traversal_chaos.test.js @@ -20,10 +20,12 @@ function makeConfigService(fsStub, readlineOverride) { const constants = require('../../../src/config/index') const stubs = { 'fs': fsStub || require('fs'), - '../config/constants': { + '../config': { ...constants, - configDir: '/test/config' + configDir: '/test/config', + EXTERNAL_DB: false }, + './database_service': { getDatabaseContainerId: async () => null }, '../utils/helpers': { stringToCoin: (s) => s } } if (readlineOverride) { diff --git a/test/chaos/config_resilience_chaos.test/malformed_config_content_chaos.test.js b/test/chaos/config_resilience_chaos.test/malformed_config_content_chaos.test.js index bea14ae6..cca10dd5 100644 --- a/test/chaos/config_resilience_chaos.test/malformed_config_content_chaos.test.js +++ b/test/chaos/config_resilience_chaos.test/malformed_config_content_chaos.test.js @@ -15,15 +15,28 @@ const { expect } = require('chai') const proxyquire = require('proxyquire').noCallThru() const { Readable } = require('stream') +// getDefaultConfig rewrites the sidecar with generated database passwords; +// these experiments parse the main config, so the sidecar calls are no-ops. +function withSidecarCalls(fsStub) { + return { + readFileSync: sinon.stub().returns(''), + writeFileSync: sinon.stub(), + chmodSync: sinon.stub(), + ...fsStub + } +} + // Helpers function makeConfigService(fsStub, readlineOverride) { const constants = require('../../../src/config/index') const stubs = { - 'fs': fsStub || require('fs'), - '../config/constants': { + 'fs': fsStub ? withSidecarCalls(fsStub) : require('fs'), + '../config': { ...constants, - configDir: '/test/config' + configDir: '/test/config', + EXTERNAL_DB: false }, + './database_service': { getDatabaseContainerId: async () => null }, '../utils/helpers': { stringToCoin: (s) => s } } if (readlineOverride) { diff --git a/test/chaos/config_resilience_chaos.test/port_validation_under_chaos.test.js b/test/chaos/config_resilience_chaos.test/port_validation_under_chaos.test.js index dc23b4a4..b692e06f 100644 --- a/test/chaos/config_resilience_chaos.test/port_validation_under_chaos.test.js +++ b/test/chaos/config_resilience_chaos.test/port_validation_under_chaos.test.js @@ -19,10 +19,12 @@ function makeConfigService(fsStub, readlineOverride) { const constants = require('../../../src/config/index') const stubs = { 'fs': fsStub || require('fs'), - '../config/constants': { + '../config': { ...constants, - configDir: '/test/config' + configDir: '/test/config', + EXTERNAL_DB: false }, + './database_service': { getDatabaseContainerId: async () => null }, '../utils/helpers': { stringToCoin: (s) => s } } if (readlineOverride) { diff --git a/test/chaos/config_resilience_chaos.test/shared_module_config_chaos.test.js b/test/chaos/config_resilience_chaos.test/shared_module_config_chaos.test.js index 300271f2..14acaf84 100644 --- a/test/chaos/config_resilience_chaos.test/shared_module_config_chaos.test.js +++ b/test/chaos/config_resilience_chaos.test/shared_module_config_chaos.test.js @@ -13,35 +13,45 @@ const sinon = require('sinon') const { expect } = require('chai') const proxyquire = require('proxyquire').noCallThru() +const fs = require('fs') +const os = require('os') +const path = require('path') // Helpers -function makeConfigService(fsStub, readlineOverride) { +// getDefaultConfig writes sidecars under configDir through the real fs, so +// each experiment gets a fresh writable directory. +function makeConfigService(configDir) { const constants = require('../../../src/config/index') const stubs = { - 'fs': fsStub || require('fs'), - '../config/constants': { + 'fs': fs, + '../config': { ...constants, - configDir: '/test/config' + configDir, + EXTERNAL_DB: false }, + './database_service': { getDatabaseContainerId: async () => null }, '../utils/helpers': { stringToCoin: (s) => s } } - if (readlineOverride) { - stubs['readline'] = readlineOverride - } return proxyquire('../../../src/services/config_service', stubs) } describe('Chaos: Config Resilience', function () { + let configDir + + beforeEach(function () { + configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xchain-node-config-chaos-')) + }) afterEach(function () { sinon.restore() + fs.rmSync(configDir, { recursive: true, force: true }) }) // Experiment: getDefaultConfig with no coin/network (shared modules) describe('Experiment: Shared module config (no coin/network)', function () { it('returns shared defaults when coin and network are null', async function () { - const cs = makeConfigService() + const cs = makeConfigService(configDir) const config = await cs.getDefaultConfig('xchain-hub', null, null) expect(config).to.have.property('HUB_HOST', '0.0.0.0') @@ -50,7 +60,7 @@ describe('Chaos: Config Resilience', function () { }) it('returns shared defaults when coin and network are empty strings', async function () { - const cs = makeConfigService() + const cs = makeConfigService(configDir) const config = await cs.getDefaultConfig('xchain-explorer', '', '') expect(config).to.have.property('EXPLORER_HOST') diff --git a/test/chaos/git_clone_resilience_chaos.test.js b/test/chaos/git_clone_resilience_chaos.test.js index 351e7e9d..57d45b9a 100644 --- a/test/chaos/git_clone_resilience_chaos.test.js +++ b/test/chaos/git_clone_resilience_chaos.test.js @@ -30,6 +30,12 @@ function makeStubs() { } } +// The clone is one git call among several the service makes, so tests pick it +// out by its arguments instead of counting every execFile call. +function cloneCalls(stubs) { + return stubs.execFile.getCalls().filter(c => c.args[0] === 'git' && Array.isArray(c.args[1]) && c.args[1][0] === 'clone') +} + function loadModuleService(stubs, configOverrides = {}) { return proxyquire('../../src/services/module_service', { 'child_process': { execFile: stubs.execFile }, @@ -215,8 +221,9 @@ describe('Chaos: Git Clone Resilience', function () { const ms = loadModuleService(stubs) await ms.cloneGit('xchain-encoder', false, false, 'feature/my-branch') - expect(stubs.execFile.calledOnce).to.be.true - expect(stubs.execFile.firstCall.args[1]).to.include('feature/my-branch') + const clones = cloneCalls(stubs) + expect(clones).to.have.length(1) + expect(clones[0].args[1]).to.include.members(['-b', 'feature/my-branch']) }) }) }) @@ -250,7 +257,7 @@ describe('Chaos: Git Clone Resilience', function () { }) await ms.cloneGit('xchain-encoder', true, false) - expect(stubs.execFile.calledOnce).to.be.true + expect(cloneCalls(stubs)).to.have.length(1) // The replacement is a swap of the staged clone, not a delete-then-clone: // the old directory only goes away after git has succeeded. expect(stubs.fs.renameSync.callCount).to.equal(2) diff --git a/test/chaos/process_resilience_chaos.test.js b/test/chaos/process_resilience_chaos.test.js index 352d74cd..394fadbf 100644 --- a/test/chaos/process_resilience_chaos.test.js +++ b/test/chaos/process_resilience_chaos.test.js @@ -42,8 +42,9 @@ describe('Chaos: Process Resilience', function () { await ms.buildAndUp('xchain-encoder', 'bitcoin', 'regtest') expect.fail('should have rejected') } catch (err) { - expect(err).to.include('Error creating Docker image') - expect(err).to.include('Unexpected build error') + const message = err instanceof Error ? err.message : err + expect(message).to.include('Error creating Docker image') + expect(message).to.include('Unexpected build error') } }) }) diff --git a/test/chaos/process_resilience_chaos.test/async_error_propagation_overwrite_kill_chaos.test.js b/test/chaos/process_resilience_chaos.test/async_error_propagation_overwrite_kill_chaos.test.js index b4fcfa3f..ba05e508 100644 --- a/test/chaos/process_resilience_chaos.test/async_error_propagation_overwrite_kill_chaos.test.js +++ b/test/chaos/process_resilience_chaos.test/async_error_propagation_overwrite_kill_chaos.test.js @@ -22,7 +22,7 @@ describe('Chaos: Process Resilience', function () { describe('Experiment 11: Async error propagation', function () { - it('handles rejection from killContainer during overwrite gracefully', async function () { + it('propagates rejection from stopContainerByName during overwrite', async function () { const stubs = makeStubs() sinon.stub(console, 'log') const containerId = 'b'.repeat(64) @@ -38,14 +38,18 @@ describe('Experiment 11: Async error propagation', function () { } }) - const ms = loadModuleService(stubs, { - killContainer: sinon.stub().rejects(new Error('container not running')), - removeContainer: sinon.stub().resolves(true) - }) - - // killContainer failure should be caught (container may not be running) - const result = await ms.buildAndUp('xchain-encoder', 'bitcoin', 'regtest', 'old-container-id') - expect(result).to.equal(containerId) + const stopContainerByName = sinon.stub().rejects(new Error('container not running')) + const removeContainer = sinon.stub().resolves(true) + const ms = loadModuleService(stubs, { stopContainerByName, removeContainer }) + + try { + await ms.buildAndUp('xchain-encoder', 'bitcoin', 'regtest', 'old-container-id') + expect.fail('should have rejected') + } catch (err) { + expect(err.message).to.equal('container not running') + } + expect(stopContainerByName.calledOnceWith('old-container-id', 30)).to.be.true + expect(removeContainer.called).to.be.false }) }) }) diff --git a/test/chaos/process_resilience_chaos.test/async_error_propagation_overwrite_remove_chaos.test.js b/test/chaos/process_resilience_chaos.test/async_error_propagation_overwrite_remove_chaos.test.js index 728d7521..9998ac15 100644 --- a/test/chaos/process_resilience_chaos.test/async_error_propagation_overwrite_remove_chaos.test.js +++ b/test/chaos/process_resilience_chaos.test/async_error_propagation_overwrite_remove_chaos.test.js @@ -22,9 +22,10 @@ describe('Chaos: Process Resilience', function () { describe('Experiment 11: Async error propagation', function () { - it('propagates rejection from removeContainer during overwrite', async function () { + it('handles rejection from removeContainer during overwrite gracefully', async function () { const stubs = makeStubs() sinon.stub(console, 'log') + const containerId = 'b'.repeat(64) stubs.execFile.callsFake((cmd, args, ...rest) => { let cb @@ -32,20 +33,19 @@ describe('Experiment 11: Async error propagation', function () { else cb = rest[1] if (args[0] === 'build') { cb(null) + } else if (args[0] === 'run') { + cb(null, containerId + '\n') } }) - const ms = loadModuleService(stubs, { - killContainer: sinon.stub().resolves(true), - removeContainer: sinon.stub().rejects(new Error('container in use')) - }) + const stopContainerByName = sinon.stub().resolves({ stopped: true, seconds: 1, killed: false }) + const removeContainer = sinon.stub().rejects(new Error('container in use')) + const ms = loadModuleService(stubs, { stopContainerByName, removeContainer }) - try { - await ms.buildAndUp('xchain-encoder', 'bitcoin', 'regtest', 'old-container-id') - expect.fail('should have rejected') - } catch (err) { - expect(err.message).to.equal('container in use') - } + const result = await ms.buildAndUp('xchain-encoder', 'bitcoin', 'regtest', 'old-container-id') + expect(result).to.equal(containerId) + expect(stopContainerByName.calledOnceWith('old-container-id', 30)).to.be.true + expect(removeContainer.calledOnceWith('old-container-id')).to.be.true }) }) }) diff --git a/test/chaos/process_resilience_chaos.test/async_error_propagation_run_chaos.test.js b/test/chaos/process_resilience_chaos.test/async_error_propagation_run_chaos.test.js index eaa78ceb..71937a4a 100644 --- a/test/chaos/process_resilience_chaos.test/async_error_propagation_run_chaos.test.js +++ b/test/chaos/process_resilience_chaos.test/async_error_propagation_run_chaos.test.js @@ -42,7 +42,8 @@ describe('Experiment 11: Async error propagation', function () { await ms.buildAndUp('xchain-encoder', 'bitcoin', 'regtest') expect.fail('should have rejected') } catch (err) { - expect(err).to.include('Error creating the container') + const message = err instanceof Error ? err.message : err + expect(message).to.include('Error creating the container') } }) }) diff --git a/test/chaos/process_resilience_chaos.test/empty_container_id_non_hex_chaos.test.js b/test/chaos/process_resilience_chaos.test/empty_container_id_non_hex_chaos.test.js index bd11755a..6c95dc3f 100644 --- a/test/chaos/process_resilience_chaos.test/empty_container_id_non_hex_chaos.test.js +++ b/test/chaos/process_resilience_chaos.test/empty_container_id_non_hex_chaos.test.js @@ -43,7 +43,8 @@ describe('Experiment: Empty container ID propagation', function () { await ms.buildAndUp('xchain-encoder', 'bitcoin', 'regtest') expect.fail('should have rejected') } catch (err) { - expect(err).to.include('Invalid container ID') + const message = err instanceof Error ? err.message : err + expect(message).to.include('Invalid container ID') } }) }) diff --git a/test/chaos/process_resilience_chaos.test/empty_container_id_whitespace_chaos.test.js b/test/chaos/process_resilience_chaos.test/empty_container_id_whitespace_chaos.test.js index 60565607..d309379a 100644 --- a/test/chaos/process_resilience_chaos.test/empty_container_id_whitespace_chaos.test.js +++ b/test/chaos/process_resilience_chaos.test/empty_container_id_whitespace_chaos.test.js @@ -43,7 +43,8 @@ describe('Chaos: Process Resilience', function () { await ms.buildAndUp('xchain-encoder', 'bitcoin', 'regtest') expect.fail('should have rejected') } catch (err) { - expect(err).to.include('Invalid container ID') + const message = err instanceof Error ? err.message : err + expect(message).to.include('Invalid container ID') } }) }) diff --git a/test/chaos/process_resilience_chaos.test/helpers/index.js b/test/chaos/process_resilience_chaos.test/helpers/index.js index 7b1bf548..4e32226e 100644 --- a/test/chaos/process_resilience_chaos.test/helpers/index.js +++ b/test/chaos/process_resilience_chaos.test/helpers/index.js @@ -71,6 +71,9 @@ function loadModuleService(stubs, opts = {}) { }, './docker_service': { killContainer: opts.killContainer || sinon.stub().resolves(true), + stopContainerByName: opts.stopContainerByName || sinon.stub().resolves({ + stopped: true, seconds: 1, killed: false + }), removeContainer: opts.removeContainer || sinon.stub().resolves(true), getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } }) }, diff --git a/test/chaos/process_resilience_chaos.test/precheck_hub_failure_chaos.test.js b/test/chaos/process_resilience_chaos.test/precheck_hub_failure_chaos.test.js index ef1bdd28..22d69d5b 100644 --- a/test/chaos/process_resilience_chaos.test/precheck_hub_failure_chaos.test.js +++ b/test/chaos/process_resilience_chaos.test/precheck_hub_failure_chaos.test.js @@ -39,7 +39,14 @@ describe('Experiment: Precheck failure cascade', function () { checkDockerInstalledAndReachable: sinon.stub().resolves(true), createDockerNetwork: sinon.stub().resolves(true) }, - './services/config_service': { getDockerNetwork: () => 'xchain-node' }, + './services/config_service': { getDockerNetwork: () => 'xchain-node', applyHubApiKeyFromSidecar: sinon.stub() }, + './services/database_service': { + buildDatabaseModule: sinon.stub().resolves(true), + ensureXchainNodeAccess: sinon.stub().resolves({ user: 'u', password: 'p', database: 'd' }), + getDatabaseHostPort: sinon.stub().resolves(3306), + getExternalDbConfig: sinon.stub().resolves({ host: 'h', port: 1 }) + }, + './services/discovery_service': { scanAndRegisterModules: sinon.stub().resolves() }, './services/version_service': { checkAllRemoteVersions: sinon.stub() }, './services/status_service': { getStatus: sinon.stub().resolves({}) }, './services/hub_service': { diff --git a/test/e2e/helpers/e2e-env.js b/test/e2e/helpers/e2e-env.js index 0a5d309f..965cb345 100644 --- a/test/e2e/helpers/e2e-env.js +++ b/test/e2e/helpers/e2e-env.js @@ -19,6 +19,7 @@ const { proxyquireDockerService } = require('../../helpers/docker_service_loader const TestEnv = require('../../integration/helpers/test-env') const CommandCapture = require('../../integration/helpers/command-capture') const HttpCapture = require('../../integration/helpers/http-capture') +const { migrationsDirOf } = require('../../../src/utils/migration_files') const ROOT = path.join(__dirname, '..', '..', '..') @@ -47,6 +48,7 @@ class E2EEnv extends TestEnv { // wired and the real implementations ran against the host instead. this.hostSeamCalls = [] this._sealedSeams = [] + this._originalDbPool = null } async setup() { @@ -54,6 +56,16 @@ class E2EEnv extends TestEnv { this.capture = new CommandCapture() this.http = new HttpCapture() this.hostSeamCalls = [] + const state = require(path.join(ROOT, 'src/state')) + this._originalDbPool = state.db.pool + state.db.assertReady = this._origDbMethods.assertReady + state.db.getModuleContainerStrict = this._origDbMethods.getModuleContainerStrict + state.db.pool = { + query: async (_sql, [module, coin, network]) => { + const containerId = await this._store.getModuleContainer(module, coin, network) + return containerId === null ? [] : [{ container_id: containerId }] + } + } this.sealBootstrapSeam() return this } @@ -111,15 +123,14 @@ class E2EEnv extends TestEnv { } /** - * The two remaining call-time seams, sealed once createCLI() has built the - * patched ConfigService they have to answer from: + * The call-time seams sealed once createCLI() has built the patched + * ConfigService they have to answer from: * - * - ConfigService.dbPasswordCanRotate runtime-requires DatabaseService, - * whose getDatabaseContainerId runs `docker inspect` on the host. A box - * with a resident xchain-node-database answers yes and the install mints - * generated credentials; a box without one answers no and it uses the - * static defaults. Answer from the harness's own docker routes instead, - * which present a database container on every venue. + * - DatabaseService.getDatabaseContainerId runs `docker inspect` on the + * host. ConfigService.dbPasswordCanRotate answers without it, so an + * install does not reach it; the seal is a guard so any caller + * that does reach it gets the harness's database container and is + * recorded in hostSeamCalls. * - StatusService.probeServiceHealthPayload runtime-requires * child_process AND the real ConfigService, then `docker exec`s wget * into the container. Against the host that reads the resident stack's @@ -130,6 +141,7 @@ class E2EEnv extends TestEnv { */ sealLazyRequireSeams(patchedConfigService) { const dbContainerId = 'd'.repeat(64) + this._store.setModuleContainer('database', '', '', dbContainerId) this._sealSeam('src/services/database_service', { getDatabaseContainerId: async () => dbContainerId }) @@ -161,6 +173,8 @@ class E2EEnv extends TestEnv { async teardown() { this.restoreHostSeams() + require(path.join(ROOT, 'src/state')).db.pool = this._originalDbPool + this._originalDbPool = null return super.teardown() } @@ -432,7 +446,11 @@ class E2EEnv extends TestEnv { // generic command success. const dbSpawnStub = function (command, args, options) { const child = spawnStub(command, args, options) - child.stdin = { on: () => {}, end: () => {} } + const call = capture.history()[capture.history().length - 1] + child.stdin = { + on: () => {}, + end: input => { call.stdin = String(input || '') } + } process.nextTick(() => { child.stdout.emit('data', '0') child.emit('close', 0) @@ -502,6 +520,29 @@ class E2EEnv extends TestEnv { } }) + const RealSkewGuardService = require(path.join(ROOT, 'src/services/skew_guard_service')) + const SkewGuardService = Object.assign({}, RealSkewGuardService, { + assertHubNotBehind: (module, branch) => RealSkewGuardService.assertHubNotBehind(module, branch, { + cloneGit: ModuleService.cloneGit, + readPackageJson: name => JSON.parse(fs.readFileSync( + path.join(ConfigService.getModuleTmpDir(name), 'package.json'), + 'utf8' + )) + }) + }) + + const RealMigrationPreconditionService = require(path.join(ROOT, 'src/services/migration_precondition_service')) + const MigrationPreconditionService = Object.assign({}, RealMigrationPreconditionService, { + assertRequiredMigrationsApplied: (module, coin, network, branch) => + RealMigrationPreconditionService.assertRequiredMigrationsApplied(module, coin, network, branch, { + cloneGit: ModuleService.cloneGit, + listDeployPreconditionMigrations: () => + RealMigrationPreconditionService.listDeployPreconditionMigrations( + migrationsDirOf(ConfigService.getModuleTmpDir(module)) + ) + }) + }) + // moduleOperations: the main entry point. // Must also stub 'util' because resetModules uses promisify(execFile) at top level const moduleOps = proxyquire(path.join(ROOT, 'src/operations/module_operations'), { @@ -513,6 +554,8 @@ class E2EEnv extends TestEnv { '../services/docker_service': DockerService, '../services/database_service': DatabaseService, '../services/module_service': ModuleService, + '../services/skew_guard_service': SkewGuardService, + '../services/migration_precondition_service': MigrationPreconditionService, '../services/status_service': StatusService }) diff --git a/test/e2e/scenarios/config_overrides.test.js b/test/e2e/scenarios/config_overrides.test.js index 596e65a3..cf94f2ad 100644 --- a/test/e2e/scenarios/config_overrides.test.js +++ b/test/e2e/scenarios/config_overrides.test.js @@ -30,6 +30,24 @@ async function teardownEnv() { await env.teardown() } +function moduleRun(module) { + return env.capture.findCommands(/docker run/).find(c => c.command.includes(module)) +} + +function expectContainerEnv(run, name, value) { + // Each name must immediately follow its own --env flag. + // The captured child environment carries its resolved value. + expect(run.args.some((arg, index) => arg === '--env' && run.args[index + 1] === name)).to.be.true + expect(run.args.some(arg => String(arg).startsWith(`${name}=`))).to.be.false + expect(run.options.env).to.have.property(name) + if (value !== undefined) expect(run.options.env[name]).to.equal(String(value)) +} + +function expectNoContainerEnv(run, name) { + expect(run.args).to.not.include(name) + expect(run.options.env).to.not.have.property(name) +} + describe('E2E: Configuration Overrides (Scenario 4.4)', function () { this.timeout(30000) beforeEach(setupEnv) @@ -45,10 +63,10 @@ describe('E2E: Configuration Overrides (Scenario 4.4)', function () { const serviceList = filterCommandParameters(null, 'xchain-encoder', 'bitcoin', 'regtest') await cli.moduleOps.installModules(serviceList, 'master') - const runCmd = env.capture.findCommands(/docker run/).find(c => c.command.includes('xchain-encoder')).command - expect(runCmd).to.include('NODE_PORT=18444') - expect(runCmd).to.include('ENCODER_API_PORT=3003') - expect(runCmd).to.include('NETWORK=regtest') + const run = moduleRun('xchain-encoder') + expectContainerEnv(run, 'NODE_PORT', 18444) + expectContainerEnv(run, 'ENCODER_API_PORT', 3003) + expectContainerEnv(run, 'NETWORK', 'bitcoin-regtest') }) it('encoder on mainnet gets NODE_PORT=8332', async function () { @@ -59,9 +77,9 @@ describe('E2E: Configuration Overrides (Scenario 4.4)', function () { const serviceList = filterCommandParameters(null, 'xchain-encoder', 'bitcoin', 'mainnet') await cli.moduleOps.installModules(serviceList, 'master') - const runCmd = env.capture.findCommands(/docker run/).find(c => c.command.includes('xchain-encoder')).command - expect(runCmd).to.include('NODE_PORT=8332') - expect(runCmd).to.include('NETWORK=mainnet') + const run = moduleRun('xchain-encoder') + expectContainerEnv(run, 'NODE_PORT', 8332) + expectContainerEnv(run, 'NETWORK', 'bitcoin-mainnet') }) it('encoder on testnet gets NODE_PORT=18332', async function () { @@ -72,8 +90,8 @@ describe('E2E: Configuration Overrides (Scenario 4.4)', function () { const serviceList = filterCommandParameters(null, 'xchain-encoder', 'bitcoin', 'testnet') await cli.moduleOps.installModules(serviceList, 'master') - const runCmd = env.capture.findCommands(/docker run/).find(c => c.command.includes('xchain-encoder')).command - expect(runCmd).to.include('NODE_PORT=18332') + const run = moduleRun('xchain-encoder') + expectContainerEnv(run, 'NODE_PORT', 18332) }) }) }) @@ -93,9 +111,9 @@ describe('E2E: Configuration Overrides (Scenario 4.4)', function () { const serviceList = filterCommandParameters(null, 'xchain-encoder', 'bitcoin', 'regtest') await cli.moduleOps.installModules(serviceList, 'master') - const runCmd = env.capture.findCommands(/docker run/).find(c => c.command.includes('xchain-encoder')).command - expect(runCmd).to.include('ENCODER_API_PORT=4003') - expect(runCmd).to.include('-p 4003:4003') + const run = moduleRun('xchain-encoder') + expectContainerEnv(run, 'ENCODER_API_PORT', 4003) + expect(run.command).to.include('-p 4003:4003') }) it('DECODER_PORT override flows to docker run', async function () { @@ -106,9 +124,9 @@ describe('E2E: Configuration Overrides (Scenario 4.4)', function () { const serviceList = filterCommandParameters(null, 'xchain-decoder', 'bitcoin', 'regtest') await cli.moduleOps.installModules(serviceList, 'master') - const runCmd = env.capture.findCommands(/docker run/).find(c => c.command.includes('xchain-decoder')).command - expect(runCmd).to.include('DECODER_API_PORT=4002') - expect(runCmd).to.include('-p 4002:4002') + const run = moduleRun('xchain-decoder') + expectContainerEnv(run, 'DECODER_API_PORT', 4002) + expect(run.command).to.include('-p 4002:4002') }) }) }) @@ -126,13 +144,13 @@ describe('E2E: Configuration Overrides (Scenario 4.4)', function () { const serviceList = filterCommandParameters(null, 'xchain-encoder', 'bitcoin', 'regtest') await cli.moduleOps.installModules(serviceList, 'master') - const runCmd = env.capture.findCommands(/docker run/).find(c => c.command.includes('xchain-encoder')).command + const run = moduleRun('xchain-encoder') // NODE_USER overridden - expect(runCmd).to.include('NODE_USER=customuser') - expect(runCmd).to.include('NODE_PASSWORD=custompass') + expectContainerEnv(run, 'NODE_USER', 'customuser') + expectContainerEnv(run, 'NODE_PASSWORD', 'custompass') }) - it('unspecified values fall back to defaults', async function () { + it('missing RPC credentials are generated and passed through the child env', async function () { env.setupFullStack('bitcoin', 'regtest') env.writeConfigFile('bitcoin-regtest', 'NODE_USER=customuser\n') cli = env.createCLI() @@ -140,10 +158,13 @@ describe('E2E: Configuration Overrides (Scenario 4.4)', function () { const serviceList = filterCommandParameters(null, 'xchain-encoder', 'bitcoin', 'regtest') await cli.moduleOps.installModules(serviceList, 'master') - const runCmd = env.capture.findCommands(/docker run/).find(c => c.command.includes('xchain-encoder')).command - expect(runCmd).to.include('NODE_USER=customuser') + const run = moduleRun('xchain-encoder') + const resolvedConfig = await cli.ConfigService.getDefaultConfig('xchain-encoder', 'bitcoin', 'regtest') + expectContainerEnv(run, 'NODE_USER', 'customuser') // NODE_PASSWORD uses default - expect(runCmd).to.include('NODE_PASSWORD=rpc') + // The current default is a generated credential rather than the legacy literal. + expect(resolvedConfig.NODE_PASSWORD).to.match(/^[0-9a-f]{48}$/) + expectContainerEnv(run, 'NODE_PASSWORD', resolvedConfig.NODE_PASSWORD) }) }) }) @@ -162,9 +183,9 @@ describe('E2E: Configuration Overrides (Scenario 4.4)', function () { const serviceList = filterCommandParameters(null, 'xchain-encoder', 'bitcoin', 'regtest') await cli.moduleOps.installModules(serviceList, 'master') - const runCmd = env.capture.findCommands(/docker run/).find(c => c.command.includes('xchain-encoder')).command - expect(runCmd).to.include('REGTEST_MINER_URL') - expect(runCmd).to.include('REGTEST_MINER_API_PORT') + const run = moduleRun('xchain-encoder') + expectContainerEnv(run, 'REGTEST_MINER_URL') + expectContainerEnv(run, 'REGTEST_MINER_API_PORT') }) it('mainnet does NOT include REGTEST_MINER_URL', async function () { @@ -175,8 +196,8 @@ describe('E2E: Configuration Overrides (Scenario 4.4)', function () { const serviceList = filterCommandParameters(null, 'xchain-encoder', 'bitcoin', 'mainnet') await cli.moduleOps.installModules(serviceList, 'master') - const runCmd = env.capture.findCommands(/docker run/).find(c => c.command.includes('xchain-encoder')).command - expect(runCmd).to.not.include('REGTEST_MINER_URL') + const run = moduleRun('xchain-encoder') + expectNoContainerEnv(run, 'REGTEST_MINER_URL') }) }) }) @@ -196,8 +217,8 @@ describe('E2E: Configuration Overrides (Scenario 4.4)', function () { const serviceList = filterCommandParameters(null, 'xchain-decoder', 'bitcoin', 'mainnet') await cli.moduleOps.installModules(serviceList, 'master') - const runCmd = env.capture.findCommands(/docker run/).find(c => c.command.includes('xchain-decoder')).command - expect(runCmd).to.include('DECODER_DB_NAME=XChain_BTC_Mainnet_Decoder') + const run = moduleRun('xchain-decoder') + expectContainerEnv(run, 'DECODER_DB_NAME', 'XChain_BTC_Mainnet_Decoder') }) it('indexer dogecoin/testnet → XChain_DOGE_Testnet_Indexer', async function () { @@ -208,8 +229,8 @@ describe('E2E: Configuration Overrides (Scenario 4.4)', function () { const serviceList = filterCommandParameters(null, 'xchain-indexer', 'dogecoin', 'testnet') await cli.moduleOps.installModules(serviceList, 'master') - const runCmd = env.capture.findCommands(/docker run/).find(c => c.command.includes('xchain-indexer')).command - expect(runCmd).to.include('INDEXER_DB_NAME=XChain_DOGE_Testnet_Indexer') + const run = moduleRun('xchain-indexer') + expectContainerEnv(run, 'INDEXER_DB_NAME', 'XChain_DOGE_Testnet_Indexer') }) }) }) @@ -227,8 +248,8 @@ describe('E2E: Configuration Overrides (Scenario 4.4)', function () { const serviceList = filterCommandParameters(null, 'xchain-decoder', 'litecoin', 'regtest') await cli.moduleOps.installModules(serviceList, 'master') - const runCmd = env.capture.findCommands(/docker run/).find(c => c.command.includes('xchain-decoder')).command - expect(runCmd).to.include('DECODER_DB_NAME=XChain_LTC_Regtest_Decoder') + const run = moduleRun('xchain-decoder') + expectContainerEnv(run, 'DECODER_DB_NAME', 'XChain_LTC_Regtest_Decoder') }) it('DB user follows xchain___ pattern', async function () { @@ -239,8 +260,8 @@ describe('E2E: Configuration Overrides (Scenario 4.4)', function () { const serviceList = filterCommandParameters(null, 'xchain-decoder', 'bitcoin', 'mainnet') await cli.moduleOps.installModules(serviceList, 'master') - const runCmd = env.capture.findCommands(/docker run/).find(c => c.command.includes('xchain-decoder')).command - expect(runCmd).to.include('DECODER_DB_USER=xchain_decoder_bitcoin_mainnet') + const run = moduleRun('xchain-decoder') + expectContainerEnv(run, 'DECODER_DB_USER', 'xchain_decoder_bitcoin_mainnet') }) }) }) diff --git a/test/e2e/scenarios/error_handling.test.js b/test/e2e/scenarios/error_handling.test.js index 8268daa8..17a1ad1b 100644 --- a/test/e2e/scenarios/error_handling.test.js +++ b/test/e2e/scenarios/error_handling.test.js @@ -95,6 +95,32 @@ describe('E2E: Error Handling (Scenario 4.10)', function () { }) }) +describe('E2E: Error Handling (Scenario 4.10)', function () { + this.timeout(30000) + beforeEach(setupEnv) + afterEach(teardownEnv) + + describe('E2E-066: Module registry read failure', function () { + it('propagates a query error through the production strict read', async function () { + env.setupDefaultRoutes() + cli = env.createCLI() + + const state = require('../../../src/state') + const MariaDbStore = require('../../../src/db') + expect(state.db.getModuleContainerStrict).to.equal(MariaDbStore.prototype.getModuleContainerStrict) + sinon.stub(state.db.pool, 'query').rejects(new Error('modules table unavailable')) + + const serviceList = filterCommandParameters(null, 'xchain-encoder', 'bitcoin', 'mainnet') + const err = await cli.moduleOps.uninstallModules(serviceList).then(() => null, caught => caught) + + expect(err).to.be.an('error') + expect(err.message).to.include('modules table unavailable') + expect(err.failures[0]).to.include({ module: 'xchain-encoder' }) + expect(env.capture.findCommands(/docker stop|docker rm/)).to.have.lengthOf(0) + }) + }) +}) + describe('E2E: Error Handling (Scenario 4.10)', function () { this.timeout(30000) beforeEach(setupEnv) diff --git a/test/e2e/scenarios/install_lifecycle.test.js b/test/e2e/scenarios/install_lifecycle.test.js index 2e2b3cd7..19ef52b5 100644 --- a/test/e2e/scenarios/install_lifecycle.test.js +++ b/test/e2e/scenarios/install_lifecycle.test.js @@ -358,32 +358,29 @@ describe('E2E: Install Lifecycle (Scenarios 4.1, 4.3)', function () { afterEach(teardownEnv) // E2E-006: Full lifecycle sequence (install → stop → start → uninstall) describe('E2E-006: Full lifecycle sequence', function () { - it('install → stop → start → uninstall all succeed in sequence', async function () { env.setupFullStack('bitcoin', 'regtest') cli = env.createCLI() const serviceList = filterCommandParameters(null, 'all', 'bitcoin', 'regtest') - // Install + // Install returns the complete registration result. const installResult = await cli.moduleOps.installModules(serviceList, 'master') - expect(installResult).to.be.true - + expect(installResult.installed).to.have.lengthOf(7) + expect(installResult.skipped).to.deep.equal([]) const modulesAfterInstall = await env.getAllModules() expect(modulesAfterInstall.length).to.be.greaterThanOrEqual(5) - // Stop + // Stop keeps the registered container identities intact. const stopResult = await cli.moduleOps.stopModules(serviceList) expect(stopResult).to.be.true - // Start + // Start reuses the containers recorded before the stop. const startResult = await cli.moduleOps.startModules(serviceList) expect(startResult).to.be.true - // Container IDs unchanged after stop/start const modulesAfterRestart = await env.getAllModules() expect(modulesAfterRestart).to.have.lengthOf(modulesAfterInstall.length) - for (const mod of modulesAfterInstall) { const afterRestart = modulesAfterRestart.find( m => m.module === mod.module && m.coin === mod.coin && m.network === mod.network @@ -393,8 +390,10 @@ describe('E2E: Install Lifecycle (Scenarios 4.1, 4.3)', function () { } // Uninstall + // Uninstall reports both removals and intentional skips. const uninstallResult = await cli.moduleOps.uninstallModules(serviceList) - expect(uninstallResult).to.be.true + expect(uninstallResult.uninstalled).to.have.lengthOf(5) + expect(uninstallResult.skipped.map(s => `${s.module}:${s.reason}`)).to.deep.equal(['xchain-explorer:shared', 'node:not-installed']) }) }) }) diff --git a/test/e2e/scenarios/precheck.test.js b/test/e2e/scenarios/precheck.test.js index cad9fab1..bad543e9 100644 --- a/test/e2e/scenarios/precheck.test.js +++ b/test/e2e/scenarios/precheck.test.js @@ -86,6 +86,9 @@ function makePreCheck(capture, overrides = {}) { }, './services/explorer_service': overrides.ExplorerService || { updateExplorer: async () => true + }, + './services/discovery_service': overrides.DiscoveryService || { + scanAndRegisterModules: async () => true } }) diff --git a/test/e2e/scenarios/reset.test.js b/test/e2e/scenarios/reset.test.js index b2ccd30b..784aa231 100644 --- a/test/e2e/scenarios/reset.test.js +++ b/test/e2e/scenarios/reset.test.js @@ -50,7 +50,7 @@ describe('E2E: Reset Command (Scenario 4.8)', function () { env.capture.reset() - await cli.moduleOps.resetModules('xchain-decoder', 'bitcoin', 'regtest') + await cli.moduleOps.resetModules('xchain-decoder', 'bitcoin', 'regtest', true, true) // Should have called docker stop for the decoder const stopCmds = env.capture.findCommands(/docker stop/) @@ -73,7 +73,7 @@ describe('E2E: Reset Command (Scenario 4.8)', function () { const idBefore = await env.getModule('xchain-decoder', 'bitcoin', 'regtest') - await cli.moduleOps.resetModules('xchain-decoder', 'bitcoin', 'regtest') + await cli.moduleOps.resetModules('xchain-decoder', 'bitcoin', 'regtest', true, true) const idAfter = await env.getModule('xchain-decoder', 'bitcoin', 'regtest') expect(idAfter).to.equal(idBefore) @@ -98,12 +98,12 @@ describe('E2E: Reset Command (Scenario 4.8)', function () { env.capture.reset() - await cli.moduleOps.resetModules('xchain-decoder', 'bitcoin', 'regtest') + await cli.moduleOps.resetModules('xchain-decoder', 'bitcoin', 'regtest', true, true) // Should have executed a docker exec mariadb command with DROP DATABASE const execCmds = env.capture.findCommands(/docker exec/) const hasDropCreate = execCmds.some(c => - c.command.includes('DROP DATABASE') && c.command.includes('CREATE DATABASE') + c.stdin && c.stdin.includes('DROP DATABASE') && c.stdin.includes('CREATE DATABASE') ) expect(hasDropCreate, 'DROP and CREATE DATABASE executed').to.be.true }) @@ -133,7 +133,7 @@ describe('E2E: Reset Command (Scenario 4.8)', function () { env.capture.reset() - await cli.moduleOps.resetModules('all', 'bitcoin', 'regtest') + await cli.moduleOps.resetModules('all', 'bitcoin', 'regtest', true) const stopCmds = env.capture.findCommands(/docker stop/) diff --git a/test/e2e/scenarios/update_flow.test.js b/test/e2e/scenarios/update_flow.test.js index 1a03fe10..0604e8c8 100644 --- a/test/e2e/scenarios/update_flow.test.js +++ b/test/e2e/scenarios/update_flow.test.js @@ -16,19 +16,26 @@ const E2EEnv = require('../helpers/e2e-env') const TestEnv = require('../../integration/helpers/test-env') const { filterCommandParameters } = require('../../../src/services/config_service') -let env, cli +let env, cli, previousNodeDataDir async function setupEnvironment() { env = new E2EEnv() await env.setup() env.setupDefaultRoutes() + previousNodeDataDir = process.env.XCHAIN_NODE_DATA_DIR + process.env.XCHAIN_NODE_DATA_DIR = env.dataDir const state = require('../../../src/state') state.setDbRootPassword('testrootpw') } async function teardownEnvironment() { - await env.teardown() + try { + await env.teardown() + } finally { + if (previousNodeDataDir === undefined) delete process.env.XCHAIN_NODE_DATA_DIR + else process.env.XCHAIN_NODE_DATA_DIR = previousNodeDataDir + } } describe('E2E: Update Flow (Scenario 4.6)', function () { @@ -37,9 +44,10 @@ describe('E2E: Update Flow (Scenario 4.6)', function () { afterEach(teardownEnvironment) // E2E-040: Update kills old container and creates new one + // The current replacement contract uses a budgeted stop rather than kill. describe('E2E-040: Update replaces container', function () { - it('kills old container, removes it, builds new image, and runs new container', async function () { + it('stops old container, removes it, builds new image, and runs new container', async function () { env.setupFullStack('bitcoin', 'regtest') cli = env.createCLI() @@ -56,10 +64,12 @@ describe('E2E: Update Flow (Scenario 4.6)', function () { await cli.moduleOps.updateModules(serviceList) // Should have killed the old container - const killCmds = env.capture.findCommands(/docker kill/) - expect(killCmds.length).to.be.greaterThanOrEqual(1) - const killHasOldId = killCmds.some(c => c.command.includes(oldContainerId)) - expect(killHasOldId, 'kill references old container').to.be.true + // Replacement uses one budgeted stop for the old container and never docker kill. + expect(env.capture.findCommands(/docker kill/)).to.have.lengthOf(0) + const stopCmds = env.capture.findCommands(/docker stop -t \d+ /) + expect(stopCmds).to.have.lengthOf(1) + const stopHasOldId = stopCmds.some(c => c.command.includes(oldContainerId)) + expect(stopHasOldId, 'stop references old container').to.be.true // Should have removed the old container const rmCmds = env.capture.findCommands(/docker rm/) @@ -140,6 +150,7 @@ describe('E2E: Update Flow (Scenario 4.6)', function () { env.setupFullStack('bitcoin', 'regtest') cli = env.createCLI() + require('../../../src/state').setRemoteModuleVersion('node-bitcoin', { tag_name: 'v28.1' }) const serviceList = filterCommandParameters(null, 'all', 'bitcoin', 'regtest') await cli.moduleOps.installModules(serviceList, 'master') diff --git a/test/fuzz/command_construction.fuzz.js b/test/fuzz/command_construction.fuzz.js index 81a4face..2aa47689 100644 --- a/test/fuzz/command_construction.fuzz.js +++ b/test/fuzz/command_construction.fuzz.js @@ -143,10 +143,8 @@ describe('Fuzz: Docker Command Construction', function () { describe('Fuzz: Docker Command Construction', function () { - // env vars are passed as raw array elements; execFile needs no shell quoting. - - // --- env vars are passed as raw array elements (no shell quoting with execFile) --- - it('every -e flag is followed by a raw KEY=value pair', async function () { + // --- env names are bare on argv while values stay in the child env --- + it('every container env name is bare on argv and its value is in the child env', async function () { const stubs = makeStubs({ 'KEY1': 'val1', 'KEY2': 'val2', @@ -154,14 +152,17 @@ describe('Fuzz: Docker Command Construction', function () { 'ENCODER_PORT': 3003, 'ENCODER_API_PORT': 3003 }) - const getCmd = captureDockerRunCmd(stubs) + captureDockerRunCmd(stubs) const ms = loadModuleService(stubs) await ms.buildAndUp(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') - const cmd = getCmd() - // With execFile, env vars appear as "-e KEY1=val1" (no quotes around KEY=value) - expect(cmd).to.include('-e KEY1=val1') - expect(cmd).to.include('-e KEY2=val2') - expect(cmd).to.include('-e KEY3=val3') + const runCall = stubs.execFile.getCalls().find(call => call.args[1].includes('run')) + const args = runCall.args[1] + const env = runCall.args[2].env + for (const [key, value] of Object.entries({ KEY1: 'val1', KEY2: 'val2', KEY3: 'val3' })) { + expect(args[args.indexOf(key) - 1]).to.equal('--env') + expect(args).to.not.include(key + '=' + value) + expect(env[key]).to.equal(value) + } }) // --- Malicious env var keys --- @@ -226,8 +227,8 @@ describe('Fuzz: Docker Command Construction', function () { const ms = loadModuleService(stubs) await ms.cloneGit('xchain-encoder', false, false, null) expect(cloneCmd).to.not.include('-b') - // Should be: git clone - expect(cloneCmd).to.include('git clone git@') + // modulesUrls (src/config/index.js) is HTTPS by design + expect(cloneCmd).to.include('git clone ' + modulesUrls['xchain-encoder']) }) }) }) diff --git a/test/fuzz/config_parsing.fuzz.js b/test/fuzz/config_parsing.fuzz.js index a4d33cdd..a2e7cc5c 100644 --- a/test/fuzz/config_parsing.fuzz.js +++ b/test/fuzz/config_parsing.fuzz.js @@ -33,6 +33,7 @@ function streamFromString(str) { function makeServiceWithConfig(configContent) { const fsStub = { createReadStream: sinon.stub().callsFake(() => streamFromString(configContent)), + readFileSync: sinon.stub().callsFake(() => configContent), existsSync: sinon.stub().returns(true), appendFileSync: sinon.stub(), writeFileSync: sinon.stub(), diff --git a/test/fuzz/container_id.fuzz.js b/test/fuzz/container_id.fuzz.js index 540d1d4f..61cec0ac 100644 --- a/test/fuzz/container_id.fuzz.js +++ b/test/fuzz/container_id.fuzz.js @@ -148,6 +148,7 @@ describe('Fuzz: Container ID Validation', function () { await ms.buildAndUp(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') expect.fail('Should have rejected invalid container ID') } catch (err) { + expect(err).to.be.a('string') expect(err).to.include('Invalid container ID') } }) @@ -175,6 +176,7 @@ describe('Fuzz: Container ID Validation', function () { await ms.buildAndUp(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') expect.fail('Should have rejected') } catch (err) { + expect(err).to.be.a('string') expect(err).to.include('Invalid container ID') } }) diff --git a/test/fuzz/env_escaping.fuzz.js b/test/fuzz/env_escaping.fuzz.js index 237f717d..eec4e472 100644 --- a/test/fuzz/env_escaping.fuzz.js +++ b/test/fuzz/env_escaping.fuzz.js @@ -79,6 +79,7 @@ function makeStubs(envVars) { function captureDockerRunArgs(stubs) { let runArgs = null let runCmd = null + let runEnv = null stubs.execFile.callsFake((cmd, args, opts, cb) => { if (typeof opts === 'function') { cb = opts; opts = {} } if (args && args.includes('build')) { @@ -86,10 +87,19 @@ function captureDockerRunArgs(stubs) { } else if (args && args.includes('run')) { runArgs = args runCmd = cmd + ' ' + (args || []).join(' ') + runEnv = opts.env cb(null, 'a'.repeat(64) + '\n') } }) - return { getCmd: () => runCmd, getArgs: () => runArgs } + return { getCmd: () => runCmd, getArgs: () => runArgs, getEnv: () => runEnv } +} + +function expectDockerEnv(args, env, key, value) { + const index = args.indexOf(key) + expect(index).to.be.greaterThan(0) + expect(args[index - 1]).to.equal('--env') + expect(args.some(arg => String(arg).startsWith(key + '='))).to.be.false + expect(env[key]).to.equal(String(value)) } // --------------------------------------------------------------------------- @@ -121,24 +131,14 @@ describe('Fuzz: Environment Variable Handling with execFile', function () { for (const [desc, rawValue] of rawPassthroughInputs) { it(`passes ${desc} as raw value in args array (no escaping needed)`, async function () { const stubs = makeStubs({ 'TEST_KEY': rawValue, 'ENCODER_PORT': 3003, 'ENCODER_API_PORT': 3003 }) - const { getArgs } = captureDockerRunArgs(stubs) + const { getArgs, getEnv } = captureDockerRunArgs(stubs) const ms = loadModuleService(stubs) await ms.buildAndUp(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') const args = getArgs() expect(args).to.exist // Find the -e flag followed by the TEST_KEY= arg - const eIndex = args.indexOf('-e') - let found = false - for (let i = 0; i < args.length; i++) { - if (args[i] === '-e' && args[i + 1] && args[i + 1].startsWith('TEST_KEY=')) { - const val = args[i + 1].substring('TEST_KEY='.length) - expect(val).to.equal(String(rawValue)) - found = true - break - } - } - expect(found, 'TEST_KEY env var found in args').to.be.true + expectDockerEnv(args, getEnv(), 'TEST_KEY', rawValue) }) } }) @@ -152,7 +152,7 @@ describe('Fuzz: Environment Variable Handling with execFile', function () { 'ENCODER_PORT': 3003, 'ENCODER_API_PORT': 3003 }) - const { getArgs } = captureDockerRunArgs(stubs) + const { getArgs, getEnv } = captureDockerRunArgs(stubs) const ms = loadModuleService(stubs) await ms.buildAndUp(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') const args = getArgs() @@ -160,9 +160,7 @@ describe('Fuzz: Environment Variable Handling with execFile', function () { // The newline is safe with execFile; no flag injection possible. // Verify the value is in the args array as a single element - const envArg = args.find(a => a.startsWith('EVIL_KEY=')) - expect(envArg).to.exist - expect(envArg).to.equal('EVIL_KEY=safe_value\n-v /:/host:ro') + expectDockerEnv(args, getEnv(), 'EVIL_KEY', 'safe_value\n-v /:/host:ro') }) it('carriage return characters are passed raw (safe with execFile)', async function () { @@ -171,15 +169,13 @@ describe('Fuzz: Environment Variable Handling with execFile', function () { 'ENCODER_PORT': 3003, 'ENCODER_API_PORT': 3003 }) - const { getArgs } = captureDockerRunArgs(stubs) + const { getArgs, getEnv } = captureDockerRunArgs(stubs) const ms = loadModuleService(stubs) await ms.buildAndUp(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') const args = getArgs() expect(args).to.exist - const envArg = args.find(a => a.startsWith('EVIL_KEY=')) - expect(envArg).to.exist - expect(envArg).to.equal('EVIL_KEY=safe_value\r-v /:/host:ro') + expectDockerEnv(args, getEnv(), 'EVIL_KEY', 'safe_value\r-v /:/host:ro') }) }) @@ -190,16 +186,13 @@ describe('Fuzz: Environment Variable Handling with execFile', function () { 'ENCODER_PORT': 3003, 'ENCODER_API_PORT': 3003 }) - const { getArgs } = captureDockerRunArgs(stubs) + const { getArgs, getEnv } = captureDockerRunArgs(stubs) const ms = loadModuleService(stubs) await ms.buildAndUp(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') const args = getArgs() expect(args).to.exist - const envArg = args.find(a => a.startsWith('EVIL_KEY=')) - expect(envArg).to.exist - // The value is a single array element; no command injection possible - expect(envArg).to.equal('EVIL_KEY=value\r\n--privileged') + expectDockerEnv(args, getEnv(), 'EVIL_KEY', 'value\r\n--privileged') }) // --- Null byte and binary data --- @@ -225,15 +218,13 @@ describe('Fuzz: Environment Variable Handling with execFile', function () { 'ENCODER_PORT': 3003, 'ENCODER_API_PORT': 3003 }) - const { getArgs } = captureDockerRunArgs(stubs) + const { getArgs, getEnv } = captureDockerRunArgs(stubs) const ms = loadModuleService(stubs) await ms.buildAndUp(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') const args = getArgs() expect(args).to.exist - const envArg = args.find(a => a.startsWith('TEST=')) - expect(envArg).to.exist - expect(envArg).to.include('A'.repeat(1000)) + expectDockerEnv(args, getEnv(), 'TEST', 'A'.repeat(100000)) }) it('handles empty string env var value', async function () { @@ -242,14 +233,13 @@ describe('Fuzz: Environment Variable Handling with execFile', function () { 'ENCODER_PORT': 3003, 'ENCODER_API_PORT': 3003 }) - const { getArgs } = captureDockerRunArgs(stubs) + const { getArgs, getEnv } = captureDockerRunArgs(stubs) const ms = loadModuleService(stubs) await ms.buildAndUp(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') const args = getArgs() expect(args).to.exist - const envArg = args.find(a => a === 'TEST=') - expect(envArg).to.exist + expectDockerEnv(args, getEnv(), 'TEST', '') }) }) @@ -261,14 +251,13 @@ describe('Fuzz: Environment Variable Handling with execFile', function () { 'ENCODER_PORT': 3003, 'ENCODER_API_PORT': 3003 }) - const { getArgs } = captureDockerRunArgs(stubs) + const { getArgs, getEnv } = captureDockerRunArgs(stubs) const ms = loadModuleService(stubs) await ms.buildAndUp(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') const args = getArgs() expect(args).to.exist - const envArg = args.find(a => a === 'PORT=8332') - expect(envArg).to.exist + expectDockerEnv(args, getEnv(), 'PORT', 8332) }) it('handles boolean env var value', async function () { @@ -277,14 +266,13 @@ describe('Fuzz: Environment Variable Handling with execFile', function () { 'ENCODER_PORT': 3003, 'ENCODER_API_PORT': 3003 }) - const { getArgs } = captureDockerRunArgs(stubs) + const { getArgs, getEnv } = captureDockerRunArgs(stubs) const ms = loadModuleService(stubs) await ms.buildAndUp(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') const args = getArgs() expect(args).to.exist - const envArg = args.find(a => a === 'FLAG=false') - expect(envArg).to.exist + expectDockerEnv(args, getEnv(), 'FLAG', false) }) }) @@ -295,14 +283,13 @@ describe('Fuzz: Environment Variable Handling with execFile', function () { 'ENCODER_PORT': 3003, 'ENCODER_API_PORT': 3003 }) - const { getArgs } = captureDockerRunArgs(stubs) + const { getArgs, getEnv } = captureDockerRunArgs(stubs) const ms = loadModuleService(stubs) await ms.buildAndUp(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') const args = getArgs() expect(args).to.exist - const envArg = args.find(a => a === 'NULLVAL=null') - expect(envArg).to.exist + expectDockerEnv(args, getEnv(), 'NULLVAL', null) }) it('handles undefined env var value via String() coercion', async function () { @@ -311,14 +298,13 @@ describe('Fuzz: Environment Variable Handling with execFile', function () { 'ENCODER_PORT': 3003, 'ENCODER_API_PORT': 3003 }) - const { getArgs } = captureDockerRunArgs(stubs) + const { getArgs, getEnv } = captureDockerRunArgs(stubs) const ms = loadModuleService(stubs) await ms.buildAndUp(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') const args = getArgs() expect(args).to.exist - const envArg = args.find(a => a === 'UNDEF=undefined') - expect(envArg).to.exist + expectDockerEnv(args, getEnv(), 'UNDEF', undefined) }) }) @@ -345,15 +331,13 @@ describe('Fuzz: Environment Variable Handling with execFile', function () { 'ENCODER_PORT': 3003, 'ENCODER_API_PORT': 3003 }) - const { getArgs } = captureDockerRunArgs(stubs) + const { getArgs, getEnv } = captureDockerRunArgs(stubs) const ms = loadModuleService(stubs) await ms.buildAndUp(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') const args = getArgs() expect(args).to.exist // With execFile, the value is a single array element; no shell interpretation occurs - const envArg = args.find(a => a.startsWith('COMBINED=')) - expect(envArg).to.exist - expect(envArg).to.equal('COMBINED=' + combined) + expectDockerEnv(args, getEnv(), 'COMBINED', combined) }) }) diff --git a/test/fuzz/resolve_args.fuzz.js b/test/fuzz/resolve_args.fuzz.js index 5e6b34a5..30a54845 100644 --- a/test/fuzz/resolve_args.fuzz.js +++ b/test/fuzz/resolve_args.fuzz.js @@ -18,6 +18,21 @@ const { } = require('../../src/config') const { resolveArgs } = require('../../src/services/config_service') +// Matches the exact refusal pinned by test/unit/config_service.test/resolve_args.test.js. +function expectRefusal(args, token, options) { + let message = null + try { + resolveArgs(args, options) + } catch (err) { + message = err.message + } + expect(message, 'resolveArgs should refuse').to.be.a('string') + expect(message).to.equal(`Unrecognized argument '${token}'. Valid services: database, node, ` + + 'xchain-decoder, xchain-e2e-test, xchain-encoder, xchain-explorer, xchain-hub, xchain-indexer, ' + + 'xchain-regtest-miner, xchain-sync, xchain-utxo-tracker. Valid coins: bitcoin, litecoin, dogecoin. ' + + 'Networks: mainnet, testnet, regtest.') +} + describe('Fuzz: resolveArgs()', function () { // --- Unknown / garbage arguments --- @@ -31,7 +46,6 @@ describe('Fuzz: resolveArgs()', function () { 'Infinity', '[]', '{}', - '', ' ', '-v', '--verbose', @@ -47,20 +61,21 @@ describe('Fuzz: resolveArgs()', function () { 'xchain-encoder\x00extra', ] + it(`does not crash on garbage argument: ${JSON.stringify('')}`, function () { + const result = resolveArgs(['']) + expect(result).to.have.property('service') + expect(result).to.have.property('chain') + expect(result).to.have.property('network') + }) + for (const arg of garbageArgs) { - it(`does not crash on garbage argument: ${JSON.stringify(arg)}`, function () { - const result = resolveArgs([arg]) - expect(result).to.have.property('service') - expect(result).to.have.property('chain') - expect(result).to.have.property('network') + it(`refuses unrecognized argument: ${JSON.stringify(arg)}`, function () { + expectRefusal([arg], arg) }) } - it('defaults to all/all/all when no recognized args', function () { - const result = resolveArgs(['unknown1', 'unknown2']) - expect(result.service).to.equal('all') - expect(result.chain).to.equal('all') - expect(result.network).to.equal('all') + it('refuses the first unrecognized argument instead of defaulting to all/all/all', function () { + expectRefusal(['unknown1', 'unknown2'], 'unknown1') }) }) @@ -156,8 +171,7 @@ describe('Fuzz: resolveArgs()', function () { }) it('branch is null when expectBranch=false', function () { - const result = resolveArgs(['develop', 'xchain-encoder']) - expect(result.branch).to.be.null + expectRefusal(['develop', 'xchain-encoder'], 'develop') }) }) @@ -193,10 +207,9 @@ describe('Fuzz: resolveArgs()', function () { describe('Fuzz: resolveArgs()', function () { // --- Massive argument list --- - it('handles 100 garbage arguments without crashing', function () { + it('refuses the first of 100 garbage arguments', function () { const args = Array.from({ length: 100 }, (_, i) => 'garbage_' + i) - const result = resolveArgs(args) - expect(result.service).to.equal('all') + expectRefusal(args, 'garbage_0') }) }) @@ -209,11 +222,7 @@ describe('Fuzz: resolveArgs()', function () { 'xchain-encoder | nc evil 4444', ] for (const inj of injections) { - const result = resolveArgs([inj]) - // None of these should match a known service, chain, or network - expect(result.service).to.equal('all') - expect(result.chain).to.equal('all') - expect(result.network).to.equal('all') + expectRefusal([inj], inj) } }) }) diff --git a/test/helpers/sibling_checkout.js b/test/helpers/sibling_checkout.js new file mode 100644 index 00000000..3cf5c6df --- /dev/null +++ b/test/helpers/sibling_checkout.js @@ -0,0 +1,148 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * May a cross-repo guard trust the sibling file it is about to read? + * + * The guards in this tree reach a sibling repo as `../../../xchain-`, + * which is only a statement about the directory layout, not about which + * commit of the sibling is sitting there. Two layouts give an honest answer: + * a main checkout beside its sibling main checkouts (a developer's tree), and + * a CI venue, which clones every declared sibling as a real directory beside + * the repo under test. A third layout gives a dishonest one: a linked worktree + * cut under a lane directory whose `xchain-` entries are SYMLINKS into + * the platform's main checkouts. There the path resolves into a peer + * session's live working tree, uncommitted edits and all, and a guard reads + * green against state no commit holds. On 2026-09-14 a cross-lineage + * falsification returned a false pass for exactly that reason. + * + * So a sibling is refused when it is absent, and also when this checkout is a + * linked worktree and the sibling entry beside it is a symlink whose target is + * a main checkout (its `.git` is a directory). A symlink into another linked + * worktree is allowed: that is a deliberately cut tree at a known ref. + * + * The verdict never decides soft versus strict on its own. Guards skip on a + * refusal in soft mode and fail naming the reason when the run declared its + * siblings supplied with XCHAIN_REQUIRE_SIBLINGS=1, which is what skipOrFail() + * does. Guards keep their own path literals, so every census that greps for + * `xchain-/...` still sees what each guard reads. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const cp = require('child_process'); + +const OWN_ROOT = path.resolve(__dirname, '..', '..'); + +/** True when the sibling checkouts were declared supplied for this run. */ +function siblingsRequired(env = process.env) { + return env.XCHAIN_REQUIRE_SIBLINGS === '1'; +} + +/** A linked worktree carries a `.git` FILE pointing at its common dir. */ +function isLinkedWorktree(root) { + try { return fs.lstatSync(path.join(root, '.git')).isFile(); } + catch (e) { return false; } +} + +/** The nearest ancestor of `dir` (inclusive) that holds a `.git` entry, or null. */ +function checkoutRootOf(dir) { + for (let d = dir; ; d = path.dirname(d)) { + if (fs.existsSync(path.join(d, '.git'))) return d; + if (path.dirname(d) === d) return null; + } +} + +function pinnedMainCheckout(root, name, env) { + let pins; + try { pins = JSON.parse(env.XCHAIN_SIBLING_COMMITS || '{}'); } + catch (e) { return false; } + const expected = pins && pins[name]; + if (typeof expected !== 'string' || !/^[0-9a-f]{40}$/.test(expected)) return false; + + let actual; + try { + actual = cp.execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] + }).trim(); + } catch (e) { return false; } + if (actual !== expected) return false; + + const clean = (args) => cp.spawnSync('git', ['-C', root, ...args], { stdio: 'ignore' }).status === 0; + return clean(['diff', '--quiet', '--ignore-submodules', '--']) + && clean(['diff', '--cached', '--quiet', '--ignore-submodules', '--']); +} + +/** + * Judge one sibling path. + * + * @param {string} fromDir the directory the guard's literal is relative to (its __dirname) + * @param {string} target the guard's own path literal, relative or absolute + * @param {object} [opts] + * @param {string} [opts.ownRoot] the checkout the guard runs in; defaults to this repo + * @returns {{usable: boolean, path: string, reason: string|null}} + */ +function siblingCheckout(fromDir, target, opts = {}) { + const ownRoot = opts.ownRoot || OWN_ROOT; + const env = opts.env || process.env; + const abs = path.resolve(fromDir, target); + + if (!fs.existsSync(abs)) + return { usable: false, path: abs, reason: 'sibling path absent: ' + abs }; + + // Only the entry directly beside this checkout can be the lane symlink. A + // path that does not pass through that parent is not a sibling reference. + const parent = path.dirname(ownRoot); + const rel = path.relative(parent, abs); + if (rel.startsWith('..') || path.isAbsolute(rel)) + return { usable: true, path: abs, reason: null }; + const entry = path.join(parent, rel.split(path.sep)[0]); + + if (!isLinkedWorktree(ownRoot) || !fs.lstatSync(entry).isSymbolicLink()) + return { usable: true, path: abs, reason: null }; + + const real = fs.realpathSync(entry); + const targetRoot = checkoutRootOf(real); + const targetIsMain = targetRoot !== null + && fs.lstatSync(path.join(targetRoot, '.git')).isDirectory(); + if (targetIsMain) { + if (pinnedMainCheckout(targetRoot, path.basename(entry), env)) + return { usable: true, path: abs, reason: null }; + return { + usable: false, path: abs, + reason: 'sibling ' + path.basename(entry) + ' resolves through a symlink into the live main checkout ' + + targetRoot + ', which no commit pins; cut a real sibling worktree to test against it', + }; + } + return { usable: true, path: abs, reason: null }; +} + +/** + * Act on a refusal inside a mocha test or hook: fail when siblings were + * declared supplied, otherwise skip. Returns false so a caller can write + * `if (!verdict.usable) return skipOrFail(this, verdict, 'what this guard checks');`. + * + * @param {object} ctx the mocha `this` + * @param {{usable: boolean, reason: string|null}} verdict from siblingCheckout() + * @param {string} what one clause naming the guard, for the failure message + */ +function skipOrFail(ctx, verdict, what) { + if (verdict.usable) return true; + if (siblingsRequired()) + throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but ' + what + ' cannot run: ' + verdict.reason); + ctx.skip(); + return false; +} + +module.exports = { siblingCheckout, skipOrFail, siblingsRequired, isLinkedWorktree, pinnedMainCheckout }; diff --git a/test/helpers/stake_harness.js b/test/helpers/stake_harness.js index 87d493ff..922eebf8 100644 --- a/test/helpers/stake_harness.js +++ b/test/helpers/stake_harness.js @@ -42,9 +42,13 @@ function makeExplorer(chain) { ? sinon.stub().rejects(new Error(chain.delegationsThrow)) : sinon.stub().resolves(chain.delegationsBody || { total: (chain.delegations || []).length, data: chain.delegations || [] }), - // The explorer's indexed tip, under every stake chain. + // The explorer's indexed tip, keyed by route code (BTC/TBTC/RBTC) as the real /status is. getStatus: sinon.stub().resolves({ last_block: { - 'bitcoin-mainnet': chain.tip ?? 200000, 'bitcoin-testnet': chain.tip ?? 200000, 'bitcoin-regtest': chain.tip ?? 200000 } }) + BTC: chain.tip ?? 200000, TBTC: chain.tip ?? 200000, RBTC: chain.tip ?? 200000 } }), + // The SLEEP rows the explorer's address lane holds for the stake address; none by default. + getSleeps: chain.sleepsThrow + ? sinon.stub().rejects(new Error(chain.sleepsThrow)) + : sinon.stub().resolves({ total: (chain.sleeps || []).length, data: chain.sleeps || [] }) } } diff --git a/test/hub_failover_testnet_ids.test.js b/test/hub_failover_testnet_ids.test.js new file mode 100644 index 00000000..7644b187 --- /dev/null +++ b/test/hub_failover_testnet_ids.test.js @@ -0,0 +1,117 @@ +'use strict' + +// Copyright (c) 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const { test } = require('node:test') + +const ROOT = path.join(__dirname, '..') +const BASE_PATH = path.join(ROOT, '.github/hub-failover/compose.yml') +const OVERRIDE_PATH = path.join(ROOT, '.github/hub-failover/compose.testnet-ids.yml') +const HUBS = ['hub-a', 'hub-b'] +const PEER_LIST_KEYS = ['P2P_PEERS', 'SEED_NODES'] + +function load (file) { + return fs.readFileSync(file, 'utf8') +} + +function indentation (line) { + return line.length - line.trimStart().length +} + +function block (source, header, depth) { + const lines = source.split('\n') + const start = lines.indexOf(header) + assert.notEqual(start, -1, `missing ${header.trim()}`) + + let end = start + 1 + while (end < lines.length && (!lines[end].trim() || indentation(lines[end]) > depth)) end++ + return lines.slice(start, end).join('\n').trimEnd() +} + +function keysAt (source, depth) { + return source.split('\n') + .filter((line) => indentation(line) === depth && /^[ ]*[A-Za-z0-9_<>&-]+:/.test(line)) + .map((line) => line.trim().split(':', 1)[0]) +} + +function scalar (source, key) { + const match = source.match(new RegExp(`^ +${key}: +([^\\n]+)$`, 'm')) + assert.notEqual(match, null, `missing ${key}`) + return match[1] +} + +function registrations (command) { + return [...command.matchAll(/'({[^']+"method":"registervalidator"[^']+})'/g)] + .map((match) => JSON.parse(match[1])) +} + +function isUrl (value) { + try { + return Boolean(new URL(value).protocol) + } catch { + return false + } +} + +test('hub failover testnet identities preserve peer URLs and all other compose settings', () => { + const base = load(BASE_PATH) + const override = load(OVERRIDE_PATH) + + assert.deepEqual(keysAt(override, 0), ['services']) + assert.deepEqual(keysAt(override, 2), [...HUBS, 'hub-federation-init']) + + const validatorIds = new Map() + for (const hub of HUBS) { + const baseService = block(base, ` ${hub}:`, 2) + const overrideService = block(override, ` ${hub}:`, 2) + const baseEnvironment = block(baseService, ' environment:', 4) + const overrideEnvironment = block(overrideService, ' environment:', 4) + assert.deepEqual(keysAt(overrideService, 4), ['environment']) + assert.deepEqual(keysAt(overrideEnvironment, 6), ['P2P_VALIDATOR_ADDR']) + + const validatorId = scalar(overrideEnvironment, 'P2P_VALIDATOR_ADDR') + assert.match(validatorId, /^mverify[1-9A-HJ-NP-Za-km-z]+$/) + assert.equal(isUrl(validatorId), false) + validatorIds.set(hub, validatorId) + + const peerLists = PEER_LIST_KEYS + .filter((key) => new RegExp(`^ +${key}:`, 'm').test(baseEnvironment)) + .map((key) => scalar(baseEnvironment, key)) + assert.ok(peerLists.length > 0, `${hub} must keep a peer list`) + for (const peers of peerLists) { + for (const peer of peers.split(',')) assert.match(peer, /^wss?:\/\//) + } + } + assert.equal(new Set(validatorIds.values()).size, HUBS.length) + + const baseInit = block(base, ' hub-federation-init:', 2) + const overrideInit = block(override, ' hub-federation-init:', 2) + assert.deepEqual(keysAt(overrideInit, 4), ['command']) + + const baseCommand = block(baseInit, ' command:', 4) + const overrideCommand = block(overrideInit, ' command:', 4) + const restoredCommand = HUBS.reduce((command, hub) => { + const baseEnvironment = block(block(base, ` ${hub}:`, 2), ' environment:', 4) + const url = scalar(baseEnvironment, 'P2P_VALIDATOR_ADDR') + assert.equal(isUrl(url), true) + return command.replaceAll(validatorIds.get(hub), url) + }, overrideCommand) + assert.equal(restoredCommand, baseCommand) + + const registered = registrations(overrideCommand) + assert.equal(registered.length, HUBS.length) + assert.deepEqual( + registered.map((request) => request.params.addr), + HUBS.map((hub) => validatorIds.get(hub)) + ) + + for (const validatorId of validatorIds.values()) { + assert.equal(override.split(validatorId).length - 1, 2) + } +}) diff --git a/test/integration/database_setup.test/set_database_parameters.test.js b/test/integration/database_setup.test/set_database_parameters.test.js index efc1d7f4..a0380d6f 100644 --- a/test/integration/database_setup.test/set_database_parameters.test.js +++ b/test/integration/database_setup.test/set_database_parameters.test.js @@ -40,6 +40,9 @@ databaseSuite('setDatabaseParameters', function (fixture) { await DatabaseService.setDatabaseParameters() + // The drift guard ran inside the fixture, never against the host daemon. + expect(capture.findCommands(/\.Config\.Env/)).to.have.lengthOf(2) + const createUserCmds = capture.findCommands(/CREATE USER/) const userNames = createUserCmds.map(c => c.command) diff --git a/test/integration/database_setup.test/support/fixture.js b/test/integration/database_setup.test/support/fixture.js index bdc354f5..cb72122d 100644 --- a/test/integration/database_setup.test/support/fixture.js +++ b/test/integration/database_setup.test/support/fixture.js @@ -75,6 +75,10 @@ function configureCapture(capture, options, dbContainerId) { capture.when(/docker network inspect/).returns({ stdout: JSON.stringify([{ IPAM: { Config: [{ Gateway: options.gateway || '172.18.0.1' }] } }]) }) + // The credential-drift probe reads each decoder/indexer container's env; + // none runs in this fixture, so answer "no such container" (registered + // first because the --format route below also matches the probe's argv). + capture.when(/docker inspect .*\{\{json \.Config\.Env\}\}/).returns({ error: new Error('No such container') }) // getDatabaseContainerId() runs `docker inspect --type container // --format {{.Id}}`, which prints the bare 64-hex container id (not // JSON) on success. This route has to be registered ahead of the @@ -105,6 +109,12 @@ function proxyDatabaseService(capture, options) { }, 'util': { promisify: () => execFileAsyncStub }, 'enquirer': { Password: class { async run() { return 'testrootpw' } } }, + // Route the real drift guard through the capture (unstubbed, it probed the + // venue's own docker daemon and refused on a CI venue's live bitcoin stack). + './db_credential_drift': proxyquire('../../../../src/services/db_credential_drift', { + 'child_process': { execFile: capture.createExecFileStub() }, + 'util': { promisify: () => execFileAsyncStub } + }), './status_service': { statusChanged: async () => true, getStatus: async () => ({}), diff --git a/test/integration/docker_commands.test/support/fixture.js b/test/integration/docker_commands.test/support/fixture.js index 220e3a33..18f0cd3e 100644 --- a/test/integration/docker_commands.test/support/fixture.js +++ b/test/integration/docker_commands.test/support/fixture.js @@ -103,6 +103,10 @@ function makeBuildAndUp(env, capture) { }, './database_service': { setDatabaseParameters: async () => true + }, + './hub_consensus_env_guard': { + assertNoHubConsensusEnvDrift: async () => [], + isHubConsensusEnvDriftError: () => false } }) diff --git a/test/integration/docker_commands.test/xchain_regtest_miner_docker_run_command.test.js b/test/integration/docker_commands.test/xchain_regtest_miner_docker_run_command.test.js index c369c58b..e5035af9 100644 --- a/test/integration/docker_commands.test/xchain_regtest_miner_docker_run_command.test.js +++ b/test/integration/docker_commands.test/xchain_regtest_miner_docker_run_command.test.js @@ -29,10 +29,25 @@ dockerSuite('xchain-regtest-miner Docker run command', function (fixture) { expect(runCmd).to.include('--env NODE_PORT') expect(runCmd).to.include('--env NETWORK') expect(runCmd).to.not.include('NODE_PORT=18444') - expect(runCmd).to.not.include('NETWORK=regtest') + expect(runCmd).to.not.include('NETWORK=bitcoin-regtest') expect(runEntry.options.env.NODE_PORT).to.equal('18444') - expect(runEntry.options.env.NETWORK).to.equal('regtest') + expect(runEntry.options.env.NETWORK).to.equal('bitcoin-regtest') expect(runCmd).to.include('-p 3005:3005') expect(runCmd).to.include('--network xchain-node-bitcoin-regtest') }) + + // The miner takes its coin from the NETWORK prefix; a bare value runs LTC and DOGE with bitcoin fee and dust rules. + for (const coin of ['litecoin', 'dogecoin']) { + it('names the coin in NETWORK for a ' + coin + ' regtest miner', async function () { + const { env, capture, makeBuildAndUp } = fixture() + env.writeConfigFile(coin + '-regtest', '') + env.createFakeModule('xchain-regtest-miner') + + const { ModuleService } = makeBuildAndUp() + await ModuleService.buildAndUp('xchain-regtest-miner', coin, 'regtest', null, true) + + const runEntry = capture.findCommands(/docker run/)[0] + expect(runEntry.options.env.NETWORK).to.equal(coin + '-regtest') + }) + } }) diff --git a/test/integration/hub_config_update.test/04_checkpoint_self_sync_opt_in_survives_the_shell_that_set_it.test.js b/test/integration/hub_config_update.test/04_checkpoint_self_sync_opt_in_survives_the_shell_that_set_it.test.js index 14f36622..eb6adda2 100644 --- a/test/integration/hub_config_update.test/04_checkpoint_self_sync_opt_in_survives_the_shell_that_set_it.test.js +++ b/test/integration/hub_config_update.test/04_checkpoint_self_sync_opt_in_survives_the_shell_that_set_it.test.js @@ -15,7 +15,7 @@ const proxyquire = require('proxyquire').noCallThru() const { hubConfigSuite } = require('./support/fixture') -async function pushWithContainerEnv(fixture, containerEnv) { +async function pushWithContainerEnv(fixture, containerEnv, networks = { bitcoin: 'regtest', litecoin: 'regtest' }) { const { env, httpCapture, TestEnv } = fixture() const state = require('../../../src/state') @@ -24,16 +24,16 @@ async function pushWithContainerEnv(fixture, containerEnv) { const ltcId = TestEnv.fakeContainerId('l') await env.insertModule('xchain-hub', '', '', hubId) - await env.insertModule('xchain-indexer', 'bitcoin', 'regtest', btcId) - await env.insertModule('xchain-indexer', 'litecoin', 'regtest', ltcId) + await env.insertModule('xchain-indexer', 'bitcoin', networks.bitcoin, btcId) + await env.insertModule('xchain-indexer', 'litecoin', networks.litecoin, ltcId) - env.writeConfigFile('bitcoin-regtest', '') - env.writeConfigFile('litecoin-regtest', '') + env.writeConfigFile('bitcoin-' + networks.bitcoin, '') + env.writeConfigFile('litecoin-' + networks.litecoin, '') state.setStatusUpdated(true) state.setLastStatus({ - 'bitcoin': { 'regtest': { 'xchain-indexer': { container_id: btcId, status: { State: { Status: 'running' } } } } }, - 'litecoin': { 'regtest': { 'xchain-indexer': { container_id: ltcId, status: { State: { Status: 'running' } } } } } + 'bitcoin': { [networks.bitcoin]: { 'xchain-indexer': { container_id: btcId, status: { State: { Status: 'running' } } } } }, + 'litecoin': { [networks.litecoin]: { 'xchain-indexer': { container_id: ltcId, status: { State: { Status: 'running' } } } } } }) httpCapture.when('127.0.0.1:10000').returns({ data: { result: true } }) @@ -115,3 +115,36 @@ checkpointSuite(function (fixture) { expect(config['litecoin']['regtest'].checkpoint).to.be.undefined }) }) + +// Provisioning grants the HubMirror schema to indexer accounts only off mainnet, +// so a mainnet block is still emitted (the explorer requires one per serving coin) +// but the push names the exact manual GRANT the explorer's CREATE DATABASE needs. +checkpointSuite(function (fixture) { + + it('warns with the exact mirror GRANT for a mainnet coin and still emits its block', async function () { + const warnings = [] + const originalWarn = console.warn + console.warn = (...args) => { warnings.push(args.join(' ')) } + let config + try { + config = await pushWithContainerEnv(fixture, { HUB_API_URL: 'http://xchain-node-xchain-hub:10000' }, + { bitcoin: 'mainnet', litecoin: 'regtest' }) + } finally { + console.warn = originalWarn + } + + const mainnetBlock = config['bitcoin']['mainnet'].checkpoint + expect(mainnetBlock, 'mainnet checkpoint block').to.exist + expect(mainnetBlock.self_sync).to.be.true + expect(config['litecoin']['regtest'].checkpoint, 'regtest checkpoint block').to.exist + + const grantWarnings = warnings.filter(line => line.includes('GRANT ALL PRIVILEGES ON')) + expect(grantWarnings, 'one warning per mainnet coin').to.have.length(1) + const escapedSchema = mainnetBlock.name.replace(/_/g, '\\_') + expect(grantWarnings[0]).to.include('`' + escapedSchema + '`.*') + expect(grantWarnings[0]).to.include("'" + mainnetBlock.user + "'@'%'") + expect(grantWarnings[0]).to.include('bitcoin mainnet') + expect(grantWarnings[0]).to.not.include('%_HubMirror') + expect(grantWarnings[0]).to.not.include(mainnetBlock.pass) + }) +}) diff --git a/test/integration/multi_module_orchestration.test/01_update_modules_orchestration.test.js b/test/integration/multi_module_orchestration.test/01_update_modules_orchestration.test.js index 99b158a2..a9fe3ccb 100644 --- a/test/integration/multi_module_orchestration.test/01_update_modules_orchestration.test.js +++ b/test/integration/multi_module_orchestration.test/01_update_modules_orchestration.test.js @@ -15,6 +15,11 @@ const proxyquire = require('proxyquire').noCallThru() const { multiModuleSuite } = require('./support/fixture') +const installTargetServiceStub = { + recordInstallTarget: () => true, + resolveUpdateTarget: async () => ({ kind: 'branch', ref: 'master', inferred: false }) +} + // updateModules multiModuleSuite('updateModules orchestration', function (fixture) { @@ -47,7 +52,8 @@ multiModuleSuite('updateModules orchestration', function (fixture) { }, '../services/database_service': { buildDatabaseModule: async () => true - } + }, + '../services/install_target_service': installTargetServiceStub }) const serviceList = { 'bitcoin': { 'mainnet': ['xchain-encoder'] } } @@ -96,6 +102,10 @@ multiModuleSuite('updateModules orchestration', function (fixture) { }, '../services/database_service': { buildDatabaseModule: async () => true + }, + '../services/install_target_service': installTargetServiceStub, + '../services/migration_precondition_service': { + assertRequiredMigrationsApplied: async () => ({ checked: false, reason: 'stubbed' }) } }) diff --git a/test/regression/regression.test/06_e2e_lifecycle_workflows.test.js b/test/regression/regression.test/06_e2e_lifecycle_workflows.test.js index ffa69f31..6d4a38db 100644 --- a/test/regression/regression.test/06_e2e_lifecycle_workflows.test.js +++ b/test/regression/regression.test/06_e2e_lifecycle_workflows.test.js @@ -218,7 +218,9 @@ describe('Regression Suite', function () { const seams = env.hostSeamCallNames() expect(seams, 'utxo-tracker freshness').to.include('bootstrap_service.utxoTrackerVolumeFreshness') expect(seams, 'decoder/indexer freshness').to.include('bootstrap_service.mariaDbModuleFreshness') - expect(seams, 'db container probe').to.include('database_service.getDatabaseContainerId') + // Credential rotation is decided without probing the host's db + // container, so the install never reaches that seam. + expect(seams, 'no db container probe').to.not.include('database_service.getDatabaseContainerId') expect(seams, 'container health probe').to.include('bootstrap_health_gate.probeServiceStatus') expect(seams, 'config read').to.include('config_service.getDefaultConfig') diff --git a/test/unit/autoheal_service.test/19_service_healthcheck_flags.test.js b/test/unit/autoheal_service.test/19_service_healthcheck_flags.test.js index 17a9b333..758ca2a8 100644 --- a/test/unit/autoheal_service.test/19_service_healthcheck_flags.test.js +++ b/test/unit/autoheal_service.test/19_service_healthcheck_flags.test.js @@ -23,5 +23,18 @@ describe('AutohealService', () => { it('xchain-indexer is opted in', () => { expect(SERVICE_HEALTHCHECK['xchain-indexer'].autoheal).to.equal(true) }) + + it('xchain-encoder is never opted in (its probe reports tracker readiness)', () => { + expect(SERVICE_HEALTHCHECK['xchain-encoder'].autoheal).to.equal(undefined) + }) + + it('the opt-in set is exactly the decoder and the indexer', () => { + const optedIn = Object.keys(SERVICE_HEALTHCHECK) + .filter(name => SERVICE_HEALTHCHECK[name].autoheal === true) + .sort() + expect(optedIn, 'changing the autoheal opt-in set also changes the README Autoheal ' + + 'section and the probation comment in autoheal_service/restart_state.js') + .to.deep.equal(['xchain-decoder', 'xchain-indexer']) + }) }) }) diff --git a/test/unit/autoheal_service.test/03_restart_cooldown.test.js b/test/unit/autoheal_service.test/backoff/03_restart_cooldown.test.js similarity index 97% rename from test/unit/autoheal_service.test/03_restart_cooldown.test.js rename to test/unit/autoheal_service.test/backoff/03_restart_cooldown.test.js index 4fd9183d..ba5b0c52 100644 --- a/test/unit/autoheal_service.test/03_restart_cooldown.test.js +++ b/test/unit/autoheal_service.test/backoff/03_restart_cooldown.test.js @@ -57,14 +57,14 @@ function makeStubs() { } function loadService(stubs) { - return proxyquire('../../../src/services/autoheal_service', { + return proxyquire('../../../../src/services/autoheal_service', { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, restartContainer: stubs.restartContainer }, // Real descriptor table: asserts the actual opt-in flags too. - './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } + './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } }) } diff --git a/test/unit/autoheal_service.test/04_exponential_backoff.test.js b/test/unit/autoheal_service.test/backoff/04_exponential_backoff.test.js similarity index 97% rename from test/unit/autoheal_service.test/04_exponential_backoff.test.js rename to test/unit/autoheal_service.test/backoff/04_exponential_backoff.test.js index 16453fa1..07958652 100644 --- a/test/unit/autoheal_service.test/04_exponential_backoff.test.js +++ b/test/unit/autoheal_service.test/backoff/04_exponential_backoff.test.js @@ -57,14 +57,14 @@ function makeStubs() { } function loadService(stubs) { - return proxyquire('../../../src/services/autoheal_service', { + return proxyquire('../../../../src/services/autoheal_service', { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, restartContainer: stubs.restartContainer }, // Real descriptor table: asserts the actual opt-in flags too. - './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } + './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } }) } diff --git a/test/unit/autoheal_service.test/05_recovery_resets_backoff.test.js b/test/unit/autoheal_service.test/backoff/05_recovery_resets_backoff.test.js similarity index 97% rename from test/unit/autoheal_service.test/05_recovery_resets_backoff.test.js rename to test/unit/autoheal_service.test/backoff/05_recovery_resets_backoff.test.js index 6776f12a..c2ffcc04 100644 --- a/test/unit/autoheal_service.test/05_recovery_resets_backoff.test.js +++ b/test/unit/autoheal_service.test/backoff/05_recovery_resets_backoff.test.js @@ -57,14 +57,14 @@ function makeStubs() { } function loadService(stubs) { - return proxyquire('../../../src/services/autoheal_service', { + return proxyquire('../../../../src/services/autoheal_service', { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, restartContainer: stubs.restartContainer }, // Real descriptor table: asserts the actual opt-in flags too. - './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } + './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } }) } diff --git a/test/unit/autoheal_service.test/07_stopped_container_backoff.test.js b/test/unit/autoheal_service.test/backoff/07_stopped_container_backoff.test.js similarity index 97% rename from test/unit/autoheal_service.test/07_stopped_container_backoff.test.js rename to test/unit/autoheal_service.test/backoff/07_stopped_container_backoff.test.js index 0da6b070..9dd696fc 100644 --- a/test/unit/autoheal_service.test/07_stopped_container_backoff.test.js +++ b/test/unit/autoheal_service.test/backoff/07_stopped_container_backoff.test.js @@ -64,14 +64,14 @@ function makeStubs() { } function loadService(stubs) { - return proxyquire('../../../src/services/autoheal_service', { + return proxyquire('../../../../src/services/autoheal_service', { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, restartContainer: stubs.restartContainer }, // Real descriptor table: asserts the actual opt-in flags too. - './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } + './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } }) } diff --git a/test/unit/autoheal_service.test/10_backoff_calculations.test.js b/test/unit/autoheal_service.test/backoff/10_backoff_calculations.test.js similarity index 96% rename from test/unit/autoheal_service.test/10_backoff_calculations.test.js rename to test/unit/autoheal_service.test/backoff/10_backoff_calculations.test.js index c1b9ca29..1b8db64e 100644 --- a/test/unit/autoheal_service.test/10_backoff_calculations.test.js +++ b/test/unit/autoheal_service.test/backoff/10_backoff_calculations.test.js @@ -28,14 +28,14 @@ function makeStubs() { } function loadService(stubs) { - return proxyquire('../../../src/services/autoheal_service', { + return proxyquire('../../../../src/services/autoheal_service', { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, restartContainer: stubs.restartContainer }, // Real descriptor table: asserts the actual opt-in flags too. - './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } + './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } }) } diff --git a/test/unit/autoheal_service.test/06_starting_probation.test.js b/test/unit/autoheal_service.test/probation/06_starting_probation.test.js similarity index 97% rename from test/unit/autoheal_service.test/06_starting_probation.test.js rename to test/unit/autoheal_service.test/probation/06_starting_probation.test.js index 464f70ca..89f87205 100644 --- a/test/unit/autoheal_service.test/06_starting_probation.test.js +++ b/test/unit/autoheal_service.test/probation/06_starting_probation.test.js @@ -57,14 +57,14 @@ function makeStubs() { } function loadService(stubs) { - return proxyquire('../../../src/services/autoheal_service', { + return proxyquire('../../../../src/services/autoheal_service', { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, restartContainer: stubs.restartContainer }, // Real descriptor table: asserts the actual opt-in flags too. - './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } + './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } }) } diff --git a/test/unit/autoheal_service.test/08_recovery_past_probation.test.js b/test/unit/autoheal_service.test/probation/08_recovery_past_probation.test.js similarity index 98% rename from test/unit/autoheal_service.test/08_recovery_past_probation.test.js rename to test/unit/autoheal_service.test/probation/08_recovery_past_probation.test.js index 9af9d02f..32f8cc55 100644 --- a/test/unit/autoheal_service.test/08_recovery_past_probation.test.js +++ b/test/unit/autoheal_service.test/probation/08_recovery_past_probation.test.js @@ -86,14 +86,14 @@ function makeStubs() { } function loadService(stubs) { - return proxyquire('../../../src/services/autoheal_service', { + return proxyquire('../../../../src/services/autoheal_service', { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, restartContainer: stubs.restartContainer }, // Real descriptor table: asserts the actual opt-in flags too. - './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } + './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } }) } diff --git a/test/unit/autoheal_service.test/09_pass_inside_probation.test.js b/test/unit/autoheal_service.test/probation/09_pass_inside_probation.test.js similarity index 97% rename from test/unit/autoheal_service.test/09_pass_inside_probation.test.js rename to test/unit/autoheal_service.test/probation/09_pass_inside_probation.test.js index b93aed11..9cc047c3 100644 --- a/test/unit/autoheal_service.test/09_pass_inside_probation.test.js +++ b/test/unit/autoheal_service.test/probation/09_pass_inside_probation.test.js @@ -86,14 +86,14 @@ function makeStubs() { } function loadService(stubs) { - return proxyquire('../../../src/services/autoheal_service', { + return proxyquire('../../../../src/services/autoheal_service', { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, restartContainer: stubs.restartContainer }, // Real descriptor table: asserts the actual opt-in flags too. - './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } + './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } }) } diff --git a/test/unit/bootstrap_health_gate.test.js b/test/unit/bootstrap_health_gate.test.js index 039f3a9a..f87b10e1 100644 --- a/test/unit/bootstrap_health_gate.test.js +++ b/test/unit/bootstrap_health_gate.test.js @@ -26,6 +26,12 @@ const { refusal, sinon } = require('./bootstrap_health_gate.test/support/bootstrap_health_gate') + +// Build a runner for a healthy indexer whose paired decoder database answers as `decoder` says. +function pairedIndexerRunner(decoder) { + return makeRunner({ status: { status: 'healthy', lag: 0, decoderReorgHalted: false }, decoder }) +} + describe('BootstrapHealthGate', function () { installEnvironmentHooks() @@ -58,6 +64,8 @@ describe('BootstrapHealthGate', function () { expect(err.message).to.match(/full resync from a known-good snapshot/) // The message must name the artifact hazard, not just the symptom. expect(err.message).to.match(/publishing an unverified one is worse than publishing nothing/) + // The closing paragraph must not contradict the line's own clear-reorg-halt recovery. + expect(err.message).to.not.match(/durable halt marker that means a full resync/) }) it('REFUSES a database carrying an uncleared xchain-sync divergence halt', async function () { @@ -213,21 +221,19 @@ describe('BootstrapHealthGate', function () { // yes, and the gate's one image-independent backstop had no reach at all here. it('REFUSES an indexer whose PAIRED DECODER database carries a REORG_HALT row', async function () { const gate = loadGate() - const runner = makeRunner({ - status: { status: 'healthy', lag: 0, decoderReorgHalted: false }, - decoder: { reorgHaltRows: '1' } - }) + const runner = pairedIndexerRunner({ reorgHaltRows: '1' }) const err = await refusal(callGate(gate, { module: XChainService.XCHAIN_INDEXER, runner })) expect(err.message).to.match(new RegExp(`paired decoder database ${DECODER_DB} carries a durable REORG_HALT`)) expect(err.message).to.match(/frozen behind a decoder that aborted mid-rollback/) + // The paired-decoder line names the in-place clear first and the resync as the fallback. + const paired = err.reasons.find(r => /paired decoder database .* carries a durable REORG_HALT/.test(r)) + expect(paired).to.match(/xchain-node clear-reorg-halt /) + expect(paired).to.match(/full resync of the decoder and this indexer/) }) it('REFUSES an indexer whose paired decoder database carries an uncleared sync halt', async function () { const gate = loadGate() - const runner = makeRunner({ - status: { status: 'healthy', lag: 0, decoderReorgHalted: false }, - decoder: { syncHaltRows: '2' } - }) + const runner = pairedIndexerRunner({ syncHaltRows: '2' }) const err = await refusal(callGate(gate, { module: XChainService.XCHAIN_INDEXER, runner })) expect(err.message).to.match(/paired decoder database[\s\S]*uncleared[\s\S]*sync_halt/) }) @@ -245,10 +251,7 @@ describe('BootstrapHealthGate', function () { // must not arrive as "the decoder has no halt marker". it('REFUSES an indexer when the paired decoder database is absent', async function () { const gate = loadGate() - const runner = makeRunner({ - status: { status: 'healthy', lag: 0, decoderReorgHalted: false }, - decoder: { tables: '0\t0' } - }) + const runner = pairedIndexerRunner({ tables: '0\t0' }) const err = await refusal(callGate(gate, { module: XChainService.XCHAIN_INDEXER, runner })) expect(err.message).to.match(/paired decoder database[\s\S]*could not be probed/) expect(err.message).to.match(/reports no events table/) @@ -256,10 +259,7 @@ describe('BootstrapHealthGate', function () { it('REFUSES an indexer when the paired decoder probe throws', async function () { const gate = loadGate() - const runner = makeRunner({ - status: { status: 'healthy', lag: 0, decoderReorgHalted: false }, - decoder: { throws: new Error('access denied') } - }) + const runner = pairedIndexerRunner({ throws: new Error('access denied') }) const err = await refusal(callGate(gate, { module: XChainService.XCHAIN_INDEXER, runner })) expect(err.message).to.match(/paired decoder database[\s\S]*could not be probed[\s\S]*access denied/) }) diff --git a/test/unit/bootstrap_health_gate.test/configured_database_names.test.js b/test/unit/bootstrap_health_gate.test/configured_database_names.test.js new file mode 100644 index 00000000..941a48bd --- /dev/null +++ b/test/unit/bootstrap_health_gate.test/configured_database_names.test.js @@ -0,0 +1,70 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const sinon = require('sinon') +const { expect } = require('chai') +const proxyquire = require('proxyquire').noCallThru() +const { configStub } = require('../../helpers/config_stub') +const { XChainService } = require('../../../src/config') + +const COIN = 'litecoin' +const NETWORK = 'mainnet' + +// Load the marker reader over the native client, with the stack's config file +// answering `cfg` and every derived default carrying a recognisable name. +function loadMarkers(cfg) { + return proxyquire('../../../src/services/bootstrap_health_gate/halt_markers.js', { + '../../config': configStub({ XChainService, EXTERNAL_DB: true }), + '../config_service': { + getDefaultConfig: sinon.stub().resolves(cfg), + getModuleDatabaseName: sinon.stub().callsFake(m => `default_${m.replace(/-/g, '_')}`) + } + }) +} + +// A clean database answering every marker query, recording each statement. +function nativeDeps() { + const executeNativeMariaDbCommand = sinon.stub().callsFake(async (c, sql) => + (/information_schema\.TABLES/.test(sql) ? '1\t1' : '0')) + return { executeNativeMariaDbCommand, getExternalDbConfig: sinon.stub().resolves({ host: 'h' }) } +} + +function statements(deps) { + return deps.executeNativeMariaDbCommand.getCalls().map(c => String(c.args[1])) +} + +describe('BootstrapHealthGate', function () { + describe('halt markers on a stack with overridden database names', function () { + it('probes the configured indexer database and its configured paired decoder database', async function () { + const deps = nativeDeps() + const markers = await loadMarkers({ DECODER_DB_NAME: 'CustomDecoder', INDEXER_DB_NAME: 'CustomIndexer' }) + .readHaltMarkers(COIN, NETWORK, XChainService.XCHAIN_INDEXER, deps) + const sql = statements(deps) + expect(sql.some(s => s.includes('CustomIndexer'))).to.equal(true) + expect(sql.some(s => s.includes('CustomDecoder'))).to.equal(true) + expect(sql.some(s => s.includes('default_'))).to.equal(false) + expect(markers.upstream.dbName).to.equal('CustomDecoder') + }) + + it('probes the derived default when the config names no database', async function () { + const deps = nativeDeps() + await loadMarkers({}).readHaltMarkers(COIN, NETWORK, XChainService.XCHAIN_DECODER, deps) + expect(statements(deps).some(s => s.includes('default_xchain_decoder'))).to.equal(true) + }) + + it('refuses to probe an unsafe configured name', async function () { + const deps = nativeDeps() + let error = null + try { + await loadMarkers({ DECODER_DB_NAME: 'Custom; DROP DATABASE mysql' }) + .readHaltMarkers(COIN, NETWORK, XChainService.XCHAIN_DECODER, deps) + } catch (err) { error = err } + expect(error && error.message).to.match(/Unsafe MariaDB database name/) + expect(deps.executeNativeMariaDbCommand.called).to.equal(false) + }) + }) +}) diff --git a/test/unit/bootstrap_health_gate.test/make_bootstrap_consults_the_source_health_gate.test.js b/test/unit/bootstrap_health_gate.test/make_bootstrap_consults_the_source_health_gate.test.js index 7bee873a..0420474f 100644 --- a/test/unit/bootstrap_health_gate.test/make_bootstrap_consults_the_source_health_gate.test.js +++ b/test/unit/bootstrap_health_gate.test/make_bootstrap_consults_the_source_health_gate.test.js @@ -11,6 +11,7 @@ const { NETWORK, SVC_CONTAINER, XChainService, + containerDb, expect, proxyquire, sinon @@ -18,7 +19,7 @@ const { function loadServiceWithGate(gateStub) { return proxyquire('../../../../src/services/bootstrap_service', { - '../state': { db: { getModuleContainer: sinon.stub().resolves(SVC_CONTAINER) } }, + '../state': { db: containerDb() }, './config_service': { getDefaultConfig: sinon.stub().resolves({}), getModuleDatabaseName: sinon.stub().returns('db'), @@ -53,7 +54,7 @@ describe('makeBootstrap() consults the source health gate', function () { const gateStub = sinon.stub().rejects(new Error('Refusing to create a bootstrap')) const stopContainer = sinon.stub().resolves() const svc = proxyquire('../../../../src/services/bootstrap_service', { - '../state': { db: { getModuleContainer: sinon.stub().resolves(SVC_CONTAINER) } }, + '../state': { db: containerDb() }, './config_service': { getDefaultConfig: sinon.stub().resolves({ UTXO_TRACKER_BOOTSTRAP_VOLUME: '/tmp/x' }), getModuleDatabaseName: sinon.stub().returns('db'), diff --git a/test/unit/bootstrap_health_gate.test/support/bootstrap_health_gate.js b/test/unit/bootstrap_health_gate.test/support/bootstrap_health_gate.js index a0e50e15..31ff8416 100644 --- a/test/unit/bootstrap_health_gate.test/support/bootstrap_health_gate.js +++ b/test/unit/bootstrap_health_gate.test/support/bootstrap_health_gate.js @@ -19,6 +19,12 @@ const DB_CONTAINER = 'd'.repeat(64) const DECODER_DB = 'xchain_ltc_mainnet_decoder' const INDEXER_DB = 'xchain_ltc_mainnet_indexer' +function containerDb() { + const db = { getModuleContainer: sinon.stub().resolves(SVC_CONTAINER) } + db.getModuleContainerStrict = sinon.stub().callsFake((...args) => db.getModuleContainer(...args)) + return db +} + // A container that is up, stable, and passing its healthcheck. function healthyInspect({ started = '2026-01-01T00:00:00.000Z' } = {}) { return `running|false|0|${started}|healthy\n` @@ -63,7 +69,7 @@ function loadGate({ external = false, nativeResolves = null } = {}) { }) return proxyquire('../../../../src/services/bootstrap_health_gate', { '../config': config, - '../state': { db: { getModuleContainer: sinon.stub().resolves(SVC_CONTAINER) } }, + '../state': { db: containerDb() }, './config_service': configService, './database_service': { getDatabaseContainerId: sinon.stub().resolves(DB_CONTAINER), @@ -140,6 +146,7 @@ function callGate(gate, { module = XChainService.XCHAIN_DECODER, runner, contain return gate.assertBootstrapSourceHealthy(COIN, NETWORK, module, { runner, getModuleContainer: sinon.stub().resolves(container), + getModuleContainerStrict: sinon.stub().resolves(container), now: now || Date.parse('2026-07-27T00:00:00.000Z'), since: since === undefined ? null : since }) @@ -179,6 +186,7 @@ module.exports = { SVC_CONTAINER, XChainService, callGate, + containerDb, expect, healthyInspect, installEnvironmentHooks, diff --git a/test/unit/bootstrap_node_tip_guard.test.js b/test/unit/bootstrap_node_tip_guard.test.js index be1f5249..5eba7ef3 100644 --- a/test/unit/bootstrap_node_tip_guard.test.js +++ b/test/unit/bootstrap_node_tip_guard.test.js @@ -170,7 +170,9 @@ describe('BootstrapNodeTipGuard', function () { }) expect(r.verdict).to.equal('behind-refuse') expect(r.refuse).to.equal(true) - expect(logs.join('\n')).to.match(/^REFUSING the xchain-utxo-tracker bootstrap restore/m) + // The caller prints the refusal once the archive is verified; the height here is unverified. + expect(r.detail).to.match(/964970/) + expect(logs.join('\n')).to.not.match(/^REFUSING the/m) }) it('falls back to the module version when the service cannot be probed', async function () { diff --git a/test/unit/bootstrap_node_tip_guard.test/ibd_and_claimed_identity.test.js b/test/unit/bootstrap_node_tip_guard.test/ibd_and_claimed_identity.test.js new file mode 100644 index 00000000..b3121da5 --- /dev/null +++ b/test/unit/bootstrap_node_tip_guard.test/ibd_and_claimed_identity.test.js @@ -0,0 +1,123 @@ +'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 node tip guard against the services' own wait rule (initial block +// download only) and against an archive claiming another target. + +const sinon = require('sinon') +const { expect } = require('chai') + +const guard = require('../../../src/services/bootstrap_node_tip_guard') +const { XChainService } = require('../../../src/config') + +const DECODER = XChainService.XCHAIN_DECODER +const TRACKER = XChainService.XCHAIN_UTXO_TRACKER +const INDEXER = XChainService.XCHAIN_INDEXER +let logs, logStub + +function setupLogCapture() { + logs = [] + logStub = sinon.stub(console, 'log').callsFake((...args) => logs.push(args.join(' '))) + delete process.env.XCHAIN_NODE_SKIP_NODE_TIP_GUARD +} + +function cleanupLogCapture() { + logStub.restore() + delete process.env.XCHAIN_NODE_SKIP_NODE_TIP_GUARD +} + +// The decoder and tracker wait out a lower node tip only while the node reports +// initial block download; out of it they read the gap as a rollback. +describe('BootstrapNodeTipGuard', function () { + beforeEach(setupLogCapture) + afterEach(cleanupLogCapture) + + describe('a node below the archive but out of initial block download', function () { + const settledBehind = { blocks: 962304, headers: 964980, initialblockdownload: false } + const archive = async () => ({ format: 1, height: 964970 }) + + it('refuses the decoder and tracker even when the image waits, without blaming the image', function () { + for (const module of [DECODER, TRACKER]) { + const r = guard.evaluateNodeTipAgainstArchive({ archiveHeight: 964970, chainInfo: settledBehind, serviceWaits: true, module }) + expect(r.verdict, module).to.equal('behind-refuse') + expect(r).to.include({ ibd: false, gap: 2666 }) + expect(r.detail).to.match(/not in initial block download/) + expect(r.detail).to.match(/XCHAIN_NODE_FORCE_BOOTSTRAP=1/) + expect(r.detail).to.match(/--no-bootstrap/) + expect(r.detail).to.not.match(/WAITING FOR NODE|does not wait out a catching-up node/) + } + }) + + it('still passes the indexer, which follows the decoder', function () { + const r = guard.evaluateNodeTipAgainstArchive({ archiveHeight: 964970, chainInfo: settledBehind, serviceWaits: true, module: INDEXER }) + expect(r.verdict).to.equal('behind-wait') + }) + + it('refuses through the assessment without probing the service or the version', async function () { + const probe = sinon.stub().resolves({ status: 'healthy', node_catching_up: null }) + const version = sinon.stub().resolves('0.16.0') + const r = await guard.assessNodeTipForRestore({ coin: 'bitcoin', network: 'mainnet', module: DECODER, archivePath: '/x' }, { + readBootstrapArchiveMeta: archive, readCoinNodeChainInfo: async () => settledBehind, + probeServiceCapability: probe, getLocalModuleVersion: version + }) + expect(r.verdict).to.equal('behind-refuse') + expect(r.refuse).to.equal(true) + expect(probe.called).to.equal(false) + expect(version.called).to.equal(false) + expect(logs.join('\n')).to.not.match(/^REFUSING the|WAITING FOR NODE/m) + }) + + it('keeps the indexer restoring through the assessment', async function () { + const r = await guard.assessNodeTipForRestore({ coin: 'bitcoin', network: 'mainnet', module: INDEXER, archivePath: '/x' }, { + readBootstrapArchiveMeta: archive, readCoinNodeChainInfo: async () => settledBehind + }) + expect(r.verdict).to.equal('behind-wait') + expect(r.refuse).to.equal(false) + }) + }) +}) + +// bootstrap.json is read before the signature check, so a claimed identity for +// another target only stops the comparison; it never refuses. +describe('BootstrapNodeTipGuard', function () { + beforeEach(setupLogCapture) + afterEach(cleanupLogCapture) + + describe('assessNodeTipForRestore() and the archive\'s claimed identity', function () { + const behindNode = async () => ({ blocks: 962304, headers: 964980, initialblockdownload: true }) + const claimed = (fields) => async () => ({ format: 1, module: DECODER, coin: 'bitcoin', network: 'mainnet', height: 964970, ...fields }) + const target = { coin: 'bitcoin', network: 'mainnet', module: DECODER, archivePath: '/x' } + + it('is unknown, never a refusal, for an archive claiming another module, coin or network, and asks nothing', async function () { + for (const fields of [{ module: TRACKER }, { coin: 'litecoin' }, { network: 'testnet' }]) { + const node = sinon.stub().callsFake(behindNode) + const probe = sinon.stub().resolves({ node_catching_up: null }) + const r = await guard.assessNodeTipForRestore(target, { readBootstrapArchiveMeta: claimed(fields), readCoinNodeChainInfo: node, probeServiceCapability: probe }) + const [field, value] = Object.entries(fields)[0] + expect(r.verdict, field).to.equal('unknown') + expect(r.refuse).to.equal(false) + expect(r.detail).to.include(`${field} ${value} (unverified)`) + expect(node.called).to.equal(false) + expect(probe.called).to.equal(false) + } + expect(logs.join('\n')).to.match(/^Note: the archive's bootstrap\.json declares/m) + }) + + it('compares the height as before for a matching or converter-shape archive', async function () { + const deps = { readCoinNodeChainInfo: behindNode, probeServiceCapability: async () => ({ node_catching_up: null }) } + const full = await guard.assessNodeTipForRestore(target, { ...deps, readBootstrapArchiveMeta: claimed({}) }) + const converter = await guard.assessNodeTipForRestore(target, { ...deps, readBootstrapArchiveMeta: claimed({ coin: null, network: null }) }) + expect(full.verdict).to.equal('behind-wait') + expect(converter.verdict).to.equal('behind-wait') + }) + }) +}) diff --git a/test/unit/bootstrap_service.test/configured_database_name.test.js b/test/unit/bootstrap_service.test/configured_database_name.test.js new file mode 100644 index 00000000..365b32d5 --- /dev/null +++ b/test/unit/bootstrap_service.test/configured_database_name.test.js @@ -0,0 +1,170 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const { + sinon, + expect, + PassThrough, + XChainService, + COIN, + NETWORK, + FAKE_DB_CONTAINER, + makeSpawnProc, + drainPassThrough, + makeStubs, + stubVerifiedInner, + loadBootstrapService +} = require('./helpers/support') + +// The support stub derives every module's default name as this one string. +const DEFAULT_NAME = 'xchain_btc_mainnet_decoder' +const VOLUMES = { + DECODER_BOOTSTRAP_VOLUME: '/data/bitcoin/mainnet/xchain-decoder/bootstrap/', + INDEXER_BOOTSTRAP_VOLUME: '/data/bitcoin/mainnet/xchain-indexer/bootstrap/' +} + +let savedRequireSigned + +function saveRequireSignedBootstrapSetting() { + savedRequireSigned = process.env.XCHAIN_NODE_REQUIRE_SIGNED_BOOTSTRAP + process.env.XCHAIN_NODE_REQUIRE_SIGNED_BOOTSTRAP = '0' +} + +function restoreRequireSignedBootstrapSetting() { + if (savedRequireSigned === undefined) delete process.env.XCHAIN_NODE_REQUIRE_SIGNED_BOOTSTRAP + else process.env.XCHAIN_NODE_REQUIRE_SIGNED_BOOTSTRAP = savedRequireSigned +} + +function stubsWithConfig(overrides) { + const stubs = makeStubs() + stubs.configService.getDefaultConfig.resolves({ ...VOLUMES, ...overrides }) + return stubs +} + +// Every argv element the module handed to execFile and spawn, flattened to strings. +function issuedArgs(stubs) { + const calls = [...stubs.execFile.getCalls(), ...(stubs.spawn ? stubs.spawn.getCalls() : [])] + return calls.flatMap(c => [c.args[0], ...(Array.isArray(c.args[1]) ? c.args[1] : [])]).map(String) +} + +async function restoreWithConfig(module, overrides) { + const stubs = stubsWithConfig(overrides) + const archivePath = `${module === XChainService.XCHAIN_DECODER ? VOLUMES.DECODER_BOOTSTRAP_VOLUME : VOLUMES.INDEXER_BOOTSTRAP_VOLUME}dump.sql.gz` + stubVerifiedInner(stubs, { archivePath, innerName: 'dump.sql.gz', checksumName: 'dump.sha256', initiallyPresent: true }) + stubs.databaseService.getDatabaseContainerId.resolves(FAKE_DB_CONTAINER) + stubs.db.getModuleContainer.resolves('svc-container-id') + stubs.fs.promises.stat.resolves({ size: 1024 }) + const mysqlProc = makeSpawnProc() + stubs.spawn = sinon.stub().callsFake(() => { + setImmediate(() => { drainPassThrough(mysqlProc.stdin); mysqlProc.emit('close', 0) }) + return mysqlProc + }) + const bs = loadBootstrapService(stubs) + let error = null + try { await bs.restoreBootstrap(COIN, NETWORK, module, 'dump.sql.gz') } catch (err) { error = err } + return { stubs, error } +} + +async function dumpWithConfig(overrides) { + const stubs = stubsWithConfig(overrides) + stubs.databaseService.getDatabaseContainerId.resolves(FAKE_DB_CONTAINER) + stubs.execFile = sinon.stub().resolves({ stdout: '1024\n' }) + const dumpProc = makeSpawnProc() + stubs.spawn = sinon.stub().returns(dumpProc) + stubs.fs.promises.stat.resolves({ size: 512 }) + stubs.fs.createReadStream.callsFake(() => { + const s = new PassThrough() + setImmediate(() => { s.emit('data', Buffer.from('sql')); s.emit('end') }) + return s + }) + const writeStream = new PassThrough() + drainPassThrough(writeStream) + stubs.fs.createWriteStream.returns(writeStream) + const bs = loadBootstrapService(stubs) + const promise = bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_DECODER) + setImmediate(() => { dumpProc.stdout.end(); writeStream.emit('finish') }) + await promise + return stubs +} + +// Register the restore cases: each configured name is the one dropped and restored. +function registerRestoreCases() { + it('restores into the configured decoder database, never the derived default', async function () { + const { stubs, error } = await restoreWithConfig(XChainService.XCHAIN_DECODER, { DECODER_DB_NAME: 'CustomDecoder' }) + expect(error).to.equal(null) + const args = issuedArgs(stubs) + expect(args.some(a => a.includes('DROP DATABASE IF EXISTS CustomDecoder'))).to.equal(true) + expect(args).to.include('CustomDecoder') + expect(args.some(a => a.includes(DEFAULT_NAME))).to.equal(false) + }) + + it('restores into the configured indexer database', async function () { + const { stubs, error } = await restoreWithConfig(XChainService.XCHAIN_INDEXER, + { DECODER_DB_NAME: 'CustomDecoder', INDEXER_DB_NAME: 'CustomIndexer' }) + expect(error).to.equal(null) + const args = issuedArgs(stubs) + expect(args.some(a => a.includes('DROP DATABASE IF EXISTS CustomIndexer'))).to.equal(true) + expect(args.some(a => a.includes('CustomDecoder') || a.includes(DEFAULT_NAME))).to.equal(false) + }) + + it('refuses an unsafe configured name before stopping the service or dropping anything', async function () { + const { stubs, error } = await restoreWithConfig(XChainService.XCHAIN_DECODER, + { DECODER_DB_NAME: 'Custom; DROP DATABASE mysql' }) + expect(error && error.message).to.match(/Unsafe MariaDB database name/) + expect(stubs.dockerService.stopContainer.called).to.equal(false) + expect(issuedArgs(stubs).some(a => a.includes('DROP DATABASE'))).to.equal(false) + }) +} + +// Register the freshness and dump cases: both read the configured name or refuse it. +function registerFreshnessAndDumpCases() { + it('samples freshness from the configured database', async function () { + const stubs = stubsWithConfig({ INDEXER_DB_NAME: 'CustomIndexer' }) + stubs.execFile = sinon.stub().resolves({ stdout: '1\n' }) + const bs = loadBootstrapService(stubs) + const result = await bs.mariaDbModuleFreshness(COIN, NETWORK, XChainService.XCHAIN_INDEXER) + expect(result).to.equal('populated') + const args = issuedArgs(stubs) + expect(args.some(a => a.includes('CustomIndexer'))).to.equal(true) + expect(args.some(a => a.includes(DEFAULT_NAME))).to.equal(false) + }) + + it('samples freshness from the derived default when the configured name is blank', async function () { + const stubs = stubsWithConfig({ INDEXER_DB_NAME: ' ' }) + stubs.execFile = sinon.stub().resolves({ stdout: '1\n' }) + const bs = loadBootstrapService(stubs) + await bs.mariaDbModuleFreshness(COIN, NETWORK, XChainService.XCHAIN_INDEXER) + expect(issuedArgs(stubs).some(a => a.includes(DEFAULT_NAME))).to.equal(true) + }) + + it('reports freshness unknown, probing nothing, when the configured name is unsafe', async function () { + const stubs = stubsWithConfig({ DECODER_DB_NAME: 'Custom; DROP DATABASE mysql' }) + stubs.execFile = sinon.stub().resolves({ stdout: '0\n' }) + const bs = loadBootstrapService(stubs) + const result = await bs.mariaDbModuleFreshness(COIN, NETWORK, XChainService.XCHAIN_DECODER) + expect(result).to.equal('unknown') + expect(stubs.execFile.called).to.equal(false) + }) + + it('dumps the configured database when publishing a bootstrap', async function () { + const stubs = await dumpWithConfig({ DECODER_DB_NAME: 'CustomDecoder' }) + const [, dumpArgs] = stubs.spawn.firstCall.args + expect(dumpArgs).to.include('mariadb-dump') + expect(dumpArgs[dumpArgs.length - 1]).to.equal('CustomDecoder') + expect(issuedArgs(stubs).some(a => a.includes(DEFAULT_NAME))).to.equal(false) + }) +} + +describe('BootstrapService', function () { + beforeEach(saveRequireSignedBootstrapSetting) + afterEach(restoreRequireSignedBootstrapSetting) + + describe('operator-overridden database names', function () { + registerRestoreCases() + registerFreshnessAndDumpCases() + }) +}) diff --git a/test/unit/bootstrap_service.test/helpers/support.js b/test/unit/bootstrap_service.test/helpers/support.js index 74ba3348..c8121c2d 100644 --- a/test/unit/bootstrap_service.test/helpers/support.js +++ b/test/unit/bootstrap_service.test/helpers/support.js @@ -113,6 +113,7 @@ function makeServiceStubs() { getModuleContainer: sinon.stub().resolves(FAKE_CONTAINER_ID), isReady: sinon.stub().returns(true) } + dbStub.getModuleContainerStrict = sinon.stub().callsFake((...args) => dbStub.getModuleContainer(...args)) const axiosStub = sinon.stub() diff --git a/test/unit/bootstrap_service.test/maria_db_restore_failure_after_drop.test.js b/test/unit/bootstrap_service.test/maria_db_restore_failure_after_drop.test.js new file mode 100644 index 00000000..d4ef22dc --- /dev/null +++ b/test/unit/bootstrap_service.test/maria_db_restore_failure_after_drop.test.js @@ -0,0 +1,131 @@ +'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 + +// A MariaDB restore that fails once the DROP has run leaves the service's +// database gone or partly imported. The service stays stopped, the error is +// tagged post-wipe, and the install summary says so instead of "syncing from +// block 0". A failure before the DROP still restarts the service. + +const { + sinon, + expect, + PassThrough, + XChainService, + COIN, + NETWORK, + FAKE_DB_CONTAINER, + makeSpawnProc, + drainPassThrough, + makeStubs, + stubVerifiedInner, + loadBootstrapService +} = require('./helpers/support') + +const DECODER = XChainService.XCHAIN_DECODER +const ARCHIVE_PATH = '/data/bitcoin/mainnet/xchain-decoder/bootstrap/dump.sql.gz' +let savedRequireSigned + +function saveRequireSignedBootstrapSetting() { + savedRequireSigned = process.env.XCHAIN_NODE_REQUIRE_SIGNED_BOOTSTRAP + process.env.XCHAIN_NODE_REQUIRE_SIGNED_BOOTSTRAP = '0' +} + +function restoreRequireSignedBootstrapSetting() { + if (savedRequireSigned === undefined) delete process.env.XCHAIN_NODE_REQUIRE_SIGNED_BOOTSTRAP + else process.env.XCHAIN_NODE_REQUIRE_SIGNED_BOOTSTRAP = savedRequireSigned +} + +function failingImport(stubs, code) { + const mysqlProc = makeSpawnProc() + stubs.spawn = sinon.stub().callsFake(() => { + setImmediate(() => { drainPassThrough(mysqlProc.stdin); mysqlProc.emit('close', code) }) + return mysqlProc + }) +} + +function captureLogs() { + const lines = [] + const stub = sinon.stub(console, 'log').callsFake((...a) => lines.push(a.join(' '))) + return { lines, restore: () => stub.restore() } +} + +async function rejection(promise) { + try { await promise } catch (err) { return err } + throw new Error('expected a rejection') +} + +describe('BootstrapService', function () { + beforeEach(saveRequireSignedBootstrapSetting) + afterEach(restoreRequireSignedBootstrapSetting) + + describe('restoreBootstrapMariaDb(): a failure after the DROP', function () { + it('leaves the service stopped when the DROP/CREATE call itself fails, and never imports', async function () { + const stubs = makeStubs() + stubVerifiedInner(stubs, { archivePath: ARCHIVE_PATH, innerName: 'dump.sql.gz', checksumName: 'dump.sha256', initiallyPresent: true }) + stubs.execFile.withArgs('docker', sinon.match((args) => args.join(' ').includes('DROP DATABASE'))) + .rejects(new Error('ERROR 2013: Lost connection to server during query')) + stubs.db.getModuleContainer.resolves('svc-container-id') + failingImport(stubs, 0) + + const bs = loadBootstrapService(stubs) + const err = await rejection(bs.restoreBootstrap(COIN, NETWORK, DECODER, 'dump.sql.gz')) + expect(err.message).to.include('Lost connection') + expect(err.postWipe, 'the DROP may have run before the call failed').to.be.true + expect(stubs.dockerService.stopContainer.called).to.be.true + expect(stubs.dockerService.startContainer.called).to.be.false + expect(stubs.spawn.called).to.be.false + }) + + it('does not tag a failure before the DROP, which leaves the database as it was', async function () { + const stubs = makeStubs() + stubVerifiedInner(stubs, { archivePath: ARCHIVE_PATH, innerName: 'dump.sql.gz', checksumName: 'dump.sha256', initiallyPresent: true }) + stubs.databaseService.getDatabaseContainerId.resolves(null) + const bs = loadBootstrapService(stubs) + const err = await rejection(bs.restoreBootstrap(COIN, NETWORK, DECODER, 'dump.sql.gz')) + expect(err.message).to.include('MariaDB container not found') + expect(err.postWipe).to.equal(undefined) + const drop = stubs.execFile.getCalls().find((c) => Array.isArray(c.args[1]) && c.args[1].join(' ').includes('DROP DATABASE')) + expect(drop, 'nothing was dropped').to.not.exist + }) + }) +}) + +describe('BootstrapService', function () { + beforeEach(saveRequireSignedBootstrapSetting) + afterEach(restoreRequireSignedBootstrapSetting) + + describe('ensureBootstrapMariaDb(): a failure after the DROP', function () { + it('records the service as wiped and left down, not as syncing from block 0', async function () { + const stubs = makeStubs() + stubs.fs.existsSync.callsFake((p) => !/\.(pem|sig)$/.test(String(p))) + const dataStream = new PassThrough() + const writeStream = new PassThrough() + drainPassThrough(writeStream) + stubs.fs.createWriteStream.returns(writeStream) + stubs.axios.resolves({ status: 200, headers: {}, data: dataStream }) + stubVerifiedInner(stubs, { innerName: 'dump.sql.gz', checksumName: 'dump.sha256', manageExistsSync: false }) + stubs.databaseService.getDatabaseContainerId.resolves(FAKE_DB_CONTAINER) + stubs.db.getModuleContainer.resolves('svc-cid') + stubs.fs.promises.stat.resolves({ size: 512 }) + failingImport(stubs, 2) + const bs = loadBootstrapService(stubs) + bs.resetBootstrapOutcomes() + + const promise = bs.ensureBootstrapMariaDb(COIN, NETWORK, DECODER) + setImmediate(() => { dataStream.end(); writeStream.emit('finish') }) + expect(await promise).to.be.false + expect(stubs.dockerService.startContainer.called).to.be.false + + const logs = captureLogs() + try { bs.reportBootstrapOutcomes() } finally { logs.restore() } + const summary = logs.lines.join('\n') + expect(summary).to.match(/xchain-decoder: NOT restored, DATA WIPED, container left stopped: mariadb restore exited with code 2/) + expect(summary).to.match(/drop the database/) + expect(summary).to.not.match(/syncing from block 0/) + }) + }) +}) diff --git a/test/unit/bootstrap_service.test/restore_bootstrap_maria_db_happy_path.test.js b/test/unit/bootstrap_service.test/restore_bootstrap_maria_db_happy_path.test.js index a84defe1..84cf4e42 100644 --- a/test/unit/bootstrap_service.test/restore_bootstrap_maria_db_happy_path.test.js +++ b/test/unit/bootstrap_service.test/restore_bootstrap_maria_db_happy_path.test.js @@ -130,9 +130,12 @@ describe('BootstrapService', function () { expect.fail() } catch (err) { expect(err.message).to.include('mariadb restore exited with code 2') + // The database was already dropped and is only partly imported, so + // the error is tagged and the service is NOT restarted over it. + expect(err.postWipe).to.be.true } - // service container should still be restarted - expect(stubs.dockerService.startContainer.calledWith('svc-container-id')).to.be.true + expect(stubs.dockerService.stopContainer.called).to.be.true + expect(stubs.dockerService.startContainer.called).to.be.false }) }) }) diff --git a/test/unit/bootstrap_service.test/restore_checks_archive_identity_and_refusal_signature.test.js b/test/unit/bootstrap_service.test/restore_checks_archive_identity_and_refusal_signature.test.js index 317002d5..8e355a80 100644 --- a/test/unit/bootstrap_service.test/restore_checks_archive_identity_and_refusal_signature.test.js +++ b/test/unit/bootstrap_service.test/restore_checks_archive_identity_and_refusal_signature.test.js @@ -258,3 +258,54 @@ describe('BootstrapService', function () { }) }) }) + +describe('BootstrapService', function () { + afterEach(restoreRequireSignedBootstrapSetting) + describe('the node tip guard refusal is announced only after the archive is verified', function () { + + // Refuse on the node tip, run the ensure path, and capture what it printed while it ran. + async function refusedRunOutput(ensure) { + const stubs = makeStubs() + const finishDownload = stubDownloadedArchive(stubs) + stubs.nodeTipGuard.assessNodeTipForRestore.resolves({ verdict: 'behind-refuse', refuse: true, detail: 'the coin node is 5 blocks below the archive' }) + const bs = loadBootstrapService(stubs) + bs.resetBootstrapOutcomes() + const lines = [] + const log = sinon.stub(console, 'log').callsFake((...a) => lines.push(a.join(' '))) + let result + try { + const promise = ensure(bs) + setImmediate(finishDownload) + result = await promise + } finally { log.restore() } + return { result, output: lines.join('\n') } + } + + it('prints no REFUSING line for an unsigned archive on either restore path', async function () { + savedRequireSigned = process.env.XCHAIN_NODE_REQUIRE_SIGNED_BOOTSTRAP + delete process.env.XCHAIN_NODE_REQUIRE_SIGNED_BOOTSTRAP + for (const ensure of [bs => bs.ensureBootstrapMariaDb(COIN, NETWORK, XChainService.XCHAIN_DECODER), bs => bs.ensureBootstrapUtxoTracker(COIN, NETWORK)]) { + const { result, output } = await refusedRunOutput(ensure) + expect(result).to.equal(false) + expect(output).to.not.match(/REFUSING the/) + expect(output).to.match(/bootstrap auto-restore failed/) + } + }) + + it('prints the REFUSING line once the archive verifies, before it is retired, on either restore path', async function () { + allowUnsignedBootstrap() + const cases = [ + [XChainService.XCHAIN_DECODER, bs => bs.ensureBootstrapMariaDb(COIN, NETWORK, XChainService.XCHAIN_DECODER)], + [XChainService.XCHAIN_UTXO_TRACKER, bs => bs.ensureBootstrapUtxoTracker(COIN, NETWORK)] + ] + for (const [module, ensure] of cases) { + const { result, output } = await refusedRunOutput(ensure) + expect(result).to.equal(false) + const refused = output.indexOf(`REFUSING the ${module} bootstrap restore: the coin node is 5 blocks below the archive.`) + expect(refused, output).to.be.at.least(0) + const removed = output.indexOf('Bootstrap archive removed') + expect(removed, output).to.be.above(refused) + } + }) + }) +}) diff --git a/test/unit/bootstrap_service.test/restore_stops_service_when_lookup_fails.test.js b/test/unit/bootstrap_service.test/restore_stops_service_when_lookup_fails.test.js new file mode 100644 index 00000000..e6588fc5 --- /dev/null +++ b/test/unit/bootstrap_service.test/restore_stops_service_when_lookup_fails.test.js @@ -0,0 +1,87 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const { + sinon, + expect, + XChainService, + COIN, + NETWORK, + FAKE_DB_CONTAINER, + makeStubs, + stubVerifiedInner, + loadBootstrapService +} = require('./helpers/support') + +const ARCHIVE_PATH = '/data/bitcoin/mainnet/xchain-decoder/bootstrap/dump.sql.gz' + +function makeRestoreStubs() { + const stubs = makeStubs() + process.env.XCHAIN_NODE_REQUIRE_SIGNED_BOOTSTRAP = '0' + stubVerifiedInner(stubs, { archivePath: ARCHIVE_PATH, innerName: 'dump.sql.gz', checksumName: 'dump.sha256', initiallyPresent: true }) + stubs.databaseService.getDatabaseContainerId.resolves(FAKE_DB_CONTAINER) + stubs.databaseService.askMariadbRootPassword.resolves('rootpass') + stubs.databaseService.ensureDatabasePool.resolves() + stubs.fs.promises.stat.resolves({ size: 2048 }) + stubs.spawn = sinon.stub() + return stubs +} + +describe('BootstrapService', function () { + let savedRequireSigned + + beforeEach(function () { savedRequireSigned = process.env.XCHAIN_NODE_REQUIRE_SIGNED_BOOTSTRAP }) + afterEach(function () { + if (savedRequireSigned === undefined) delete process.env.XCHAIN_NODE_REQUIRE_SIGNED_BOOTSTRAP + else process.env.XCHAIN_NODE_REQUIRE_SIGNED_BOOTSTRAP = savedRequireSigned + }) + + describe('restoreBootstrapMariaDb(): registry lookup', function () { + it('aborts before touching the database when the registry lookup fails', async function () { + const stubs = makeRestoreStubs() + stubs.db.getModuleContainerStrict = sinon.stub().rejects(new Error('registry unavailable')) + + const bs = loadBootstrapService(stubs) + let error = null + try { + await bs.restoreBootstrap(COIN, NETWORK, XChainService.XCHAIN_DECODER, 'dump.sql.gz') + } catch (err) { error = err } + + expect(error, 'restore must reject').to.be.an('error') + expect(error.message).to.include('registry unavailable') + expect(stubs.dockerService.stopContainer.called).to.be.false + expect(stubs.spawn.called).to.be.false + }) + + it('restores without stopping anything when the registry confirms no service row', async function () { + const stubs = makeRestoreStubs() + stubs.db.getModuleContainerStrict = sinon.stub().resolves(null) + stubs.spawn.callsFake(() => { throw new Error('reached restore') }) + + const bs = loadBootstrapService(stubs) + let error = null + try { + await bs.restoreBootstrap(COIN, NETWORK, XChainService.XCHAIN_DECODER, 'dump.sql.gz') + } catch (err) { error = err } + + expect(stubs.db.getModuleContainerStrict.calledOnce).to.be.true + expect(stubs.dockerService.stopContainer.called).to.be.false + expect(error && error.message).to.not.include('registry') + }) + + it('stops the service container found by the strict lookup', async function () { + const stubs = makeRestoreStubs() + stubs.db.getModuleContainerStrict = sinon.stub().resolves('svc-container-id') + stubs.spawn.callsFake(() => { throw new Error('reached restore') }) + + const bs = loadBootstrapService(stubs) + await bs.restoreBootstrap(COIN, NETWORK, XChainService.XCHAIN_DECODER, 'dump.sql.gz').catch(() => {}) + + expect(stubs.dockerService.stopContainer.calledWith('svc-container-id')).to.be.true + }) + }) +}) diff --git a/test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js b/test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js index 83ab6e47..bf1a3c31 100644 --- a/test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js +++ b/test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js @@ -111,6 +111,38 @@ describe('BootstrapService', function () { }) }) +describe('BootstrapService', function () { + beforeEach(saveRequireSignedBootstrapSetting) + afterEach(restoreRequireSignedBootstrapSetting) + describe('makeBootstrapUtxoTracker(): an unreadable post-restart height is never archived', function () { + // A pre-stop floor with no post-restart reading may understate the archived data, so the run refuses + // before the compress; the probe's 5 s retry delays run on fake timers. + it('refuses before the compress when the post-restart height cannot be read on the snapshot path', async function () { + const { stubs, probeLog } = trackerStubsWithProbe([100, new Error('connection refused')]) + const spawnCalls = makeAutoSpawn(stubs) + const clock = sinon.useFakeTimers({ toFake: ['setTimeout'] }) + try { + const bs = loadBootstrapService(stubs) + let err = null + const run = bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER).then(() => null, e => { err = e }) + for (let i = 0; i < 200 && err === null; i++) await clock.tickAsync(5000) + await run + + expect(err, 'an unreadable post-restart height must fail the create').to.not.equal(null) + expect(err.message).to.match(/did not report its committed height after the restart/) + expect(err.message).to.include('pre-stop reading 100') + expect(probeLog.filter(p => p.restarted)).to.have.length(60) + expect(spawnCalls, 'no compress or wrap may run').to.have.length(0) + expect(stubs.archiveMeta.writeBootstrapMeta.called).to.equal(false) + expect(stubs.dockerService.startContainer.callCount).to.equal(1) + expect(stubs.encoderMaintenance.clearEncoderMaintenance.callCount).to.equal(1) + } finally { + clock.restore() + } + }) + }) +}) + describe('BootstrapService', function () { beforeEach(saveRequireSignedBootstrapSetting) afterEach(restoreRequireSignedBootstrapSetting) @@ -157,10 +189,10 @@ describe('BootstrapService', function () { describe('tracker archive height helpers', function () { const trackerArchive = require('../../../src/services/bootstrap_service/tracker_archive') - it('chooseArchiveHeight never records below either reading and keeps the floor when the post-read fails', function () { + it('chooseArchiveHeight never records below either reading and refuses when the post-read fails', function () { loadBootstrapService(makeStubs()) expect(trackerArchive.chooseArchiveHeight(null, null)).to.equal(null) - expect(trackerArchive.chooseArchiveHeight(100, null)).to.equal(100) + expect(() => trackerArchive.chooseArchiveHeight(100, null)).to.throw(/refusing to record the pre-stop height 100/) expect(trackerArchive.chooseArchiveHeight(null, 101)).to.equal(101) expect(trackerArchive.chooseArchiveHeight(100, 101)).to.equal(101) expect(trackerArchive.chooseArchiveHeight(100, 98)).to.equal(100) diff --git a/test/unit/cli_self_update.test.js b/test/unit/cli_self_update.test.js index f5a765c6..97888015 100644 --- a/test/unit/cli_self_update.test.js +++ b/test/unit/cli_self_update.test.js @@ -111,12 +111,12 @@ describe('cli maybeSelfUpdateBeforeUpdate()', function () { expect(selfUpdate.selfUpdateAndReexec.called).to.equal(false) }) - it('serializes on the command lock and hands it to the move as the pre-spawn release', async function () { + it('serializes on the command lock and gives the move its parent lock to release', async function () { const release = sinon.stub() deps.acquireCommandLock.returns(release) await maybeSelfUpdateBeforeUpdate(['all'], deps) expect(deps.acquireCommandLock.calledOnce).to.equal(true) - expect(selfUpdate.selfUpdateAndReexec.firstCall.args[0].deps.beforeSpawn).to.equal(release) + expect(selfUpdate.selfUpdateAndReexec.firstCall.args[0].deps.commandLock).to.equal(release) expect(release.called, 'released after a no-move outcome too').to.equal(true) }) diff --git a/test/unit/coins_conformance.test.js b/test/unit/coins_conformance.test.js index 4b8b207f..05ffa3ec 100644 --- a/test/unit/coins_conformance.test.js +++ b/test/unit/coins_conformance.test.js @@ -31,6 +31,7 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); +const { siblingCheckout, skipOrFail } = require('../helpers/sibling_checkout.js'); const coins = require('../../src/coins'); const { CONSENSUS_CONFIG_PIN } = require('../../src/coins/consensus_pin.js'); @@ -38,8 +39,7 @@ const { CONSENSUS_CONFIG_PIN } = require('../../src/coins/consensus_pin.js'); const LOCAL_COINS_DIR = path.join(__dirname, '../../src/coins'); const HUB_DIR = process.env.XCHAIN_HUB_DIR || path.join(__dirname, '../../../xchain-hub'); const CANON_DIR = path.join(HUB_DIR, 'src', 'coins'); -const CANON_PRESENT = fs.existsSync(CANON_DIR); -const REQUIRE_SIBLINGS = process.env.XCHAIN_REQUIRE_SIBLINGS === '1'; +const CANON_VERDICT = siblingCheckout(__dirname, CANON_DIR); // Keep in lockstep with FILES in xchain-hub/bin/sync-coins.sh. const VENDORED_FILES = ['BTC.js', 'LTC.js', 'DOGE.js', 'index.js', 'consensus_pin.js']; @@ -64,11 +64,8 @@ describe('coin-registry conformance (vendored copy) @regression', function(){ describe('byte-identity to canonical xchain-hub/src/coins', function(){ before(function(){ - if(!CANON_PRESENT){ - if(REQUIRE_SIBLINGS) - throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but canonical xchain-hub coins dir not found at ' + CANON_DIR); - this.skip(); - } + if(!CANON_VERDICT.usable) + return skipOrFail(this, CANON_VERDICT, 'the canonical coin-registry guard'); }); VENDORED_FILES.forEach(function(f){ diff --git a/test/unit/command_lock.test.js b/test/unit/command_lock.test.js index 00159172..9638091c 100644 --- a/test/unit/command_lock.test.js +++ b/test/unit/command_lock.test.js @@ -8,7 +8,7 @@ const fs = require('fs') const os = require('os') const path = require('path') -const { acquireCommandLock, getLockFilePath, isPidAlive } = require('../../src/utils/command_lock') +const { acquireCommandLock, adoptCommandLock, getLockFilePath, isPidAlive } = require('../../src/utils/command_lock') let tmpDir @@ -105,6 +105,30 @@ describe('commandLock', () => { }) }) +describe('commandLock handoff', () => { + beforeEach(prepareCommandLock) + afterEach(cleanCommandLock) + + it('releases the parent lock before the child acquires it normally', () => { + const release = acquireCommandLock({ command: 'update (self-update)' }) + const lockFile = getLockFilePath() + adoptCommandLock(release) + assert.strictEqual(fs.existsSync(lockFile), false) + + const childRelease = acquireCommandLock({ command: 'update' }) + const holder = JSON.parse(fs.readFileSync(lockFile, 'utf8')) + assert.strictEqual(holder.pid, process.pid) + assert.strictEqual(holder.command, 'update') + release() + assert.ok(fs.existsSync(lockFile), 'the old release must not remove the child lock') + childRelease() + }) + + it('requires a command lock release function', () => { + assert.throws(() => adoptCommandLock(null), TypeError) + }) +}) + describe('commandLock', () => { beforeEach(prepareCommandLock) afterEach(cleanCommandLock) diff --git a/test/unit/config/module_names.test.js b/test/unit/config/module_names.test.js new file mode 100644 index 00000000..b53db51b --- /dev/null +++ b/test/unit/config/module_names.test.js @@ -0,0 +1,49 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const constants = require('../../../src/config/module_names') + +const NAME_KEYS = [ + 'NODE_MODULE_NAME', + 'DB_MODULE_NAME', + 'HUB_MODULE_NAME', + 'EXPLORER_MODULE_NAME', + 'SYNC_MODULE_NAME', + 'NODE_VERSION_FILE_NAME' +] +const MODULE_KEYS = NAME_KEYS.filter(key => key.endsWith('_MODULE_NAME')) + +describe('module names', function () { + it('covers every exported module and file name', function () { + const exportedNameKeys = Object.keys(constants) + .filter(key => /_(?:MODULE|FILE)_NAME$/.test(key)) + expect(exportedNameKeys).to.have.members(NAME_KEYS) + }) + + it('exports non-empty strings for every module and file name', function () { + for (const key of NAME_KEYS) { + expect(constants[key], key).to.be.a('string').and.not.be.empty + } + }) + + it('keeps every module and file name free of whitespace and path separators', function () { + for (const key of NAME_KEYS) { + expect(constants[key], key).to.not.match(/[\s\\/]/) + } + }) + + it('keeps module names pairwise distinct', function () { + const moduleNames = MODULE_KEYS.map(key => constants[key]) + expect(new Set(moduleNames).size).to.equal(moduleNames.length) + }) +}) diff --git a/test/unit/config_service.test.js b/test/unit/config_service.test.js index 062ea63e..11cf6207 100644 --- a/test/unit/config_service.test.js +++ b/test/unit/config_service.test.js @@ -334,3 +334,4 @@ require('./config_service.test/ensure_hub_api_key.test') require('./config_service.test/filter_command_parameters.test') require('./config_service.test/resolve_args.test') require('./config_service.test/hub_failover.test') +require('./config_service.test/colocated_indexer_urls.test') diff --git a/test/unit/config_service.test/colocated_indexer_urls.test.js b/test/unit/config_service.test/colocated_indexer_urls.test.js new file mode 100644 index 00000000..4f3bc6ea --- /dev/null +++ b/test/unit/config_service.test/colocated_indexer_urls.test.js @@ -0,0 +1,75 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { + sinon, expect, HUB_MODULE_NAME, XChainService, makeServiceWithConfig +} = require('./helpers.test') +const stateModule = require('../../../src/state') + +const INDEXER_ENV = ['HUB_NETWORK', 'LTC_INDEXER_URL', 'DOGE_INDEXER_URL'] + +describe('ConfigService colocated hub indexer URLs', function () { + let savedEnv + + beforeEach(function () { + savedEnv = Object.fromEntries(INDEXER_ENV.map(name => [name, process.env[name]])) + process.env.HUB_NETWORK = 'mainnet' + process.env.LTC_INDEXER_URL = 'http://127.0.1.1:3004' + process.env.DOGE_INDEXER_URL = 'http://127.0.1.1:3004' + }) + + afterEach(function () { + sinon.restore() + for (const [name, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + }) + + it('uses Docker DNS for LTC and DOGE indexers registered on the hub box', async function () { + sinon.stub(stateModule.db, 'getAllModuleContainers').resolves([ + { coin: 'litecoin', network: 'mainnet', module: XChainService.XCHAIN_INDEXER }, + { coin: 'dogecoin', network: 'mainnet', module: XChainService.XCHAIN_INDEXER } + ]) + + const config = await makeServiceWithConfig('').getDefaultConfig(HUB_MODULE_NAME, null, null) + + expect(config.LTC_INDEXER_URL) + .to.equal('http://xchain-node-litecoin-mainnet-xchain-indexer:3004') + expect(config.DOGE_INDEXER_URL) + .to.equal('http://xchain-node-dogecoin-mainnet-xchain-indexer:3004') + }) + + it('preserves configured URLs for indexers not registered on the hub box', async function () { + sinon.stub(stateModule.db, 'getAllModuleContainers').resolves([]) + + const config = await makeServiceWithConfig('').getDefaultConfig(HUB_MODULE_NAME, null, null) + + expect(config.LTC_INDEXER_URL).to.equal('http://127.0.1.1:3004') + expect(config.DOGE_INDEXER_URL).to.equal('http://127.0.1.1:3004') + }) + + it('preserves configured URLs when the deployment network is ambiguous', async function () { + delete process.env.HUB_NETWORK + sinon.stub(stateModule.db, 'getAllModuleContainers').resolves([ + { coin: 'litecoin', network: 'mainnet', module: XChainService.XCHAIN_INDEXER }, + { coin: 'dogecoin', network: 'mainnet', module: XChainService.XCHAIN_INDEXER }, + { coin: 'litecoin', network: 'testnet', module: XChainService.XCHAIN_INDEXER } + ]) + + const config = await makeServiceWithConfig('').getDefaultConfig(HUB_MODULE_NAME, null, null) + + expect(config).not.to.have.property('HUB_NETWORK') + expect(config.LTC_INDEXER_URL).to.equal('http://127.0.1.1:3004') + expect(config.DOGE_INDEXER_URL).to.equal('http://127.0.1.1:3004') + }) +}) diff --git a/test/unit/config_service.test/database_credentials.test.js b/test/unit/config_service.test/database_credentials.test.js index 2a10e2db..2809433a 100644 --- a/test/unit/config_service.test/database_credentials.test.js +++ b/test/unit/config_service.test/database_credentials.test.js @@ -22,15 +22,15 @@ const { } = require('./helpers.test') function databaseCredentials1() { - it('does NOT auto-generate DB passwords where rotation cannot apply them (native/no-container -> static default)', async function () { - // The 2026-06-26 indexer outage: a generated sidecar password the native-DB - // rotation could never apply, desyncing config from the live account. + it('generates and persists DB passwords without a DB container', async function () { const { cs, files } = makeMemoryConfigService() const config = await cs.getDefaultConfig('xchain-decoder', 'bitcoin', 'mainnet') - expect(config['DECODER_DB_PASS']).to.equal('xchain' + SEP + 'password') - expect(config['INDEXER_DB_PASS']).to.equal('xchain' + SEP + 'password') - expect(files[coinSidecar] || '').to.not.include('DECODER_DB_SECRET=') - expect(files[coinSidecar] || '').to.not.include('INDEXER_DB_SECRET=') + expect(config['DECODER_DB_PASS']).to.match(/^[0-9a-f]{48}$/) + expect(config['INDEXER_DB_PASS']).to.match(/^[0-9a-f]{48}$/) + expect(config['HUB_DB_PASS']).to.match(/^[0-9a-f]{48}$/) + expect(files[coinSidecar]).to.include('DECODER_DB_SECRET=' + config['DECODER_DB_PASS']) + expect(files[coinSidecar]).to.include('INDEXER_DB_SECRET=' + config['INDEXER_DB_PASS']) + expect(files[hubSidecar]).to.include('HUB_DB_SECRET=' + config['HUB_DB_PASS']) }) it('generates per-install DB passwords when a DB container exists (rotation can apply them) and persists them', async function () { @@ -91,13 +91,13 @@ function databaseCredentials2() { expect(files[coinSidecar] || '').to.include('NODE_USER=' + gluedUser) // relocated to the sidecar }) - it('HUB_DB_PASS falls back to the shared static default when rotation cannot apply it', async function () { + it('HUB_DB_PASS is generated, shared, and persisted without a DB container', async function () { const { cs, files } = makeMemoryConfigService() const coinCfg = await cs.getDefaultConfig('xchain-decoder', 'bitcoin', 'mainnet') const hubCfg = await cs.getDefaultConfig(HUB_MODULE_NAME, '', '') - expect(coinCfg['HUB_DB_PASS']).to.equal('xchain' + SEP + 'password') + expect(coinCfg['HUB_DB_PASS']).to.match(/^[0-9a-f]{48}$/) expect(hubCfg['HUB_DB_PASS']).to.equal(coinCfg['HUB_DB_PASS']) - expect(files[hubSidecar]).to.equal(undefined) + expect(files[hubSidecar]).to.include('HUB_DB_SECRET=' + coinCfg['HUB_DB_PASS']) }) it('HUB_DB_PASS is generated, shared, and persisted when rotation can apply it', async function () { @@ -110,17 +110,12 @@ function databaseCredentials2() { expect(files[hubSidecar]).to.include('HUB_DB_SECRET=') }) - // #2246: on the non-rotatable path INDEXER_DB_PASS is not yet in - // defaultConfig when the indexer's HUB_DB_PASS bind runs, so the old - // unconditional copy planted HUB_DB_PASS=undefined - the key then - // "existed", the shared/static fallbacks skipped it, and the container - // got HUB_DB_PASS="undefined" against an account whose password fell - // through to the static default (HubDbSync ER_ACCESS_DENIED lockout). - it('indexer HUB_DB_PASS matches the indexer account static default when rotation cannot apply (never undefined)', async function () { - const { cs } = makeMemoryConfigService() + it('indexer HUB_DB_PASS matches its generated and persisted INDEXER_DB_PASS without a DB container', async function () { + const { cs, files } = makeMemoryConfigService() const config = await cs.getDefaultConfig('xchain-indexer', 'bitcoin', 'mainnet') - expect(config['HUB_DB_PASS']).to.equal('xchain' + SEP + 'password') + expect(config['INDEXER_DB_PASS']).to.match(/^[0-9a-f]{48}$/) expect(config['HUB_DB_PASS']).to.equal(config['INDEXER_DB_PASS']) + expect(files[coinSidecar]).to.include('INDEXER_DB_SECRET=' + config['INDEXER_DB_PASS']) }) it('indexer HUB_DB_PASS matches the generated per-install INDEXER_DB_PASS when rotation can apply', async function () { diff --git a/test/unit/config_service.test/helpers.test.js b/test/unit/config_service.test/helpers.test.js index 64bed318..b0f2046e 100644 --- a/test/unit/config_service.test/helpers.test.js +++ b/test/unit/config_service.test/helpers.test.js @@ -44,7 +44,10 @@ const NO_VALIDATOR = { function makeConfigService(fsStub) { return proxyquire('../../../src/services/config_service', { 'fs': fsStub || require('fs'), - './validator_service': NO_VALIDATOR + './validator_service': NO_VALIDATOR, + './database_service': { + getDatabaseContainerId: async () => null + } }) } diff --git a/test/unit/config_service.test/hub_seed_suppresses_api_url.test.js b/test/unit/config_service.test/hub_seed_suppresses_api_url.test.js index 2bb99780..dfed1a24 100644 --- a/test/unit/config_service.test/hub_seed_suppresses_api_url.test.js +++ b/test/unit/config_service.test/hub_seed_suppresses_api_url.test.js @@ -16,13 +16,17 @@ describe('mergeDefaults hub seed selection', function () { else process.env.HUB_SEED_URLS = savedHubSeedUrls }) - it('omits the default hub API URL when the per-coin config has seed URLs', async function () { - const config = await makeServiceWithConfig('HUB_SEED_URLS=default\n') - .getDefaultConfig('xchain-indexer', 'dogecoin', 'mainnet') - - expect(config.HUB_SEED_URLS).to.equal('default') - expect(config).not.to.have.property('HUB_API_URL') - }) + for (const coin of ['litecoin', 'dogecoin']) { + for (const network of ['regtest', 'mainnet']) { + it(`omits the default hub API URL for seeded ${coin} ${network}`, async function () { + const config = await makeServiceWithConfig('HUB_SEED_URLS=default\n') + .getDefaultConfig('xchain-indexer', coin, network) + + expect(config.HUB_SEED_URLS).to.equal('default') + expect(config).not.to.have.property('HUB_API_URL') + }) + } + } it('omits the hub API URL when host defaults have seed URLs', async function () { process.env.HUB_SEED_URLS = 'default' @@ -40,6 +44,15 @@ describe('mergeDefaults hub seed selection', function () { expect(config.HUB_API_URL).to.equal(hubApiUrl) }) + it('keeps the default hub API URL when per-coin config disables host seed URLs', async function () { + process.env.HUB_SEED_URLS = 'default' + const config = await makeServiceWithConfig('HUB_SEED_URLS=\n') + .getDefaultConfig('xchain-indexer', 'dogecoin', 'mainnet') + + expect(config.HUB_SEED_URLS).to.equal('') + expect(config.HUB_API_URL).to.equal(hubApiUrl) + }) + it('keeps an explicit per-coin hub API URL when no seed URLs are set', async function () { const explicitUrl = 'http://feed.example:10002' const config = await makeServiceWithConfig('HUB_API_URL=' + explicitUrl + '\n') diff --git a/test/unit/config_service_hub_network.test.js b/test/unit/config_service_hub_network.test.js index 8d252d5a..89d9d059 100644 --- a/test/unit/config_service_hub_network.test.js +++ b/test/unit/config_service_hub_network.test.js @@ -33,6 +33,10 @@ const NO_VALIDATOR = { validatorModeReport: () => ({ mode: "standalone", dir: "/tmp/test-xchain-config/validator", missing: [] }) } +const NO_DATABASE = { + getDatabaseContainerId: async () => null +} + // The hub API-key sidecar is a real file on an operator box. Read, it would key the hub // and skip the keyless declaration one case below asserts, so this describes a host that // has never run `validator init`. @@ -43,6 +47,7 @@ const FS_WITHOUT_SIDECAR = Object.assign({}, realFs, { function makeConfigService() { return proxyquire('../../src/services/config_service', { 'fs': FS_WITHOUT_SIDECAR, + './database_service': NO_DATABASE, './validator_service': NO_VALIDATOR }) } diff --git a/test/unit/database_host_port_override.test.js b/test/unit/database_host_port_override.test.js new file mode 100644 index 00000000..3d70d1b0 --- /dev/null +++ b/test/unit/database_host_port_override.test.js @@ -0,0 +1,75 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect, VALID_CONTAINER_ID, makeStubs, loadDatabaseService } = require('./database_service.test/helpers/harness') + +function findDockerRunArgs(execFileAsync) { + const call = execFileAsync.getCalls().find(({ args }) => + args[0] === 'docker' && Array.isArray(args[1]) && args[1][0] === 'run') + return call ? call.args[1] : null +} + +describe('database host port override', function () { + let savedHostPort + + beforeEach(function () { + savedHostPort = process.env.XCHAIN_NODE_DB_HOST_PORT + }) + + afterEach(function () { + if (savedHostPort === undefined) delete process.env.XCHAIN_NODE_DB_HOST_PORT + else process.env.XCHAIN_NODE_DB_HOST_PORT = savedHostPort + }) + + it('publishes MariaDB on XCHAIN_NODE_DB_HOST_PORT and checks that port for conflicts', async function () { + process.env.XCHAIN_NODE_DB_HOST_PORT = '23306' + const stubs = makeStubs() + stubs.execFileAsync.onFirstCall().rejects(new Error('No such container')) + stubs.execFileAsync.resolves({ stdout: VALID_CONTAINER_ID + '\n' }) + const service = loadDatabaseService(stubs) + + await service.buildDatabaseModule('bitcoin', 'mainnet') + + expect(findDockerRunArgs(stubs.execFileAsync)).to.include('127.0.0.1:23306:3306') + expect(stubs.assertNoHostPortConflicts.calledWith( + ['-p', '127.0.0.1:23306:3306'], 'xchain-node-database' + )).to.be.true + }) + + it('uses the override as the fallback connection port when Docker cannot report the binding', async function () { + process.env.XCHAIN_NODE_DB_HOST_PORT = '23307' + const stubs = makeStubs() + stubs.execFileAsync.rejects(new Error('docker unavailable')) + const service = loadDatabaseService(stubs) + + expect(await service.getDatabaseHostPort()).to.equal(23307) + }) + + it('rejects an invalid override before docker run', async function () { + process.env.XCHAIN_NODE_DB_HOST_PORT = 'not-a-port' + const stubs = makeStubs() + stubs.execFileAsync.onFirstCall().rejects(new Error('No such container')) + stubs.execFileAsync.resolves({ stdout: VALID_CONTAINER_ID + '\n' }) + const service = loadDatabaseService(stubs) + + let error + try { + await service.buildDatabaseModule('bitcoin', 'mainnet') + } catch (err) { + error = err + } + + expect(error).to.be.an.instanceOf(Error) + expect(error.message).to.include('XCHAIN_NODE_DB_HOST_PORT=not-a-port') + expect(findDockerRunArgs(stubs.execFileAsync)).to.be.null + }) +}) diff --git a/test/unit/database_no_static_default_password.test.js b/test/unit/database_no_static_default_password.test.js new file mode 100644 index 00000000..3d271688 --- /dev/null +++ b/test/unit/database_no_static_default_password.test.js @@ -0,0 +1,65 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const fs = require('fs') +const path = require('path') +const { expect } = require('chai') +const { + makeMemoryConfigService, HUB_MODULE_NAME, SEP, coinSidecar, hubSidecar +} = require('./config_service.test/helpers.test') + +const staticPassword = ['xchain', 'password'].join(SEP) + +describe('database passwords', function () { + it('generates and persists unique service passwords without a database container', async function () { + const { cs, files } = makeMemoryConfigService() + const config = await cs.getDefaultConfig('xchain-decoder', 'bitcoin', 'mainnet') + + expect(config.DECODER_DB_PASS).to.match(/^[0-9a-f]{48}$/) + expect(config.INDEXER_DB_PASS).to.match(/^[0-9a-f]{48}$/) + expect(config.HUB_DB_PASS).to.match(/^[0-9a-f]{48}$/) + expect(config.DECODER_DB_PASS).to.not.equal(staticPassword) + expect(config.INDEXER_DB_PASS).to.not.equal(staticPassword) + expect(config.HUB_DB_PASS).to.not.equal(staticPassword) + expect(new Set([ + config.DECODER_DB_PASS, config.INDEXER_DB_PASS, config.HUB_DB_PASS + ]).size).to.equal(3) + expect(files[coinSidecar]).to.include('DECODER_DB_SECRET=') + expect(files[coinSidecar]).to.include('INDEXER_DB_SECRET=') + expect(files[hubSidecar]).to.include('HUB_DB_SECRET=') + }) + + it('reuses generated passwords for later service configuration', async function () { + const { cs } = makeMemoryConfigService() + const first = await cs.getDefaultConfig('xchain-decoder', 'bitcoin', 'mainnet') + const second = await cs.getDefaultConfig('xchain-decoder', 'bitcoin', 'mainnet') + const hub = await cs.getDefaultConfig(HUB_MODULE_NAME, '', '') + + expect(second.DECODER_DB_PASS).to.equal(first.DECODER_DB_PASS) + expect(second.INDEXER_DB_PASS).to.equal(first.INDEXER_DB_PASS) + expect(hub.HUB_DB_PASS).to.equal(first.HUB_DB_PASS) + }) + + it('does not share generated passwords between fresh installs', async function () { + const first = await makeMemoryConfigService().cs.getDefaultConfig('xchain-decoder', 'bitcoin', 'mainnet') + const second = await makeMemoryConfigService().cs.getDefaultConfig('xchain-decoder', 'bitcoin', 'mainnet') + + for (const key of ['DECODER_DB_PASS', 'INDEXER_DB_PASS', 'HUB_DB_PASS']) { + expect(second[key]).to.not.equal(first[key]) + } + }) + + it('contains no static database password fallback in configuration sources', function () { + for (const relativePath of [ + 'src/services/config_service/defaults.js', + 'src/services/config_service/database.js' + ]) { + const source = fs.readFileSync(path.resolve(__dirname, '../..', relativePath), 'utf8') + expect(source, relativePath).to.not.match(/["']xchain["']\s*\+\s*SEP\s*\+\s*["']password["']/) + } + }) +}) diff --git a/test/unit/database_service.test/add_user_password_to_database.test.js b/test/unit/database_service.test/add_user_password_to_database.test.js index 7f20dbc1..c421d7a1 100644 --- a/test/unit/database_service.test/add_user_password_to_database.test.js +++ b/test/unit/database_service.test/add_user_password_to_database.test.js @@ -129,6 +129,50 @@ describe('DatabaseService', function () { expect(executedCommands.find(c => c && c.includes('DrillB')), 'no wildcard grant on mainnet').to.not.exist; }); + it('grants a MAINNET indexer only its own HubMirror database by exact name', async function () { + const stubs = makeStubs() + const executedCommands = [] + stubs.spawn.callsFake(fakeSpawn((sql) => { + executedCommands.push(sql) + if (sql.startsWith('SELECT COUNT')) return { stdout: '0\n' } + if (sql.startsWith('SHOW GRANTS')) return { stdout: 'GRANT USAGE ON *.* TO user\n' } + return { stdout: '' } + })) + const ds = loadDatabaseService(stubs) + await ds.addUserPasswordToDatabase( + 'xchain-indexer', 'bitcoin', 'mainnet', + 'XChain_BTC_Mainnet_Indexer', 'xchain_indexer_bitcoin_mainnet', 'test-pass' + ) + const grants = executedCommands.filter(c => c && c.includes('HubMirror')) + expect(grants).to.deep.equal([ + "GRANT ALL PRIVILEGES ON `XChain\\_BTC\\_Mainnet\\_Indexer\\_HubMirror`.* TO 'xchain_indexer_bitcoin_mainnet'@'%';\n" + ]) + }) + }) +}) + +describe('DatabaseService', function () { + + describe('addUserPasswordToDatabase()', function () { + + it('keeps the HubMirror wildcard off a MAINNET indexer', async function () { + const stubs = makeStubs() + const executedCommands = [] + stubs.spawn.callsFake(fakeSpawn((sql) => { + executedCommands.push(sql) + if (sql.startsWith('SELECT COUNT')) return { stdout: '0\n' } + if (sql.startsWith('SHOW GRANTS')) return { stdout: 'GRANT USAGE ON *.* TO user\n' } + return { stdout: '' } + })) + const ds = loadDatabaseService(stubs) + await ds.addUserPasswordToDatabase( + 'xchain-indexer', 'bitcoin', 'mainnet', + 'XChain_BTC_Mainnet_Indexer', 'xchain_indexer_bitcoin_mainnet', 'test-pass' + ) + const wildcardGrant = executedCommands.find(c => c && c.includes('XChain\\_%\\_HubMirror')) + expect(wildcardGrant).to.not.exist + }) + it('grants SLAVE MONITOR to indexer and decoder accounts, on MAINNET too', async function () { // Unlike the DrillB grant above, this one is NOT withheld from mainnet: a // silently stalled replica does the most damage there, and the privilege is a diff --git a/test/unit/database_service.test/build_database_module.test.js b/test/unit/database_service.test/build_module/build_database_module.test.js similarity index 99% rename from test/unit/database_service.test/build_database_module.test.js rename to test/unit/database_service.test/build_module/build_database_module.test.js index 84098620..67b47e67 100644 --- a/test/unit/database_service.test/build_database_module.test.js +++ b/test/unit/database_service.test/build_module/build_database_module.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, VALID_CONTAINER_ID, fakeSpawn, mariadbAttempts, makeStubs, loadDatabaseService } = require('./helpers/harness') +const { sinon, expect, VALID_CONTAINER_ID, fakeSpawn, mariadbAttempts, makeStubs, loadDatabaseService } = require('../helpers/harness') // Locate the `docker run -d ...` argv array among the execFileAsync calls. @@ -236,7 +236,7 @@ describe('DatabaseService', function () { expect(String(runArgs[spIdx + 1]), 'the DB start period must stay DEPENDENCY_HEALTH_START_PERIOD: the hub and ' + 'explorer windows are derived from it and would silently go narrow' - ).to.equal(require('../../../src/config').DEPENDENCY_HEALTH_START_PERIOD) + ).to.equal(require('../../../../src/config').DEPENDENCY_HEALTH_START_PERIOD) }) diff --git a/test/unit/database_service.test/no_coin_network_build_database_module.test.js b/test/unit/database_service.test/build_module/no_coin_network_build_database_module.test.js similarity index 97% rename from test/unit/database_service.test/no_coin_network_build_database_module.test.js rename to test/unit/database_service.test/build_module/no_coin_network_build_database_module.test.js index 51c3b4e9..ab783e1b 100644 --- a/test/unit/database_service.test/no_coin_network_build_database_module.test.js +++ b/test/unit/database_service.test/build_module/no_coin_network_build_database_module.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, VALID_CONTAINER_ID, fakeSpawn, mariadbAttempts, makeStubs, loadDatabaseService } = require('./helpers/harness') +const { sinon, expect, VALID_CONTAINER_ID, fakeSpawn, mariadbAttempts, makeStubs, loadDatabaseService } = require('../helpers/harness') describe('DatabaseService', function () { diff --git a/test/unit/database_service.test/external_db_add_user_password.test.js b/test/unit/database_service.test/external_db/external_db_add_user_password.test.js similarity index 98% rename from test/unit/database_service.test/external_db_add_user_password.test.js rename to test/unit/database_service.test/external_db/external_db_add_user_password.test.js index ae44ca4f..886d3bee 100644 --- a/test/unit/database_service.test/external_db_add_user_password.test.js +++ b/test/unit/database_service.test/external_db/external_db_add_user_password.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, VALID_CONTAINER_ID, fakeSpawn, mariadbAttempts, makeStubs, loadDatabaseService } = require('./helpers/harness') +const { sinon, expect, VALID_CONTAINER_ID, fakeSpawn, mariadbAttempts, makeStubs, loadDatabaseService } = require('../helpers/harness') function setExtEnv() { const saved = {} diff --git a/test/unit/database_service.test/external_db_build_database_module.test.js b/test/unit/database_service.test/external_db/external_db_build_database_module.test.js similarity index 98% rename from test/unit/database_service.test/external_db_build_database_module.test.js rename to test/unit/database_service.test/external_db/external_db_build_database_module.test.js index 13234310..3a4f7f12 100644 --- a/test/unit/database_service.test/external_db_build_database_module.test.js +++ b/test/unit/database_service.test/external_db/external_db_build_database_module.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, VALID_CONTAINER_ID, fakeSpawn, mariadbAttempts, makeStubs, loadDatabaseService } = require('./helpers/harness') +const { sinon, expect, VALID_CONTAINER_ID, fakeSpawn, mariadbAttempts, makeStubs, loadDatabaseService } = require('../helpers/harness') describe('DatabaseService', function () { diff --git a/test/unit/database_service.test/external_db_ensure_xchain_node_access.test.js b/test/unit/database_service.test/external_db/external_db_ensure_xchain_node_access.test.js similarity index 98% rename from test/unit/database_service.test/external_db_ensure_xchain_node_access.test.js rename to test/unit/database_service.test/external_db/external_db_ensure_xchain_node_access.test.js index 68900469..70fbd3ab 100644 --- a/test/unit/database_service.test/external_db_ensure_xchain_node_access.test.js +++ b/test/unit/database_service.test/external_db/external_db_ensure_xchain_node_access.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, VALID_CONTAINER_ID, fakeSpawn, mariadbAttempts, makeStubs, loadDatabaseService } = require('./helpers/harness') +const { sinon, expect, VALID_CONTAINER_ID, fakeSpawn, mariadbAttempts, makeStubs, loadDatabaseService } = require('../helpers/harness') function extEnv() { return { diff --git a/test/unit/database_service.test/get_external_db_config.test.js b/test/unit/database_service.test/external_db/get_external_db_config.test.js similarity index 99% rename from test/unit/database_service.test/get_external_db_config.test.js rename to test/unit/database_service.test/external_db/get_external_db_config.test.js index 7e69404b..fb2ecec9 100644 --- a/test/unit/database_service.test/get_external_db_config.test.js +++ b/test/unit/database_service.test/external_db/get_external_db_config.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, VALID_CONTAINER_ID, fakeSpawn, mariadbAttempts, makeStubs, loadDatabaseService } = require('./helpers/harness') +const { sinon, expect, VALID_CONTAINER_ID, fakeSpawn, mariadbAttempts, makeStubs, loadDatabaseService } = require('../helpers/harness') let savedEnv = {} diff --git a/test/unit/database_service.test/helpers/harness.js b/test/unit/database_service.test/helpers/harness.js index dc4b1684..be1f2a8d 100644 --- a/test/unit/database_service.test/helpers/harness.js +++ b/test/unit/database_service.test/helpers/harness.js @@ -93,6 +93,7 @@ function makeStubs(overrides = {}) { assertReady: sinon.stub(), createDatabase: sinon.stub().resolves(), getModuleContainer: sinon.stub().resolves('db-container-id'), + getModuleContainerStrict: sinon.stub().resolves('db-container-id'), setModuleContainer: sinon.stub().resolves(true) }, getInstalledCoinsAndNetworks: sinon.stub().resolves({ bitcoin: ['mainnet'] }), diff --git a/test/unit/database_service.test/mariadb_exec/root_password_unconfirmed_absence.test.js b/test/unit/database_service.test/mariadb_exec/root_password_unconfirmed_absence.test.js new file mode 100644 index 00000000..73443142 --- /dev/null +++ b/test/unit/database_service.test/mariadb_exec/root_password_unconfirmed_absence.test.js @@ -0,0 +1,76 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect, makeStubs, loadDatabaseService } = require('../helpers/harness') + +// Run askMariadbRootPassword with the container id lookup failing (null id), docker's +// tri-state presence answer injected, and the env override and stdin TTY set as asked. +async function resolveWithPresence({ presence, presenceThrows = false, envPassword = null, tty = false }) { + const stubs = makeStubs() + stubs.getDbRootPassword.returns(null) + stubs.execFileAsync.rejects(new Error('Cannot connect to the Docker daemon')) + if (presenceThrows) stubs.probeContainerPresenceByName.rejects(new Error('docker hung')) + else stubs.probeContainerPresenceByName.resolves(presence) + const savedEnv = process.env.XCHAIN_NODE_DB_ROOT_PASSWORD + if (envPassword === null) delete process.env.XCHAIN_NODE_DB_ROOT_PASSWORD + else process.env.XCHAIN_NODE_DB_ROOT_PASSWORD = envPassword + const savedIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') + Object.defineProperty(process.stdin, 'isTTY', { value: tty, configurable: true }) + try { + const ds = loadDatabaseService(stubs) + let err = null + let result = null + try { result = await ds.askMariadbRootPassword('bitcoin', 'mainnet') } catch (e) { err = e } + return { stubs, err, result } + } finally { + if (savedIsTTY) Object.defineProperty(process.stdin, 'isTTY', savedIsTTY) + else delete process.stdin.isTTY + if (savedEnv === undefined) delete process.env.XCHAIN_NODE_DB_ROOT_PASSWORD + else process.env.XCHAIN_NODE_DB_ROOT_PASSWORD = savedEnv + } +} + +function expectRefusedWithNothingSaved({ stubs, err }, presence) { + expect(err, 'an unconfirmed absence must refuse').to.be.an('error') + expect(err.message).to.include(`Docker reported '${presence}'`) + expect(err.message).to.not.include('env-root-pass') + expect(stubs.setDbRootPassword.called).to.equal(false) + expect(stubs.saveDbRootPassword.called).to.equal(false) +} + +describe('DatabaseService', function () { + describe('askMariadbRootPassword() when the container id lookup fails', function () { + + it('refuses the env override, saving nothing, when docker answers unknown', async function () { + expectRefusedWithNothingSaved(await resolveWithPresence({ presence: 'unknown', envPassword: 'env-root-pass' }), 'unknown') + }) + + it('refuses the env override when a container exists but its id could not be resolved', async function () { + expectRefusedWithNothingSaved(await resolveWithPresence({ presence: 'exists', envPassword: 'env-root-pass' }), 'exists') + }) + + it('refuses on a TTY instead of prompting for a new password to set', async function () { + expectRefusedWithNothingSaved(await resolveWithPresence({ presence: 'unknown', tty: true }), 'unknown') + }) + + it('treats a presence probe that throws as unknown', async function () { + expectRefusedWithNothingSaved(await resolveWithPresence({ presenceThrows: true, envPassword: 'env-root-pass' }), 'unknown') + }) + + it('still takes the fresh-install path once docker confirms the container is gone', async function () { + const { stubs, err, result } = await resolveWithPresence({ presence: 'gone', envPassword: 'env-root-pass' }) + expect(err).to.equal(null) + expect(result).to.equal('env-root-pass') + expect(stubs.saveDbRootPassword.calledWith('env-root-pass')).to.equal(true) + }) + }) +}) diff --git a/test/unit/database_service.test/set_database_parameters.test.js b/test/unit/database_service.test/set_database_parameters.test.js index fb7fe6af..6965b069 100644 --- a/test/unit/database_service.test/set_database_parameters.test.js +++ b/test/unit/database_service.test/set_database_parameters.test.js @@ -83,7 +83,7 @@ describe('DatabaseService', function () { it('throws when the registry lists a coin but holds no decoder/indexer container', async function () { const stubs = makeStubs() - stubs.db.getModuleContainer.resolves(null) + stubs.db.getModuleContainerStrict.resolves(null) const ds = loadDatabaseService(stubs) try { await ds.setDatabaseParameters() @@ -160,3 +160,67 @@ describe('DatabaseService', function () { }) }) }) + +describe('DatabaseService', function () { + + describe('setDatabaseParameters()', function () { + + // A registry read that errors is not "no indexer installed": the decoder's + // account alone keeps the count above zero, so a swallowed indexer read + // would report success over an indexer MariaDB never got a password for. + it('fails when the indexer registry read errors after the decoder was provisioned', async function () { + const stubs = makeStubs() + stubs.db.getModuleContainerStrict.callsFake(async (module) => { + if (module === 'xchain-indexer') throw new Error('ER_LOCK_WAIT_TIMEOUT') + return 'db-container-id' + }) + const statements = [] + stubs.spawn.callsFake(fakeSpawn((sql) => { + statements.push(sql) + if (sql.startsWith('SELECT COUNT')) return { stdout: '1\n' } + return { stdout: '' } + })) + const ds = loadDatabaseService(stubs) + let caught = null + try { + await ds.setDatabaseParameters() + } catch (err) { + caught = err + } + expect(caught, 'a failed indexer lookup must not read as success').to.be.an('error') + expect(caught.message).to.equal('ER_LOCK_WAIT_TIMEOUT') + expect(statements.some(s => /xchain_decoder_bitcoin_mainnet/.test(s))).to.be.true + expect(statements.some(s => /xchain_indexer_bitcoin_mainnet/.test(s))).to.be.false + expect(stubs.db.getModuleContainer.called).to.be.false + }) + }) +}) + +describe('DatabaseService', function () { + + describe('setDatabaseParameters()', function () { + + // The DrillB and HubMirror patterns span every chain, and their comments scope + // them to the indexer account. The decoder account the indexer reads is a + // different user, so it must not pick them up when the indexer is provisioned. + it('keeps the indexer-only pattern grants off the decoder account on a non-mainnet network', async function () { + const stubs = makeStubs() + stubs.getInstalledCoinsAndNetworks.resolves({ bitcoin: ['testnet'] }) + const statements = [] + stubs.spawn.callsFake(fakeSpawn((sql) => { + statements.push(sql) + if (sql.startsWith('SELECT COUNT')) return { stdout: '1\n' } + return { stdout: '' } + })) + const ds = loadDatabaseService(stubs) + await ds.setDatabaseParameters() + const grants = statements.filter(s => /GRANT/.test(s)) + const patternGrants = grants.filter(s => /DrillB|HubMirror/.test(s)) + expect(patternGrants.some(s => /DrillB/.test(s) && /xchain_indexer_bitcoin_mainnet/.test(s))).to.be.true + expect(patternGrants.some(s => /HubMirror/.test(s) && /xchain_indexer_bitcoin_mainnet/.test(s))).to.be.true + expect(patternGrants.filter(s => /xchain_decoder_bitcoin_mainnet/.test(s))).to.deep.equal([]) + expect(grants.some(s => /SLAVE MONITOR/.test(s) && /xchain_decoder_bitcoin_mainnet/.test(s))).to.be.true + expect(grants.some(s => /SLAVE MONITOR/.test(s) && /xchain_indexer_bitcoin_mainnet/.test(s))).to.be.true + }) + }) +}) diff --git a/test/unit/database_service.test/clear_hub_price_ingest_watermark.test.js b/test/unit/database_service.test/watermark/clear_hub_price_ingest_watermark.test.js similarity index 99% rename from test/unit/database_service.test/clear_hub_price_ingest_watermark.test.js rename to test/unit/database_service.test/watermark/clear_hub_price_ingest_watermark.test.js index afa16cb3..ab4ebc0d 100644 --- a/test/unit/database_service.test/clear_hub_price_ingest_watermark.test.js +++ b/test/unit/database_service.test/watermark/clear_hub_price_ingest_watermark.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, VALID_CONTAINER_ID, fakeSpawn, mariadbAttempts, makeStubs, loadDatabaseService } = require('./helpers/harness') +const { sinon, expect, VALID_CONTAINER_ID, fakeSpawn, mariadbAttempts, makeStubs, loadDatabaseService } = require('../helpers/harness') // clearHubPriceIngestWatermark diff --git a/test/unit/database_service.test/pre_network_column_clear_hub_price_ingest_watermark.test.js b/test/unit/database_service.test/watermark/pre_network_column_clear_hub_price_ingest_watermark.test.js similarity index 99% rename from test/unit/database_service.test/pre_network_column_clear_hub_price_ingest_watermark.test.js rename to test/unit/database_service.test/watermark/pre_network_column_clear_hub_price_ingest_watermark.test.js index b5a8fa4a..3a9925a1 100644 --- a/test/unit/database_service.test/pre_network_column_clear_hub_price_ingest_watermark.test.js +++ b/test/unit/database_service.test/watermark/pre_network_column_clear_hub_price_ingest_watermark.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, VALID_CONTAINER_ID, fakeSpawn, mariadbAttempts, makeStubs, loadDatabaseService } = require('./helpers/harness') +const { sinon, expect, VALID_CONTAINER_ID, fakeSpawn, mariadbAttempts, makeStubs, loadDatabaseService } = require('../helpers/harness') // The rail's hub runs a schema older than the (network, source_chain) re-key, diff --git a/test/unit/db/halt_markers.test.js b/test/unit/db/halt_markers.test.js new file mode 100644 index 00000000..dc695dd2 --- /dev/null +++ b/test/unit/db/halt_markers.test.js @@ -0,0 +1,86 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const { expect } = require('chai') + +const haltMarkers = require('../../../src/db/halt_markers') + +const expectedExports = [ + 'markerTablesSql', + 'liveReorgHaltCountSql', + 'liveSyncHaltCountSql', + 'eventsWatermarkSql', + 'syncHaltWatermarkSql', + 'reorgHaltsSinceSql', + 'syncHaltsSinceSql' +] + +describe('halt marker exports and live SQL builders', () => { + it('exports exactly the supported builders', () => { + expect(Object.keys(haltMarkers)).to.have.members(expectedExports) + expect(Object.keys(haltMarkers)).to.have.lengthOf(expectedExports.length) + }) + + it('builds the marker table discovery query', () => { + const sql = haltMarkers.markerTablesSql('xchain_btc') + + expect(sql).to.include("TABLE_SCHEMA='xchain_btc'") + expect(sql).to.include("TABLE_NAME='events'") + expect(sql).to.include("TABLE_NAME='sync_halt'") + expect(sql).to.match(/;$/) + }) + + it('builds the live reorg halt query', () => { + const sql = haltMarkers.liveReorgHaltCountSql('xchain_btc') + + expect(sql).to.include('`xchain_btc`.events') + expect(sql).to.include("code='REORG_HALT'") + expect(sql).to.include("code='REORG_HALT_CLEARED'") + expect(sql).to.match(/;$/) + }) + + it('builds the live sync halt query', () => { + const sql = haltMarkers.liveSyncHaltCountSql('xchain_btc') + + expect(sql).to.include('`xchain_btc`.sync_halt') + expect(sql).to.include('cleared_at IS NULL') + expect(sql).to.match(/;$/) + }) +}) + +describe('halt marker watermark SQL builders', () => { + it('builds the events watermark query', () => { + const sql = haltMarkers.eventsWatermarkSql('xchain_btc') + + expect(sql).to.include('`xchain_btc`.events') + expect(sql).to.match(/;$/) + }) + + it('builds the sync halt watermark query', () => { + const sql = haltMarkers.syncHaltWatermarkSql('xchain_btc') + + expect(sql).to.include('`xchain_btc`.sync_halt') + expect(sql).to.match(/;$/) + }) + + it('builds the reorg halt query after a watermark', () => { + const sql = haltMarkers.reorgHaltsSinceSql('xchain_btc', 7) + + expect(sql).to.include('`xchain_btc`.events') + expect(sql).to.include("code='REORG_HALT'") + expect(sql).to.include('id > 7') + expect(sql).to.match(/;$/) + }) + + it('builds the sync halt query after a watermark', () => { + const sql = haltMarkers.syncHaltsSinceSql('xchain_btc', 7) + + expect(sql).to.include('`xchain_btc`.sync_halt') + expect(sql).to.include('id > 7') + expect(sql).to.match(/;$/) + }) +}) diff --git a/test/unit/db_credential_drift.test/02_read_container_env.test.js b/test/unit/db_credential_drift.test/02_read_container_env.test.js index ccf94006..ffd8faa4 100644 --- a/test/unit/db_credential_drift.test/02_read_container_env.test.js +++ b/test/unit/db_credential_drift.test/02_read_container_env.test.js @@ -63,5 +63,23 @@ describe('DbCredentialDrift', () => { const execFileAsync = sinon.stub().resolves({ stdout: 'not json' }) expect(await readContainerEnv('dec', { execFileAsync })).to.equal(null) }) + + // With no executor passed, the shared parser must still run docker through + // this module's own child_process, so a stub there reaches every read. + it('defaults to this module\'s executor when no deps executor is given', async () => { + const execFile = sinon.stub().callsFake((cmd, args, cb) => { + cb(null, JSON.stringify(['A=1', 'DECODER_DB_PASS=p']) + '\n') + }) + const { readContainerEnv } = proxyquire('../../../src/services/db_credential_drift', { + 'child_process': { execFile }, + '../config': configStub({ + XChainService: { XCHAIN_DECODER: DECODER, XCHAIN_INDEXER: INDEXER }, + HUB_MODULE_NAME: HUB + }), + './config_service': { getDockerContainerImageName: () => 'unused' } + }) + expect(await readContainerEnv('dec')).to.deep.equal({ A: '1', DECODER_DB_PASS: 'p' }) + expect(execFile.calledOnce).to.equal(true) + }) }) }) diff --git a/test/unit/docker_service.test/12_restart_container_error_branches.test.js b/test/unit/docker_service.test/error_branches/12_restart_container_error_branches.test.js similarity index 95% rename from test/unit/docker_service.test/12_restart_container_error_branches.test.js rename to test/unit/docker_service.test/error_branches/12_restart_container_error_branches.test.js index d89317f9..c7aa6c2e 100644 --- a/test/unit/docker_service.test/12_restart_container_error_branches.test.js +++ b/test/unit/docker_service.test/error_branches/12_restart_container_error_branches.test.js @@ -12,7 +12,7 @@ const sinon = require('sinon') const { expect } = require('chai') -const { proxyquireDockerService } = require('../../helpers/docker_service_loader') +const { proxyquireDockerService } = require('../../../helpers/docker_service_loader') // Helpers function makeStubs() { @@ -24,7 +24,7 @@ function makeStubs() { } function loadDockerService(stubs, fsStub) { - return proxyquireDockerService(require.resolve('../../../src/services/docker_service'), { + return proxyquireDockerService(require.resolve('../../../../src/services/docker_service'), { 'child_process': { execFile: stubs.execFile, spawn: stubs.spawn, diff --git a/test/unit/docker_service.test/13_exec_container_error_branch.test.js b/test/unit/docker_service.test/error_branches/13_exec_container_error_branch.test.js similarity index 92% rename from test/unit/docker_service.test/13_exec_container_error_branch.test.js rename to test/unit/docker_service.test/error_branches/13_exec_container_error_branch.test.js index 747e04bc..cd26dbf1 100644 --- a/test/unit/docker_service.test/13_exec_container_error_branch.test.js +++ b/test/unit/docker_service.test/error_branches/13_exec_container_error_branch.test.js @@ -12,7 +12,7 @@ const sinon = require('sinon') const { expect } = require('chai') -const { proxyquireDockerService } = require('../../helpers/docker_service_loader') +const { proxyquireDockerService } = require('../../../helpers/docker_service_loader') // Helpers function makeStubs() { @@ -24,7 +24,7 @@ function makeStubs() { } function loadDockerService(stubs, fsStub) { - return proxyquireDockerService(require.resolve('../../../src/services/docker_service'), { + return proxyquireDockerService(require.resolve('../../../../src/services/docker_service'), { 'child_process': { execFile: stubs.execFile, spawn: stubs.spawn, diff --git a/test/unit/docker_service.test/14_get_docker_container_file_data_error_branch.test.js b/test/unit/docker_service.test/error_branches/14_get_docker_container_file_data_error_branch.test.js similarity index 91% rename from test/unit/docker_service.test/14_get_docker_container_file_data_error_branch.test.js rename to test/unit/docker_service.test/error_branches/14_get_docker_container_file_data_error_branch.test.js index 3f644872..1749ce82 100644 --- a/test/unit/docker_service.test/14_get_docker_container_file_data_error_branch.test.js +++ b/test/unit/docker_service.test/error_branches/14_get_docker_container_file_data_error_branch.test.js @@ -12,7 +12,7 @@ const sinon = require('sinon') const { expect } = require('chai') -const { proxyquireDockerService } = require('../../helpers/docker_service_loader') +const { proxyquireDockerService } = require('../../../helpers/docker_service_loader') // Helpers function makeStubs() { @@ -24,7 +24,7 @@ function makeStubs() { } function loadDockerService(stubs, fsStub) { - return proxyquireDockerService(require.resolve('../../../src/services/docker_service'), { + return proxyquireDockerService(require.resolve('../../../../src/services/docker_service'), { 'child_process': { execFile: stubs.execFile, spawn: stubs.spawn, diff --git a/test/unit/docker_service.test/15_get_docker_container_file_cat_error_branch.test.js b/test/unit/docker_service.test/error_branches/15_get_docker_container_file_cat_error_branch.test.js similarity index 91% rename from test/unit/docker_service.test/15_get_docker_container_file_cat_error_branch.test.js rename to test/unit/docker_service.test/error_branches/15_get_docker_container_file_cat_error_branch.test.js index 9459594e..976592dd 100644 --- a/test/unit/docker_service.test/15_get_docker_container_file_cat_error_branch.test.js +++ b/test/unit/docker_service.test/error_branches/15_get_docker_container_file_cat_error_branch.test.js @@ -12,7 +12,7 @@ const sinon = require('sinon') const { expect } = require('chai') -const { proxyquireDockerService } = require('../../helpers/docker_service_loader') +const { proxyquireDockerService } = require('../../../helpers/docker_service_loader') // Helpers function makeStubs() { @@ -24,7 +24,7 @@ function makeStubs() { } function loadDockerService(stubs, fsStub) { - return proxyquireDockerService(require.resolve('../../../src/services/docker_service'), { + return proxyquireDockerService(require.resolve('../../../../src/services/docker_service'), { 'child_process': { execFile: stubs.execFile, spawn: stubs.spawn, diff --git a/test/unit/docker_service.test/16_create_docker_network_network_create_failure.test.js b/test/unit/docker_service.test/error_branches/16_create_docker_network_network_create_failure.test.js similarity index 92% rename from test/unit/docker_service.test/16_create_docker_network_network_create_failure.test.js rename to test/unit/docker_service.test/error_branches/16_create_docker_network_network_create_failure.test.js index 43d8b7d4..aceae06f 100644 --- a/test/unit/docker_service.test/16_create_docker_network_network_create_failure.test.js +++ b/test/unit/docker_service.test/error_branches/16_create_docker_network_network_create_failure.test.js @@ -12,7 +12,7 @@ const sinon = require('sinon') const { expect } = require('chai') -const { proxyquireDockerService } = require('../../helpers/docker_service_loader') +const { proxyquireDockerService } = require('../../../helpers/docker_service_loader') // Helpers function makeStubs() { @@ -24,7 +24,7 @@ function makeStubs() { } function loadDockerService(stubs, fsStub) { - return proxyquireDockerService(require.resolve('../../../src/services/docker_service'), { + return proxyquireDockerService(require.resolve('../../../../src/services/docker_service'), { 'child_process': { execFile: stubs.execFile, spawn: stubs.spawn, diff --git a/test/unit/docker_service.test/17_get_docker_network_inspect_error_branch.test.js b/test/unit/docker_service.test/error_branches/17_get_docker_network_inspect_error_branch.test.js similarity index 91% rename from test/unit/docker_service.test/17_get_docker_network_inspect_error_branch.test.js rename to test/unit/docker_service.test/error_branches/17_get_docker_network_inspect_error_branch.test.js index 04cd9525..76a3c883 100644 --- a/test/unit/docker_service.test/17_get_docker_network_inspect_error_branch.test.js +++ b/test/unit/docker_service.test/error_branches/17_get_docker_network_inspect_error_branch.test.js @@ -12,7 +12,7 @@ const sinon = require('sinon') const { expect } = require('chai') -const { proxyquireDockerService } = require('../../helpers/docker_service_loader') +const { proxyquireDockerService } = require('../../../helpers/docker_service_loader') // Helpers function makeStubs() { @@ -24,7 +24,7 @@ function makeStubs() { } function loadDockerService(stubs, fsStub) { - return proxyquireDockerService(require.resolve('../../../src/services/docker_service'), { + return proxyquireDockerService(require.resolve('../../../../src/services/docker_service'), { 'child_process': { execFile: stubs.execFile, spawn: stubs.spawn, diff --git a/test/unit/docker_service.test/18_stop_container_error_branches.test.js b/test/unit/docker_service.test/error_branches/18_stop_container_error_branches.test.js similarity index 91% rename from test/unit/docker_service.test/18_stop_container_error_branches.test.js rename to test/unit/docker_service.test/error_branches/18_stop_container_error_branches.test.js index a91426d4..3a5d31c2 100644 --- a/test/unit/docker_service.test/18_stop_container_error_branches.test.js +++ b/test/unit/docker_service.test/error_branches/18_stop_container_error_branches.test.js @@ -12,7 +12,7 @@ const sinon = require('sinon') const { expect } = require('chai') -const { proxyquireDockerService } = require('../../helpers/docker_service_loader') +const { proxyquireDockerService } = require('../../../helpers/docker_service_loader') // Helpers function makeStubs() { @@ -24,7 +24,7 @@ function makeStubs() { } function loadDockerService(stubs, fsStub) { - return proxyquireDockerService(require.resolve('../../../src/services/docker_service'), { + return proxyquireDockerService(require.resolve('../../../../src/services/docker_service'), { 'child_process': { execFile: stubs.execFile, spawn: stubs.spawn, diff --git a/test/unit/docker_service.test/09_start_docker_monitor.test.js b/test/unit/docker_service.test/monitor/09_start_docker_monitor.test.js similarity index 96% rename from test/unit/docker_service.test/09_start_docker_monitor.test.js rename to test/unit/docker_service.test/monitor/09_start_docker_monitor.test.js index 55f0b81e..c1194789 100644 --- a/test/unit/docker_service.test/09_start_docker_monitor.test.js +++ b/test/unit/docker_service.test/monitor/09_start_docker_monitor.test.js @@ -12,7 +12,7 @@ const sinon = require('sinon') const { expect } = require('chai') -const { proxyquireDockerService } = require('../../helpers/docker_service_loader') +const { proxyquireDockerService } = require('../../../helpers/docker_service_loader') // Helpers function makeStubs() { @@ -24,7 +24,7 @@ function makeStubs() { } function loadDockerService(stubs, fsStub) { - return proxyquireDockerService(require.resolve('../../../src/services/docker_service'), { + return proxyquireDockerService(require.resolve('../../../../src/services/docker_service'), { 'child_process': { execFile: stubs.execFile, spawn: stubs.spawn, diff --git a/test/unit/docker_service.test/20_start_docker_monitor_with_containers.test.js b/test/unit/docker_service.test/monitor/20_start_docker_monitor_with_containers.test.js similarity index 96% rename from test/unit/docker_service.test/20_start_docker_monitor_with_containers.test.js rename to test/unit/docker_service.test/monitor/20_start_docker_monitor_with_containers.test.js index 1cce7ed8..47260cd9 100644 --- a/test/unit/docker_service.test/20_start_docker_monitor_with_containers.test.js +++ b/test/unit/docker_service.test/monitor/20_start_docker_monitor_with_containers.test.js @@ -12,7 +12,7 @@ const sinon = require('sinon') const { expect } = require('chai') -const { proxyquireDockerService } = require('../../helpers/docker_service_loader') +const { proxyquireDockerService } = require('../../../helpers/docker_service_loader') // Helpers function makeStubs() { @@ -24,7 +24,7 @@ function makeStubs() { } function loadDockerService(stubs, fsStub) { - return proxyquireDockerService(require.resolve('../../../src/services/docker_service'), { + return proxyquireDockerService(require.resolve('../../../../src/services/docker_service'), { 'child_process': { execFile: stubs.execFile, spawn: stubs.spawn, @@ -85,7 +85,7 @@ function registerMonitorUiExit() { const fixture = makeMonitorFixture(stubs, setKeyHandler) const { mockScreen, blessedStub, childStdout, childStderr, logChild } = fixture - const ds = proxyquireDockerService(require.resolve('../../../src/services/docker_service'), { + const ds = proxyquireDockerService(require.resolve('../../../../src/services/docker_service'), { 'child_process': { execFile: stubs.execFile, spawn: stubs.spawn, @@ -152,7 +152,7 @@ function registerMonitorTruncationWarning() { const logSpy = sinon.spy(console, 'log') try { - const ds = proxyquireDockerService(require.resolve('../../../src/services/docker_service'), { + const ds = proxyquireDockerService(require.resolve('../../../../src/services/docker_service'), { 'child_process': { execFile: stubs.execFile, spawn: stubs.spawn, spawnSync: stubs.spawnSync }, 'fs': { readFileSync: sinon.stub(), mkdirSync: sinon.stub(), createWriteStream: sinon.stub() }, 'blessed': blessedStub @@ -213,7 +213,7 @@ function registerMonitorPlainBanner() { const logSpy = sinon.spy(console, 'log') try { - const ds = proxyquireDockerService(require.resolve('../../../src/services/docker_service'), { + const ds = proxyquireDockerService(require.resolve('../../../../src/services/docker_service'), { 'child_process': { execFile: stubs.execFile, spawn: stubs.spawn, spawnSync: stubs.spawnSync }, 'fs': { readFileSync: sinon.stub(), mkdirSync: sinon.stub(), createWriteStream: sinon.stub() }, 'blessed': blessedStub @@ -269,7 +269,7 @@ function registerMonitorWithoutFollow() { return logChild }) - const ds = proxyquireDockerService(require.resolve('../../../src/services/docker_service'), { + const ds = proxyquireDockerService(require.resolve('../../../../src/services/docker_service'), { 'child_process': { execFile: stubs.execFile, spawn: stubs.spawn, spawnSync: stubs.spawnSync }, 'fs': { readFileSync: sinon.stub(), mkdirSync: sinon.stub(), createWriteStream: sinon.stub() }, 'blessed': blessedStub diff --git a/test/unit/github_downloader.test/13_hash_gate_host_arch.test.js b/test/unit/github_downloader.test/13_hash_gate_host_arch.test.js new file mode 100644 index 00000000..5a4c9492 --- /dev/null +++ b/test/unit/github_downloader.test/13_hash_gate_host_arch.test.js @@ -0,0 +1,112 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { + expect, + loadDownloader, + makeAxiosStub, + sinon +} = require('./support/helpers') + +// The newest release pins only an x86_64 hash; the older one pins both arches. +const PARTIAL_HASHES = { + 'owner/repo': { + 'v2.0.0': { x86_64: 'a'.repeat(64) }, + 'v1.0.0': { x86_64: 'b'.repeat(64), aarch64: 'c'.repeat(64) } + } +} + +const RELEASES = [ + { tag_name: 'v1.0.0', published_at: '2026-01-01T00:00:00Z', assets: [] }, + { tag_name: 'v2.0.0', published_at: '2026-06-01T00:00:00Z', assets: [] } +] + +// Builds a downloader over the given hashes file and an axios stub. +function downloaderWith(hashes, axiosStub) { + const fsStub = { + existsSync: sinon.stub().returns(true), + readFileSync: sinon.stub().callsFake((p) => { + if (p.endsWith('hashes.json')) return JSON.stringify(hashes) + return Buffer.from('data') + }), + statSync: sinon.stub().returns({ isFile: () => true, isDirectory: () => false }) + } + const { GitHubDownloader } = loadDownloader({ fs: fsStub, axios: axiosStub }) + return new GitHubDownloader('/test/hashes.json') +} + +function hostArchSuite(title, tests) { + describe('GitHubDownloader', function () { + describe(title, function () { + const originalArch = process.arch + afterEach(function () { + Object.defineProperty(process, 'arch', { value: originalArch, configurable: true }) + }) + tests() + }) + }) +} + +// Pretends the tests run on the named Node arch (x64 or arm64). +function onArch(nodeArch) { + Object.defineProperty(process, 'arch', { value: nodeArch, configurable: true }) +} + +hostArchSuite('getLatestCompatibleVersion(): host-arch hash gate', function () { + it('skips a newer release with no hash for an aarch64 host', async function () { + onArch('arm64') + const axiosStub = makeAxiosStub() + axiosStub.get.resolves({ data: RELEASES.map(r => ({ ...r })) }) + const release = await downloaderWith(PARTIAL_HASHES, axiosStub).getLatestCompatibleVersion('owner', 'repo', true) + expect(release.tag_name).to.equal('v1.0.0') + }) + + it('still picks the newest release on an x86_64 host', async function () { + onArch('x64') + const axiosStub = makeAxiosStub() + axiosStub.get.resolves({ data: RELEASES.map(r => ({ ...r })) }) + const release = await downloaderWith(PARTIAL_HASHES, axiosStub).getLatestCompatibleVersion('owner', 'repo', true) + expect(release.tag_name).to.equal('v2.0.0') + }) + + it('throws when no release has a hash for the host arch', async function () { + onArch('arm64') + const onlyX86 = { 'owner/repo': { 'v2.0.0': { x86_64: 'a'.repeat(64) } } } + const axiosStub = makeAxiosStub() + axiosStub.get.resolves({ data: RELEASES.map(r => ({ ...r })) }) + try { + await downloaderWith(onlyX86, axiosStub).getLatestCompatibleVersion('owner', 'repo', true) + expect.fail('should have thrown') + } catch (e) { + expect(e.message).to.include('hashes file') + expect(e.message).to.include('aarch64') + } + }) +}) + +hostArchSuite('downloadRepoVersion(): host-arch hash gate', function () { + it('refuses before any asset download when the host arch has no hash', async function () { + onArch('arm64') + const axiosStub = makeAxiosStub() + axiosStub.get.resolves({ data: { tag_name: 'v2.0.0', assets: [] } }) + const dl = downloaderWith(PARTIAL_HASHES, axiosStub) + const assetDownload = sinon.stub(dl, 'downloadReleaseAsset').resolves() + try { + await dl.downloadRepoVersion('owner', 'repo', 'v2.0.0', { verifyHash: true }) + expect.fail('should have thrown') + } catch (e) { + expect(e.message).to.include('Required SHA-256 hash not found') + expect(e.message).to.include('aarch64') + } + expect(assetDownload.called).to.equal(false) + }) +}) diff --git a/test/unit/github_request_timeouts.test.js b/test/unit/github_request_timeouts.test.js new file mode 100644 index 00000000..7c702279 --- /dev/null +++ b/test/unit/github_request_timeouts.test.js @@ -0,0 +1,124 @@ +'use strict' + +// Copyright © 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. + +// Axios waits forever by default, so a GitHub request with no timeout turns a +// stalled route into a hung status or precheck instead of the degraded warning +// the advisory callers are written to print. Every GitHub call names a bound. + +const sinon = require('sinon') +const { expect } = require('chai') +const proxyquire = require('proxyquire').noCallThru() + +const { GITHUB_API_TIMEOUT_MS, GITHUB_DOWNLOAD_TIMEOUT_MS } = require('../../src/utils/github_api') + +function expectBounded(options, bound) { + expect(options, 'the request options').to.be.an('object') + expect(options.timeout, 'the request timeout').to.equal(bound) +} + +function loadManifestService(axiosGet) { + return proxyquire('../../src/services/release_manifest_service', { + 'fs': { readFileSync: sinon.stub().throws(new Error('ENOENT')) }, + 'axios': { get: axiosGet }, + './release_signature_service': { + verifyManifestForTag: sinon.stub().resolves({ verified: true, fingerprint: 'F'.repeat(40) }) + } + }) +} + +function loadDownloader(axiosStub) { + const fsStub = { + existsSync: sinon.stub().returns(true), + readFileSync: sinon.stub().returns(JSON.stringify({})), + writeFileSync: sinon.stub(), + createWriteStream: sinon.stub(), + mkdirSync: sinon.stub() + } + const hashVerification = proxyquire('../../src/services/github_downloader/hash_verification.js', { 'fs': fsStub }) + const GitHubDownloader = proxyquire('../../src/services/github_downloader', { + 'fs': fsStub, + 'axios': axiosStub, + 'child_process': { spawnSync: sinon.stub().returns({ status: 0 }) }, + './github_downloader/hash_verification.js': hashVerification + }) + return new GitHubDownloader('/test/hashes.json') +} + +describe('GitHub request timeouts', function () { + + afterEach(() => sinon.restore()) + + it('declares positive bounds, the download one no shorter than the API one', function () { + expect(GITHUB_API_TIMEOUT_MS).to.be.a('number').and.to.be.above(0) + expect(GITHUB_DOWNLOAD_TIMEOUT_MS).to.be.a('number').and.to.be.at.least(GITHUB_API_TIMEOUT_MS) + }) + + it('bounds the release manifest read', async function () { + const axiosGet = sinon.stub().resolves({ + data: { encoding: 'base64', content: Buffer.from(JSON.stringify({ platform_version: '9.9.9' })).toString('base64') } + }) + await loadManifestService(axiosGet).fetchManifestAtTag('v9.9.9') + expectBounded(axiosGet.firstCall.args[1], GITHUB_API_TIMEOUT_MS) + }) + + it('bounds the latest-release lookup', async function () { + const axiosGet = sinon.stub().resolves({ data: { tag_name: 'v9.9.9' } }) + await loadManifestService(axiosGet).resolveLatestReleaseTag() + expectBounded(axiosGet.firstCall.args[1], GITHUB_API_TIMEOUT_MS) + }) + + it('bounds every hop of a release asset fetch', async function () { + const axiosGet = sinon.stub() + axiosGet.onFirstCall().resolves({ data: { assets: [{ name: 'sig.asc', url: 'https://api.github.com/asset/1' }] } }) + axiosGet.onSecondCall().resolves({ status: 302, headers: { location: 'https://objects.example/sig.asc' } }) + axiosGet.onThirdCall().resolves({ data: Buffer.from('sig') }) + await loadManifestService(axiosGet).fetchReleaseAsset('v9.9.9', 'sig.asc') + expect(axiosGet.callCount).to.equal(3) + expectBounded(axiosGet.firstCall.args[1], GITHUB_API_TIMEOUT_MS) + expectBounded(axiosGet.secondCall.args[1], GITHUB_API_TIMEOUT_MS) + expectBounded(axiosGet.thirdCall.args[1], GITHUB_DOWNLOAD_TIMEOUT_MS) + }) + +}) + +describe('GitHub request timeouts for coin releases', function () { + + afterEach(() => sinon.restore()) + + it('bounds the coin release list and tag lookups', async function () { + const axiosStub = { get: sinon.stub().resolves({ data: [] }) } + const dl = loadDownloader(axiosStub) + await dl.getReleases('bitcoin', 'bitcoin') + await dl.getReleaseByTag('bitcoin', 'bitcoin', 'v1.0.0') + expectBounded(axiosStub.get.firstCall.args[1], GITHUB_API_TIMEOUT_MS) + expectBounded(axiosStub.get.secondCall.args[1], GITHUB_API_TIMEOUT_MS) + }) + + it('bounds the coin release asset download', async function () { + const axiosStub = sinon.stub().rejects(new Error('stop after the request is built')) + axiosStub.get = sinon.stub() + const dl = loadDownloader(axiosStub) + const release = { + tag_name: 'v1.0.0', + assets: ['x86_64', 'aarch64'].map(arch => ({ + name: 'coin-' + arch + '-linux-gnu.tar.gz', + browser_download_url: 'https://github.example/coin-' + arch + '.tar.gz' + })) + } + await dl.downloadReleaseAsset(release, '/tmp/out', 'owner/repo', 'v1.0.0', false).then( + () => expect.fail('the stubbed request rejects'), + () => {} + ) + expect(axiosStub.calledOnce).to.equal(true) + expectBounded(axiosStub.firstCall.args[0], GITHUB_DOWNLOAD_TIMEOUT_MS) + }) +}) diff --git a/test/unit/go_live_gate.test.js b/test/unit/go_live_gate.test.js index 5863c2e4..def9b899 100644 --- a/test/unit/go_live_gate.test.js +++ b/test/unit/go_live_gate.test.js @@ -174,49 +174,64 @@ describe('GoLiveGate', () => { }) }) -describe('GoLiveGate', () => { +describe('GoLiveGate findFlagDayPlaceholders', () => { beforeEach(resetGateState) afterEach(restoreGateState) - describe('findFlagDayPlaceholders', () => { - it('finds an un-armed mainnet activation anywhere under src/ but skips node_modules', () => { - const dir = makeModuleDir({ - 'src/deep/activation.js': 'const X_MAINNET_TIME = ' + FLAG_DAY_PLACEHOLDER, - 'src/node_modules/dep.js': 'const X_MAINNET_TIME = ' + FLAG_DAY_PLACEHOLDER, - 'test/outside.js': 'const X_MAINNET_TIME = ' + FLAG_DAY_PLACEHOLDER - }) - expect(findFlagDayPlaceholders(dir)).to.deep.equal([path.join('src', 'deep', 'activation.js')]) - }) - - // The gate reads activation VALUES, not substrings: the sentinel is only - // a violation where a mainnet instant or height actually lives. - describe('hasUnarmedMainnetActivation', () => { - const cases = [ - ['addChange literal mainnet time', "this.addChange('A','0.2.0',9999999999,0,0,0,0,0);", true], - ['addChange literal mainnet block', "this.addChange('A','0.2.0',0,0,0,999999999,0,0);", true], - ['addChange identifier resolved', "const A_MAINNET_TIME = 9999999999;\nthis.addChange('A','0.2.0',A_MAINNET_TIME,0,0,0,0,0);", true], - ['map mainnet key', 'const A_ACTIVATION = { mainnet: 999999999, testnet: 0 }', true], - ['map coin-scoped mainnet key', "const A = { 'BTC:mainnet': 9999999999, 'BTC:testnet': 0 }", true], - ['MAINNET constant', 'const A_MAINNET_HEIGHT = 999999999', true], - ['1798761600 is an ARMED instant', "this.addChange('CROSS_CHAIN_ROYALTY','0.2.0',1798761600,0,0,0,0,0);", false], - ['sentinel in the testnet position', "this.addChange('A','0.2.0',0,9999999999,0,0,0,0);", false], - ['sentinel in a testnet map entry', 'const A = { mainnet: 0, testnet: 9999999999 }', false], - ['sentinel as a query upper bound', 'let blockCap = blockIndex || 999999999;', false], - ['sentinel in a line comment', '// mainnet: 9999999999 until ruled\nconst A = { mainnet: 0 }', false], - ['sentinel in a block comment', '/* addChange(x, y, 9999999999) */ const A = { mainnet: 0 }', false], - ['sentinel in a string', "log('mainnet sentinel 9999999999 seen')", false], - ['an armed real instant', 'const A = { mainnet: 1788825600, testnet: 0 }', false], - ['an inert null', 'const A = { mainnet: null, testnet: 0 }', false] - ] - for (const [label, source, expected] of cases) { - it(`${expected ? 'flags' : 'passes'}: ${label}`, () => { - expect(hasUnarmedMainnetActivation(source)).to.equal(expected) - }) - } + it('finds an un-armed mainnet activation anywhere under src/ but skips node_modules', () => { + const dir = makeModuleDir({ + 'src/deep/activation.js': 'const X_MAINNET_TIME = ' + FLAG_DAY_PLACEHOLDER, + 'src/node_modules/dep.js': 'const X_MAINNET_TIME = ' + FLAG_DAY_PLACEHOLDER, + 'test/outside.js': 'const X_MAINNET_TIME = ' + FLAG_DAY_PLACEHOLDER }) + expect(findFlagDayPlaceholders(dir)).to.deep.equal([path.join('src', 'deep', 'activation.js')]) + }) - it('returns empty for a module with no src dir', () => { - expect(findFlagDayPlaceholders(makeModuleDir({ 'lib/a.js': 'x' }))).to.deep.equal([]) + const unarmedIdentifierCases = [ + ['mainnet map entry', 'const A_ACTIVATION = { mainnet: UNARMED, testnet: 0 }'], + ['MAINNET constant', 'const A_MAINNET_HEIGHT = UNARMED'] + ] + for (const [label, source] of unarmedIdentifierCases) { + it(`finds an UNARMED identifier in a ${label}`, () => { + const dir = makeModuleDir({ 'src/activation.js': source }) + expect(findFlagDayPlaceholders(dir)).to.deep.equal([path.join('src', 'activation.js')]) }) + } + + it('returns empty for a module with no src dir', () => { + expect(findFlagDayPlaceholders(makeModuleDir({ 'lib/a.js': 'x' }))).to.deep.equal([]) }) }) + +// The gate reads activation VALUES, not substrings: the sentinel is only +// a violation where a mainnet instant or height actually lives. +describe('GoLiveGate hasUnarmedMainnetActivation', () => { + const cases = [ + ['addChange literal mainnet time', "this.addChange('A','0.2.0',9999999999,0,0,0,0,0);", true], + ['addChange literal mainnet block', "this.addChange('A','0.2.0',0,0,0,999999999,0,0);", true], + ['addChange identifier resolved', "const A_MAINNET_TIME = 9999999999;\nthis.addChange('A','0.2.0',A_MAINNET_TIME,0,0,0,0,0);", true], + ['map mainnet key', 'const A_ACTIVATION = { mainnet: 999999999, testnet: 0 }', true], + ['map coin-scoped mainnet key', "const A = { 'BTC:mainnet': 9999999999, 'BTC:testnet': 0 }", true], + ['MAINNET constant', 'const A_MAINNET_HEIGHT = 999999999', true], + ['map mainnet UNARMED identifier', 'const A_ACTIVATION = { mainnet: UNARMED, testnet: 0 }', true], + ['MAINNET constant UNARMED identifier', 'const A_MAINNET_HEIGHT = UNARMED', true], + ['1798761600 is an ARMED instant', "this.addChange('CROSS_CHAIN_ROYALTY','0.2.0',1798761600,0,0,0,0,0);", false], + ['bare UNARMED addChange argument', "this.addChange('A','0.2.0',UNARMED,0,0,0,0,0);", false], + ['aliased UNARMED addChange argument', "const A_TIME = UNARMED;\nthis.addChange('A','0.2.0',A_TIME,0,0,0,0,0);", false], + ['sentinel in the testnet position', "this.addChange('A','0.2.0',0,9999999999,0,0,0,0);", false], + ['sentinel in a testnet map entry', 'const A = { mainnet: 0, testnet: 9999999999 }', false], + ['UNARMED in a testnet map entry', 'const A = { mainnet: 0, testnet: UNARMED }', false], + ['UNARMED in a non-MAINNET constant', 'const A_TESTNET_HEIGHT = UNARMED', false], + ['sentinel as a query upper bound', 'let blockCap = blockIndex || 999999999;', false], + ['sentinel in a line comment', '// mainnet: 9999999999 until ruled\nconst A = { mainnet: 0 }', false], + ['sentinel in a block comment', '/* addChange(x, y, 9999999999) */ const A = { mainnet: 0 }', false], + ['sentinel in a string', "log('mainnet sentinel 9999999999 seen')", false], + ['an armed real instant', 'const A = { mainnet: 1788825600, testnet: 0 }', false], + ['an inert null', 'const A = { mainnet: null, testnet: 0 }', false] + ] + for (const [label, source, expected] of cases) { + it(`${expected ? 'flags' : 'passes'}: ${label}`, () => { + expect(hasUnarmedMainnetActivation(source)).to.equal(expected) + }) + } +}) diff --git a/test/unit/hub_service.test.js b/test/unit/hub_service.test.js index 727c05e2..78eeedf5 100644 --- a/test/unit/hub_service.test.js +++ b/test/unit/hub_service.test.js @@ -12,41 +12,42 @@ * ********************************************************************** * HubService.updateHub: shared-container network attachment. - * - * updateHub() used to catch every addContainerToNetwork failure, discard the - * error and still return true, so a topology change published module config - * while the shared hub/sync container was never joined to the new coin - * network, leaving those endpoints unreachable until an unrelated later - * mutation happened to retry. These cases pin the replacement contract: - * one retry, then report the networks that stayed unreachable. - * - * The hub and sync loops share attachSharedContainer, so exercising the sync - * loop (hub registry row absent) covers both without dragging the heavy - * updateHubOrExplorer config push into a unit test. ********************************************************************/ const sinon = require('sinon') +const assert = require('node:assert/strict') const { expect } = require('chai') const proxyquire = require('proxyquire') const { HUB_MODULE_NAME, SYNC_MODULE_NAME } = require('../../src/config') const { updateHubOrExplorer } = require('../../src/services/hub_service/update_hub_or_explorer') -function loadHubService({ addContainerToNetwork } = {}) { +function loadHubService({ + addContainerToNetwork, + hubContainerId = null, + syncContainerId = 'sync1234sync1234', + installedCoinsAndNetworks = { bitcoin: ['mainnet'] }, + updateConfig = sinon.stub().resolves(true) +} = {}) { const getModuleContainer = sinon.stub() - getModuleContainer.withArgs(HUB_MODULE_NAME, '', '').resolves(null) - getModuleContainer.withArgs(SYNC_MODULE_NAME, '', '').resolves('sync1234sync1234') + getModuleContainer.withArgs(HUB_MODULE_NAME, '', '').resolves(hubContainerId) + getModuleContainer.withArgs(SYNC_MODULE_NAME, '', '').resolves(syncContainerId) const attach = addContainerToNetwork || sinon.stub().resolves(true) const svc = proxyquire('../../src/services/hub_service', { '../state': { db: { getModuleContainer } }, - './status_service': { getInstalledCoinsAndNetworks: async () => ({ bitcoin: ['mainnet'] }) }, + './status_service': { getInstalledCoinsAndNetworks: async () => installedCoinsAndNetworks }, + './config_service': { getDockerContainerImageName: () => 'xchain-node-hub' }, './docker_service': { addContainerToNetwork: attach }, '../utils/helpers': { sleep: async () => {} }, + './hub_service/update_hub_or_explorer.js': { + configureUpdateHubOrExplorer: sinon.stub(), + updateHubOrExplorer: updateConfig + } }) - return { svc, attach } + return { svc, attach, updateConfig } } function configDeliveryDependencies({ updateConfig = sinon.stub().resolves(true), lastStatus = {} } = {}) { @@ -76,6 +77,25 @@ function configDeliveryDependencies({ updateConfig = sinon.stub().resolves(true) describe('HubService.updateHub network attachment', function () { + it('updateHub re-resolves a stale hub container id', async function () { + const staleHubId = 'stale-hub-id' + const attach = sinon.stub() + attach.onFirstCall().rejects(new Error('No such container: ' + staleHubId)) + attach.onSecondCall().resolves(true) + const { svc, updateConfig } = loadHubService({ + addContainerToNetwork: attach, + hubContainerId: staleHubId, + syncContainerId: null, + installedCoinsAndNetworks: { bitcoin: ['regtest'] } + }) + + expect(await svc.updateHub()).to.be.true + sinon.assert.calledTwice(attach) + expect(attach.firstCall.calledWithExactly(staleHubId, 'xchain-node-bitcoin-regtest')).to.be.true + expect(attach.secondCall.calledWithExactly('xchain-node-hub', 'xchain-node-bitcoin-regtest')).to.be.true + sinon.assert.calledOnceWithExactly(updateConfig, HUB_MODULE_NAME) + }) + it('returns true when every shared-container attach succeeds', async function () { const { svc, attach } = loadHubService() expect(await svc.updateHub()).to.be.true @@ -94,15 +114,16 @@ describe('HubService.updateHub network attachment', function () { it('rejects naming the unreachable network instead of reporting success', async function () { const attach = sinon.stub().rejects(new Error('network xchain-node-bitcoin-mainnet not found')) const { svc } = loadHubService({ addContainerToNetwork: attach }) - let threw = null - try { - await svc.updateHub() - } catch (err) { - threw = err - } - expect(threw).to.be.an('error') - expect(threw.message).to.match(/xchain-sync -> bitcoin\/mainnet/) + await assert.rejects(svc.updateHub(), /xchain-sync -> bitcoin\/mainnet/) + expect(attach.callCount).to.equal(2) + }) + + it('keeps missing xchain-sync containers on the existing retry and report path', async function () { + const attach = sinon.stub().rejects(new Error('No such container: sync1234sync1234')) + const { svc } = loadHubService({ addContainerToNetwork: attach }) + await assert.rejects(svc.updateHub(), /xchain-sync -> bitcoin\/mainnet/) expect(attach.callCount).to.equal(2) + sinon.assert.alwaysCalledWithExactly(attach, 'sync1234sync1234', 'xchain-node-bitcoin-mainnet') }) }) diff --git a/test/unit/migration_precondition_service.test/02_migration_inventory.test.js b/test/unit/migration_precondition_service.test/02_migration_inventory.test.js index d6f88e59..d790b44d 100644 --- a/test/unit/migration_precondition_service.test/02_migration_inventory.test.js +++ b/test/unit/migration_precondition_service.test/02_migration_inventory.test.js @@ -75,7 +75,11 @@ function registerRealIndexerInventory({ fs, expect, listDeployPreconditionMigrat + ' but has no migrations directory at ' + INDEXER_MIGRATIONS).to.equal(true) const required = listDeployPreconditionMigrations(INDEXER_MIGRATIONS) expect(required).to.include('2026-09-12-bridge-tables.sql') - expect(required).to.not.include('2026-09-12-token-bridge-fields.sql') + // Prove the auto migration is present, so its absence from the list is not vacuous. + const autoFields = '2026-10-08-token-bridge-fields.sql' + expect(fs.readdirSync(INDEXER_MIGRATIONS), autoFields + ' missing from ' + INDEXER_MIGRATIONS) + .to.include(autoFields) + expect(required).to.not.include(autoFields) }) } diff --git a/test/unit/migration_precondition_service.test/05_from_genesis_v0200_reset.test.js b/test/unit/migration_precondition_service.test/05_from_genesis_v0200_reset.test.js new file mode 100644 index 00000000..e848e532 --- /dev/null +++ b/test/unit/migration_precondition_service.test/05_from_genesis_v0200_reset.test.js @@ -0,0 +1,79 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const { expect } = require('chai') + +const { XChainService } = require('../../../src/config') +const { assertRequiredMigrationsApplied } = require('../../../src/services/migration_precondition_service') +const { migrationDeclaresDeployPrecondition } = require('../../../src/services/migration_precondition_service/migration_scan') +const { + V0190_MIGRATIONS, PRECONDITION_MIGRATIONS, resetLedger, resetSchemaModel, + unsatisfiedSchemaModel, readResetLedger +} = require('./helpers/reset_schema_state') + +function v0190MigrationSource(name) { + const required = PRECONDITION_MIGRATIONS.has(name) ? ' deploy-precondition=required' : '' + return '-- xchain:migration mode=manual' + required + "\nSELECT '" + name + "'" +} + +function scanV0190Preconditions() { + return V0190_MIGRATIONS.filter(name => migrationDeclaresDeployPrecondition(v0190MigrationSource(name))) +} + +function runDowngradeGuard(result) { + return assertRequiredMigrationsApplied( + XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', 'v0.19.0', { + cloneGit: async () => {}, + listDeployPreconditionMigrations: scanV0190Preconditions, + readAppliedMigrations: async () => result, + runningBuildSupportsPerFileMigrations: async () => true, + pendingManualMigrations: () => [] + }) +} + +async function downgradeRefusal(result) { + try { + await runDowngradeGuard(result) + } catch (err) { + return err + } + return null +} + +describe('from-genesis v0.20.0 migration ledger', () => { + it('earns all 82 v0.19.0 rows from the reset schema and passes the real precondition scan', async () => { + expect(resetLedger()).to.have.length(67) + + const { result, ledger, queries } = await readResetLedger(resetSchemaModel()) + + expect(result.state, result.reason).to.equal('ledger') + expect([...result.applied].filter(name => V0190_MIGRATIONS.includes(name))) + .to.have.members(V0190_MIGRATIONS).and.have.length(82) + expect([...result.applied]).to.have.length(83) + expect(ledger).to.have.length(67) + expect(queries.some(sql => /^INSERT INTO /i.test(sql))).to.equal(false) + expect(scanV0190Preconditions()).to.have.members([...PRECONDITION_MIGRATIONS]) + expect(await runDowngradeGuard(result)).to.deep.equal({ + checked: true, + required: scanV0190Preconditions(), + missing: [], + ok: true + }) + }) + + it('keeps the v0.19.0 downgrade refused when every generated predicate is false', async () => { + const { result, ledger, queries } = await readResetLedger(unsatisfiedSchemaModel()) + + expect(result.state, result.reason).to.equal('ledger') + expect([...result.applied]).to.have.length(67) + expect(ledger).to.have.length(67) + expect(queries.some(sql => /^INSERT INTO /i.test(sql))).to.equal(false) + const refusal = await downgradeRefusal(result) + expect(refusal, 'the downgrade must stay refused').to.be.an('error') + expect(refusal.message).to.include('2026-09-12-bridge-tables.sql') + }) +}) diff --git a/test/unit/migration_precondition_service.test/05_from_genesis_v0200_reset_predicates.test.js b/test/unit/migration_precondition_service.test/05_from_genesis_v0200_reset_predicates.test.js new file mode 100644 index 00000000..c8352e6e --- /dev/null +++ b/test/unit/migration_precondition_service.test/05_from_genesis_v0200_reset_predicates.test.js @@ -0,0 +1,51 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const { expect } = require('chai') + +const { SYNTHESIZED_ROWS, resetSchemaModel, unsatisfiedSchemaModel, readResetLedger } = require('./helpers/reset_schema_state') + +// Facts for migrations newer than the v0.20.0 reset: evaluated, never satisfied by its schema. +const POST_RESET_FACTS = ['2026-09-22-oracle-prices-widen-tick.sql', '2026-09-30-list-share-tables.sql'] +const MIRROR_ID_REPAIR = '2026-06-10-mirror-id-autoincrement-repair.sql' +const MIRROR_ID_TABLES = ['price_snapshots', 'cross_chain_matches', 'capability_snapshots', 'state_checkpoints'] + +async function mirrorIdRepairSatisfied(updateColumns) { + const model = resetSchemaModel() + const mirrorIds = model.columns.filter(column => + column.column_name === 'id' && MIRROR_ID_TABLES.includes(column.table_name)) + model.columns = model.columns.filter(column => !mirrorIds.includes(column)) + model.columns.push(...updateColumns(mirrorIds)) + return (await readResetLedger(model)).satisfied.includes(MIRROR_ID_REPAIR) +} + +describe('from-genesis v0.20.0 reset predicates', () => { + it('evaluates all 21 generated predicates and finds the 19 v0.19.0 rows satisfied by the reset schema', async () => { + const { evaluated, satisfied } = await readResetLedger(resetSchemaModel()) + + expect(evaluated).to.have.members([...SYNTHESIZED_ROWS, ...POST_RESET_FACTS]).and.have.length(21) + expect(satisfied).to.have.members([...SYNTHESIZED_ROWS]).and.have.length(19) + }) + + it('evaluates all 21 generated predicates and finds none satisfied by an unmigrated schema', async () => { + const { evaluated, satisfied } = await readResetLedger(unsatisfiedSchemaModel()) + + expect(evaluated).to.have.members([...SYNTHESIZED_ROWS, ...POST_RESET_FACTS]).and.have.length(21) + expect(satisfied).to.deep.equal([]) + }) + + it('requires all four mirror ids to be bigint auto-increment columns while allowing either signedness', async () => { + expect(await mirrorIdRepairSatisfied(columns => columns)).to.equal(true) + expect(await mirrorIdRepairSatisfied(columns => + columns.map(column => ({ ...column, column_type: 'bigint' })))).to.equal(true) + expect(await mirrorIdRepairSatisfied(columns => columns.slice(1))).to.equal(false) + expect(await mirrorIdRepairSatisfied(columns => + columns.map((column, index) => index ? column : { ...column, data_type: 'int', column_type: 'int' }))).to.equal(false) + expect(await mirrorIdRepairSatisfied(columns => + columns.map((column, index) => index ? column : { ...column, extra: '' }))).to.equal(false) + }) +}) diff --git a/test/unit/migration_precondition_service.test/06_indexer_precondition_fact_parity.test.js b/test/unit/migration_precondition_service.test/06_indexer_precondition_fact_parity.test.js new file mode 100644 index 00000000..f377a64b --- /dev/null +++ b/test/unit/migration_precondition_service.test/06_indexer_precondition_fact_parity.test.js @@ -0,0 +1,75 @@ +'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 + +// The node counts a migration as applied when its ledger row exists OR its end +// state already holds. The indexer baselines the same files at boot from +// MIGRATION_PRECONDITIONS, so a key with no node fact makes the deploy guard +// refuse a database the indexer would accept. + +const fs = require('fs') +const path = require('path') +const { expect } = require('chai') + +const { appliedMigrationsSql } = require('../../../src/db/migrations') +const { listDeployPreconditionMigrations } = require('../../../src/services/migration_precondition_service') +const { migrationsDirOf } = require('../../../src/utils/migration_files') +const { DATABASE, resetSchemaModel, readResetLedger } = require('./helpers/reset_schema_state') + +const INDEXER_DIR = path.join(__dirname, '../../../../xchain-indexer') +const REQUIRE_SIBLINGS = process.env.XCHAIN_REQUIRE_SIBLINGS === '1' +const LIST_SHARE = '2026-09-30-list-share-tables.sql' +const ORACLE_WIDEN = '2026-09-22-oracle-prices-widen-tick.sql' + +function table(name) { + return { table_schema: DATABASE, table_name: name, engine: 'InnoDB', table_collation: 'utf8mb3_general_ci', table_type: 'BASE TABLE' } +} +function tickColumn(length) { + return { + table_schema: DATABASE, table_name: 'oracle_prices', column_name: 'tick', data_type: 'varchar', + is_nullable: 'NO', column_default: null, extra: '', character_maximum_length: length, + column_type: 'varchar(' + length + ')', ordinal_position: 2, + character_set_name: 'utf8mb3', collation_name: 'utf8mb3_general_ci', generation_expression: null + } +} +async function satisfiedWith({ tables = [], columns = [] }) { + const model = resetSchemaModel() + model.tables.push(...tables) + model.columns.push(...columns) + return (await readResetLedger(model)).satisfied +} + +describe('indexer precondition fact parity', () => { + it('has a node fact for every indexer baseline predicate and every deploy-precondition file', function () { + if (!fs.existsSync(INDEXER_DIR)) { + if (REQUIRE_SIBLINGS) throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but xchain-indexer is not checked out at ' + INDEXER_DIR) + return this.skip() // sibling repo not checked out + } + const { MIGRATION_PRECONDITIONS } = require(path.join(INDEXER_DIR, 'src/db/database/migration_tables.js')) + const required = listDeployPreconditionMigrations(migrationsDirOf(INDEXER_DIR)) + const names = new Set([...Object.keys(MIGRATION_PRECONDITIONS), ...required]) + expect(required, 'the indexer declares deploy-precondition files').to.include(LIST_SHARE) + const sql = appliedMigrationsSql('XChain_BTC_Mainnet_Indexer', 'schema_migrations') + const missing = [...names].filter(name => !sql.includes("SELECT '" + name + "' AS name WHERE ")) + expect(missing, 'indexer migrations with no node schema fact').to.deep.equal([]) + }) + + it('treats the list-share migration as applied once both tables exist', async () => { + expect(await satisfiedWith({ tables: [table('list_snapshots'), table('list_share_mirrors')] })).to.include(LIST_SHARE) + }) + + it('keeps the list-share migration missing while either table is absent', async () => { + expect(await satisfiedWith({ tables: [table('list_snapshots')] })).to.not.include(LIST_SHARE) + expect(await satisfiedWith({ tables: [table('list_share_mirrors')] })).to.not.include(LIST_SHARE) + }) + + it('treats the oracle tick widening as applied at 250 characters or wider, and not below', async () => { + expect(await satisfiedWith({ columns: [tickColumn(250)] })).to.include(ORACLE_WIDEN) + expect(await satisfiedWith({ columns: [tickColumn(300)] })).to.include(ORACLE_WIDEN) + expect(await satisfiedWith({ columns: [tickColumn(50)] })).to.not.include(ORACLE_WIDEN) + expect(await satisfiedWith({ columns: [tickColumn(249)] })).to.not.include(ORACLE_WIDEN) + }) +}) diff --git a/test/unit/migration_precondition_service.test/07_refusal_follows_running_build.test.js b/test/unit/migration_precondition_service.test/07_refusal_follows_running_build.test.js new file mode 100644 index 00000000..4badcb0a --- /dev/null +++ b/test/unit/migration_precondition_service.test/07_refusal_follows_running_build.test.js @@ -0,0 +1,108 @@ +'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 + +// The refusal's remedy runs inside the container being REPLACED, so the files it +// names come from that build's own --status rows: a precondition first shipping +// in the release being deployed is not something that build can apply, and its +// migrate CLI rejects a --file it does not carry. + +const sinon = require('sinon') +const { expect } = require('chai') + +const { XChainService } = require('../../../src/config') +const { + assertRequiredMigrationsApplied, readRunningBuildMigrationStatus +} = require('../../../src/services/migration_precondition_service') + +const NEW_FILE = '2026-09-30-list-share-tables.sql' +const OLD_FILE = '2026-09-12-bridge-tables.sql' +const STATUS_SOURCE = "const STATUS = '--status'\nmain()\n" + +function depsFor({ required, rows, supportsPerFile = true, targetPending = [] }) { + return { + cloneGit: sinon.stub().resolves(), + listDeployPreconditionMigrations: sinon.stub().returns(required), + readAppliedMigrations: sinon.stub().resolves({ state: 'ledger', applied: new Set(['2026-05-30-balances-composite-index.sql']) }), + readRunningBuildMigrationStatus: sinon.stub().resolves({ supportsPerFile, rows }), + pendingManualMigrations: sinon.stub().returns(targetPending) + } +} +async function refusal(deps) { + try { + await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', 'master', deps) + } catch (err) { + return err.message + } + throw new Error('the deploy must be refused') +} + +describe('migration precondition refusal: the running build decides the remedy', () => { + beforeEach(() => { sinon.stub(console, 'warn') }) + afterEach(() => { sinon.restore() }) + + it('prints no --file for a missing file the running build does not carry, and names the hand-apply route', async () => { + const message = await refusal(depsFor({ + required: [NEW_FILE], + rows: [{ file: '2026-05-30-balances-composite-index.sql', applied: true, mode: 'manual' }] + })) + expect(message).to.not.contain('--file ' + NEW_FILE) + expect(message).to.contain('does not carry ' + NEW_FILE) + expect(message).to.match(/from the release being deployed by hand/) + expect(message).to.contain('XCHAIN_NODE_SKIP_MIGRATION_PRECONDITION=1') + }) + + it('prints --file only for the missing file the running build carries', async () => { + const message = await refusal(depsFor({ + required: [OLD_FILE, NEW_FILE], + rows: [{ file: OLD_FILE, applied: false, mode: 'manual' }] + })) + expect(message).to.contain('--file ' + OLD_FILE) + expect(message).to.not.contain('--file ' + NEW_FILE) + expect(message).to.contain('does not carry ' + NEW_FILE) + }) + + it('labels a blast radius it could only read from the source being deployed', async () => { + const message = await refusal(depsFor({ + required: [OLD_FILE], rows: null, supportsPerFile: null, targetPending: [OLD_FILE] + })) + expect(message).to.contain('DO NOT run') + expect(message).to.contain('listed from the source being deployed') + expect(message).to.contain(OLD_FILE + ' (the one you need)') + }) +}) + +describe('readRunningBuildMigrationStatus()', () => { + it("keeps a valid status response's rows, so the refusal can read that build's file list", async () => { + const status = { + database: 'XChain_BTC_Mainnet_Indexer', total: 2, applied: 1, pending: 1, + migrations: [ + { file: 'a.sql', applied: true, mode: 'auto', appliedAt: '2026-09-24T00:00:00.000Z' }, + { file: 'b.sql', applied: false, mode: 'manual', appliedAt: null } + ] + } + const result = await readRunningBuildMigrationStatus('indexer', { + getDockerContainerFileCat: sinon.stub().callsFake(async (c, p) => { + if (p === 'src/db/migration/migrate.js') return STATUS_SOURCE + throw new Error('missing') + }), + execContainer: sinon.stub().resolves(JSON.stringify(status)) + }) + expect(result.supportsPerFile).to.equal(true) + expect(result.rows).to.deep.equal([ + { file: 'a.sql', applied: true, mode: 'auto' }, + { file: 'b.sql', applied: false, mode: 'manual' } + ]) + }) + + it('keeps no rows from a response that fails the contract', async () => { + const result = await readRunningBuildMigrationStatus('indexer', { + getDockerContainerFileCat: sinon.stub().resolves(STATUS_SOURCE), + execContainer: sinon.stub().resolves('{"database":"x","migrations":[{"file":"a.sql"}]}') + }) + expect(result).to.deep.equal({ supportsPerFile: false, rows: null }) + }) +}) diff --git a/test/unit/migration_precondition_service.test/08_ledger_rename_rekey.test.js b/test/unit/migration_precondition_service.test/08_ledger_rename_rekey.test.js new file mode 100644 index 00000000..c2338044 --- /dev/null +++ b/test/unit/migration_precondition_service.test/08_ledger_rename_rekey.test.js @@ -0,0 +1,200 @@ +'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 + +// The deploy guard re-keys the ledger with the target tree's own rename map, the +// way the indexer runner does at boot, and re-keys nothing when that map cannot +// be read exactly. + +const fs = require('fs') +const os = require('os') +const path = require('path') +const sinon = require('sinon') +const { expect } = require('chai') + +const { XChainService } = require('../../../src/config') +const { assertRequiredMigrationsApplied } = require('../../../src/services/migration_precondition_service') +const { readLedgerRenames, rekeyApplied } = require('../../../src/services/migration_precondition_service/migration_scan') +const { migrationsDirOf } = require('../../../src/utils/migration_files') + +const INDEXER_DIR = path.join(__dirname, '../../../../xchain-indexer') +const REQUIRE_SIBLINGS = process.env.XCHAIN_REQUIRE_SIBLINGS === '1' +const OLD = '2026-09-09-gated.sql' +const NEW = '2026-09-13-gated.sql' + +const REGISTRY = [ + "const Database = require('../index.js');", + '', + 'const MIGRATION_LEDGER_RENAMES = {', + " 'add_balances_composite_index.sql': '2026-05-30-balances-composite-index.sql',", + ' // a full-line comment inside the literal is skipped', + '', + " '" + OLD + "': '" + NEW + "',", + '};', + '' +].join('\n') + +function treeWith(registryText) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ledger-renames-')) + if (registryText !== null) { + fs.mkdirSync(path.join(root, 'src/db/database'), { recursive: true }) + fs.writeFileSync(path.join(root, 'src/db/database/migration_registry.js'), registryText) + } + return root +} + +function guardDeps({ applied, renames, pendingManual = [] }) { + return { + cloneGit: sinon.stub().resolves(), + listDeployPreconditionMigrations: sinon.stub().returns([NEW]), + readAppliedMigrations: sinon.stub().resolves({ state: 'ledger', applied: new Set(applied) }), + runningBuildSupportsPerFileMigrations: sinon.stub().resolves(true), + pendingManualMigrations: sinon.stub().returns(pendingManual), + readLedgerRenames: sinon.stub().returns(renames) + } +} + +async function refusalOf(deps) { + try { + await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', 'master', deps) + } catch (err) { return err } + return null +} + +let warn + +// Stub console.warn around each test in the calling describe, exposed as `warn`. +function installWarnStub() { + beforeEach(() => { warn = sinon.stub(console, 'warn') }) + afterEach(() => { warn.restore() }) +} + +describe('migration precondition guard: ledger rename re-key', () => { + installWarnStub() + + describe('readLedgerRenames', () => { + it('parses the literal into its exact map, skipping comments and blank lines', () => { + const res = readLedgerRenames(treeWith(REGISTRY)) + expect(res.state).to.equal('parsed') + expect(res.renames).to.deep.equal({ + 'add_balances_composite_index.sql': '2026-05-30-balances-composite-index.sql', + [OLD]: NEW + }) + }) + + it('calls a tree with no registry file absent, with an empty map', () => { + const res = readLedgerRenames(treeWith(null)) + expect(res.state).to.equal('absent') + expect(res.renames).to.deep.equal({}) + }) + + it('refuses the whole map when any line inside the literal is not a quoted pair', () => { + for (const bad of [' ...EXTRA,', ' [computed]: \'x.sql\',', ' "a.sql": "b.sql",', " 'a.sql': `b.sql`,"]) { + const res = readLedgerRenames(treeWith(REGISTRY.replace(' // a full-line', bad + '\n // a full-line'))) + expect(res.state, bad).to.equal('unparseable') + expect(res.renames, bad).to.deep.equal({}) + } + }) + + it('refuses a repeated or self-mapped key and an unclosed literal', () => { + const dup = REGISTRY.replace('};', " '" + OLD + "': 'other.sql',\n};") + expect(readLedgerRenames(treeWith(dup)).state).to.equal('unparseable') + const self = REGISTRY.replace('};', " 'same.sql': 'same.sql',\n};") + expect(readLedgerRenames(treeWith(self)).state).to.equal('unparseable') + expect(readLedgerRenames(treeWith(REGISTRY.replace('};', ''))).state).to.equal('unparseable') + }) + }) + + describe('rekeyApplied', () => { + it('moves a recorded old name to its new name without touching the input', () => { + const input = new Set([OLD, 'other.sql']) + const out = rekeyApplied(input, { [OLD]: NEW }) + expect([...out]).to.have.members([NEW, 'other.sql']) + expect([...input]).to.have.members([OLD, 'other.sql']) + }) + + it('leaves the set alone when the new name is already recorded or the old one is not', () => { + expect([...rekeyApplied(new Set([OLD, NEW]), { [OLD]: NEW })]).to.have.members([OLD, NEW]) + expect([...rekeyApplied(new Set(['other.sql']), { [OLD]: NEW })]).to.have.members(['other.sql']) + }) + + it('plans every move against the input set, as the runner planner does', () => { + const out = rekeyApplied(new Set(['a.sql']), { 'a.sql': 'b.sql', 'b.sql': 'c.sql' }) + expect([...out]).to.have.members(['b.sql']) + }) + }) +}) + +describe('migration precondition guard: ledger rename re-key', () => { + installWarnStub() + + describe('assertRequiredMigrationsApplied', () => { + it('passes a database that recorded the precondition under its pre-rename name', async () => { + const deps = guardDeps({ applied: [OLD], renames: { state: 'parsed', renames: { [OLD]: NEW } } }) + expect(await refusalOf(deps)).to.equal(null) + }) + + it('still refuses when the target tree carries no rename map', async () => { + const deps = guardDeps({ applied: [OLD], renames: { state: 'absent', renames: {} } }) + const err = await refusalOf(deps) + expect(err, 'the deploy must be refused').to.not.equal(null) + expect(err.message).to.contain(NEW) + }) + + it('still refuses, and says why, when the rename map is unparseable', async () => { + const deps = guardDeps({ applied: [OLD], renames: { state: 'unparseable', renames: { [OLD]: NEW }, reason: 'line 9' } }) + const err = await refusalOf(deps) + expect(err, 'the deploy must be refused').to.not.equal(null) + expect(warn.getCalls().map(c => String(c.args[0])).join(' ')).to.contain('rename map was not used') + }) + + it('lists pending manual files against the re-keyed ledger', async () => { + const MANUAL_OLD = '2026-09-01-manual.sql' + const MANUAL_NEW = '2026-09-14-manual.sql' + const deps = guardDeps({ + applied: [MANUAL_OLD], + renames: { state: 'parsed', renames: { [MANUAL_OLD]: MANUAL_NEW } }, + pendingManual: [NEW] + }) + const err = await refusalOf(deps) + expect(err, 'the deploy must be refused').to.not.equal(null) + const seen = deps.pendingManualMigrations.firstCall.args[1] + expect(seen.has(MANUAL_NEW)).to.equal(true) + expect(seen.has(MANUAL_OLD)).to.equal(false) + }) + }) +}) + +describe('migration precondition guard: ledger rename re-key', () => { + installWarnStub() + + describe('sibling parity', () => { + it('parses the real indexer registry into exactly MIGRATION_LEDGER_RENAMES', function () { + if (!fs.existsSync(INDEXER_DIR)) { + if (REQUIRE_SIBLINGS) throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but xchain-indexer is not checked out at ' + INDEXER_DIR) + return this.skip() // sibling repo not checked out + } + const res = readLedgerRenames(INDEXER_DIR) + expect(res.state, res.reason).to.equal('parsed') + let registry = null + try { + registry = require(path.join(INDEXER_DIR, 'src/db/database/migration_registry.js')) + } catch { + registry = null // indexer dependencies not installed + } + if (registry && registry.MIGRATION_LEDGER_RENAMES) { + expect(res.renames).to.deep.equal(registry.MIGRATION_LEDGER_RENAMES) + return + } + const files = new Set(fs.readdirSync(migrationsDirOf(INDEXER_DIR))) + expect(Object.keys(res.renames)).to.not.have.length(0) + for (const [from, to] of Object.entries(res.renames)) { + expect(files.has(to), to + ' exists in the indexer tree').to.equal(true) + expect(files.has(from), from + ' no longer exists in the indexer tree').to.equal(false) + } + }) + }) +}) diff --git a/test/unit/migration_precondition_service.test/09_decoder_refusal_reads_decoder_cli.test.js b/test/unit/migration_precondition_service.test/09_decoder_refusal_reads_decoder_cli.test.js new file mode 100644 index 00000000..35835a17 --- /dev/null +++ b/test/unit/migration_precondition_service.test/09_decoder_refusal_reads_decoder_cli.test.js @@ -0,0 +1,103 @@ +'use strict' + +// Copyright © 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 guard refuses decoder deploys too, and the decoder's CLI sits at +// src/db/migrate.js and answers --status --json with name lists. A refusal that +// only knew the indexer layout named a path the decoder container lacks and +// never offered the scoped --file command the decoder build supports. + +const sinon = require('sinon') +const { expect } = require('chai') + +const { XChainService } = require('../../../src/config') +const { + assertRequiredMigrationsApplied, readRunningBuildMigrationStatus +} = require('../../../src/services/migration_precondition_service') + +const DECODER_CLI = 'src/db/migrate.js' +const GATED_FILE = '2026-08-10-action-data-utf8mb4.sql' +const DECODER_SOURCE = "if (a === '--status') {}\nif (a === '--file' || a === '-f') {}\n" + +function catServing(filePath, source) { + return sinon.stub().callsFake(async (container, candidate) => { + if (candidate === filePath) return source + throw new Error('missing') + }) +} + +function decoderDeps(statusJson, source = DECODER_SOURCE) { + return { + getDockerContainerFileCat: catServing(DECODER_CLI, source), + execContainer: sinon.stub().resolves(statusJson) + } +} + +const STATUS = JSON.stringify({ applied: ['2026-06-02-widen-ids-to-bigint.sql'], pending: [GATED_FILE] }) + +describe('migration precondition refusal: a decoder deploy reads the decoder CLI', () => { + beforeEach(() => { sinon.stub(console, 'warn') }) + afterEach(() => { sinon.restore() }) + + it("reads the decoder build's status rows at the decoder CLI path", async () => { + const deps = decoderDeps(STATUS) + const result = await readRunningBuildMigrationStatus('decoder-c1', deps, XChainService.XCHAIN_DECODER) + expect(result).to.deep.equal({ + supportsPerFile: true, + rows: [ + { file: '2026-06-02-widen-ids-to-bigint.sql', applied: true }, + { file: GATED_FILE, applied: false } + ] + }) + expect(deps.execContainer.firstCall.args[1]).to.deep.equal(['node', DECODER_CLI, '--status', '--json']) + }) + + it('refuses a decoder status that lists one file as both applied and pending', async () => { + const deps = decoderDeps(JSON.stringify({ applied: [GATED_FILE], pending: [GATED_FILE] })) + const result = await readRunningBuildMigrationStatus('decoder-c2', deps, XChainService.XCHAIN_DECODER) + expect(result).to.deep.equal({ supportsPerFile: false, rows: null }) + }) + + it('refuses a decoder status when the CLI source shows no --file', async () => { + const deps = decoderDeps(STATUS, "if (a === '--status') {}\n") + const result = await readRunningBuildMigrationStatus('decoder-c3', deps, XChainService.XCHAIN_DECODER) + expect(result).to.deep.equal({ supportsPerFile: false, rows: null }) + }) + + it('keeps the name-list shape off an indexer read', async () => { + const deps = { + getDockerContainerFileCat: catServing('src/db/migration/migrate.js', DECODER_SOURCE), + execContainer: sinon.stub().resolves(STATUS) + } + const result = await readRunningBuildMigrationStatus('indexer-c4', deps, XChainService.XCHAIN_INDEXER) + expect(result).to.deep.equal({ supportsPerFile: false, rows: null }) + }) + + it('names the decoder CLI and its scoped --file command in a decoder refusal', async () => { + const deps = { + ...decoderDeps(STATUS), + cloneGit: sinon.stub().resolves(), + listDeployPreconditionMigrations: sinon.stub().returns([GATED_FILE]), + readAppliedMigrations: sinon.stub().resolves({ state: 'ledger', applied: new Set(['2026-06-02-widen-ids-to-bigint.sql']) }), + pendingManualMigrations: sinon.stub().returns([]) + } + let message = null + try { + await assertRequiredMigrationsApplied(XChainService.XCHAIN_DECODER, 'bitcoin', 'mainnet', 'master', deps) + } catch (err) { + message = err.message + } + expect(message, 'the deploy must be refused').to.be.a('string') + expect(message).to.contain('node ' + DECODER_CLI + ' --file ' + GATED_FILE) + expect(message).to.not.contain('src/db/migration/migrate.js') + expect(message).to.not.contain('DO NOT run') + }) +}) diff --git a/test/unit/migration_precondition_service.test/helpers/reset_schema_state.js b/test/unit/migration_precondition_service.test/helpers/reset_schema_state.js new file mode 100644 index 00000000..62d8e36a --- /dev/null +++ b/test/unit/migration_precondition_service.test/helpers/reset_schema_state.js @@ -0,0 +1,385 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const { DatabaseSync } = require('node:sqlite') +const { readAppliedMigrations } = require('../../../../src/services/migration_precondition_service') + +const DATABASE = 'XChain_BTC_Mainnet_Indexer' +const RESET_MARKER = '2026-09-16-admission-height.sql' + +const V0190_MIGRATIONS = ` + 2026-05-30-balances-composite-index.sql 2026-06-03-unique-full-column-index-addresses.sql 2026-06-09-cross-chain-matches-partial-fill-columns.sql + 2026-06-10-mirror-id-autoincrement-repair.sql 2026-06-13-attests-request-status-add-rejected.sql 2026-06-14-capability-snapshots-add-source.sql + 2026-06-16-drop-orphaned-contract-balances.sql 2026-06-17-attests-drop-unique-request-id-version.sql 2026-06-19-index-tables-add-block-index.sql + 2026-06-21-index-tables-block-index-secondary-idx.sql 2026-06-23-tokens-action-index-idx.sql 2026-06-23-validator-rewards-block-index-idx.sql + 2026-06-26-cross-chain-settlements-block-index-idx.sql 2026-06-26-price-snapshots-status-block-round-idx.sql 2026-06-26-unstakes-cooldown-status-composite-idx.sql + 2026-06-27-polls-weight-mode-add-quadratic-time-weighted.sql 2026-07-03-votes-append-only-unique-idx.sql 2026-07-05-contract-index-columns-nullable.sql + 2026-07-05-polls-binding-callback-columns.sql 2026-07-07-cross-chain-matches-payout-legs.sql 2026-07-07-tick-id-columns-nullable.sql + 2026-07-10-contract-state-bin-key-index.sql 2026-07-13-balances-drop-redundant-address-id-index.sql 2026-07-15-markets-dedup-unique-pair.sql + 2026-07-15-sweeps-drop-legacy-escrows-column.sql 2026-07-16-mirror-id-unsigned-align.sql 2026-07-16-mirror-twin-bigint-unsigned-align.sql + 2026-07-16-reposition-state-key-bin.sql 2026-07-17-attests-add-responsible-set-json.sql 2026-07-17-cross-chain-matches-reorg-fence-columns.sql + 2026-07-17-oracle-prices-add-push-generation.sql 2026-07-17-polls-callback-delay-columns.sql 2026-07-18-reward-tables-source-block-composite-idx.sql + 2026-07-18-status-tables-status-action-composite-idx.sql 2026-07-19-anchor-actions-publisher-attestation-columns.sql 2026-07-19-events-reorg-marker-witness-columns.sql + 2026-07-19-price-snapshots-cross-chain-calls-reorg-fence-columns.sql 2026-07-20-capability-snapshots-source-in-unique-key.sql 2026-07-21-anchor-reward-attestations-table.sql + 2026-07-24-pubkeys-widen-uncompressed.sql 2026-07-26-bet-cancel-resolve-status-tables.sql 2026-07-26-tokens-backfill-lock-mint-supply.sql + 2026-07-28-escrow-leaf-journal-table.sql 2026-07-28-state-tree-roots-contract-state-root.sql 2026-07-28-state-tree-roots-escrow-shadow.sql + 2026-07-29-gated-files-threshold-and-publisher.sql 2026-07-29-state-checkpoints-uq-chain-seq.sql 2026-07-30-attests-add-relay-origin-columns.sql + 2026-08-10-bet-cancel-resolve-standalone-indexes.sql 2026-08-11-attests-relay-identity-index.sql 2026-08-12-validator-rewards-derive-block-index.sql + 2026-08-13-anchor-reward-attestations-doge-anchor-txid.sql 2026-08-13-contract-delegation-rotations-table.sql 2026-08-15-destroys-drop-unique-action-index.sql + 2026-08-15-lists-add-memo.sql 2026-08-19-attest-validator-stats-surrogate-id.sql 2026-08-19-files-name-index.sql + 2026-08-19-utf8mb4-index-memos-memo.sql 2026-08-19-utf8mb4-user-text-columns.sql 2026-08-24-validator-rewards-round-qualifier.sql + 2026-08-26-prices-add-v2-batch-columns.sql 2026-08-28-anchor-actions-section-index-pk.sql 2026-08-30-rollcall-tables.sql + 2026-09-02-issues-backfill-transfer-supply-id.sql 2026-09-02-utf8mb4-raw-wire-fields-not-null.sql 2026-09-02-utf8mb4-raw-wire-fields.sql + 2026-09-03-attestation-responses.sql 2026-09-03-attests-batch-action-index.sql 2026-09-03-attests-batch-chunk-columns.sql + 2026-09-06-attestation-responses-identity-effective-time.sql 2026-09-06-price-snapshots-status-timestamp-round-idx.sql 2026-09-06-utf8mb4-attestation-response-provider-fields.sql + 2026-09-07-rollcall-gates.sql 2026-09-08-deploy-deferred-assembly.sql 2026-09-10-markets-native-coin-side.sql + 2026-09-11-contract-meta-columns.sql 2026-09-11-cross-chain-btc-chain-id.sql 2026-09-11-price-snapshots-batch-block-time.sql + 2026-09-12-bridge-tables.sql 2026-09-12-state-tree-roots-block-index-idx.sql 2026-09-12-token-bridge-fields.sql + 2026-09-13-destroys-sends-leg-ordinal.sql +`.trim().split(/\s+/) + +const PRECONDITION_MIGRATIONS = new Set([ + '2026-07-24-pubkeys-widen-uncompressed.sql', + '2026-08-24-validator-rewards-round-qualifier.sql', + '2026-09-12-bridge-tables.sql' +]) + +const RESET_BASELINED_ROWS = new Set([ + '2026-07-24-pubkeys-widen-uncompressed.sql', + '2026-08-12-validator-rewards-derive-block-index.sql', + '2026-08-24-validator-rewards-round-qualifier.sql' +]) + +const SYNTHESIZED_ROWS = new Set(` + 2026-05-30-balances-composite-index.sql 2026-06-03-unique-full-column-index-addresses.sql 2026-06-10-mirror-id-autoincrement-repair.sql + 2026-06-16-drop-orphaned-contract-balances.sql + 2026-07-10-contract-state-bin-key-index.sql 2026-07-15-markets-dedup-unique-pair.sql 2026-07-15-sweeps-drop-legacy-escrows-column.sql + 2026-07-16-mirror-twin-bigint-unsigned-align.sql 2026-07-16-reposition-state-key-bin.sql 2026-07-24-pubkeys-widen-uncompressed.sql + 2026-07-26-tokens-backfill-lock-mint-supply.sql 2026-07-29-state-checkpoints-uq-chain-seq.sql 2026-08-12-validator-rewards-derive-block-index.sql + 2026-08-19-utf8mb4-index-memos-memo.sql 2026-08-24-validator-rewards-round-qualifier.sql 2026-09-02-issues-backfill-transfer-supply-id.sql + 2026-09-02-utf8mb4-raw-wire-fields-not-null.sql 2026-09-10-markets-native-coin-side.sql 2026-09-12-bridge-tables.sql +`.trim().split(/\s+/)) + +const ADMIT_BLOCK_COLUMNS = ['btc', 'ltc', 'doge'].map(chain => + ['admit_block_' + chain, 'bigint', { unsigned: true, nullable: true }]) +const SIGNED_ROW_TAIL_COLUMNS = [ + ['finalizing_view', 'int', { default: '0' }], ['validator_signatures', 'text'], + ['status', 'varchar', { length: 20, default: 'finalized' }], ['push_generation', 'bigint', { default: '0' }], + ['btc_chain_id', 'char', { length: 64, nullable: true }], + ['created_at', 'timestamp', { nullable: true, default: 'CURRENT_TIMESTAMP' }] +] +const BRIDGE_SCHEMA = { + bridge_transfers: { + columns: [ + ['id', 'bigint', { unsigned: true, autoIncrement: true }], ['transfer_id', 'char', { length: 64 }], + ['snapshot_block', 'bigint', { unsigned: true }], ['network', 'varchar', { length: 20 }], + ['src_chain', 'varchar', { length: 10 }], ['src_action_index', 'bigint', { unsigned: true }], + ['src_address', 'varchar', { length: 255 }], ['dest_chain', 'varchar', { length: 10 }], + ['dest_address', 'varchar', { length: 255 }], ['tick', 'varchar', { length: 250 }], + ['decimals', 'tinyint', { unsigned: true }], ['amount', 'varchar', { length: 250 }], + ['effective_time', 'bigint', { unsigned: true }], ...ADMIT_BLOCK_COLUMNS, ...SIGNED_ROW_TAIL_COLUMNS + ], + indexes: [ + ['PRIMARY', ['id'], 0], ['uq_transfer_id', ['transfer_id'], 0], + ['idx_snapshot_block', ['snapshot_block'], 1], ['idx_src_ref', ['src_chain', 'src_action_index'], 1], + ['idx_dest_chain', ['dest_chain'], 1], ['idx_effective', ['effective_time'], 1], + ['idx_status', ['status'], 1], ['idx_tick', ['tick'], 1] + ] + }, + bridge_settlements: { + columns: [ + ['action_index', 'bigint', { unsigned: true }], ['transfer_id', 'char', { length: 64 }], + ['kind', 'varchar', { length: 10, default: 'transfer' }], ['block_index', 'bigint', { unsigned: true }], + ['src_chain', 'varchar', { length: 10, nullable: true }], ['src_action_index', 'bigint', { unsigned: true, nullable: true }], + ['dest_chain', 'varchar', { length: 10, nullable: true }], ['dest_address', 'varchar', { length: 255, nullable: true }], + ['tick', 'varchar', { length: 250, nullable: true }] + ], + indexes: [ + ['uq_transfer_kind', ['transfer_id', 'kind'], 0], ['idx_action_index', ['action_index'], 1], + ['idx_block_index', ['block_index'], 1], ['idx_src_ref', ['src_chain', 'src_action_index'], 1] + ] + }, + policy_snapshots: { + columns: [ + ['id', 'bigint', { unsigned: true, autoIncrement: true }], ['snapshot_id', 'char', { length: 64 }], + ['snapshot_block', 'bigint', { unsigned: true }], ['origin_chain', 'varchar', { length: 10 }], + ['tick', 'varchar', { length: 250 }], ['policy_seq', 'bigint', { unsigned: true }], + ['origin_block', 'bigint', { unsigned: true }], ['policy_hash', 'char', { length: 64 }], + ['allow_list', 'mediumtext', { nullable: true }], ['block_list', 'mediumtext', { nullable: true }], + ['sleeping', 'tinyint', { default: '0' }], ['effective_time', 'bigint', { unsigned: true }], + ['network', 'varchar', { length: 20 }], ...ADMIT_BLOCK_COLUMNS, ...SIGNED_ROW_TAIL_COLUMNS + ], + indexes: [ + ['PRIMARY', ['id'], 0], ['uq_policy_seq', ['network', 'origin_chain', 'tick', 'policy_seq'], 0], + ['uq_snapshot_id', ['snapshot_id'], 0], ['idx_effective', ['effective_time'], 1], + ['idx_origin_tick', ['origin_chain', 'tick'], 1] + ] + }, + xbridges: { + columns: [ + ['action_index', 'bigint', { unsigned: true }], ['version', 'tinyint', { unsigned: true, nullable: true }], + ['tick_id', 'bigint', { unsigned: true, nullable: true }], ['dest_chain', 'varchar', { length: 10, nullable: true }], + ['dest_address_id', 'bigint', { unsigned: true, nullable: true }], + ['amount', 'varchar', { length: 250, nullable: true, charset: 'utf8mb4', collation: 'utf8mb4_general_ci' }], + ['decimals', 'tinyint', { unsigned: true, nullable: true }], ['min_depth', 'bigint', { unsigned: true, nullable: true }], + ['memo_id', 'bigint', { unsigned: true, nullable: true }], ['status_id', 'bigint', { unsigned: true, nullable: true }], + ['block_index', 'bigint', { unsigned: true }] + ], + indexes: [ + ['uq_action_index', ['action_index'], 0], ['idx_tick_id', ['tick_id'], 1], + ['idx_dest_address_id', ['dest_address_id'], 1], ['idx_status_id', ['status_id'], 1], + ['idx_block_index', ['block_index'], 1], ['idx_version_block', ['version', 'block_index'], 1] + ] + } +} + +function resetLedger() { + const ledger = new Set(V0190_MIGRATIONS.filter(name => !SYNTHESIZED_ROWS.has(name) || RESET_BASELINED_ROWS.has(name))) + ledger.add(RESET_MARKER) + return ledger +} + +function column(table, name, dataType, options = {}) { + const textType = ['char', 'varchar', 'text', 'mediumtext'].includes(dataType) + return { + table_schema: DATABASE, + table_name: table, + column_name: name, + data_type: dataType, + is_nullable: options.nullable ? 'YES' : 'NO', + column_default: options.default === undefined ? null : options.default, + extra: options.autoIncrement ? 'auto_increment' : (options.extra || ''), + character_maximum_length: options.length === undefined ? null : options.length, + column_type: dataType + (options.unsigned ? ' unsigned' : ''), + ordinal_position: options.ordinal, + character_set_name: options.charset || (textType ? 'utf8mb3' : null), + collation_name: options.collation || (textType ? 'utf8mb3_general_ci' : null), + generation_expression: options.generationExpression || null + } +} + +function index(table, name, columns, nonUnique) { + return columns.map((nameOfColumn, offset) => ({ + table_schema: DATABASE, + table_name: table, + index_name: name, + seq_in_index: offset + 1, + column_name: nameOfColumn, + sub_part: null, + index_type: 'BTREE', + non_unique: nonUnique + })) +} + +function resetSchemaModel() { + const columns = [ + column('contract_state', 'state_key', 'varchar', { length: 256, ordinal: 5 }), + column('contract_state', 'state_key_bin', 'varchar', { + length: 256, ordinal: 6, collation: 'utf8mb3_bin', extra: 'VIRTUAL GENERATED', generationExpression: '`state_key`' + }), + column('pubkeys', 'pubkey', 'varchar', { length: 130, ordinal: 2 }), + column('validator_rewards', 'round_qualifier', 'bigint', { unsigned: true, default: '0', ordinal: 6 }), + column('validator_rewards', 'derive_block_index', 'bigint', { unsigned: true, nullable: true, ordinal: 9 }), + column('anchor_reward_reconcile_log', 'round_qualifier', 'bigint', { unsigned: true, default: '0', ordinal: 5 }), + column('anchor_reward_reconcile_log', 'reward_derive_block_index', 'bigint', { unsigned: true, nullable: true, ordinal: 10 }), + column('index_memos', 'memo', 'varchar', { + length: 250, charset: 'utf8mb4', collation: 'utf8mb4_general_ci' + }), + column('markets', 'coin1_id', 'bigint', { unsigned: true, default: '0', ordinal: 21 }), + column('markets', 'coin2_id', 'bigint', { unsigned: true, default: '0', ordinal: 22 }) + ] + for (const [table, name] of [ + ['cross_chain_calls', 'target_contract_index'], ['cross_chain_calls', 'gas_limit'], + ['cross_chain_calls', 'effective_time'], ['cross_chain_matches', 'snapshot_block'], + ['cross_chain_matches', 'a_action_index'], ['cross_chain_matches', 'b_action_index'], + ['cross_chain_matches', 'effective_time'], ['capability_snapshots', 'snapshot_block'] + ]) columns.push(column(table, name, 'bigint', { unsigned: true })) + for (const table of ['price_snapshots', 'cross_chain_matches', 'capability_snapshots', 'state_checkpoints']) + columns.push(column(table, 'id', 'bigint', { unsigned: true, autoIncrement: true, ordinal: 1 })) + for (const [table, name, dataType, length] of [ + ['contracts', 'code', 'mediumtext'], ['deploy_chunks', 'code_part', 'mediumtext'], + ['deposits', 'amount', 'varchar', 250], ['withdrawals', 'amount', 'varchar', 250], + ['stakes', 'amount', 'varchar', 250], ['unstakes', 'amount', 'varchar', 250], + ['contract_stakes', 'amount', 'varchar', 250], ['contract_unstakes', 'amount', 'varchar', 250], + ['reward_claims', 'amount', 'varchar', 250], ['gated_files', 'gate_ticker', 'varchar', 250], + ['attests', 'provider_id', 'varchar', 32] + ]) columns.push(column(table, name, dataType, { length, charset: 'utf8mb4', collation: 'utf8mb4_general_ci' })) + + const indexes = [ + ...index('balances', 'addr_tick', ['address_id', 'tick_id'], 0), + ...index('index_addresses', 'address', ['address'], 0), + ...index('contract_state', 'idx_latest_bin', ['contract_index', 'state_key_bin', 'id'], 1), + ...index('markets', 'uq_markets_pair', ['tick1_id', 'tick2_id'], 0), + ...index('state_checkpoints', 'uq_chain_seq', ['chain', 'network', 'checkpoint_seq'], 0), + ...index('validator_rewards', 'derive_block_index', ['derive_block_index'], 1), + ...index('validator_rewards', 'reward_unique', + ['source_id', 'signing_pubkey_id', 'reward_type', 'round_reference', 'round_qualifier'], 0) + ] + const tables = [{ table_schema: DATABASE, table_name: 'sweeps', engine: 'InnoDB', table_collation: 'utf8mb3_general_ci', table_type: 'BASE TABLE' }] + for (const [table, shape] of Object.entries(BRIDGE_SCHEMA)) { + shape.columns.forEach(([name, type, options = {}], offset) => + columns.push(column(table, name, type, { ...options, ordinal: offset + 1 }))) + shape.indexes.forEach(([name, members, nonUnique]) => indexes.push(...index(table, name, members, nonUnique))) + tables.push({ table_schema: DATABASE, table_name: table, engine: 'InnoDB', table_collation: 'utf8mb3_general_ci', table_type: 'BASE TABLE' }) + } + return { columns, indexes, tables, unsatisfiedData: false } +} + +function unsatisfiedSchemaModel() { + return { + columns: [], + indexes: [], + tables: [{ + table_schema: DATABASE, + table_name: 'contract_balances', + engine: 'InnoDB', + table_collation: 'utf8mb3_general_ci', + table_type: 'BASE TABLE' + }], + unsatisfiedData: true + } +} + +function attachInformationSchema(db) { + db.function('LEAST', { varargs: true }, (...values) => Math.min(...values)) + db.function('GREATEST', { varargs: true }, (...values) => Math.max(...values)) + db.exec("ATTACH DATABASE ':memory:' AS information_schema") + db.exec("ATTACH DATABASE ':memory:' AS `" + DATABASE + "`") + db.exec(` + CREATE TABLE information_schema.columns ( + table_schema TEXT, table_name TEXT, column_name TEXT, data_type TEXT, + is_nullable TEXT, column_default TEXT, extra TEXT, character_maximum_length INTEGER, + column_type TEXT, ordinal_position INTEGER, character_set_name TEXT, + collation_name TEXT, generation_expression TEXT + ); + CREATE TABLE information_schema.statistics ( + table_schema TEXT, table_name TEXT, index_name TEXT, seq_in_index INTEGER, + column_name TEXT, sub_part INTEGER, index_type TEXT, non_unique INTEGER + ); + CREATE TABLE information_schema.tables ( + table_schema TEXT, table_name TEXT, engine TEXT, table_collation TEXT, table_type TEXT + ); + `) +} + +function createSchemaTables(db) { + db.exec(` + CREATE TABLE \`${DATABASE}\`.schema_migrations (name TEXT); + CREATE TABLE \`${DATABASE}\`.tokens (tick_id INTEGER, lock_mint_supply INTEGER); + CREATE TABLE \`${DATABASE}\`.issues ( + tick_id INTEGER, lock_mint_supply TEXT, status_id INTEGER, + transfer_supply_id INTEGER, action_index INTEGER + ); + CREATE TABLE \`${DATABASE}\`.index_statuses (id INTEGER, status TEXT); + CREATE TABLE \`${DATABASE}\`.debits (action_index INTEGER, tick_id INTEGER, address_id INTEGER); + CREATE TABLE \`${DATABASE}\`.credits (action_index INTEGER, tick_id INTEGER, address_id INTEGER); + CREATE TABLE \`${DATABASE}\`.fees (action_index INTEGER, destination_id INTEGER); + CREATE TABLE \`${DATABASE}\`.markets ( + id INTEGER, tick1_id INTEGER, tick2_id INTEGER, coin1_id INTEGER, coin2_id INTEGER + ); + CREATE TABLE \`${DATABASE}\`.orders ( + action_index INTEGER, get_tick_id INTEGER, give_tick_id INTEGER, + get_coin_id INTEGER, give_coin_id INTEGER + ); + CREATE TABLE \`${DATABASE}\`.order_matches ( + action_index INTEGER, get_tick_id INTEGER, give_tick_id INTEGER, + get_coin_id INTEGER, give_coin_id INTEGER + ); + `) +} + +function insertRows(db, table, rows) { + if (!rows.length) return + const names = Object.keys(rows[0]) + const statement = db.prepare('INSERT INTO ' + table + ' (' + names.join(', ') + ') VALUES (' + names.map(() => '?').join(', ') + ')') + rows.forEach(row => statement.run(...names.map(name => row[name] === undefined ? null : row[name]))) +} + +function seedUnsatisfiedDataRows(db) { + db.exec(` + INSERT INTO \`${DATABASE}\`.tokens VALUES (1, 0); + INSERT INTO \`${DATABASE}\`.index_statuses VALUES (1, 'valid'); + INSERT INTO \`${DATABASE}\`.issues VALUES (1, '1', 1, NULL, 10); + INSERT INTO \`${DATABASE}\`.debits VALUES (10, 1, 100); + INSERT INTO \`${DATABASE}\`.credits VALUES (10, 1, 200); + `) +} + +function createSchemaDatabase(ledger, model) { + const db = new DatabaseSync(':memory:') + attachInformationSchema(db) + createSchemaTables(db) + insertRows(db, 'information_schema.columns', model.columns) + insertRows(db, 'information_schema.statistics', model.indexes) + insertRows(db, 'information_schema.tables', model.tables) + insertRows(db, '`' + DATABASE + '`.schema_migrations', [...ledger].map(name => ({ name }))) + if (model.unsatisfiedData) seedUnsatisfiedDataRows(db) + return db +} + +function sqliteSql(sql) { + return sql + .replace(/TRIM\(BOTH '''' FROM column_default\)/g, "trim(column_default, '''')") + .replace(/ <=> /g, ' IS ') +} + +function generatedFacts(sql) { + const matches = [...sql.matchAll(/SELECT '([^']+\.sql)' AS name WHERE /g)] + return matches.map((match, index) => { + const end = matches[index + 1] ? matches[index + 1].index : sql.length + return { + name: match[1], + statement: sql.slice(match.index, end).replace(/\s+UNION\s*$/, '') + } + }) +} + +function runnerFor(ledger, model) { + const queries = [] + const evaluated = [] + const satisfied = [] + const db = createSchemaDatabase(ledger, model) + const runner = async sql => { + queries.push(sql) + if (/TABLE_NAME = 'schema_migrations'/.test(sql)) return '1' + if (/^SELECT COUNT\(\*\)/.test(sql)) return '100' + const probe = createSchemaDatabase(new Set(), model) + for (const fact of generatedFacts(sql)) { + evaluated.push(fact.name) + satisfied.push(...probe.prepare(sqliteSql(fact.statement)).all().map(row => row.name)) + } + probe.close() + return db.prepare(sqliteSql(sql)).all().map(row => row.name).join('\n') + } + return { runner, queries, evaluated, satisfied } +} + +async function readResetLedger(model) { + const ledger = resetLedger() + const db = runnerFor(ledger, model) + const result = await readAppliedMigrations( + { database: DATABASE, coin: 'bitcoin', network: 'mainnet' }, + { runner: db.runner }) + return { + result, + ledger, + queries: db.queries, + evaluated: db.evaluated, + satisfied: db.satisfied + } +} + +module.exports = { + DATABASE, + V0190_MIGRATIONS, + PRECONDITION_MIGRATIONS, + SYNTHESIZED_ROWS, + resetLedger, + resetSchemaModel, + unsatisfiedSchemaModel, + readResetLedger +} diff --git a/test/unit/module_database_name.test.js b/test/unit/module_database_name.test.js new file mode 100644 index 00000000..6ca6657c --- /dev/null +++ b/test/unit/module_database_name.test.js @@ -0,0 +1,47 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const { XChainService } = require('../../src/config') +const { configuredDatabaseName } = require('../../src/utils/module_database_name') + +const DEFAULT_DECODER = 'XChain_BTC_Mainnet_Decoder' +const DEFAULT_INDEXER = 'XChain_BTC_Mainnet_Indexer' + +describe('configuredDatabaseName()', function () { + it('uses the configured decoder name over the derived default', function () { + const cfg = { DECODER_DB_NAME: 'CustomDecoder', INDEXER_DB_NAME: 'CustomIndexer' } + expect(configuredDatabaseName(XChainService.XCHAIN_DECODER, cfg, DEFAULT_DECODER)).to.equal('CustomDecoder') + }) + + it('uses the configured indexer name over the derived default', function () { + const cfg = { DECODER_DB_NAME: 'CustomDecoder', INDEXER_DB_NAME: ' CustomIndexer ' } + expect(configuredDatabaseName(XChainService.XCHAIN_INDEXER, cfg, DEFAULT_INDEXER)).to.equal('CustomIndexer') + }) + + it('falls back to the derived default when the key is missing, blank or not a string', function () { + for (const cfg of [{}, { DECODER_DB_NAME: '' }, { DECODER_DB_NAME: ' ' }, { DECODER_DB_NAME: 7 }, null]) { + expect(configuredDatabaseName(XChainService.XCHAIN_DECODER, cfg, DEFAULT_DECODER)).to.equal(DEFAULT_DECODER) + } + }) + + it('ignores database keys for a module that has no database of its own', function () { + const cfg = { DECODER_DB_NAME: 'CustomDecoder', INDEXER_DB_NAME: 'CustomIndexer' } + expect(configuredDatabaseName(XChainService.XCHAIN_ENCODER, cfg, 'derived')).to.equal('derived') + }) + + it('refuses an unsafe configured name instead of handing it to SQL', function () { + const cfg = { INDEXER_DB_NAME: 'Custom; DROP DATABASE mysql' } + expect(() => configuredDatabaseName(XChainService.XCHAIN_INDEXER, cfg, DEFAULT_INDEXER)) + .to.throw(/Unsafe MariaDB database name/) + }) +}) diff --git a/test/unit/module_operations.test/08_uninstall_and_e2e.test.js b/test/unit/module_operations.test/08_uninstall_and_e2e.test.js index 3fc35895..cc4c64f3 100644 --- a/test/unit/module_operations.test/08_uninstall_and_e2e.test.js +++ b/test/unit/module_operations.test/08_uninstall_and_e2e.test.js @@ -200,8 +200,11 @@ describe('moduleOperations', function () { await ops.runE2ETest('bitcoin', 'mainnet', 'myTest', null) const installArgs = stubs.installModule.firstCall.args const dockerCmdArgs = installArgs[7] - expect(dockerCmdArgs).to.include('mocha') - expect(dockerCmdArgs.some(a => a.includes('myTest'))).to.be.true + expect(dockerCmdArgs.slice(0, 2)).to.deep.equal(['sh', '-c']) + expect(dockerCmdArgs[2]).to.include('exec npx mocha') + expect(dockerCmdArgs[2]).to.include('initial_check.test.js') + expect(dockerCmdArgs[2]).to.include('initialCheck.test.js') + expect(dockerCmdArgs.slice(3)).to.deep.equal(['sh', 'test/actions/myTest.test.js']) }) it('includes --grep when grep is provided with testName', async function () { @@ -210,8 +213,8 @@ describe('moduleOperations', function () { const ops = loadOperations(stubs) await ops.runE2ETest('bitcoin', 'mainnet', 'myTest', 'my grep pattern') const dockerCmdArgs = stubs.installModule.firstCall.args[7] - expect(dockerCmdArgs).to.include('--grep') - expect(dockerCmdArgs).to.include('my grep pattern') + expect(dockerCmdArgs.slice(-2)).to.deep.equal(['--grep', 'my grep pattern']) + expect(dockerCmdArgs[2]).to.not.include('my grep pattern') }) }) }) @@ -264,3 +267,63 @@ describe('moduleOperations', function () { }) }) }) + +// filterCommandParameters leaves the hub and sync out of `all`, so a full +// teardown with --include-shared adds them itself, hub last, and never asks +// for the database, which uninstallModule refuses. +function registerFullTeardownTests() { + const ALL_SHAPE = () => ({ '': { '': ['xchain-explorer'] }, bitcoin: { mainnet: ['xchain-encoder'] } }) + const called = (stubs) => stubs.uninstallModule.getCalls().map(c => c.args[2]) + + it('removes the explorer, then sync, then the hub on an all teardown, and keeps the database', async function () { + const stubs = makeStubs() + const ops = loadOperations(stubs) + const result = await ops.uninstallModules(ALL_SHAPE(), true, { all: true }) + expect(called(stubs)).to.deep.equal(['xchain-encoder', 'xchain-explorer', 'xchain-sync', 'xchain-hub']) + const db = result.skipped.find(s => s.module === 'database') + expect(db, 'the database must be reported as kept').to.exist + expect(db.reason).to.contain('removed manually') + }) + + it('keeps the hub and sync on an all teardown while another chain is still installed', async function () { + const stubs = makeStubs() + stubs.db.getAllModuleContainers.resolves([ + { module: 'xchain-indexer', coin: 'dogecoin', network: 'mainnet', container_id: 'c1' } + ]) + const ops = loadOperations(stubs) + const result = await ops.uninstallModules(ALL_SHAPE(), true, { all: true }) + expect(called(stubs)).to.deep.equal(['xchain-encoder']) + for (const m of ['xchain-hub', 'xchain-sync']) { + const kept = result.skipped.find(s => s.module === m) + expect(kept, `${m} must be reported as kept`).to.exist + expect(kept.reason).to.contain('dogecoin mainnet') + } + }) + + it('adds neither the hub nor sync without --include-shared or outside all', async function () { + for (const [includeShared, opts] of [[false, { all: true }], [true, {}]]) { + const stubs = makeStubs() + const ops = loadOperations(stubs) + await ops.uninstallModules(ALL_SHAPE(), includeShared, opts) + expect(called(stubs)).to.not.include('xchain-hub') + expect(called(stubs)).to.not.include('xchain-sync') + } + }) + + it('reaches the hub and sync from the real all expansion', async function () { + const { filterCommandParameters } = requireFromUnit('../../src/services/config_service/filters.js') + const stubs = makeStubs() + const ops = loadOperations(stubs) + await ops.uninstallModules(filterCommandParameters(null, 'all', 'bitcoin', 'mainnet'), true, { all: true }) + expect(called(stubs).slice(-3)).to.deep.equal(['xchain-explorer', 'xchain-sync', 'xchain-hub']) + expect(called(stubs)).to.not.include('database') + }) +} + +describe('moduleOperations', function () { + registerLifecycleHooks(() => {}) + + describe('uninstallModules(): all with --include-shared', function () { + registerFullTeardownTests() + }) +}) diff --git a/test/unit/module_operations.test/09_reset_modules.test.js b/test/unit/module_operations.test/reset/09_reset_modules.test.js similarity index 97% rename from test/unit/module_operations.test/09_reset_modules.test.js rename to test/unit/module_operations.test/reset/09_reset_modules.test.js index f36f2200..9f1a8c4b 100644 --- a/test/unit/module_operations.test/09_reset_modules.test.js +++ b/test/unit/module_operations.test/reset/09_reset_modules.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('./helpers/harness') +const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('../helpers/harness') describe('moduleOperations', function () { let resolveInstallTargetStub, recordInstallTargetStub, resolveUpdateTargetStub @@ -125,7 +125,8 @@ describe('moduleOperations', function () { const result = await ops.resetModules('all', 'bitcoin', 'mainnet', true) expect(result).to.be.false expect(stubs.resetDatabases.called).to.be.false - expect(stubs.execFile.called).to.be.false // no wipe ran + // No docker call ran past the pre-stop wipe-image check: no volume probe, no wipe. + expect(stubs.execFile.getCalls().filter(c => c.args[1][0] !== 'image')).to.have.length(0) expect(stubs.startContainer.calledOnce).to.be.true // node put back }) @@ -160,7 +161,7 @@ describe('moduleOperations', function () { const ops = loadOperations(stubs) const result = await ops.resetModules('xchain-utxo-tracker', 'bitcoin', 'mainnet', true) expect(result).to.be.false - expect(stubs.execFile.called).to.be.false + expect(stubs.execFile.getCalls().filter(c => c.args[1][0] !== 'image')).to.have.length(0) }) it('clears the hub price ingest fence when the indexer DB is reset', async function () { @@ -305,7 +306,7 @@ describe('moduleOperations', function () { const ops = loadOperations(stubs) const result = await ops.resetModules('node', 'bitcoin', 'mainnet', true) expect(result).to.be.false - expect(stubs.execFile.called).to.be.false + expect(stubs.execFile.getCalls().filter(c => c.args[1][0] !== 'image')).to.have.length(0) }) // #3144: reset must fail loud on bad args rather than reporting success diff --git a/test/unit/module_operations.test/10_reset_node_datadir_resolution.test.js b/test/unit/module_operations.test/reset/10_reset_node_datadir_resolution.test.js similarity index 99% rename from test/unit/module_operations.test/10_reset_node_datadir_resolution.test.js rename to test/unit/module_operations.test/reset/10_reset_node_datadir_resolution.test.js index ba4cd12d..4c38218d 100644 --- a/test/unit/module_operations.test/10_reset_node_datadir_resolution.test.js +++ b/test/unit/module_operations.test/reset/10_reset_node_datadir_resolution.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('./helpers/harness') +const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('../helpers/harness') diff --git a/test/unit/module_operations.test/11_reset_external_db_reachability.test.js b/test/unit/module_operations.test/reset/11_reset_external_db_reachability.test.js similarity index 99% rename from test/unit/module_operations.test/11_reset_external_db_reachability.test.js rename to test/unit/module_operations.test/reset/11_reset_external_db_reachability.test.js index 62135dfb..526fae6b 100644 --- a/test/unit/module_operations.test/11_reset_external_db_reachability.test.js +++ b/test/unit/module_operations.test/reset/11_reset_external_db_reachability.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('./helpers/harness') +const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('../helpers/harness') diff --git a/test/unit/module_operations.test/12_reset_bootstrap_republish.test.js b/test/unit/module_operations.test/reset/12_reset_bootstrap_republish.test.js similarity index 99% rename from test/unit/module_operations.test/12_reset_bootstrap_republish.test.js rename to test/unit/module_operations.test/reset/12_reset_bootstrap_republish.test.js index 0cc21f16..061299c5 100644 --- a/test/unit/module_operations.test/12_reset_bootstrap_republish.test.js +++ b/test/unit/module_operations.test/reset/12_reset_bootstrap_republish.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('./helpers/harness') +const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('../helpers/harness') diff --git a/test/unit/module_operations.test/13_reset_registry_read.test.js b/test/unit/module_operations.test/reset/13_reset_registry_read.test.js similarity index 98% rename from test/unit/module_operations.test/13_reset_registry_read.test.js rename to test/unit/module_operations.test/reset/13_reset_registry_read.test.js index dc26c879..cea8f23a 100644 --- a/test/unit/module_operations.test/13_reset_registry_read.test.js +++ b/test/unit/module_operations.test/reset/13_reset_registry_read.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('./helpers/harness') +const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('../helpers/harness') diff --git a/test/unit/module_operations.test/14_reset_utxo_tracker_volume.test.js b/test/unit/module_operations.test/reset/14_reset_utxo_tracker_volume.test.js similarity index 91% rename from test/unit/module_operations.test/14_reset_utxo_tracker_volume.test.js rename to test/unit/module_operations.test/reset/14_reset_utxo_tracker_volume.test.js index f269f0a8..a5300162 100644 --- a/test/unit/module_operations.test/14_reset_utxo_tracker_volume.test.js +++ b/test/unit/module_operations.test/reset/14_reset_utxo_tracker_volume.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('./helpers/harness') +const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('../helpers/harness') @@ -97,7 +97,9 @@ describe('moduleOperations', function () { describe('resetModules()', function () { describe('the utxo-tracker volume wipe', function () { - it('aborts and restores the stack when the wipe fails with nothing else touched', async function () { + // A wipe that STARTED may have deleted part of the volume (find -delete keeps going past a + // per-entry error), so even with nothing else touched it must not restart the tracker over it. + it('leaves the stack down and reports a possibly partial wipe when the wipe fails with nothing else touched', async function () { const stubs = makeStubs() stubs.execFile.callsFake((cmd, args, cb) => { if (args[0] === 'volume') return cb(null, '', '') @@ -115,10 +117,13 @@ describe('moduleOperations', function () { } expect(result).to.be.false expect(stubs.resetDatabases.called).to.be.false + expect(stubs.startContainer.called).to.be.false const output = lines.join('\n') expect(output).to.include(`clearing the Docker volume ${VOLUME} failed`) expect(output).to.include('permission denied') - expect(output).to.include('No data was touched.') + expect(output).to.include('may be PARTLY cleared') + expect(output).to.include('The stopped services are left down: xchain-utxo-tracker') + expect(output).to.not.include('No data was touched.') }) }) }) diff --git a/test/unit/module_operations.test/15_reset_regtest_hub_purge.test.js b/test/unit/module_operations.test/reset/15_reset_regtest_hub_purge.test.js similarity index 99% rename from test/unit/module_operations.test/15_reset_regtest_hub_purge.test.js rename to test/unit/module_operations.test/reset/15_reset_regtest_hub_purge.test.js index fd4814fa..5ad359aa 100644 --- a/test/unit/module_operations.test/15_reset_regtest_hub_purge.test.js +++ b/test/unit/module_operations.test/reset/15_reset_regtest_hub_purge.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('./helpers/harness') +const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('../helpers/harness') diff --git a/test/unit/module_operations.test/16_reset_coupled_decoder_indexer.test.js b/test/unit/module_operations.test/reset/16_reset_coupled_decoder_indexer.test.js similarity index 99% rename from test/unit/module_operations.test/16_reset_coupled_decoder_indexer.test.js rename to test/unit/module_operations.test/reset/16_reset_coupled_decoder_indexer.test.js index 3b677fe1..9118244f 100644 --- a/test/unit/module_operations.test/16_reset_coupled_decoder_indexer.test.js +++ b/test/unit/module_operations.test/reset/16_reset_coupled_decoder_indexer.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('./helpers/harness') +const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('../helpers/harness') diff --git a/test/unit/module_operations.test/reset_wipes/wipe_failures.test.js b/test/unit/module_operations.test/reset_wipes/wipe_failures.test.js new file mode 100644 index 00000000..d6686e8f --- /dev/null +++ b/test/unit/module_operations.test/reset_wipes/wipe_failures.test.js @@ -0,0 +1,114 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks } = require('../helpers/harness') + +const NODE_DATADIR = '/srv/xchain/data/node/bitcoin/mainnet' +const VOLUME = 'xchain-utxo-tracker-bitcoin-mainnet-data' + +// The `docker run` wipes this reset issued, as the host side of each `-v :/data` mount. +function wipeMounts(execFileStub) { + return execFileStub.getCalls() + .filter(c => Array.isArray(c.args[1]) && c.args[1][0] === 'run') + .map(c => c.args[1][c.args[1].indexOf('-v') + 1].replace(/:\/data$/, '')) +} + +// Run a forced reset with console.log captured, and hand back the result and what it printed. +async function resetCapturing(stubs, service) { + const ops = loadOperations(stubs) + const lines = [] + const logStub = sinon.stub(console, 'log').callsFake((...a) => lines.push(a.join(' '))) + let result + try { + result = await ops.resetModules(service, 'bitcoin', 'mainnet', true) + } finally { + logStub.restore() + } + return { result, output: lines.join('\n') } +} + +describe('moduleOperations', function () { + registerLifecycleHooks(() => {}) + + describe('resetModules() wipe-image pre-flight', function () { + + it('refuses before stopping anything when the alpine image can be neither found nor pulled', async function () { + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, cb) => { + if (args[0] === 'image' || args[0] === 'pull') return cb(new Error('dial tcp: lookup registry-1.docker.io: no such host')) + cb(null, '', '') + }) + const { result, output } = await resetCapturing(stubs, 'all') + expect(result).to.be.false + expect(stubs.stopContainer.called).to.be.false + expect(wipeMounts(stubs.execFile)).to.have.length(0) + expect(stubs.resetDatabases.called).to.be.false + expect(output).to.include('cannot obtain the alpine image') + expect(output).to.include('No data was touched.') + }) + + it('pulls the image when it is not cached and goes on to complete the reset', async function () { + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, cb) => { + if (args[0] === 'image') return cb(new Error('Error: No such image: alpine')) + cb(null, '', '') + }) + const { result } = await resetCapturing(stubs, 'xchain-utxo-tracker') + expect(result).to.be.true + expect(stubs.execFile.getCalls().some(c => c.args[1][0] === 'pull' && c.args[1][1] === 'alpine')).to.be.true + expect(wipeMounts(stubs.execFile)).to.include(VOLUME) + }) + }) +}) + +describe('moduleOperations', function () { + registerLifecycleHooks(() => {}) + + describe('resetModules() a node data wipe that fails', function () { + + it('resolves false with every stopped service left down and the datadir named as possibly partial', async function () { + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, cb) => { + if (args[0] === 'run' && args.join(' ').includes(NODE_DATADIR)) return cb(new Error('Error response from daemon: container exited')) + cb(null, '', '') + }) + const { result, output } = await resetCapturing(stubs, 'all') + expect(result).to.be.false + expect(stubs.resetDatabases.called).to.be.false + expect(stubs.startContainer.called).to.be.false + expect(wipeMounts(stubs.execFile)).to.not.include(VOLUME) + expect(output).to.include(`clearing node data at ${NODE_DATADIR} failed`) + expect(output).to.include('may be PARTLY cleared') + expect(output).to.include(`NOT touched: the Docker volume ${VOLUME}, the decoder/indexer databases.`) + expect(output).to.match(/The stopped services are left down: .*xchain-indexer/) + expect(output).to.not.include('No data was touched.') + }) + + it('says the datadir WAS cleared when a relocated wipe fails after it', async function () { + const stubs = makeStubs() + stubs.resolveBlocksDir.resolves('/mnt/blocks') + stubs.fs.existsSync.returns(true) + stubs.execFile.callsFake((cmd, args, cb) => { + if (args[0] === 'run' && args.join(' ').includes('/mnt/blocks/bitcoin/mainnet:')) return cb(new Error('permission denied')) + cb(null, '', '') + }) + const { result, output } = await resetCapturing(stubs, 'node') + expect(result).to.be.false + expect(stubs.startContainer.called).to.be.false + expect(wipeMounts(stubs.execFile)).to.not.include('/mnt/blocks/bitcoin/mainnet-txindex') + expect(output).to.include('clearing relocated node data at /mnt/blocks/bitcoin/mainnet failed (permission denied)') + expect(output).to.include('The node data for this stack WAS already cleared') + expect(output).to.include(NODE_DATADIR) + expect(output).to.not.include('No data was touched.') + }) + }) +}) diff --git a/test/unit/module_operations.test/05_update_modules_branch_handling.test.js b/test/unit/module_operations.test/update/05_update_modules_branch_handling.test.js similarity index 98% rename from test/unit/module_operations.test/05_update_modules_branch_handling.test.js rename to test/unit/module_operations.test/update/05_update_modules_branch_handling.test.js index 3ac574b4..a2daadd4 100644 --- a/test/unit/module_operations.test/05_update_modules_branch_handling.test.js +++ b/test/unit/module_operations.test/update/05_update_modules_branch_handling.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('./helpers/harness') +const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('../helpers/harness') diff --git a/test/unit/module_operations.test/06_update_modules_no_ref_target_resolution.test.js b/test/unit/module_operations.test/update/06_update_modules_no_ref_target_resolution.test.js similarity index 99% rename from test/unit/module_operations.test/06_update_modules_no_ref_target_resolution.test.js rename to test/unit/module_operations.test/update/06_update_modules_no_ref_target_resolution.test.js index 0124669b..d59dcb16 100644 --- a/test/unit/module_operations.test/06_update_modules_no_ref_target_resolution.test.js +++ b/test/unit/module_operations.test/update/06_update_modules_no_ref_target_resolution.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('./helpers/harness') +const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('../helpers/harness') diff --git a/test/unit/module_operations.test/07_update_modules_all_expansion.test.js b/test/unit/module_operations.test/update/07_update_modules_all_expansion.test.js similarity index 99% rename from test/unit/module_operations.test/07_update_modules_all_expansion.test.js rename to test/unit/module_operations.test/update/07_update_modules_all_expansion.test.js index 3804a43a..af36a2a6 100644 --- a/test/unit/module_operations.test/07_update_modules_all_expansion.test.js +++ b/test/unit/module_operations.test/update/07_update_modules_all_expansion.test.js @@ -10,7 +10,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('./helpers/harness') +const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks, requireFromUnit } = require('../helpers/harness') const fs = require('fs') const os = require('os') const path = require('path') diff --git a/test/unit/module_operations.test/update/08_update_all_continues_after_refusal.test.js b/test/unit/module_operations.test/update/08_update_all_continues_after_refusal.test.js new file mode 100644 index 00000000..587a682a --- /dev/null +++ b/test/unit/module_operations.test/update/08_update_all_continues_after_refusal.test.js @@ -0,0 +1,71 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks } = require('../helpers/harness') + +describe('moduleOperations updateModules(): a guard refusal under `all`', function () { + registerLifecycleHooks(() => {}) + + const services = { + '': { '': ['xchain-hub'] }, + bitcoin: { regtest: ['xchain-indexer', 'xchain-encoder'] } + } + + function refuseFirst(stubs, message) { + stubs.assertHubNotBehind.onFirstCall().rejects(new Error(message)) + } + + it('records the refused service as failed and still updates the rest', async function () { + const stubs = makeStubs() + refuseFirst(stubs, 'update refused: xchain-hub requires a newer hub') + const ops = loadOperations(stubs) + const warn = sinon.stub(console, 'warn') + let outcome + try { + outcome = await ops.updateModules(services, null, { all: true }) + } finally { warn.restore() } + + expect(outcome.failed).to.have.length(1) + expect(outcome.failed[0]).to.include({ module: 'xchain-hub', coin: '', network: '' }) + expect(outcome.failed[0].reason).to.match(/update refused/) + expect(outcome.updated.map(u => u.module)).to.deep.equal(['xchain-sync', 'xchain-indexer', 'xchain-encoder']) + expect(stubs.installModule.callCount).to.equal(3) + }) + + it('records a migration refusal the same way', async function () { + const stubs = makeStubs() + stubs.assertRequiredMigrationsApplied.onFirstCall().rejects(new Error('migrations pending')) + const ops = loadOperations(stubs) + const warn = sinon.stub(console, 'warn') + let outcome + try { + outcome = await ops.updateModules(services, null, { all: true }) + } finally { warn.restore() } + + expect(outcome.failed.map(f => f.module)).to.deep.equal(['xchain-hub']) + expect(outcome.updated).to.have.length(3) + }) + + it('still stops a targeted update at the first refusal', async function () { + const stubs = makeStubs() + refuseFirst(stubs, 'update refused: xchain-indexer requires a newer hub') + const ops = loadOperations(stubs) + let err + try { + await ops.updateModules({ bitcoin: { regtest: ['xchain-indexer', 'xchain-encoder'] } }) + } catch (caught) { err = caught } + + expect(err).to.be.an('error') + expect(err.message).to.match(/update refused/) + expect(stubs.installModule.callCount).to.equal(0) + }) +}) diff --git a/test/unit/module_operations.test/20_update_partial_outcome.test.js b/test/unit/module_operations.test/update/20_update_partial_outcome.test.js similarity index 99% rename from test/unit/module_operations.test/20_update_partial_outcome.test.js rename to test/unit/module_operations.test/update/20_update_partial_outcome.test.js index 1462cf15..b81b0f48 100644 --- a/test/unit/module_operations.test/20_update_partial_outcome.test.js +++ b/test/unit/module_operations.test/update/20_update_partial_outcome.test.js @@ -11,7 +11,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks } = require('./helpers/harness') +const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks } = require('../helpers/harness') describe('moduleOperations updateModules(): partial update continuation', function () { registerLifecycleHooks(() => {}) diff --git a/test/unit/module_operations.test/update/21_update_all_honours_node_version_pin.test.js b/test/unit/module_operations.test/update/21_update_all_honours_node_version_pin.test.js new file mode 100644 index 00000000..3a8ae6cd --- /dev/null +++ b/test/unit/module_operations.test/update/21_update_all_honours_node_version_pin.test.js @@ -0,0 +1,69 @@ +'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. + +// `update all` leaves a coin node running only when it already carries the +// daemon an update would install, and with XCHAIN_NODE_NODE_VERSION_ set +// that daemon is the pin, not the latest release. + +const { sinon, expect, makeStubs, loadOperations, registerLifecycleHooks } = require('../helpers/harness') +const fs = require('fs') +const os = require('os') +const path = require('path') + +const PIN_ENV = 'XCHAIN_NODE_NODE_VERSION_BITCOIN' + +describe('moduleOperations', function () { + let dataDir, previousDataDir + beforeEach(function () { + previousDataDir = process.env.XCHAIN_NODE_DATA_DIR + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xchain-node-update-pin-')) + process.env.XCHAIN_NODE_DATA_DIR = dataDir + }) + afterEach(function () { + delete process.env[PIN_ENV] + try { + fs.rmSync(dataDir, { recursive: true, force: true }) + } finally { + if (previousDataDir === undefined) delete process.env.XCHAIN_NODE_DATA_DIR + else process.env.XCHAIN_NODE_DATA_DIR = previousDataDir + } + }) + registerLifecycleHooks(() => {}) + + describe('updateModules(): `all` against a daemon version pin', function () { + + it('leaves a coin node running when it carries the pinned daemon, though a newer release exists', async function () { + process.env[PIN_ENV] = 'v27.0' + const stubs = makeStubs() + stubs.getLastStatus = () => ({ bitcoin: { mainnet: { node: { container_version: '27.0\n' } } } }) + stubs.getRemoteModuleVersions = () => ({ 'node-bitcoin': { tag_name: 'v28.1' } }) + const ops = loadOperations(stubs) + const log = sinon.stub(console, 'log') + let result + try { + result = await ops.updateModules({ bitcoin: { mainnet: ['node'] } }, 'v0.11.0', { all: true }) + } finally { log.restore() } + expect(stubs.installModule.calledWith('node')).to.be.false + expect(result.skipped).to.deep.include({ module: 'node', coin: 'bitcoin', network: 'mainnet', reason: 'current' }) + }) + + it('rebuilds a coin node running the latest release when the pin names another daemon', async function () { + process.env[PIN_ENV] = 'v27.0' + const stubs = makeStubs() + stubs.getLastStatus = () => ({ bitcoin: { mainnet: { node: { container_version: '28.1' } } } }) + stubs.getRemoteModuleVersions = () => ({ 'node-bitcoin': { tag_name: 'v28.1' } }) + const ops = loadOperations(stubs) + await ops.updateModules({ bitcoin: { mainnet: ['node'] } }, 'v0.11.0', { all: true }) + expect(stubs.installModule.calledWith('node', 'bitcoin', 'mainnet', true)).to.be.true + }) + }) +}) diff --git a/test/unit/module_service.test/01_buildandup.test.js b/test/unit/module_service.test/01_buildandup.test.js index 309c4935..2980dfd3 100644 --- a/test/unit/module_service.test/01_buildandup.test.js +++ b/test/unit/module_service.test/01_buildandup.test.js @@ -72,6 +72,13 @@ moduleSuite('buildAndUp()', function () { expect(buildArgs).to.include('xchain-node-bitcoin-mainnet-xchain-encoder') }) + it('does not inspect or inherit a previous container on the normal build path', async function () { + const stubs = makeStubs(), seen = stubDockerCreate(stubs) + const ms = loadModuleService(stubs) + await ms.buildAndUp('xchain-encoder', 'bitcoin', 'mainnet') + expect(seen.some(call => call.args.includes('{{json .Config.Env}}'))).to.equal(false) + }) + }) moduleSuite('buildAndUp()', function () {it('passes environment variables via the child env (bare --env NAME), not as argv values', async function () { diff --git a/test/unit/module_service.test/03_buildandup_healthcheck_args.test.js b/test/unit/module_service.test/03_buildandup_healthcheck_args.test.js index 98e27a47..b54b6a19 100644 --- a/test/unit/module_service.test/03_buildandup_healthcheck_args.test.js +++ b/test/unit/module_service.test/03_buildandup_healthcheck_args.test.js @@ -173,6 +173,10 @@ moduleSuite('buildAndUp() healthcheck args', function () { describe('dependency- ).to.be.at.least(startPeriodSeconds(tracker)) }) + it('does not enrol the encoder in autoheal, since a tracker halt 503s its probe', function () { + expect(loadModuleService(makeStubs()).SERVICE_HEALTHCHECK['xchain-encoder'].autoheal).to.not.equal(true) + }) + it('grants the hub and the explorer at least the DB start period their probes SELECT 1 against', function () { const ms = loadModuleService(makeStubs()) const dbSeconds = startPeriodSeconds(['--health-start-period', DEPENDENCY_HEALTH_START_PERIOD]) diff --git a/test/unit/module_service.test/08_installmodule_node_checkremotenodeversion_called_when_coin_not_in_remoteversions.test.js b/test/unit/module_service.test/08_installmodule_node_checkremotenodeversion_called_when_coin_not_in_remoteversions.test.js index 4b19a02b..0d9eb514 100644 --- a/test/unit/module_service.test/08_installmodule_node_checkremotenodeversion_called_when_coin_not_in_remoteversions.test.js +++ b/test/unit/module_service.test/08_installmodule_node_checkremotenodeversion_called_when_coin_not_in_remoteversions.test.js @@ -274,18 +274,17 @@ moduleSuite('unit-suite venue independence', function () { 'child_process': { execFile: stubs.execFile }, './docker_service': { getPublishedHostPorts: stubs.getPublishedHostPorts } }) - // The flip must not outlive that one load: a plain proxyquire with the same - // partial stub has to shadow the whole dependency, so the missing probe reads - // as undefined rather than falling back to the real (here guarded) export. - const ms = proxyquire('../../../src/services/module_service', { - 'child_process': { execFile: stubs.execFile }, - 'fs': stubs.fs, - '../state': { db: stubs.db, getRemoteModuleVersions: () => ({}), getLastStatus: () => null }, - './docker_service': { killContainer: stubs.killContainer } + // The flip must not outlive that one load: a plain proxyquire with a partial + // stub has to shadow the whole dependency, so the missing probe reads as + // undefined rather than falling back to the real (here guarded) export. + // Load docker_args directly because module_service deliberately supplies its + // own captured probe when an injected dependency is absent. + const dockerArgs = proxyquire('../../../src/services/module_service/docker_args', { + '../docker_service': { killContainer: stubs.killContainer } }) let threw = null try { - await ms.assertNoHostPortConflicts(['-p', '3001:3001'], 'self') + await dockerArgs.assertNoHostPortConflicts(['-p', '3001:3001'], 'self') } catch (err) { threw = err } expect(threw).to.be.an.instanceOf(TypeError) }) diff --git a/test/unit/module_service.test/10_buildandup_rollcall_wiring_guard.test.js b/test/unit/module_service.test/10_buildandup_rollcall_wiring_guard.test.js index 7af076c8..d59e7da6 100644 --- a/test/unit/module_service.test/10_buildandup_rollcall_wiring_guard.test.js +++ b/test/unit/module_service.test/10_buildandup_rollcall_wiring_guard.test.js @@ -16,17 +16,21 @@ // wiring: a refusal reaches the caller with no docker call made, and a one-shot // execution container is exempt. -const { sinon, expect, makeStubs, makeConfigServiceStub, loadModuleService, moduleSuite } = require('./support/helpers') +const { + sinon, expect, makeStubs, makeConfigServiceStub, loadModuleService, + dockerEnvInspectOutput, moduleSuite +} = require('./support/helpers') // A docker fake that records every call and answers the create path. The // indexer stages its bundled xchain-vm with cpSync, so the fixture fs gets one. -function recordDocker(stubs) { +function recordDocker(stubs, containerEnv = null) { const seen = [] stubs.fs.cpSync = sinon.stub() stubs.execFile.callsFake((cmd, args, ...rest) => { const cb = typeof rest[0] === 'function' ? rest[0] : rest[1] seen.push({ cmd, args }) - if (args[0] === 'build') cb(null) + if (args.includes('{{json .Config.Env}}')) cb(null, dockerEnvInspectOutput(containerEnv || {})) + else if (args[0] === 'build') cb(null) else if (args[0] === 'run') cb(null, 'f'.repeat(64) + '\n') else cb(null, '') }) @@ -73,4 +77,17 @@ moduleSuite('buildAndUp() ROLLCALL wiring guard', function () { await ms.buildAndUp('xchain-indexer', 'bitcoin', 'mainnet') expect(seen.some(c => c.args[0] === 'run')).to.equal(true) }) + + it('checks the carried recreate env instead of refusing a shell-only DOGE read', async function () { + const stubs = makeStubs() + const seen = recordDocker(stubs, { + DOGE_INDEXER_API_URL: 'preserved-value' + }) + const ms = loadWithoutDogeRead(stubs) + await ms.buildAndUp( + 'xchain-indexer', 'bitcoin', 'mainnet', 'old-indexer-id', false, null, + { reuseImage: true } + ) + expect(seen.some(c => c.args[0] === 'run')).to.equal(true) + }) }) diff --git a/test/unit/module_service.test/11_port_spec_grammar.test.js b/test/unit/module_service.test/11_port_spec_grammar.test.js index 2803b1b1..bb69a104 100644 --- a/test/unit/module_service.test/11_port_spec_grammar.test.js +++ b/test/unit/module_service.test/11_port_spec_grammar.test.js @@ -13,7 +13,7 @@ // Pins the one `-p` grammar the port validator and the host-port conflict // check share: split on the last colon, host port is the field before it. -const { expect, proxyquire, moduleSuite } = require('./support/helpers') +const { sinon, expect, proxyquire, moduleSuite } = require('./support/helpers') const { validatePort } = require('../../../src/services/config_service/validation') const { parsePortSpec } = require('../../../src/services/module_service/docker_args') // Load a fresh copy so a sibling suite's configureDependencies cannot swap validatePort. @@ -21,6 +21,36 @@ const { validatePortArgs } = proxyquire('../../../src/services/module_service/bu '../config_service': { validatePort } }) +moduleSuite('configureDependencies()', function () { + it('configureDependencies keeps its own getPublishedHostPorts and getUtxoTrackerVolumeName when the injected value is absent', async function () { + const ownGetUtxoTrackerVolumeName = sinon.stub().returns('own-tracker-volume') + const ownGetPublishedHostPorts = sinon.stub().resolves(new Map()) + const dockerArgs = proxyquire('../../../src/services/module_service/docker_args', { + '../config_service': { getUtxoTrackerVolumeName: ownGetUtxoTrackerVolumeName }, + '../docker_service': { getPublishedHostPorts: ownGetPublishedHostPorts } + }) + + dockerArgs.configureDependencies({ + SERVICE_REGISTRY: { + tracker: { + docker: { + volumes: [{ hostFn: 'utxoTrackerVolume', container: '/data' }] + } + } + }, + getUtxoTrackerVolumeName: undefined, + getPublishedHostPorts: undefined + }) + + const built = dockerArgs.buildModuleDockerArgs('tracker', {}, 'bitcoin', 'mainnet') + expect(built.volumeArgs).to.deep.equal(['-v', 'own-tracker-volume:/data']) + expect(ownGetUtxoTrackerVolumeName.calledOnceWithExactly('bitcoin', 'mainnet')).to.equal(true) + + await dockerArgs.assertNoHostPortConflicts(['-p', '8080:80'], 'self') + expect(ownGetPublishedHostPorts.calledOnceWithExactly()).to.equal(true) + }) +}) + moduleSuite('parsePortSpec()', function () { it('splits HOST:CONTAINER with an empty ip', function () { expect(parsePortSpec('8080:80')).to.deep.equal({ ip: '', hostPort: '8080', containerPort: '80' }) diff --git a/test/unit/module_service.test/12_buildandup_shutdown_timeout_env.test.js b/test/unit/module_service.test/12_buildandup_shutdown_timeout_env.test.js index 36d0b1e9..db15c2b7 100644 --- a/test/unit/module_service.test/12_buildandup_shutdown_timeout_env.test.js +++ b/test/unit/module_service.test/12_buildandup_shutdown_timeout_env.test.js @@ -68,6 +68,11 @@ moduleSuite('buildAndUp() SHUTDOWN_TIMEOUT_MS', function () { expect(run.opts.env.SHUTDOWN_TIMEOUT_MS).to.equal('45000') }) + it('replaces an explicit SHUTDOWN_TIMEOUT_MS the service would ignore with the derived drain', async function () { + const run = await createWithConfig('xchain-decoder', { SHUTDOWN_TIMEOUT_MS: 'slow' }) + expect(run.opts.env.SHUTDOWN_TIMEOUT_MS).to.equal('100000') + }) + it('gives a one-shot execution container no drain, as it gets no stop budget', async function () { const run = await createWithConfig('xchain-decoder', null, true) expect(run.args).to.not.include('--stop-timeout') diff --git a/test/unit/module_service.test/15_installmodule_node_missing_remote_release.test.js b/test/unit/module_service.test/15_installmodule_node_missing_remote_release.test.js new file mode 100644 index 00000000..720a8bb2 --- /dev/null +++ b/test/unit/module_service.test/15_installmodule_node_missing_remote_release.test.js @@ -0,0 +1,48 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { sinon, expect, makeStubs, loadModuleService, moduleSuite } = require('./support/helpers') + +// Loads ModuleService with the given remote node entry and a spy download. +function loadWithRemoteEntry(entry, getCryptoNode) { + const stubs = makeStubs() + return loadModuleService(stubs, undefined, { + '../state': { + db: stubs.db, + getRemoteModuleVersions: () => ({ 'node-bitcoin': entry }), + getLastStatus: () => null + }, + './node_service': { + buildCryptoNode: sinon.stub().resolves(true), + getCryptoNode + } + }) +} + +moduleSuite('installModule(): node: no remote release to download', function () { + // A null entry is what checkRemoteNodeVersion stores for a coin it has no + // release source for; an entry without a tag is the same missing answer. + for (const [label, entry] of [['a null entry', null], ['an entry with no tag_name', {}]]) { + it(`refuses by name, never with a TypeError, for ${label}`, async function () { + const getCryptoNode = sinon.stub().resolves() + const ms = loadWithRemoteEntry(entry, getCryptoNode) + try { + await ms.installModule('node', 'bitcoin', 'mainnet', false) + expect.fail('should have thrown') + } catch (err) { + expect(err).to.not.be.instanceOf(TypeError) + expect(err.message).to.include('There is no valid version to download for the bitcoin/mainnet node') + } + expect(getCryptoNode.called).to.equal(false) + }) + } +}) diff --git a/test/unit/module_service.test/16_installmodule_node_honours_version_pin.test.js b/test/unit/module_service.test/16_installmodule_node_honours_version_pin.test.js new file mode 100644 index 00000000..aa169172 --- /dev/null +++ b/test/unit/module_service.test/16_installmodule_node_honours_version_pin.test.js @@ -0,0 +1,86 @@ +'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 CLI install and update reach the daemon through installModule('node'), +// so XCHAIN_NODE_NODE_VERSION_ has to bind there exactly as it does for +// the menu's installNode, or the pin a harness sets is silently ignored. + +const { sinon, expect, makeStubs, loadModuleService, moduleSuite } = require('./support/helpers') + +const PIN_ENV = 'XCHAIN_NODE_NODE_VERSION_BITCOIN' + +// Loads ModuleService with a remote latest of v28.1 and the given cached daemon. +function loadWith(localVersion) { + const stubs = makeStubs() + const getCryptoNode = sinon.stub().resolves() + const checkRemoteNodeVersion = sinon.stub().resolves() + const ms = loadModuleService(stubs, undefined, { + '../state': { + db: stubs.db, + getRemoteModuleVersions: () => ({ 'node-bitcoin': { tag_name: 'v28.1' } }), + getLastStatus: () => null + }, + './version_service': { + getLocalNodeVersion: sinon.stub().resolves(localVersion), + getLocalModuleVersion: sinon.stub().resolves(null), + checkRemoteNodeVersion + }, + './node_service': { + buildCryptoNode: sinon.stub().resolves(true), + getCryptoNode + } + }) + return { ms, getCryptoNode, checkRemoteNodeVersion } +} + +moduleSuite('installModule(): node: XCHAIN_NODE_NODE_VERSION_ pin', function () { + afterEach(function () { delete process.env[PIN_ENV] }) + + it('an update downloads the pinned tag, not the latest release', async function () { + process.env[PIN_ENV] = 'v27.0' + const { ms, getCryptoNode } = loadWith('28.1\n') + await ms.installModule('node', 'bitcoin', 'mainnet', true) + expect(getCryptoNode.calledOnceWith('bitcoin', 'mainnet', 'v27.0')).to.equal(true) + }) + + it('a fresh install downloads the pinned tag', async function () { + process.env[PIN_ENV] = 'v27.0' + const { ms, getCryptoNode } = loadWith(null) + await ms.installModule('node', 'bitcoin', 'mainnet', false) + expect(getCryptoNode.calledOnceWith('bitcoin', 'mainnet', 'v27.0')).to.equal(true) + }) + + it('refuses to reuse a cached daemon that differs from the pin', async function () { + process.env[PIN_ENV] = 'v27.0' + const { ms, getCryptoNode } = loadWith('28.1\n') + try { + await ms.installModule('node', 'bitcoin', 'mainnet', false) + expect.fail('should have thrown') + } catch (err) { + expect(err.message).to.match(/pins v27\.0/) + } + expect(getCryptoNode.called).to.equal(false) + }) + + it('reuses a cached bare-version daemon that matches a v-tagged pin', async function () { + process.env[PIN_ENV] = 'v28.1' + const { ms, getCryptoNode } = loadWith('28.1\n') + expect(await ms.installModule('node', 'bitcoin', 'mainnet', false)).to.equal(true) + expect(getCryptoNode.called).to.equal(false) + }) + + it('with no pin, an update still takes the latest release', async function () { + const { ms, getCryptoNode } = loadWith('27.0\n') + await ms.installModule('node', 'bitcoin', 'mainnet', true) + expect(getCryptoNode.calledOnceWith('bitcoin', 'mainnet', 'v28.1')).to.equal(true) + }) +}) diff --git a/test/unit/module_service.test/17_recreate_carries_container_env.test.js b/test/unit/module_service.test/17_recreate_carries_container_env.test.js new file mode 100644 index 00000000..6809ddc8 --- /dev/null +++ b/test/unit/module_service.test/17_recreate_carries_container_env.test.js @@ -0,0 +1,151 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { + sinon, expect, makeStubs, makeConfigServiceStub, loadModuleService, + dockerEnvInspectOutput, moduleSuite +} = require('./support/helpers') +const { + readContainerEnv, + carryContainerEnv +} = require('../../../src/services/module_service/carry_container_env') + +function recordRecreate(stubs, liveEnv, events = []) { + const seen = [] + stubs.stopContainerByName.callsFake(async () => { + events.push('stop') + return { stopped: true, seconds: 1, killed: false } + }) + stubs.execFile.callsFake((cmd, args, ...rest) => { + const opts = typeof rest[0] === 'function' ? {} : (rest[0] || {}) + const cb = typeof rest[0] === 'function' ? rest[0] : rest[1] + seen.push({ cmd, args, opts }) + if (args.includes('{{json .Config.Env}}')) { + events.push('inspect-env') + cb(null, dockerEnvInspectOutput(liveEnv)) + } else if (args[0] === 'run') { + events.push('run') + cb(null, 'e'.repeat(64) + '\n') + } else { + cb(null, 'image-id\n') + } + }) + return seen +} + +describe('CarryContainerEnv', function () { + + it('parses empty and equals-bearing values without exposing or truncating them', async function () { + const execFileAsync = sinon.stub().resolves({ + stdout: dockerEnvInspectOutput({ AUX_POW: '1', EMPTY: '', TOKEN: 'left=right=tail' }) + }) + expect(await readContainerEnv('old-id', { execFileAsync })).to.deep.equal({ + AUX_POW: '1', EMPTY: '', TOKEN: 'left=right=tail' + }) + }) + + it('lets values from current config replace stale values from the old container', async function () { + const execFileAsync = sinon.stub().resolves({ + stdout: dockerEnvInspectOutput({ AUX_POW: '1', NETWORK: 'dogecoin-regtest' }) + }) + const merged = await carryContainerEnv( + { NETWORK: 'dogecoin-mainnet' }, + { reuseImage: true, overwriteContainerId: 'old-id' }, + { execFileAsync } + ) + expect(merged).to.deep.equal({ AUX_POW: '1', NETWORK: 'dogecoin-mainnet' }) + }) + + it('returns current config unchanged when inspect fails or this is not a recreate', async function () { + const current = { NETWORK: 'bitcoin-mainnet' } + const failedInspect = sinon.stub().rejects(new Error('daemon unavailable')) + expect(await carryContainerEnv( + current, + { reuseImage: true, overwriteContainerId: 'old-id' }, + { execFileAsync: failedInspect } + )).to.equal(current) + + const mustNotRun = sinon.stub().rejects(new Error('must not inspect')) + expect(await carryContainerEnv( + current, + { reuseImage: false, overwriteContainerId: 'old-id' }, + { execFileAsync: mustNotRun } + )).to.equal(current) + expect(mustNotRun.called).to.equal(false) + }) + + it('tries the deterministic name when a stale registry id no longer exists', async function () { + const execFileAsync = sinon.stub().callsFake(async (cmd, args) => { + if (args[args.length - 1] === 'stale-id') throw new Error('No such container') + return { stdout: dockerEnvInspectOutput({ AUX_POW: '1' }) } + }) + const merged = await carryContainerEnv( + { NETWORK: 'dogecoin-regtest' }, + { reuseImage: true, overwriteContainerId: 'stale-id', containerName: 'rail-decoder' }, + { execFileAsync } + ) + expect(merged.AUX_POW).to.equal('1') + expect(execFileAsync.secondCall.args[1]).to.include('rail-decoder') + }) +}) + +moduleSuite('buildAndUp() recreate env carry', function () { + + it('carries all shell-only rail values before teardown and keeps them off argv', async function () { + const stubs = makeStubs() + const events = [] + const seen = recordRecreate(stubs, { + AUX_POW: '1', + XC_ROLLCALL_REGTEST_ACTIVATION: '220', + HUB_SYNC_ANCHOR_ATTEST_GRACE_S: '0', + RAIL_VALUE_WITH_EQUALS: 'left=right', + NETWORK: 'stale-network' + }, events) + const configService = makeConfigServiceStub() + configService.getDefaultConfig = sinon.stub().resolves({ + NETWORK: 'dogecoin-regtest', + ENCODER_PORT: 3003, + ENCODER_API_PORT: 3003 + }) + const ms = loadModuleService(stubs, undefined, { './config_service': configService }) + + await ms.buildAndUp( + 'xchain-encoder', 'dogecoin', 'regtest', 'old-container-id', false, null, + { reuseImage: true } + ) + + const envInspect = seen.find(call => call.args.includes('{{json .Config.Env}}')) + expect(envInspect.args[envInspect.args.length - 1]).to.equal('old-container-id') + expect(events.indexOf('inspect-env')).to.be.lessThan(events.indexOf('stop')) + const run = seen.find(call => call.args[0] === 'run') + expect(run.opts.env.AUX_POW).to.equal('1') + expect(run.opts.env.XC_ROLLCALL_REGTEST_ACTIVATION).to.equal('220') + expect(run.opts.env.HUB_SYNC_ANCHOR_ATTEST_GRACE_S).to.equal('0') + expect(run.opts.env.RAIL_VALUE_WITH_EQUALS).to.equal('left=right') + expect(run.opts.env.NETWORK).to.equal('dogecoin-regtest') + expect(run.args).to.not.include('AUX_POW=1') + expect(run.args).to.include.members(['--env', 'AUX_POW']) + }) + + it('falls back to the container name when the registry lost its id', async function () { + const stubs = makeStubs() + const seen = recordRecreate(stubs, { AUX_POW: '1' }) + const ms = loadModuleService(stubs) + await ms.buildAndUp( + 'xchain-encoder', 'bitcoin', 'mainnet', null, false, null, + { reuseImage: true } + ) + const envInspect = seen.find(call => call.args.includes('{{json .Config.Env}}')) + expect(envInspect.args[envInspect.args.length - 1]) + .to.equal('xchain-node-bitcoin-mainnet-xchain-encoder') + }) +}) diff --git a/test/unit/module_service.test/18_healthcheck_failure_output.test.js b/test/unit/module_service.test/18_healthcheck_failure_output.test.js new file mode 100644 index 00000000..d820ac19 --- /dev/null +++ b/test/unit/module_service.test/18_healthcheck_failure_output.test.js @@ -0,0 +1,100 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const http = require('http') +const { spawn } = require('child_process') +const { expect } = require('./support/helpers') +const { healthcheckCommand } = require('../../../src/services/module_service/healthcheck_command') + +function readBody(request) { + return new Promise((resolve, reject) => { + let body = '' + request.setEncoding('utf8') + request.on('data', chunk => { body += chunk }) + request.on('end', () => resolve(body)) + request.on('error', reject) + }) +} + +function runCommand(command) { + return new Promise(resolve => { + const child = spawn('sh', ['-c', command]) + let stdout = '' + child.stdout.on('data', chunk => { stdout += chunk }) + child.on('close', code => resolve({ code, stdout })) + }) +} + +function listen(server) { + return new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) +} + +function close(server) { + return new Promise(resolve => server.close(resolve)) +} + +describe('healthcheckCommand() failure output', function () { + let server + let requests + let baseUrl + + beforeEach(async function () { + requests = [] + server = http.createServer(async (request, response) => { + requests.push({ method: request.method, body: await readBody(request) }) + response.writeHead(503, { 'Content-Type': 'application/json' }) + response.end('{"status":"degraded","reason":"oracle_stale"}') + }) + await listen(server) + baseUrl = `http://127.0.0.1:${server.address().port}` + }) + + afterEach(async function () { + await close(server) + }) + + it('prints a failed GET response status and degraded reason', async function () { + const command = healthcheckCommand({ url: `${baseUrl}/live`, timeoutSeconds: 5 }) + const result = await runCommand(command) + expect(result.code).to.equal(1) + expect(result.stdout).to.contain('HTTP 503') + expect(result.stdout).to.contain('oracle_stale') + }) + + it('replays a failed JSON-RPC health POST for diagnostics', async function () { + const postData = '{"jsonrpc":"2.0","method":"health","id":1}' + const command = healthcheckCommand({ url: `${baseUrl}/`, postData, timeoutSeconds: 5 }) + const result = await runCommand(command) + expect(result.code).to.equal(1) + expect(result.stdout).to.contain('HTTP 503') + expect(result.stdout).to.contain('oracle_stale') + const posts = requests.filter(request => request.method === 'POST') + expect(posts.length).to.be.greaterThan(0) + expect(posts.every(request => JSON.parse(request.body).method === 'health')).to.be.true + }) + + it('prints an error line when the endpoint is unreachable', async function () { + const port = server.address().port + await close(server) + server = http.createServer() + const command = healthcheckCommand({ url: `http://127.0.0.1:${port}/`, timeoutSeconds: 1 }) + const result = await runCommand(command) + expect(result.code).to.equal(1) + expect(result.stdout).to.contain('health probe failed:') + }) + + it('keeps the wget timeout prefix and exact GET path', function () { + const command = healthcheckCommand({ url: `${baseUrl}/live`, timeoutSeconds: 5 }) + expect(command).to.match(/^wget -T 5 /) + expect(command).to.not.contain('/status') + }) +}) diff --git a/test/unit/module_service.test/support/helpers.js b/test/unit/module_service.test/support/helpers.js index 60057983..27fa9dbd 100644 --- a/test/unit/module_service.test/support/helpers.js +++ b/test/unit/module_service.test/support/helpers.js @@ -227,6 +227,10 @@ function runArgsOf(seen) { return run ? run.args : null } +function dockerEnvInspectOutput(env) { + return JSON.stringify(Object.entries(env).map(([key, value]) => `${key}=${value}`)) + '\n' +} + // What the readback reports, in bytes. 'requested' echoes the cap the create // actually asked for, which the service derives from the RAM of whatever host // runs the suite and so cannot be written as a literal here. @@ -332,5 +336,5 @@ module.exports = { sinon, configStub, expect, proxyquire, modulesUrls, XChainService, DEFAULT_NODE_PREFIX, DEPENDENCY_HEALTH_START_PERIOD, makeStubs, makeConfigServiceStub, loadModuleService, stubDockerCreate, runArgsOf, inspectMemoryBytes, - captureConsole, proxyquireCallThru, moduleSuite + dockerEnvInspectOutput, captureConsole, proxyquireCallThru, moduleSuite } diff --git a/test/unit/nightly_e2e_workflow.test/sharding.test.js b/test/unit/nightly_e2e_workflow.test/sharding.test.js index b9b6a9eb..31212424 100644 --- a/test/unit/nightly_e2e_workflow.test/sharding.test.js +++ b/test/unit/nightly_e2e_workflow.test/sharding.test.js @@ -122,6 +122,12 @@ function registerWorkflowChecks() { expect(doc.jobs['e2e-shard'].strategy.matrix).to.have.keys('coin', 'shard') }) + it('plans six shards per coin, for 18 concurrent runners across the full matrix', function () { + const shardsPerCoin = Number(doc.jobs.plan.env.E2E_SHARDS) + expect(shardsPerCoin).to.equal(6) + expect(shardsPerCoin * 3).to.equal(18) + }) + it('runs the aggregate after the shards even when one failed, and fails it per coin', function () { expect(doc.jobs.e2e.needs).to.include('e2e-shard') expect(doc.jobs.e2e.if).to.match(/!cancelled\(\)/) diff --git a/test/unit/node_service.test/blocks_dir_persistence.test.js b/test/unit/node_service.test/blocks_dir_persistence.test.js index 3ef8c7db..97d1d688 100644 --- a/test/unit/node_service.test/blocks_dir_persistence.test.js +++ b/test/unit/node_service.test/blocks_dir_persistence.test.js @@ -63,6 +63,7 @@ describe("NodeService: buildCryptoNode()", function () { expect(stubs.forceRemoveContainerByName.called).to.be.false expect(stubs.execFile.getCalls().some(c => c.args[1][0] === 'run')).to.be.false }) + }) }) diff --git a/test/unit/node_service.test/blocks_dir_source_drift.test.js b/test/unit/node_service.test/blocks_dir_source_drift.test.js new file mode 100644 index 00000000..7ae872af --- /dev/null +++ b/test/unit/node_service.test/blocks_dir_source_drift.test.js @@ -0,0 +1,37 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { sinon, expect, makeNodeServiceStubs, build } = require('./support/helpers') + +describe('NodeService: buildCryptoNode() datadir source guard', function () { + it('leaves the existing container untouched when the datadir source changes', async function () { + const stubs = makeNodeServiceStubs() + stubs.forceRemoveContainerByName = sinon.stub().resolves(true) + stubs.stopContainerByName = sinon.stub().resolves({ stopped: true, seconds: 1, killed: false }) + stubs.getContainerBindMounts = sinon.stub().resolves([ + { source: '/carrier-a/node/bitcoin/mainnet', destination: '/root/.bitcoin' } + ]) + + let threw = null + try { + await build(stubs, { envBlocksDir: null }) + } catch (err) { threw = err } + + expect(String(threw)).to.include('Refusing to replace container') + expect(String(threw)).to.include('bind mount source') + expect(String(threw)).to.include('/carrier-a/node/bitcoin/mainnet') + expect(String(threw)).to.include('/data/node/bitcoin/mainnet') + expect(stubs.stopContainerByName.called).to.be.false + expect(stubs.forceRemoveContainerByName.called).to.be.false + expect(stubs.execFile.getCalls().some(call => call.args[1][0] === 'run')).to.be.false + }) +}) diff --git a/test/unit/node_service.test/build_crypto_node.test.js b/test/unit/node_service.test/build_crypto_node.test.js index 233e93a3..9ad74777 100644 --- a/test/unit/node_service.test/build_crypto_node.test.js +++ b/test/unit/node_service.test/build_crypto_node.test.js @@ -121,6 +121,60 @@ describe("NodeService: buildCryptoNode()", function () { // The guard runs before the build, so `docker build` must NOT fire. expect(built).to.be.false }) + + // A config typo must cost no running node: refuse before stop, force-remove or build. + for (const [key, ports] of [ + ['NODE_EXPOSED_PORT', { NODE_EXPOSED_PORT: '3000/tcp', NODE_PORT: 8332 }], + ['NODE_EXPOSED_PORT', { NODE_EXPOSED_PORT: '99999', NODE_PORT: 8332 }], + ['NODE_PORT', { NODE_EXPOSED_PORT: 8333, NODE_PORT: 'abc' }] + ]) { + it(`refuses ${key}=${ports[key]} before touching the live container`, async function () { + const stopContainerByName = sinon.stub().resolves({ stopped: true, seconds: 1, killed: false }) + const forceRemoveContainerByName = sinon.stub().resolves(true) + const stubs = makeNodeServiceStubs({ + validatePort: require('../../../src/services/config_service/validation').validatePort, + getDefaultConfig: sinon.stub().resolves({ ...ports, NODE_USER: 'u', NODE_PASSWORD: 'p' }), + stopContainerByName, + forceRemoveContainerByName + }) + stubs.execFile.callsFake((cmd, args, opts, cb) => { + if (args[0] === 'build') return cb(null) + if (args[0] === 'run') return cb(null, 'f'.repeat(64) + '\n') + }) + + const ns = loadNodeService(stubs) + let threw = null + try { + await ns.buildCryptoNode('bitcoin', 'mainnet') + } catch (err) { threw = err } + expect(threw).to.be.an.instanceOf(Error) + expect(threw.message).to.include('Invalid port value in configuration: ' + key) + expect(stopContainerByName.called).to.be.false + expect(forceRemoveContainerByName.called).to.be.false + const dockerVerbs = stubs.execFile.getCalls().map(c => c.args[1][0]) + expect(dockerVerbs).to.not.include('build') + expect(dockerVerbs).to.not.include('run') + }) + } +}) + +describe("NodeService: buildCryptoNode()", function () { + + it('still publishes valid ports under the real validator', async function () { + const stubs = makeNodeServiceStubs({ + validatePort: require('../../../src/services/config_service/validation').validatePort + }) + stubs.execFile.callsFake((cmd, args, opts, cb) => { + if (args[0] === 'build') return cb(null) + if (args[0] === 'run') return cb(null, 'f'.repeat(64) + '\n') + }) + + const ns = loadNodeService(stubs) + await ns.buildCryptoNode('bitcoin', 'mainnet') + const runCall = stubs.execFile.getCalls().find(c => c.args[1][0] === 'run') + const argv = runCall.args[1] + expect(argv[argv.indexOf('-p') + 1]).to.equal('8333:8332') + }) }) describe("NodeService: buildCryptoNode()", function () { diff --git a/test/unit/node_service.test/install_node.test.js b/test/unit/node_service.test/install_node.test.js index 60431df5..e0721ae6 100644 --- a/test/unit/node_service.test/install_node.test.js +++ b/test/unit/node_service.test/install_node.test.js @@ -81,6 +81,24 @@ describe("NodeService: installNode()", function () { expect(err.message).to.match(/no valid version/) } }) + + // checkRemoteNodeVersion stores a null entry for a coin it has no release + // source for; the guard must cover the entry, not only its tag. + it('throws the no-valid-version error, not a TypeError, when the remote entry is null', async function () { + const stubs = makeNodeServiceStubs({ + getLocalNodeVersion: sinon.stub().resolves(null), + getRemoteModuleVersions: () => ({ 'node-bitcoin': null }) + }) + + const ns = loadNodeService(stubs) + try { + await ns.installNode('bitcoin', 'mainnet') + expect.fail('Should have thrown') + } catch (err) { + expect(err).to.not.be.instanceOf(TypeError) + expect(err.message).to.match(/no valid version/) + } + }) }) describe("NodeService: installNode()", function () { diff --git a/test/unit/node_service.test/node_version_pin.test.js b/test/unit/node_service.test/node_version_pin.test.js index 55785130..8771e746 100644 --- a/test/unit/node_service.test/node_version_pin.test.js +++ b/test/unit/node_service.test/node_version_pin.test.js @@ -41,6 +41,12 @@ describe("NodeService: node version pin (XCHAIN_NODE_NODE_VERSION_)", func expect(() => ns.assertNodeVersionPin('litecoin', 'regtest', 'v0.21.4', 'v0.21.4')).to.not.throw() expect(() => ns.assertNodeVersionPin('litecoin', 'regtest', null, 'v0.21.4')).to.not.throw() }) + + it('assertNodeVersionPin accepts the bare version file the bitcoin download writes', function () { + const ns = loadNodeService(makeNodeServiceStubs()) + expect(() => ns.assertNodeVersionPin('bitcoin', 'mainnet', '28.1\n', 'v28.1')).to.not.throw() + expect(() => ns.assertNodeVersionPin('bitcoin', 'mainnet', '28.1', 'v27.0')).to.throw(/pins v27\.0/) + }) }) describe("NodeService: node version pin (XCHAIN_NODE_NODE_VERSION_)", function () { diff --git a/test/unit/node_service.test/support/helpers.js b/test/unit/node_service.test/support/helpers.js index a9bc228a..f2c45629 100644 --- a/test/unit/node_service.test/support/helpers.js +++ b/test/unit/node_service.test/support/helpers.js @@ -115,7 +115,8 @@ function makeCoreDependencies(stubs) { getDockerContainerImageName: stubs.getDockerContainerImageName, getDockerNetwork: stubs.getDockerNetwork, getDefaultConfig: stubs.getDefaultConfig, - validatePort: () => true, + // Default: every port passes. A test passes the real validator to reach the refusal. + validatePort: stubs.validatePort || (() => true), // resolveBlocksDir sidecar persistence. Defaults: nothing // persisted, persistence is a no-op. Tests override to simulate a // config/node.local value. diff --git a/test/unit/publish_bootstraps_forced_republish.test.js b/test/unit/publish_bootstraps_forced_republish.test.js index eea8ee21..4fa4ab9c 100644 --- a/test/unit/publish_bootstraps_forced_republish.test.js +++ b/test/unit/publish_bootstraps_forced_republish.test.js @@ -45,6 +45,7 @@ function createWorkDir() { workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xchain-publish-')) binDir = path.join(workDir, 'bin') fs.mkdirSync(binDir) + fs.writeFileSync(path.join(binDir, 'flock'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 }) } function removeWorkDir() { @@ -56,18 +57,23 @@ function removeWorkDir() { * planner uses and nothing else. --dry-run exits before any create, so the * plan is the whole observable behaviour. */ -function fakeNode({ combos = [], due = [] } = {}) { +function fakeNode({ combos = [], due = [], createRefusalReason = null } = {}) { // Each line is emitted by its own printf so no escape sequence in the // fixture is ever interpreted by the shell; the hostile-input case below // depends on the fake echoing its combos back verbatim. const emit = list => list.length === 0 ? 'true' : list.map(c => `printf '%s\\n' ${JSON.stringify(c)}`).join('; ') + const create = createRefusalReason === null + ? 'echo "unexpected: $*" >&2; exit 3' + : `printf '%s\\n' 'Refusing to create a bootstrap: the source is not known-good.' ` + + `${JSON.stringify(` - ${createRefusalReason}`)} >&2; exit 1` const script = [ '#!/usr/bin/env bash', 'case "$1" in', ` bootstrap-combos) ${emit(combos)} ;;`, ` bootstrap-republish-due) ${emit(due)} ;;`, + ` bootstrap) [ "$2" = create ] && { ${create}; } ;;`, ' *) echo "unexpected: $*" >&2; exit 3 ;;', 'esac', 'exit 0', @@ -185,6 +191,18 @@ describe('publish-bootstraps.sh: forced republish after a reindex', function () expect(planLine(out)).to.include('xchain-utxo-tracker:bitcoin:testnet') }) + it('carries the reason through a refused forced tracker republish', function () { + fakeNode({ + combos: ['xchain-decoder:bitcoin:testnet'], + due: ['xchain-utxo-tracker:dogecoin:testnet'], + createRefusalReason: 'service status probe could not reach the tracker API' + }) + + expect(() => runPlan([ + '--all', '--allow-unsigned', '--no-publish', '--force-due-trackers' + ])).to.throw(/xchain-utxo-tracker:dogecoin:testnet: SOURCE-UNHEALTHY \(service status probe could not reach the tracker API\)/) + }) + it('--no-forced-due falls back to the schedule alone', function () { fakeNode({ combos: ['xchain-decoder:bitcoin:testnet', 'xchain-utxo-tracker:bitcoin:testnet'], diff --git a/test/unit/publish_bootstraps_refusal_reason.test.js b/test/unit/publish_bootstraps_refusal_reason.test.js new file mode 100644 index 00000000..a887a0bb --- /dev/null +++ b/test/unit/publish_bootstraps_refusal_reason.test.js @@ -0,0 +1,93 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const fs = require('fs') +const os = require('os') +const path = require('path') +const { spawnSync } = require('child_process') +const { expect } = require('chai') + +const SCRIPT = path.join(__dirname, '../../scripts/publish-bootstraps.sh') + +function runWithCreateOutput(binDir, workDir, lines) { + const fakeNode = path.join(binDir, 'xchain-node') + const body = lines.map(line => `printf '%s\\n' ${JSON.stringify(line)} >&2`).join('\n') + fs.writeFileSync(fakeNode, [ + '#!/usr/bin/env bash', + 'if [ "$1" = bootstrap ] && [ "$2" = create ]; then', + body, + ' exit 1', + 'fi', + 'echo "unexpected: $*" >&2', + 'exit 3', + '' + ].join('\n'), { mode: 0o755 }) + + return spawnSync(SCRIPT, [ + 'xchain-utxo-tracker:dogecoin:testnet', + '--with-trackers', + '--no-publish', + '--allow-unsigned', + '--no-forced-due' + ], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH}`, + STAGE_DIR: path.join(workDir, 'stage'), + TMP_DIR: path.join(workDir, 'tmp'), + LOCK_FILE: path.join(workDir, 'publish.lock'), + ROLL_HOLD_FILE: path.join(workDir, 'no-roll-hold') + } + }) +} + +describe('publish-bootstraps.sh: source-health refusal reason', function () { + this.timeout(10000) + + let workDir + let binDir + + before(function () { + if (spawnSync('bash', ['-c', 'mapfile -t x < /dev/null']).status !== 0) this.skip() + }) + + beforeEach(function () { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xchain-publish-refusal-')) + binDir = path.join(workDir, 'bin') + fs.mkdirSync(binDir) + fs.writeFileSync(path.join(binDir, 'flock'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 }) + }) + + afterEach(function () { + fs.rmSync(workDir, { recursive: true, force: true }) + }) + + it('keeps the health-gate reasons in the refusal and summary', function () { + const result = runWithCreateOutput(binDir, workDir, [ + 'Refusing to create a bootstrap from dogecoin/testnet xchain-utxo-tracker: the source is not known-good.', + ' - service status probe could not reach the tracker API', + ' - container health is unhealthy' + ]) + + expect(result.status).to.equal(1) + expect(result.stdout).to.include( + 'SOURCE-UNHEALTHY (service status probe could not reach the tracker API; container health is unhealthy)') + expect(result.stdout).to.include( + 'source is not known-good (service status probe could not reach the tracker API; container health is unhealthy)') + }) + + it('uses an explicit fallback when an older CLI emits no bullet reason', function () { + const result = runWithCreateOutput(binDir, workDir, [ + 'Refusing to create a bootstrap from dogecoin/testnet xchain-utxo-tracker' + ]) + + expect(result.status).to.equal(1) + expect(result.stdout).to.include( + 'SOURCE-UNHEALTHY (reason unavailable; inspect create output above)') + }) +}) diff --git a/test/unit/rail_leg_workflow.test/wait_indexer_schema.test.js b/test/unit/rail_leg_workflow.test/wait_indexer_schema.test.js new file mode 100644 index 00000000..53f510e4 --- /dev/null +++ b/test/unit/rail_leg_workflow.test/wait_indexer_schema.test.js @@ -0,0 +1,105 @@ +'use strict' + +// Copyright (c) 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const path = require('path') + +const HELPER = path.join(__dirname, '../../../.github/attest-mirror/wait-indexer-schema.js') +const ready = require(HELPER) + +function response (body, ok) { + return { ok: ok !== false, json: async () => body } +} + +function config (overrides) { + return Object.assign({ + statusUrl: 'http://127.0.0.1:1/status', + fetchImpl: async () => response({ schemaReady: true }), + dbOptions: { host: '127.0.0.1', port: 2, user: 'root', password: 'secret' }, + connect: async () => ({ query: async () => [{ n: 1 }], end: async () => {} }), + waitMs: 10, + intervalMs: 0, + now: Date.now, + sleep: async () => {}, + }, overrides || {}) +} + +describe('attest-mirror wait-indexer-schema.js', function () { + it('trusts schemaReady true without opening a database connection', async function () { + let connected = false + const value = await ready.schemaReady(config({ connect: async () => { connected = true } })) + expect(value).to.equal(true) + expect(connected).to.equal(false) + }) + + it('waits on schemaReady false even when the issues table exists', async function () { + let connected = false + const value = await ready.schemaReady(config({ + fetchImpl: async () => response({ schemaReady: false }), + connect: async () => { connected = true; return { query: async () => [{ n: 1 }] } }, + })) + expect(value).to.equal(false) + expect(connected).to.equal(false) + }) + + it('uses the issues table for an older status response without schemaReady', async function () { + let query = '' + let ended = false + const value = await ready.schemaReady(config({ + fetchImpl: async () => response({ isSynced: false }), + connect: async () => ({ + query: async (sql) => { query = sql; return [{ n: 1 }] }, + end: async () => { ended = true }, + }), + })) + expect(value).to.equal(true) + expect(query).to.include("table_schema = '" + ready.DATABASE + "'") + expect(query).to.include("table_name = '" + ready.TABLE + "'") + expect(ended).to.equal(true) + }) + + it('does not use the fallback when status is unavailable or malformed', async function () { + let connections = 0 + const connect = async () => { connections++; return { query: async () => [{ n: 1 }] } } + expect(await ready.schemaReady(config({ fetchImpl: async () => response({}, false), connect }))).to.equal(false) + const invalidJson = async () => ({ ok: true, json: async () => { throw new Error('bad json') } }) + expect(await ready.schemaReady(config({ fetchImpl: invalidJson, connect }))).to.equal(false) + expect(connections).to.equal(0) + }) +}) + +describe('attest-mirror wait-indexer-schema.js polling', function () { + it('polls until schemaReady changes from false to true', async function () { + let polls = 0 + let sleeps = 0 + const value = await ready.waitForSchema(config({ + fetchImpl: async () => response({ schemaReady: ++polls > 1 }), + sleep: async () => { sleeps++ }, + })) + expect(value).to.equal(true) + expect(polls).to.equal(2) + expect(sleeps).to.equal(1) + }) + + it('closes a fallback connection when the issues-table query fails', async function () { + let ended = false + const value = await ready.schemaReady(config({ + fetchImpl: async () => response({}), + connect: async () => ({ + query: async () => { throw new Error('query failed') }, + end: async () => { ended = true }, + }), + })) + expect(value).to.equal(false) + expect(ended).to.equal(true) + }) +}) diff --git a/test/unit/release_manifest_service.test/resolve_install_target.test.js b/test/unit/release_manifest_service.test/resolve_install_target.test.js index 66c00554..593d8cf2 100644 --- a/test/unit/release_manifest_service.test/resolve_install_target.test.js +++ b/test/unit/release_manifest_service.test/resolve_install_target.test.js @@ -13,6 +13,7 @@ const sinon = require('sinon') const { expect } = require('chai') const proxyquire = require('proxyquire').noCallThru() +const repoRefs = require('../../../src/operations/module_operations/repo_refs') const PIN_SHA = 'a'.repeat(40) const OTHER_SHA = 'b'.repeat(40) @@ -104,14 +105,23 @@ serviceSuite('resolveInstallTarget()', () => { }) serviceSuite('resolveInstallTarget()', () => { - it('falls back to the default branch when the lookup fails outright', async () => { - // Offline / rate-limited operators must still be able to install. - sinon.stub(console, 'warn') + it('fails closed on a thrown lookup error even when a branch fallback is allowed', async () => { + const recordInstallTarget = sinon.stub() + const run = sinon.stub() + repoRefs.configure({ + DEFAULT_MODULE_BRANCH: 'master', + installTargetService: { recordInstallTarget }, + releaseManifestService: svc + }) stubs.axiosGet.rejects(new Error('getaddrinfo ENOTFOUND')) - const t = await svc.resolveInstallTarget(null, { defaultBranch: 'master' }) - expect(t.kind).to.equal('branch') - expect(t.ref).to.equal('master') - expect(t.resolvedFrom).to.equal('fallback after lookup failure') + await repoRefs.withInstallTarget(null, run).then( + () => { throw new Error('should have rejected') }, + e => { + expect(e.message).to.match(/Could not resolve the latest xchain-node release/) + expect(e.message).to.match(/Nothing was changed/) + }) + expect(recordInstallTarget.called).to.equal(false) + expect(run.called).to.equal(false) }) // An UPDATE of a release node must never degrade to a branch: that diff --git a/test/unit/release_manifest_service.test/the_provenance_gate.test.js b/test/unit/release_manifest_service.test/the_provenance_gate.test.js index 6fbc0d79..53f10944 100644 --- a/test/unit/release_manifest_service.test/the_provenance_gate.test.js +++ b/test/unit/release_manifest_service.test/the_provenance_gate.test.js @@ -125,3 +125,34 @@ serviceSuite('the provenance gate', () => { expect(stubs.axiosGet.thirdCall.args[1].headers).to.not.have.property('Accept') }) }) + +// The release-asset hop is an api.github.com call, so it maps a rate limit like +// its siblings. These cases rely on the REAL githubRateLimitError: the service +// requires it from ../utils/github_api, which load() above does not stub. +serviceSuite('the provenance gate', () => { + function assetHopRejects(response) { + const err = new Error('Request failed with status code ' + response.status) + err.response = response + stubs.axiosGet.onCall(0).resolves({ data: { assets: [{ name: 'SHA256SUMS', url: 'https://api/asset/1' }] } }) + stubs.axiosGet.onCall(1).rejects(err) + return err + } + + it('fetchReleaseAsset reports a rate-limited asset hop with the reset time and GITHUB_TOKEN advice', async () => { + assetHopRejects({ status: 403, headers: { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '1783529945' } }) + await svc.fetchReleaseAsset('v0.9.0', 'SHA256SUMS').then( + () => { throw new Error('should have rejected') }, + e => { + expect(e.message).to.match(/rate limit exhausted/) + expect(e.message).to.match(/GITHUB_TOKEN/) + }) + expect(stubs.axiosGet.callCount).to.equal(2) + }) + + it('fetchReleaseAsset rethrows any other asset-hop failure unchanged', async () => { + const original = assetHopRejects({ status: 403, headers: { 'x-ratelimit-remaining': '42' } }) + await svc.fetchReleaseAsset('v0.9.0', 'SHA256SUMS').then( + () => { throw new Error('should have rejected') }, + e => expect(e).to.equal(original)) + }) +}) diff --git a/test/unit/self_update_lock_handoff.test.js b/test/unit/self_update_lock_handoff.test.js new file mode 100644 index 00000000..123261ad --- /dev/null +++ b/test/unit/self_update_lock_handoff.test.js @@ -0,0 +1,88 @@ +'use strict' + +const assert = require('assert') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { spawn } = require('child_process') + +const { acquireCommandLock, getLockFilePath } = require('../../src/utils/command_lock') +const { selfUpdateAndReexec } = require('../../src/services/self_update_service') + +function reexecProgram(lockModule) { + return ` + const { acquireCommandLock } = require(${JSON.stringify(lockModule)}) + try { + const release = acquireCommandLock({ command: 'update' }) + release() + process.exit(22) + } catch (err) { + process.exit(err && err.code === 'ELOCKHELD' ? 23 : 24) + } + ` +} + +function handoffDeps(parentRelease, childProgram, winGap, recordExit) { + return { + env: { ...process.env }, + logger: { log() {}, warn() {}, error() {} }, + currentVersion: () => '1.0.0', + describeCarrier: async () => ({ isRepo: true, commit: 'c'.repeat(40), dirty: [] }), + execFile: async () => ({ stdout: '' }), + verifyGitTagSignature: () => ({ fingerprint: 'F'.repeat(40) }), + signatureCheckDisabled: () => false, + spawn: (bin, argv, opts) => { + winGap() + return spawn(process.execPath, ['-e', childProgram], opts) + }, + exit: recordExit, + commandLock: parentRelease + } +} + +describe('self-update command lock handoff', function () { + this.timeout(10000) + + let lockDir + + beforeEach(function () { + lockDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xchain-lock-handoff-')) + process.env.XCHAIN_NODE_LOCK_DIR = lockDir + }) + + afterEach(function () { + delete process.env.XCHAIN_NODE_LOCK_DIR + fs.rmSync(lockDir, { recursive: true, force: true }) + }) + + it('refuses the re-exec update when a mutator wins the unlocked gap', async function () { + const lockModule = require.resolve('../../src/utils/command_lock') + const childProgram = reexecProgram(lockModule) + + const parentRelease = acquireCommandLock({ command: 'update (self-update)' }) + let contenderRelease = null + let exitCode = null + try { + await selfUpdateAndReexec({ + tag: 'v9.9.9', + childArgs: ['update'], + deps: handoffDeps( + parentRelease, + childProgram, + () => { contenderRelease = acquireCommandLock({ command: 'reset' }) }, + code => { exitCode = code } + ) + }) + assert.strictEqual(exitCode, 23, 'the re-exec child must refuse while reset owns the lock') + assert.strictEqual(JSON.parse(fs.readFileSync(getLockFilePath(), 'utf8')).command, 'reset') + parentRelease() + assert.strictEqual(fs.existsSync(getLockFilePath()), true, 'the old release must preserve the contender lock') + contenderRelease() + contenderRelease = null + assert.strictEqual(fs.existsSync(getLockFilePath()), false, 'the contender release removes its lock') + } finally { + parentRelease() + if (contenderRelease) contenderRelease() + } + }) +}) diff --git a/test/unit/self_update_service.test.js b/test/unit/self_update_service.test.js index 86b9f5bf..91e003d3 100644 --- a/test/unit/self_update_service.test.js +++ b/test/unit/self_update_service.test.js @@ -158,9 +158,10 @@ describe('SelfUpdateService', function () { afterEach(tearDownSelfUpdate) describe('selfUpdateAndReexec()', function () { - it('fetches, verifies the tag, checks it out, installs, hands back the lock and re-executes the explicit command', async function () { - const beforeSpawn = sinon.stub() - const d = deps({ beforeSpawn }) + it('fetches, verifies, installs, releases the lock and re-executes the explicit command', async function () { + const commandLock = sinon.stub() + const adopt = sinon.stub() + const d = deps({ commandLock, adoptCommandLock: adopt }) await svc.selfUpdateAndReexec({ tag: 'v0.15.2', childArgs: ['update', 'all', 'all', 'all', 'v0.15.2'], deps: d }) const calls = d.execFile.calls @@ -170,8 +171,8 @@ describe('SelfUpdateService', function () { expect(calls[1].slice(3)).to.deep.equal(['checkout', '--detach', '--quiet', 'v0.15.2']) expect(calls[2][0]).to.equal('npm') expect(calls[2]).to.include('install') - expect(beforeSpawn.calledOnce).to.equal(true) - expect(beforeSpawn.calledBefore(d.spawn)).to.equal(true) + expect(adopt.calledOnceWithExactly(commandLock)).to.equal(true) + expect(adopt.calledBefore(d.spawn)).to.equal(true) const [bin, argv, opts] = d.spawn.firstCall.args expect(bin).to.equal(process.execPath) @@ -180,6 +181,19 @@ describe('SelfUpdateService', function () { expect(opts.stdio).to.equal('inherit') expect(d.exit.calledWith(0)).to.equal(true) }) + + it('does not spawn the child when the parent lock cannot be released', async function () { + const adoptionError = new Error('ownership changed') + const d = deps({ + commandLock: sinon.stub(), + adoptCommandLock: sinon.stub().throws(adoptionError) + }) + let err = null + try { await svc.selfUpdateAndReexec({ tag: 'v0.15.2', childArgs: ['update'], deps: d }) } catch (e) { err = e } + expect(err).to.equal(adoptionError) + expect(d.spawn.called).to.equal(false) + expect(d.exit.called).to.equal(false) + }) }) }) diff --git a/test/unit/services/bootstrap_service/tracker_height.test.js b/test/unit/services/bootstrap_service/tracker_height.test.js new file mode 100644 index 00000000..0eb6fb18 --- /dev/null +++ b/test/unit/services/bootstrap_service/tracker_height.test.js @@ -0,0 +1,89 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const { expect } = require('chai') +const sinon = require('sinon') +const trackerHeight = require('../../../../src/services/bootstrap_service/tracker_height') + +const TRACKER_SERVICE = 'xchain-utxo-tracker' +const PORT_KEY = 'UTXO_TRACKER_API_PORT' + +function configureTrackerHeight(probeServiceStatus) { + const execFile = sinon.stub().callsFake((command, args, options, callback) => { + callback(null, { stdout: '', stderr: '' }) + }) + const getDefaultConfig = sinon.stub().resolves({ [PORT_KEY]: 8080 }) + const logger = { info: sinon.stub() } + + trackerHeight.configureDependencies({ + childProcess: { execFile }, + config: { XChainService: { XCHAIN_UTXO_TRACKER: TRACKER_SERVICE } }, + configService: { getDefaultConfig }, + bootstrapHealthGate: { + probeServiceStatus, + MODULE_API_PORT_KEY: { [TRACKER_SERVICE]: PORT_KEY } + }, + logger + }) + return { execFile, getDefaultConfig, logger } +} + +describe('tracker_height', function () { + let probeServiceStatus + let fakes + + beforeEach(function () { + probeServiceStatus = sinon.stub() + fakes = configureTrackerHeight(probeServiceStatus) + }) + + for (const [relationship, tracker, archive, expected] of [ + ['ahead of', 100, 105, 105], + ['equal to', 100, 100, 100], + ['behind', 100, 95, 100] + ]) { + it(`chooses safely when the archive height is ${relationship} the tracker height`, function () { + expect(trackerHeight.chooseArchiveHeight(tracker, archive)).to.equal(expected) + }) + } + + it('refuses when only the tracker height before the stop was read', function () { + expect(() => trackerHeight.chooseArchiveHeight(100, null)).to.throw(/refusing to record the pre-stop height 100/) + }) + + it('uses the archive height when the tracker height is missing', function () { + expect(trackerHeight.chooseArchiveHeight(null, 105)).to.equal(105) + }) + + it('returns null when both heights are missing', function () { + expect(trackerHeight.chooseArchiveHeight(null, null)).to.equal(null) + }) + + it('reads the committed height through configured fakes', async function () { + probeServiceStatus.resolves({ committed_height: 321 }) + + const height = await trackerHeight.readTrackerCommittedHeight('bitcoin', 'mainnet', 'container-id') + + expect(height).to.equal(321) + expect(fakes.getDefaultConfig.calledOnceWithExactly(TRACKER_SERVICE, 'bitcoin', 'mainnet')).to.equal(true) + expect(probeServiceStatus.firstCall.args.slice(0, 2)).to.deep.equal(['container-id', 8080]) + expect(fakes.execFile.called).to.equal(false) + }) + + it('retries the post-restart height through configured fakes', async function () { + probeServiceStatus.onFirstCall().rejects(new Error('not ready')) + probeServiceStatus.onSecondCall().resolves({ tracker_height: 322 }) + + const height = await trackerHeight.readTrackerHeightAfterRestart( + 'bitcoin', 'mainnet', 'container-id', { attempts: 2, delayMs: 0 } + ) + + expect(height).to.equal(322) + expect(probeServiceStatus.callCount).to.equal(2) + expect(fakes.execFile.called).to.equal(false) + }) +}) diff --git a/test/unit/services/github_downloader/host_arch.test.js b/test/unit/services/github_downloader/host_arch.test.js new file mode 100644 index 00000000..46d695ac --- /dev/null +++ b/test/unit/services/github_downloader/host_arch.test.js @@ -0,0 +1,41 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const { expect } = require('chai') +const { getHostArch } = require('../../../../src/services/github_downloader/host_arch') + +const originalArchDescriptor = Object.getOwnPropertyDescriptor(process, 'arch') + +function setProcessArch(arch) { + Object.defineProperty(process, 'arch', { ...originalArchDescriptor, value: arch }) +} + +describe('getHostArch', function () { + afterEach(function () { + Object.defineProperty(process, 'arch', originalArchDescriptor) + }) + + it('maps x64 to x86_64', function () { + setProcessArch('x64') + + expect(getHostArch()).to.equal('x86_64') + }) + + it('maps arm64 to aarch64', function () { + setProcessArch('arm64') + + expect(getHostArch()).to.equal('aarch64') + }) + + for (const arch of ['ia32', 'ppc64']) { + it(`rejects unsupported architecture ${arch}`, function () { + setProcessArch(arch) + + expect(getHostArch).to.throw(Error, `Unsupported host architecture for GitHub asset download: ${arch}`) + }) + } +}) diff --git a/test/unit/services/memory_override_applied.test.js b/test/unit/services/memory_override_applied.test.js new file mode 100644 index 00000000..fc61df57 --- /dev/null +++ b/test/unit/services/memory_override_applied.test.js @@ -0,0 +1,90 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const { + makeNodeServiceStubs, + loadNodeService +} = require('../node_service.test/support/helpers') +const { + makeStubs, + loadDatabaseService, + VALID_CONTAINER_ID +} = require('../database_service.test/helpers/harness') + +function dockerRunArgs(execFile) { + const call = execFile.getCalls().find(candidate => + candidate.args[0] === 'docker' && + Array.isArray(candidate.args[1]) && + candidate.args[1][0] === 'run') + return call && call.args[1] +} + +describe('container memory overrides', function () { + const envNames = [ + 'XCHAIN_NODE_MODULE_MEMORY_MB_NODE', + 'XCHAIN_NODE_MODULE_MEMORY_MB_DATABASE', + 'XCHAIN_NODE_MODULE_STOP_TIMEOUT_SECONDS_DATABASE' + ] + let savedEnv + + beforeEach(function () { + savedEnv = Object.fromEntries(envNames.map(name => [name, process.env[name]])) + for (const name of envNames) delete process.env[name] + }) + + afterEach(function () { + for (const [name, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + }) + + it('puts the node override in the docker run argv', async function () { + process.env.XCHAIN_NODE_MODULE_MEMORY_MB_NODE = '768' + const stubs = makeNodeServiceStubs() + stubs.execFile.callsFake((command, args, options, callback) => { + if (args[0] === 'build') return callback(null) + if (args[0] === 'run') return callback(null, VALID_CONTAINER_ID + '\n') + }) + + await loadNodeService(stubs).buildCryptoNode('bitcoin', 'mainnet') + + const args = dockerRunArgs(stubs.execFile) + const memoryIndex = args.indexOf('--memory') + expect(args.slice(memoryIndex, memoryIndex + 4)).to.deep.equal([ + '--memory', '768m', '--memory-swap', '768m' + ]) + expect(memoryIndex).to.be.lessThan(args.lastIndexOf('xchain-node-bitcoin-mainnet-node')) + expect(stubs.execFile.getCalls().some(call => call.args[1][0] === 'update')).to.be.false + }) + + it('puts the database override and stop timeout in the MariaDB docker run argv', async function () { + process.env.XCHAIN_NODE_MODULE_MEMORY_MB_DATABASE = '1024' + process.env.XCHAIN_NODE_MODULE_STOP_TIMEOUT_SECONDS_DATABASE = '47' + const stubs = makeStubs() + stubs.execFileAsync.onFirstCall().rejects(new Error('No such container')) + stubs.execFileAsync.resolves({ stdout: VALID_CONTAINER_ID + '\n' }) + + await loadDatabaseService(stubs).buildDatabaseModule('bitcoin', 'mainnet') + + const args = dockerRunArgs(stubs.execFileAsync) + const memoryIndex = args.indexOf('--memory') + const timeoutIndex = args.indexOf('--stop-timeout') + expect(args.slice(memoryIndex, memoryIndex + 4)).to.deep.equal([ + '--memory', '1024m', '--memory-swap', '1024m' + ]) + expect(args.slice(timeoutIndex, timeoutIndex + 2)).to.deep.equal(['--stop-timeout', '47']) + expect(memoryIndex).to.be.lessThan(args.lastIndexOf('xchain-node-database')) + expect(timeoutIndex).to.be.lessThan(args.lastIndexOf('xchain-node-database')) + }) +}) diff --git a/test/unit/services/release_signature_service/release_assets.test.js b/test/unit/services/release_signature_service/release_assets.test.js new file mode 100644 index 00000000..c22a3557 --- /dev/null +++ b/test/unit/services/release_signature_service/release_assets.test.js @@ -0,0 +1,48 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const fs = require('fs') +const path = require('path') +const { expect } = require('chai') + +const { + PLATFORM_KEY_FINGERPRINT, + KEY_PATH, + SUMS_ASSET, + SIG_ASSET, + MANIFEST_ASSET, + ReleaseIntegrityError +} = require('../../../../src/services/release_signature_service/release_assets') + +describe('release_assets', function () { + it('pins a forty-character upper-case hexadecimal fingerprint', function () { + expect(PLATFORM_KEY_FINGERPRINT).to.match(/^[0-9A-F]{40}$/) + }) + + it('points to the shipped release signing key', function () { + expect(path.isAbsolute(KEY_PATH)).to.equal(true) + expect(KEY_PATH).to.match(/release-signing-key\.asc$/) + expect(fs.existsSync(KEY_PATH)).to.equal(true) + }) + + it('names the release integrity assets', function () { + expect([SUMS_ASSET, SIG_ASSET, MANIFEST_ASSET]).to.deep.equal([ + 'SHA256SUMS', + 'SHA256SUMS.asc', + 'release-manifest.json' + ]) + }) + + it('provides an Error subclass that preserves its name and message', function () { + const error = new ReleaseIntegrityError('integrity check failed') + + expect(error).to.be.instanceOf(ReleaseIntegrityError) + expect(error).to.be.instanceOf(Error) + expect(error.name).to.equal('ReleaseIntegrityError') + expect(error.message).to.equal('integrity check failed') + }) +}) diff --git a/test/unit/services/validator_stake_service/send_lock.test.js b/test/unit/services/validator_stake_service/send_lock.test.js new file mode 100644 index 00000000..4308b339 --- /dev/null +++ b/test/unit/services/validator_stake_service/send_lock.test.js @@ -0,0 +1,84 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const sinon = require('sinon') +const { expect } = require('chai') + +const { NO_LOCK, sendLockFor, releaseOnIndexWait, holdForSend } = + require('../../../../src/services/validator_stake_service/send_lock') + +describe('validator stake send lock selection and release', function () { + afterEach(function () { sinon.restore() }) + + it('provides a frozen no-op lock', function () { + expect(Object.isFrozen(NO_LOCK)).to.be.true + expect(NO_LOCK.hold()).to.equal(undefined) + expect(NO_LOCK.release()).to.equal(undefined) + }) + + it('selects a supplied lock only for broadcasts', function () { + const lock = { hold: sinon.stub(), release: sinon.stub() } + expect(sendLockFor({ broadcast: true }, { sendLock: lock })).to.equal(lock) + expect(sendLockFor({ broadcast: 'yes' }, { sendLock: lock })).to.equal(lock) + expect(sendLockFor()).to.equal(NO_LOCK) + expect(sendLockFor({ broadcast: true })).to.equal(NO_LOCK) + expect(sendLockFor({ broadcast: false }, { sendLock: lock })).to.equal(NO_LOCK) + expect(sendLockFor({ broadcast: true }, {})).to.equal(NO_LOCK) + }) + + it('releases only when a send reaches its index wait', function () { + const lock = { release: sinon.stub() } + const onStep = releaseOnIndexWait(lock) + onStep('broadcasting') + onStep('confirmed') + expect(lock.release.called).to.be.false + onStep('waiting') + expect(lock.release.calledOnce).to.be.true + }) +}) + +describe('validator stake send lock hold', function () { + afterEach(function () { sinon.restore() }) + + it('holds the lock before a send', function () { + const lock = { hold: sinon.stub() } + holdForSend(lock, [], 'stake transaction') + expect(lock.hold.calledOnce).to.be.true + }) + + it('rethrows the original error before anything was sent', function () { + const original = Object.assign(new Error('lock held'), { code: 'ELOCKHELD' }) + const lock = { hold: sinon.stub().throws(original) } + expect(() => holdForSend(lock, [], 'stake transaction')).to.throw(original) + }) + + it('describes every earlier broadcast when a later hold fails', function () { + const original = Object.assign(new Error('lock held'), { code: 'ELOCKHELD' }) + const lock = { hold: sinon.stub().throws(original) } + const sent = [ + { step: 'fund', txid: 'tx-123' }, + { step: 'register', txid: 'tx-456' } + ] + + let stopped + try { + holdForSend(lock, sent, 'stake transaction') + } catch (err) { + stopped = err + } + expect(stopped).to.be.an('error').and.not.equal(original) + expect(stopped.code).to.equal(original.code) + expect(stopped.message).to.include('stake transaction') + expect(stopped.message).to.include('fund tx-123') + expect(stopped.message).to.include('register tx-456') + }) +}) diff --git a/test/unit/services/validator_stake_service/token_balance_read.test.js b/test/unit/services/validator_stake_service/token_balance_read.test.js new file mode 100644 index 00000000..d95b6635 --- /dev/null +++ b/test/unit/services/validator_stake_service/token_balance_read.test.js @@ -0,0 +1,93 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') + +const { readTokenRow } = require('../../../../src/services/validator_stake_service/token_balance_read') + +function recordingSdk(responder) { + const opts = [] + return { + opts, + async getBalances(address, requestOpts) { + opts.push(requestOpts) + return responder(address, requestOpts) + } + } +} + +async function expectIncomplete(read) { + let failure + try { + await read + } catch (err) { + failure = err + } + expect(failure).to.be.an('error') + expect(failure.message).to.match(/^balance list read incomplete/) + expect(failure.incompleteRead).to.equal(true) +} + +describe('validator stake token balance read', function () { + it('returns a matching row from the first page with the bounded request', async function () { + const row = { tick: 'XC', balance: '12' } + const sdk = recordingSdk(() => ({ data: [{ tick: 'AA' }, row], total: 2 })) + + expect(await readTokenRow(sdk, 'address-1', 'XC')).to.equal(row) + expect(sdk.opts).to.deep.equal([{ page: 1, limit: 500, sortorder: 'ASC' }]) + }) + + it('returns null when the reported total equals the rows seen', async function () { + const sdk = recordingSdk(() => ({ data: [{ tick: 'AA' }, { tick: 'BB' }], total: 2 })) + + expect(await readTokenRow(sdk, 'address-1', 'XC')).to.equal(null) + expect(sdk.opts).to.have.length(1) + }) + + it('returns null for a short page when no total is reported', async function () { + const sdk = recordingSdk(() => ({ data: [{ tick: 'AA' }] })) + + expect(await readTokenRow(sdk, 'address-1', 'XC')).to.equal(null) + expect(sdk.opts).to.have.length(1) + }) + + it('rejects a response without a data array as incomplete', async function () { + const sdk = recordingSdk(() => ({ total: 1 })) + + await expectIncomplete(readTokenRow(sdk, 'address-1', 'XC')) + expect(sdk.opts).to.have.length(1) + }) + + it('rejects when the reported total shrinks between pages', async function () { + const rows = Array.from({ length: 500 }, (_, index) => ({ tick: 'T' + index })) + const sdk = recordingSdk((address, opts) => ({ data: rows, total: opts.page === 1 ? 1000 : 999 })) + + await expectIncomplete(readTokenRow(sdk, 'address-1', 'XC')) + expect(sdk.opts).to.have.length(2) + }) + + it('rejects an empty page before the reported total is reached', async function () { + const sdk = recordingSdk(() => ({ data: [], total: 1 })) + + await expectIncomplete(readTokenRow(sdk, 'address-1', 'XC')) + expect(sdk.opts).to.have.length(1) + }) + + it('rejects after the maximum readable pages without fetching another page', async function () { + const rows = Array.from({ length: 500 }, (_, index) => ({ tick: 'T' + index })) + const sdk = recordingSdk(() => ({ data: rows })) + + await expectIncomplete(readTokenRow(sdk, 'address-1', 'XC')) + expect(sdk.opts).to.have.length(201) + expect(sdk.opts[200]).to.deep.equal({ page: 201, limit: 500, sortorder: 'ASC' }) + }) +}) diff --git a/test/unit/stop_budget_service.test.js b/test/unit/stop_budget_service.test.js index d080a803..c69db283 100644 --- a/test/unit/stop_budget_service.test.js +++ b/test/unit/stop_budget_service.test.js @@ -258,11 +258,10 @@ describe('StopBudgetService', function () { expect(overLine(), 'a 30 s default-budget service').to.match(/xchain-explorer.*30 s stop budget/) }) - it('stays quiet for an explicit value under the budget or one the service would ignore', function () { + it('stays quiet for an explicit value under the budget', function () { sbs.shutdownTimeoutEnv('xchain-decoder', { SHUTDOWN_TIMEOUT_MS: '119000' }, {}) sbs.shutdownTimeoutEnv('xchain-decoder', { SHUTDOWN_TIMEOUT_MS: '45000' }, DECODER_300) sbs.shutdownTimeoutEnv('xchain-explorer', { SHUTDOWN_TIMEOUT_MS: '8000' }, {}) - sbs.shutdownTimeoutEnv('xchain-decoder', { SHUTDOWN_TIMEOUT_MS: 'slow' }, {}) expect(warnStub.called).to.be.false }) @@ -325,3 +324,47 @@ describe('StopBudgetService', function () { } }) }) + +// A SHUTDOWN_TIMEOUT_MS the service would ignore leaves its own default timer, which only fits the default budget. +describe('StopBudgetService', function () { + beforeEach(prepareConsoleStubs) + afterEach(restoreConsoleStubs) + + const DECODER_60 = { XCHAIN_NODE_MODULE_STOP_TIMEOUT_SECONDS_XCHAIN_DECODER: '60' } + const TRACKER_60 = { XCHAIN_NODE_MODULE_STOP_TIMEOUT_SECONDS_XCHAIN_UTXO_TRACKER: '60' } + const ignoredLine = () => warnStub.args.map(a => String(a[0])).find(l => /the service ignores/.test(l)) + const cleanStop = () => sinon.stub().resolves({ stopped: true, seconds: 5, killed: false, exitCode: 0 }) + + it('forwards the derived drain timer in place of an explicit value the service would ignore, and says so', function () { + for (const bad of ['slow', '0', '-5']) { + warnStub.resetHistory() + expect(sbs.shutdownTimeoutEnv('xchain-decoder', { SHUTDOWN_TIMEOUT_MS: bad }, DECODER_60), bad) + .to.deep.equal({ SHUTDOWN_TIMEOUT_MS: '40000' }) + expect(ignoredLine(), bad).to.include('xchain-decoder').and.to.include('SHUTDOWN_TIMEOUT_MS=' + bad) + expect(ignoredLine(), bad).to.include('forwarding 40000') + } + expect(sbs.shutdownTimeoutEnv('xchain-utxo-tracker', { SHUTDOWN_TIMEOUT_MS: 'slow' }, TRACKER_60)) + .to.deep.equal({ SHUTDOWN_TIMEOUT_MS: '40000' }) + expect(sbs.shutdownTimeoutEnv('xchain-decoder', { SHUTDOWN_TIMEOUT_MS: 'slow' }, {}), 'default budget') + .to.deep.equal({ SHUTDOWN_TIMEOUT_MS: '100000' }) + }) + + it('keeps a default-budget unlisted service on its own default, still naming the ignored value', function () { + expect(sbs.shutdownTimeoutEnv('xchain-explorer', { SHUTDOWN_TIMEOUT_MS: 'slow' }, {})).to.deep.equal({}) + expect(ignoredLine()).to.include('xchain-explorer').and.to.include('keeps its own default') + }) + + it('warns before the stop when a container on a lowered budget carries a value the service ignores', async function () { + const read = sinon.stub().resolves({ stopTimeout: 60, shutdownTimeoutMs: 'slow' }) + await sbs.stopModuleContainer(cleanStop(), 'xchain-decoder', 'bitcoin', 'mainnet', 'abc123', DECODER_60, read) + const line = warnStub.args.map(a => String(a[0])).find(l => /was created/.test(l)) + expect(line).to.match(/xchain-decoder \(bitcoin mainnet\) was created with SHUTDOWN_TIMEOUT_MS=slow, which the service ignores/) + expect(line).to.match(/xchain-node recreate/) + }) + + it('stays quiet before the stop for an ignored value at the default budget, where the service default fits', async function () { + const read = sinon.stub().resolves({ stopTimeout: 120, shutdownTimeoutMs: 'slow' }) + await sbs.stopModuleContainer(cleanStop(), 'xchain-decoder', 'bitcoin', 'mainnet', 'abc123', {}, read) + expect(warnStub.called).to.be.false + }) +}) diff --git a/test/unit/ui/menu/module_choices.test.js b/test/unit/ui/menu/module_choices.test.js new file mode 100644 index 00000000..95c98a87 --- /dev/null +++ b/test/unit/ui/menu/module_choices.test.js @@ -0,0 +1,145 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const { + NODE_MODULE_NAME, + DB_MODULE_NAME, + XChainService, + Network +} = require('../../../../src/config') +const { + expectedModules, + buildModuleChoices +} = require('../../../../src/ui/menu/module_choices') + +function installedModule(containerId, status) { + return { + container_id: containerId, + status: { State: { Status: status } } + } +} + +function moduleKey(color, moduleName, status) { + return `\x1b[${color}m${moduleName} (${status})\x1b[37m` +} + +function assertInstalledModules(result, installedModules) { + const expectedChoices = installedModules.map(({ color, moduleName, status }) => ({ + name: moduleKey(color, moduleName, status), + value: moduleName + })) + + expect(result.moduleChoices.slice(0, installedModules.length)).to.deep.equal(expectedChoices) + for (const { color, moduleName, containerId, status } of installedModules) { + const key = moduleKey(color, moduleName, status) + expect(result.actionModules[key]).to.deep.equal({ + value: moduleName, + container_id: containerId, + status + }) + } +} + +function assertMissingModules(result, installed) { + const missing = expectedModules(Network.MAINNET) + .filter(moduleName => !(moduleName in installed)) + const missingChoices = missing.map(moduleName => ({ + name: moduleKey(34, moduleName, 'missing'), + value: moduleName + })) + + expect(result.moduleChoices.slice(Object.keys(installed).length, -2)).to.deep.equal(missingChoices) + for (const moduleName of missing) { + const key = moduleKey(34, moduleName, 'missing') + expect(result.actionModules[key]).to.deep.equal({ value: moduleName, status: 'missing' }) + } +} + +describe('expectedModules', function () { + it('excludes the E2E module on every network', function () { + for (const network of Object.values(Network)) { + expect(expectedModules(network)).to.not.include(XChainService.XCHAIN_E2E_TEST) + } + }) + + it('includes the regtest miner only on regtest', function () { + expect(expectedModules(Network.MAINNET)).to.not.include(XChainService.XCHAIN_REGTEST_MINER) + expect(expectedModules(Network.TESTNET)).to.not.include(XChainService.XCHAIN_REGTEST_MINER) + expect(expectedModules(Network.REGTEST)).to.include(XChainService.XCHAIN_REGTEST_MINER) + }) + + it('ends with the node and database module names on every network', function () { + for (const network of Object.values(Network)) { + expect(expectedModules(network).slice(-2)).to.deep.equal([ + NODE_MODULE_NAME, + DB_MODULE_NAME + ]) + } + }) +}) + +describe('buildModuleChoices fallback', function () { + it('offers installation and return when the coin or network is absent', function () { + const fallbackChoices = [ + { name: 'Install the node', value: 'Install the node' }, + { name: 'Return', value: 'return' } + ] + + for (const modulesStatus of [{}, { bitcoin: {} }]) { + const result = buildModuleChoices(modulesStatus, 'bitcoin', Network.MAINNET) + expect(result.moduleChoices).to.deep.equal(fallbackChoices) + expect(result.actionModules).to.deep.equal({}) + } + }) +}) + +describe('buildModuleChoices populated status', function () { + it('colours installed modules, maps their status, and lists every missing module', function () { + const installed = { + [XChainService.XCHAIN_ENCODER]: installedModule('encoder-id', 'exited'), + [XChainService.XCHAIN_DECODER]: installedModule('decoder-id', 'running') + } + const installedModules = [ + { color: 31, moduleName: XChainService.XCHAIN_ENCODER, containerId: 'encoder-id', status: 'exited' }, + { color: 32, moduleName: XChainService.XCHAIN_DECODER, containerId: 'decoder-id', status: 'running' } + ] + const modulesStatus = { bitcoin: { [Network.MAINNET]: installed } } + const result = buildModuleChoices(modulesStatus, 'bitcoin', Network.MAINNET) + + assertInstalledModules(result, installedModules) + assertMissingModules(result, installed) + expect(result.moduleChoices.slice(-2)).to.deep.equal([ + { name: 'Uninstall all the modules', value: 'Uninstall all the modules' }, + { name: 'Return', value: 'return' } + ]) + }) +}) + +describe('buildModuleChoices on regtest', function () { + it('places the E2E action immediately before uninstall and return', function () { + const modulesStatus = { + bitcoin: { + [Network.REGTEST]: { + [XChainService.XCHAIN_REGTEST_MINER]: installedModule('miner-id', 'running') + } + } + } + const result = buildModuleChoices(modulesStatus, 'bitcoin', Network.REGTEST) + + expect(result.moduleChoices.slice(-3)).to.deep.equal([ + { name: 'Perform an E2E test', value: 'e2etest' }, + { name: 'Uninstall all the modules', value: 'Uninstall all the modules' }, + { name: 'Return', value: 'return' } + ]) + }) +}) diff --git a/test/unit/ui/menu/node_container_actions.test.js b/test/unit/ui/menu/node_container_actions.test.js new file mode 100644 index 00000000..15e4095f --- /dev/null +++ b/test/unit/ui/menu/node_container_actions.test.js @@ -0,0 +1,83 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const sinon = require('sinon') +const proxyquire = require('proxyquire').noCallThru() +const { makeStubs } = require('../../module_service.test/support/helpers') + +const CONTAINER_ID = 'c'.repeat(64) +const LABELS = ['Update Container', 'Install Local Version in Container', 'Reinstall'] + +function loadMenu(installModule) { + return proxyquire('../../../../src/ui/menu.js', { + '../services/version_service': {}, + '../operations/module_operations': {}, + '../services/module_service': { installModule, cloneGit: async () => {} }, + '../cli/dispatch': { scopedCommandLock: () => ({ hold() {}, release() {} }) } + }) +} + +function loadService(localNodeVersion) { + const stubs = makeStubs() + const buildCryptoNode = sinon.stub().resolves(true) + const getCryptoNode = sinon.stub().resolves() + const ms = proxyquire('../../../../src/services/module_service', { + 'child_process': { execFile: stubs.execFile }, + 'fs': stubs.fs, + '../state': { + db: stubs.db, + getRemoteModuleVersions: () => ({}), + getLastStatus: () => ({ bitcoin: { mainnet: { node: { container_version: 'v25.0.0' } } } }) + }, + './config_service': { + getModuleDir: (mod) => '/modules/' + mod, + checkIfModuleExists: sinon.stub().returns(true), + getDockerContainerImageName: sinon.stub().returns('node'), + getDockerNetwork: sinon.stub().returns('net'), + validatePort: () => true, + getDefaultConfig: sinon.stub().resolves({}) + }, + './status_service': { statusChanged: stubs.statusChanged, getStatus: stubs.getStatus }, + './docker_service': { stopContainerByName: stubs.stopContainerByName, removeContainer: stubs.removeContainer, forceRemoveContainerByName: stubs.forceRemoveContainerByName }, + './database_service': { setDatabaseParameters: sinon.stub().resolves(), setHubDatabaseParameters: sinon.stub().resolves() }, + './version_service': { getLocalNodeVersion: sinon.stub().resolves(localNodeVersion), getLocalModuleVersion: sinon.stub().resolves(null), checkRemoteNodeVersion: sinon.stub().resolves() }, + './node_service': { buildCryptoNode, getCryptoNode }, + './explorer_service': { installExplorerModule: sinon.stub().resolves(true) } + }) + return { ms, buildCryptoNode, getCryptoNode } +} + +describe('ui/menu node container actions rebuild the node', function () { + for (const label of LABELS) { + it(`"${label}" on the node hands the container id to installModule`, async function () { + const install = sinon.stub().resolves(true) + const menu = loadMenu(install) + await menu.runInstalledModuleAction(label, { value: 'node', container_id: CONTAINER_ID }, 'bitcoin', 'mainnet') + expect(install.calledOnceWithExactly('node', 'bitcoin', 'mainnet', false, CONTAINER_ID)).to.equal(true) + }) + } + + it('installModule rebuilds an installed node when given a container id, without downloading', async function () { + const { ms, buildCryptoNode, getCryptoNode } = loadService('v25.0.0') + const result = await ms.installModule('node', 'bitcoin', 'mainnet', false, CONTAINER_ID) + expect(result).to.equal(true) + expect(buildCryptoNode.calledOnceWithExactly('bitcoin', 'mainnet')).to.equal(true) + expect(getCryptoNode.called).to.equal(false) + }) + + it('installModule still skips an installed node with no rebuild request', async function () { + const { ms, buildCryptoNode } = loadService('v25.0.0') + expect(await ms.installModule('node', 'bitcoin', 'mainnet', false)).to.equal(false) + expect(buildCryptoNode.called).to.equal(false) + }) +}) diff --git a/test/unit/ui/menu/node_version_order.test.js b/test/unit/ui/menu/node_version_order.test.js new file mode 100644 index 00000000..61c22168 --- /dev/null +++ b/test/unit/ui/menu/node_version_order.test.js @@ -0,0 +1,57 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const { + NODE_VERSION_ORDER, + nodeVersionParts, + remoteNodeVersion +} = require('../../../../src/ui/menu/node_version_order') + +describe('ui/menu/node_version_order', function () { + describe('nodeVersionParts()', function () { + it('reads two-, three- and four-part daemon tags, with or without the v', function () { + expect(nodeVersionParts('28.1\n')).to.deep.equal([28, 1]) + expect(nodeVersionParts('v1.14.9')).to.deep.equal([1, 14, 9]) + expect(nodeVersionParts('v0.21.5.6')).to.deep.equal([0, 21, 5, 6]) + }) + + it('rejects the menu sentinel "0" and anything that is not a dotted version', function () { + for (const raw of ['0', '', null, undefined, 'latest', 'v28', '28.1-rc1', '28..1']) { + expect(nodeVersionParts(raw), String(raw)).to.equal(null) + } + }) + }) + + describe('NODE_VERSION_ORDER', function () { + it('orders by numeric parts, not by string or by semver', function () { + expect(NODE_VERSION_ORDER.gt('v31.1', '28.1')).to.equal(true) + expect(NODE_VERSION_ORDER.gt('v0.21.5.6', 'v0.21.5.5')).to.equal(true) + expect(NODE_VERSION_ORDER.gt('v1.14.10', 'v1.14.9')).to.equal(true) + expect(NODE_VERSION_ORDER.gt('28.1', 'v31.1')).to.equal(false) + }) + + it('treats a missing trailing part as zero and ignores the v', function () { + expect(NODE_VERSION_ORDER.eq('v28.1', '28.1\n')).to.equal(true) + expect(NODE_VERSION_ORDER.eq('28.1', '28.1.0')).to.equal(true) + }) + }) + + describe('remoteNodeVersion()', function () { + it('reads the release tag, or "0" when no release was stored', function () { + expect(remoteNodeVersion({ tag_name: 'v28.1', id: 7 })).to.equal('v28.1') + expect(remoteNodeVersion(null)).to.equal('0') + expect(remoteNodeVersion(undefined)).to.equal('0') + expect(remoteNodeVersion({})).to.equal('0') + }) + }) +}) diff --git a/test/unit/ui_menu.test.js b/test/unit/ui_menu.test.js index 00a68a02..198bfc77 100644 --- a/test/unit/ui_menu.test.js +++ b/test/unit/ui_menu.test.js @@ -214,3 +214,111 @@ describe('ui/menu mutating actions hold the command lock', function () { assert.deepStrictEqual(handlers.match(new RegExp('(? ({}) } = {}) { + const calls = { update: [], clone: [], install: [] }; + const mod = proxyquire('../../src/ui/menu.js', { + '../services/version_service': { + getLocalNodeVersion: async () => { + if (localNodeVersion == null) throw 'No file'; + return localNodeVersion; + }, + getLocalModuleVersion: async () => '1.0.0', + getContainerNodeVersion: async () => '0', + getContainerModuleVersion: async () => '0', + }, + '../operations/module_operations': { + installModules: async () => true, uninstallModules: async () => true, + restartModules: async () => true, logModules: async () => true, runE2ETest: async () => null, + updateModules: async (list) => { calls.update.push(list); return updateModules(list); }, + }, + '../services/module_service': { + cloneGit: async (...args) => { calls.clone.push(args); }, + installModule: async (...args) => { calls.install.push(args); }, + }, + '../cli/dispatch': { scopedCommandLock: () => ({ hold() {}, release() {} }) }, + }); + return { mod, calls }; +} + +const NODE = 'node'; +const offered = (actions) => actions.map(a => a.name); + +describe('ui/menu coin node remote versions', function () { + it('reads the node remote version from the release tag_name', async function () { + const { mod } = loadNodeMenu({ localNodeVersion: 'v1.14.8' }); + const versions = await mod.readModuleVersions(NODE, { 'node-dogecoin': { tag_name: 'v1.14.9', id: 1 } }, 'dogecoin', 'mainnet'); + assert.deepStrictEqual(versions, { remoteVersion: 'v1.14.9', localVersion: 'v1.14.8' }); + }); + + it('answers "0" for a node release that has not been looked up yet', async function () { + const { mod } = loadNodeMenu(); + const versions = await mod.readModuleVersions(NODE, {}, 'bitcoin', 'mainnet'); + assert.deepStrictEqual(versions, { remoteVersion: '0', localVersion: '0' }); + const actions = offered(mod.installedModuleActions('running', NODE, versions.localVersion, versions.remoteVersion, '0')); + assert.ok(!actions.includes('Update local version') && !actions.includes('Reinstall from remote'), actions.join(', ')); + }); + + it('offers an update for each coin whose installed node is behind the release', function () { + const { mod } = loadNodeMenu(); + const cases = [['28.1\n', 'v31.1'], ['v1.14.8', 'v1.14.9'], ['v0.21.4', 'v0.21.5.6'], ['v0.21.5.5', 'v0.21.5.6']]; + for (const [local, remote] of cases) { + const actions = offered(mod.installedModuleActions('running', NODE, local, remote, local)); + assert.ok(actions.includes('Update local version'), `${local} -> ${remote}: ${actions.join(', ')}`); + } + }); + + it('offers a reinstall when the installed node matches the release', function () { + const { mod } = loadNodeMenu(); + const actions = offered(mod.installedModuleActions('running', NODE, '28.1\n', 'v28.1', '28.1')); + assert.ok(actions.includes('Reinstall from remote'), actions.join(', ')); + assert.ok(!actions.includes('Update local version'), actions.join(', ')); + }); + + it('still compares a module as semver', function () { + const { mod } = loadNodeMenu(); + const actions = offered(mod.installedModuleActions('running', 'xchain-encoder', '1.0.0', '1.1.0', '1.0.0')); + assert.ok(actions.includes('Update local version'), actions.join(', ')); + const twoPart = offered(mod.installedModuleActions('running', 'xchain-encoder', '1.0', '1.1', '1.0')); + assert.ok(!twoPart.includes('Update local version'), twoPart.join(', ')); + }); +}); + +describe('ui/menu coin node remote actions run update, never a clone', function () { + const selected = { value: NODE, container_id: 'c'.repeat(64), status: 'running' }; + + for (const label of ['Update local version', 'Reinstall from remote']) { + it(`"${label}" on the node runs update node for that coin and network`, async function () { + const { mod, calls } = loadNodeMenu(); + await mod.runInstalledModuleAction(label, selected, 'bitcoin', 'mainnet'); + assert.deepStrictEqual(calls.update, [{ bitcoin: { mainnet: [NODE] } }]); + assert.deepStrictEqual(calls.clone, []); + assert.deepStrictEqual(calls.install, []); + }); + } + + it('reports a refused node update and stays in the menu', async function () { + const refusal = new Error('Refusing to update a node while XCHAIN_NODE_DATA_DIR is unset.'); + const { mod } = loadNodeMenu({ updateModules: async () => { throw refusal; } }); + const saved = console.log; + const lines = []; + console.log = (...a) => lines.push(a.join(' ')); + try { + await mod.runInstalledModuleAction('Update local version', selected, 'bitcoin', 'mainnet'); + } finally { + console.log = saved; + } + assert.ok(lines.some(l => l.includes('XCHAIN_NODE_DATA_DIR')), lines.join('\n')); + }); + + it('"Update local version" on a module still clones its source', async function () { + const { mod, calls } = loadNodeMenu(); + await mod.runInstalledModuleAction('Update local version', { value: 'xchain-encoder' }, 'bitcoin', 'mainnet'); + assert.deepStrictEqual(calls.clone, [['xchain-encoder', true, false]]); + assert.deepStrictEqual(calls.update, []); + }); +}); diff --git a/test/unit/validator_sdk_encoder_compat.test.js b/test/unit/validator_sdk_encoder_compat.test.js index 6838f0f7..6d5be3b3 100644 --- a/test/unit/validator_sdk_encoder_compat.test.js +++ b/test/unit/validator_sdk_encoder_compat.test.js @@ -109,7 +109,10 @@ function makeExplorer(chain) { getToken: sinon.stub().resolves({ mints: { max: 10000, address_max: 50000 } }), getValidators: sinon.stub().resolves({ data: [] }), // No delegation holds the key; without an answer the stake command refuses to send. - getDelegations: sinon.stub().resolves({ total: 0, data: [] }) + getDelegations: sinon.stub().resolves({ total: 0, data: [] }), + // The tip and an empty sleep list, so the stake address reads as awake rather than unknown. + getStatus: sinon.stub().resolves({ last_block: { BTC: 200000, TBTC: 200000, RBTC: 200000 } }), + getSleeps: sinon.stub().resolves({ total: 0, data: [] }) } } diff --git a/test/unit/validator_service.test/17_capability_drift_probe.test.js b/test/unit/validator_service.test/17_capability_drift_probe.test.js index 0715072f..58b282cb 100644 --- a/test/unit/validator_service.test/17_capability_drift_probe.test.js +++ b/test/unit/validator_service.test/17_capability_drift_probe.test.js @@ -113,7 +113,7 @@ function makeIndexerSdk({ sets = {}, throwsOn, errorOn, truncatedOn, noMethod, l const explorer = { getStatus: statusThrows ? sinon.stub().rejects(new Error(statusThrows)) - : sinon.stub().resolves({ last_block: { 'bitcoin-testnet': lastBlock } }) + : sinon.stub().resolves({ last_block: { TBTC: lastBlock } }) } if (!noMethod) { explorer.getCapabilityValidators = sinon.stub().callsFake(async ({ capability, block_index }) => { diff --git a/test/unit/validator_stake_service.test/explorer_tip_key.test.js b/test/unit/validator_stake_service.test/explorer_tip_key.test.js new file mode 100644 index 00000000..37619c95 --- /dev/null +++ b/test/unit/validator_stake_service.test/explorer_tip_key.test.js @@ -0,0 +1,41 @@ +'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 key the stake CLI reads the explorer tip under. The explorer's /status +// keys last_block by route code (BTC/TBTC/RBTC), never by the SDK network name, +// and the stake fakes build their status from coins.stakeCoin, so this pins +// that code to what the SDK itself routes to. + +const { expect } = require('chai') +const { XChainSDK } = require('@dankest-llc/xchain-sdk') + +const { COIN_NETWORKS } = require('../../../src/services/validator_service') +const { readAnsweredCapabilitySets } = require('../../../src/services/validator_service/capability_drift') + +describe('explorer tip key', function () { + for (const [network, coins] of Object.entries(COIN_NETWORKS)) { + it(`keys the ${network} stake tip by the explorer route code the SDK uses`, function () { + expect(coins.stakeCoin).to.equal(new XChainSDK({ network: coins.stake }).explorer.coin) + expect(coins.stakeCoin).to.not.equal(coins.stake) + }) + } + + it('cannot read a tip keyed by the SDK network name', async function () { + const sdk = { explorer: { + getCapabilityValidators: async () => ({ validators: [] }), + getStatus: async () => ({ last_block: { 'bitcoin-testnet': 200000 } }) + } } + const r = await readAnsweredCapabilitySets('ab'.repeat(32), 'testnet', ['price'], { sdk: { XChainSDK }, makeSdk: () => sdk }) + expect(r.unavailable).to.equal(true) + expect(r.reason).to.match(/no last block for TBTC/) + }) +}) diff --git a/test/unit/validator_stake_service.test/send_lock.test.js b/test/unit/validator_stake_service.test/send_lock.test.js index 6dce7c12..82206c93 100644 --- a/test/unit/validator_stake_service.test/send_lock.test.js +++ b/test/unit/validator_stake_service.test/send_lock.test.js @@ -56,7 +56,8 @@ function makeExplorer(chain) { getValidators: sinon.stub().resolves({ data: chain.existing ? [chain.existing] : [] }), getDelegations: sinon.stub().resolves({ total: 0, data: [] }), getStatus: sinon.stub().resolves({ last_block: Object.fromEntries( - Object.values(COIN_NETWORKS).map(c => [c.stake, 200000])) }) + Object.values(COIN_NETWORKS).map(c => [c.stakeCoin, 200000])) }), + getSleeps: sinon.stub().resolves({ total: 0, data: [] }) } } diff --git a/test/unit/validator_stake_service.test/signing_pubkey_shape.test.js b/test/unit/validator_stake_service.test/signing_pubkey_shape.test.js new file mode 100644 index 00000000..ec73c924 --- /dev/null +++ b/test/unit/validator_stake_service.test/signing_pubkey_shape.test.js @@ -0,0 +1,89 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const sinon = require('sinon') + +const { stakeValidator, unstakeValidator } = require('../../../src/services/validator_stake_service') +const { PUBKEY, ADDRESS, makeSdk, run } = require('../../helpers/stake_harness') + +// Deps whose every reach past the pubkey check is recorded: the WIF read and the SDK build. +function guardedDeps(pubkey) { + const touched = { wif: 0 } + const wallets = { NETWORK: 'testnet', STAKE_ADDRESS: ADDRESS } + Object.defineProperty(wallets, 'STAKE_WIF_SECRET', { get() { touched.wif++; return 'cFakeWif' } }) + const buildSdk = sinon.stub().throws(new Error('the SDK must not be built for a malformed pubkey')) + const settings = { enabled: true, network: 'testnet', P2P_PORT: 10002 } + if (pubkey !== undefined) settings.pubkey = pubkey + return { touched, buildSdk, deps: { settings, wallets, makeSdk: buildSdk, sdk: {}, log: () => {} } } +} + +async function rejection(promise) { + try { await promise } catch (e) { return e } + return null +} + +describe('ValidatorStakeService', function () { + describe('the validator signing pubkey shape', function () { + + const malformed = { + '66 hex (a secp256k1 key pasted in)': 'ab'.repeat(33), + '62 hex (truncated)': 'ab'.repeat(31), + '64 characters, not hex': 'zz'.repeat(32), + 'empty': '', + 'missing': undefined + } + + for (const [label, pubkey] of Object.entries(malformed)) { + it(`stake refuses a ${label} pubkey before the WIF read or any chain read`, async function () { + const { touched, buildSdk, deps } = guardedDeps(pubkey) + const err = await rejection(stakeValidator({}, deps)) + expect(err, 'a malformed pubkey must be refused').to.be.an('error') + expect(err.message).to.match(/signing pubkey in validator\.json is not 64 hex characters/) + expect(err.message).to.match(/Nothing was sent/) + expect(touched.wif).to.equal(0) + expect(buildSdk.called).to.equal(false) + }) + } + + it('stake with --broadcast refuses a truncated pubkey and sends no MINT and no STAKE', async function () { + const { sdk, calls } = makeSdk({ xchain: 0, coin: '0.001' }) + const deps = { + settings: { enabled: true, pubkey: 'ab'.repeat(31), network: 'testnet', P2P_PORT: 10002 }, + wallets: { NETWORK: 'testnet', STAKE_ADDRESS: ADDRESS, STAKE_WIF_SECRET: 'cFakeWif' }, + makeSdk: () => sdk, sdk: {}, log: () => {} + } + const err = await rejection(stakeValidator({ broadcast: true }, deps)) + expect(err && err.message).to.match(/not 64 hex characters/) + expect(calls.mint).to.have.length(0) + expect(calls.stake).to.have.length(0) + // Control: the same chain with a well-formed pubkey does mint, so the zero above is the guard. + const control = await run({ broadcast: true }, { xchain: 0, coin: '0.001' }) + expect(control.calls.mint.length).to.be.above(0) + }) + + it('stake accepts an uppercase 64-hex pubkey and sends it lowercased', async function () { + const { calls } = await run({ broadcast: true }, { xchain: 30000, coin: '0.001' }, { pubkey: PUBKEY.toUpperCase() }) + expect(calls.stake).to.have.length(1) + expect(calls.stake[0].params.SIGNING_PUBKEY).to.equal(PUBKEY) + }) + + it('unstake refuses a malformed pubkey with the shape error, not "carries no valid stake"', async function () { + const { touched, buildSdk, deps } = guardedDeps('ab'.repeat(33)) + const err = await rejection(unstakeValidator({ broadcast: true }, deps)) + expect(err && err.message).to.match(/not 64 hex characters/) + expect(err.message).to.not.match(/carries no valid stake/) + expect(touched.wif).to.equal(0) + expect(buildSdk.called).to.equal(false) + }) + }) +}) diff --git a/test/unit/validator_stake_service.test/source_sleep.test.js b/test/unit/validator_stake_service.test/source_sleep.test.js new file mode 100644 index 00000000..57ae3ed8 --- /dev/null +++ b/test/unit/validator_stake_service.test/source_sleep.test.js @@ -0,0 +1,200 @@ +'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. + +// A stake address that put itself to sleep: the indexer rejects its MINTs, STAKE and UNSTAKE +// as 'invalid: SOURCE (sleeping)' only after the fees are spent, so the CLI refuses first. + +const { expect } = require('chai') + +const { stakeValidator, unstakeValidator } = require('../../../src/services/validator_stake_service') +const { judgeAddressSleep, readAddressSleep } = require('../../../src/services/validator_stake_service/address_sleep') +const { PUBKEY, ADDRESS, makeSdk, run } = require('../../helpers/stake_harness') + +const TIP = 200000 +const SLEPT = (action_index, resume_block, extra = {}) => + ({ source: ADDRESS, type: 1, tick: null, resume_block, status: 'valid', action_index, block_index: 150000, ...extra }) +const STAKED = { status: 'valid', signing_pubkey: PUBKEY, source: ADDRESS, amount: '25000', action_index: '44', + activation_block: '199000' } + +// Run a command against a prepared fake SDK; resolves to the result or the refusal, with every send recorded. +async function runWith(command, opts, sdk) { + const sent = [] + const origSession = sdk.session + sdk.session = () => { + const s = origSession() + const inner = s.submit + s.submit = async (a, e, o) => { sent.push(a.action); return inner(a, e, o) } + return s + } + const logged = [] + const deps = { + settings: { enabled: true, pubkey: PUBKEY, network: 'testnet', P2P_PORT: 10002 }, + wallets: { NETWORK: 'testnet', STAKE_ADDRESS: ADDRESS, STAKE_WIF_SECRET: 'cFakeWif' }, + makeSdk: () => sdk, + sdk: {}, + log: m => logged.push(String(m)) + } + try { + return { err: null, result: await command(opts, deps), sent, logged } + } catch (err) { + return { err, result: null, sent, logged } + } +} + +const stakeChain = (extra) => ({ xchain: 5000, coin: '0.001', tip: TIP, ...extra }) +const unstakeSdk = (extra) => makeSdk({ existing: STAKED, tip: TIP, ...extra }).sdk + +// Registers the stake refusals and passes for a sleeping stake address. +function registerStakeRefusals () { + for (const broadcast of [false, true]) { + const mode = broadcast ? 'a --broadcast run' : 'the dry run' + + it('refuses ' + mode + ' when the address is asleep indefinitely', async function () { + const { result, calls, logged } = await run({ broadcast }, stakeChain({ sleeps: [SLEPT(90, -1)] })) + expect(result.staked).to.be.false + expect(calls.mint).to.have.length(0) + expect(calls.stake).to.have.length(0) + const out = logged.join('\n') + expect(out).to.match(/BLOCKED: .*asleep indefinitely/) + expect(out).to.include('cannot wake itself') + expect(out).to.not.include('Dry run') + }) + + it('refuses ' + mode + ' when the sleep read fails', async function () { + const { result, calls, logged } = await run({ broadcast }, stakeChain({ sleepsThrow: 'explorer 503' })) + expect(result.staked).to.be.false + expect(calls.mint).to.have.length(0) + expect(calls.stake).to.have.length(0) + expect(logged.join('\n')).to.match(/BLOCKED: could not confirm the stake address .* is not asleep \(explorer 503\)/) + }) + } + + it('refuses until the resume block when the sleep ends after the next block', async function () { + const { result, calls, logged } = await run({ broadcast: true }, stakeChain({ sleeps: [SLEPT(90, TIP + 2)] })) + expect(result.staked).to.be.false + expect(calls.mint).to.have.length(0) + expect(logged.join('\n')).to.match(/BLOCKED: .*asleep until block 200002/) + }) + + it('refuses when the SDK has no sleep lookup, never reading that as awake', async function () { + const { sdk } = makeSdk(stakeChain()) + delete sdk.explorer.getSleeps + const { result, sent, logged } = await runWith(stakeValidator, { broadcast: true }, sdk) + expect(result.staked).to.be.false + expect(sent).to.have.length(0) + expect(logged.join('\n')).to.include('this SDK has no getSleeps') + }) + + it('stakes when the sleep resumes at the next block', async function () { + const { result, calls } = await run({ broadcast: true }, stakeChain({ sleeps: [SLEPT(90, TIP + 1)] })) + expect(result.staked).to.be.true + expect(calls.stake).to.have.length(1) + }) + + it('stakes when a newer resume-now SLEEP supersedes an older indefinite one', async function () { + const { result } = await run({ broadcast: true }, stakeChain({ sleeps: [SLEPT(9, -1), SLEPT(10, 0)] })) + expect(result.staked).to.be.true + }) +} + +// Registers the unstake refusals and passes for a sleeping owner. +function registerUnstakeRefusals () { + for (const broadcast of [false, true]) { + it('refuses ' + (broadcast ? 'a --broadcast run' : 'the dry run') + ' when the owner is asleep indefinitely', async function () { + const { err, sent, logged } = await runWith(unstakeValidator, { broadcast }, unstakeSdk({ sleeps: [SLEPT(90, -1)] })) + expect(err, 'a sleeping owner must refuse').to.exist + expect(sent).to.have.length(0) + expect(err.message).to.include('asleep indefinitely') + expect(err.message).to.include('Nothing was sent') + expect(logged.join('\n')).to.not.include('Dry run') + }) + } + + it('refuses when the sleep read fails', async function () { + const { err, sent } = await runWith(unstakeValidator, { broadcast: true }, unstakeSdk({ sleepsThrow: 'explorer 503' })) + expect(err).to.exist + expect(sent).to.have.length(0) + expect(err.message).to.match(/could not confirm the stake address .* is not asleep \(explorer 503\)/) + }) + + it('withdraws when the sleep resumes at the next block', async function () { + const { err, sent } = await runWith(unstakeValidator, { broadcast: true }, unstakeSdk({ sleeps: [SLEPT(90, TIP + 1)] })) + expect(err).to.equal(null) + expect(sent).to.deep.equal(['UNSTAKE']) + }) +} + +// Registers the cases for deciding which address sleep row wins. +function registerJudgeCases () { + it('lets only the newest valid address sleep from this address decide', function () { + const rows = [ + SLEPT(9, -1), + SLEPT(10, 0), + SLEPT(11, -1, { status: 'invalid: SOURCE (sleeping)' }), + SLEPT(12, -1, { type: 2, tick: 'FOO' }), + SLEPT(13, -1, { source: 'mSomeoneElse' }) + ] + expect(judgeAddressSleep(rows, ADDRESS, TIP + 1)).to.deep.equal({ sleeping: false, resumeBlock: 0, actionIndex: 10 }) + }) + + it('orders by action index as a number, not as text', function () { + const j = judgeAddressSleep([SLEPT('10', -1), SLEPT('9', 0)], ADDRESS, TIP + 1) + expect(j).to.deep.equal({ sleeping: true, resumeBlock: -1, actionIndex: 10 }) + }) + + it('reads a resume block equal to the landing block as awake and one past it as asleep', function () { + expect(judgeAddressSleep([SLEPT(1, TIP + 1)], ADDRESS, TIP + 1).sleeping).to.be.false + expect(judgeAddressSleep([SLEPT(1, TIP + 2)], ADDRESS, TIP + 1).sleeping).to.be.true + }) + + it('reads an address with no sleep rows as awake', function () { + expect(judgeAddressSleep([], ADDRESS, TIP + 1)).to.deep.equal({ sleeping: false, resumeBlock: null, actionIndex: null }) + }) + + it('throws on a deciding row whose resume block is not an integer', function () { + expect(() => judgeAddressSleep([SLEPT(1, 'soon')], ADDRESS, TIP + 1)).to.throw(/resume block/) + }) +} + +// Registers the cases for paging the address sleep lane. +function registerReadCases () { + it('pages the address lane to the end in ascending order', async function () { + const first = Array.from({ length: 100 }, (_, i) => SLEPT(i + 1, 0)) + const pages = [{ total: 101, data: first }, { total: 101, data: [SLEPT(500, -1)] }] + const asked = [] + const sdk = { explorer: { getSleeps: async (q, t, o) => { asked.push([q, t, o]); return pages[o.page - 1] } } } + const j = await readAddressSleep(sdk, ADDRESS, TIP + 1) + expect(j).to.deep.equal({ sleeping: true, resumeBlock: -1, actionIndex: 500 }) + expect(asked[0]).to.deep.equal([ADDRESS, 'address', { page: 1, limit: 100, sortorder: 'ASC' }]) + expect(asked).to.have.length(2) + }) + + it('throws on a page with no row list and on a read cut short', async function () { + const noList = { explorer: { getSleeps: async () => ({ error: 'no route' }) } } + let err = null + try { await readAddressSleep(noList, ADDRESS, TIP + 1) } catch (e) { err = e } + expect(err && err.message).to.match(/no row list/) + const short = { explorer: { getSleeps: async (q, t, o) => (o.page === 1 + ? { total: 150, data: Array.from({ length: 100 }, (_, i) => SLEPT(i + 1, 0)) } + : { total: 150, data: [] }) } } + err = null + try { await readAddressSleep(short, ADDRESS, TIP + 1) } catch (e) { err = e } + expect(err && err.message).to.match(/incomplete: saw 100 of 150/) + }) +} + +describe('ValidatorStakeService', function () { + describe('stakeValidator() from a sleeping stake address', registerStakeRefusals) + describe('unstakeValidator() from a sleeping stake address', registerUnstakeRefusals) + describe('judgeAddressSleep()', registerJudgeCases) + describe('readAddressSleep()', registerReadCases) +}) diff --git a/test/unit/validator_stake_service.test/stake_validator.test.js b/test/unit/validator_stake_service.test/stake_validator.test.js index 6d431294..54f69122 100644 --- a/test/unit/validator_stake_service.test/stake_validator.test.js +++ b/test/unit/validator_stake_service.test/stake_validator.test.js @@ -27,6 +27,10 @@ describe('ValidatorStakeService', function () { expect(out).to.include('25000 XCHAIN is escrowed for as long as you stay staked') expect(out).to.include('cooldown of 1000 blocks (roughly 7 days)') expect(out).to.include('6 blocks it takes to leave the active set') + // The indexer starts both clocks at the UNSTAKE block, so the delay overlaps the cooldown. + expect(out).to.include('counted from the\n block the UNSTAKE lands in') + expect(out).to.include('run inside the cooldown, not before it') + expect(out).to.not.include('on top of') }) it('the post-broadcast summary takes the activation delay from the registry', async function () { @@ -269,7 +273,7 @@ describe('ValidatorStakeService', function () { it('calls explorer methods that the published SDK actually exposes', function () { const { XChainSDK } = require('@dankest-llc/xchain-sdk') const sdk = new XChainSDK({ network: 'bitcoin-testnet' }) // offline: no network I/O in the constructor - for (const m of ['getValidators', 'getAddress', 'getToken', 'getDelegations', 'getStatus']) + for (const m of ['getValidators', 'getAddress', 'getToken', 'getDelegations', 'getStatus', 'getSleeps']) expect(sdk.explorer[m], 'sdk.explorer.' + m).to.be.a('function') for (const m of ['getBalances', 'session']) expect(sdk[m], 'sdk.' + m).to.be.a('function') diff --git a/test/unit/validator_stake_service.test/unstake_validator.test.js b/test/unit/validator_stake_service.test/unstake_validator.test.js index 9aba2d64..8e32ac67 100644 --- a/test/unit/validator_stake_service.test/unstake_validator.test.js +++ b/test/unit/validator_stake_service.test/unstake_validator.test.js @@ -43,7 +43,9 @@ function makeExplorer(chain) { getStatus: chain.statusThrow ? sinon.stub().rejects(new Error(chain.statusThrow)) : sinon.stub().resolves({ last_block: Object.fromEntries( - Object.values(COIN_NETWORKS).map(c => [c.stake, chain.tip ?? TIP])) }) + Object.values(COIN_NETWORKS).map(c => [c.stakeCoin, chain.tip ?? TIP])) }), + // No SLEEP rows, so the stake address reads as awake. + getSleeps: sinon.stub().resolves({ total: 0, data: [] }) } } diff --git a/test/unit/validator_stake_service.test/upstream_reads.test.js b/test/unit/validator_stake_service.test/upstream_reads.test.js index 5621ea4d..31fcfc9f 100644 --- a/test/unit/validator_stake_service.test/upstream_reads.test.js +++ b/test/unit/validator_stake_service.test/upstream_reads.test.js @@ -32,7 +32,7 @@ function fakeSdk(addressBody, tokenBody) { getToken: async () => tokenBody, getValidators: async () => ({ data: [] }), getDelegations: async () => ({ total: 0, data: [] }), - getStatus: async () => ({ last_block: { 'bitcoin-testnet': 200000 } }) + getStatus: async () => ({ last_block: { TBTC: 200000 } }) }, getBalances: async () => ({ data: [{ tick: 'XCHAIN', amount: '0' }] }) } diff --git a/test/unit/validator_stake_service.test/validator_set_paging.test.js b/test/unit/validator_stake_service.test/validator_set_paging.test.js index 3bf483ed..8895209a 100644 --- a/test/unit/validator_stake_service.test/validator_set_paging.test.js +++ b/test/unit/validator_stake_service.test/validator_set_paging.test.js @@ -57,7 +57,8 @@ function makeSdk(getValidators) { getToken: sinon.stub().resolves({ mints: { max: 10000, address_max: 50000 } }), getValidators, getStatus: sinon.stub().resolves({ last_block: Object.fromEntries( - Object.values(COIN_NETWORKS).map(c => [c.stake, TIP])) }) + Object.values(COIN_NETWORKS).map(c => [c.stakeCoin, TIP])) }), + getSleeps: sinon.stub().resolves({ total: 0, data: [] }) }, getBalances: async () => ({ data: [{ tick: 'XCHAIN', amount: '25000' }] }), session: () => ({ diff --git a/test/unit/version_service.test.js b/test/unit/version_service.test.js index e062ba4b..4996bb22 100644 --- a/test/unit/version_service.test.js +++ b/test/unit/version_service.test.js @@ -22,7 +22,6 @@ function makeStubs() { existsSync: sinon.stub().returns(true), readFile: sinon.stub() }, - axiosGet: sinon.stub(), getDockerContainerFileData: sinon.stub(), // Defaults to failing so the pre-existing `docker cp` expectations below // still exercise the fallback path; tests of the preferred exec read @@ -39,7 +38,6 @@ function makeStubs() { function loadVersionService(stubs) { return proxyquire('../../src/services/version_service', { 'fs': stubs.fs, - 'axios': { get: stubs.axiosGet }, '../config/index': require('../../src/config'), '../state': { gitHubDownloader: stubs.gitHubDownloader, @@ -58,46 +56,6 @@ function loadVersionService(stubs) { }) } -describe('VersionService', function () { - - describe('getGithubProjectVersion()', function () { - - it('calls GitHub API with correct URL', async function () { - const stubs = makeStubs() - stubs.axiosGet.resolves({ data: { tag_name: 'v1.2.3', id: 42 } }) - const vs = loadVersionService(stubs) - await vs.getGithubProjectVersion('owner', 'repo') - expect(stubs.axiosGet.firstCall.args[0]).to.equal( - 'https://api.github.com/repos/owner/repo/releases/latest' - ) - }) - - it('strips leading v from tag_name', async function () { - const stubs = makeStubs() - stubs.axiosGet.resolves({ data: { tag_name: 'v1.2.3', id: 42 } }) - const vs = loadVersionService(stubs) - const result = await vs.getGithubProjectVersion('owner', 'repo') - expect(result.version).to.equal('1.2.3') - }) - - it('preserves tag_name without v prefix', async function () { - const stubs = makeStubs() - stubs.axiosGet.resolves({ data: { tag_name: '1.2.3', id: 42 } }) - const vs = loadVersionService(stubs) - const result = await vs.getGithubProjectVersion('owner', 'repo') - expect(result.version).to.equal('1.2.3') - }) - - it('returns the release id', async function () { - const stubs = makeStubs() - stubs.axiosGet.resolves({ data: { tag_name: 'v1.0.0', id: 99 } }) - const vs = loadVersionService(stubs) - const result = await vs.getGithubProjectVersion('owner', 'repo') - expect(result.id).to.equal(99) - }) - }) -}) - describe('VersionService', function () { describe('checkRemoteNodeVersion()', function () {