diff --git a/.github/workflows/nightly-e2e.yml b/.github/workflows/nightly-e2e.yml index 523d0fd..f39e243 100644 --- a/.github/workflows/nightly-e2e.yml +++ b/.github/workflows/nightly-e2e.yml @@ -1,5 +1,20 @@ name: E2E (regtest) +# The RUN title, which is a different string from `name` above and is the one +# that actually distinguishes runs in the Actions list. Without it every leg of +# a three-coin matrix renders as the identical "E2E (regtest)" and the only way +# to tell which chain a run is testing is to open it - which is exactly what you +# are doing when a release matrix is in flight and you want the one that failed. +# +# A dispatch names its coin. A scheduled run covers all three in one run (see the +# matrix on the job), so it says so rather than naming a coin it is not limited +# to. The ref is included because the same workflow grades develop, a release +# branch and a published tag, and "which code" is the second question after +# "which chain". A suite filter is appended only when one is set, so the common +# full-suite run stays short. +run-name: >- + E2E ${{ github.event_name == 'schedule' && 'all coins' || inputs.coin }} regtest @ ${{ inputs.ref || 'develop' }}${{ inputs.suite && format(' [{0} only]', inputs.suite) || '' }} + # Cross-component integration gate. Boots the FULL XChain stack on regtest via # xchain-node - which clones every service repo at the REF THIS RUN NAMES and # runs them as Docker containers - then runs the live xchain-e2e-test suites against it @@ -28,12 +43,23 @@ name: E2E (regtest) # ───────────────────────────────────────────────────────────────────────────── on: - # Manual / on-demand gate. The nightly schedule is intentionally DISABLED while the - # sub-repos are PRIVATE - a scheduled run would fail at the first private clone and - # spam nightly failures. Re-enable it once the repos are public per the launch plan - # (anonymous HTTPS clones just work then, and no SUBREPO_CLONE_TOKEN is needed): - # schedule: - # - cron: '0 7 * * *' # 07:00 UTC nightly + # Nightly gate, RE-ENABLED 2026-09-05. It was disabled only because the sub-repos + # were PRIVATE and a scheduled run would fail at the first clone; they are public + # now, so anonymous HTTPS clones work and no SUBREPO_CLONE_TOKEN is needed. + # + # WHY A NIGHTLY IS WORTH ITS RUNNER TIME. This gate is the only thing that + # exercises consensus and money movement end to end, and a full pass is ~1h50m, + # so a release cut that meets it for the first time discovers a whole train's + # worth of breakage against a two-hour clock and has to restart it. Running the + # same pass every night against develop moves that discovery to a day when + # nobody is waiting. The v0.15.0 cut is the worked example: at cut time the last + # matrix evidence was three days old AND had been taken against master, so the + # entire attestation-mirror and roll-call surface had never once met this gate. + # + # Hosted minutes are free on a public repo, so the cost of the nightly is zero + # and the cost of not having it is a restarted release. + schedule: + - cron: '0 7 * * *' # 07:00 UTC nightly, against the `ref` default below # # NOTE for whoever re-enables the cron: a scheduled workflow runs from the # DEFAULT branch's file, so once develop is the default the nightly boots @@ -107,6 +133,17 @@ permissions: jobs: e2e: runs-on: ubuntu-latest + # WHY THE COIN LIST IS EVENT-DEPENDENT. A scheduled run carries NO inputs, so + # without this it would silently test bitcoin alone and the nightly would be + # two thirds blind: the litecoin and dogecoin legs are where the chain-specific + # breakage actually lands. A dispatch keeps exactly its old behaviour, one + # chosen coin, because a subset proves a fix and must not pretend to be a train. + # The three legs are independent stacks, so fail-fast would throw away two + # answers to report one; a release needs all three verdicts, not the first. + strategy: + fail-fast: false + matrix: + coin: ${{ fromJSON(github.event_name == 'schedule' && '["bitcoin","litecoin","dogecoin"]' || format('["{0}"]', github.event.inputs.coin || 'bitcoin')) }} # A BTC full action suite alone runs ~1h50m of wall clock, and the security # and performance suites are sequenced AFTER it, so at 120 the two of them # shared whatever minutes the action suite happened to leave - usually none. @@ -126,7 +163,77 @@ jobs: # legitimately long pass. timeout-minutes: 360 env: - COIN: ${{ github.event.inputs.coin || 'bitcoin' }} + COIN: ${{ matrix.coin }} + # The anchor-reward attestation barrier holds each block until the hub-mirror + # stream watermark is 120s past that block's own timestamp. On a shared ledger + # that is free, because blocks are ten minutes apart and the watermark is long + # past by the time one is processed. Here blocks are stamped at about wall clock + # and the watermark tracks wall clock, so a freshly mined block can never be + # 120s behind it: the barrier cannot be satisfied and every affected block burns + # its full 60s timeout before proceeding anyway. + # + # Measured on the 2026-09-06 release matrix, before this line existed: the BTC + # leg parsed 367 blocks in six hours and was killed by the job budget, against + # 2013 blocks in 1h52m on the pre-mirror build. 160 deferrals, ~2.7 hours spent + # waiting for a condition that could not arrive. The litecoin and dogecoin legs + # were green in ~2h25m throughout, because this barrier is BTC-only, which is + # exactly what made it look like a bitcoin-specific defect rather than a venue + # constant sized for a different block cadence. + # + # 2s, not 0: the barrier still has to MEAN something here, or the venue stops + # exercising the ordering it exists to enforce and a real mirror-lag defect would + # ride through green. The indexer accepts this ONLY on regtest and ignores it with + # a warning anywhere else, because a watermark grace is a consensus input and a + # per-node value forks settlement - so this cannot leak onto a shared ledger even + # if it is copied somewhere it does not belong. + HUB_SYNC_ANCHOR_ATTEST_GRACE_S: "2" + # Bounds ONE mirror-barrier attempt. On expiry the block is DEFERRED and + # retried, never committed uncertified - the indexer says so outright + # ("purely operational: it opens no barrier and commits no block") - so a + # shorter attempt trades away no safety at all, only the cost of a failure. + # + # It is here because the grace above fixed the barrier that could never be + # satisfied, and revealed a second cost behind it. Measured at the 60s + # default on 2026-09-06: 119 deferrals cost 119 minutes of a 289-minute + # bitcoin leg, 41% of the wall clock. Ten seconds keeps the barrier honest + # while making a deferral cost a tenth as much. + # + # CORRECTION, measured 2026-09-06 off run 34015867460's own artifact: those + # 119 deferrals were NOT a lagging mirror. They all name the SAME block, and + # the stream watermark tracked wall clock throughout (1-6s behind, advancing + # at 0.9999 of real time) while the hub logged no late heartbeat and no + # backpressure close. The block was future-stamped by the COINPay clock jump + # (see XCHAIN_COINPAY_EXPIRATION_S below), so the barrier was waiting on real + # time, not on delivery. This knob therefore makes a failed attempt cheaper + # but recovers no wall clock on its own; the window below is what does. + # + # NOT a consensus input, unlike the grace, so it carries no fork risk - but + # it is still passed through on regtest only, because a shared ledger wants + # the long attempt: there a lagging mirror is a fault worth waiting on. + HUB_PRICE_SYNC_TIMEOUT_MS: "10000" + # COINPay obligation expiration window, in seconds. The shared-ledger value is + # 7200 (two hours), which no e2e suite can wait out, so the COINPay expiry case + # freezes the node clock past the deadline and mines. That stamps the mined + # blocks two hours into the FUTURE, and the anchor-attest barrier above compares + # a block's own timestamp against a wall-clock watermark: the indexer then waits + # those two hours in REAL time on that one block, with every other block behind + # it deferred. + # + # Measured on run 34015867460 (bitcoin, 2026-09-06): blocks 516 and 517 were + # mined at 07:08:10Z and stamped 09:18:01Z, and the indexer deferred block 516 + # 119 times over 2h08m50s. That single stall is 44% of the 289-minute leg and is + # the reason thirty downstream waits gave up. Nothing was lagging. + # + # 300s, not seconds: the case still has to assert the obligation is PENDING + # before it expires it, and a window shorter than that setup window would expire + # the obligation underneath the assertion and make the case flaky in the other + # direction. 300 leaves the clock jump at about five minutes instead of 2h10m. + # + # Consensus input, so the indexer accepts it ONLY on regtest and ignores it with + # a warning anywhere else (resolveCoinpayExpiration); the node's passthrough is + # gated on regtest a second time. Neither gate alone can carry it onto a shared + # ledger, which is what makes a venue-local window safe to set at all. + XCHAIN_COINPAY_EXPIRATION_S: "300" # Drives BOTH the xchain-node checkout below and the `install` boot # argument, so the CLI running the install is the same version as the # stack it installs. Splitting those two was how "we tested the release" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ba5d06..715df5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.15.0] - 2026-09-07 + +### Added +- The indexer's hub mirror is armed on regtest, and the attest response, roll-call rail and oracle batch landing-reserve knobs pass through to the hub and indexer. +- A private explorer can set its own serving limits. +- A reindex forces a bootstrap republish. +- The tracker volume is snapshotted by hardlink and an encoder maintenance window is declared around it. +- `ENCODER_TRUST_PROXY`, `ENCODER_RATE_LIMIT_RPM`, and five explorer per-route rate-limit knobs now pass through from the host env, so a container recreate no longer drops them. + +### Fixed +- A chain daemon is stopped gracefully on update and its release tree is staged before the swap. +- `validator init` no longer mints a hub API key on a re-run, and the CLI sends the key it generated when it pushes config to the hub. +- `HUB_RATE_LIMIT_EXEMPT_LOCAL` passes through to the hub container. +- The hub consensus-env guard derives its key list per network. +- Reset resolves the datadir from the container bind mount and fails closed instead of skipping the chain wipe. +- An explicitly injected null validator settings object is honoured. +- The regtest block-assembly fee floor is lowered beside the relay floor. +- The mainnet federation oracle epoch defaults to its ruled past instant. + +### Activation +- The ATTEST response mirror activates on Bitcoin testnet at block 151324 and on regtest from genesis. Mainnet is unratified and the legacy on-chain response path runs there byte for byte. +- ROLLCALL activates on Bitcoin testnet at block 151200, which the chain has already passed, so it is live from the moment a node updates. Mainnet is unratified. +- Both change state derived from existing bytes on testnet, so an updated node and one still on 0.14.0 judge a mirrored response differently once one lands. Update every indexer and hub together. + ## [0.14.0] - 2026-09-02 ### Fixed diff --git a/README.md b/README.md index 885305a..dea7ff6 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ # XChain Platform Node

- Version - Tests + Version + Tests Node License

@@ -156,11 +156,11 @@ Turn it off with any of: `--no-telemetry` on any command (sticks for future runs | Command | Description | |---|---| -| `npm test` | Unit tests (1,787 tests) | -| `npm run test:integration` | Integration tests (103 tests) | +| `npm test` | Unit tests (1,930 tests) | +| `npm run test:integration` | Integration tests (105 tests) | | `npm run test:smoke` | Smoke tests (159 tests) | | `npm run test:boundary` | Boundary condition tests (57 tests) | -| `npm run test:security` | Security tests (74 tests) | +| `npm run test:security` | Security tests (76 tests) | | `npm run test:e2e` | End-to-end tests (57 tests) | | `npm run test:fuzz` | Fuzz tests (264 tests) | | `npm run test:chaos` | Chaos engineering tests (121 tests) | @@ -168,7 +168,7 @@ Turn it off with any of: `--no-telemetry` on any command (sticks for future runs | `npm run test:regression:p0` | Regression P0: critical gate (33 tests) | | `npm run test:regression:p0p1` | Regression P0+P1: standard gate (51 tests) | | `npm run test:mutation` | Mutation testing (Stryker Mutator) | -| `npm run test:all` | All tests (~2,549 tests; excludes security/boundary) | +| `npm run test:all` | All tests (~2,694 tests; excludes security/boundary) | | `npm run benchmark` | Performance benchmarks (5 scenarios) | | `npm run benchmark:quick` | Quick benchmarks | diff --git a/crypto_nodes/bitcoin/bitcoin-regtest.conf b/crypto_nodes/bitcoin/bitcoin-regtest.conf index 03c5ea6..5abfc7a 100644 --- a/crypto_nodes/bitcoin/bitcoin-regtest.conf +++ b/crypto_nodes/bitcoin/bitcoin-regtest.conf @@ -17,7 +17,9 @@ rpcbind=0.0.0.0 rpcallowip=0.0.0.0/0 rpcport=18444 rpctimeout=30 +# Bitcoin Core 31 replaced the ancestor/descendant SIZE limits with cluster +# limits (limitclustercount/limitclustersize) and no longer parses +# limitdescendantsize, so only the count limit is kept here. limitdescendantcount=2500 -limitdescendantsize=3000 port=18443 bind=0.0.0.0:18443 \ No newline at end of file diff --git a/crypto_nodes/dogecoin/dogecoin-regtest.conf b/crypto_nodes/dogecoin/dogecoin-regtest.conf index ec93802..86e5f7f 100644 --- a/crypto_nodes/dogecoin/dogecoin-regtest.conf +++ b/crypto_nodes/dogecoin/dogecoin-regtest.conf @@ -34,5 +34,12 @@ limitdescendantsize=3000 # kick in. acceptnonstdtxn=1 (regtest default but explicit here) accepts # the non-standard scripts the encoder emits. minrelaytxfee=0.00000001 +# A regtest node must MINE whatever it accepts. minrelaytxfee lowers only the +# RELAY floor; block assembly keeps its own blockmintxfee (default 0.00001/kB), +# so the two have to be lowered together or the node accepts a transaction into +# its mempool and never puts it in a block. Chunked bodies of ~11kB pay a flat +# 5460-sat fee (0.49 sat/vB) and sit below that assembly floor. dogecoind -help +# carries the option on v1.14. +blockmintxfee=0.00000001 limitfreerelay=99999 acceptnonstdtxn=1 diff --git a/crypto_nodes/litecoin/litecoin-regtest.conf b/crypto_nodes/litecoin/litecoin-regtest.conf index dcfc72c..4499138 100644 --- a/crypto_nodes/litecoin/litecoin-regtest.conf +++ b/crypto_nodes/litecoin/litecoin-regtest.conf @@ -21,6 +21,14 @@ rpcthreads=16 # ineffective on the deployed node - keep it flat.) dustrelayfee=0 minrelaytxfee=0.00000001 +# A regtest node must MINE whatever it accepts. minrelaytxfee above lowers only +# the RELAY floor; block assembly keeps its own blockmintxfee (default 0.00001/kB +# = 1 sat/vB), so the two have to be lowered together or the node accepts a +# transaction into its mempool and never puts it in a block. Chunked bodies of +# ~11kB pay a flat 5460-sat fee (0.49 sat/vB) and sit below that assembly floor, +# which stalls the chunk runner and keeps the miner producing blocks forever +# against a mempool that never empties. +blockmintxfee=0.00000001 limitfreerelay=99999 datacarriersize=1000 acceptnonstdtxn=1 diff --git a/package-lock.json b/package-lock.json index 3102c57..f64893e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,17 @@ { "name": "xchain-node", - "version": "0.14.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-node", - "version": "0.14.0", + "version": "0.15.0", "license": "AGPL-3.0-or-later", "dependencies": { "@dankest-llc/xchain-sdk": "^0.11.1", "axios": "^1.18.1", "blessed": "^0.1.81", - "chalk": "^5.6.0", "commander": "^14.0.2", "dotenv": "^16.4.5", "enquirer": "^2.4.1", @@ -2033,6 +2032,7 @@ "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -2351,12 +2351,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "license": "MIT" - }, "node_modules/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -3882,6 +3876,12 @@ "node": ">= 18" } }, + "node_modules/mathjs/node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "license": "MIT" + }, "node_modules/md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", diff --git a/package.json b/package.json index ed165c5..49b68a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xchain-node", - "version": "0.14.0", + "version": "0.15.0", "description": "xchain-node allows users to install, configure and run XChain platform nodes.", "license": "AGPL-3.0-or-later", "repository": { @@ -12,7 +12,6 @@ "@dankest-llc/xchain-sdk": "^0.11.1", "axios": "^1.18.1", "blessed": "^0.1.81", - "chalk": "^5.6.0", "commander": "^14.0.2", "dotenv": "^16.4.5", "enquirer": "^2.4.1", @@ -62,7 +61,8 @@ "tmp": "^0.2.7", "js-yaml": "^4.3.1", "form-data": "^4.0.6", - "ip-address": "^10.3.1" + "ip-address": "^10.3.1", + "decimal.js": "10.4.3" }, "bin": { "xchain-node": "./src/index.js" diff --git a/scripts/publish-bootstraps.sh b/scripts/publish-bootstraps.sh index 245d281..4333a99 100755 --- a/scripts/publish-bootstraps.sh +++ b/scripts/publish-bootstraps.sh @@ -44,6 +44,15 @@ # - Prunes old archives locally and on the sync host, keeping the newest $KEEP # by filename plus the newest $KEEP that are SIGNED (see PRUNE_SCRIPT), so a # prune can never evict the archive the sync host advertises as latest. +# - Forces a republish after a reindex. A `reset` rebuilds a store on a NEW +# lineage, so every archive already published for that combo describes the +# old one and restoring it halts a fresh install. The node records those +# combos and `bootstrap create` clears them, so this run asks +# `xchain-node bootstrap-republish-due` and pulls due combos into the plan +# even when the schedule would have dropped them. Trackers are reported and +# deferred to a --with-trackers run by default (their create means +# downtime); --force-due-trackers overrides that, --no-forced-due disables +# the whole mechanism. # - flock guard so overlapping cron runs cannot collide. # # ┌─ DOWNTIME WARNING ──────────────────────────────────────────────────────┐ @@ -60,6 +69,8 @@ # scripts/publish-bootstraps.sh --trackers-only --all # only trackers (DOWNTIME) # scripts/publish-bootstraps.sh xchain-decoder:litecoin:mainnet # explicit combo(s) # scripts/publish-bootstraps.sh --dry-run --all # show what would run +# scripts/publish-bootstraps.sh --all --force-due-trackers # + republish reindexed trackers now (DOWNTIME) +# scripts/publish-bootstraps.sh --all --no-forced-due # schedule only, ignore reindex markers # # Combos are ::. Services: xchain-decoder, xchain-indexer, # xchain-utxo-tracker. --all auto-detects served combos from the module registry @@ -97,9 +108,16 @@ LOCK_WAIT_MIN="${LOCK_WAIT_MIN:-30}" # minutes to wait out a LOCK_POLL_SEC="${LOCK_POLL_SEC:-30}" # seconds between those attempts TRACKER_SVC="xchain-utxo-tracker" ALL_SERVICES=(xchain-decoder xchain-indexer xchain-utxo-tracker) +# What a coin or network field may contain. Coin and network names are lowercase +# registry identifiers, so anything else in a listing is not a combo this box can +# publish. Deliberately narrower than "no colons": a listing is read from a file +# on disk and its strings become elements of the publish plan, where a payload +# like `xchain-decoder:bitcoin:testnet; rm -rf /` clears a bare [^:]+ filter. +COMBO_FIELD='[a-z0-9][a-z0-9-]*' # ── Flags ───────────────────────────────────────────────────────────────── USE_ALL=0 WITH_TRACKERS=0 TRACKERS_ONLY=0 NO_PUBLISH=0 DRY_RUN=0 ALLOW_UNSIGNED=0 +FORCE_DUE=1 FORCE_DUE_TRACKERS=0 declare -a COMBOS=() while [ $# -gt 0 ]; do case "$1" in @@ -108,9 +126,11 @@ while [ $# -gt 0 ]; do --trackers-only) TRACKERS_ONLY=1; WITH_TRACKERS=1 ;; --no-publish) NO_PUBLISH=1 ;; --allow-unsigned) ALLOW_UNSIGNED=1 ;; + --no-forced-due) FORCE_DUE=0 ;; + --force-due-trackers) FORCE_DUE_TRACKERS=1 ;; --dry-run) DRY_RUN=1 ;; --keep) KEEP="$2"; shift ;; - -h|--help) sed -n '2,60p' "$0"; exit 0 ;; + -h|--help) sed -n '2,94p' "$0"; exit 0 ;; -*) echo "unknown flag: $1" >&2; exit 2 ;; *) COMBOS+=("$1") ;; esac @@ -205,7 +225,7 @@ detect_combos() { local svc_pattern svc_pattern="$(IFS='|'; echo "${ALL_SERVICES[*]}")" "$XCHAIN_NODE_BIN" bootstrap-combos 2>/dev/null \ - | grep -E "^($svc_pattern):[^:]+:[^:]+$" \ + | grep -E "^($svc_pattern):$COMBO_FIELD:$COMBO_FIELD$" \ | sort -u } @@ -215,17 +235,105 @@ if [ "${#COMBOS[@]}" -eq 0 ]; then [ "${#COMBOS[@]}" -gt 0 ] || die "--all detected no served combos. Check that the MariaDB container is up and that '$XCHAIN_NODE_BIN bootstrap-combos' runs (it needs an xchain-node carrying that subcommand)." fi -# Apply tracker policy. -declare -a SELECTED=() +# ── Forced republishes after a reindex ──────────────────────────────────── +# A `reset` wipes a store and rebuilds it on a NEW lineage, so from that moment +# every archive already published for that combo describes the OLD one: a fresh +# install that takes it restores pre-reindex state and halts. No age check +# catches that, because the wrong archive is hours old and perfectly fresh. +# +# The node records those combos (`xchain-node bootstrap-republish-due`) and +# `bootstrap create` clears them, so a due combo is one whose newest published +# archive predates its last reindex. Due combos are pulled into the plan even +# when the schedule would have dropped them, which is the only thing that turns +# "the operator remembers to republish" into a forcing function. +# +# Trackers are the exception BY DEFAULT: their create stops the container, and a +# nightly cron must not take the tracker down on its own initiative. A due +# tracker is reported loudly on every run instead and republished by the weekly +# --with-trackers run; --force-due-trackers overrides that for an operator who +# wants the downtime now. --no-forced-due disables the whole mechanism. +declare -a DUE=() +detect_due() { + local svc_pattern + svc_pattern="$(IFS='|'; echo "${ALL_SERVICES[*]}")" + "$XCHAIN_NODE_BIN" bootstrap-republish-due 2>/dev/null \ + | grep -E "^($svc_pattern):$COMBO_FIELD:$COMBO_FIELD$" \ + | sort -u +} +if [ "$FORCE_DUE" = 1 ]; then + mapfile -t DUE < <(detect_due) + if [ "${#DUE[@]}" -gt 0 ]; then + log "reindexed since their last publish (${#DUE[@]}): ${DUE[*]}" + fi +fi + +is_due() { + local want="$1" d + for d in ${DUE+"${DUE[@]}"}; do [ "$d" = "$want" ] && return 0; done + return 1 +} + +in_list() { + local want="$1"; shift + local x + for x in "$@"; do [ "$x" = "$want" ] && return 0; done + return 1 +} + +# Apply tracker policy. A due combo overrides a skip: that is the forcing. +declare -a SELECTED=() DEFERRED_DUE=() for c in "${COMBOS[@]}"; do svc="${c%%:*}" if [ "$svc" = "$TRACKER_SVC" ]; then - [ "$WITH_TRACKERS" = 1 ] || { log "skip (tracker, needs --with-trackers): $c"; continue; } + if [ "$WITH_TRACKERS" != 1 ]; then + if is_due "$c" && [ "$FORCE_DUE_TRACKERS" = 1 ]; then + log "FORCED (reindexed since last publish; overrides the tracker opt-in, DOWNTIME): $c" + elif is_due "$c"; then + log "DUE but DEFERRED (tracker create means downtime): $c" + DEFERRED_DUE+=("$c") + continue + else + log "skip (tracker, needs --with-trackers): $c"; continue + fi + fi else - [ "$TRACKERS_ONLY" = 1 ] && { log "skip (non-tracker, --trackers-only): $c"; continue; } + if [ "$TRACKERS_ONLY" = 1 ]; then + if is_due "$c"; then + log "FORCED (reindexed since last publish; overrides --trackers-only): $c" + else + log "skip (non-tracker, --trackers-only): $c"; continue + fi + fi fi SELECTED+=("$c") done + +# A due combo that the resolved plan never contained at all (explicit combos on +# the command line, or a registry that no longer lists it) still has a wrong +# archive standing as newest, so pull it in too, under the same tracker rule. +for d in ${DUE+"${DUE[@]}"}; do + in_list "$d" ${SELECTED+"${SELECTED[@]}"} && continue + in_list "$d" ${DEFERRED_DUE+"${DEFERRED_DUE[@]}"} && continue + if [ "${d%%:*}" = "$TRACKER_SVC" ] && [ "$WITH_TRACKERS" != 1 ] && [ "$FORCE_DUE_TRACKERS" != 1 ]; then + log "DUE but DEFERRED (tracker create means downtime): $d" + DEFERRED_DUE+=("$d") + continue + fi + log "FORCED (reindexed since last publish; not in the resolved plan): $d" + SELECTED+=("$d") +done + +warn_deferred_due() { + [ "${#DEFERRED_DUE[@]}" -gt 0 ] || return 0 + log "WARNING: ${#DEFERRED_DUE[@]} combo(s) were reindexed and are still serving a PRE-reindex archive:" + local d + for d in "${DEFERRED_DUE[@]}"; do log " $d"; done + log " A restore of that archive puts a fresh install on the old lineage, which is how it halts." + log " Republish at the next maintenance window (tracker creates mean downtime):" + log " $0 --with-trackers ${DEFERRED_DUE[*]}" +} +warn_deferred_due + [ "${#SELECTED[@]}" -gt 0 ] || die "no combos selected after tracker policy." log "publish plan (${#SELECTED[@]}): ${SELECTED[*]}" @@ -356,5 +464,6 @@ done log "── summary ──" for s in "${SUMMARY[@]}"; do log " $s"; done +warn_deferred_due [ "$fail" = 0 ] && log "all selected combos OK" || log "one or more combos FAILED" exit "$fail" diff --git a/src/GitHubDownloader.js b/src/GitHubDownloader.js index 7ce7305..3ead7d9 100644 --- a/src/GitHubDownloader.js +++ b/src/GitHubDownloader.js @@ -147,24 +147,37 @@ class GitHubDownloader { const repoKey = `${owner}/${repoName}`; const fullOutputPath = path.join(outputPath, `${repoName}`); + // Extract into a staging sibling and swap it in, never into the live tree. + // downloadReleaseAsset flattens the archive's top-level directory only when + // it is the sole entry of the output path, so extracting over a previous + // release's bin/ and share/ left the new release nested one level down: the + // Dockerfile installed the OLD bin/*, and the version file claimed the new + // release (litecoind v0.21.4 reported itself under a v0.21.5.6 version file + // on the regtest rehearsal, 2026-09-03). Staging also keeps the previous + // tree intact when the download or hash check fails. + const stagingPath = fullOutputPath + '.staging'; 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}`); } try { - await this.downloadReleaseAsset(release, fullOutputPath, repoKey, version, verifyHash); + if (fs.existsSync(stagingPath)) { + fs.rmSync(stagingPath, { recursive: true, force: true }); + } + await this.downloadReleaseAsset(release, stagingPath, repoKey, version, verifyHash); + fs.writeFileSync(path.join(stagingPath, version_file_name), version) - if (fs.existsSync(fullOutputPath)) { - fs.writeFileSync(fullOutputPath + "/" + version_file_name, version) - } - + if (fs.existsSync(fullOutputPath)) { + fs.rmSync(fullOutputPath, { recursive: true, force: true }); + } + fs.renameSync(stagingPath, fullOutputPath); return fullOutputPath; } catch (error) { - if (fs.existsSync(fullOutputPath)) { - fs.rmSync(fullOutputPath, { recursive: true }); + if (fs.existsSync(stagingPath)) { + fs.rmSync(stagingPath, { recursive: true, force: true }); } throw error; } diff --git a/src/MariaDbStore.js b/src/MariaDbStore.js index 1cab41e..b2d2113 100644 --- a/src/MariaDbStore.js +++ b/src/MariaDbStore.js @@ -21,6 +21,7 @@ ********************************************************************/ const mariadb = require('mariadb') +const crypto = require('crypto') const { sleep } = require('./utils/helpers') const { NODE_PREFIX, DEFAULT_NODE_PREFIX } = require('./config/constants') const { assertSafeDbIdentifier } = require('./utils/sqlSafety') @@ -44,10 +45,23 @@ const { assertSafeDbIdentifier } = require('./utils/sqlSafety') * because the registry is DERIVED state: precheck runs scanAndRegisterModules * against `docker ps -a` on every command, immediately after createDatabase, so * the first command after the change repopulates it. + * + * THE NAME MUST BE INJECTIVE IN THE PREFIX, and the sanitized head alone is not. + * NODE_PREFIX admits `-`, `.` and `_` (constants.js), and the sanitizer folds the + * first two onto the third, so `stack-a`, `stack.a` and `stack_a` all named ONE + * table; truncation collapsed any two prefixes sharing a long head the same way. + * Two stacks that collide there are back to sharing a registry, which is the + * overwrite-and-purge failure this scoping exists to prevent (uuid:c8e46a8b). So + * the readable head is shortened to make room for a digest of the RAW prefix: + * distinct prefixes now differ in the digest even when the head is identical. + * 8 + 30 + 1 + 12 = 51 characters, inside MariaDB's 64-character identifier limit. */ const MODULES_TABLE = NODE_PREFIX === DEFAULT_NODE_PREFIX ? 'modules' - : assertSafeDbIdentifier('modules_' + NODE_PREFIX.replace(/[^a-z0-9_]/g, '_').substring(0, 40), 'registry table name') + : assertSafeDbIdentifier( + 'modules_' + NODE_PREFIX.replace(/[^a-z0-9_]/g, '_').substring(0, 30) + + '_' + crypto.createHash('sha256').update(NODE_PREFIX).digest('hex').substring(0, 12), + 'registry table name') class MariaDbStore { constructor(config = null) { @@ -198,6 +212,24 @@ class MariaDbStore { } } + // Answer the same question as getModuleContainer, but only from evidence. + // getModuleContainer returns null for a genuine zero-row miss AND for every + // SQL error AND for an unopened pool, so a registry blip is indistinguishable + // from "not installed". A caller that acts DESTRUCTIVELY on that answer then + // skips stopping a live service and wipes its store underneath it + // (uuid:846cc40d). Here the pool is asserted and the query error propagates, + // so only an empty result set means absent. + async getModuleContainerStrict(module, coin, network) { + this.assertReady(`the ${module} registry lookup`) + const rows = await this.pool.query( + `SELECT container_id FROM ${MODULES_TABLE} + WHERE module = ? AND coin = ? AND network = ?`, + [module, coin || '', network || ''] + ) + if (rows.length === 0) return null + return rows[0].container_id + } + async removeModuleContainer(module, coin, network) { if (!this.pool) return false try { diff --git a/src/cli.js b/src/cli.js index f76dd9f..e3ecd24 100644 --- a/src/cli.js +++ b/src/cli.js @@ -41,6 +41,7 @@ const { getStatus } = require('./services/StatusService') const { scanAndRegisterModules } = require('./services/DiscoveryService') const { maybeReportTelemetry } = require('./services/TelemetryService') const { makeBootstrap, listServedBootstrapCombos } = require('./services/BootstrapService') +const { listRepublishDue } = require('./services/BootstrapRepublishLedger') const { initValidator, getValidatorSettings, isInitialized, getCapabilityConfigHostPath, readWallets, publicWalletInfo, getSignerMountDir, COIN_NETWORKS, WALLETS_FILE, getRollcallStatus } = require('./services/ValidatorService') @@ -108,7 +109,7 @@ async function parseCommand() { // updateconfig round-trip on multi-coin nodes. Any command NOT listed here // (install, update, start, stop, restart, uninstall, reset, sync, …) still // pushes; the default is to sync, so a new/unknown command stays safe. - const readOnlyCommands = ['ps', 'tail', 'logs', 'monitor', 'tailmonitor', 'bootstrap-combos'] + const readOnlyCommands = ['ps', 'tail', 'logs', 'monitor', 'tailmonitor', 'bootstrap-combos', 'bootstrap-republish-due'] // Commands that mutate stack state (containers, images, DBs, config // pushes). Two of these interleaving from concurrent shells can corrupt an // install mid-flight, so they serialize on a pidfile lock; a second @@ -142,6 +143,13 @@ async function parseCommand() { // It stays listed in mutatingCommands above so that a real // implementation, which would drop this early return, is serialized. if (commandName === 'rollback') return + // `bootstrap-republish-due` reads one local JSON file and prints it. It + // must not provision Docker/MariaDB or take the command lock: the + // publisher asks it on EVERY run, and a read-only command that waits out + // a lock holder exits non-zero, which the publisher would read as "no + // combo is due" and silently drop the forced republish this whole + // mechanism exists to guarantee. + if (commandName === 'bootstrap-republish-due') return // preCheck provisions shared containers/DB/hub (buildDatabaseModule, // ensureXchainNodeAccess, scanAndRegisterModules, installHubModule) for @@ -361,6 +369,31 @@ gate could report them, so the cron exited 0 while a consumer archive went stale return process.exit(0) }) + program + .command('bootstrap-republish-due') + .description('List combos whose published bootstrap predates their last reindex (scriptable)') + .option('--json', 'emit the full records (reindexedAt, publishedAt, reason) instead of bare combos') + .addHelpText('after', ` +A reset wipes a store and rebuilds it on a NEW lineage, so every bootstrap +already published for that combo describes the old one: a fresh install that +takes it restores pre-reindex state and halts. Nothing forced a republish, and +no age check catches it, because the stale-lineage archive is hours old and +simply wrong. + +\`reset\` records the combos it wiped, \`bootstrap create\` records what it +re-derived, and this lists the difference. scripts/publish-bootstraps.sh reads +it to pull due combos into its plan even when the schedule or the tracker +opt-in would have skipped them.`) + .action(async (options) => { + const due = listRepublishDue() + if (options && options.json) { + console.log(JSON.stringify(due, null, 2)) + } else { + for (const entry of due) console.log(entry.combo) + } + return process.exit(0) + }) + program .command('sync') .description('Scan Docker for xchain-node containers and register any missing in the database') @@ -627,6 +660,7 @@ Notes: .option('--import-stake-key', 'use your own BTC stake key: prompts for the WIF (or set XCHAIN_NODE_STAKE_WIF)') .option('--import-doge-key', 'use your own DOGE publisher key: prompts for the WIF (or set XCHAIN_NODE_DOGE_WIF)') .option('--no-wallets', 'skip wallet generation (you run your own signer via XCHAIN_NODE_HUB_SIGNER_DIR)') + .option('--mint-hub-api-key', 'on a re-run, generate a HUB_API_KEY if this host has none (401s every consumer that carries no key)') .option('--force', 'overwrite existing validator config (generates a NEW signing key; wallets are kept)') .option('--force-wallets', 'also replace existing wallets (the old addresses and any coin at them are abandoned)') .action(async (opts) => { diff --git a/src/coins/BTC.js b/src/coins/BTC.js index f36c86f..c846405 100644 --- a/src/coins/BTC.js +++ b/src/coins/BTC.js @@ -284,6 +284,26 @@ module.exports = { OWNERSHIP_ESCROW: 50000, AIRDROP_PER_RECIPIENT: 100, DIVIDEND_PER_RECIPIENT: 100, + // SWEEP / CALLBACK, priced on the unified schedule from the + // UNIFIED_FEES_SWEEP_CALLBACK flag day. The legacy flat per-DB-hit fee prices a + // small action BELOW the dust threshold of a native-fee chain (LTC/DOGE, where a + // missing fee output is rejected outright rather than falling back to an XCHAIN + // balance debit), so the fee output cannot be created and the action cannot be + // submitted at all: a Litecoin SWEEP needs ~273 DB hits before it clears LTC's + // 5460-satoshi floor at LTC $100 / XCHAIN $2. + // + // The BASE keys are what close that: gas is what buys the output, so the SMALLEST + // possible SWEEP or CALLBACK has to buy an above-dust one on its own. The floor a + // chain demands is dust_sats * COIN_USD / (1000 * XCHAIN_USD) gas units, so 5000 gas + // (0.05 XCHAIN) clears Litecoin while COIN/XCHAIN stays under ~915 and Dogecoin + // while it stays under ~50, both far outside any plausible band. The PER_ITEM keys + // hold the marginal cost at AIRDROP/DIVIDEND per-recipient parity; a SWEEP item is + // one swept balance, one closed order/swap/dispenser escrow, or one transferred + // ownership. + SWEEP_BASE: 5000, + SWEEP_PER_ITEM: 100, + CALLBACK_BASE: 5000, + CALLBACK_PER_RECIPIENT: 100, // BET (parimutuel betting, spec decision F): feed creation is duration- // metered like ORDER/SWAP/DISPENSER expiration (same free window via // UNIFIED_EXPIRATION_FEE_FREE_DAYS) but under its OWN per-day key so the diff --git a/src/coins/DOGE.js b/src/coins/DOGE.js index 71bd5d5..0ec1a85 100644 --- a/src/coins/DOGE.js +++ b/src/coins/DOGE.js @@ -218,6 +218,26 @@ module.exports = { OWNERSHIP_ESCROW: 50000, AIRDROP_PER_RECIPIENT: 100, DIVIDEND_PER_RECIPIENT: 100, + // SWEEP / CALLBACK, priced on the unified schedule from the + // UNIFIED_FEES_SWEEP_CALLBACK flag day. The legacy flat per-DB-hit fee prices a + // small action BELOW the dust threshold of a native-fee chain (LTC/DOGE, where a + // missing fee output is rejected outright rather than falling back to an XCHAIN + // balance debit), so the fee output cannot be created and the action cannot be + // submitted at all: a Litecoin SWEEP needs ~273 DB hits before it clears LTC's + // 5460-satoshi floor at LTC $100 / XCHAIN $2. + // + // The BASE keys are what close that: gas is what buys the output, so the SMALLEST + // possible SWEEP or CALLBACK has to buy an above-dust one on its own. The floor a + // chain demands is dust_sats * COIN_USD / (1000 * XCHAIN_USD) gas units, so 5000 gas + // (0.05 XCHAIN) clears Litecoin while COIN/XCHAIN stays under ~915 and Dogecoin + // while it stays under ~50, both far outside any plausible band. The PER_ITEM keys + // hold the marginal cost at AIRDROP/DIVIDEND per-recipient parity; a SWEEP item is + // one swept balance, one closed order/swap/dispenser escrow, or one transferred + // ownership. + SWEEP_BASE: 5000, + SWEEP_PER_ITEM: 100, + CALLBACK_BASE: 5000, + CALLBACK_PER_RECIPIENT: 100, // BET (parimutuel betting, spec decision F): feed creation is duration- // metered like ORDER/SWAP/DISPENSER expiration (same free window via // UNIFIED_EXPIRATION_FEE_FREE_DAYS) but under its OWN per-day key so the diff --git a/src/coins/LTC.js b/src/coins/LTC.js index f02d1e3..3f26fe9 100644 --- a/src/coins/LTC.js +++ b/src/coins/LTC.js @@ -213,6 +213,26 @@ module.exports = { OWNERSHIP_ESCROW: 50000, AIRDROP_PER_RECIPIENT: 100, DIVIDEND_PER_RECIPIENT: 100, + // SWEEP / CALLBACK, priced on the unified schedule from the + // UNIFIED_FEES_SWEEP_CALLBACK flag day. The legacy flat per-DB-hit fee prices a + // small action BELOW the dust threshold of a native-fee chain (LTC/DOGE, where a + // missing fee output is rejected outright rather than falling back to an XCHAIN + // balance debit), so the fee output cannot be created and the action cannot be + // submitted at all: a Litecoin SWEEP needs ~273 DB hits before it clears LTC's + // 5460-satoshi floor at LTC $100 / XCHAIN $2. + // + // The BASE keys are what close that: gas is what buys the output, so the SMALLEST + // possible SWEEP or CALLBACK has to buy an above-dust one on its own. The floor a + // chain demands is dust_sats * COIN_USD / (1000 * XCHAIN_USD) gas units, so 5000 gas + // (0.05 XCHAIN) clears Litecoin while COIN/XCHAIN stays under ~915 and Dogecoin + // while it stays under ~50, both far outside any plausible band. The PER_ITEM keys + // hold the marginal cost at AIRDROP/DIVIDEND per-recipient parity; a SWEEP item is + // one swept balance, one closed order/swap/dispenser escrow, or one transferred + // ownership. + SWEEP_BASE: 5000, + SWEEP_PER_ITEM: 100, + CALLBACK_BASE: 5000, + CALLBACK_PER_RECIPIENT: 100, // BET (parimutuel betting, spec decision F): feed creation is duration- // metered like ORDER/SWAP/DISPENSER expiration (same free window via // UNIFIED_EXPIRATION_FEE_FREE_DAYS) but under its OWN per-day key so the diff --git a/src/coins/consensus_pin.js b/src/coins/consensus_pin.js index 50ce2d0..53a2113 100644 --- a/src/coins/consensus_pin.js +++ b/src/coins/consensus_pin.js @@ -66,16 +66,25 @@ module.exports = { // so the public testnet announces with zero pre-announcement test actions. // Same one-wave rule as every regeneration above. Regtest and mainnet are // untouched and were re-verified as unchanged by this edit. + // REGENERATED 2026-09-01: GAS_SCHEDULE gains SWEEP_BASE, + // SWEEP_PER_ITEM, CALLBACK_BASE and CALLBACK_PER_RECIPIENT, the unified prices + // SWEEP and CALLBACK move onto at the UNIFIED_FEES_SWEEP_CALLBACK flag day (both + // networks UNARMED; regtest genesis-active). GAS_SCHEDULE is hashed whole by + // consensusSubset(), so ADDING a key moves every hash even while the flag that + // reads it is unarmed, and the same one-wave rollout rule as every regeneration + // above applies in full: every service bundling these must ship the new values + // together, and a straggler fail-closes on verifyConsensusPin() at boot rather + // than forking. Mainnet stays null (Phase 6 arms it). testnet: { - BTC: 'f6589c6b88dc930db05998070ef0b73743f58623a0d23fbc30fdb158c49d1427', - LTC: '9faf066a1470be2486d8a2cd121548ca02de1397d0678a2ab8dc0e712ebfa8fd', - DOGE: '2991d7e7caf2b212de959dd5831ac1477e0b13da95ac1ed8c2b43e2704732439', + BTC: 'd3c66a4fb288b2666a2a4fad85200bbeac162bb36fed8a3eddcfc7b2d4d48070', + LTC: 'ae94a951a838e64f9c36e503b978d9b9ad5ea74f7b443465baaabca8f675ea0d', + DOGE: 'b90aec4381b0ad32caba078706c8fb244cbe267390e41668fa063d9e64fb60e6', }, regtest: { - BTC: '24e6a363e5a36285574dea357328a997fdee5762ef812d8947eacf69c51afc24', - LTC: '5ad03b383d873d309640e75dfefa2787a5806cb8a84ee46f4cc7fb25ca7f808b', - DOGE: '019220a461e34c99fcf5cbf107673f13d3f2a57d2a20e16a0323ed44c81edd11', + BTC: '29976bd33cad1842320c57acdc849250646adea765f70a0ae5dad3f201f7d5d7', + LTC: 'bca62db9f59a6f7566620b086380c10fffac08dabe99f00a4fcc7cd038e46146', + DOGE: '816632e9f6647e726042282c37789ae8d924e8d4a1b2995ddde8d6a54a0bba54', }, }, }; diff --git a/src/config/constants.js b/src/config/constants.js index dbb9f2f..caa98fd 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -32,6 +32,13 @@ const SEP = "-" const DB_SEP = "_" const HUB_PORT = 10000 +// Docker healthcheck grace for a container whose probe judges a HARD DEPENDENCY's +// startup, not its own boot. Both such steps are 60s: MariaDB's server init and +// the utxo-tracker's first sync. Such a probe must be granted a window at least +// as long as the step it judges, or it reports that startup as a failure, so +// every dependent window derives from this one name and follows when it is raised. +const DEPENDENCY_HEALTH_START_PERIOD = '60s' + // External (host-native) MariaDB mode. When XCHAIN_NODE_EXTERNAL_DB=1, the // CLI skips provisioning its own dockerized MariaDB and instead expects a // reachable MariaDB at the configured host/port. All managed services @@ -364,6 +371,7 @@ module.exports = { SEP, DB_SEP, HUB_PORT, + DEPENDENCY_HEALTH_START_PERIOD, EXTERNAL_DB, EXTERNAL_DB_HOST, EXTERNAL_DB_PORT, diff --git a/src/github_hashes.json b/src/github_hashes.json index e29c4c4..ecc104a 100644 --- a/src/github_hashes.json +++ b/src/github_hashes.json @@ -1,5 +1,9 @@ { "bitcoin/bitcoin": { + "v31.1": { + "x86_64": "b80d9c3e04da78fb6f0569685673418cf686fadba9042d926d13fb87ff503f9e", + "aarch64": "dcf1873f2208ba4f962f3398d47e154c39c0084be8f4553e05c940d0ace3d004" + }, "v28.1": { "x86_64": "07f77afd326639145b9ba9562912b2ad2ccec47b8a305bd075b4f4cb127b7ed7", "aarch64": "6ddb6990690bd4c9a9f4319ed6f6e9c995c85ce5530ee9f120e80ce09e090c44" @@ -12,6 +16,10 @@ } }, "litecoin-project/litecoin": { + "v0.21.5.6": { + "x86_64": "3c0a217651a431ef446641669a0b74ce7dbcd9b9ed1a118fc830b8f6779ee83f", + "aarch64": "81c3ca2a7fcbccaabaf0a0ea2022f1990787f0cc1937aaad4dcc61d2856799a8" + }, "v0.21.4": { "x86_64": "857fc41091f2bae65c3bf0fd4d388fca915fc93a03f16dd2578ac3cc92898390", "aarch64": "517e3a9069e658eb92de98c934c61836589ee2410d99464a768a5698985926c9" diff --git a/src/operations/moduleOperations.js b/src/operations/moduleOperations.js index cba9fe0..b4f1f64 100644 --- a/src/operations/moduleOperations.js +++ b/src/operations/moduleOperations.js @@ -25,12 +25,13 @@ const { NODE_MODULE_NAME, DB_MODULE_NAME, HUB_MODULE_NAME, EXPLORER_MODULE_NAME, const { db } = require('../state') const { sleep } = require('../utils/helpers') const { getDockerContainerImageName, getUtxoTrackerVolumeName, filterCommandParameters, getDockerNetwork } = require('../services/ConfigService') -const { createDockerNetwork, killContainer, removeContainer, forceRemoveContainerByName, probeContainerPresenceByName, stopContainer, startContainer, restartContainer, execContainer, shellContainer, logContainer, startDockerMonitor, waitContainer, saveContainerLogs } = require('../services/DockerService') -const { buildDatabaseModule, resetDatabases, clearHubPriceIngestWatermark, getDatabaseContainerId } = require('../services/DatabaseService') +const { createDockerNetwork, killContainer, removeContainer, probeContainerPresenceByName, stopContainer, startContainer, restartContainer, execContainer, shellContainer, logContainer, startDockerMonitor, waitContainer, saveContainerLogs, getContainerBindMounts } = require('../services/DockerService') +const { buildDatabaseModule, resetDatabases, clearHubPriceIngestWatermark, getDatabaseContainerId, pingExternalDatabase } = require('../services/DatabaseService') const { getModuleBranch, installModule, uninstallModule } = require('../services/ModuleService') const { assertHubNotBehind } = require('../services/SkewGuardService') const { assertRequiredMigrationsApplied } = require('../services/MigrationPreconditionService') const { statusChanged } = require('../services/StatusService') +const { reindexAffectedModules, recordReindex } = require('../services/BootstrapRepublishLedger') // Resolve the operator's single ref slot into an install target and publish it // for the duration of the run, so every module clone and every bundled-library @@ -230,17 +231,15 @@ async function updateModulesOnBranch(servicesList, branch = null) { } const moduleContainerId = await db.getModuleContainer(nextModule, nextCoin, nextNetwork) if (nextModule === NODE_MODULE_NAME) { - // Tear down the existing node container before rebuilding. The node - // branch of installModule calls buildCryptoNode, which `docker run - // --name`s the node but never removes a prior container of that name - // on `update` that collided and crashed (unhandled rejection). - // Remove by NAME so it also clears a leftover Created-state carcass - // the module registry no longer tracks; a no-op on a clean or - // already-missing node. (Done here rather than inside buildCryptoNode - // to keep that hot path, shared with fresh `install`, untouched; - // the node is briefly down during the image rebuild, which an update - // implies anyway.) - await forceRemoveContainerByName(getDockerContainerImageName(NODE_MODULE_NAME, nextCoin, nextNetwork)) + // The running node is deliberately left alone here. buildCryptoNode + // stops it gracefully (SIGTERM with a flush budget) and force-removes + // the stopped carcass right before its `docker run --name`, so the + // daemon keeps serving through the download and image build and its + // block index is flushed before it goes. An up-front `docker rm -f` + // at this point was SIGKILL: the killed daemon came back at its last + // flushed index (16 regtest blocks lost, 2026-09-03), and it also + // hid the old container from buildCryptoNode's bind-mount drift guard. + // // Recreate even when the container was missing from the registry: // the old `if (!moduleContainerId) continue` made `update node` a // silent no-op (exit 0, nothing created) once the node had crashed or @@ -732,14 +731,35 @@ function isNoSuchContainerError(err) { return /no such container/i.test(String(err.message || err.stderr || '')) } +// True when a docker error means the named volume is already gone. Same rule +// isNoSuchContainerError uses, and the one DockerService.probeContainerPresenceByName +// states: docker SAYING "no such volume" is the only thing that means absent; +// every other failure is unknown and must be treated as possibly-present. +function isNoSuchVolumeError(err) { + if (!err || typeof err === 'string') return false + return /no such volume/i.test(String(err.message || err.stderr || '')) +} + +// The operator-facing reason for a rejected docker or registry call. Handles the +// bare-string rejection stopContainer can produce as well as an Error. +function failureReason(err) { + return (err && err.message) || String(err) +} + // Put back the services an aborted reset already stopped, so the abort leaves // the stack as it found it rather than half torn down. Returns the modules that // could not be restarted, for the operator message. +// +// Reads the registry strictly: getModuleContainer answers null on a SQL error as +// well as on a miss, so a rollback run during the very registry outage that +// caused the abort restarted nothing and still reported zero failures +// (uuid:846cc40d). A lookup that fails now lands in `failed` and is named in the +// STILL DOWN line. async function restartStoppedModules(modules, coin, network) { const failed = [] for (const module of modules) { try { - const containerId = await db.getModuleContainer(module, coin, network) + const containerId = await db.getModuleContainerStrict(module, coin, network) if (!containerId) continue await startContainer(containerId) } catch { @@ -749,6 +769,50 @@ async function restartStoppedModules(modules, coin, network) { return failed } +/** + * Resolve the HOST directory that holds this chain's node datadir, asking the + * node container itself first. + * + * `dataDir` is env-derived (XCHAIN_NODE_DATA_DIR, else the in-repo data/), so a + * shell that never sourced the operator's profile resolves a path the stack has + * never used. The wipe was guarded on fs.existsSync of that path, so the guard + * went silently false and `reset all` reported success with the chain untouched. + * The container name is already resolved deterministically from the prefix and + * coin/network, so use that same key to read the datadir off the container's own + * bind mounts: whatever the daemon actually writes to is what a reset must wipe. + * + * Falls back to the env-derived path only when it really is on disk. Returns + * path=null when neither answer exists, and the caller fails closed on that + * rather than skipping the wipe. + * + * @returns {Promise<{path: (string|null), resolvedFrom: (string|null), configuredPath: string, containerName: string}>} + */ +async function resolveNodeDataPath(coin, network) { + const containerName = getDockerContainerImageName(NODE_MODULE_NAME, coin, network) + const configuredPath = path.join(dataDir, NODE_MODULE_NAME, coin, network) + + let mounts = [] + try { + mounts = await getContainerBindMounts(containerName) + } catch { /* no container, or docker unreachable: fall through to the configured path */ } + const dataMount = (Array.isArray(mounts) ? mounts : []) + .find(m => m && m.destination === `/root/.${coin}` && m.source) + if (dataMount) { + return { + path: dataMount.source, + resolvedFrom: `the /root/.${coin} bind mount of container ${containerName}`, + configuredPath, + containerName + } + } + + if (fs.existsSync(configuredPath)) { + return { path: configuredPath, resolvedFrom: 'the configured data dir', configuredPath, containerName } + } + + return { path: null, resolvedFrom: null, configuredPath, containerName } +} + // The service names `reset` can act on. `reset` is the only destructive CLI path // and the only one that bypasses resolveArgs/filterCommandParameters, so it must // validate its own raw args: without this an unrecognised service (a typo, or a @@ -819,10 +883,50 @@ async function resetModules(service, coin, network, force = false, withIndexer = const blocksHostPath = blocksDir ? `${blocksDir}/${coin}/${network}` : null const txindexHostPath = blocksDir ? `${blocksDir}/${coin}/${network}-txindex` : null + // Resolve the node datadir BEFORE anything is stopped or confirmed, and + // refuse the whole reset by name when it cannot be resolved. The + // old code re-derived the path from XCHAIN_NODE_DATA_DIR at the wipe site + // and skipped the wipe whenever that path was absent, so a reset run from a + // profile-less shell wiped the decoder/indexer DBs, left the chain in place, + // and exited 0; the missing "Clearing node data" line was the only tell. + // "Not installed" stays a legitimate skip, and is stated out loud. + let nodeDataPath = null + if (resetNode) { + let nodeInstalled = null + let registryReadable = true + try { + nodeInstalled = await db.getModuleContainer(NODE_MODULE_NAME, coin, network) + } catch { registryReadable = false } + + const resolved = await resolveNodeDataPath(coin, network) + if (resolved.path) { + nodeDataPath = resolved.path + if (path.resolve(nodeDataPath) !== path.resolve(resolved.configuredPath)) { + console.log(`Node datadir resolved from ${resolved.resolvedFrom}: ${nodeDataPath}`) + console.log(` (XCHAIN_NODE_DATA_DIR in this shell would have pointed at ${resolved.configuredPath})`) + } + } else if (registryReadable && !nodeInstalled) { + console.log(`No ${NODE_MODULE_NAME} container is installed for ${coin} ${network}; there is no node data to clear.`) + } else { + const envState = process.env.XCHAIN_NODE_DATA_DIR && process.env.XCHAIN_NODE_DATA_DIR.trim() !== '' + ? `set to ${process.env.XCHAIN_NODE_DATA_DIR}` + : 'UNSET in this shell (non-interactive shells do not source the profile)' + console.log(`Aborted: cannot resolve the ${coin} ${network} node datadir. No data was touched.`) + console.log(` Container ${resolved.containerName} reported no /root/.${coin} bind mount ` + + '(it is absent, or docker is unreachable from here).') + console.log(` The configured path ${resolved.configuredPath} does not exist either.`) + console.log(` XCHAIN_NODE_DATA_DIR is ${envState}.`) + console.log(' Set XCHAIN_NODE_DATA_DIR to this stack\'s data root (or make docker reachable so the') + console.log(' node container can be inspected) and re-run. Refusing rather than resetting the') + console.log(' databases around a chain that would stay untouched.') + return false + } + } + if (!force) { const targets = [] if (resetNode) { - targets.push('node datadir') + if (nodeDataPath) targets.push(`node datadir (${nodeDataPath})`) if (blocksDir) { targets.push(`relocated blocks dir (${blocksHostPath})`) targets.push(`relocated txindex dir (${txindexHostPath})`) @@ -843,20 +947,31 @@ async function resetModules(service, coin, network, force = false, withIndexer = } } - // Fail fast BEFORE any destructive wipe: in docker (non-external) mode a - // DB reset needs the MariaDB container, and resetDatabases would otherwise - // `docker exec null` and abort mid-reset with node/utxo data already wiped - // (the half-reset failure the EXTERNAL_DB branch already guards). Probe here - // so nothing is touched when the container is gone (uuid:6f6584dc). It sits - // ahead of the stop loop, not after it: this abort returns before the restart - // pass, so probing later left every already-stopped service DOWN while still - // reporting that no data was touched (uuid:bb190060). + // Fail fast BEFORE any destructive wipe: a DB reset needs a working MariaDB, + // and resetDatabases is not reached until AFTER the stop loop and every wipe + // below, so discovering the problem there half-destroys the stack. In docker + // mode the failure is `docker exec null` with the container gone + // (uuid:6f6584dc); in EXTERNAL_DB mode it is an unreachable host, or a + // getExternalDbConfig throw on a partial env, and NOTHING probed for it + // (uuid:41887889). Both modes are probed here. It sits ahead of the stop + // loop, not after it: this abort returns before the restart pass, so probing + // later left every already-stopped service DOWN while still reporting that + // no data was touched (uuid:bb190060). const dbResetNeeded = resetDecoder || resetIndexer - if (dbResetNeeded && !EXTERNAL_DB) { - const dbContainerId = await getDatabaseContainerId() - if (!dbContainerId) { - console.log('Aborted: MariaDB container not found; install the database first. No data was touched.') - return false + if (dbResetNeeded) { + if (EXTERNAL_DB) { + const probe = await pingExternalDatabase() + if (!probe.ok) { + console.log(`Aborted: cannot reach the external MariaDB at ${probe.host}:${probe.port}` + + ` (${probe.error}). No data was touched.`) + return false + } + } else { + const dbContainerId = await getDatabaseContainerId() + if (!dbContainerId) { + console.log('Aborted: MariaDB container not found; install the database first. No data was touched.') + return false + } } } @@ -875,14 +990,37 @@ async function resetModules(service, coin, network, force = false, withIndexer = // (uuid:9c88cfe6). Only a "no such container" miss is still a legitimate // skip; the registry miss is already handled by the null check. const stoppedModules = [] + // Refuse the whole reset, put back whatever this run stopped, and report it. + // Only reachable while nothing has been wiped yet, which is why it may still + // promise that no data was touched. + const abortBeforeAnyWipe = async (reason) => { + const restartFailures = await restartStoppedModules(stoppedModules, coin, network) + console.log(`Aborted: ${reason}. No data was touched.`) + if (stoppedModules.length > 0) { + console.log(` Restarted ${stoppedModules.length - restartFailures.length} of ` + + `${stoppedModules.length} already-stopped service(s).`) + } + if (restartFailures.length > 0) { + console.log(` STILL DOWN, start by hand: ${restartFailures.join(', ')}`) + } + return false + } for (const module of modulesToStop) { let containerId = null try { - containerId = await db.getModuleContainer(module, coin, network) - } catch { continue /* not installed, skip */ } - // getModuleContainer returns null on a registry miss rather than - // throwing, so only this explicit check can skip "not installed"; - // without it stopContainer(null) fails and now ABORTS the reset + containerId = await db.getModuleContainerStrict(module, coin, network) + } catch (err) { + // A registry read that FAILED is not evidence the module is absent. + // The swallowing read this replaced answered null on any SQL error, so + // a blip after the reachability precheck made a live indexer look + // uninstalled: the loop skipped stopping it and the wipes below, + // resetDatabases included, ran underneath it (uuid:846cc40d). + return await abortBeforeAnyWipe( + `cannot read the ${module} registry row (${failureReason(err)}), so it is not known ` + + 'whether that service is running') + } + // A SUCCESSFUL read with no row is still a legitimate "not installed" + // skip; without this check stopContainer(null) fails and ABORTS the reset // (uuid:fd7cc224 sibling site). if (!containerId) continue try { @@ -890,25 +1028,48 @@ async function resetModules(service, coin, network, force = false, withIndexer = stoppedModules.push(module) } catch (err) { if (isNoSuchContainerError(err)) continue - const restartFailures = await restartStoppedModules(stoppedModules, coin, network) - const reason = (err && err.message) || String(err) - console.log(`Aborted: ${module} failed to stop (${reason}). No data was touched.`) - if (stoppedModules.length > 0) { - console.log(` Restarted ${stoppedModules.length - restartFailures.length} of ` - + `${stoppedModules.length} already-stopped service(s).`) - } - if (restartFailures.length > 0) { - console.log(` STILL DOWN, start by hand: ${restartFailures.join(', ')}`) + return await abortBeforeAnyWipe(`${module} failed to stop (${failureReason(err)})`) + } + } + + // Classify the tracker volume's presence BEFORE the first wipe. The wipe + // below swallowed every failure as "the volume may not exist", so a + // permission error, an unreachable daemon or a failed alpine pull left stale + // tracker data behind while the decoder/indexer databases were dropped and + // the run reported success (uuid:e24c98d4). Only docker SAYING "no such + // volume" is absence; anything else refuses here, while the stack is whole. + let utxoVolumeName = null + let utxoVolumePresent = false + if (resetUtxoTracker) { + // Name the volume through the shared helper so it carries NODE_PREFIX + // (uuid:7523dd94): an unprefixed name resolves to the DEFAULT_NODE_PREFIX + // stack's volume and wipes that one instead of the intended target. + utxoVolumeName = getUtxoTrackerVolumeName(coin, network) + try { + await execFileAsync('docker', ['volume', 'inspect', utxoVolumeName]) + utxoVolumePresent = true + } catch (err) { + if (!isNoSuchVolumeError(err)) { + return await abortBeforeAnyWipe( + `cannot determine whether the Docker volume ${utxoVolumeName} exists ` + + `(${failureReason(err)})`) } - return false + console.log(`No Docker volume ${utxoVolumeName} to clear.`) } } + // 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 + if (resetNode) { - const nodeDataPath = path.join(dataDir, NODE_MODULE_NAME, coin, network) - if (fs.existsSync(nodeDataPath)) { + // 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 } // Relocated blocks/txindex (XCHAIN_NODE_BLOCKS_DIR) live outside the // datadir, so wipe them here too or the daemon restarts over stale @@ -917,19 +1078,34 @@ async function resetModules(service, coin, network, force = false, withIndexer = 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 (resetUtxoTracker) { - // Routed through the shared helper (uuid:7523dd94): the unprefixed name - // used here previously wiped the DEFAULT_NODE_PREFIX stack's volume - // under a non-default NODE_PREFIX, silently missing the intended target. - const volumeName = getUtxoTrackerVolumeName(coin, network) + if (resetUtxoTracker && utxoVolumePresent) { try { - console.log(`Clearing Docker volume ${volumeName}...`) - await execFileAsync('docker', ['run', '--rm', '-v', `${volumeName}:/data`, 'alpine', 'sh', '-c', 'find /data -mindepth 1 -delete']) - } catch { /* volume may not exist, skip */ } + console.log(`Clearing Docker volume ${utxoVolumeName}...`) + await execFileAsync('docker', ['run', '--rm', '-v', `${utxoVolumeName}:/data`, 'alpine', '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) + } } const dbModulesToReset = [ @@ -958,6 +1134,40 @@ async function resetModules(service, coin, network, force = false, withIndexer = } } + // A reset is a REINDEX: from here the wiped stores rebuild on a new lineage, + // and every bootstrap archive already published for these combos describes + // the old one. Without a marker nothing forces a republish, so the + // stale-lineage archive stays newest until the next scheduled run (up to a + // week for a tracker, which is opt-in besides) and no age check catches it, + // because the file is hours old and simply wrong. Mark the combos DUE so + // the publisher pulls them into its next plan regardless of schedule or + // tracker opt-in. + // + // Best-effort by design: the wipes already happened, so a bookkeeping + // failure must never abort the restart pass and leave the stack down. It is + // reported loudly instead, with the command to publish by hand. + const reindexedModules = reindexAffectedModules({ + node: resetNode, utxoTracker: resetUtxoTracker, decoder: resetDecoder, indexer: resetIndexer + }) + if (reindexedModules.length > 0) { + try { + const marked = recordReindex(reindexedModules, coin, network, { reason: `reset ${service}` }) + if (marked.length > 0) { + console.log(`Marked ${marked.length} bootstrap combo(s) for republish after this reindex: ${marked.join(', ')}`) + } else { + throw new Error('the republish ledger could not be written') + } + } catch (err) { + console.warn('WARNING: could not record this reindex in the bootstrap republish ledger: ' + + ((err && err.message) ? err.message : err)) + console.warn(' The published archives for these combos are now from the PRE-reset lineage and') + console.warn(' nothing will force a republish. Republish by hand once the stack has caught up:') + for (const module of reindexedModules) { + console.warn(` xchain-node bootstrap create ${module} ${coin} ${network}`) + } + } + } + console.log(`Restarting ${coin} ${network} services...`) // Track restart failures instead of swallowing them: a silent skip here // left a wiped stack DOWN (node never restarted, every dependent service @@ -968,14 +1178,21 @@ async function resetModules(service, coin, network, force = false, withIndexer = for (const module of modulesToStop) { let containerId = null try { - containerId = await db.getModuleContainer(module, coin, network) - } catch { continue /* not installed, skip */ } - // getModuleContainer never throws on a registry miss (MariaDbStore - // returns null), so the catch above cannot catch "not installed" - - // only this explicit null check can. Without it, startContainer(null) - // fails on every branch and every `reset all` on mainnet/testnet - // (where the regtest-only miner has no registry row) reports a false - // failure after the reset actually succeeded (uuid:fd7cc224). + containerId = await db.getModuleContainerStrict(module, coin, network) + } catch (err) { + // Strict, like the stop loop: the swallowing read answered null on a + // SQL error too, so a registry blip here left a just-wiped service + // DOWN and still reported a clean reset (uuid:846cc40d). Nothing can + // be undone at this point, so report it with the other start + // failures rather than aborting. + startFailures.push({ module, error: `registry lookup failed (${failureReason(err)})` }) + continue + } + // A SUCCESSFUL read with no row is "not installed" and stays a skip. + // Without this check startContainer(null) fails on every branch, and + // every `reset all` on mainnet/testnet (where the regtest-only miner has + // no registry row) reports a false failure after the reset actually + // succeeded (uuid:fd7cc224). if (!containerId) continue /* not installed, skip */ try { await startContainer(containerId) diff --git a/src/release-manifest.json b/src/release-manifest.json index 684ebce..d1978f5 100644 --- a/src/release-manifest.json +++ b/src/release-manifest.json @@ -1,68 +1,67 @@ { "_comment": [ - "Pinned component set for XChain Platform v0.14.0.", - "Written at ceremony step 6 from the ACTUAL tagged master merge commits.", + "Pinned component set for XChain Platform v0.15.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-indexer and xchain-hub", - "(the attestation responsible-set liveness ladder, flag day BTC testnet 150780) and", - "the carrier; xchain-documentation is tagged with the train but not pinned here.", + "A train tags only the repos it touches. This one moves xchain-vm, xchain-decoder, xchain-indexer, xchain-hub, xchain-sync, xchain-encoder, xchain-utxo-tracker, xchain-explorer, xchain-sdk, xchain-e2e-test, xchain-contracts and xchain-regtest-miner.", "Every other component is unchanged and keeps the tag it already carries, which is", "what section 4 means by a version being the platform version at which a component", "last changed. A gap is unchanged, not skipped.", - "Each commit below is the master MERGE commit its tag names, and every tag is", - "GPG-signed by the platform release key and reports verified against the", - "tagger identity releases@xchain.io." + "Each commit below is the master MERGE commit its tag names. This run verified,", + "against origin, that every tag exists, resolves to a commit reachable from that", + "repo's origin/master, and is a GPG-signed annotated tag verifying against the", + "platform release key (fingerprint 1DA7C4896F56EA22CF491EDF4361611A82F90B70)." ], - "platform_version": "0.14.0", - "released": "2026-09-02", + "platform_version": "0.15.0", + "released": "2026-09-07", "components": { "xchain-vm": { - "tag": "v0.12.0", - "commit": "d58db3af879e072d0255cb5b6bb9cfd3e78cbd0c" + "tag": "v0.15.0", + "commit": "5dde39f33274d282665cd7d538f559190ee05327" }, "xchain-decoder": { - "tag": "v0.12.0", - "commit": "9f333337c1362ff2651aa8877d7a5445476b5b12" + "tag": "v0.15.0", + "commit": "087c1c5f19b0702899ecf77a9ae09963a7433b52" }, "xchain-indexer": { - "tag": "v0.14.0", - "commit": "e0d183eaf4011859b9c4f44c34112d61d43be28f" + "tag": "v0.15.0", + "commit": "994d1a98598b0b3ada00f99390fabfaea6254a64" }, "xchain-hub": { - "tag": "v0.14.0", - "commit": "e7f3b9728847a3f7916d6e410bc0f727e6b2b844" + "tag": "v0.15.0", + "commit": "053cc98bd3c64e90b1b8a9b04c1f72f64ba76c5c" }, "xchain-sync": { - "tag": "v0.12.0", - "commit": "62ff717444a302ce27b9fdcd167d62446a29a6ee" + "tag": "v0.15.0", + "commit": "a526063dba24883efe46a538f3b2351c5468819c" }, "xchain-encoder": { - "tag": "v0.12.0", - "commit": "7b88df99958016e6f993fcd31a854cb5f985af20" + "tag": "v0.15.0", + "commit": "bbe86aae9e5ed3157d084c22604f87a2bbde67e3" }, "xchain-utxo-tracker": { - "tag": "v0.12.0", - "commit": "34949dee6e2a94f5e0557d22296409555e6867cf" + "tag": "v0.15.0", + "commit": "30cf73a6e1ce07584584700d8b3f7eb232e625c9" }, "xchain-explorer": { - "tag": "v0.12.0", - "commit": "b13d41e63f5ab4250da0023b6f12ae5c9e585b69" + "tag": "v0.15.0", + "commit": "d38f613e39f1b088af87eafc1088c3587f32cb5b" }, "xchain-sdk": { - "tag": "v0.12.0", - "commit": "ba223637c77230c671e2f35f40a43a8b33b4220c" + "tag": "v0.15.0", + "commit": "c7ffcec5b647639c95b27227199e0c9f73790906" }, "xchain-e2e-test": { - "tag": "v0.12.0", - "commit": "4da014da256e78b7b00982e122492f2bb5ede703" + "tag": "v0.15.0", + "commit": "33f797f315fcc9693bc257553e0905af4b0067c0" }, "xchain-contracts": { - "tag": "v0.12.0", - "commit": "426067b26c66377e8875ca9826a8913fa32e9699" + "tag": "v0.15.0", + "commit": "f04c51476dca663f95cb9f26fdd82cd6d0dee12b" }, "xchain-regtest-miner": { - "tag": "v0.12.0", - "commit": "ac0038f876d99435d4e82ade8ce3b7dfc6214e36" + "tag": "v0.15.0", + "commit": "ae42be8dd0291ff69caca04a0f0f50c983268fc1" } } } diff --git a/src/services/AutohealService.js b/src/services/AutohealService.js index e4da66f..ac6e914 100644 --- a/src/services/AutohealService.js +++ b/src/services/AutohealService.js @@ -161,6 +161,29 @@ function getUnhealthySinceMs(health) { return since } +// When the newest PASSING probe in Health.Log ran, or null when the retained +// entries hold no pass. Reads Start (the same clock getUnhealthySinceMs seeds +// the onset from) so the two values compare like for like, falling back to End +// only when Start is unparseable. Never throws on a missing or garbage log. +// +// This is the only positive evidence of a RECOVERY the retained log can carry. +// Its absence means nothing either way: five entries at a 15s probe interval +// span ~60-75s, so an older pass has simply rotated out. runAutoheal treats it +// that way, resetting the episode only on a pass it can actually see. +function getLastHealthyProbeMs(health) { + const log = Array.isArray(health && health.Log) ? health.Log : [] + for (let i = log.length - 1; i >= 0; i--) { + const entry = log[i] + if (!entry || entry.ExitCode !== 0) continue + const at = Date.parse(entry.Start) + if (!Number.isNaN(at)) return at + const end = Date.parse(entry.End) + if (!Number.isNaN(end)) return end + return null + } + return null +} + // One autoheal pass over the module registry. Never throws for a single bad // container (a vanished container id must not abort the whole sweep). // Returns { candidates, restarted, failed, skipped } where each array holds @@ -271,11 +294,23 @@ async function runAutoheal({ dryRun = false, now = Date.now() } = {}) { // ~60-75s back and a 120s grace window is unreachable. Seed // from the derived value so a container already wedged when autoheal first // runs is credited the episode Docker can still see. + // Two halves of one rule. Reset the onset when the retained probes + // POSITIVELY show a pass after it: a recovery-then-relapse that falls + // entirely between two passes is never observed by the `!== unhealthy` + // branch above, so without this the new episode inherits the old one's + // clock and gets restarted inside its own grace window. Preserve the + // onset when the log merely ROTATED older evidence away, which is the + // ordinary case: absence of a pass is not evidence of one. let since = state.unhealthySince[containerId] + const lastHealthy = getLastHealthyProbeMs(health) if (typeof since !== 'number' || !Number.isFinite(since)) { since = Math.min(now, derived) state.unhealthySince[containerId] = since onsetChanged = true + } else if (typeof lastHealthy === 'number' && lastHealthy > since) { + since = Math.min(now, derived) + state.unhealthySince[containerId] = since + onsetChanged = true } if (now - since < graceMs) { @@ -340,9 +375,8 @@ async function runAutoheal({ dryRun = false, now = Date.now() } = {}) { module.exports = { runAutoheal, getUnhealthySinceMs, - getStateFilePath, + getLastHealthyProbeMs, restartBackoffMs, - DEFAULT_GRACE_MS, DEFAULT_COOLDOWN_MS, DEFAULT_COOLDOWN_CEILING_MS } diff --git a/src/services/BootstrapHealthGate.js b/src/services/BootstrapHealthGate.js index 48e196a..79ba896 100644 --- a/src/services/BootstrapHealthGate.js +++ b/src/services/BootstrapHealthGate.js @@ -140,6 +140,30 @@ function evaluateContainerState(raw, { now = Date.now() } = {}) { return reasons } +// Parse whitespace-separated COUNT(*) output from a `mariadb -BN` probe into +// whole nonnegative integers, throwing on anything else. +// +// `parseInt` is the wrong tool for a fail-closed probe: it reads a PREFIX and +// discards the rest, so '0garbage' becomes 0 and '-1' becomes -1, and both then +// survive `Number.isFinite` and lose every `> 0` comparison the gate makes. That +// turns unreadable probe output into a healthy zero, which is exactly the "we +// could not tell" -> "it is fine" collapse the file header forbids. The token +// count is checked too: a truncated multi-count answer that happens to parse is +// still not the answer to the question that was asked. +// +// `what` names the probe for the refusal reason; `max` bounds a count whose only +// legal values are known (a table-existence count is 0 or 1). +function parseCountTokens(raw, { expected, max = null, what }) { + const text = String(raw == null ? '' : raw).trim() + const tokens = text.length === 0 ? [] : text.split(/\s+/) + const bad = tokens.length !== expected + || tokens.some(t => !/^\d+$/.test(t)) + || (max !== null && tokens.some(t => Number(t) > max)) + if (bad) + throw new Error(`the ${what} returned unreadable output: ${JSON.stringify(text)}`) + return tokens.map(Number) +} + async function inspectContainer(containerId, runner) { // RestartCount is top-level, NOT under .State. `{{.State.RestartCount}}` is not a // field that reads empty, it is a template-execution ERROR ("map has no entry for @@ -176,10 +200,18 @@ function evaluateStatusPayload(payload, { maxLag = DEFAULT_MAX_LAG_BLOCKS } = {} // marker probe is fail-soft on purpose (a DB blip keeps the last known state), // and that state starts at false with checked_at null, so a decoder that has // NEVER completed a probe publishes exactly what a clean one publishes. Keyed on - // OWNING reorg_halted: the boolean and its timestamp shipped in the same decoder - // commit, so a payload carrying one always carries the other, and an image - // publishing neither is unaffected. The indexer's decoderReorgHalted has no - // companion timestamp yet; extend this to it when the indexer publishes one. + // OWNING reorg_halted, and the pairing is per SURFACE rather than per image: the + // decoder's JSON-RPC `health` result has always carried the timestamp, its GET + // /status body did not until the field was added there (xchain-decoder + // src/api.js), and its /live body still publishes the boolean alone. Only the + // first two are probed here (see probeServiceStatus, which tries JSON-RPC `health` + // then GET /status and nothing else), so an image predating that /status field is + // refused by this leg on the fallback path. That is the intended fail-closed + // direction and costs nothing today, because such a body carries no lag field + // either and the lag leg below already refuses it. Do not restate this as "one + // field implies the other": that claim was false for the /status fallback for as + // long as it stood here. The indexer's decoderReorgHalted has no companion + // timestamp yet; extend this to it when the indexer publishes one. if (Object.prototype.hasOwnProperty.call(payload, 'reorg_halted') && (payload.reorg_halt_checked_at === null || payload.reorg_halt_checked_at === undefined)) reasons.push('the decoder has never completed a REORG_HALT marker probe (reorg_halt_checked_at is ' + @@ -322,17 +354,14 @@ async function readHaltMarkers(coin, network, module, deps) { return String(stdout || '') } - // Read one count, refusing on anything that is not a number. Output the probe - // could not produce (an empty string from a mis-parsed client option, a driver - // that returned nothing, a permission error rendered on stdout) parsed to NaN - // here, and NaN loses every `> 0` comparison below, so "we could not tell" - // arrived at the caller as "no halt markers" - the one collapse the file - // header forbids. + // Read one count, refusing on anything that is not a whole nonnegative + // integer. Output the probe could not produce (an empty string from a + // mis-parsed client option, a driver that returned nothing, a permission + // error rendered on stdout) used to reach `parseInt` and lose every `> 0` + // comparison below, so "we could not tell" arrived at the caller as "no halt + // markers" - the one collapse the file header forbids. const readCount = async (sql, what) => { - const raw = String(await run(sql)).trim() - const value = parseInt(raw, 10) - if (!Number.isFinite(value)) - throw new Error(`the ${what} probe returned unreadable output: ${JSON.stringify(raw)}`) + const [value] = parseCountTokens(await run(sql), { expected: 1, what: `${what} probe` }) return value } @@ -348,11 +377,13 @@ async function readHaltMarkers(coin, network, module, deps) { `(SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='${name}' AND TABLE_NAME='events'), ` + `(SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='${name}' AND TABLE_NAME='sync_halt');` - const rawTables = String(await run(query)).trim() - const tableCounts = rawTables.split(/\s+/).map(n => parseInt(n, 10)) - if (tableCounts.length !== 2 || !tableCounts.every(Number.isFinite)) - throw new Error(`the marker-table probe for ${name} returned unreadable output: ${JSON.stringify(rawTables)}`) - const [hasEvents, hasSyncHalt] = tableCounts + // Two tokens, each 0 or 1: TABLE_SCHEMA + TABLE_NAME is unique in + // information_schema.TABLES, so any other value means the output is not the + // answer to the question that was asked. Capping at 1 is what makes + // `10garbage` a refusal instead of a silent [1, 0] that skips the + // sync_halt probe entirely. + const [hasEvents, hasSyncHalt] = parseCountTokens(await run(query), + { expected: 2, max: 1, what: `marker-table probe for ${name}` }) // `events` is not optional on a decoder/indexer database: both provision it // unconditionally at startup (each repo's verifyTables creates every @@ -521,8 +552,5 @@ module.exports = { // Exported for tests / reuse evaluateContainerState, evaluateStatusPayload, - probeServiceStatus, - readHaltMarkers, - CRASH_LOOP_UPTIME_MS, - DEFAULT_MAX_LAG_BLOCKS + parseCountTokens } diff --git a/src/services/BootstrapRepublishLedger.js b/src/services/BootstrapRepublishLedger.js new file mode 100644 index 0000000..74a7246 --- /dev/null +++ b/src/services/BootstrapRepublishLedger.js @@ -0,0 +1,264 @@ +/********************************************************************* + * + * 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 - bootstrap republish ledger (reindex -> forced republish) + * + * The published bootstrap for a combo is only useful while it belongs to the + * SAME lineage as the chain the fleet is running. A reindex (a `reset` that + * wipes the node datadir, the tracker volume, or a decoder/indexer database and + * rebuilds it) starts a new lineage on this box, and from that moment the + * newest published archive describes the OLD one: a fresh install that takes it + * restores pre-reindex state and then halts or diverges the first time it meets + * a block the old lineage disagreed about. + * + * A timer alone cannot force that republish. The publisher runs nightly for + * decoder/indexer and weekly for trackers (trackers opt-in besides, because + * their create takes the container down), so a stale-lineage archive can stand + * as newest for up to a week, and no age check catches it: the file itself is + * hours old, perfectly fresh, and completely wrong. + * + * So the reindex itself is the trigger. `reset` records the combos it wiped + * here; `bootstrap create` records what it published; a combo whose reindex is + * NEWER than its last publish is DUE, and the publisher pulls due combos into + * its plan even when the schedule or the tracker opt-in would have skipped them. + * + * The ledger lives in the per-user ~/.xchain-node dir (with credentials.json and + * command.lock), NOT under XCHAIN_NODE_DATA_DIR: + * + * - a `reset` wipes paths under the data dir, so a marker there is erased by + * the very event it exists to record, and + * - the publisher runs `bootstrap create` with XCHAIN_NODE_DATA_DIR pointed at + * its own staging volume, so a data-dir marker written by a reset would not + * even be on the path the publisher reads. + * + * Every write is best-effort and non-fatal: a reset that already wiped a store + * must not abort because a bookkeeping file could not be written. Every read is + * fail-soft on I/O but STRICT on content - a combo key is re-validated against + * the known service/coin/network sets before it is returned, because the + * publisher feeds these strings into its shell plan. + ********************************************************************/ + +const fs = require('fs') +const os = require('os') +const path = require('path') + +const { XChainService, Coin, Network } = require('../config/constants') + +const LEDGER_DIR_NAME = '.xchain-node' +const LEDGER_FILE_NAME = 'bootstrap-reindex.json' +const LEDGER_VERSION = 1 + +// The combos that have a published bootstrap at all. xchain-hub archives are +// created by a different path and are not part of the served fan-out. +const BOOTSTRAPPED_SERVICES = [ + XChainService.XCHAIN_UTXO_TRACKER, + XChainService.XCHAIN_DECODER, + XChainService.XCHAIN_INDEXER +] + +function getReindexLedgerPath() { + // XCHAIN_NODE_REINDEX_LEDGER_DIR is a test/ops override; the default matches + // the CredentialsService per-user directory. + const dir = process.env.XCHAIN_NODE_REINDEX_LEDGER_DIR || path.join(os.homedir(), LEDGER_DIR_NAME) + return path.join(dir, LEDGER_FILE_NAME) +} + +function comboKey(module, coin, network) { + return `${module}:${coin}:${network}` +} + +// Split and re-validate a key read back from disk. Returns null for anything +// that is not a combo this node could actually publish, so a corrupt or +// tampered ledger cannot smuggle a token into the publisher's plan. +function parseComboKey(key) { + if (typeof key !== 'string') return null + const parts = key.split(':') + if (parts.length !== 3) return null + const [module, coin, network] = parts + if (!BOOTSTRAPPED_SERVICES.includes(module)) return null + if (!Object.values(Coin).includes(coin)) return null + if (!Object.values(Network).includes(network)) return null + return { combo: key, module, coin, network } +} + +// An ISO-8601 instant, or null. Anything unparseable is treated as absent +// rather than as "epoch": a garbled publish timestamp must not make a due combo +// look published, and a garbled reindex timestamp must not force an endless +// republish loop. +function parseInstant(value) { + if (typeof value !== 'string' || value === '') return null + const ms = Date.parse(value) + return Number.isFinite(ms) ? ms : null +} + +function emptyLedger() { + return { version: LEDGER_VERSION, combos: {} } +} + +function readReindexLedger() { + let raw + try { + raw = fs.readFileSync(getReindexLedgerPath(), 'utf8') + } catch { + return emptyLedger() // absent (the normal case on a box that never reindexed) + } + let parsed + try { + parsed = JSON.parse(raw) + } catch { + return emptyLedger() // corrupt: start clean rather than throw inside a reset + } + if (!parsed || typeof parsed !== 'object' || !parsed.combos || typeof parsed.combos !== 'object') { + return emptyLedger() + } + const combos = {} + for (const [key, entry] of Object.entries(parsed.combos)) { + if (!parseComboKey(key)) continue + if (!entry || typeof entry !== 'object') continue + combos[key] = { + reindexedAt: typeof entry.reindexedAt === 'string' ? entry.reindexedAt : null, + publishedAt: typeof entry.publishedAt === 'string' ? entry.publishedAt : null, + reason: typeof entry.reason === 'string' ? entry.reason : null + } + } + return { version: LEDGER_VERSION, combos } +} + +// Atomic replace (write a sibling temp file, then rename) so a crash or a +// concurrent reader never sees a half-written ledger. Returns false instead of +// throwing: every caller is doing bookkeeping alongside work that already +// happened. +function writeReindexLedger(ledger) { + const target = getReindexLedgerPath() + const tmp = `${target}.${process.pid}.tmp` + try { + fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }) + fs.writeFileSync(tmp, JSON.stringify(ledger, null, 2) + '\n', { mode: 0o600 }) + fs.renameSync(tmp, target) + return true + } catch { + try { fs.unlinkSync(tmp) } catch { /* nothing staged */ } + return false + } +} + +/** + * Which published combos a reset puts in doubt: exactly the stores it wiped. + * + * A wiped store is re-derived, and after a genesis or protocol change it is + * re-derived DIFFERENTLY, so its published archive can no longer be assumed to + * match. That is a question for a human, which is what a due marker asks. + * + * A wiped NODE datadir deliberately marks nothing on its own. It resyncs the + * same chain from peers and leaves every derived store untouched and still + * valid, so fanning out from it would warn about three combos on every ordinary + * node resync. The case that really does stale the derived archives is a + * re-genesis, and that is run as `reset all`, which wipes those stores directly + * and marks them through their own flags. Even a re-genesis leaves the + * utxo-tracker archives valid, because the tracker follows the raw chain UTXO + * set and consumes no firstBlock. + */ +// `node` is accepted and deliberately unused, so the caller can pass the reset +// flags whole and the decision above stays readable at this one site. +function reindexAffectedModules({ node = false, utxoTracker = false, decoder = false, indexer = false } = {}) { + const affected = [] + if (utxoTracker) affected.push(XChainService.XCHAIN_UTXO_TRACKER) + if (decoder) affected.push(XChainService.XCHAIN_DECODER) + if (indexer) affected.push(XChainService.XCHAIN_INDEXER) + return affected +} + +/** + * Record that these modules were reindexed for coin/network. Returns the combo + * keys marked (empty when there was nothing to mark or the write failed). + */ +function recordReindex(modules, coin, network, { at = new Date(), reason = null } = {}) { + const when = at instanceof Date ? at.toISOString() : String(at) + const ledger = readReindexLedger() + const marked = [] + for (const module of modules || []) { + const key = comboKey(module, coin, network) + if (!parseComboKey(key)) continue + const prev = ledger.combos[key] || {} + ledger.combos[key] = { + reindexedAt: when, + publishedAt: prev.publishedAt || null, + reason: reason || prev.reason || null + } + marked.push(key) + } + if (marked.length === 0) return [] + return writeReindexLedger(ledger) ? marked : [] +} + +/** + * Record that a bootstrap was created (and so is about to be published) for a + * combo. This is what clears a due marker: the new archive belongs to the + * post-reindex lineage. + */ +function recordBootstrapPublished(module, coin, network, { at = new Date() } = {}) { + const key = comboKey(module, coin, network) + if (!parseComboKey(key)) return false + const ledger = readReindexLedger() + const prev = ledger.combos[key] + if (!prev) return true // never reindexed: nothing to clear, and no reason to grow the file + ledger.combos[key] = { + reindexedAt: prev.reindexedAt || null, + publishedAt: at instanceof Date ? at.toISOString() : String(at), + reason: prev.reason || null + } + return writeReindexLedger(ledger) +} + +// A combo is due when it has been reindexed and no publish has happened since. +// A missing publish timestamp is due by construction; equal timestamps are NOT +// due, so a publish that lands in the same millisecond as its own marker does +// not re-trigger itself forever. +function isRepublishDue(entry) { + if (!entry) return false + const reindexedAt = parseInstant(entry.reindexedAt) + if (reindexedAt === null) return false + const publishedAt = parseInstant(entry.publishedAt) + if (publishedAt === null) return true + return publishedAt < reindexedAt +} + +/** + * Every combo whose published archive predates its last reindex, sorted so the + * output is stable for scripts and diffs. + */ +function listRepublishDue(ledger = readReindexLedger()) { + const due = [] + for (const [key, entry] of Object.entries(ledger.combos || {})) { + const parsed = parseComboKey(key) + if (!parsed) continue + if (!isRepublishDue(entry)) continue + due.push({ ...parsed, reindexedAt: entry.reindexedAt, publishedAt: entry.publishedAt, reason: entry.reason }) + } + return due.sort((a, b) => a.combo.localeCompare(b.combo)) +} + +module.exports = { + BOOTSTRAPPED_SERVICES, + LEDGER_VERSION, + getReindexLedgerPath, + comboKey, + parseComboKey, + readReindexLedger, + writeReindexLedger, + reindexAffectedModules, + recordReindex, + recordBootstrapPublished, + isRepublishDue, + listRepublishDue +} diff --git a/src/services/BootstrapService.js b/src/services/BootstrapService.js index 57f9e77..cf77c39 100644 --- a/src/services/BootstrapService.js +++ b/src/services/BootstrapService.js @@ -34,6 +34,8 @@ const { getDatabaseContainerId, ensureDatabasePool, getExternalDbConfig, execute const { assertSafeArchiveMemberNames, redactSecrets } = require('../utils/helpers') const { dockerMariadbArgs, mariadbEnv } = require('../utils/dockerMariadb') const { assertBootstrapSourceHealthy } = require('./BootstrapHealthGate') +const { recordBootstrapPublished } = require('./BootstrapRepublishLedger') +const { declareEncoderMaintenance, clearEncoderMaintenance } = require('./EncoderMaintenanceWindow') // Bootstrap signing (supply-chain integrity): // @@ -435,12 +437,167 @@ async function makeBootstrap(coin, network, module) { // first so an unknown module still fails on its own message. await assertBootstrapSourceHealthy(coin, network, module) - switch (module) { - case XChainService.XCHAIN_UTXO_TRACKER: - return makeBootstrapUtxoTracker(coin, network) - default: - return makeBootstrapMariaDb(coin, network, module) + const result = module === XChainService.XCHAIN_UTXO_TRACKER + ? await makeBootstrapUtxoTracker(coin, network) + : await makeBootstrapMariaDb(coin, network, module) + + // A fresh archive is a fresh LINEAGE, so it clears any republish this + // combo was owed after a reset (see BootstrapRepublishLedger). Recorded on + // CREATE rather than after the upload: the create is what re-derives the + // archive from the post-reindex store, and an upload that then fails is + // already reported as PUBLISH-FAIL and retried by the next scheduled run. + // Never fatal - the archive exists either way. + try { + recordBootstrapPublished(module, coin, network) + } catch (err) { + console.log(`Warning: could not clear the bootstrap republish marker for ${module} ${coin}/${network} (${err.message}).`) + } + + return result +} + +// Where the pre-compress hardlink snapshot of the tracker volume lives. +// +// It has to sit INSIDE the volume, because a hardlink cannot cross a +// filesystem, and the volume is the only thing mounted into the helper +// container. It is a sibling of the LevelDB store (LevelUpDb opens +// /data/, never /data itself), so the running tracker never looks at +// it. Fixed literal with no shell metacharacters: it is interpolated into the +// snapshot shell script below. +const TRACKER_SNAPSHOT_DIR = '.xchain-bootstrap-snapshot' + +// Freeze the tracker volume without keeping the tracker down for the compress. +// +// Taken while the container is STOPPED, so the store is quiescent and the +// result is a byte-exact point-in-time copy. Two passes, because the two kinds +// of file in a classic-level store need different treatment: +// +// 1. Hardlink everything. Compaction afterwards unlinks the SSTs it merges +// away, but the snapshot's links keep those inodes alive, so the compress +// reads the store as it stood at the stop even though the tracker has been +// serving traffic for hours by then. That is the whole trick, and it is +// only sound because an .ldb/.sst file is written once and never edited. +// +// 2. Replace the link with a real copy for every OTHER regular file. LevelDB +// appends to its live MANIFEST and write-ahead log in place, and a +// hardlink shares the inode, so those would follow the live store forward +// and the archive would carry a manifest describing SSTs it does not hold. +// In practice leveldb opens a fresh MANIFEST/log per open (reuse_logs is +// off) and rewrites CURRENT via rename, so the links would usually survive +// untouched; "usually" is not a property to hand a published mainnet +// bootstrap. These files are kilobytes-to-megabytes next to a 162 GB +// store, so copying them costs nothing measurable. +// +// Cost while the snapshot is held: the volume keeps every SST the tracker +// compacts away during the run, so it needs headroom for that churn rather than +// for a second full copy. The snapshot is dropped in the caller's finally. +// +// Rejects when the volume's filesystem will not take the hardlinks; the caller +// falls back to the old behavior (compress with the container stopped). +async function snapshotTrackerVolume(volumeName) { + const snapPath = `/data/${TRACKER_SNAPSHOT_DIR}` + const script = [ + 'set -e', + `rm -rf ${snapPath}`, + `mkdir -p ${snapPath}`, + // -mindepth 1 -maxdepth 1 walks only the volume's top level, and the + // ! -name guard keeps the snapshot from copying itself. + `find /data -mindepth 1 -maxdepth 1 ! -name ${TRACKER_SNAPSHOT_DIR} -exec cp -al {} ${snapPath}/ ';'`, + // Pass 2. The .xcsnap guard keeps a temp file from being re-processed + // if the directory walk sees one mid-flight. + `find ${snapPath} -type f ! -name '*.ldb' ! -name '*.sst' ! -name '*.xcsnap'` + + ` -exec sh -c 'cp -a "$1" "$1.xcsnap" && mv -f "$1.xcsnap" "$1"' _ {} ';'` + ].join('\n') + + await execFileAsync('docker', [ + 'run', '--rm', '-v', `${volumeName}:/data`, 'alpine', 'sh', '-c', script + ]) + return true +} + +// Headroom the volume should have spare before we pin its compaction churn for +// the length of a compress, as a fraction of the store size. A guess by +// construction (nobody can predict a run's write amplification), so it only +// gates a warning. +const TRACKER_SNAPSHOT_HEADROOM_RATIO = 0.15 + +async function warnOnThinTrackerVolume(volumeName, totalBytes) { + if (!totalBytes || totalBytes <= 0) return + let availableBytes = 0 + try { + const { stdout } = await execFileAsync('docker', [ + 'run', '--rm', '-v', `${volumeName}:/data`, 'alpine', 'df', '-Pk', '/data' + ]) + const cols = stdout.trim().split('\n').pop().trim().split(/\s+/) + availableBytes = (parseInt(cols[3], 10) || 0) * 1024 + } catch { + return // unknown free space: nothing honest to say } + const wanted = Math.round(totalBytes * TRACKER_SNAPSHOT_HEADROOM_RATIO) + if (availableBytes >= wanted) return + const gb = bytes => (bytes / 1024 / 1024 / 1024).toFixed(1) + console.log( + `WARNING: ${volumeName} has ${gb(availableBytes)} GB free against a ${gb(totalBytes)} GB store.\n` + + `The snapshot holds every SST the tracker compacts away while the archive is built, so a long\n` + + `run on this volume can fill it and halt the tracker. Free space or expect a stopped tracker.` + ) +} + +async function removeTrackerSnapshot(volumeName) { + await execFileAsync('docker', [ + 'run', '--rm', '-v', `${volumeName}:/data`, 'alpine', + 'rm', '-rf', `/data/${TRACKER_SNAPSHOT_DIR}` + ]) +} + +// Bundle already-compressed members into the published .tar.gz WITHOUT +// recompressing them. +// +// The outer archive exists only so the payload travels with its own checksum; +// its members (data.tar.gz / dump.sql.gz) are gzip streams already, which +// deflate cannot shrink. The old `tar czf` therefore pushed the whole dataset +// through gzip a second time for no size win: on 2026-08-01 that was 162.5 GB +// of incompressible bytes re-deflated. Level 0 emits stored deflate blocks, so +// the result is still a genuine gzip file that every existing consumer reads +// unchanged (`tar tzf` / `tar xzf`, the tracker's own single-layer restore), +// only without the CPU. +function writeStoredGzipTar(finalOutput, workDir, members) { + return new Promise((resolve, reject) => { + const tarProc = spawn('tar', ['cf', '-', '-C', workDir, ...members]) + const storeStream = zlib.createGzip({ level: 0 }) + const writeStream = fs.createWriteStream(finalOutput) + + let tarExit = null + let written = false + let settled = false + + // The half-written file sits in the directory the publish rsyncs from, + // so it has to go: a truncated archive that survives here is one the + // next node restores from. + const discardPartial = () => { + try { writeStream.destroy() } catch { /* already torn down */ } + try { fs.rmSync(finalOutput, { force: true }) } catch { /* nothing to remove */ } + } + + // Both conditions are required: tar can die mid-stream, which ends the + // pipe and fires 'finish' on a TRUNCATED archive. Resolving on 'finish' + // alone would publish that truncated file as a good bootstrap. + const settle = () => { + if (settled || tarExit === null || !written) return + settled = true + if (tarExit !== 0) { discardPartial(); reject(new Error(`tar exited with code ${tarExit}`)) } + else resolve() + } + const fail = err => { if (!settled) { settled = true; discardPartial(); reject(err) } } + + tarProc.stdout.pipe(storeStream).pipe(writeStream) + tarProc.stderr.on('data', () => {}) + tarProc.on('error', fail) + storeStream.on('error', fail) + writeStream.on('error', fail) + writeStream.on('finish', () => { written = true; settle() }) + tarProc.on('close', code => { tarExit = code; settle() }) + }) } async function makeBootstrapUtxoTracker(coin, network) { @@ -456,6 +613,13 @@ async function makeBootstrapUtxoTracker(coin, network) { const checksumFile = path.join(workDir, 'data.sha256') const finalOutput = path.join(outputDir, archiveName) + // Drop a snapshot left behind by a crashed run BEFORE measuring, so the + // estimate describes the real dataset and no stale directory can end up + // inside the archive on the stopped-container fallback path. Deliberately + // not swallowed: failing here costs no downtime, whereas publishing a + // snapshot-polluted archive is silent corruption. + await removeTrackerSnapshot(volumeName) + let totalBytes = 0 try { const { stdout } = await execFileAsync( @@ -470,25 +634,86 @@ async function makeBootstrapUtxoTracker(coin, network) { // below, so a capacity failure never costs the tracker any downtime. assertBootstrapCapacity(workDir, outputDir, totalBytes, `${coin}/${network} utxo-tracker`) + // Warn, don't refuse: the snapshot below pins every SST the tracker compacts + // away while the compress runs, so the VOLUME (not just the staging and + // output filesystems checked above) needs churn headroom for the length of + // the run. Refusing here would be worse than the old behavior, but filling + // the volume halts the tracker, so the operator should hear about it. + await warnOnThinTrackerVolume(volumeName, totalBytes) + const containerId = await db.getModuleContainer(XChainService.XCHAIN_UTXO_TRACKER, coin, network) if (!containerId) throw new Error(`utxo-tracker container not found for ${coin}/${network}`) + // Staging and output dirs are prepared before the stop for the same reason + // as the capacity check: a read-only mount or a missing parent should not be + // discovered with the tracker already dark. + if (fs.existsSync(workDir)) fs.rmSync(workDir, { recursive: true }) + ensureDir(workDir) + await ensureDirWritable(outputDir) + + // Declared BEFORE the stop, so the first probe that sees the tracker gone + // already has the operator's reason for it. The encoder keeps reporting the + // outage truthfully (tracker_reachable:false, 503); this only lets the + // public board call it Maintenance instead of Degraded. Best-effort by + // construction: a status label must never hold up a publish. + let maintenanceDeclared = await declareEncoderMaintenance(coin, network, { + reason: `${XChainService.XCHAIN_UTXO_TRACKER} bootstrap publish` + }) + // Ends the window the moment the tracker is back, not when the whole + // multi-hour compress finishes: on the snapshot path the encoder recovers + // seconds after the stop, and leaving the window open would have /status + // advertising maintenance on an encoder that is serving again. Idempotent, + // and never throws for the same reason the declare does not. + const endMaintenanceWindow = async () => { + if (!maintenanceDeclared) return + maintenanceDeclared = false + await clearEncoderMaintenance(coin, network) + } + console.log(`Stopping ${XChainService.XCHAIN_UTXO_TRACKER} container...`) await stopContainer(containerId) + let snapshotTaken = false + let containerRestored = false try { - if (fs.existsSync(workDir)) fs.rmSync(workDir, { recursive: true }) - ensureDir(workDir) - await ensureDirWritable(outputDir) + try { + snapshotTaken = await snapshotTrackerVolume(volumeName) + } catch (err) { + console.log(`Volume snapshot unavailable (${err.message}); compressing with the tracker stopped.`) + // A half-written snapshot must not be swept into the fallback + // archive, and a volume we cannot clean is not one we can publish + // from: let this throw into the outer finally, which restarts the + // container. + await removeTrackerSnapshot(volumeName) + } + + // The whole point of the snapshot: the outage ends HERE, seconds after + // the stop, instead of after the multi-hour compress below. Without it + // the monthly cron took each mainnet encoder's tracker dark for the + // full run (2026-08-01: 3h36m BTC, 1h04m LTC, 42m DOGE), which the + // encoder correctly published as tracker_reachable:false. + if (snapshotTaken) { + console.log(`Starting ${XChainService.XCHAIN_UTXO_TRACKER} container (compressing from the snapshot)...`) + await startContainer(containerId) + containerRestored = true + await endMaintenanceWindow() + } + + const tarSource = snapshotTaken ? `/data/${TRACKER_SNAPSHOT_DIR}` : '/data' const progress = startProgress('Compressing LevelDB data...', totalBytes) + // Hashed inline off the gzip output rather than by re-reading the + // finished file: at tracker scale that second full read was another + // pass over 162.5 GB to learn something the write already knew. + const innerHash = crypto.createHash('sha256') await new Promise((resolve, reject) => { - const tarProc = spawn('docker', ['run', '--rm', '-v', `${volumeName}:/data`, 'alpine', 'tar', 'cf', '-', '-C', '/data', '.']) + const tarProc = spawn('docker', ['run', '--rm', '-v', `${volumeName}:/data`, 'alpine', 'tar', 'cf', '-', '-C', tarSource, '.']) const counter = new PassThrough() const gzipStream = zlib.createGzip() const writeStream = fs.createWriteStream(innerArchive) counter.on('data', chunk => progress.update(chunk.length)) + gzipStream.on('data', chunk => innerHash.update(chunk)) tarProc.stdout.pipe(counter).pipe(gzipStream).pipe(writeStream) @@ -503,13 +728,12 @@ async function makeBootstrapUtxoTracker(coin, network) { const innerStats = await fs.promises.stat(innerArchive) progress.stop(`LevelDB compressed: ${(innerStats.size / 1024 / 1024).toFixed(1)} MB`) - process.stdout.write('Computing checksum... ') - const checksum = await computeSha256(innerArchive) + const checksum = innerHash.digest('hex') await fs.promises.writeFile(checksumFile, `${checksum} data.tar.gz\n`) - console.log(checksum) + console.log(`Checksum: ${checksum}`) console.log(`Wrapping into ${archiveName}...`) - await execFileAsync('tar', ['czf', finalOutput, '-C', workDir, 'data.tar.gz', 'data.sha256']) + await writeStoredGzipTar(finalOutput, workDir, ['data.tar.gz', 'data.sha256']) await maybeSignBootstrap(finalOutput) @@ -517,8 +741,21 @@ async function makeBootstrapUtxoTracker(coin, network) { console.log(redactSecrets(`Bootstrap created: ${finalOutput}`)) } finally { - console.log(`Starting ${XChainService.XCHAIN_UTXO_TRACKER} container...`) - await startContainer(containerId) + // Cleanup first, but never let it throw past the restart: a pinned + // snapshot costs disk, a tracker left stopped costs the encoder. + try { + await removeTrackerSnapshot(volumeName) + } catch (err) { + console.log(`Warning: could not remove /data/${TRACKER_SNAPSHOT_DIR} in ${volumeName} (${err.message}); it holds disk until removed.`) + } + if (!containerRestored) { + console.log(`Starting ${XChainService.XCHAIN_UTXO_TRACKER} container...`) + await startContainer(containerId) + } + // After the restart, always: the fallback path held the window for the + // whole compress, and a failed run must not leave the board excusing an + // encoder that is serving again. + await endMaintenanceWindow() } return true @@ -601,6 +838,32 @@ async function makeBootstrapMariaDb(coin, network, module) { const innerStats = await fs.promises.stat(innerArchive) progress.stop(`${dbName} dumped: ${(innerStats.size / 1024 / 1024).toFixed(1)} MB compressed`) + // Re-gate the SOURCE before anything is packaged, checksummed or signed. + // + // The pre-flight gate in makeBootstrap runs before the dump, and the producers + // stay live throughout it: a decoder can write events.code='REORG_HALT' and + // xchain-sync can insert an uncleared sync_halt row while mariadb-dump is + // streaming. Without this second reading the archive ships carrying the very + // marker the gate exists to refuse, signed, as the newest (and therefore + // default) file in the served directory. + // + // Deliberately conservative rather than exact: the reading is taken after the + // --single-transaction snapshot point, so it can discard an archive whose halt + // arrived after the snapshot, and it cannot see a marker inserted and then + // cleared during the dump. Publishing nothing beats publishing unverified, and + // the same reading also catches every other late fault the gate covers (the + // container died mid-dump, lag grew past the ceiling, the module went wedged). + // Cheap: askMariadbRootPassword caches, so no second prompt, and a skipped gate + // (XCHAIN_NODE_BOOTSTRAP_SKIP_HEALTH_GATE) skips both calls alike. + try { + await assertBootstrapSourceHealthy(coin, network, module) + } catch (err) { + console.log(`The ${module} source stopped being known-good while ${dbName} was dumping; ` + + 'discarding the finished dump rather than publishing it.') + try { fs.rmSync(workDir, { recursive: true }) } catch { /* the refusal is what matters */ } + throw err + } + process.stdout.write('Computing checksum... ') const checksum = await computeSha256(innerArchive) await fs.promises.writeFile(checksumFile, `${checksum} dump.sql.gz\n`) @@ -834,26 +1097,54 @@ async function restoreBootstrapMariaDb(coin, network, module, fileName) { } } -// Whether the utxo-tracker LevelDB volume already holds data. Used as a -// race-free freshness gate: it must be checked BEFORE the container starts, -// because a freshly-started tracker creates an (empty) LevelDB immediately. -// Returns false when the volume is absent or empty (i.e. a fresh install). -async function utxoTrackerVolumeHasData(coin, network) { +// The three answers a freshness probe may give. Only EMPTY is a positive +// finding of "there is nothing here to lose", and only EMPTY may authorise the +// destructive restore path. UNKNOWN keeps an inspection FAILURE distinct from +// that finding: conflated, a transient MariaDB or docker fault during a rolling +// update reads a populated store as fresh and drives an unforced DROP DATABASE +// + restore over it (uuid:7037604f). +const FRESHNESS_EMPTY = 'empty' +const FRESHNESS_POPULATED = 'populated' +const FRESHNESS_UNKNOWN = 'unknown' + +// Say so once, in the operator's log, whenever a probe could not answer. Silent +// UNKNOWNs are how the old conflation stayed invisible for so long. +function reportUnknownFreshness(subject, err) { + console.log(`WARNING: could not determine whether ${subject} already holds data ` + + `(${redactSecrets(String((err && err.message) || err))}).`) + console.log(' Treating it as NOT empty: automatic bootstrap restore is skipped rather than') + console.log(' risking a DROP over populated data. Re-run once the inspection works, or set') + console.log(' FORCE_BOOTSTRAP to restore anyway.') +} + +// Freshness of the utxo-tracker LevelDB volume. Used as a race-free gate: it +// must be checked BEFORE the container starts, because a freshly-started tracker +// creates an (empty) LevelDB immediately. +// +// EMPTY when docker itself says there is no such volume, or the volume is there +// and holds nothing. POPULATED when it holds anything. UNKNOWN for every other +// inspection failure, which is NOT evidence of absence. +async function utxoTrackerVolumeFreshness(coin, network) { // Routed through the shared helper (uuid:a61fc673): the unprefixed name // used here previously read the wrong stack's freshness under a // non-default NODE_PREFIX. const volumeName = getUtxoTrackerVolumeName(coin, network) try { await execFileAsync('docker', ['volume', 'inspect', volumeName]) - } catch { - return false // volume doesn't exist yet (fresh install) + } catch (err) { + if (/no such volume/i.test(String((err && (err.message || err.stderr)) || ''))) { + return FRESHNESS_EMPTY // docker SAID it is absent: a fresh install + } + reportUnknownFreshness(`the Docker volume ${volumeName}`, err) + return FRESHNESS_UNKNOWN } try { const { stdout } = await execFileAsync('docker', ['run', '--rm', '-v', `${volumeName}:/data`, 'alpine', 'sh', '-c', 'ls -A /data 2>/dev/null | head -1']) - return stdout.trim().length > 0 - } catch { - return false + return String(stdout).trim().length > 0 ? FRESHNESS_POPULATED : FRESHNESS_EMPTY + } catch (err) { + reportUnknownFreshness(`the Docker volume ${volumeName}`, err) + return FRESHNESS_UNKNOWN } } @@ -1065,15 +1356,27 @@ async function ensureBootstrapUtxoTracker(coin, network) { } } -// Whether the decoder/indexer MariaDB database already holds indexed data. -// The MariaDB analogue of utxoTrackerVolumeHasData: a fresh install has either -// no database yet or an empty `blocks` table (both decoder and indexer carry a -// `blocks` table that fills as they follow the chain). Used as the freshness -// gate before the service container starts decoding/indexing. Best-effort: -// any lookup error is treated as "fresh" so install proceeds with a normal sync. -async function mariaDbModuleHasData(coin, network, module) { +// Freshness of the decoder/indexer MariaDB database. The MariaDB analogue of +// utxoTrackerVolumeFreshness: a fresh install has either no database yet or an +// empty `blocks` table (both decoder and indexer carry a `blocks` table that +// fills as they follow the chain). Used as the freshness gate before the service +// container starts decoding/indexing. +// +// EMPTY only on a successful inspection that found no database, no `blocks` +// table, or no rows. POPULATED on a successful inspection that found rows. +// UNKNOWN whenever the inspection itself failed or answered something that will +// 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 (uuid:7037604f). +async function mariaDbModuleFreshness(coin, network, module) { const { askMariadbRootPassword } = require('./DatabaseService') const dbName = getModuleDatabaseName(module, coin, network) + // Row counts and table counts are only evidence when they parse: an answer + // that does not parse becomes null, never a number that lands on "fresh". + const countOf = (out) => { + const n = parseInt(String(out).trim(), 10) + return Number.isFinite(n) ? n : null + } // External-DB mode has no local `xchain-node-database` container, so the // container-id lookup below always returns null and would report "fresh" @@ -1085,35 +1388,46 @@ async function mariaDbModuleHasData(coin, network, module) { try { const externalCfg = await getExternalDbConfig() const existsQuery = `SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = '${dbName}' AND TABLE_NAME = 'blocks'` - const tblOut = await executeNativeMariaDbCommand(externalCfg, existsQuery, '-BN') - if (parseInt(String(tblOut).trim(), 10) === 0) return false + const tables = countOf(await executeNativeMariaDbCommand(externalCfg, existsQuery, '-BN')) + if (tables === null) throw new Error(`unparseable table count for ${dbName}.blocks`) + if (tables === 0) return FRESHNESS_EMPTY const countQuery = `SELECT COUNT(*) FROM \`${dbName}\`.blocks` - const cntOut = await executeNativeMariaDbCommand(externalCfg, countQuery, '-BN') - return parseInt(String(cntOut).trim(), 10) > 0 - } catch { - return false + const rows = countOf(await executeNativeMariaDbCommand(externalCfg, countQuery, '-BN')) + if (rows === null) throw new Error(`unparseable row count for ${dbName}.blocks`) + return rows > 0 ? FRESHNESS_POPULATED : FRESHNESS_EMPTY + } catch (err) { + reportUnknownFreshness(`the external database ${dbName}`, err) + return FRESHNESS_UNKNOWN } } let dbContainerId try { dbContainerId = await getDatabaseContainerId() - } catch { return false } - if (!dbContainerId) return false // no DB container yet (fresh install) + } catch (err) { + reportUnknownFreshness(`the database ${dbName}`, err) + return FRESHNESS_UNKNOWN + } + if (!dbContainerId) return FRESHNESS_EMPTY // no DB container yet (fresh install) let rootPassword try { rootPassword = await askMariadbRootPassword(coin, network) - } catch { return false } + } catch (err) { + reportUnknownFreshness(`the database ${dbName}`, err) + return FRESHNESS_UNKNOWN + } try { - // Does the `blocks` table exist? (DB or table absent ⇒ fresh) + // Does the `blocks` table exist? (DB or table absent means fresh) const existsQuery = `SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = '${dbName}' AND TABLE_NAME = 'blocks'` const { stdout: tblOut } = await execFileAsync( 'docker', dockerMariadbArgs(dbContainerId, ['mariadb', '-u', 'root', '-BN', '-e', existsQuery, 'information_schema']), { env: mariadbEnv(rootPassword) } ) - if (parseInt(tblOut.trim(), 10) === 0) return false + const tables = countOf(tblOut) + if (tables === null) throw new Error(`unparseable table count for ${dbName}.blocks`) + if (tables === 0) return FRESHNESS_EMPTY // Table exists: does it hold any rows? const countQuery = `SELECT COUNT(*) FROM \`${dbName}\`.blocks` @@ -1121,9 +1435,12 @@ async function mariaDbModuleHasData(coin, network, module) { 'docker', dockerMariadbArgs(dbContainerId, ['mariadb', '-u', 'root', '-BN', '-e', countQuery]), { env: mariadbEnv(rootPassword) } ) - return parseInt(cntOut.trim(), 10) > 0 - } catch { - return false + const rows = countOf(cntOut) + if (rows === null) throw new Error(`unparseable row count for ${dbName}.blocks`) + return rows > 0 ? FRESHNESS_POPULATED : FRESHNESS_EMPTY + } catch (err) { + reportUnknownFreshness(`the database ${dbName}`, err) + return FRESHNESS_UNKNOWN } } @@ -1199,10 +1516,13 @@ module.exports = { makeBootstrap, restoreBootstrap, downloadBootstrap, - utxoTrackerVolumeHasData, + utxoTrackerVolumeFreshness, ensureBootstrapUtxoTracker, - mariaDbModuleHasData, + mariaDbModuleFreshness, ensureBootstrapMariaDb, + FRESHNESS_EMPTY, + FRESHNESS_POPULATED, + FRESHNESS_UNKNOWN, forceBootstrapRequested, reportBootstrapOutcomes, resetBootstrapOutcomes, diff --git a/src/services/ConfigService.js b/src/services/ConfigService.js index a96754e..f339541 100644 --- a/src/services/ConfigService.js +++ b/src/services/ConfigService.js @@ -318,6 +318,25 @@ async function ensureHubApiKey() { return { path: sidecarPath, generated: true } } +/** + * Report whether this host already holds a HUB_API_KEY, WITHOUT ever minting one. + * + * A credential APPEARING is as breaking as one disappearing. A hub deployed with no key + * runs keyless (HUB_ALLOW_UNAUTHENTICATED), and every indexer, explorer and shared service + * pointed at it carries no key either; a key landing in this sidecar flips the hub to + * authenticated on its next deploy and 401s all of them at once, while the hub itself still + * looks healthy. So the callers that only need to SAY where the credential lives (a re-run + * of `validator init` over an already-provisioned node) read through here, and generation + * stays with the fresh-install path in ensureHubApiKey. + * + * @returns {Promise<{path: string, present: boolean}>} + */ +async function readHubApiKey() { + const sidecarPath = hubSidecarPath() + const existing = await readSidecarValue(sidecarPath, "HUB_API_KEY") + return { path: sidecarPath, present: !!existing } +} + // Fill in HUB_API_KEY from the shared sidecar when the host env did not supply one. // The hub, the co-located indexer and the shared services must all present the SAME // value or their writes 401 against each other, so they resolve it from one file. @@ -432,6 +451,30 @@ async function getDefaultConfig(module, coin, network) { defaultValues["CORS_ORIGIN"] = process.env.CORS_ORIGIN || "*" } + // Encoder passthrough. A production encoder sits behind a reverse proxy on + // ANOTHER box, reached over a public address, so its default trust-proxy + // setting (loopback, uniquelocal) never honours X-Forwarded-For and the + // per-IP limiter keys every visitor on the proxy's egress address: one + // bucket per encoder for the whole world. ENCODER_TRUST_PROXY names that + // egress address so the container recovers the real client. + // ENCODER_RATE_LIMIT_RPM rides the same passthrough, placed after the + // regtest block above so a host value wins over the 99999 regtest literal + // and survives update/recreate. Read BY NAME, same as the explorer + // passthrough below: a computed process.env read is invisible to the + // platform's env-var coverage gate, which is what turns an undocumented + // variable into a silent one. + if (module === XChainService.XCHAIN_ENCODER) { + const encoderPassthroughVars = ["ENCODER_TRUST_PROXY", "ENCODER_RATE_LIMIT_RPM"] + for (const key of encoderPassthroughVars) { + const value = { + ENCODER_TRUST_PROXY: process.env.ENCODER_TRUST_PROXY, + ENCODER_RATE_LIMIT_RPM: process.env.ENCODER_RATE_LIMIT_RPM + }[key] + if (value === undefined || value === "") continue + defaultValues[key] = value + } + } + // Native-coin protocol fee destination (per coin/network). Defaults from the vendored // canonical coin registry (src/coins), so a stock install provisions the decoder's // FEE_DESTINATION (fee-output capture into transaction_outputs) and the indexer's @@ -528,6 +571,98 @@ async function getDefaultConfig(module, coin, network) { defaultValues[varName] = process.env[varName] } } + // ROLLCALL rail env (xchain-indexer only). Two separate things, both of which a + // deployed indexer needs before an epoch close can do anything at all. + // + // 1. DOGE_INDEXER_API_URL / DOGE_INDEXER_API_KEY, on EVERY network. Roll calls + // land on DOGECOIN and the BTC indexer is the only place the close runs, so + // rollcall_proof_client.js (and anchor_proof_client.js beside it) has to be + // able to ask a DOGE indexer. With no URL the close returns + // `{decided:false, reason:'DOGE indexer not configured'}` and the BTC indexer + // DEFERS the block forever, which is exactly how a single-coin venue wedges. + // Sourced from host env so the pair survives an `update` instead of needing + // to be hand-set on the container after every deploy. + // + // 2. XC_ROLLCALL_REGTEST_ACTIVATION, on REGTEST ONLY. This is the one value a + // regtest venue owns: the no-tunable-input rule is scoped to shared-ledger + // networks, because two regtest venues cannot fork each other. It is gated on + // the network here as well as in the indexer's own rollcall_activation.js, + // which is structurally unable to reach the environment for mainnet or + // testnet - two independent gates, so neither one being edited alone can arm + // a shared ledger from a host variable. + // + // 3. HUB_SYNC_ANCHOR_ATTEST_GRACE_S, on REGTEST ONLY, for the same reason as + // (2) and with the same two independent gates: the indexer's own + // resolveWatermarkGrace IGNORES it off regtest with a warning, because a + // watermark grace is a consensus input and a per-node value forks + // settlement. + // + // WHY A REGTEST VENUE NEEDS IT AT ALL. The anchor-reward attestation + // barrier holds a block until `streamWatermark >= blockTime + 120`. Off + // regtest that is free: blocks are ten minutes apart, so by the time one is + // processed the watermark is long past it. On regtest, blocks are stamped at + // about wall clock and the watermark tracks wall clock too, so a freshly + // mined block can NEVER be 120s behind the watermark and the barrier is + // unsatisfiable by construction. Every affected block then burns the full + // 60s timeout before proceeding anyway. + // + // MEASURED, on the 2026-09-06 release matrix: the BTC leg parsed 367 blocks + // in six hours and was killed by the job budget, against 2013 blocks in 1h52m + // on the pre-mirror build - 160 deferrals at 60s each, about 2.7 hours spent + // waiting for a condition that could not arrive. The other two coins were + // unaffected because this barrier is BTC-only. Nothing was wrong with the + // product: the venue was simply running a shared-ledger constant on a chain + // whose block cadence it was never sized for. + // 4. HUB_PRICE_SYNC_TIMEOUT_MS, on REGTEST ONLY here even though the value + // itself is not a consensus input. It bounds ONE mirror-barrier ATTEMPT: + // on expiry the block is DEFERRED and retried, never committed + // uncertified, which XChainIndexer states outright ("purely operational: + // it opens no barrier and commits no block"). So shortening it trades + // nothing away; it only makes a failed attempt cheaper. + // + // WHY A FAST VENUE NEEDS IT. Where the mirror legitimately lags the + // chain, every affected block waits the full attempt before deferring. + // Measured on the 2026-09-06 release matrix: 119 anchor-attest deferrals + // at the 60s default burned 119 minutes of a 289-minute BTC leg, 41% of + // the wall clock, and the indexer fell far enough behind that thirty + // e2e waits gave up on rows that had not landed yet. The barrier is + // doing its job; the cost per attempt is what a fast venue cannot afford. + // + // Gated on regtest anyway, because a shared ledger wants the long + // attempt: there a lagging mirror is a real fault worth waiting on, not + // a cadence mismatch. + // 5. XCHAIN_COINPAY_EXPIRATION_S, on REGTEST ONLY, for the same reason as (2) + // and (3) and with the same two independent gates: the indexer's own + // resolveCoinpayExpiration IGNORES it off regtest with a warning, because + // the window is added to a match's BLOCK_TIME and STORED as the + // obligation's deadline, so a per-node value expires the same escrow at + // different blocks and forks the ledger. + // + // WHY A REGTEST VENUE NEEDS IT. The e2e COINPay expiry case cannot wait out + // a two-hour deadline, so it freezes the node clock past the deadline and + // mines. That stamps the mined blocks two hours into the FUTURE, and the + // anchor-attest barrier in (3) compares a block's own timestamp against a + // wall-clock watermark, so the indexer then waits those two hours in real + // time on that one block. + // + // MEASURED, on the 2026-09-06 release matrix run 34015867460: all 119 + // deferrals in the BTC leg named the SAME block, held 2h08m50s, while the + // watermark tracked wall clock throughout (1-6s behind, advancing at 0.9999 + // of real time) and the hub logged no late heartbeat and no backpressure. + // Nothing was lagging. Shortening the window on regtest removes the clock + // jump that causes it, rather than teaching every barrier to special-case a + // future-stamped block. + const rollcallPassthroughVars = ["DOGE_INDEXER_API_URL", "DOGE_INDEXER_API_KEY"] + if (network === Network.REGTEST) rollcallPassthroughVars.push("XC_ROLLCALL_REGTEST_ACTIVATION", + "HUB_SYNC_ANCHOR_ATTEST_GRACE_S", + "HUB_PRICE_SYNC_TIMEOUT_MS", + "XCHAIN_COINPAY_EXPIRATION_S") + for (const varName of rollcallPassthroughVars) { + if (process.env[varName] !== undefined && process.env[varName] !== "") { + defaultValues[varName] = process.env[varName] + } + } + // The indexer pushes chain tips / config to the hub (HUB_API_URL); when that // hub enforces HUB_API_KEY, the indexer must present the same key or its writes // 401. Sourced from host env (.env) so it persists across `update`, then from the @@ -564,26 +699,49 @@ async function getDefaultConfig(module, coin, network) { // win. HUB_DB_PASS is reconciled after the per-install DB password is resolved // (see below); HUB_DB_HOST/PORT are already set above. // - // WHY regtest IS EXCLUDED, corrected 2026-07-26. The old note here said "regtest - // has no hub to sync from", which stopped being true at 336a7d5 (HUB_API_URL is - // now composed for regtest too, and a regtest indexer does reach the hub: enabling - // this on litecoin-regtest bootstrapped 3 real rows into oracle_prices). The - // exclusion is still right, for a different and harder reason: turning the mirror - // on ARMS the block-loop price barriers, and `_priceTimeSyncSatisfied` only opens - // on `streamWatermark >= blockTime + 600s` (the frozen price grace). Production - // block timestamps LAG wall clock, so the watermark runs ahead and the escape - // fires; regtest blocks are stamped at ~now, so it can NEVER be 600s ahead and - // every freshly mined block defers forever. Observed live: block 1479 deferred on - // a 60s timeout, repeatedly, until this was reverted. + // WHY regtest WAS EXCLUDED UNTIL NOW, corrected 2026-07-26 then armed 2026-09-03 + // (the regtest mirror wedge). The old note here said "regtest has no hub to sync from", which + // stopped being true at 336a7d5 (HUB_API_URL is now composed for regtest too, and + // a regtest indexer does reach the hub: enabling this on litecoin-regtest + // bootstrapped 3 real rows into oracle_prices). The exclusion stood for a + // different and harder reason: turning the mirror on ARMS the block-loop + // watermark barriers (price, oracle, and now the ATTEST response mirror), and + // each one only opens once the mirror's stream watermark clears the row's time + // plus that barrier's grace. Production block timestamps LAG wall clock, so the + // watermark runs ahead and the escape fires; regtest blocks are stamped at ~now, + // so a real-network grace can NEVER be satisfied and every freshly mined block + // defers forever. Observed live: block 1479 deferred on a 60s timeout, repeatedly, + // until this was reverted. // - // To enable it on regtest deliberately (the mirror-leg test venue), the - // operator must ALSO set HUB_SYNC_PRICE_GRACE_S=0 and HUB_SYNC_ORACLE_GRACE_S=0, - // which hub_db_sync honours on regtest only, precisely for this. Do not "fix" the - // wedge by widening those off regtest: a per-node grace forks settlement. - if (network !== "regtest") { - defaultValues.HUB_DB_NAME = defaultValues.INDEXER_DB_NAME - defaultValues.HUB_DB_USER = defaultValues.INDEXER_DB_USER - defaultValues.HUB_DB_SYNC_ENABLED = "true" + // Armed unconditionally now (mainnet/testnet keep the exact same assignment they + // always had) because leaving the mirror off on regtest silently defeats every + // reader that expects hub state to reach the indexer, not just PRICE but + // the ATTEST response mirror this arms for too. Arming the pointer alone would + // reproduce the price wedge above, so every watermark grace this mirror gates is + // defaulted to 0 on regtest in the SAME step below: a config-file value or a host + // env override for any one of them still wins (resolveWatermarkGrace in + // hub_db_sync.js honours an override on regtest only, so the default below is + // exactly the value that seam already expects). Do not widen these off regtest: + // a per-node grace forks settlement. + defaultValues.HUB_DB_NAME = defaultValues.INDEXER_DB_NAME + defaultValues.HUB_DB_USER = defaultValues.INDEXER_DB_USER + defaultValues.HUB_DB_SYNC_ENABLED = "true" + + if (network === Network.REGTEST) { + // The three barrier graces the armed regtest mirror must clear to avoid the + // wedge above. HUB_SYNC_ATTEST_RESPONSE_GRACE_S is the passthrough this row + // adds (xchain-indexer/src/hub_db_sync.js:615 reads it via resolveWatermarkGrace); + // HUB_SYNC_PRICE_GRACE_S / HUB_SYNC_ORACLE_GRACE_S are the pair the regtest mirror wedge already + // requires be set to 0 alongside it. A host env value always wins over the + // regtest default so an e2e drill can still exercise a nonzero grace. + const hubSyncRegtestGraceVars = [ + "HUB_SYNC_PRICE_GRACE_S", "HUB_SYNC_ORACLE_GRACE_S", "HUB_SYNC_ATTEST_RESPONSE_GRACE_S" + ] + for (const varName of hubSyncRegtestGraceVars) { + defaultValues[varName] = (process.env[varName] !== undefined && process.env[varName] !== "") + ? process.env[varName] + : "0" + } } } } else { @@ -693,6 +851,57 @@ async function getDefaultConfig(module, coin, network) { if (process.env.EXPLORER_VM_QUERY_ENABLED !== undefined && process.env.EXPLORER_VM_QUERY_ENABLED !== "") { defaultValues.EXPLORER_VM_QUERY_ENABLED = process.env.EXPLORER_VM_QUERY_ENABLED } + + // Serving limits, same host-env injection point as the knobs above, + // because every one of these defaults is tuned for a PUBLIC explorer + // and is wrong for a private venue: + // EXPLORER_*RATE_LIMIT_RPM - the eight request budgets, per IP: the + // app-wide cap, the quote/pre-flight caps, and the five per-route + // caps (checkpoint-list, checkpoint-verify, action-proof, + // validator-set-proof, vm-query). A dev box reaches the explorer + // through one tunnel, so every browser and every test run shares a + // single bucket, and a browser-driven suite sustains far more than + // any one of these caps on its own. All eight are now reachable + // from the host env; the five per-route caps were unreachable on a + // node-managed explorer (the regtest venue), which could raise only + // the app-wide and fee-quote caps before this change. + // EXPLORER_TIP_MAX_AGE_S - 6h by default, and 0 disables it. A + // regtest chain has no block cadence: it advances only when someone + // mines, so an idle one crosses the age gate and the explorer delists + // a chain that is perfectly healthy (503 COIN_DATA_STALE on every + // read, lag 0 on /status). + // Read BY NAME rather than by scanning process.env for a pattern: a + // computed read is invisible to the platform's env-var coverage gate, + // which is what turns an undocumented variable into a silent one. The + // per-coin EXPLORER_TIP_MAX_AGE_S_ form is deliberately NOT + // carried here - the explorer honours it directly, and the global knob + // already covers the case this passthrough exists for (an instance + // serving nothing but a private venue). + for (const key of [ + "EXPLORER_RATE_LIMIT_RPM", + "EXPLORER_FEE_QUOTE_RATE_LIMIT_RPM", + "EXPLORER_PREFLIGHT_POST_RATE_LIMIT_RPM", + "EXPLORER_TIP_MAX_AGE_S", + "EXPLORER_CHECKPOINT_LIST_RATE_LIMIT_RPM", + "EXPLORER_CHECKPOINT_VERIFY_RATE_LIMIT_RPM", + "EXPLORER_ACTION_PROOF_RATE_LIMIT_RPM", + "EXPLORER_VALIDATOR_SET_PROOF_RATE_LIMIT_RPM", + "EXPLORER_VM_QUERY_RATE_LIMIT_RPM" + ]) { + const value = { + EXPLORER_RATE_LIMIT_RPM: process.env.EXPLORER_RATE_LIMIT_RPM, + EXPLORER_FEE_QUOTE_RATE_LIMIT_RPM: process.env.EXPLORER_FEE_QUOTE_RATE_LIMIT_RPM, + EXPLORER_PREFLIGHT_POST_RATE_LIMIT_RPM: process.env.EXPLORER_PREFLIGHT_POST_RATE_LIMIT_RPM, + EXPLORER_TIP_MAX_AGE_S: process.env.EXPLORER_TIP_MAX_AGE_S, + EXPLORER_CHECKPOINT_LIST_RATE_LIMIT_RPM: process.env.EXPLORER_CHECKPOINT_LIST_RATE_LIMIT_RPM, + EXPLORER_CHECKPOINT_VERIFY_RATE_LIMIT_RPM: process.env.EXPLORER_CHECKPOINT_VERIFY_RATE_LIMIT_RPM, + EXPLORER_ACTION_PROOF_RATE_LIMIT_RPM: process.env.EXPLORER_ACTION_PROOF_RATE_LIMIT_RPM, + EXPLORER_VALIDATOR_SET_PROOF_RATE_LIMIT_RPM: process.env.EXPLORER_VALIDATOR_SET_PROOF_RATE_LIMIT_RPM, + EXPLORER_VM_QUERY_RATE_LIMIT_RPM: process.env.EXPLORER_VM_QUERY_RATE_LIMIT_RPM + }[key] + if (value === undefined || value === "") continue + defaultValues[key] = value + } } // The explorer resolves each coin's utxo-tracker and decoder from @@ -822,6 +1031,30 @@ async function getDefaultConfig(module, coin, network) { // through so the host env survives a hub container regenerate. "ORACLE_BATCH_WINDOW_ROUNDS", "ORACLE_BATCH_GRACE_MS", "ORACLE_BATCH_SIGN_TIMEOUT_MS", "ORACLE_BATCH_BUFFER_MAX_ROUNDS", + // Same family: the time budgeted between a window closing and its batch + // being readable on chain (assembly, co-signing, broadcast, one DOGE + // confirmation). The publisher subtracts it from the fee-price staleness + // bound to derive the window ceiling, so a venue whose + // landing latency differs from the fleet's tunes it here rather than + // being clamped to a window that does not suit it. + "ORACLE_BATCH_LANDING_RESERVE_MS", + // ATTEST response mirror regtest-only overrides (the attest response mirror design). Both are + // honoured by the receiving hub module ONLY when HUB_NETWORK=regtest (a warn- + // and-ignore off regtest, the same posture resolveWatermarkGrace takes on the + // indexer side), so passing them through here unconditionally mirrors the + // ORACLE_BATCH_* family above: they cannot arm anything off regtest by any path + // in this file, the real gate lives at the point of consumption. + // + // ATTEST_RESPONSE_FORWARD_S_OVERRIDE lets a regtest venue's leader pick a short + // effective_time margin instead of the real 120s ATTEST_RESPONSE_FORWARD_S, so a + // response can bind within the same short block cadence a regtest drill runs at + // (xchain-hub/src/lib/attest_response_timing.js). + "ATTEST_RESPONSE_FORWARD_S_OVERRIDE", + // ATTEST_BATCH_WINDOW_S_OVERRIDE is the same seam for the batch cadence: + // AttestationBatchPublisher (row 20, not yet built) will read it on the same + // regtest-only pattern as the forward override above, so the passthrough is + // wired ahead of that publisher rather than after it. + "ATTEST_BATCH_WINDOW_S_OVERRIDE", // Per-IP request/min cap on the hub's express API (default 100). Too low // for legitimate multi-indexer re-bootstrap: every indexer on a box shares // one source IP, so a fleet bootstrapping HubDbSync tables (oracle_prices, @@ -829,7 +1062,15 @@ async function getDefaultConfig(module, coin, network) { // collectively blows 100/min and gets 429'd, so the heartbeat gate then stays // closed and the chain stalls. Raise for prod fleets. Passed through so the // host env survives a hub container regenerate. - "HUB_RATE_LIMIT_RPM", + // + // The hub exempts loopback and private-range callers from + // that cap by default, which covers the case above: the indexers reach the hub + // container over the bridge network this compose file creates, so a managed + // node no longer needs the limit raised to rebuild price history from the chain. + // HUB_RATE_LIMIT_EXEMPT_LOCAL=false turns the exemption off and restores the + // old behavior for an operator who wants the cap enforced on every caller; + // passed through for the same container-regenerate reason. + "HUB_RATE_LIMIT_RPM", "HUB_RATE_LIMIT_EXEMPT_LOCAL", // XCHAIN derived-price source. XCHAIN is listed on no exchange, so // a validator computes XCHAIN/USD from realized fills in its OWN BTC indexer // database instead of fetching it. Every native-coin fee decision on LTC and @@ -870,7 +1111,21 @@ async function getDefaultConfig(module, coin, network) { // HUB_ALLOW_UNAUTHENTICATED=true is the documented keyless escape hatch and // suits a single-host regtest venue that already ran open; a real network // sets HUB_API_KEY instead. - "HUB_API_KEY", "HUB_ALLOW_UNAUTHENTICATED" + "HUB_API_KEY", "HUB_ALLOW_UNAUTHENTICATED", + // The regtest ROLLCALL arming opt-in. The hub carries a byte-twin of + // the indexer's rollcall_activation.js, and ROLLCALL_ACTIVATION is one of + // consensus_rules_digest.js's SHARED_GATES, so an indexer armed against an + // inert container hub reports a rules MISMATCH on the venue. Both sides take + // the same variable, so a venue arms as a unit. + // + // Passed through with no network gate, unlike the indexer's copy above: the + // hub is a shared service and getDefaultConfig is called for it as + // (module, null, null), so there is no network here to gate on. That is safe + // because the real gate is in the hub's own rollcall_activation.js, which can + // reach the environment for regtest and for nothing else - mainnet and testnet + // are literal there and unreachable from env by any path in the file. On a + // mainnet or testnet hub this variable is therefore inert, not dangerous. + "XC_ROLLCALL_REGTEST_ACTIVATION" ] for (const varName of hubPassthroughVars) { // Secret-bearing names in this list (XCHAIN_PRICE_INDEXER_DB_PASS) are also @@ -1063,16 +1318,21 @@ async function getDefaultConfig(module, coin, network) { if (Object.keys(freshDbCreds).length) upsertSidecarValues(localFilePath, freshDbCreds) // 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, mainnet/testnet), 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 + // 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). - if (module === XChainService.XCHAIN_INDEXER && network !== "regtest" && !("HUB_DB_PASS" in defaultConfig)) { + // 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. + 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"] @@ -1279,6 +1539,7 @@ module.exports = { readSidecarValue, ensureHubApiKey, applyHubApiKeyFromSidecar, + readHubApiKey, filterCommandParameters, resolveArgs } diff --git a/src/services/DatabaseService.js b/src/services/DatabaseService.js index c609be1..d924c4f 100644 --- a/src/services/DatabaseService.js +++ b/src/services/DatabaseService.js @@ -23,7 +23,8 @@ const { Password, Input, NumberPrompt } = require('enquirer') const { DB_MODULE_NAME, HUB_MODULE_NAME, XChainService, SEP, CoinTickerSymbol, - EXTERNAL_DB, EXTERNAL_DB_HOST, EXTERNAL_DB_PORT, EXTERNAL_DB_ROOT_USER + EXTERNAL_DB, EXTERNAL_DB_HOST, EXTERNAL_DB_PORT, EXTERNAL_DB_ROOT_USER, + DEPENDENCY_HEALTH_START_PERIOD } = require('../config/constants') const { db, getDbRootPassword, setDbRootPassword } = require('../state') const { sleep, redactSecrets } = require('../utils/helpers') @@ -31,7 +32,7 @@ const { assertSafeDbIdentifier, escapeSqlStringLiteral } = require('../utils/sql const { dockerMariadbArgs, mariadbEnv } = require('../utils/dockerMariadb') const { getDefaultConfig, getDockerContainerImageName, getDockerNetwork, getModuleDatabaseName, validatePort } = require('./ConfigService') const { getStatusFromContainer, getDockerNetworkInspect, addContainerToNetwork, forceRemoveContainerByName, probeContainerPresenceByName } = require('./DockerService') -const { assertNoDbCredentialDrift, isDbCredentialDriftError } = require('./DbCredentialDrift') +const { assertNoDbCredentialDrift, assertNoHubDbCredentialDrift, isDbCredentialDriftError } = require('./DbCredentialDrift') const { statusChanged } = require('./StatusService') const { XCHAIN_NODE_DB, getOsUserDbName, generatePassword, @@ -254,6 +255,27 @@ async function _pingMariaDb({ host, port, root_user, root_password }) { } } +// Resolve the external config and prove the server answers, REPORTING failure +// instead of throwing. A caller standing in front of a destructive section needs +// to abort cleanly and return; an exception unwinding out of it skips the +// restart pass and leaves the stack down (uuid:41887889). Swallows the +// non-interactive throw from getExternalDbConfig for the same reason. Never +// returns or logs the password. +async function pingExternalDatabase() { + let cfg = null + try { + cfg = await getExternalDbConfig() + } catch (err) { + return { ok: false, host: null, port: null, error: (err && err.message) || String(err) } + } + try { + await _pingMariaDb(cfg) + return { ok: true, host: cfg.host, port: cfg.port } + } catch (err) { + return { ok: false, host: cfg.host, port: cfg.port, error: (err && err.message) || String(err) } + } +} + // Read a mariadb client option string the way the client itself reads argv: // short flags cluster, so "-BN" means "-B -N". The docker path hands this same // string to a real client that clusters (executeDockerMariaDbCommand splits it @@ -376,6 +398,14 @@ async function askMariadbRootPassword(coin, network) { return envPassword } } catch { /* fall through to the container-env read / prompt below */ } + // Say so when the override loses. The fall-through is correct, but an + // operator who set this variable believes it IS the credential in force, + // so a silent switch to the container's own password hides exactly the + // half-done rotation this resolver exists to survive (uuid:aa6c2267). + // Names the variable, never a value: this line reaches logs and CI output. + console.warn('WARNING: XCHAIN_NODE_DB_ROOT_PASSWORD did not authenticate against the running ' + + 'MariaDB container and is being ignored; falling back to the container\'s own ' + + 'MYSQL_ROOT_PASSWORD. Rotate both sides, or unset the variable.') } // If the mariadb container is already up, its MYSQL_ROOT_PASSWORD env is @@ -851,11 +881,55 @@ async function setDatabaseParameters() { // this only after a successful hub buildAndUp, so the hub exists. async function setHubDatabaseParameters() { const cfg = await getDefaultConfig(HUB_MODULE_NAME, null, null) + + // Refuse before the ALTER USER when another running container holds this shared + // account on a different password: the hub half of the guard above (uuid:a48aab2c). + // Excludes this install's own hub, which the caller has just rebuilt on the + // intended password, so its frozen value is not a lockout. + await assertNoHubDbCredentialDrift( + { user: cfg["HUB_DB_USER"], pass: cfg["HUB_DB_PASS"] }, + { excludeContainers: [getDockerContainerImageName(HUB_MODULE_NAME, "", "")] } + ) + await addUserPasswordToDatabase(HUB_MODULE_NAME, "", "", cfg["HUB_DB_NAME"], cfg["HUB_DB_USER"], cfg["HUB_DB_PASS"]) return true } async function resetDatabases(coin, network, modules = [XChainService.XCHAIN_DECODER, XChainService.XCHAIN_INDEXER]) { + // Drop the databases this stack ACTUALLY uses, which is what provisioning + // resolved: setDatabaseParameters grants on cfg["DECODER_DB_NAME"] / + // cfg["INDEXER_DB_NAME"], and both are operator-overridable in the + // - config file. Deriving the DEFAULT name here instead meant + // an overridden stack had its live database left untouched while the reset + // dropped whatever else on that MariaDB happened to answer to the default + // name - a wipe of another stack's data, reported as a successful reset + // (uuid:fd543c4a). The derived name stays the fallback for a config that + // carries no name at all, and for any module outside the two DB modules. + // + // Gate every resolved name on the identifier allowlist before the first DROP. + // A database name reaches SQL as text (an identifier cannot be bound), which + // is why addUserPasswordToDatabase, clearHubPriceIngestWatermark and the + // BootstrapHealthGate readers all assert it; this destructive site was the + // one that opted out (uuid:0257cadf). Asserted for the whole set up front, + // not per iteration: a name refused on the second module would otherwise + // throw with the first module's database already dropped. The assert now + // also covers an operator-supplied name, which is the only untrusted one. + const configuredDbNameKey = { + [XChainService.XCHAIN_DECODER]: "DECODER_DB_NAME", + [XChainService.XCHAIN_INDEXER]: "INDEXER_DB_NAME" + } + const resetTargets = [] + for (const module of modules) { + let dbName = getModuleDatabaseName(module, coin, network) + const configKey = configuredDbNameKey[module] + if (configKey) { + const cfg = await getDefaultConfig(module, coin, network) + const configured = cfg ? cfg[configKey] : undefined + if (typeof configured === "string" && configured.trim() !== "") dbName = configured.trim() + } + resetTargets.push(assertSafeDbIdentifier(dbName, 'database name')) + } + // External (host-native) MariaDB: there is no database container to exec // into it (`docker exec ... null` failed here and aborted the reset mid-way, // leaving data wiped, DBs stale, services stopped). Use the driver-based @@ -863,8 +937,7 @@ async function resetDatabases(coin, network, modules = [XChainService.XCHAIN_DEC // mariadb CLI, the driver rejects multi-statement strings. if (EXTERNAL_DB) { const cfg = await getExternalDbConfig() - for (const module of modules) { - const dbName = getModuleDatabaseName(module, coin, network) + for (const dbName of resetTargets) { await executeNativeMariaDbCommand(cfg, `DROP DATABASE IF EXISTS ${dbName}`) await executeNativeMariaDbCommand(cfg, `CREATE DATABASE ${dbName}`) console.log(`Database ${dbName} reset!`) @@ -886,8 +959,7 @@ async function resetDatabases(coin, network, modules = [XChainService.XCHAIN_DEC throw new Error("MariaDB container not found; install the database first") } - for (const module of modules) { - const dbName = getModuleDatabaseName(module, coin, network) + for (const dbName of resetTargets) { await executeDockerMariaDbCommand(mariadbContainerId, mariadbRootPassword, `DROP DATABASE IF EXISTS ${dbName}; CREATE DATABASE ${dbName}` ) @@ -1023,7 +1095,12 @@ async function buildDatabaseModule(coin, network) { // not enrolled in autoheal: the DB has no SERVICE_HEALTHCHECK descriptor, // so AutohealService's `hc.autoheal !== true` gate skips it outright. runArgs.push('--health-cmd', 'healthcheck.sh --connect --innodb_initialized') - runArgs.push('--health-interval', '15s', '--health-timeout', '5s', '--health-retries', '5', '--health-start-period', '60s') + // The start period is DEPENDENCY_HEALTH_START_PERIOD, shared with the hub and + // explorer descriptors in ModuleService whose probes SELECT 1 against this + // container: their grace windows are derived from this one, so widening + // MariaDB's server-init budget widens theirs in the same edit instead of + // 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 // Every other docker run port in this file is validated before reaching @@ -1288,6 +1365,7 @@ module.exports = { executeDockerMariaDbCommand, executeNativeMariaDbCommand, getExternalDbConfig, + pingExternalDatabase, addUserPasswordToDatabase, setDatabaseParameters, setHubDatabaseParameters, diff --git a/src/services/DbCredentialDrift.js b/src/services/DbCredentialDrift.js index 0ec5e4e..ab13b70 100644 --- a/src/services/DbCredentialDrift.js +++ b/src/services/DbCredentialDrift.js @@ -33,7 +33,7 @@ const { execFile } = require('child_process') const { promisify } = require('util') const execFileAsync = promisify(execFile) -const { XChainService } = require('../config/constants') +const { XChainService, HUB_MODULE_NAME } = require('../config/constants') const { getDockerContainerImageName } = require('./ConfigService') // Escape hatch for the operator who knows the lagging container is about to be @@ -55,6 +55,10 @@ const ACCOUNT_CONSUMERS = [ { module: XChainService.XCHAIN_INDEXER, envKey: 'INDEXER_DB_PASS', account: 'indexer' } ] +// Env keys by which a container declares the SHARED hub account it authenticates with. +const HUB_USER_ENV_KEY = 'HUB_DB_USER' +const HUB_PASS_ENV_KEY = 'HUB_DB_PASS' + /** * Compare the passwords a provision is about to write against the passwords the * running containers already carry. Pure: no docker, no DB, no logging. @@ -119,6 +123,109 @@ function formatDbCredentialDriftError(coin, network, drift, alsoRecreate = []) { ) } +/** + * Compare the SHARED hub password a provision is about to write against the + * passwords the running containers already carry. Pure: no docker, no logging. + * + * @param {{user?: string, pass?: string}} intended The hub account this install will write. + * @param {Array<{name: string, env: Object}>} containers Running containers. + * @returns {Array<{container: string, envKey: string, account: string}>} One row per lockout. + */ +function findHubDbCredentialDrift(intended, containers) { + const drift = [] + const user = intended ? intended.user : undefined + const pass = intended ? intended.pass : undefined + // Claim nothing when this install names no account or no password for it. + if (!user || !pass) return drift + for (const container of containers || []) { + const env = container.env || {} + // Keys on the ACCOUNT, not the module: the indexer points HUB_DB_USER at its + // own account, so a module-keyed row false-alarms on it (uuid:a48aab2c). + if (env[HUB_USER_ENV_KEY] !== user) continue + const live = env[HUB_PASS_ENV_KEY] + // Absent on either side is no claim, the same rule findDbCredentialDrift uses. + if (live === undefined || live === null || live === '') continue + if (live !== pass) { + drift.push({ container: container.name, envKey: HUB_PASS_ENV_KEY, account: user }) + } + } + return drift +} + +/** + * Render the shared-account refusal. The coin/network formatter above would print + * blanks here, because the hub is a shared service with neither. + * + * @param {Array<{container: string, envKey: string, account: string}>} drift + * @returns {string} + */ +function formatHubDbCredentialDriftError(drift) { + const lines = drift.map(d => + ` - ${d.container} carries a ${d.envKey} that differs from this install's config ` + + `(shared '${d.account}' account)` + ) + return ( + `Refusing to rotate the shared hub MariaDB account: a running container was built from a ` + + `DIFFERENT config store and would be locked out (ER_ACCESS_DENIED) the moment the password ` + + `is written.\n` + + lines.join('\n') + '\n' + + `Nothing has been changed. Point both installs at one config/hub.local, then run ` + + `\`xchain-node recreate ${HUB_MODULE_NAME}\` from the install that owns the stack. ` + + `Set ${DRIFT_OVERRIDE_ENV}=1 to rotate anyway.` + ) +} + +/** + * List every running container's name, or [] when docker cannot answer. + * Tolerant by design: an unreadable daemon is not drift. + * + * @param {{execFileAsync?: Function}} [deps] + * @returns {Promise} + */ +async function listRunningContainerNames(deps = {}) { + const runDocker = deps.execFileAsync || execFileAsync + try { + const { stdout } = await runDocker('docker', ['ps', '--format', '{{.Names}}']) + return String(stdout).split('\n').map(name => name.trim()).filter(Boolean) + } catch { + return [] + } +} + +/** + * Fail closed when a running holder of the shared hub account would be locked out + * by the password this install is about to write. + * + * @param {{user?: string, pass?: string}} intended + * @param {{execFileAsync?: Function, env?: Object, excludeContainers?: string[]}} [deps] + * @returns {Promise} the drift rows (empty when clean, or when overridden) + */ +async function assertNoHubDbCredentialDrift(intended, deps = {}) { + const env = deps.env || process.env + // Sweeps the whole daemon instead of deriving names: the hub has no coin/network + // to derive from, and a co-located install runs its own under another NODE_PREFIX. + const exclude = new Set(Array.isArray(deps.excludeContainers) ? deps.excludeContainers : []) + const containers = [] + for (const name of await listRunningContainerNames(deps)) { + if (exclude.has(name)) continue + const containerEnv = await readContainerEnv(name, deps) + if (containerEnv) containers.push({ name, env: containerEnv }) + } + + const drift = findHubDbCredentialDrift(intended, containers) + if (drift.length === 0) return drift + + if (env[DRIFT_OVERRIDE_ENV] === '1') { + console.log(formatHubDbCredentialDriftError(drift)) + console.log(`${DRIFT_OVERRIDE_ENV}=1 is set; rotating anyway.`) + return drift + } + const error = new Error(formatHubDbCredentialDriftError(drift)) + error.code = DRIFT_ERROR_CODE + error.drift = drift + 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. @@ -204,10 +311,11 @@ function isDbCredentialDriftError(err) { module.exports = { DRIFT_OVERRIDE_ENV, DRIFT_ERROR_CODE, - ACCOUNT_CONSUMERS, findDbCredentialDrift, formatDbCredentialDriftError, readContainerEnv, assertNoDbCredentialDrift, + findHubDbCredentialDrift, + assertNoHubDbCredentialDrift, isDbCredentialDriftError } diff --git a/src/services/DockerService.js b/src/services/DockerService.js index c41a0b3..c0991c5 100644 --- a/src/services/DockerService.js +++ b/src/services/DockerService.js @@ -319,6 +319,20 @@ async function stopContainer(containerId) { }) } +// Graceful stop by NAME with an explicit shutdown budget, for stateful +// containers (chain daemons) that must flush before they go. SIGTERM first; +// docker escalates to SIGKILL only after `timeoutSeconds`. Resolves true when +// docker reports the stop, false otherwise (already gone, never existed, or +// daemon unreachable): the caller's subsequent force-remove/run surfaces any +// real error, so a missing container is not a failure here. +async function stopContainerByName(name, timeoutSeconds) { + return new Promise((resolve) => { + execFile('docker', ['stop', '-t', String(timeoutSeconds), name], (error, stdout) => { + resolve(!error && stdout.trim() === name) + }) + }) +} + async function startContainer(containerId) { return new Promise((resolve, reject) => { execFile('docker', ['start', containerId], (error, stdout) => { @@ -601,6 +615,7 @@ module.exports = { getDockerContainerFileCat, stringToDockerContainerFile, stopContainer, + stopContainerByName, startContainer, restartContainer, removeContainer, diff --git a/src/services/EncoderMaintenanceWindow.js b/src/services/EncoderMaintenanceWindow.js new file mode 100644 index 0000000..e57b211 --- /dev/null +++ b/src/services/EncoderMaintenanceWindow.js @@ -0,0 +1,105 @@ +/********************************************************************* + * + * 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 - encoder scheduled-maintenance window + * + * Tells the encoder serving a coin/network that the outage it is about to + * observe is PLANNED, so the public board (encoder.xchain.io) can paint + * "Maintenance" instead of "Degraded". + * + * Why it exists: a bootstrap publish stops the UTXO tracker. The encoder + * probes the tracker, correctly reports tracker_reachable:false, answers + * 503, and the board has no way to tell that outage apart from a broken + * encoder. On 2026-08-01 the monthly cron therefore showed the mainnet + * BTC encoder Degraded for 3h36m. The probe is right and must not be + * silenced (see xchain-encoder src/maintenanceWindow.js, which folds this + * window in as CONTEXT and never lets it move a readiness field or the + * 503). What was missing was the operator's own declaration. + * + * The sentinel is written INTO the encoder container with `docker exec + * tee`, the same mechanism ExplorerService's config push already uses. No + * bind mount, so an already-running encoder starts reporting maintenance + * with no container recreate; and an encoder restart drops the file, + * which fails in the honest direction (back to the raw fault). + * + * Every call here is best-effort. A publish must never fail, and a + * tracker must never stay down, because a cosmetic status label could not + * be written. + ********************************************************************/ + +const { XChainService } = require('../config/constants') +const { db } = require('../state') +const { stringToDockerContainerFile, execContainer } = require('./DockerService') + +// Must match xchain-encoder's DEFAULT_SENTINEL. The encoder side can be +// repointed with ENCODER_MAINTENANCE_FILE; repoint this with the same value. +const SENTINEL_PATH = process.env.XCHAIN_NODE_ENCODER_MAINTENANCE_FILE + || '/tmp/xchain-encoder-maintenance.json' + +// How long a declared window stays credible without being renewed. Deliberately +// generous against the slowest publish on record (3h36m) and still well under +// the encoder's own 24h ceiling, so a run that overshoots expires into an +// honest Degraded rather than excusing an outage nobody is working on. +const DEFAULT_WINDOW_MINUTES = 6 * 60 + +async function encoderContainerId(coin, network) { + try { + return await db.getModuleContainer(XChainService.XCHAIN_ENCODER, coin, network) + } catch { + return null + } +} + +// Declare a window on the encoder for this coin/network. Resolves true when the +// sentinel landed, false otherwise (no encoder here, or the write failed): a +// caller can log the difference but must not treat false as fatal. +async function declareEncoderMaintenance(coin, network, { reason, minutes = DEFAULT_WINDOW_MINUTES } = {}) { + const containerId = await encoderContainerId(coin, network) + if (!containerId) return false // no encoder on this host; nothing to tell + + const now = Date.now() + const sentinel = JSON.stringify({ + reason: String(reason || 'scheduled maintenance'), + since: new Date(now).toISOString(), + until: new Date(now + minutes * 60 * 1000).toISOString() + }) + try { + await stringToDockerContainerFile(containerId, sentinel + '\n', SENTINEL_PATH) + return true + } catch (err) { + console.log(`Warning: could not declare the encoder maintenance window for ${coin}/${network} (${err.message}); the status board will show Degraded for the outage.`) + return false + } +} + +// End the window early. The sentinel expires on its own, so this only shortens +// it; failing to remove it leaves the board excusing an encoder that has +// recovered, which is why the caller logs but never throws. +async function clearEncoderMaintenance(coin, network) { + const containerId = await encoderContainerId(coin, network) + if (!containerId) return false + try { + await execContainer(containerId, ['rm', '-f', SENTINEL_PATH]) + return true + } catch (err) { + console.log(`Warning: could not clear the encoder maintenance window for ${coin}/${network} (${err.message}); it expires on its own.`) + return false + } +} + +module.exports = { + declareEncoderMaintenance, + clearEncoderMaintenance, + SENTINEL_PATH, + DEFAULT_WINDOW_MINUTES +} diff --git a/src/services/HubConsensusEnvGuard.js b/src/services/HubConsensusEnvGuard.js index f52707a..189055d 100644 --- a/src/services/HubConsensusEnvGuard.js +++ b/src/services/HubConsensusEnvGuard.js @@ -17,9 +17,10 @@ * SHELL happens to export it; a var it does not export is simply left out of * the container env, and the hub falls back to its own built-in default with * no message anywhere. For most of the ~30 hub passthrough vars that is fine - * (they are genuinely optional). Five of them are not: HUB_NETWORK, - * ORACLE_MIN_SUBMISSIONS, ORACLE_ROUND_INTERVAL, ORACLE_SUBMISSION_WINDOW and - * XCHAIN_PRICE_INDEXER_DB_* are CONSENSUS-SHAPED (they change what the hub's + * (they are genuinely optional). A handful are not: HUB_NETWORK, + * ORACLE_MIN_SUBMISSIONS, ORACLE_ROUND_INTERVAL, ORACLE_SUBMISSION_WINDOW, + * XCHAIN_PRICE_INDEXER_DB_* and (on regtest, see below) the four XCHAIN/BTC + * derivation overrides are CONSENSUS-SHAPED (they change what the hub's * oracle finalizes, or whether it finalizes at all), so a `recreate` run from * a shell that lacks one of them silently deploys a hub with different * consensus behavior than the one that was just torn down. That is the same @@ -46,6 +47,32 @@ * * Values are never logged, only compared, for the same reason as * DbCredentialDrift: XCHAIN_PRICE_INDEXER_DB_PASS is a credential. + * + * NETWORK-GATED KEYS + * ------------------ + * Some of the passthrough vars are consensus-shaped only on the network where + * the hub actually honors them. The four XCHAIN/BTC derivation parameters + * (XCHAIN_PRICE_WINDOW_BLOCKS, _CONFIRMATION_BUFFER, _BOOTSTRAP_SATS, + * _MIN_BTC_VOLUME) are the case that forced this: XchainPriceSource's + * pinOffRegtest honors them ONLY when HUB_NETWORK is regtest, and pins them to + * the constants.js values (with a "set but IGNORED" warning) on mainnet, + * testnet and standalone alike. + * + * Adding them to the flat key list unconditionally would have been the wrong + * fix, and worse than leaving them out. This guard counts a DROP as drift, so + * a mainnet hub whose container still carries a stale, already-ignored + * XCHAIN_PRICE_BOOTSTRAP_SATS would have had every future recreate REFUSED + * until the operator either re-exported a variable the hub throws away or set + * the override - a refusal protecting a value that cannot change anything. + * + * So the honored set is DERIVED from the network rather than fixed: a group + * may carry `honoredOn`, and its keys enter the comparison only when the + * deploy's effective network is in that list. The effective network is read + * from HUB_NETWORK alone (this deploy's, else the running container's), never + * from the coin/network the node command is operating on, because HUB_NETWORK + * is exactly what the hub's own gate reads: falling back to the node's network + * would make the guard protect keys a standalone hub (HUB_NETWORK unset) is + * already ignoring, which is the same wrong refusal in a different disguise. ********************************************************************/ const { HUB_MODULE_NAME } = require('../config/constants') @@ -85,23 +112,100 @@ const CONSENSUS_ENV_GROUPS = [ ], why: 'feeds the derived XCHAIN/USD price; unset is a supported "abstain from the pair" state for a ' + 'hub that never had it, but a hub that WAS deriving the pair losing this source changes what it submits' + }, + { + keys: [ + 'XCHAIN_PRICE_WINDOW_BLOCKS', 'XCHAIN_PRICE_CONFIRMATION_BUFFER', + 'XCHAIN_PRICE_BOOTSTRAP_SATS', 'XCHAIN_PRICE_MIN_BTC_VOLUME' + ], + // Honored only on regtest, so guarded only on regtest. See NETWORK-GATED + // KEYS in the header: on any other network the hub pins these to the + // constants.js values and warns, which makes a drop unable to change + // anything and a refusal over one pure obstruction. + honoredOn: ['regtest'], + why: 'the CONSENSUS-UNIFORM XCHAIN/BTC derivation parameters, honored on regtest only; on a regtest ' + + 'venue losing one silently retunes the window, buffer, bootstrap price or supersession threshold ' + + 'this hub derives the pair with, which is what an e2e drill is measuring' } ] const CONSENSUS_ENV_KEYS = CONSENSUS_ENV_GROUPS.flatMap(g => g.keys) +// The key whose value decides which of the network-gated groups apply. Guarded +// in its own right (the first group), so a deploy that would drop it is refused +// on that ground before any of its gating consequences matter. +const NETWORK_KEY = 'HUB_NETWORK' + +/** + * The network this deploy's hub will actually run as, lowercased, for deciding + * which network-gated keys are in force. '' means standalone/unset, which every + * gated group treats the same as a non-regtest network (the hub's own seams all + * fail closed to the consensus pin there). + * + * @param {Object} intended The env this deploy is about to write. + * @param {Object|null} [liveEnv] The running container's frozen env, if any. + * @returns {string} + */ +function resolveHubNetwork(intended, liveEnv) { + const fromIntended = (intended || {})[NETWORK_KEY] + if (fromIntended !== undefined && fromIntended !== null && String(fromIntended) !== '') { + return String(fromIntended).toLowerCase() + } + // The shell that invoked this deploy did not export it. The running + // container's value is the next best evidence of what this hub IS: a + // regtest hub whose recreate lost HUB_NETWORK still had its price knobs + // honored a moment ago, and dropping one alongside is real drift. + const fromLive = (liveEnv || {})[NETWORK_KEY] + if (fromLive !== undefined && fromLive !== null && String(fromLive) !== '') { + return String(fromLive).toLowerCase() + } + return '' +} + +/** + * Whether a consensus-shaped key is honored by a hub running on `network`. + * An ungated group (no `honoredOn`) is honored everywhere. + * + * @param {string} key + * @param {string} network Lowercased network name, '' for standalone/unset. + * @returns {boolean} + */ +function isConsensusEnvKeyHonoredOn(key, network) { + const group = CONSENSUS_ENV_GROUPS.find(g => g.keys.includes(key)) + if (!group || !group.honoredOn) return true + return group.honoredOn.includes(String(network || '').toLowerCase()) +} + +/** + * The consensus-shaped keys in force on `network`: the flat list minus every + * network-gated key this hub would ignore. Everything the guard walks (drift + * comparison and the supply log alike) comes from here, so a key the hub + * ignores is never reported as missing and never counted as drift when dropped. + * + * @param {string} network Lowercased network name, '' for standalone/unset. + * @returns {string[]} + */ +function consensusEnvKeysForNetwork(network) { + return CONSENSUS_ENV_KEYS.filter(key => isConsensusEnvKeyHonoredOn(key, network)) +} + /** * Which of the consensus-shaped keys this deploy supplies vs. leaves for the - * hub's own default. Pure: reads only the object passed in. + * hub's own default. Pure: reads only the objects passed in. + * + * Scoped to the keys the deploy's network actually honors, so a mainnet deploy + * is never told it "did not supply" a regtest-only derivation override. * * @param {Object} intended The env this deploy is about to write. + * @param {string} [network] Effective network; defaults to this deploy's own HUB_NETWORK. * @returns {{supplied: string[], defaulted: string[]}} */ -function describeConsensusEnvSupply(intended) { +function describeConsensusEnvSupply(intended, network) { const src = intended || {} + const scope = consensusEnvKeysForNetwork(network === undefined ? resolveHubNetwork(src, null) : network) const supplied = [] const defaulted = [] - for (const key of CONSENSUS_ENV_KEYS) { + for (const key of scope) { const v = src[key] if (v === undefined || v === null || v === '') defaulted.push(key) else supplied.push(key) @@ -118,6 +222,11 @@ function describeConsensusEnvSupply(intended) { * (including nothing at all). A key the live container never carried is not * drift: that hub was already running without it, so there is nothing to lose. * + * A key this hub's network does not honor is not drift either, whatever the + * live container carries. Unsetting a variable the hub already throws away is + * housekeeping, not a consensus change, and the guard must not stand in front + * of it (see NETWORK-GATED KEYS in the header). + * * @param {Object} intended The env this deploy is about to write. * @param {Object|null} liveEnv The running container's frozen env, or null. * @returns {Array<{key: string}>} @@ -126,7 +235,7 @@ function findHubConsensusEnvDrift(intended, liveEnv) { const drift = [] if (!liveEnv) return drift const next = intended || {} - for (const key of CONSENSUS_ENV_KEYS) { + for (const key of consensusEnvKeysForNetwork(resolveHubNetwork(next, liveEnv))) { const live = liveEnv[key] if (live === undefined || live === null || live === '') continue const nextValue = (next[key] === undefined || next[key] === null) ? '' : String(next[key]) @@ -163,9 +272,10 @@ function formatHubConsensusEnvDriftError(drift) { * install gets the same observability a recreate does. * * @param {Object} intended The env this deploy is about to write. + * @param {string} [network] Effective network; defaults to this deploy's own HUB_NETWORK. */ -function logConsensusEnvSupplyState(intended) { - const { supplied, defaulted } = describeConsensusEnvSupply(intended) +function logConsensusEnvSupplyState(intended, network) { + const { supplied, defaulted } = describeConsensusEnvSupply(intended, network) if (defaulted.length > 0) { console.warn( 'WARNING: hub consensus-shaped settings NOT supplied by the invoking shell (the hub will use ' + @@ -192,10 +302,16 @@ function logConsensusEnvSupplyState(intended) { async function assertNoHubConsensusEnvDrift(environmentVariables, deps = {}) { const env = deps.env || process.env - logConsensusEnvSupplyState(environmentVariables) - const containerName = deps.containerName || getDockerContainerImageName(HUB_MODULE_NAME, null, null) const liveEnv = await readContainerEnv(containerName, deps) + + // Read the container BEFORE logging so the supply report is scoped to the + // same network the drift comparison uses: a recreate whose shell dropped + // HUB_NETWORK still resolves regtest from the running container, and its + // regtest-only derivation overrides belong in the report. readContainerEnv + // returns null rather than throwing, so this still logs on a fresh install. + logConsensusEnvSupplyState(environmentVariables, resolveHubNetwork(environmentVariables, liveEnv)) + if (!liveEnv) return [] // no running hub container: fresh install, nothing to drift against const drift = findHubConsensusEnvDrift(environmentVariables, liveEnv) @@ -226,8 +342,12 @@ function isHubConsensusEnvDriftError(err) { module.exports = { DRIFT_OVERRIDE_ENV, DRIFT_ERROR_CODE, + NETWORK_KEY, CONSENSUS_ENV_GROUPS, CONSENSUS_ENV_KEYS, + resolveHubNetwork, + isConsensusEnvKeyHonoredOn, + consensusEnvKeysForNetwork, describeConsensusEnvSupply, findHubConsensusEnvDrift, formatHubConsensusEnvDriftError, diff --git a/src/services/HubService.js b/src/services/HubService.js index 8623ccc..b89c6ca 100644 --- a/src/services/HubService.js +++ b/src/services/HubService.js @@ -135,7 +135,7 @@ const { addUserPasswordToDatabase, getExternalDbConfig } = require('./DatabaseSe // The explorer container's env is the durable record of the checkpoint self-sync // opt-in; this reader already exists for the DB-credential drift guard and is // tolerant of a missing container, which is exactly the posture wanted here. -const { readContainerEnv } = require('./DbCredentialDrift') +const { readContainerEnv, assertNoHubDbCredentialDrift } = require('./DbCredentialDrift') const HubConnector = require('../HubConnector.js') async function updateHubOrExplorer(module) { @@ -392,6 +392,15 @@ async function installHubModule(branch = null) { const hubPin = resolveComponentRef(HUB_MODULE_NAME, branch) await cloneGit(HUB_MODULE_NAME, true, false, hubPin.ref, hubPin.commit) + // Guard the install-time rotation too: it writes the same shared account, and it + // runs BEFORE buildAndUp, so a sibling install's live hub is still serving on the + // old password when the ALTER lands (uuid:a48aab2c). This install's own hub is + // excluded because buildAndUp restarts it on the intended password moments later. + await assertNoHubDbCredentialDrift( + { user: defaultConfig["HUB_DB_USER"], pass: defaultConfig["HUB_DB_PASS"] }, + { excludeContainers: [getDockerContainerImageName(HUB_MODULE_NAME, "", "")] } + ) + await addUserPasswordToDatabase( HUB_MODULE_NAME, "", "", defaultConfig["HUB_DB_NAME"], defaultConfig["HUB_DB_USER"], defaultConfig["HUB_DB_PASS"] diff --git a/src/services/MigrationPreconditionService.js b/src/services/MigrationPreconditionService.js index dbf587d..381b871 100644 --- a/src/services/MigrationPreconditionService.js +++ b/src/services/MigrationPreconditionService.js @@ -395,13 +395,11 @@ async function assertRequiredMigrationsApplied(module, coin, network, branch = n module.exports = { MIGRATION_BEARING_MODULES, SKIP_ENV, - LEDGER_TABLE, migrationDeclaresDeployPrecondition, migrationMode, listDeployPreconditionMigrations, pendingManualMigrations, runningBuildSupportsPerFileMigrations, - refusalMessage, // 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/ModuleService.js b/src/services/ModuleService.js index 98feca4..6b1efbb 100644 --- a/src/services/ModuleService.js +++ b/src/services/ModuleService.js @@ -24,7 +24,7 @@ const path = require('path') const { NODE_MODULE_NAME, DB_MODULE_NAME, HUB_MODULE_NAME, EXPLORER_MODULE_NAME, SYNC_MODULE_NAME, XChainService, SEP, modulesUrls, LIBRARY_BUNDLES, SERVICE_REGISTRY, DEFAULT_MODULE_BRANCH, - Coin, Network + Coin, Network, DEPENDENCY_HEALTH_START_PERIOD } = require('../config/constants') const { db } = require('../state') const { @@ -533,15 +533,28 @@ async function assertNoHostPortConflicts(portArgs, selfName) { // interval=15s - frequent enough to detect a stuck service quickly without hammering // timeout=5s - generous but short of the interval; covers a slow DB query // retries=3 - three misses (~45s) before marking unhealthy; avoids flapping -// startPeriod= - varies: fast workers (encoder) get 30s; DB-dependent services -// (decoder, indexer, utxo-tracker, miner) get 60s; hub/explorer get -// 45s; sync gets its own hub-wait window, see its line below. A -// service whose probe judges a startup step must grant a window -// at least as long as that step, or the probe reports the startup -// itself as a failure. +// startPeriod= - NOT sized from the service's own boot time. A service whose +// probe judges a startup step must grant a window at least as +// long as that step, or the probe reports the startup itself as +// a failure, so every entry whose probe cannot pass until a HARD +// DEPENDENCY is up takes DEPENDENCY_HEALTH_START_PERIOD (60s, +// config/constants.js) rather than a number of its own: encoder +// (its default /status 503s until the utxo-tracker is reachable +// and synced), hub and explorer (their probes run SELECT 1 against +// MariaDB). Self-judging boots keep their own literal: decoder +// (/live), indexer, utxo-tracker and miner all take 60s for their +// own DB connect or wallet prep, and sync gets its own hub-wait +// window, see its line below. const SERVICE_HEALTHCHECK = { [XChainService.XCHAIN_DECODER]: { portKey: 'DECODER_API_PORT', probe: 'http_get', path: '/live', interval: '15s', timeout: '5s', retries: 3, startPeriod: '60s', autoheal: true }, - [XChainService.XCHAIN_ENCODER]: { portKey: 'ENCODER_API_PORT', probe: 'http_get', interval: '15s', timeout: '5s', retries: 3, startPeriod: '30s', autoheal: true }, + // The encoder carries no `path`, so its probe is the default GET /status, and + // that route 503s until the utxo-tracker is reachable AND synced + // (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 }, [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 @@ -556,8 +569,11 @@ const SERVICE_HEALTHCHECK = { // hub that had stopped producing usable consensus data read healthy through the // narrow probe. Deliberately no autoheal: oracle staleness is usually // upstream, where a restart flaps the container and disrupts in-flight rounds. - [HUB_MODULE_NAME]: { portKey: 'HUB_PORT', probe: 'jsonrpc_health', interval: '15s', timeout: '5s', retries: 3, startPeriod: '45s' }, - [EXPLORER_MODULE_NAME]: { portKey: 'EXPLORER_API_PORT_HTTP', probe: 'jsonrpc_ping', interval: '15s', timeout: '5s', retries: 3, startPeriod: '45s' }, + // Both this and the explorer's probe race a SELECT 1 against MariaDB and 503 + // when it loses, so both windows cover the DB container's own 60s start period + // rather than the 45s each was given from its own boot time. + [HUB_MODULE_NAME]: { portKey: 'HUB_PORT', probe: 'jsonrpc_health', interval: '15s', timeout: '5s', retries: 3, startPeriod: DEPENDENCY_HEALTH_START_PERIOD }, + [EXPLORER_MODULE_NAME]: { portKey: 'EXPLORER_API_PORT_HTTP', probe: 'jsonrpc_ping', interval: '15s', timeout: '5s', retries: 3, startPeriod: DEPENDENCY_HEALTH_START_PERIOD }, // sync's startPeriod covers MAX_HUB_WAIT_MS (xchain-sync/src/config.js, default // 300000ms), not just process boot: /health answers 503 'starting' for the // whole hub wait instead of reporting healthy with zero pollers running, and @@ -871,7 +887,8 @@ async function buildAndUp(module, coin, network, overwriteContainerId = null, on assertGoLiveReady(module, coin, network, environmentVariables, dir) // Hub consensus-shaped settings (HUB_NETWORK, ORACLE_MIN_SUBMISSIONS, - // ORACLE_ROUND_INTERVAL/SUBMISSION_WINDOW, XCHAIN_PRICE_INDEXER_DB_*) are + // ORACLE_ROUND_INTERVAL/SUBMISSION_WINDOW, XCHAIN_PRICE_INDEXER_DB_*, and + // on regtest the four XCHAIN/BTC derivation overrides) are // passed through from the INVOKING SHELL with no warning when absent, so a // recreate/update run from a shell that lacks one quietly deploys a hub // with different consensus behavior than the one just torn down. Refuses @@ -1254,6 +1271,18 @@ async function installModule(module, coin, network, remoteUpdate = false, overwr }, { excludeModules: [module] }) } + // Same ordering rule for the SHARED hub account: setHubDatabaseParameters + // runs its guard after buildAndUp has already torn this hub down and back + // up, so refuse here while nothing has been touched yet (uuid:a48aab2c). + if (module === HUB_MODULE_NAME && !onlyExecution) { + const { assertNoHubDbCredentialDrift } = require('./DbCredentialDrift') + const hubCfg = await getDefaultConfig(HUB_MODULE_NAME, null, null) + await assertNoHubDbCredentialDrift( + { user: hubCfg["HUB_DB_USER"], pass: hubCfg["HUB_DB_PASS"] }, + { excludeContainers: [getDockerContainerImageName(HUB_MODULE_NAME, "", "")] } + ) + } + // Under a pinned install the manifest, not the operator's branch // argument, decides this module's ref: `install v0.9.0` means the // v0.9.0 component set, and the pinned commit is verified after the @@ -1282,18 +1311,25 @@ async function installModule(module, coin, network, remoteUpdate = false, overwr } // Fresh-install detection must happen BEFORE buildAndUp starts the // tracker (a fresh tracker creates an empty LevelDB immediately). + // Only a CONFIRMED empty store authorises the bootstrap restore + // below, because that restore reaches DROP DATABASE: an inspection + // failure answers UNKNOWN, never fresh, so a transient MariaDB or + // docker fault during a rolling update costs a slow sync from + // scratch rather than a populated store (uuid:7037604f). let utxoWasFresh = false if (module === XChainService.XCHAIN_UTXO_TRACKER && !onlyExecution) { - const { utxoTrackerVolumeHasData, forceBootstrapRequested } = require('./BootstrapService') - utxoWasFresh = !(await utxoTrackerVolumeHasData(coin, network)) || forceBootstrapRequested() + const { utxoTrackerVolumeFreshness, forceBootstrapRequested, FRESHNESS_EMPTY } = require('./BootstrapService') + utxoWasFresh = (await utxoTrackerVolumeFreshness(coin, network)) === FRESHNESS_EMPTY + || forceBootstrapRequested() } // Decoder/indexer freshness must also be sampled BEFORE buildAndUp; // once the service starts it fills its `blocks` table, which would // make a fresh install look populated. let mariaWasFresh = false if ((module === XChainService.XCHAIN_DECODER || module === XChainService.XCHAIN_INDEXER) && !onlyExecution) { - const { mariaDbModuleHasData, forceBootstrapRequested } = require('./BootstrapService') - mariaWasFresh = !(await mariaDbModuleHasData(coin, network, module)) || forceBootstrapRequested() + const { mariaDbModuleFreshness, forceBootstrapRequested, FRESHNESS_EMPTY } = require('./BootstrapService') + mariaWasFresh = (await mariaDbModuleFreshness(coin, network, module)) === FRESHNESS_EMPTY + || forceBootstrapRequested() } const containerId = await buildAndUp(module, coin, network, overwriteContainerId, onlyExecution, dockerCmdArgs) if (module === XChainService.XCHAIN_DECODER || module === XChainService.XCHAIN_INDEXER) { diff --git a/src/services/NodeService.js b/src/services/NodeService.js index 122ba8b..8eb1a84 100644 --- a/src/services/NodeService.js +++ b/src/services/NodeService.js @@ -28,6 +28,13 @@ const { } = require('../config/constants') const nodeVersion = process.versions.node +// Shutdown budget for a chain daemon container, in seconds. A daemon flushes +// its block index and chainstate only on a clean shutdown, and a mainnet +// bitcoind with a large dbcache can take minutes to do it. `docker stop` +// returns as soon as the process exits, so a wide budget costs nothing on the +// common path and only matters when the flush is genuinely slow. +const NODE_STOP_TIMEOUT_SECONDS = 600 + const { gitHubDownloader, db, getRemoteModuleVersions } = require('../state') const { decompressTarGz } = require('../utils/helpers') const { cryptoNodesDir } = require('../config/constants') @@ -234,7 +241,7 @@ async function getCryptoNode(coin, network, version) { // Whether the coin's pinned daemon honors `-blocksdir`. Dogecoin Core (v1.14.x) // is based on a pre-0.18 Bitcoin Core and silently ignores the flag (added -// upstream in Bitcoin Core 0.18); Bitcoin (v28) and Litecoin (v0.21) both honor +// upstream in Bitcoin Core 0.18); Bitcoin (v31) and Litecoin (v0.21) both honor // it. For a daemon that ignores it, blocks are relocated by bind-mounting the // external path straight onto the in-datadir blocks directory instead. function daemonSupportsBlocksdir(coin) { @@ -343,7 +350,7 @@ function stageBuildScaffold(coin, network, nodeDir, defaultConfig) { return generatedName } -async function buildCryptoNode(coin, network, bitcoinVer = null) { +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"] @@ -420,7 +427,7 @@ async function buildCryptoNode(coin, network, bitcoinVer = null) { // 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 } = require('./DockerService') + const { forceRemoveContainerByName, getContainerBindMounts, stopContainerByName } = require('./DockerService') let existingMounts = [] try { existingMounts = await getContainerBindMounts(containerPrefix) @@ -436,6 +443,13 @@ async function buildCryptoNode(coin, network, bitcoinVer = null) { return } + // Stop the running daemon cleanly BEFORE the force-remove below: + // `docker rm -f` is SIGKILL, and a killed daemon restarts at its last + // flushed block index. The regtest litecoind rehearsal of the + // v0.21.5.6 bump lost 16 mined blocks that way (2026-09-03); a + // mainnet node would face a long replay or a corrupt store instead. + await stopContainerByName(containerPrefix, NODE_STOP_TIMEOUT_SECONDS) + // Name-keyed cleanup immediately before `docker run --name`, making // (re)creation idempotent against a leftover carcass unregistered by // an insert-failure at the tail of this function (see reject() below) @@ -461,6 +475,10 @@ async function buildCryptoNode(coin, network, bitcoinVer = null) { '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(NODE_STOP_TIMEOUT_SECONDS), // 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 @@ -498,7 +516,11 @@ async function buildCryptoNode(coin, network, bitcoinVer = null) { } runArgs.push('-p', `${defaultExposedPort}:${defaultNodePort}`) } - runArgs.push('-e', `CRYPTO_NODE_VERSION=${bitcoinVer}`, '-t', containerPrefix) + // No CRYPTO_NODE_VERSION env: no caller ever supplied a version, so the + // key only ever baked the literal "null" into every coin-node container + // while nothing read it (uuid:1d4208f4). The daemon version lives in the + // image at //__VERSION__.txt (VersionService.getContainerNodeVersion). + runArgs.push('-t', containerPrefix) // Only daemons that honor -blocksdir need a CMD override to pass it. // doged relocates via the nested bind-mount above and keeps its // default CMD (which already references its conf). @@ -616,8 +638,12 @@ async function installNode(coin, network) { console.log("Downloading xchain-utxo-tracker...") await cloneGit(XChainService.XCHAIN_UTXO_TRACKER, true) console.log("Building xchain-utxo-tracker...") - const { utxoTrackerVolumeHasData, ensureBootstrapUtxoTracker, forceBootstrapRequested } = require('./BootstrapService') - const utxoWasFresh = !(await utxoTrackerVolumeHasData(coin, network)) || forceBootstrapRequested() + // Only a CONFIRMED empty volume authorises the restore below; an inspection + // that failed is not evidence of emptiness (uuid:7037604f). + const { utxoTrackerVolumeFreshness, ensureBootstrapUtxoTracker, forceBootstrapRequested, + FRESHNESS_EMPTY } = require('./BootstrapService') + const utxoWasFresh = (await utxoTrackerVolumeFreshness(coin, network)) === FRESHNESS_EMPTY + || forceBootstrapRequested() await buildAndUp(XChainService.XCHAIN_UTXO_TRACKER, coin, network) if (utxoWasFresh) await ensureBootstrapUtxoTracker(coin, network) diff --git a/src/services/TelemetryService.js b/src/services/TelemetryService.js index 86a8b42..7ec25e5 100644 --- a/src/services/TelemetryService.js +++ b/src/services/TelemetryService.js @@ -189,10 +189,5 @@ async function maybeReportTelemetry(commandName, cliOptOut) { module.exports = { maybeReportTelemetry, - isOptedOut, - eventForCommand, - gatherPayload, - getPrefPath, - loadPref, - savePref + gatherPayload } diff --git a/src/services/ValidatorService.js b/src/services/ValidatorService.js index 9e7be6b..beafbfc 100644 --- a/src/services/ValidatorService.js +++ b/src/services/ValidatorService.js @@ -39,11 +39,13 @@ * Never as an argv value: a WIF in argv is a WIF in every process listing. * * The hub's API key is deliberately NOT one of these. A hub refuses to boot - * without HUB_API_KEY unless keyless operation is declared, so init mints one, - * but it belongs to the HOST rather than to this validator identity: the local - * indexer and the shared services authenticate to the same hub with the same - * value. It therefore lives in the shared 0600 sidecar config/hub.local - * alongside HUB_DB_PASS (ConfigService.ensureHubApiKey). + * without HUB_API_KEY unless keyless operation is declared, so a FRESH init + * mints one, but it belongs to the HOST rather than to this validator identity: + * the local indexer and the shared services authenticate to the same hub with + * the same value. It therefore lives in the shared 0600 sidecar config/hub.local + * alongside HUB_DB_PASS (ConfigService.ensureHubApiKey). A RE-RUN over an + * already-initialized node only READS it (ConfigService.readHubApiKey): see + * initValidator for why minting there breaks a keyless deployment. * * Why capabilities.json sits in its own `hub-caps/` subdirectory rather than * beside the other two: the hub container mounts it, and a SINGLE-FILE bind @@ -66,7 +68,7 @@ const fs = require('fs') const path = require('path') const crypto = require('crypto') const { configDir } = require('../config/constants') -const { ensureHubApiKey } = require('./ConfigService') +const { ensureHubApiKey, readHubApiKey } = require('./ConfigService') const VALIDATOR_DIR = path.join(configDir, 'validator') // The stack ref this CLI tells an operator to install. xchain-node's own @@ -123,8 +125,12 @@ const P2P_PORT_BY_NETWORK = { mainnet: 10001, testnet: 10002 } // Oracle round-numbering anchor per federation. A hub with a different value // computes different round numbers and its submissions never line up, so the // known federations' values are defaults here; --oracle-epoch-start overrides. +// Both values are deliberately in the PAST: an epoch in the future numbers +// every round negative and OracleRound drops peer submissions for round < 0, +// which is what cost testnet a federation-wide flag day on 2026-08-28. // testnet: read from the live validator01-05 containers on 2026-08-29. -const ORACLE_EPOCH_START_BY_NETWORK = { testnet: 1787875200000 } +// mainnet: ruled by the operator board 2026-09-01 (2026-09-01T00:00:00Z). +const ORACLE_EPOCH_START_BY_NETWORK = { mainnet: 1788220800000, testnet: 1787875200000 } // SDK network names and public encoder coin prefixes per hub network. const COIN_NETWORKS = { @@ -336,20 +342,38 @@ module.exports = { if (encoded.encoding !== 'P2SH' && encoded.encoding !== 'P2WSH') return { txid: signed.txid }; - const spendParams = { - pubkey: ADDRESS, - p2shHash: signed.txid, - p2shHex: signed.txHex, - data: payload, - encoding: encoded.encoding, - change: ADDRESS - }; - if (FEE_PER_KB !== undefined) spendParams.feePerKb = FEE_PER_KB; - const spendResult = await encoder.spendP2sh(spendParams); - const spendSigned = sdk.wallet.signRevealPsbt(spendResult.psbt, WIF); - await encoder.broadcastTx(spendSigned.txHex); - - return { txid: spendSigned.txid, phase1_txid: signed.txid }; + // Phase 1 has funded the P2SH outputs on chain, so every failure below is a + // POST-SPEND failure and has to say so on the way out. The hub reads a + // definitive encoder rejection as safe to retry, and a retry re-enters this + // function, runs createTx over fresh UTXOs and funds the same payload a second + // time. fundsCommitted makes the hub fail closed instead; phase1Txid is what an + // operator reconciles the stranded funding transaction against. + try { + const spendParams = { + pubkey: ADDRESS, + p2shHash: signed.txid, + p2shHex: signed.txHex, + data: payload, + encoding: encoded.encoding, + change: ADDRESS + }; + if (FEE_PER_KB !== undefined) spendParams.feePerKb = FEE_PER_KB; + const spendResult = await encoder.spendP2sh(spendParams); + const spendSigned = sdk.wallet.signRevealPsbt(spendResult.psbt, WIF); + await encoder.broadcastTx(spendSigned.txHex); + + return { txid: spendSigned.txid, phase1_txid: signed.txid }; + } catch (err) { + // Mutate and rethrow the SAME object where there is one: the classifier + // reads err.response and err.message off the original, and a fresh wrapper + // would drop both. A thrown non-object gets a carrier instead. + const tagged = (err && typeof err === 'object') + ? err + : new Error('doge-signer: phase 2 failed after funding: ' + String(err)); + tagged.fundsCommitted = true; + tagged.phase1Txid = signed.txid; + throw tagged; + } }, // Sign an encoder-built PSBT -> signed raw tx hex. The hub's built-in @@ -570,9 +594,40 @@ function assertCapsDirIsolated() { } } -// One line of operator-facing output naming WHERE the hub credential lives. The value -// is never printed: an API key in a terminal is an API key in a scrollback buffer. +/** + * Resolve the host's hub credential for this init run. + * + * A fresh install may GENERATE one (the hub refuses to boot in validator mode without it, + * so an install that leaves none behind ends in a node that cannot start). A re-run over an + * already-provisioned node may only READ, because minting one there is a silent outage: + * see the refusal wording in reportHubApiKey. `--mint-hub-api-key` is the explicit opt-in + * for the one case a re-run legitimately needs to generate, an old install that was + * provisioned before init minted anything and now sits at a refused hub boot. + */ +async function resolveHubApiKey(alreadyInitialized, opts) { + if (!alreadyInitialized || opts.mintHubApiKey) { + const key = await ensureHubApiKey() + return { path: key.path, generated: key.generated, missing: false } + } + const key = await readHubApiKey() + return { path: key.path, generated: false, missing: !key.present } +} + +// One line of operator-facing output naming WHERE the hub credential lives, or the refusal +// and its consequence when there is none to name. The value is never printed: an API key in +// a terminal is an API key in a scrollback buffer. function reportHubApiKey(hubApiKey) { + if (hubApiKey.missing) { + console.log(' hub API key : NONE in ' + hubApiKey.path + ' - this host runs its hub KEYLESS,') + console.log(' and re-running init does NOT mint one. Every indexer, explorer and') + console.log(' service already pointed at this hub carries no key either, so a key') + console.log(' appearing here would flip the hub to authenticated on its next deploy') + console.log(' and 401 all of them at once, while the hub still reported healthy.') + console.log(' Re-run with --mint-hub-api-key ONLY if the hub is refusing to boot for') + console.log(' want of a key, and put the same value in every consumer before') + console.log(' redeploying the hub.') + return + } console.log(' hub API key : ' + hubApiKey.path + ' (mode 0600, key HUB_API_KEY, ' + (hubApiKey.generated ? 'generated now' : 'already present, reused') + ')') } @@ -581,11 +636,12 @@ function reportHubApiKey(hubApiKey) { * Set up the two coin wallets and the DOGE signer, or report why not. * * Shared by a fresh init and by a re-run over an already-initialized - * validator, for the same reason ensureHubApiKey runs before the - * already-initialized early return: a node initialized BEFORE wallets existed - * is exactly the node that needs them, and making it rotate its signing key - * (and therefore re-stake, and wait out the activation delay again) to get - * them would be a punishing upgrade path for a working validator. + * validator: a node initialized BEFORE wallets existed is exactly the node that + * needs them, and making it rotate its signing key (and therefore re-stake, and + * wait out the activation delay again) to get them would be a punishing upgrade + * path for a working validator. Wallets are safe to repair on a re-run because + * generating them affects nothing outside this node; the hub API key is not, + * which is why that one only reads on a re-run (see initValidator). * * An existing wallets.env is KEPT unless --force-wallets: the signing key is * cheap to replace, but a funded stake or publisher address is not, and @@ -634,13 +690,22 @@ function reportWallets(walletInfo, network, verb) { // Generate a key + write all validator files. Idempotent guard via `force`. async function initValidator(opts = {}) { - // A validator-mode hub REFUSES TO BOOT with no HUB_API_KEY, so init has to leave one - // behind or this whole ceremony ends in a node that cannot start. Done BEFORE the - // already-initialized early return, because a node initialized before this existed is - // exactly the node sitting in that refused-boot state; re-running init repairs it. - const hubApiKey = await ensureHubApiKey() - - if (isInitialized() && !opts.force) { + // A validator-mode hub REFUSES TO BOOT with no HUB_API_KEY, so a FRESH init has to + // leave one behind or this whole ceremony ends in a node that cannot start. + // + // A RE-RUN over an already-provisioned node must NOT mint one, however it got here + // (plain re-run or --force). A hub deployed with no key runs keyless + // (HUB_ALLOW_UNAUTHENTICATED), and every indexer, explorer and shared service pointed + // at it carries no key either; a key appearing in the sidecar flips the hub to + // authenticated on its next deploy and 401s all of them at once. Measured on a regtest + // host: three indexers dropped off the hub-db sync socket while the visible symptom + // named none of it (a mirror-barrier timeout, hub healthy). So the re-run path reads + // and reports, and says out loud what minting would cost; --mint-hub-api-key is the + // explicit opt-in for the old install that really is stuck at a refused hub boot. + const alreadyInitialized = isInitialized() + const hubApiKey = await resolveHubApiKey(alreadyInitialized, opts) + + if (alreadyInitialized && !opts.force) { const existing = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) console.log('Validator already initialized. Pubkey: ' + existing.pubkey) reportHubApiKey(hubApiKey) @@ -1001,24 +1066,18 @@ module.exports = { readWallets, publicWalletInfo, getSignerMountDir, - ensureSignerModulesMountpoint, - fillPublisherConfig, promptSecret, loadSdk, COIN_NETWORKS, - PUBLIC_ENCODER_BASE, CAPS_CONTAINER_PATH, CAPS_CONTAINER_DIR, - CAPS_DIR, VALIDATOR_DIR, WALLETS_FILE, - SIGNER_DIR, SIGNER_CONTAINER_DIR, // Roll-call status reporting (`validator status`). getRollcallStatus, getActiveSignerFile, signerModuleExportsBroadcast, rollcallAbsenceStreak, - rollcallEpochBlocks, - ROLLCALL_DOGE_COST_PER_CALL + rollcallEpochBlocks } diff --git a/src/services/ValidatorStakeService.js b/src/services/ValidatorStakeService.js index 0d38e60..deb2db4 100644 --- a/src/services/ValidatorStakeService.js +++ b/src/services/ValidatorStakeService.js @@ -245,7 +245,11 @@ async function waitForBalance(sdk, address, amount, timeoutMs, log, pollMs) { * matter (network resolution and the WIF/address match). */ function openValidatorSession(opts, deps) { - const settings = deps.settings || getValidatorSettings() + // An explicit null is the caller SAYING there is no validator, not an absent + // injection: `||` treats the two alike and falls through to the real validator + // directory, which leaves the no-validator path exercisable only on a machine + // that happens to have none. Same idiom as deps.wallets below. + const settings = deps.settings !== undefined ? deps.settings : getValidatorSettings() if (!settings) throw fail('no validator configured. Run: xchain-node validator init') const network = settings.network || (settings.P2P_PORT === 10002 ? 'testnet' : (settings.P2P_PORT === 10001 ? 'mainnet' : null)) if (!network || !COIN_NETWORKS[network]) throw fail('validator network unknown; re-run `validator init --network testnet|mainnet`.') @@ -518,6 +522,5 @@ async function stakeValidator(opts = {}, deps = {}) { } module.exports = { - stakeValidator, unstakeValidator, planMints, readChainState, stakeTiming, - DEFAULT_STAKE_AMOUNT, STAKE_TICK + stakeValidator, unstakeValidator, planMints, stakeTiming } diff --git a/src/services/VersionService.js b/src/services/VersionService.js index 1a32dc0..a16354b 100644 --- a/src/services/VersionService.js +++ b/src/services/VersionService.js @@ -207,7 +207,6 @@ module.exports = { readContainerFile, getGithubProjectVersion, checkRemoteNodeVersion, - getRemoteModuleVersion, checkAllRemoteVersions, getLocalNodeVersion, getContainerNodeVersion, diff --git a/src/utils/helpers.js b/src/utils/helpers.js index abd9f28..a6d5ed6 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -125,6 +125,5 @@ module.exports = { stringToNetwork, decompressTarGz, assertSafeArchiveMemberNames, - assertSafeTarGzMembers, redactSecrets } diff --git a/test/unit/AutohealService.test.js b/test/unit/AutohealService.test.js index a95e4ef..d5cae52 100644 --- a/test/unit/AutohealService.test.js +++ b/test/unit/AutohealService.test.js @@ -30,6 +30,18 @@ function logEntry(agoMs, exitCode) { } } +// Health.Log entry helper keyed to an ABSOLUTE start time, for fixtures whose +// probes sit around a pass timestamp other than NOW. +function recordedProbe(atMs, exitCode) { + const start = new Date(atMs) + return { + Start: start.toISOString(), + End: new Date(atMs + 1000).toISOString(), + ExitCode: exitCode, + Output: exitCode === 0 ? 'ok' : 'wget: server returned error' + } +} + // docker-inspect shape for a container in a given health state. `runState` is // State.Status and defaults to 'running'; pass 'exited' to model what Docker // reports for a STOPPED container, whose Health.Status stays frozen at whatever @@ -456,6 +468,36 @@ describe('AutohealService', () => { expect(fresh.skipped[0].reason).to.equal('inside grace window') }) + // A recovery that falls entirely BETWEEN two passes is never seen by the + // `!== unhealthy` branch, so the persisted onset survives it. The relapsed + // episode then inherits the old episode's clock and is restarted inside its + // own grace window. The retained probes carry the evidence: a pass newer + // than the recorded onset. + it('restarts the grace clock when retained probes show a recovery after the persisted onset', async () => { + stubs.db.getAllModuleContainers.resolves([registryRow('xchain-indexer', 'relapse')]) + + stubs.getStatusFromContainer.resolves(unhealthyRingBuffer(NOW)) + const first = await service.runAutoheal({ now: NOW }) + expect(first.restarted).to.have.length(0) + + // Five minutes on. The container passed a probe 45s ago and has been + // failing for 30s since: a NEW episode, well inside the 120s grace. + const later = NOW + 5 * 60000 + stubs.getStatusFromContainer.resolves(inspectStatus('unhealthy', [ + recordedProbe(later - 45000, 0), + recordedProbe(later - 30000, 1), + recordedProbe(later - 15000, 1), + recordedProbe(later, 1) + ])) + const second = await service.runAutoheal({ now: later }) + + expect(stubs.restartContainer.called, 'a relapse must serve its own grace window').to.equal(false) + expect(second.skipped[0].reason).to.equal('inside grace window') + + const state = JSON.parse(fs.readFileSync(path.join(stateDir, 'autoheal-state.json'), 'utf8')) + expect(state.unhealthySince.relapse, 'the onset must be reseeded to the new episode').to.equal(later - 30000) + }) + it('prunes persisted onsets for containers that left the registry', async () => { stubs.db.getAllModuleContainers.resolves([registryRow('xchain-indexer', 'gone')]) stubs.getStatusFromContainer.resolves(unhealthyRingBuffer(NOW)) @@ -511,6 +553,30 @@ describe('AutohealService', () => { }) }) + describe('getLastHealthyProbeMs', () => { + it('returns the newest passing probe', () => { + const health = inspectStatus('unhealthy', [ + logEntry(90000, 0), logEntry(60000, 1), logEntry(45000, 0), logEntry(30000, 1) + ]).State.Health + expect(service.getLastHealthyProbeMs(health)).to.equal(NOW - 45000) + }) + + it('returns null when every retained probe failed', () => { + expect(service.getLastHealthyProbeMs(unhealthyRingBuffer(NOW).State.Health)).to.equal(null) + }) + + it('returns null on an empty or missing log', () => { + expect(service.getLastHealthyProbeMs({ Log: [] })).to.equal(null) + expect(service.getLastHealthyProbeMs({})).to.equal(null) + expect(service.getLastHealthyProbeMs(null)).to.equal(null) + }) + + it('returns null rather than throwing on an unparseable timestamp', () => { + const health = { Log: [{ Start: 'not-a-date', End: 'nor-this', ExitCode: 0 }] } + expect(service.getLastHealthyProbeMs(health)).to.equal(null) + }) + }) + describe('SERVICE_HEALTHCHECK opt-in flags', () => { const { SERVICE_HEALTHCHECK } = require('../../src/services/ModuleService') diff --git a/test/unit/BootstrapHealthGate.test.js b/test/unit/BootstrapHealthGate.test.js index 31c6f2e..d71d59d 100644 --- a/test/unit/BootstrapHealthGate.test.js +++ b/test/unit/BootstrapHealthGate.test.js @@ -77,9 +77,9 @@ function loadGate({ external = false, nativeResolves = null } = {}) { // so each test states only what it changes. function makeRunner({ inspect = healthyInspect(), - // A real decoder publishes reorg_halt_checked_at beside reorg_halted (both shipped - // in the same commit), and only a decoder that never completed a marker probe - // leaves it null. The fixture said "not halted" without ever having looked. + // Models the rich JSON-RPC `health` payload, the first surface probeServiceStatus + // tries; it publishes reorg_halt_checked_at beside reorg_halted. A null + // timestamp means no marker probe ever completed, so it is not a "not halted". status = { status: 'healthy', lag_blocks: 0, reorg_halted: false, reorg_halt_checked_at: 1756000000000 }, tables = '1\t1', reorgHaltRows = '0', @@ -198,6 +198,55 @@ describe('BootstrapHealthGate', function () { expect(err.message).to.match(/marker-table probe[\s\S]*returned unreadable output/) }) + // PARTIAL tokens are the shape parseInt hides: it reads a prefix and drops + // the rest, so '0garbage' would arrive as a clean 0. A 0 there reads as + // "no marker table" and SKIPS the sync_halt probe, so an unreadable answer + // must be refused rather than buy itself a pass on the very next check. + it('REFUSES when a marker-table token is a partial number, and does not skip sync_halt', async function () { + const gate = loadGate() + const runner = makeRunner({ tables: '1\t0garbage' }) + const err = await refusal(callGate(gate, { runner })) + expect(err.message).to.match(/marker-table probe[\s\S]*returned unreadable output/) + const sqls = runner.getCalls().map(c => (c.args[1] || []).join(' ')) + expect(sqls.some(s => /FROM `[^`]+`\.sync_halt/.test(s))).to.equal(false) + expect(sqls.some(s => /FROM `[^`]+`\.events/.test(s))).to.equal(false) + }) + + // TABLE_SCHEMA + TABLE_NAME is unique in information_schema.TABLES, so a + // table-existence count above 1 is not an answer to the question asked. + it('REFUSES a marker-table count outside 0..1', async function () { + const gate = loadGate() + const err = await refusal(callGate(gate, { runner: makeRunner({ tables: '1\t2' }) })) + expect(err.message).to.match(/marker-table probe[\s\S]*returned unreadable output/) + }) + + it('REFUSES when the marker-table probe returns the wrong number of tokens', async function () { + const gate = loadGate() + const err = await refusal(callGate(gate, { runner: makeRunner({ tables: '1' }) })) + expect(err.message).to.match(/marker-table probe[\s\S]*returned unreadable output/) + }) + + // A COUNT(*) is never negative and never has a suffix. Both survive + // parseInt + Number.isFinite and then lose the `> 0` test, so an unreadable + // marker count is refused, never certified as carrying no halt marker. + it('REFUSES a REORG_HALT count that is a partial number', async function () { + const gate = loadGate() + const err = await refusal(callGate(gate, { runner: makeRunner({ reorgHaltRows: '0garbage' }) })) + expect(err.message).to.match(/REORG_HALT marker probe returned unreadable output/) + }) + + it('REFUSES a negative REORG_HALT count', async function () { + const gate = loadGate() + const err = await refusal(callGate(gate, { runner: makeRunner({ reorgHaltRows: '-1' }) })) + expect(err.message).to.match(/REORG_HALT marker probe returned unreadable output/) + }) + + it('REFUSES a sync_halt count that is a partial number', async function () { + const gate = loadGate() + const err = await refusal(callGate(gate, { runner: makeRunner({ syncHaltRows: '2 rows' }) })) + expect(err.message).to.match(/sync_halt marker probe returned unreadable output/) + }) + // A decoder/indexer always provisions `events`; a probe that cannot see it // is not looking at the database that is about to be dumped. it('REFUSES a MariaDB source whose schema reports no events table', async function () { @@ -656,3 +705,44 @@ describe('makeBootstrap() consults the source health gate', function () { expect(gateStub.called).to.equal(false) }) }) + +// The parser the marker probes share. Pinned directly as well as through the +// gate: under a bare parseInt every one of these strings reads as a healthy number. +describe('parseCountTokens()', function () { + + const parse = (raw, opts) => loadGate().parseCountTokens(raw, opts) + + it('accepts whole nonnegative integers', function () { + expect(parse('0', { expected: 1, what: 'p' })).to.deep.equal([0]) + expect(parse('42\n', { expected: 1, what: 'p' })).to.deep.equal([42]) + expect(parse('1\t0', { expected: 2, max: 1, what: 'p' })).to.deep.equal([1, 0]) + }) + + it('refuses partial tokens, signs, decimals and exponents', function () { + for (const raw of ['0garbage', '-1', '1.5', '1e3', 'NaN', '+1', '0x1']) { + expect(() => parse(raw, { expected: 1, what: 'p' }), raw) + .to.throw(/returned unreadable output/) + } + }) + + it('refuses empty and whitespace-only output', function () { + expect(() => parse('', { expected: 1, what: 'p' })).to.throw(/unreadable output/) + expect(() => parse(' ', { expected: 1, what: 'p' })).to.throw(/unreadable output/) + expect(() => parse(null, { expected: 1, what: 'p' })).to.throw(/unreadable output/) + }) + + it('refuses the wrong token count in either direction', function () { + expect(() => parse('1 2', { expected: 1, what: 'p' })).to.throw(/unreadable output/) + expect(() => parse('2', { expected: 2, what: 'p' })).to.throw(/unreadable output/) + }) + + it('refuses a value above max when one is given, and ignores max when it is not', function () { + expect(() => parse('2', { expected: 1, max: 1, what: 'p' })).to.throw(/unreadable output/) + expect(parse('2', { expected: 1, what: 'p' })).to.deep.equal([2]) + }) + + it('quotes the offending output in the refusal so the operator can see it', function () { + expect(() => parse('0garbage', { expected: 1, what: 'REORG_HALT marker probe' })) + .to.throw(/the REORG_HALT marker probe returned unreadable output: "0garbage"/) + }) +}) diff --git a/test/unit/BootstrapRepublishLedger.test.js b/test/unit/BootstrapRepublishLedger.test.js new file mode 100644 index 0000000..37336ea --- /dev/null +++ b/test/unit/BootstrapRepublishLedger.test.js @@ -0,0 +1,342 @@ +'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 reset rebuilds a store on a NEW lineage, so from that moment every bootstrap +// already published for that combo describes the old one: a fresh install that +// takes it restores pre-reindex state and halts. No age check catches that, +// because the wrong archive is hours old. These suites pin the ledger that turns +// a reindex into a due republish. + +const fs = require('fs') +const os = require('os') +const path = require('path') +const { spawn, spawnSync } = require('child_process') +const { expect } = require('chai') + +const { XChainService } = require('../../src/config/constants') + +const TRACKER = XChainService.XCHAIN_UTXO_TRACKER +const DECODER = XChainService.XCHAIN_DECODER +const INDEXER = XChainService.XCHAIN_INDEXER + +describe('BootstrapRepublishLedger', function () { + + let ledgerDir + let ledger + let savedDir + + beforeEach(function () { + ledgerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xchain-republish-')) + savedDir = process.env.XCHAIN_NODE_REINDEX_LEDGER_DIR + process.env.XCHAIN_NODE_REINDEX_LEDGER_DIR = ledgerDir + // The path is resolved per call, so a single require is enough; the + // require cache is cleared anyway so each suite starts from module load. + delete require.cache[require.resolve('../../src/services/BootstrapRepublishLedger')] + ledger = require('../../src/services/BootstrapRepublishLedger') + }) + + afterEach(function () { + if (savedDir === undefined) delete process.env.XCHAIN_NODE_REINDEX_LEDGER_DIR + else process.env.XCHAIN_NODE_REINDEX_LEDGER_DIR = savedDir + fs.rmSync(ledgerDir, { recursive: true, force: true }) + }) + + function ledgerFile() { + return path.join(ledgerDir, 'bootstrap-reindex.json') + } + + function writeRaw(text) { + fs.writeFileSync(ledgerFile(), text) + } + + describe('reindexAffectedModules()', function () { + + it('marks exactly the wiped service', function () { + expect(ledger.reindexAffectedModules({ decoder: true })).to.deep.equal([DECODER]) + expect(ledger.reindexAffectedModules({ utxoTracker: true })).to.deep.equal([TRACKER]) + expect(ledger.reindexAffectedModules({ decoder: true, indexer: true })) + .to.deep.equal([DECODER, INDEXER]) + }) + + // A node-only reset resyncs the same chain and leaves every derived + // store untouched and still valid, so fanning out from it would warn + // about three combos on every ordinary resync. The case that really does + // stale them is a re-genesis, which runs as `reset all` and wipes those + // stores directly. + it('marks nothing extra for a node datadir wipe on its own', function () { + expect(ledger.reindexAffectedModules({ node: true })).to.deep.equal([]) + expect(ledger.reindexAffectedModules({ node: true, decoder: true })).to.deep.equal([DECODER]) + }) + + it('marks all three for a reset all, through their own wipes', function () { + expect(ledger.reindexAffectedModules({ node: true, utxoTracker: true, decoder: true, indexer: true })) + .to.deep.equal([TRACKER, DECODER, INDEXER]) + }) + + it('marks nothing when nothing was wiped', function () { + expect(ledger.reindexAffectedModules({})).to.deep.equal([]) + expect(ledger.reindexAffectedModules()).to.deep.equal([]) + }) + }) + + describe('recordReindex() -> listRepublishDue()', function () { + + it('makes a reindexed combo due, with its reason', function () { + const marked = ledger.recordReindex([DECODER], 'bitcoin', 'testnet', { reason: 'reset xchain-decoder' }) + expect(marked).to.deep.equal(['xchain-decoder:bitcoin:testnet']) + + const due = ledger.listRepublishDue() + expect(due).to.have.length(1) + expect(due[0].combo).to.equal('xchain-decoder:bitcoin:testnet') + expect(due[0].module).to.equal(DECODER) + expect(due[0].coin).to.equal('bitcoin') + expect(due[0].network).to.equal('testnet') + expect(due[0].reason).to.equal('reset xchain-decoder') + expect(due[0].publishedAt).to.equal(null) + }) + + it('returns a stable, sorted list across several combos', function () { + ledger.recordReindex([TRACKER, DECODER, INDEXER], 'litecoin', 'testnet', {}) + const combos = ledger.listRepublishDue().map(d => d.combo) + expect(combos).to.deep.equal([ + 'xchain-decoder:litecoin:testnet', + 'xchain-indexer:litecoin:testnet', + 'xchain-utxo-tracker:litecoin:testnet' + ]) + }) + + it('is silent for a box that never reindexed', function () { + expect(ledger.listRepublishDue()).to.deep.equal([]) + expect(fs.existsSync(ledgerFile())).to.be.false + }) + + it('refuses a combo that is not publishable', function () { + expect(ledger.recordReindex(['xchain-encoder'], 'bitcoin', 'testnet', {})).to.deep.equal([]) + expect(ledger.recordReindex([DECODER], 'notacoin', 'testnet', {})).to.deep.equal([]) + expect(ledger.recordReindex([DECODER], 'bitcoin', 'notanetwork', {})).to.deep.equal([]) + expect(ledger.listRepublishDue()).to.deep.equal([]) + }) + + it('keeps the newest reindex when a combo is wiped twice', function () { + ledger.recordReindex([DECODER], 'bitcoin', 'testnet', { at: new Date('2026-08-01T00:00:00Z'), reason: 'first' }) + ledger.recordReindex([DECODER], 'bitcoin', 'testnet', { at: new Date('2026-09-01T00:00:00Z'), reason: 'second' }) + const due = ledger.listRepublishDue() + expect(due).to.have.length(1) + expect(due[0].reindexedAt).to.equal('2026-09-01T00:00:00.000Z') + expect(due[0].reason).to.equal('second') + }) + }) + + describe('recordBootstrapPublished()', function () { + + it('clears a due combo once a newer archive exists', function () { + ledger.recordReindex([DECODER], 'bitcoin', 'testnet', { at: new Date('2026-09-01T00:00:00Z') }) + expect(ledger.listRepublishDue()).to.have.length(1) + + expect(ledger.recordBootstrapPublished(DECODER, 'bitcoin', 'testnet', { at: new Date('2026-09-01T01:00:00Z') })).to.be.true + expect(ledger.listRepublishDue()).to.deep.equal([]) + }) + + // The whole point of the item: a publish that predates the reindex is + // the STALE-lineage archive, so it must not count as satisfying it. + it('does not clear a combo whose newest archive predates the reindex', function () { + ledger.recordBootstrapPublished(DECODER, 'bitcoin', 'testnet', { at: new Date('2026-08-30T00:00:00Z') }) + ledger.recordReindex([DECODER], 'bitcoin', 'testnet', { at: new Date('2026-09-01T00:00:00Z') }) + + const due = ledger.listRepublishDue() + expect(due.map(d => d.combo)).to.deep.equal(['xchain-decoder:bitcoin:testnet']) + }) + + it('clears only the combo it names', function () { + ledger.recordReindex([TRACKER, DECODER], 'bitcoin', 'testnet', { at: new Date('2026-09-01T00:00:00Z') }) + ledger.recordBootstrapPublished(DECODER, 'bitcoin', 'testnet', { at: new Date('2026-09-01T02:00:00Z') }) + expect(ledger.listRepublishDue().map(d => d.combo)) + .to.deep.equal(['xchain-utxo-tracker:bitcoin:testnet']) + }) + + it('does not grow the ledger for a combo that was never reindexed', function () { + expect(ledger.recordBootstrapPublished(DECODER, 'bitcoin', 'testnet')).to.be.true + expect(fs.existsSync(ledgerFile())).to.be.false + }) + }) + + describe('isRepublishDue()', function () { + + it('is due with a reindex and no publish', function () { + expect(ledger.isRepublishDue({ reindexedAt: '2026-09-01T00:00:00Z', publishedAt: null })).to.be.true + }) + + // Guards against a create that stamps its publish in the same + // millisecond as the marker it clears re-triggering itself forever. + it('is not due when the publish is at or after the reindex', function () { + expect(ledger.isRepublishDue({ reindexedAt: '2026-09-01T00:00:00Z', publishedAt: '2026-09-01T00:00:00Z' })).to.be.false + expect(ledger.isRepublishDue({ reindexedAt: '2026-09-01T00:00:00Z', publishedAt: '2026-09-02T00:00:00Z' })).to.be.false + }) + + it('is not due without a reindex at all', function () { + expect(ledger.isRepublishDue({ reindexedAt: null, publishedAt: '2026-09-01T00:00:00Z' })).to.be.false + expect(ledger.isRepublishDue(null)).to.be.false + }) + + // "We cannot tell when it was published" must not read as "it was + // published after the reindex": that would silently cancel the forced + // republish, which is the exact outcome the ledger exists to prevent. + it('treats an unparseable publish timestamp as no publish', function () { + expect(ledger.isRepublishDue({ reindexedAt: '2026-09-01T00:00:00Z', publishedAt: 'whenever' })).to.be.true + }) + + it('treats an unparseable reindex timestamp as no reindex', function () { + expect(ledger.isRepublishDue({ reindexedAt: 'whenever', publishedAt: null })).to.be.false + }) + }) + + describe('reading a damaged ledger', function () { + + it('starts clean on unparseable JSON rather than throwing inside a reset', function () { + writeRaw('{ not json') + expect(ledger.readReindexLedger().combos).to.deep.equal({}) + expect(ledger.listRepublishDue()).to.deep.equal([]) + // and a fresh mark still lands + expect(ledger.recordReindex([DECODER], 'bitcoin', 'testnet', {})).to.have.length(1) + expect(ledger.listRepublishDue()).to.have.length(1) + }) + + it('starts clean on a well-formed file of the wrong shape', function () { + writeRaw(JSON.stringify({ version: 1, combos: 'nope' })) + expect(ledger.readReindexLedger().combos).to.deep.equal({}) + writeRaw(JSON.stringify([1, 2, 3])) + expect(ledger.readReindexLedger().combos).to.deep.equal({}) + }) + + // The publisher feeds these strings into its shell plan, so a key that + // is not a combo this node could publish is dropped on read rather than + // handed onward. + it('drops keys that are not publishable combos', function () { + writeRaw(JSON.stringify({ + version: 1, + combos: { + 'xchain-decoder:bitcoin:testnet': { reindexedAt: '2026-09-01T00:00:00Z' }, + 'xchain-decoder:bitcoin': { reindexedAt: '2026-09-01T00:00:00Z' }, + 'xchain-encoder:bitcoin:testnet': { reindexedAt: '2026-09-01T00:00:00Z' }, + 'xchain-decoder:bitcoin:mainnet; rm -rf /': { reindexedAt: '2026-09-01T00:00:00Z' }, + 'xchain-decoder:evilcoin:testnet': { reindexedAt: '2026-09-01T00:00:00Z' } + } + })) + expect(ledger.listRepublishDue().map(d => d.combo)) + .to.deep.equal(['xchain-decoder:bitcoin:testnet']) + }) + + it('drops entries that are not objects', function () { + writeRaw(JSON.stringify({ + version: 1, + combos: { 'xchain-decoder:bitcoin:testnet': 'reindexed' } + })) + expect(ledger.listRepublishDue()).to.deep.equal([]) + }) + }) + + describe('writeReindexLedger()', function () { + + it('replaces the file atomically and leaves no temp file behind', function () { + expect(ledger.writeReindexLedger({ version: 1, combos: {} })).to.be.true + expect(fs.existsSync(ledgerFile())).to.be.true + expect(fs.readdirSync(ledgerDir).filter(f => f.endsWith('.tmp'))).to.deep.equal([]) + }) + + // A reset has already wiped a store by the time it records anything, so + // an unwritable ledger dir must report failure, never throw. + it('reports failure instead of throwing when the target is unwritable', function () { + process.env.XCHAIN_NODE_REINDEX_LEDGER_DIR = path.join(ledgerDir, 'a-file', 'nested') + fs.writeFileSync(path.join(ledgerDir, 'a-file'), 'not a directory') + expect(ledger.writeReindexLedger({ version: 1, combos: {} })).to.be.false + expect(ledger.recordReindex([DECODER], 'bitcoin', 'testnet', {})).to.deep.equal([]) + }) + }) + + describe('getReindexLedgerPath()', function () { + + it('defaults to the per-user ~/.xchain-node dir, not the data dir', function () { + delete process.env.XCHAIN_NODE_REINDEX_LEDGER_DIR + // A reset wipes paths under the data dir, and the publisher runs + // `bootstrap create` with XCHAIN_NODE_DATA_DIR pointed at its own + // staging volume, so the marker cannot live there. + expect(ledger.getReindexLedgerPath()) + .to.equal(path.join(os.homedir(), '.xchain-node', 'bootstrap-reindex.json')) + }) + }) + + // The publisher asks this on every run and treats a non-zero exit as "no + // combo is due". Provisioning Docker/MariaDB or queuing behind the command + // lock to read one local JSON file would therefore turn a busy box into a + // silently cancelled republish. Driven as the real CLI in a child process, + // because the behaviour lives in the preAction hook, not an export. + describe('the bootstrap-republish-due command', function () { + + this.timeout(30000) + + const CLI = path.join(__dirname, '..', '..', 'src', 'index.js') + let lockDir, holder + + beforeEach(function () { + lockDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xchain-due-lock-')) + holder = spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)']) + fs.writeFileSync( + path.join(lockDir, 'command.lock'), + JSON.stringify({ pid: holder.pid, command: 'update', startedAt: new Date().toISOString() }) + ) + }) + + afterEach(function () { + if (holder) holder.kill() + fs.rmSync(lockDir, { recursive: true, force: true }) + }) + + function runDue(args = []) { + const res = spawnSync(process.execPath, [CLI, 'bootstrap-republish-due', ...args], { + env: { + ...process.env, + XCHAIN_NODE_REINDEX_LEDGER_DIR: ledgerDir, + XCHAIN_NODE_LOCK_DIR: lockDir, + // No unit test may reach a real Docker daemon. + DOCKER_HOST: 'unix:///nonexistent/xchain-node-test-docker.sock' + }, + encoding: 'utf8', + timeout: 25000 + }) + return { status: res.status, out: `${res.stdout || ''}`, err: `${res.stderr || ''}` } + } + + it('answers while another command holds the lock and Docker is unreachable', function () { + ledger.recordReindex([DECODER], 'bitcoin', 'testnet', { reason: 'reset xchain-decoder' }) + const { status, out, err } = runDue() + expect(status, `stderr: ${err}`).to.equal(0) + expect(out.trim()).to.equal('xchain-decoder:bitcoin:testnet') + expect(err).to.not.match(/holds the command lock/) + expect(err).to.not.match(/Docker is not installed/) + }) + + it('prints nothing and succeeds when no combo is due', function () { + const { status, out } = runDue() + expect(status).to.equal(0) + expect(out.trim()).to.equal('') + }) + + it('--json carries the timestamps the operator needs to judge the gap', function () { + ledger.recordReindex([TRACKER], 'litecoin', 'testnet', { + at: new Date('2026-09-01T00:00:00Z'), reason: 'reset all' + }) + const { status, out } = runDue(['--json']) + expect(status).to.equal(0) + const parsed = JSON.parse(out) + expect(parsed).to.have.length(1) + expect(parsed[0].combo).to.equal('xchain-utxo-tracker:litecoin:testnet') + expect(parsed[0].reindexedAt).to.equal('2026-09-01T00:00:00.000Z') + expect(parsed[0].publishedAt).to.equal(null) + expect(parsed[0].reason).to.equal('reset all') + }) + }) +}) diff --git a/test/unit/BootstrapService.test.js b/test/unit/BootstrapService.test.js index 61fe1b6..573037d 100644 --- a/test/unit/BootstrapService.test.js +++ b/test/unit/BootstrapService.test.js @@ -31,6 +31,44 @@ function drainPassThrough(pt) { pt.resume() } +/** + * The utxo-tracker create path spawns twice (the docker `tar cf -` that feeds + * the inner gzip, then the outer `tar cf -` that the store-only gzip wraps), so + * a single shared fake proc deadlocks the second call. Hand each spawn its own + * proc, drive it to a clean EOF + exit 0, and record what was spawned. + * + * createWriteStream is swapped for a real PassThrough per call so the pipe + * chain ends it naturally and 'finish' fires the way it does in production. + */ +function makeAutoSpawn(stubs, { exitCodes = {} } = {}) { + const calls = [] + stubs.spawn = sinon.stub().callsFake((cmd, args) => { + const proc = makeSpawnProc() + const idx = calls.length + calls.push({ cmd, args, proc }) + const code = Object.prototype.hasOwnProperty.call(exitCodes, idx) ? exitCodes[idx] : 0 + setImmediate(() => { + proc.stdout.end(Buffer.from('tar-bytes')) + setImmediate(() => proc.emit('close', code)) + }) + return proc + }) + stubs.fs.createWriteStream.callsFake(() => { + const ws = new PassThrough() + drainPassThrough(ws) + return ws + }) + return calls +} + +/** The argv of the docker `run` that snapshots the tracker volume, or undefined */ +function findSnapshotCall(execFileStub) { + return execFileStub.getCalls() + .map(c => c.args) + .find(([cmd, args]) => cmd === 'docker' && Array.isArray(args) && + args.includes('sh') && String(args[args.length - 1]).includes('cp -al')) +} + /** Make a fake axios streaming response */ function makeAxiosStreamResponse(statusCode = 200, contentLength = '1024') { const dataStream = new PassThrough() @@ -111,8 +149,26 @@ function makeStubs(overrides = {}) { assertBootstrapSourceHealthy: sinon.stub().resolves({ skipped: false, reasons: [] }) } + // The reindex -> forced-republish ledger. Stubbed so a create in these + // suites never touches the developer's real ~/.xchain-node; the ledger's own + // rules live in BootstrapRepublishLedger.test.js. + const republishLedgerStub = { + recordBootstrapPublished: sinon.stub().returns(true) + } + + // The encoder's scheduled-maintenance sentinel. Stubbed so a create in these + // suites never shells out to `docker exec` against a real encoder; the + // sentinel's own contents and failure handling live in + // EncoderMaintenanceWindow.test.js. + const encoderMaintenanceStub = { + declareEncoderMaintenance: sinon.stub().resolves(true), + clearEncoderMaintenance: sinon.stub().resolves(true) + } + return { - healthGate: healthGateStub, + healthGate: healthGateStub, + republishLedger: republishLedgerStub, + encoderMaintenance: encoderMaintenanceStub, fs: fsStub, db: dbStub, axios: axiosStub, @@ -216,6 +272,13 @@ function loadBootstrapService(stubs) { }, './BootstrapHealthGate': { assertBootstrapSourceHealthy: stubs.healthGate.assertBootstrapSourceHealthy + }, + './BootstrapRepublishLedger': { + recordBootstrapPublished: stubs.republishLedger.recordBootstrapPublished + }, + './EncoderMaintenanceWindow': { + declareEncoderMaintenance: stubs.encoderMaintenance.declareEncoderMaintenance, + clearEncoderMaintenance: stubs.encoderMaintenance.clearEncoderMaintenance } }) } @@ -318,6 +381,64 @@ describe('BootstrapService', function () { }) }) + // A reset rebuilds a store on a new lineage, so every archive + // already published for that combo is wrong while looking perfectly fresh. + // `reset` marks the combo due; only a successful create clears it, and a + // create that never reached the archive must leave the marker standing or + // the forced republish is silently cancelled by the run that failed to do it. + describe('makeBootstrap(): the reindex republish marker', function () { + + it('clears the marker after a create that produced an archive', async function () { + const stubs = makeStubs() + stubs.fs.existsSync.returns(false) + stubs.db.getModuleContainer.resolves(FAKE_CONTAINER_ID) + stubs.execFile = sinon.stub().resolves({ stdout: '104857600\t/data\n' }) + stubs.fs.promises.stat.resolves({ size: 1024 * 1024 }) + makeAutoSpawn(stubs) + + const bs = loadBootstrapService(stubs) + expect(await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER)).to.be.true + + expect(stubs.republishLedger.recordBootstrapPublished.calledOnce).to.be.true + expect(stubs.republishLedger.recordBootstrapPublished.firstCall.args.slice(0, 3)) + .to.deep.equal([XChainService.XCHAIN_UTXO_TRACKER, COIN, NETWORK]) + }) + + it('leaves the marker standing when the source-health gate refuses', async function () { + const stubs = makeStubs() + const refusal = new Error('Refusing to create a bootstrap from xchain-decoder') + refusal.name = 'BootstrapSourceUnhealthyError' + stubs.healthGate.assertBootstrapSourceHealthy.rejects(refusal) + + const bs = loadBootstrapService(stubs) + try { + await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_DECODER) + expect.fail('the refusal should propagate') + } catch (err) { + expect(err.name).to.equal('BootstrapSourceUnhealthyError') + } + expect(stubs.republishLedger.recordBootstrapPublished.called).to.be.false + }) + + it('leaves the marker standing when the create itself fails', async function () { + const stubs = makeStubs() + stubs.fs.existsSync.returns(false) + stubs.db.getModuleContainer.resolves(FAKE_CONTAINER_ID) + stubs.execFile = sinon.stub().resolves({ stdout: '104857600\t/data\n' }) + stubs.fs.promises.stat.resolves({ size: 1024 * 1024 }) + // spawn #0 is the docker tar (inner), spawn #1 the outer wrap: a + // dead wrap means no publishable archive was produced. + makeAutoSpawn(stubs, { exitCodes: { 1: 2 } }) + + const bs = loadBootstrapService(stubs) + const err = await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER) + .then(() => null, e => e) + expect(err).to.not.be.null + expect(err.message).to.include('tar exited with code 2') + expect(stubs.republishLedger.recordBootstrapPublished.called).to.be.false + }) + }) + describe('restoreBootstrap(): dispatch', function () { it('throws for unsupported module', async function () { @@ -332,17 +453,30 @@ describe('BootstrapService', function () { }) }) - describe('utxoTrackerVolumeHasData()', function () { + // uuid:7037604f: the caller reads "empty" as "fresh, restore a bootstrap over + // it", so an inspection FAILURE must never answer empty. Only a CONFIRMED + // empty volume may. + describe('utxoTrackerVolumeFreshness()', function () { - it('returns false when docker volume inspect fails (volume absent)', async function () { + it("reports empty when docker itself says there is no such volume", async function () { const stubs = makeStubs() - stubs.execFile = sinon.stub().rejects(new Error('No such volume')) + stubs.execFile = sinon.stub().rejects(new Error('Error: No such volume: xchain-utxo-tracker-x')) const bs = loadBootstrapService(stubs) - const result = await bs.utxoTrackerVolumeHasData(COIN, NETWORK) - expect(result).to.be.false + const result = await bs.utxoTrackerVolumeFreshness(COIN, NETWORK) + expect(result).to.equal('empty') }) - it('returns true when volume ls shows a non-empty entry', async function () { + // A daemon that cannot be reached says nothing about the volume. + it('reports unknown when the inspect fails for any other reason', async function () { + const stubs = makeStubs() + stubs.execFile = sinon.stub().rejects( + new Error('Cannot connect to the Docker daemon at unix:///var/run/docker.sock')) + const bs = loadBootstrapService(stubs) + const result = await bs.utxoTrackerVolumeFreshness(COIN, NETWORK) + expect(result).to.equal('unknown') + }) + + it('reports populated when volume ls shows a non-empty entry', async function () { const stubs = makeStubs() let callCount = 0 stubs.execFile = sinon.stub().callsFake(() => { @@ -351,11 +485,11 @@ describe('BootstrapService', function () { return Promise.resolve({ stdout: 'LOCK\n' }) // ls shows data }) const bs = loadBootstrapService(stubs) - const result = await bs.utxoTrackerVolumeHasData(COIN, NETWORK) - expect(result).to.be.true + const result = await bs.utxoTrackerVolumeFreshness(COIN, NETWORK) + expect(result).to.equal('populated') }) - it('returns false when volume ls output is empty', async function () { + it('reports empty when volume ls output is empty', async function () { const stubs = makeStubs() let callCount = 0 stubs.execFile = sinon.stub().callsFake(() => { @@ -364,11 +498,11 @@ describe('BootstrapService', function () { return Promise.resolve({ stdout: '' }) // empty volume }) const bs = loadBootstrapService(stubs) - const result = await bs.utxoTrackerVolumeHasData(COIN, NETWORK) - expect(result).to.be.false + const result = await bs.utxoTrackerVolumeFreshness(COIN, NETWORK) + expect(result).to.equal('empty') }) - it('returns false when ls exec fails', async function () { + it('reports unknown when ls exec fails', async function () { const stubs = makeStubs() let callCount = 0 stubs.execFile = sinon.stub().callsFake(() => { @@ -377,8 +511,8 @@ describe('BootstrapService', function () { return Promise.reject(new Error('exec error')) // ls fails }) const bs = loadBootstrapService(stubs) - const result = await bs.utxoTrackerVolumeHasData(COIN, NETWORK) - expect(result).to.be.false + const result = await bs.utxoTrackerVolumeFreshness(COIN, NETWORK) + expect(result).to.equal('unknown') }) }) @@ -1058,42 +1192,47 @@ describe('BootstrapService', function () { }) }) - describe('mariaDbModuleHasData()', function () { + // uuid:7037604f: ModuleService turns a "fresh" answer into DROP DATABASE + + // restore, so every failure below must answer unknown. Only a SUCCESSFUL read + // may authorise that path. + describe('mariaDbModuleFreshness()', function () { - it('returns false when getDatabaseContainerId throws', async function () { + it('reports unknown when getDatabaseContainerId throws', async function () { const stubs = makeStubs() stubs.databaseService.getDatabaseContainerId.rejects(new Error('docker error')) const bs = loadBootstrapService(stubs) - const result = await bs.mariaDbModuleHasData(COIN, NETWORK, XChainService.XCHAIN_DECODER) - expect(result).to.be.false + const result = await bs.mariaDbModuleFreshness(COIN, NETWORK, XChainService.XCHAIN_DECODER) + expect(result).to.equal('unknown') }) - it('returns false when getDatabaseContainerId returns null', async function () { + // No DB container at all is a real fresh install, and must stay one or + // first installs stop bootstrapping. + it('reports empty when getDatabaseContainerId returns null', async function () { const stubs = makeStubs() stubs.databaseService.getDatabaseContainerId.resolves(null) const bs = loadBootstrapService(stubs) - const result = await bs.mariaDbModuleHasData(COIN, NETWORK, XChainService.XCHAIN_DECODER) - expect(result).to.be.false + const result = await bs.mariaDbModuleFreshness(COIN, NETWORK, XChainService.XCHAIN_DECODER) + expect(result).to.equal('empty') }) - it('returns false when askMariadbRootPassword throws', async function () { + it('reports unknown when askMariadbRootPassword throws', async function () { const stubs = makeStubs() stubs.databaseService.askMariadbRootPassword.rejects(new Error('password error')) const bs = loadBootstrapService(stubs) - const result = await bs.mariaDbModuleHasData(COIN, NETWORK, XChainService.XCHAIN_DECODER) - expect(result).to.be.false + const result = await bs.mariaDbModuleFreshness(COIN, NETWORK, XChainService.XCHAIN_DECODER) + expect(result).to.equal('unknown') }) - it('returns false when blocks table does not exist (tblOut = 0)', async function () { + it('reports empty when the blocks table does not exist (tblOut = 0)', async function () { const stubs = makeStubs() // First exec → table count = 0 stubs.execFile = sinon.stub().resolves({ stdout: '0\n' }) const bs = loadBootstrapService(stubs) - const result = await bs.mariaDbModuleHasData(COIN, NETWORK, XChainService.XCHAIN_DECODER) - expect(result).to.be.false + const result = await bs.mariaDbModuleFreshness(COIN, NETWORK, XChainService.XCHAIN_DECODER) + expect(result).to.equal('empty') }) - it('returns false when blocks table exists but has 0 rows', async function () { + it('reports empty when the blocks table exists but has 0 rows', async function () { const stubs = makeStubs() let callCount = 0 stubs.execFile = sinon.stub().callsFake(() => { @@ -1102,11 +1241,11 @@ describe('BootstrapService', function () { return Promise.resolve({ stdout: '0\n' }) // zero rows }) const bs = loadBootstrapService(stubs) - const result = await bs.mariaDbModuleHasData(COIN, NETWORK, XChainService.XCHAIN_DECODER) - expect(result).to.be.false + const result = await bs.mariaDbModuleFreshness(COIN, NETWORK, XChainService.XCHAIN_DECODER) + expect(result).to.equal('empty') }) - it('returns true when blocks table has data', async function () { + it('reports populated when the blocks table has data', async function () { const stubs = makeStubs() let callCount = 0 stubs.execFile = sinon.stub().callsFake(() => { @@ -1115,11 +1254,11 @@ describe('BootstrapService', function () { return Promise.resolve({ stdout: '1000\n' }) // rows present }) const bs = loadBootstrapService(stubs) - const result = await bs.mariaDbModuleHasData(COIN, NETWORK, XChainService.XCHAIN_DECODER) - expect(result).to.be.true + const result = await bs.mariaDbModuleFreshness(COIN, NETWORK, XChainService.XCHAIN_DECODER) + expect(result).to.equal('populated') }) - it('returns true for XCHAIN_INDEXER module', async function () { + it('reports populated for XCHAIN_INDEXER module', async function () { const stubs = makeStubs() let callCount = 0 stubs.execFile = sinon.stub().callsFake(() => { @@ -1128,16 +1267,31 @@ describe('BootstrapService', function () { return Promise.resolve({ stdout: '500\n' }) }) const bs = loadBootstrapService(stubs) - const result = await bs.mariaDbModuleHasData(COIN, NETWORK, XChainService.XCHAIN_INDEXER) - expect(result).to.be.true + const result = await bs.mariaDbModuleFreshness(COIN, NETWORK, XChainService.XCHAIN_INDEXER) + expect(result).to.equal('populated') }) - it('returns false when exec throws on table check', async function () { + it('reports unknown when exec throws on the table check', async function () { const stubs = makeStubs() stubs.execFile = sinon.stub().rejects(new Error('mariadb exec error')) const bs = loadBootstrapService(stubs) - const result = await bs.mariaDbModuleHasData(COIN, NETWORK, XChainService.XCHAIN_DECODER) - expect(result).to.be.false + const result = await bs.mariaDbModuleFreshness(COIN, NETWORK, XChainService.XCHAIN_DECODER) + expect(result).to.equal('unknown') + }) + + // A count that does not parse is not a count: NaN is refused as unknown, + // never reported as the reassuring "fresh". + it('reports unknown when the row count does not parse', async function () { + const stubs = makeStubs() + let callCount = 0 + stubs.execFile = sinon.stub().callsFake(() => { + callCount++ + if (callCount === 1) return Promise.resolve({ stdout: '1\n' }) // table exists + return Promise.resolve({ stdout: 'ERROR 2002 (HY000)\n' }) + }) + const bs = loadBootstrapService(stubs) + const result = await bs.mariaDbModuleFreshness(COIN, NETWORK, XChainService.XCHAIN_DECODER) + expect(result).to.equal('unknown') }) }) @@ -1714,34 +1868,17 @@ describe('BootstrapService', function () { stubs.db.getModuleContainer.resolves(FAKE_CONTAINER_ID) - // All execFile calls succeed (du, docker mkdir, chown, chmod, tar czf) + // All execFile calls succeed (snapshot cleanup, du, snapshot, docker + // mkdir/chown/chmod) stubs.execFile = sinon.stub().resolves({ stdout: '0\n' }) - const tarProc = makeSpawnProc() - stubs.spawn = sinon.stub().returns(tarProc) - stubs.fs.promises.stat.resolves({ size: 1024 * 1024 }) stubs.fs.promises.writeFile.resolves() - stubs.fs.createReadStream.callsFake(() => { - const s = new PassThrough() - setImmediate(() => { s.emit('data', Buffer.from('x')); s.emit('end') }) - return s - }) - - const writeStream = new PassThrough() - drainPassThrough(writeStream) - stubs.fs.createWriteStream.returns(writeStream) + makeAutoSpawn(stubs) const bs = loadBootstrapService(stubs) - const promise = bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER) - - setImmediate(() => { - tarProc.stdout.end() - writeStream.emit('finish') - }) - - const result = await promise + const result = await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER) expect(result).to.be.true // Verify Docker fallback was invoked (chown + chmod calls) @@ -1762,53 +1899,405 @@ describe('BootstrapService', function () { stubs.fs.existsSync.returns(false) // workDir does not exist stubs.db.getModuleContainer.resolves(FAKE_CONTAINER_ID) - // execFile: du → stdout, tar czf → ok - let execCallIdx = 0 stubs.execFile = sinon.stub().callsFake((cmd, args) => { - execCallIdx++ if (cmd === 'docker' && args.includes('du')) { return Promise.resolve({ stdout: '104857600\t/data\n' }) } return Promise.resolve({ stdout: '' }) }) - // spawn for docker tar cf (step 3) - const tarProc = makeSpawnProc() - stubs.spawn = sinon.stub().returns(tarProc) - stubs.fs.promises.stat.resolves({ size: 1024 * 1024 }) stubs.fs.promises.writeFile.resolves() - // createReadStream for computeSha256 - stubs.fs.createReadStream.callsFake(() => { - const s = new PassThrough() - setImmediate(() => { - s.emit('data', Buffer.from('archive content')) - s.emit('end') - }) - return s + makeAutoSpawn(stubs) + + const bs = loadBootstrapService(stubs) + const result = await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER) + expect(result).to.be.true + expect(stubs.dockerService.stopContainer.calledWith(FAKE_CONTAINER_ID)).to.be.true + expect(stubs.dockerService.startContainer.calledWith(FAKE_CONTAINER_ID)).to.be.true + }) + + // Without the snapshot, the monthly publish holds the tracker down for + // the whole compress: 2026-08-01 cost 3h36m on BTC, 1h04m on LTC and + // 42m on DOGE, each of which the mainnet encoder published as + // tracker_reachable:false. The container must come back BEFORE the tar. + it('restarts the tracker before the compress, off a hardlink snapshot', async function () { + const stubs = makeStubs() + stubs.fs.existsSync.returns(false) + stubs.db.getModuleContainer.resolves(FAKE_CONTAINER_ID) + stubs.execFile = sinon.stub().resolves({ stdout: '104857600\t/data\n' }) + stubs.fs.promises.stat.resolves({ size: 1024 * 1024 }) + + let startedBeforeFirstSpawn = null + const spawnCalls = makeAutoSpawn(stubs) + const rawSpawn = stubs.spawn + stubs.spawn = sinon.stub().callsFake((cmd, args) => { + if (startedBeforeFirstSpawn === null) { + startedBeforeFirstSpawn = stubs.dockerService.startContainer.called + } + return rawSpawn(cmd, args) + }) + + const bs = loadBootstrapService(stubs) + expect(await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER)).to.be.true + + // The outage is over before any compression starts. + expect(startedBeforeFirstSpawn).to.be.true + expect(stubs.dockerService.startContainer.callCount).to.equal(1) + + // The snapshot is a hardlink farm taken inside the volume, with the + // mutable files detached from the live inodes. Pinned verbatim: this + // exact text is what test/../scratch verification runs against real + // busybox, and a silent edit here would go unvalidated. + const snapshotCall = findSnapshotCall(stubs.execFile) + expect(snapshotCall, 'expected a docker sh -c snapshot call').to.exist + expect(snapshotCall[1]).to.include('xchain-utxo-tracker-bitcoin-mainnet-data:/data') + expect(snapshotCall[1][snapshotCall[1].length - 1]).to.equal([ + 'set -e', + 'rm -rf /data/.xchain-bootstrap-snapshot', + 'mkdir -p /data/.xchain-bootstrap-snapshot', + "find /data -mindepth 1 -maxdepth 1 ! -name .xchain-bootstrap-snapshot -exec cp -al {} /data/.xchain-bootstrap-snapshot/ ';'", + "find /data/.xchain-bootstrap-snapshot -type f ! -name '*.ldb' ! -name '*.sst' ! -name '*.xcsnap'" + + ` -exec sh -c 'cp -a "$1" "$1.xcsnap" && mv -f "$1.xcsnap" "$1"' _ {} ';'` + ].join('\n')) + + // ...and the compress reads the snapshot, not the live store. + const dockerTar = spawnCalls.find(c => c.cmd === 'docker' && c.args.includes('tar')) + expect(dockerTar).to.exist + expect(dockerTar.args).to.include('/data/.xchain-bootstrap-snapshot') + expect(dockerTar.args).to.not.include('/data') + + // The snapshot is dropped again so it stops pinning compacted SSTs. + const rmCalls = stubs.execFile.getCalls().map(c => c.args) + .filter(([cmd, args]) => cmd === 'docker' && Array.isArray(args) && + args.includes('rm') && args.includes('/data/.xchain-bootstrap-snapshot')) + expect(rmCalls.length).to.be.at.least(2) // stale-snapshot sweep + teardown + }) + + // The outer archive only carries a checksum next + // to an already-gzipped payload, so re-deflating 162.5 GB bought + // nothing. Level 0 keeps the file a real .gz for every consumer. + it('wraps the outer archive with a store-only gzip, not a second deflate', async function () { + const stubs = makeStubs() + stubs.fs.existsSync.returns(false) + stubs.db.getModuleContainer.resolves(FAKE_CONTAINER_ID) + stubs.execFile = sinon.stub().resolves({ stdout: '104857600\t/data\n' }) + stubs.fs.promises.stat.resolves({ size: 1024 * 1024 }) + + const gzipOptions = [] + stubs.zlib.createGzip = sinon.stub().callsFake(opts => { + gzipOptions.push(opts) + return new PassThrough() }) - // createWriteStream for gzip output - const writeStream = new PassThrough() - drainPassThrough(writeStream) - stubs.fs.createWriteStream.returns(writeStream) + const spawnCalls = makeAutoSpawn(stubs) const bs = loadBootstrapService(stubs) - const promise = bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER) + expect(await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER)).to.be.true - // tar proc: pipe resolves when writeStream finishes - setImmediate(() => { - tarProc.stdout.end() - writeStream.emit('finish') + // No `tar czf` anywhere: the wrap is a plain tar plus level-0 gzip. + const czf = stubs.execFile.getCalls().map(c => c.args) + .find(([cmd, args]) => cmd === 'tar' && Array.isArray(args) && args[0] === 'czf') + expect(czf, 'outer archive must not be built with tar czf').to.not.exist + + const wrap = spawnCalls.find(c => c.cmd === 'tar') + expect(wrap, 'expected a plain tar spawn for the outer archive').to.exist + expect(wrap.args.slice(0, 2)).to.deep.equal(['cf', '-']) + expect(wrap.args).to.include('data.tar.gz') + expect(wrap.args).to.include('data.sha256') + + // Inner payload keeps real compression; the outer wrap does not. + expect(gzipOptions).to.have.length(2) + expect(gzipOptions[0]).to.equal(undefined) + expect(gzipOptions[1]).to.deep.equal({ level: 0 }) + }) + + it('checksums the inner archive inline instead of re-reading it', async function () { + const stubs = makeStubs() + stubs.fs.existsSync.returns(false) + stubs.db.getModuleContainer.resolves(FAKE_CONTAINER_ID) + stubs.execFile = sinon.stub().resolves({ stdout: '104857600\t/data\n' }) + stubs.fs.promises.stat.resolves({ size: 1024 * 1024 }) + stubs.fs.createReadStream = sinon.stub().throws(new Error('the inner archive must not be re-read')) + + makeAutoSpawn(stubs) + + const bs = loadBootstrapService(stubs) + expect(await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER)).to.be.true + + // The digest written to data.sha256 is the digest of the bytes the + // gzip stream actually emitted (the fake gzip is a PassThrough, so + // that is the tar payload verbatim). + const expected = require('crypto').createHash('sha256').update(Buffer.from('tar-bytes')).digest('hex') + const [, body] = stubs.fs.promises.writeFile.getCall(0).args + expect(body).to.equal(`${expected} data.tar.gz\n`) + }) + + it('falls back to compressing with the tracker stopped when the snapshot fails', async function () { + const stubs = makeStubs() + stubs.fs.existsSync.returns(false) + stubs.db.getModuleContainer.resolves(FAKE_CONTAINER_ID) + stubs.fs.promises.stat.resolves({ size: 1024 * 1024 }) + + stubs.execFile = sinon.stub().callsFake((cmd, args) => { + if (cmd === 'docker' && Array.isArray(args) && args.includes('sh')) { + return Promise.reject(new Error('cp: cannot create hard link')) + } + return Promise.resolve({ stdout: '104857600\t/data\n' }) }) - const result = await promise - expect(result).to.be.true - expect(stubs.dockerService.stopContainer.calledWith(FAKE_CONTAINER_ID)).to.be.true + let startedBeforeFirstSpawn = null + const spawnCalls = makeAutoSpawn(stubs) + const rawSpawn = stubs.spawn + stubs.spawn = sinon.stub().callsFake((cmd, args) => { + if (startedBeforeFirstSpawn === null) { + startedBeforeFirstSpawn = stubs.dockerService.startContainer.called + } + return rawSpawn(cmd, args) + }) + + const bs = loadBootstrapService(stubs) + expect(await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER)).to.be.true + + // Old behavior, on purpose: a volume that cannot take hardlinks + // still gets a correct archive, just with the outage back. + expect(startedBeforeFirstSpawn).to.be.false + expect(stubs.dockerService.startContainer.callCount).to.equal(1) + const dockerTar = spawnCalls.find(c => c.cmd === 'docker' && c.args.includes('tar')) + expect(dockerTar.args).to.include('/data') + expect(dockerTar.args).to.not.include('/data/.xchain-bootstrap-snapshot') + }) + + it('aborts before compressing when a failed snapshot cannot be cleaned up', async function () { + const stubs = makeStubs() + stubs.fs.existsSync.returns(false) + stubs.db.getModuleContainer.resolves(FAKE_CONTAINER_ID) + stubs.fs.promises.stat.resolves({ size: 1024 * 1024 }) + + // The stale-snapshot sweep before the stop succeeds; the snapshot + // itself fails, and so does the cleanup of its debris. Tarring /data + // now would sweep a half-built snapshot into the published archive. + let rmCalls = 0 + stubs.execFile = sinon.stub().callsFake((cmd, args) => { + if (cmd === 'docker' && Array.isArray(args) && args.includes('sh')) { + return Promise.reject(new Error('cp: cannot create hard link')) + } + if (cmd === 'docker' && Array.isArray(args) && args.includes('rm')) { + rmCalls++ + if (rmCalls > 1) return Promise.reject(new Error('rm: permission denied')) + } + return Promise.resolve({ stdout: '104857600\t/data\n' }) + }) + + makeAutoSpawn(stubs) + + const bs = loadBootstrapService(stubs) + const err = await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER) + .then(() => null, e => e) + expect(err).to.not.be.null + expect(err.message).to.include('rm: permission denied') + expect(stubs.spawn.called, 'must not compress a polluted volume').to.be.false + // The tracker still comes back up. expect(stubs.dockerService.startContainer.calledWith(FAKE_CONTAINER_ID)).to.be.true }) + // The snapshot shrinks the outage but does not + // remove it, and the fallback path still holds the tracker down for the + // whole compress. Whatever the outage's length, the encoder reports it + // honestly and the public board has only one word for it: + // Degraded. The publish therefore tells the encoder the outage is + // planned, so the board can say Maintenance instead. + describe('encoder maintenance window', function () { + function trackerStubs() { + const stubs = makeStubs() + stubs.fs.existsSync.returns(false) + stubs.db.getModuleContainer.resolves(FAKE_CONTAINER_ID) + stubs.fs.promises.stat.resolves({ size: 1024 * 1024 }) + stubs.execFile = sinon.stub().resolves({ stdout: '104857600\t/data\n' }) + return stubs + } + + it('declares the window BEFORE the tracker stops', async function () { + const stubs = trackerStubs() + let declaredBeforeStop = null + stubs.dockerService.stopContainer = sinon.stub().callsFake(async () => { + declaredBeforeStop = stubs.encoderMaintenance.declareEncoderMaintenance.called + }) + makeAutoSpawn(stubs) + + const bs = loadBootstrapService(stubs) + expect(await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER)).to.be.true + + // Otherwise the first probe after the stop still sees a bare + // 503 with nothing to explain it. + expect(declaredBeforeStop, 'the window must be declared before the outage starts').to.be.true + const [coin, network, opts] = stubs.encoderMaintenance.declareEncoderMaintenance.getCall(0).args + expect(coin).to.equal(COIN) + expect(network).to.equal(NETWORK) + expect(opts.reason).to.include(XChainService.XCHAIN_UTXO_TRACKER) + }) + + // On the snapshot path the encoder recovers seconds after the stop, + // so holding the window open for the multi-hour compress would have + // /status advertising maintenance on an encoder that is serving. + it('clears the window as soon as the tracker is back, not when the compress ends', async function () { + const stubs = trackerStubs() + let clearedBeforeFirstSpawn = null + const spawnCalls = makeAutoSpawn(stubs) + const rawSpawn = stubs.spawn + stubs.spawn = sinon.stub().callsFake((cmd, args) => { + if (clearedBeforeFirstSpawn === null) { + clearedBeforeFirstSpawn = stubs.encoderMaintenance.clearEncoderMaintenance.called + } + return rawSpawn(cmd, args) + }) + + const bs = loadBootstrapService(stubs) + expect(await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER)).to.be.true + + expect(clearedBeforeFirstSpawn).to.be.true + // Idempotent: the finally must not clear a window it already closed. + expect(stubs.encoderMaintenance.clearEncoderMaintenance.callCount).to.equal(1) + expect(spawnCalls.length).to.be.at.least(1) + }) + + it('holds the window for the whole compress on the stopped-tracker fallback', async function () { + const stubs = trackerStubs() + stubs.execFile = sinon.stub().callsFake((cmd, args) => { + if (cmd === 'docker' && Array.isArray(args) && args.includes('sh')) { + return Promise.reject(new Error('cp: cannot create hard link')) + } + return Promise.resolve({ stdout: '104857600\t/data\n' }) + }) + let clearedBeforeFirstSpawn = null + const rawSpawnCalls = makeAutoSpawn(stubs) + const rawSpawn = stubs.spawn + stubs.spawn = sinon.stub().callsFake((cmd, args) => { + if (clearedBeforeFirstSpawn === null) { + clearedBeforeFirstSpawn = stubs.encoderMaintenance.clearEncoderMaintenance.called + } + return rawSpawn(cmd, args) + }) + + const bs = loadBootstrapService(stubs) + expect(await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER)).to.be.true + + // The tracker is down for the whole run here, so the window has + // to outlive the compress and close with the restart. + expect(clearedBeforeFirstSpawn).to.be.false + expect(stubs.encoderMaintenance.clearEncoderMaintenance.callCount).to.equal(1) + expect(rawSpawnCalls.length).to.be.at.least(1) + }) + + it('clears the window even when the publish fails mid-compress', async function () { + const stubs = trackerStubs() + // Fallback path, so the window is still open when the run dies: + // on the snapshot path it was already closed at the restart. + stubs.execFile = sinon.stub().callsFake((cmd, args) => { + if (cmd === 'docker' && Array.isArray(args) && args.includes('sh')) { + return Promise.reject(new Error('cp: cannot create hard link')) + } + return Promise.resolve({ stdout: '104857600\t/data\n' }) + }) + stubs.fs.promises.writeFile.rejects(new Error('ENOSPC: no space left on device')) + makeAutoSpawn(stubs) + + const bs = loadBootstrapService(stubs) + const err = await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER) + .then(() => null, e => e) + expect(err).to.not.be.null + // A failed run must not leave the board excusing an encoder that + // is serving again. + expect(stubs.encoderMaintenance.clearEncoderMaintenance.called).to.be.true + }) + + // A cosmetic status label is never worth a failed publish or a + // tracker left down. + it('publishes normally when the encoder cannot be told', async function () { + const stubs = trackerStubs() + stubs.encoderMaintenance.declareEncoderMaintenance = sinon.stub().resolves(false) + makeAutoSpawn(stubs) + + const bs = loadBootstrapService(stubs) + expect(await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER)).to.be.true + // Nothing was declared, so nothing is cleared. + expect(stubs.encoderMaintenance.clearEncoderMaintenance.called).to.be.false + expect(stubs.dockerService.startContainer.calledWith(FAKE_CONTAINER_ID)).to.be.true + }) + }) + + // The snapshot buys uptime by pinning compacted SSTs, which costs volume + // space for the length of the run. Filling the volume halts the tracker, + // so a thin volume has to be said out loud. + describe('volume headroom warning', function () { + async function runWithDf(dfLine) { + const stubs = makeStubs() + stubs.fs.existsSync.returns(false) + stubs.db.getModuleContainer.resolves(FAKE_CONTAINER_ID) + stubs.fs.promises.stat.resolves({ size: 1024 * 1024 }) + stubs.execFile = sinon.stub().callsFake((cmd, args) => { + if (cmd === 'docker' && Array.isArray(args) && args.includes('du')) { + // 100 GB store + return Promise.resolve({ stdout: `${100 * 1024 * 1024 * 1024}\t/data\n` }) + } + if (cmd === 'docker' && Array.isArray(args) && args.includes('df')) { + return Promise.resolve({ stdout: `Filesystem 1024-blocks Used Available Capacity Mounted on\n${dfLine}\n` }) + } + return Promise.resolve({ stdout: '' }) + }) + makeAutoSpawn(stubs) + + const logged = [] + const origLog = console.log + console.log = (...args) => logged.push(args.join(' ')) + try { + const bs = loadBootstrapService(stubs) + expect(await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER)).to.be.true + } finally { + console.log = origLog + } + return logged.join('\n') + } + + it('warns when free space is under the churn headroom', async function () { + // 5 GB free against a 100 GB store (headroom wants 15 GB) + const out = await runWithDf(`overlay 209715200 104857600 ${5 * 1024 * 1024} 96% /data`) + expect(out).to.contain('WARNING') + expect(out).to.contain('5.0 GB free against a 100.0 GB store') + }) + + it('stays quiet when the volume has room', async function () { + // 40 GB free against a 100 GB store + const out = await runWithDf(`overlay 209715200 104857600 ${40 * 1024 * 1024} 60% /data`) + expect(out).to.not.contain('WARNING') + }) + }) + + it('refuses to publish a truncated outer archive when the wrap tar dies', async function () { + const stubs = makeStubs() + stubs.fs.existsSync.returns(false) + stubs.db.getModuleContainer.resolves(FAKE_CONTAINER_ID) + stubs.execFile = sinon.stub().resolves({ stdout: '104857600\t/data\n' }) + stubs.fs.promises.stat.resolves({ size: 1024 * 1024 }) + + // spawn #0 is the docker tar (inner), spawn #1 the outer wrap. + makeAutoSpawn(stubs, { exitCodes: { 1: 2 } }) + + const bs = loadBootstrapService(stubs) + const err = await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER) + .then(() => null, e => e) + expect(err).to.not.be.null + expect(err.message).to.include('tar exited with code 2') + expect(stubs.dockerService.startContainer.called).to.be.true + + // The partial archive must not be left in the directory the publish + // rsyncs from. + const removed = stubs.fs.rmSync.getCalls() + .some(c => String(c.args[0]).endsWith('.tar.gz')) + expect(removed, 'the truncated outer archive must be removed').to.be.true + }) + it('throws when container not found', async function () { const stubs = makeStubs() stubs.db.getModuleContainer.resolves(null) @@ -1826,39 +2315,21 @@ describe('BootstrapService', function () { const stubs = makeStubs() stubs.db.getModuleContainer.resolves(FAKE_CONTAINER_ID) - // First execFile call (docker du) throws → triggers catch at line 180 - let execCallCount = 0 - stubs.execFile = sinon.stub().callsFake(() => { - execCallCount++ - if (execCallCount === 1) return Promise.reject(new Error('docker du failed')) + // The `docker du` size estimate throws → progress falls back to ?% + stubs.execFile = sinon.stub().callsFake((cmd, args) => { + if (cmd === 'docker' && Array.isArray(args) && args.includes('du')) { + return Promise.reject(new Error('docker du failed')) + } return Promise.resolve({ stdout: '' }) }) - const tarProc = makeSpawnProc() - stubs.spawn = sinon.stub().returns(tarProc) - stubs.fs.promises.stat.resolves({ size: 1024 * 1024 }) stubs.fs.promises.writeFile.resolves() - stubs.fs.createReadStream.callsFake(() => { - const s = new PassThrough() - setImmediate(() => { s.emit('data', Buffer.from('x')); s.emit('end') }) - return s - }) - - const writeStream = new PassThrough() - drainPassThrough(writeStream) - stubs.fs.createWriteStream.returns(writeStream) + makeAutoSpawn(stubs) const bs = loadBootstrapService(stubs) - const promise = bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER) - - setImmediate(() => { - tarProc.stdout.end() - writeStream.emit('finish') - }) - - const result = await promise + const result = await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER) expect(result).to.be.true }) @@ -1969,6 +2440,65 @@ describe('BootstrapService', function () { expect(spawnArgs).to.include('MYSQL_PWD') expect(spawnArgs.some(a => String(a).includes('rootpass'))).to.be.false expect(spawnOpts.env.MYSQL_PWD).to.equal('rootpass') + + // The gate is consulted TWICE: once before the dump, and once after it + // finishes but before anything is checksummed, wrapped or signed. The + // producers stay live for the whole dump, so one reading before it + // cannot speak for the bytes that ship. + expect(stubs.healthGate.assertBootstrapSourceHealthy.callCount).to.equal(2) + }) + + // A halt marker can be written while mariadb-dump is still streaming, and + // such an archive must not ship: signed, it becomes the newest (default) + // recovery source. + it('discards a finished dump when the source stops being healthy during it', async function () { + const stubs = makeStubs() + + stubs.databaseService.getDatabaseContainerId.resolves(FAKE_DB_CONTAINER) + stubs.databaseService.askMariadbRootPassword.resolves('rootpass') + + const refusal = new Error("Refusing to create a bootstrap from btc/mainnet xchain-decoder: " + + "the database carries a durable REORG_HALT marker") + refusal.name = 'BootstrapSourceUnhealthyError' + stubs.healthGate.assertBootstrapSourceHealthy + .onFirstCall().resolves({ skipped: false, reasons: [] }) + .onSecondCall().rejects(refusal) + + stubs.execFile = sinon.stub().resolves({ stdout: '52428800\n' }) + + const dumpProc = makeSpawnProc() + stubs.spawn = sinon.stub().returns(dumpProc) + + stubs.fs.promises.stat.resolves({ size: 512 * 1024 }) + stubs.fs.promises.writeFile.resolves() + + 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') + }) + + let err = null + try { await promise } catch (e) { err = e } + expect(err, 'the create must reject rather than publish').to.equal(refusal) + + // Nothing may be packaged or signed after the refusal. + const tarCalls = stubs.execFile.getCalls() + .filter(c => c.args[0] === 'tar' && (c.args[1] || [])[0] === 'czf') + expect(tarCalls.length, 'no archive may be wrapped').to.equal(0) + expect(stubs.fs.promises.writeFile.called, 'no checksum file may be written').to.equal(false) + + // The half-built work directory goes, and the republish ledger stays + // honest: nothing was published, so nothing is recorded as published. + expect(stubs.fs.rmSync.getCalls().some(c => String(c.args[0]).includes('bootstrap-work'))) + .to.equal(true) + expect(stubs.republishLedger.recordBootstrapPublished.called).to.equal(false) }) it('throws when getDatabaseContainerId returns null', async function () { diff --git a/test/unit/ConfigService.test.js b/test/unit/ConfigService.test.js index 7169077..a3de8bf 100644 --- a/test/unit/ConfigService.test.js +++ b/test/unit/ConfigService.test.js @@ -23,9 +23,24 @@ const { moduleDir, tmpDir, cryptoNodesDir, dataDir, configDir } = require('../../src/config/constants') +// getDefaultConfig() pulls ValidatorService in lazily for the hub module, and +// ValidatorService reads config/validator/ off the REAL filesystem through its own +// `fs` binding, which the fs stub below does not reach. On a developer or operator +// box that has run `xchain-node validator init` that directory exists, so an +// unstubbed run reads the machine's recorded network (HUB_NETWORK) and its live +// signing.key into the config object under test: assertions about a standalone +// install then fail, and a real key ends up in a test fixture. Every factory here +// therefore describes a machine with no validator, which is the state CI runs in +// (config/validator/ is gitignored). Tests that WANT a validator stub their own. +const NO_VALIDATOR = { + getValidatorSettings: () => null, + getValidatorEnv: () => ({}) +} + function makeConfigService(fsStub) { return proxyquire('../../src/services/ConfigService', { - 'fs': fsStub || require('fs') + 'fs': fsStub || require('fs'), + './ValidatorService': NO_VALIDATOR }) } @@ -301,6 +316,48 @@ describe('ConfigService', function () { return makeConfigService(fsStub) } + // The regtest-only passthrough. Every name here is a value a host env var must + // never carry onto a shared ledger: three are consensus inputs where a per-node + // value forks settlement, and the fourth only shapes how much a failed barrier + // attempt costs. This gate is one of TWO independent ones (the indexer refuses + // the same vars again on its own side), and neither had a test, while the list + // is edited by whoever needs the next knob. + describe('regtest-only env passthrough to the indexer', function () { + + const REGTEST_ONLY = [ + 'XC_ROLLCALL_REGTEST_ACTIVATION', + 'HUB_SYNC_ANCHOR_ATTEST_GRACE_S', + 'HUB_PRICE_SYNC_TIMEOUT_MS', + 'XCHAIN_COINPAY_EXPIRATION_S' + ] + + let saved + beforeEach(function () { + saved = {} + for (const k of REGTEST_ONLY) { saved[k] = process.env[k]; process.env[k] = '1234' } + }) + afterEach(function () { + for (const k of REGTEST_ONLY) { + if (saved[k] === undefined) delete process.env[k] + else process.env[k] = saved[k] + } + }) + + it('carries every regtest-only var onto a regtest indexer', async function () { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(XChainService.XCHAIN_INDEXER, 'bitcoin', 'regtest') + for (const k of REGTEST_ONLY) expect(config[k], k).to.equal('1234') + }) + + for (const net of ['mainnet', 'testnet']) { + it('carries none of them onto ' + net + ', so a host variable cannot reach a shared ledger', async function () { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(XChainService.XCHAIN_INDEXER, 'bitcoin', net) + for (const k of REGTEST_ONLY) expect(config, k).to.not.have.property(k) + }) + } + }) + describe('with coin and network (coin-specific config)', function () { it('returns NETWORK matching the network arg', async function () { @@ -400,6 +457,7 @@ describe('ConfigService', function () { } const cs = proxyquire('../../src/services/ConfigService', { 'fs': fsStub, + './ValidatorService': NO_VALIDATOR, './DatabaseService': { getDatabaseContainerId: async () => dbContainerId, getExternalDbConfig: async () => ({ host: '172.18.0.1', port: 3307, root_user: 'root', root_password: 'x' }) @@ -768,6 +826,157 @@ describe('ConfigService', function () { }) }) + // The passthrough is the only supported way to arm a DEPLOYED indexer for + // ROLLCALL, and the only way its DOGE proof peer survives an `update`. + describe('ROLLCALL rail passthrough', function () { + const ROLLCALL_VARS = [ + 'DOGE_INDEXER_API_URL', 'DOGE_INDEXER_API_KEY', 'XC_ROLLCALL_REGTEST_ACTIVATION' + ] + let saved + beforeEach(function () { + saved = {} + for (const v of ROLLCALL_VARS) { saved[v] = process.env[v]; delete process.env[v] } + }) + afterEach(function () { + for (const v of ROLLCALL_VARS) { + if (saved[v] === undefined) delete process.env[v]; else process.env[v] = saved[v] + } + }) + + it('passes the DOGE proof peer through to the indexer on regtest', async function () { + process.env.DOGE_INDEXER_API_URL = 'http://dogecoin-regtest-indexer:3004/api' + process.env.DOGE_INDEXER_API_KEY = 'not-a-real-key' + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig('xchain-indexer', 'bitcoin', 'regtest') + expect(config['DOGE_INDEXER_API_URL']).to.equal('http://dogecoin-regtest-indexer:3004/api') + expect(config['DOGE_INDEXER_API_KEY']).to.equal('not-a-real-key') + }) + + // Roll calls land on DOGE on every network, so the close needs a reachable + // DOGE indexer on testnet and mainnet too, not only on the acceptance venue. + it('passes the DOGE proof peer through on testnet as well', async function () { + process.env.DOGE_INDEXER_API_URL = 'https://doge.example.invalid/api' + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig('xchain-indexer', 'bitcoin', 'testnet') + expect(config['DOGE_INDEXER_API_URL']).to.equal('https://doge.example.invalid/api') + }) + + it('arms the indexer on regtest when the host opts in', async function () { + process.env.XC_ROLLCALL_REGTEST_ACTIVATION = 'armed' + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig('xchain-indexer', 'bitcoin', 'regtest') + expect(config['XC_ROLLCALL_REGTEST_ACTIVATION']).to.equal('armed') + }) + + it('carries a bare arming height through unaltered', async function () { + process.env.XC_ROLLCALL_REGTEST_ACTIVATION = '900' + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig('xchain-indexer', 'bitcoin', 'regtest') + expect(config['XC_ROLLCALL_REGTEST_ACTIVATION']).to.equal('900') + }) + + // The deploy path is the SECOND gate. rollcall_activation.js cannot reach + // the environment for a shared-ledger network at all, and this makes the + // host variable stop at the container door there as well, so neither gate + // being edited alone can arm mainnet or testnet from a host variable. + it('NEVER arms a shared-ledger indexer, whatever the host env says', async function () { + process.env.XC_ROLLCALL_REGTEST_ACTIVATION = 'armed' + const cs = makeServiceWithConfig('') + for (const net of ['mainnet', 'testnet']) { + const config = await cs.getDefaultConfig('xchain-indexer', 'bitcoin', net) + expect(config, net).to.not.have.property('XC_ROLLCALL_REGTEST_ACTIVATION') + } + }) + + it('does NOT inject the rollcall vars into a non-indexer coin module (decoder)', async function () { + process.env.DOGE_INDEXER_API_URL = 'http://x/api' + process.env.XC_ROLLCALL_REGTEST_ACTIVATION = 'armed' + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig('xchain-decoder', 'bitcoin', 'regtest') + expect(config).to.not.have.property('DOGE_INDEXER_API_URL') + expect(config).to.not.have.property('XC_ROLLCALL_REGTEST_ACTIVATION') + }) + + // ROLLCALL_ACTIVATION is a consensus_rules_digest SHARED_GATE, so an armed + // indexer beside an inert container hub reports a rules mismatch. A venue + // has to arm as a unit, which means the hub takes the same variable. + it('arms the container hub from the same variable, so the venue arms as a unit', async function () { + process.env.XC_ROLLCALL_REGTEST_ACTIVATION = 'armed' + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig('xchain-hub', null, null) + expect(config['XC_ROLLCALL_REGTEST_ACTIVATION']).to.equal('armed') + }) + + it('omits every rollcall var when unset, so a venue ships INERT', async function () { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig('xchain-indexer', 'bitcoin', 'regtest') + for (const v of ROLLCALL_VARS) expect(config).to.not.have.property(v) + const hub = await cs.getDefaultConfig('xchain-hub', null, null) + expect(hub).to.not.have.property('XC_ROLLCALL_REGTEST_ACTIVATION') + }) + }) + + // Regtest mirror arming: the regtest indexer's hub-mirror connection, unset + // before this row, and the three watermark graces that must be zeroed alongside + // it or an armed regtest venue wedges every freshly mined block (the price-grace + // failure the regtest mirror wedge records). + describe('regtest mirror arming', function () { + const GRACE_VARS = [ + 'HUB_SYNC_PRICE_GRACE_S', 'HUB_SYNC_ORACLE_GRACE_S', 'HUB_SYNC_ATTEST_RESPONSE_GRACE_S' + ] + let saved + beforeEach(function () { + saved = {} + for (const v of GRACE_VARS) { saved[v] = process.env[v]; delete process.env[v] } + }) + afterEach(function () { + for (const v of GRACE_VARS) { + if (saved[v] === undefined) delete process.env[v]; else process.env[v] = saved[v] + } + }) + + it('arms the regtest indexer hub-mirror pointer at its own DB account', async function () { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig('xchain-indexer', 'bitcoin', 'regtest') + expect(config['HUB_DB_NAME']).to.equal(config['INDEXER_DB_NAME']) + expect(config['HUB_DB_USER']).to.equal(config['INDEXER_DB_USER']) + expect(config['HUB_DB_SYNC_ENABLED']).to.equal('true') + // The password must follow the same account, or the armed mirror + // authenticates as the indexer's own DB user with the wrong password. + expect(config['HUB_DB_PASS']).to.equal(config['INDEXER_DB_PASS']) + }) + + it('defaults all three watermark graces to 0 on regtest when the host sets none of them', async function () { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig('xchain-indexer', 'bitcoin', 'regtest') + for (const v of GRACE_VARS) expect(config[v], v).to.equal('0') + }) + + it('lets a host-set grace value win over the regtest default', async function () { + process.env.HUB_SYNC_ATTEST_RESPONSE_GRACE_S = '30' + process.env.HUB_SYNC_PRICE_GRACE_S = '15' + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig('xchain-indexer', 'bitcoin', 'regtest') + expect(config['HUB_SYNC_ATTEST_RESPONSE_GRACE_S']).to.equal('30') + expect(config['HUB_SYNC_PRICE_GRACE_S']).to.equal('15') + // The var left unset by the host still gets the regtest default. + expect(config['HUB_SYNC_ORACLE_GRACE_S']).to.equal('0') + }) + + it('leaves mainnet/testnet mirror arming exactly as before (same account, no grace defaults)', async function () { + process.env.HUB_SYNC_ATTEST_RESPONSE_GRACE_S = '30' // must be ignored off regtest + const cs = makeServiceWithConfig('') + for (const network of ['mainnet', 'testnet']) { + const config = await cs.getDefaultConfig('xchain-indexer', 'bitcoin', network) + expect(config['HUB_DB_NAME'], network).to.equal(config['INDEXER_DB_NAME']) + expect(config['HUB_DB_USER'], network).to.equal(config['INDEXER_DB_USER']) + expect(config['HUB_DB_SYNC_ENABLED'], network).to.equal('true') + expect(config['HUB_DB_PASS'], network).to.equal(config['INDEXER_DB_PASS']) + for (const v of GRACE_VARS) expect(config, network + ' ' + v).to.not.have.property(v) + } + }) + }) + it('returns correct INDEXER_COIN ticker', async function () { const cs = makeServiceWithConfig('') const config = await cs.getDefaultConfig('xchain-indexer', 'bitcoin', 'mainnet') @@ -865,6 +1074,94 @@ describe('ConfigService', function () { const config = await cs.getDefaultConfig('xchain-decoder', 'bitcoin', 'mainnet') expect(config['DECODER_DB_PORT']).to.equal(3306) }) + + // Encoder passthrough (rate-limits-that-fit-the-wallet D7/D8/C8, row 12): + // ENCODER_TRUST_PROXY and ENCODER_RATE_LIMIT_RPM survive an + // update/recreate only if they ride the host env into the container's + // default config, mirroring the explorer serving-limit passthrough below. + describe('encoder passthrough (ENCODER_TRUST_PROXY / ENCODER_RATE_LIMIT_RPM)', function () { + + it('passes ENCODER_TRUST_PROXY and ENCODER_RATE_LIMIT_RPM through from the host env', async function () { + const prev = { + proxy: process.env.ENCODER_TRUST_PROXY, + rpm: process.env.ENCODER_RATE_LIMIT_RPM + } + process.env.ENCODER_TRUST_PROXY = '203.0.113.9' + process.env.ENCODER_RATE_LIMIT_RPM = '240' + try { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') + expect(config['ENCODER_TRUST_PROXY']).to.equal('203.0.113.9') + expect(config['ENCODER_RATE_LIMIT_RPM']).to.equal('240') + } finally { + for (const [k, v] of [ + ['ENCODER_TRUST_PROXY', prev.proxy], + ['ENCODER_RATE_LIMIT_RPM', prev.rpm] + ]) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + }) + + it('emits neither key when the host env carries no encoder passthrough values', async function () { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') + expect(config).to.not.have.property('ENCODER_TRUST_PROXY') + expect(config).to.not.have.property('ENCODER_RATE_LIMIT_RPM') + }) + + // The regtest block above sets ENCODER_RATE_LIMIT_RPM=99999 unconditionally + // (a bursty e2e-suite accommodation); this passthrough runs AFTER it, so an + // operator's host value still wins on a regtest venue. + it('lets a host ENCODER_RATE_LIMIT_RPM win over the regtest 99999 literal', async function () { + const prev = process.env.ENCODER_RATE_LIMIT_RPM + process.env.ENCODER_RATE_LIMIT_RPM = '300' + try { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(XChainService.XCHAIN_ENCODER, 'bitcoin', 'regtest') + expect(config['ENCODER_RATE_LIMIT_RPM']).to.equal('300') + } finally { + if (prev === undefined) delete process.env.ENCODER_RATE_LIMIT_RPM + else process.env.ENCODER_RATE_LIMIT_RPM = prev + } + }) + + it('keeps the regtest 99999 literal when the host env sets no override', async function () { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(XChainService.XCHAIN_ENCODER, 'bitcoin', 'regtest') + expect(config['ENCODER_RATE_LIMIT_RPM']).to.equal(99999) + }) + + // Gated on module === XCHAIN_ENCODER; a decoder or utxo-tracker config + // for the same coin/network must never pick this up. + it('does not leak the encoder passthrough onto decoder or utxo-tracker configs', async function () { + const prev = { + proxy: process.env.ENCODER_TRUST_PROXY, + rpm: process.env.ENCODER_RATE_LIMIT_RPM + } + process.env.ENCODER_TRUST_PROXY = '203.0.113.9' + process.env.ENCODER_RATE_LIMIT_RPM = '240' + try { + const cs = makeServiceWithConfig('') + const decoderConfig = await cs.getDefaultConfig(XChainService.XCHAIN_DECODER, 'bitcoin', 'mainnet') + expect(decoderConfig).to.not.have.property('ENCODER_TRUST_PROXY') + expect(decoderConfig).to.not.have.property('ENCODER_RATE_LIMIT_RPM') + const trackerConfig = await cs.getDefaultConfig(XChainService.XCHAIN_UTXO_TRACKER, 'bitcoin', 'mainnet') + expect(trackerConfig).to.not.have.property('ENCODER_TRUST_PROXY') + expect(trackerConfig).to.not.have.property('ENCODER_RATE_LIMIT_RPM') + } finally { + for (const [k, v] of [ + ['ENCODER_TRUST_PROXY', prev.proxy], + ['ENCODER_RATE_LIMIT_RPM', prev.rpm] + ]) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + }) + + }) }) describe('without coin/network (shared service config)', function () { @@ -920,6 +1217,107 @@ describe('ConfigService', function () { } }) + // Both serving limits default to values tuned for a PUBLIC explorer: + // 500 requests/min/IP, and a 6-hour tip-age gate that delists a coin. + // A private venue needs both loosened (a regtest chain only advances + // when someone mines, so an idle one goes "stale" while lag stays 0), + // and the explorer is a shared service with no per-venue config file, + // so host env is the only injection point it has. + it('passes the explorer serving limits through from the host env', async function () { + const prev = { + rpm: process.env.EXPLORER_RATE_LIMIT_RPM, + fq: process.env.EXPLORER_FEE_QUOTE_RATE_LIMIT_RPM, + age: process.env.EXPLORER_TIP_MAX_AGE_S, + coin: process.env.EXPLORER_TIP_MAX_AGE_S_RBTC + } + process.env.EXPLORER_RATE_LIMIT_RPM = '5000' + process.env.EXPLORER_FEE_QUOTE_RATE_LIMIT_RPM = '2000' + process.env.EXPLORER_TIP_MAX_AGE_S = '0' + process.env.EXPLORER_TIP_MAX_AGE_S_RBTC = '0' + try { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(EXPLORER_MODULE_NAME, null, null) + expect(config['EXPLORER_RATE_LIMIT_RPM']).to.equal('5000') + expect(config['EXPLORER_FEE_QUOTE_RATE_LIMIT_RPM']).to.equal('2000') + expect(config['EXPLORER_TIP_MAX_AGE_S']).to.equal('0') + // Deliberately NOT carried: the explorer honours the per-coin + // form itself, and passing it through here would need a + // computed env read, which the platform's coverage gate cannot + // scan. The global knob covers the case this exists for. + expect(config).to.not.have.property('EXPLORER_TIP_MAX_AGE_S_RBTC') + } finally { + for (const [k, v] of [ + ['EXPLORER_RATE_LIMIT_RPM', prev.rpm], + ['EXPLORER_FEE_QUOTE_RATE_LIMIT_RPM', prev.fq], + ['EXPLORER_TIP_MAX_AGE_S', prev.age], + ['EXPLORER_TIP_MAX_AGE_S_RBTC', prev.coin] + ]) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + }) + + // Unset stays unset: the explorer's own defaults must keep applying to + // a deployment that never sets these, or every install would start + // emitting a limit nobody chose. + it('emits no serving-limit keys when the host env carries none', async function () { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(EXPLORER_MODULE_NAME, null, null) + expect(config).to.not.have.property('EXPLORER_RATE_LIMIT_RPM') + expect(config).to.not.have.property('EXPLORER_TIP_MAX_AGE_S') + }) + + // The five per-route knobs (checkpoint-list/verify, action-proof, + // validator-set-proof, vm-query) were missing from this passthrough + // (row 14, rate-limits-that-fit-the-wallet C10): a node-managed + // explorer (the regtest venue) could raise only the app-wide and + // fee-quote caps before this change. + it('passes the five per-route explorer rate limits through from the host env', async function () { + const prev = { + list: process.env.EXPLORER_CHECKPOINT_LIST_RATE_LIMIT_RPM, + verify: process.env.EXPLORER_CHECKPOINT_VERIFY_RATE_LIMIT_RPM, + action: process.env.EXPLORER_ACTION_PROOF_RATE_LIMIT_RPM, + valset: process.env.EXPLORER_VALIDATOR_SET_PROOF_RATE_LIMIT_RPM, + vmquery: process.env.EXPLORER_VM_QUERY_RATE_LIMIT_RPM + } + process.env.EXPLORER_CHECKPOINT_LIST_RATE_LIMIT_RPM = '150' + process.env.EXPLORER_CHECKPOINT_VERIFY_RATE_LIMIT_RPM = '95' + process.env.EXPLORER_ACTION_PROOF_RATE_LIMIT_RPM = '95' + process.env.EXPLORER_VALIDATOR_SET_PROOF_RATE_LIMIT_RPM = '35' + process.env.EXPLORER_VM_QUERY_RATE_LIMIT_RPM = '25' + try { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(EXPLORER_MODULE_NAME, null, null) + expect(config['EXPLORER_CHECKPOINT_LIST_RATE_LIMIT_RPM']).to.equal('150') + expect(config['EXPLORER_CHECKPOINT_VERIFY_RATE_LIMIT_RPM']).to.equal('95') + expect(config['EXPLORER_ACTION_PROOF_RATE_LIMIT_RPM']).to.equal('95') + expect(config['EXPLORER_VALIDATOR_SET_PROOF_RATE_LIMIT_RPM']).to.equal('35') + expect(config['EXPLORER_VM_QUERY_RATE_LIMIT_RPM']).to.equal('25') + } finally { + for (const [k, v] of [ + ['EXPLORER_CHECKPOINT_LIST_RATE_LIMIT_RPM', prev.list], + ['EXPLORER_CHECKPOINT_VERIFY_RATE_LIMIT_RPM', prev.verify], + ['EXPLORER_ACTION_PROOF_RATE_LIMIT_RPM', prev.action], + ['EXPLORER_VALIDATOR_SET_PROOF_RATE_LIMIT_RPM', prev.valset], + ['EXPLORER_VM_QUERY_RATE_LIMIT_RPM', prev.vmquery] + ]) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + }) + + it('emits no per-route explorer rate-limit keys when the host env carries none', async function () { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(EXPLORER_MODULE_NAME, null, null) + expect(config).to.not.have.property('EXPLORER_CHECKPOINT_LIST_RATE_LIMIT_RPM') + expect(config).to.not.have.property('EXPLORER_CHECKPOINT_VERIFY_RATE_LIMIT_RPM') + expect(config).to.not.have.property('EXPLORER_ACTION_PROOF_RATE_LIMIT_RPM') + expect(config).to.not.have.property('EXPLORER_VALIDATOR_SET_PROOF_RATE_LIMIT_RPM') + expect(config).to.not.have.property('EXPLORER_VM_QUERY_RATE_LIMIT_RPM') + }) + it('returns EXPLORER_API_PORT_HTTP as 8080', async function () { const cs = makeServiceWithConfig('') const config = await cs.getDefaultConfig(EXPLORER_MODULE_NAME, null, null) @@ -1051,16 +1449,31 @@ describe('ConfigService', function () { const config = await cs.getDefaultConfig(HUB_MODULE_NAME, null, null) expect(config['HUB_NETWORK']).to.be.undefined }) + + // The guard on the guard: the test above only describes a standalone + // install while ValidatorService is stubbed out. Unstubbed it reads the + // real config/validator/ through its own fs binding, so on any box that + // has run `validator init` the suite both fails here and pulls that + // machine's live signing key into a fixture. Assert the validator env is + // absent, which is the shape only an isolated read can produce. + it('reads no validator identity off the host filesystem', async function () { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(HUB_MODULE_NAME, null, null) + expect(config['SIGNING_PRIVKEY_HEX']).to.be.undefined + expect(config['P2P_VALIDATOR_ADDR']).to.be.undefined + expect(config['HUB_CAPABILITY_CONFIG']).to.be.undefined + }) }) - // The four PRICE batch knobs: non-consensus, so a passthrough + // The five PRICE batch knobs: non-consensus, so a passthrough // omission just leaves the hub on its own default rather than drifting // a federation, but an operator install still needs them to reach the - // container to tune window/grace/timeout/buffer at all. + // container to tune window/grace/timeout/buffer/landing-reserve at all. describe('ORACLE_BATCH_* passthrough', function () { const ORACLE_BATCH_VARS = [ 'ORACLE_BATCH_WINDOW_ROUNDS', 'ORACLE_BATCH_GRACE_MS', - 'ORACLE_BATCH_SIGN_TIMEOUT_MS', 'ORACLE_BATCH_BUFFER_MAX_ROUNDS' + 'ORACLE_BATCH_SIGN_TIMEOUT_MS', 'ORACLE_BATCH_BUFFER_MAX_ROUNDS', + 'ORACLE_BATCH_LANDING_RESERVE_MS' ] let saved beforeEach(function () { @@ -1074,25 +1487,71 @@ describe('ConfigService', function () { } }) - it('injects all four ORACLE_BATCH_* knobs from host env into the hub config', async function () { - process.env.ORACLE_BATCH_WINDOW_ROUNDS = '6' + it('injects all five ORACLE_BATCH_* knobs from host env into the hub config', async function () { + process.env.ORACLE_BATCH_WINDOW_ROUNDS = '2' process.env.ORACLE_BATCH_GRACE_MS = '300000' process.env.ORACLE_BATCH_SIGN_TIMEOUT_MS = '60000' process.env.ORACLE_BATCH_BUFFER_MAX_ROUNDS = '4032' + process.env.ORACLE_BATCH_LANDING_RESERVE_MS = '300000' const cs = makeServiceWithConfig('') const config = await cs.getDefaultConfig(HUB_MODULE_NAME, null, null) - expect(config['ORACLE_BATCH_WINDOW_ROUNDS']).to.equal('6') + expect(config['ORACLE_BATCH_WINDOW_ROUNDS']).to.equal('2') expect(config['ORACLE_BATCH_GRACE_MS']).to.equal('300000') expect(config['ORACLE_BATCH_SIGN_TIMEOUT_MS']).to.equal('60000') expect(config['ORACLE_BATCH_BUFFER_MAX_ROUNDS']).to.equal('4032') + expect(config['ORACLE_BATCH_LANDING_RESERVE_MS']).to.equal('300000') }) - it('leaves all four ORACLE_BATCH_* knobs unset when host env is absent (hub default unchanged)', async function () { + it('leaves all five ORACLE_BATCH_* knobs unset when host env is absent (hub default unchanged)', async function () { const cs = makeServiceWithConfig('') const config = await cs.getDefaultConfig(HUB_MODULE_NAME, null, null) for (const k of ORACLE_BATCH_VARS) expect(config[k]).to.be.undefined }) }) + + // The hub-side regtest-only override seams. The gate that keeps + // them inert off regtest lives at the point of consumption (attest_response_timing.js, + // and the not-yet-built AttestationBatchPublisher on the same pattern), so this + // suite only pins that the passthrough itself reaches the container config. + describe('ATTEST response mirror regtest-only override passthrough', function () { + const ATTEST_OVERRIDE_VARS = [ + 'ATTEST_RESPONSE_FORWARD_S_OVERRIDE', 'ATTEST_BATCH_WINDOW_S_OVERRIDE' + ] + let saved + beforeEach(function () { + saved = {} + for (const k of ATTEST_OVERRIDE_VARS) { saved[k] = process.env[k]; delete process.env[k] } + }) + afterEach(function () { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + }) + + it('injects both ATTEST override knobs from host env into the hub config', async function () { + process.env.ATTEST_RESPONSE_FORWARD_S_OVERRIDE = '2' + process.env.ATTEST_BATCH_WINDOW_S_OVERRIDE = '30' + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(HUB_MODULE_NAME, null, null) + expect(config['ATTEST_RESPONSE_FORWARD_S_OVERRIDE']).to.equal('2') + expect(config['ATTEST_BATCH_WINDOW_S_OVERRIDE']).to.equal('30') + }) + + it('injects only the one override set, leaving the other unset', async function () { + process.env.ATTEST_RESPONSE_FORWARD_S_OVERRIDE = '2' + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(HUB_MODULE_NAME, null, null) + expect(config['ATTEST_RESPONSE_FORWARD_S_OVERRIDE']).to.equal('2') + expect(config).to.not.have.property('ATTEST_BATCH_WINDOW_S_OVERRIDE') + }) + + it('leaves both ATTEST override knobs unset when host env is absent', async function () { + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig(HUB_MODULE_NAME, null, null) + for (const k of ATTEST_OVERRIDE_VARS) expect(config[k]).to.be.undefined + }) + }) }) }) @@ -1195,6 +1654,45 @@ describe('ConfigService', function () { expect(result.generated).to.be.true expect(realFs.existsSync(path.join(nested, 'hub.local'))).to.be.true }) + + // The non-minting read. A key APPEARING on a keyless host 401s every consumer that + // carries none, so callers that only need to report where the credential lives must + // have a way to ask that cannot create one. + describe('readHubApiKey()', function () { + + it('reports absence and writes NOTHING on a keyless host', async function () { + const cs = serviceWithConfigDir(dir) + const result = await cs.readHubApiKey() + expect(result.present).to.be.false + expect(result.path).to.equal(sidecarPath()) + expect(realFs.existsSync(sidecarPath())).to.be.false + }) + + it('leaves a sidecar that holds other credentials byte-identical', async function () { + realFs.writeFileSync(sidecarPath(), 'HUB_DB_PASS=db-fixture-value\n', { mode: 0o600 }) + const before = sidecarDigest() + const cs = serviceWithConfigDir(dir) + expect((await cs.readHubApiKey()).present).to.be.false + expect(sidecarDigest()).to.equal(before) + }) + + it('reports a present key without rotating it', async function () { + const cs = serviceWithConfigDir(dir) + await cs.ensureHubApiKey() + const before = sidecarDigest() + const result = await cs.readHubApiKey() + expect(result.present).to.be.true + expect(sidecarDigest()).to.equal(before) + }) + + it('never returns the key itself, only whether there is one', async function () { + const cs = serviceWithConfigDir(dir) + await cs.ensureHubApiKey() + const result = await cs.readHubApiKey() + expect(Object.keys(result).sort()).to.deep.equal(['path', 'present']) + expect(JSON.stringify(result)).to.not.match(/[0-9a-f]{64}/) + }) + }) }) describe('filterCommandParameters()', function () { diff --git a/test/unit/DatabaseService.test.js b/test/unit/DatabaseService.test.js index a74b1b8..0bee795 100644 --- a/test/unit/DatabaseService.test.js +++ b/test/unit/DatabaseService.test.js @@ -125,7 +125,9 @@ function makeStubs(overrides = {}) { // configValues merges into the getDefaultConfig() result, for tests that need a // key the shared default does not carry (e.g. HUB_DB_NAME). -function loadDatabaseService(stubs, constants = {}, configValues = {}) { +// configServiceOverrides replaces individual ConfigService exports (getModuleDatabaseName +// for the identifier-allowlist cases), applied last so it wins over the defaults below. +function loadDatabaseService(stubs, constants = {}, configValues = {}, configServiceOverrides = {}) { const defaultConstants = { DB_MODULE_NAME: 'database', HUB_MODULE_NAME: 'xchain-hub', @@ -139,6 +141,12 @@ function loadDatabaseService(stubs, constants = {}, configValues = {}) { EXTERNAL_DB_HOST: '127.0.0.1', EXTERNAL_DB_PORT: 3306, EXTERNAL_DB_ROOT_USER: 'root', + // The DB container's own --health-start-period, shared with the hub and + // explorer healthcheck descriptors whose probes SELECT 1 against it. Read + // from the real module rather than restated here: this stub is noCallThru, + // so a name missing from it reaches buildDatabaseModule as undefined and + // lands in the docker run args as an undefined element. + DEPENDENCY_HEALTH_START_PERIOD: require('../../src/config/constants').DEPENDENCY_HEALTH_START_PERIOD, ...constants } @@ -178,7 +186,8 @@ function loadDatabaseService(stubs, constants = {}, configValues = {}) { getDockerContainerImageName: (mod) => 'xchain-node-' + mod, getDockerNetwork: (coin, net) => 'xchain-node' + (coin ? '-' + coin : '') + (net ? '-' + net : ''), getModuleDatabaseName: (mod, coin, net) => 'XChain_BTC_Mainnet_Decoder', - validatePort: require('../../src/services/ConfigService').validatePort + validatePort: require('../../src/services/ConfigService').validatePort, + ...configServiceOverrides }, './DockerService': { getStatusFromContainer: stubs.getStatusFromContainer, @@ -548,6 +557,15 @@ describe('DatabaseService', function () { expect(String(runArgs[cmdIdx + 1])).to.include('healthcheck.sh') expect(runArgs).to.include('--health-interval') expect(runArgs).to.include('--health-start-period') + // The DB side of the cross-file window invariant: the hub and explorer + // descriptors take the same constant because their probes SELECT 1 against + // THIS container. Pin the emitted value, not just the flag, because no + // other guard reads this arg and a literal put back here would drift alone. + const spIdx = runArgs.indexOf('--health-start-period') + 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/constants').DEPENDENCY_HEALTH_START_PERIOD) }) // The probe is visibility ONLY. AutohealService restarts a container only @@ -1127,6 +1145,59 @@ describe('DatabaseService', function () { } }) + // The fall-through is right, the silence is not: an operator who set the + // variable believes it IS the credential in force, so a mid-rotation + // divergence has to be named where it happens (uuid:aa6c2267). + it('warns, without printing a value, when the env override does not authenticate', async function () { + const saved = process.env.XCHAIN_NODE_DB_ROOT_PASSWORD + process.env.XCHAIN_NODE_DB_ROOT_PASSWORD = 'stale-env-pass' + const warned = [] + const warnStub = sinon.stub(console, 'warn').callsFake((...args) => warned.push(args.join(' '))) + try { + const stubs = makeStubs() + stubs.getDbRootPassword.returns(null) + stubs.execFileAsync + .onCall(0).resolves({ stdout: VALID_CONTAINER_ID + '\n' }) + .onCall(1).rejects(new Error('Access denied for user root')) + .onCall(2).resolves({ stdout: 'container-root-pass\n' }) + .onCall(3).resolves({ stdout: 'mysqld is alive\n' }) + const ds = loadDatabaseService(stubs) + const result = await ds.askMariadbRootPassword('bitcoin', 'mainnet') + expect(result).to.equal('container-root-pass') + const output = warned.join('\n') + expect(output).to.include('XCHAIN_NODE_DB_ROOT_PASSWORD') + expect(output).to.include('MYSQL_ROOT_PASSWORD') + expect(output).to.not.include('stale-env-pass') + expect(output).to.not.include('container-root-pass') + } finally { + warnStub.restore() + if (saved === undefined) delete process.env.XCHAIN_NODE_DB_ROOT_PASSWORD + else process.env.XCHAIN_NODE_DB_ROOT_PASSWORD = saved + } + }) + + it('stays silent when the env override authenticates', async function () { + const saved = process.env.XCHAIN_NODE_DB_ROOT_PASSWORD + process.env.XCHAIN_NODE_DB_ROOT_PASSWORD = 'env-root-pass' + const warned = [] + const warnStub = sinon.stub(console, 'warn').callsFake((...args) => warned.push(args.join(' '))) + try { + const stubs = makeStubs() + stubs.getDbRootPassword.returns(null) + stubs.execFileAsync + .onCall(0).resolves({ stdout: VALID_CONTAINER_ID + '\n' }) + .onCall(1).resolves({ stdout: 'mysqld is alive\n' }) + const ds = loadDatabaseService(stubs) + const result = await ds.askMariadbRootPassword('bitcoin', 'mainnet') + expect(result).to.equal('env-root-pass') + expect(warned.join('\n')).to.not.include('XCHAIN_NODE_DB_ROOT_PASSWORD') + } finally { + warnStub.restore() + if (saved === undefined) delete process.env.XCHAIN_NODE_DB_ROOT_PASSWORD + else process.env.XCHAIN_NODE_DB_ROOT_PASSWORD = saved + } + }) + it('reads root password from running container printenv', async function () { const stubs = makeStubs() stubs.getDbRootPassword.returns(null) @@ -1731,6 +1802,117 @@ describe('DatabaseService', function () { expect(String(err.message)).to.contain('MariaDB container not found') expect(executed.filter(c => c && c.includes('DROP DATABASE'))).to.have.length(0) }) + + // A database name reaches SQL as text, so this destructive site gates it + // on the same allowlist every sibling DDL site applies (uuid:0257cadf). + // The whole set is asserted before the first DROP, so a bad name on the + // SECOND module cannot fire with the first database already gone. + it('refuses the docker-mode reset when a derived database name is not a safe identifier', async function () { + const stubs = makeStubs() + const executed = [] + stubs.spawn.callsFake(fakeSpawn((sql) => { + executed.push(sql) + return { stdout: '' } + })) + // Empty configured names so the DERIVED name is the one under test; + // the configured name has its own case below. + const ds = loadDatabaseService(stubs, {}, { DECODER_DB_NAME: '', INDEXER_DB_NAME: '' }, { + getModuleDatabaseName: () => 'XChain_BTC_Mainnet_Decoder; DROP DATABASE mysql' + }) + let err = null + try { + await ds.resetDatabases('bitcoin', 'mainnet') + } catch (e) { err = e } + expect(err).to.not.equal(null) + expect(String(err.message)).to.contain('Unsafe MariaDB database name') + expect(executed.filter(c => c && c.includes('DROP DATABASE'))).to.have.length(0) + }) + + it('refuses the external-DB reset on the second module before the first is dropped', async function () { + const stubs = makeStubs() + const executed = [] + stubs.spawn.callsFake(fakeSpawn((sql) => { + executed.push(sql) + return { stdout: '' } + })) + const queried = [] + stubs.mariadb.createConnection = sinon.stub().resolves({ + query: async (sql) => { queried.push(sql); return [] }, + end: async () => {} + }) + let call = 0 + const ds = loadDatabaseService(stubs, { EXTERNAL_DB: true }, { DECODER_DB_NAME: '', INDEXER_DB_NAME: '' }, { + // First module resolves clean, second does not: the pre-loop + // assertion is what keeps the first DROP from having run. + getModuleDatabaseName: () => (++call === 1 ? 'XChain_BTC_Mainnet_Decoder' : 'bad-name') + }) + let err = null + try { + await ds.resetDatabases('bitcoin', 'mainnet') + } catch (e) { err = e } + expect(err).to.not.equal(null) + expect(String(err.message)).to.contain('Unsafe MariaDB database name') + expect(queried.filter(q => String(q).includes('DROP DATABASE'))).to.have.length(0) + expect(executed.filter(c => c && c.includes('DROP DATABASE'))).to.have.length(0) + }) + + // Provisioning grants on cfg["*_DB_NAME"], which the operator can override in + // the coin-network config file. A reset that dropped the DERIVED default name + // instead left the live database intact and wiped whatever else on that server + // owned the default name (uuid:fd543c4a). + it('drops the CONFIGURED database names, not the derived defaults', async function () { + const stubs = makeStubs() + const executed = [] + stubs.spawn.callsFake(fakeSpawn((sql) => { + executed.push(sql) + return { stdout: '' } + })) + const ds = loadDatabaseService(stubs, {}, + { DECODER_DB_NAME: 'CustomDecoder', INDEXER_DB_NAME: 'CustomIndexer' }, + { getModuleDatabaseName: () => 'XChain_BTC_Mainnet_Derived' }) + await ds.resetDatabases('bitcoin', 'mainnet') + const drops = executed.filter(c => c && c.includes('DROP DATABASE')).join(' | ') + expect(drops).to.contain('CustomDecoder') + expect(drops).to.contain('CustomIndexer') + expect(drops).to.not.contain('XChain_BTC_Mainnet_Derived') + }) + + // The configured name is the one an operator types, so it is the untrusted + // one; the allowlist must cover it and still fire before the first DROP. + it('refuses the reset when a CONFIGURED database name is not a safe identifier', async function () { + const stubs = makeStubs() + const executed = [] + stubs.spawn.callsFake(fakeSpawn((sql) => { + executed.push(sql) + return { stdout: '' } + })) + const ds = loadDatabaseService(stubs, {}, + { DECODER_DB_NAME: 'XChain_BTC_Mainnet_Decoder', INDEXER_DB_NAME: 'Custom; DROP DATABASE mysql' }, + { getModuleDatabaseName: () => 'XChain_BTC_Mainnet_Decoder' }) + let err = null + try { + await ds.resetDatabases('bitcoin', 'mainnet') + } catch (e) { err = e } + expect(err).to.not.equal(null) + expect(String(err.message)).to.contain('Unsafe MariaDB database name') + expect(executed.filter(c => c && c.includes('DROP DATABASE'))).to.have.length(0) + }) + + // A config that carries no name at all (an older install, or a module outside + // the two DB modules) still resets the derived default rather than nothing. + it('falls back to the derived name when config carries no database name', async function () { + const stubs = makeStubs() + const executed = [] + stubs.spawn.callsFake(fakeSpawn((sql) => { + executed.push(sql) + return { stdout: '' } + })) + const ds = loadDatabaseService(stubs, {}, { DECODER_DB_NAME: '', INDEXER_DB_NAME: '' }, + { getModuleDatabaseName: () => 'XChain_BTC_Mainnet_Derived' }) + await ds.resetDatabases('bitcoin', 'mainnet') + const drops = executed.filter(c => c && c.includes('DROP DATABASE')).join(' | ') + expect(drops).to.contain('XChain_BTC_Mainnet_Derived') + }) }) // A wiped indexer DB restarts push_generations at 0, which the hub's price diff --git a/test/unit/DbCredentialDrift.test.js b/test/unit/DbCredentialDrift.test.js index 41d1252..5cf9612 100644 --- a/test/unit/DbCredentialDrift.test.js +++ b/test/unit/DbCredentialDrift.test.js @@ -16,11 +16,16 @@ const proxyquire = require('proxyquire').noCallThru() const DECODER = 'xchain-decoder' const INDEXER = 'xchain-indexer' +const HUB = 'xchain-hub' + +// The shared hub account every co-located install provisions under its own prefix. +const HUB_USER = 'xchain_hub' function load() { return proxyquire('../../src/services/DbCredentialDrift', { '../config/constants': { - XChainService: { XCHAIN_DECODER: DECODER, XCHAIN_INDEXER: INDEXER } + XChainService: { XCHAIN_DECODER: DECODER, XCHAIN_INDEXER: INDEXER }, + HUB_MODULE_NAME: HUB }, './ConfigService': { getDockerContainerImageName: (mod, coin, net) => `xchain-node-${coin}-${net}-${mod}` @@ -38,6 +43,17 @@ function inspectStub(envsByName) { }) } +// A docker daemon that answers both `ps` (the name sweep) and `inspect` (the env read). +function dockerStub(envsByName) { + return sinon.stub().callsFake(async (cmd, args) => { + if (args[0] === 'ps') return { stdout: Object.keys(envsByName).join('\n') + '\n' } + const name = args[args.length - 1] + const env = envsByName[name] + if (!env) throw new Error('No such container: ' + name) + return { stdout: JSON.stringify(Object.keys(env).map(k => `${k}=${env[k]}`)) + '\n' } + }) +} + describe('DbCredentialDrift', () => { describe('findDbCredentialDrift', () => { @@ -266,6 +282,129 @@ describe('DbCredentialDrift', () => { }) }) + describe('findHubDbCredentialDrift', () => { + + it('flags a sibling install whose hub carries a different shared password', () => { + const { findHubDbCredentialDrift } = load() + const drift = findHubDbCredentialDrift( + { user: HUB_USER, pass: 'hpass' }, + [ + { name: 'xchain-node-xchain-hub', env: { HUB_DB_USER: HUB_USER, HUB_DB_PASS: 'hpass' } }, + { name: 'scratch-clone-xchain-hub', env: { HUB_DB_USER: HUB_USER, HUB_DB_PASS: 'other' } } + ] + ) + expect(drift).to.have.length(1) + expect(drift[0].container).to.equal('scratch-clone-xchain-hub') + expect(drift[0].account).to.equal(HUB_USER) + }) + + // The indexer points HUB_DB_USER at its OWN account, so its differing + // HUB_DB_PASS is not this account's and must not raise a refusal. + it('does not flag an indexer whose hub connection uses its own account', () => { + const { findHubDbCredentialDrift } = load() + const drift = findHubDbCredentialDrift( + { user: HUB_USER, pass: 'hpass' }, + [{ + name: 'xchain-node-dogecoin-regtest-xchain-indexer', + env: { HUB_DB_USER: 'xchain_indexer_dogecoin_regtest', HUB_DB_PASS: 'ipass' } + }] + ) + expect(drift).to.deep.equal([]) + }) + + it('treats an absent or empty value on either side as no claim', () => { + const { findHubDbCredentialDrift } = load() + const containers = [ + { name: 'no-pass', env: { HUB_DB_USER: HUB_USER } }, + { name: 'empty', env: { HUB_DB_USER: HUB_USER, HUB_DB_PASS: '' } }, + { name: 'no-user', env: { HUB_DB_PASS: 'other' } } + ] + expect(findHubDbCredentialDrift({ user: HUB_USER, pass: 'hpass' }, containers)).to.deep.equal([]) + expect(findHubDbCredentialDrift({ user: HUB_USER }, [ + { name: 'live', env: { HUB_DB_USER: HUB_USER, HUB_DB_PASS: 'other' } } + ])).to.deep.equal([]) + expect(findHubDbCredentialDrift(null, containers)).to.deep.equal([]) + }) + }) + + describe('assertNoHubDbCredentialDrift', () => { + + const HUB_CONTAINERS = { + 'xchain-node-xchain-hub': { HUB_DB_USER: HUB_USER, HUB_DB_PASS: 'hpass' }, + 'scratch-clone-xchain-hub': { HUB_DB_USER: HUB_USER, HUB_DB_PASS: 'sibling-secret' }, + 'xchain-node-dogecoin-regtest-xchain-indexer': { HUB_DB_USER: 'xchain_indexer', HUB_DB_PASS: 'ipass' } + } + + it('resolves when every holder of the shared account agrees', async () => { + const { assertNoHubDbCredentialDrift } = load() + const drift = await assertNoHubDbCredentialDrift( + { user: HUB_USER, pass: 'hpass' }, + { + execFileAsync: dockerStub({ + 'xchain-node-xchain-hub': { HUB_DB_USER: HUB_USER, HUB_DB_PASS: 'hpass' } + }), + env: {} + }) + expect(drift).to.deep.equal([]) + }) + + // The sibling hub runs under another NODE_PREFIX, so a name-derived lookup + // would never see it; the daemon sweep is what makes it visible. + it('throws a tagged error naming a sibling hub under a different prefix', async () => { + const { assertNoHubDbCredentialDrift, DRIFT_ERROR_CODE } = load() + let thrown = null + try { + await assertNoHubDbCredentialDrift( + { user: HUB_USER, pass: 'hpass' }, + { execFileAsync: dockerStub(HUB_CONTAINERS), env: {} }) + } catch (err) { thrown = err } + expect(thrown).to.be.an('error') + expect(thrown.code).to.equal(DRIFT_ERROR_CODE) + expect(thrown.drift).to.have.length(1) + expect(thrown.message).to.contain('scratch-clone-xchain-hub') + expect(thrown.message).to.contain(`recreate ${HUB}`) + // The refusal reaches logs and bug reports, so it may never carry a value. + expect(thrown.message).to.not.contain('sibling-secret') + expect(thrown.message).to.not.contain('hpass') + }) + + it('ignores a container the caller is about to replace, and never inspects it', async () => { + const { assertNoHubDbCredentialDrift } = load() + const docker = dockerStub({ + 'xchain-node-xchain-hub': { HUB_DB_USER: HUB_USER, HUB_DB_PASS: 'stale' } + }) + const drift = await assertNoHubDbCredentialDrift( + { user: HUB_USER, pass: 'hpass' }, + { execFileAsync: docker, env: {}, excludeContainers: ['xchain-node-xchain-hub'] }) + expect(drift).to.deep.equal([]) + const inspected = docker.getCalls() + .filter(c => c.args[1][0] === 'inspect') + .map(c => c.args[1][c.args[1].length - 1]) + expect(inspected).to.not.contain('xchain-node-xchain-hub') + }) + + it('treats an unreadable docker daemon as no drift', async () => { + const { assertNoHubDbCredentialDrift } = load() + const drift = await assertNoHubDbCredentialDrift( + { user: HUB_USER, pass: 'hpass' }, + { execFileAsync: sinon.stub().rejects(new Error('Cannot connect to the Docker daemon')), env: {} }) + expect(drift).to.deep.equal([]) + }) + + it('proceeds with a warning when the override env is set', async () => { + const { assertNoHubDbCredentialDrift, DRIFT_OVERRIDE_ENV } = load() + const log = sinon.stub(console, 'log') + try { + const drift = await assertNoHubDbCredentialDrift( + { user: HUB_USER, pass: 'hpass' }, + { execFileAsync: dockerStub(HUB_CONTAINERS), env: { [DRIFT_OVERRIDE_ENV]: '1' } }) + expect(drift).to.have.length(1) + } finally { + log.restore() + } + }) + }) + describe('isDbCredentialDriftError', () => { it('separates the drift refusal from an unrelated failure', () => { diff --git a/test/unit/EncoderMaintenanceWindow.test.js b/test/unit/EncoderMaintenanceWindow.test.js new file mode 100644 index 0000000..8ea94ea --- /dev/null +++ b/test/unit/EncoderMaintenanceWindow.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 +// +// A bootstrap publish stops the UTXO tracker, the encoder reports +// that truthfully, and the public board had only one word for the result: +// Degraded (2026-08-01: 3h36m on mainnet BTC). This module hands the encoder +// the operator's declaration that the outage is planned, so the board can +// say Maintenance instead. +// +// Two properties carry the design: +// 1. The sentinel always carries an EXPIRY, so a publish that dies without +// cleaning up stops excusing the outage at its own end time. +// 2. Nothing here may throw. A cosmetic status label must never fail a +// publish or leave a tracker down. + +const sinon = require('sinon') +const { expect } = require('chai') +const proxyquire = require('proxyquire').noCallThru() + +const { XChainService } = require('../../src/config/constants') + +const COIN = 'bitcoin' +const NETWORK = 'mainnet' +const ENCODER_CONTAINER = 'e'.repeat(64) + +function load({ containerId = ENCODER_CONTAINER, writeErr = null, execErr = null, getContainerErr = null } = {}) { + const stubs = { + getModuleContainer: getContainerErr + ? sinon.stub().rejects(getContainerErr) + : sinon.stub().resolves(containerId), + stringToDockerContainerFile: writeErr + ? sinon.stub().rejects(writeErr) + : sinon.stub().resolves(true), + execContainer: execErr + ? sinon.stub().rejects(execErr) + : sinon.stub().resolves('') + } + const mod = proxyquire('../../src/services/EncoderMaintenanceWindow', { + '../config/constants': { XChainService }, + '../state': { db: { getModuleContainer: stubs.getModuleContainer } }, + './DockerService': { + stringToDockerContainerFile: stubs.stringToDockerContainerFile, + execContainer: stubs.execContainer + } + }) + return { mod, stubs } +} + +describe('EncoderMaintenanceWindow', function () { + + describe('declareEncoderMaintenance()', function () { + + it('writes a bounded, currently-open sentinel into the encoder container', async function () { + const { mod, stubs } = load() + const before = Date.now() + expect(await mod.declareEncoderMaintenance(COIN, NETWORK, { reason: 'utxo-tracker bootstrap publish' })).to.be.true + + expect(stubs.getModuleContainer.calledWith(XChainService.XCHAIN_ENCODER, COIN, NETWORK)).to.be.true + const [containerId, body, filePath] = stubs.stringToDockerContainerFile.getCall(0).args + expect(containerId).to.equal(ENCODER_CONTAINER) + expect(filePath).to.equal(mod.SENTINEL_PATH) + + const doc = JSON.parse(body) + expect(doc.reason).to.equal('utxo-tracker bootstrap publish') + // The expiry is the whole safety property: a crashed publish must + // stop excusing the outage on its own. + const until = Date.parse(doc.until) + const since = Date.parse(doc.since) + expect(since).to.be.at.least(before) + expect(until).to.be.greaterThan(since) + expect(until - since).to.equal(mod.DEFAULT_WINDOW_MINUTES * 60 * 1000) + }) + + it('honours an explicit window length', async function () { + const { mod, stubs } = load() + await mod.declareEncoderMaintenance(COIN, NETWORK, { reason: 'reindex', minutes: 30 }) + const doc = JSON.parse(stubs.stringToDockerContainerFile.getCall(0).args[1]) + expect(Date.parse(doc.until) - Date.parse(doc.since)).to.equal(30 * 60 * 1000) + }) + + it('falls back to a generic reason rather than writing an empty one', async function () { + const { mod, stubs } = load() + await mod.declareEncoderMaintenance(COIN, NETWORK) + const doc = JSON.parse(stubs.stringToDockerContainerFile.getCall(0).args[1]) + expect(doc.reason).to.be.a('string').and.not.be.empty + }) + + // A host that runs a tracker but no encoder is a normal deployment, not + // an error, and must not cost a log line on every publish. + it('reports false and writes nothing when there is no encoder here', async function () { + const { mod, stubs } = load({ containerId: null }) + expect(await mod.declareEncoderMaintenance(COIN, NETWORK)).to.be.false + expect(stubs.stringToDockerContainerFile.called).to.be.false + }) + + it('never throws when the container lookup fails', async function () { + const { mod, stubs } = load({ getContainerErr: new Error('store unavailable') }) + expect(await mod.declareEncoderMaintenance(COIN, NETWORK)).to.be.false + expect(stubs.stringToDockerContainerFile.called).to.be.false + }) + + it('never throws when the write fails', async function () { + const { mod } = load({ writeErr: new Error('docker exec: no such container') }) + expect(await mod.declareEncoderMaintenance(COIN, NETWORK)).to.be.false + }) + }) + + describe('clearEncoderMaintenance()', function () { + + it('removes the sentinel from the encoder container', async function () { + const { mod, stubs } = load() + expect(await mod.clearEncoderMaintenance(COIN, NETWORK)).to.be.true + const [containerId, argv] = stubs.execContainer.getCall(0).args + expect(containerId).to.equal(ENCODER_CONTAINER) + // -f so a sentinel already gone (encoder restarted mid-run) is not + // reported as a failure. + expect(argv).to.deep.equal(['rm', '-f', mod.SENTINEL_PATH]) + }) + + it('never throws when the removal fails', async function () { + const { mod } = load({ execErr: new Error('container is restarting') }) + expect(await mod.clearEncoderMaintenance(COIN, NETWORK)).to.be.false + }) + + it('is a no-op with no encoder on this host', async function () { + const { mod, stubs } = load({ containerId: null }) + expect(await mod.clearEncoderMaintenance(COIN, NETWORK)).to.be.false + expect(stubs.execContainer.called).to.be.false + }) + }) + + describe('sentinel path', function () { + // The encoder resolves the same default (xchain-encoder + // src/maintenanceWindow.js DEFAULT_SENTINEL). A drift here means the + // publish writes a window nothing ever reads. + it('defaults to the path the encoder reads', function () { + const { mod } = load() + expect(mod.SENTINEL_PATH).to.equal('/tmp/xchain-encoder-maintenance.json') + }) + }) +}) diff --git a/test/unit/GitHubDownloader.test.js b/test/unit/GitHubDownloader.test.js index e5c28b8..1a6aa77 100644 --- a/test/unit/GitHubDownloader.test.js +++ b/test/unit/GitHubDownloader.test.js @@ -36,6 +36,7 @@ function loadDownloader(opts = {}) { createWriteStream: sinon.stub(), mkdirSync: sinon.stub(), rmSync: sinon.stub(), + renameSync: sinon.stub(), readdirSync: sinon.stub().returns([]), statSync: sinon.stub().returns({ isFile: () => true, isDirectory: () => false }), readFileSync: sinon.stub().callsFake((p, enc) => { @@ -518,7 +519,7 @@ describe('GitHubDownloader', function () { } }) - it('cleans up output directory when download fails', async function () { + it('cleans up the staging directory when download fails and leaves the previous tree alone', async function () { const axiosStub = makeAxiosStub() // getReleaseByTag returns a release, but downloadReleaseAsset will fail (no matching asset) axiosStub.get.resolves({ @@ -531,7 +532,7 @@ describe('GitHubDownloader', function () { existsSync: sinon.stub().callsFake((p) => { // hashes file exists if (p.endsWith('hashes.json')) return true - // output path exists (to trigger cleanup) + // staging and output paths exist (to trigger cleanup) return true }), readFileSync: sinon.stub().callsFake((p) => { @@ -540,6 +541,7 @@ describe('GitHubDownloader', function () { }), writeFileSync: sinon.stub(), rmSync: sinon.stub(), + renameSync: sinon.stub(), mkdirSync: sinon.stub(), createWriteStream: sinon.stub(), statSync: sinon.stub().returns({ isFile: () => true, isDirectory: () => false }), @@ -551,10 +553,53 @@ describe('GitHubDownloader', function () { await dl.downloadRepoVersion('owner', 'repo', 'v1.0.0', { verifyHash: false }) expect.fail() } catch (e) { - expect(fsStub.rmSync.called).to.be.true + const removed = fsStub.rmSync.getCalls().map(c => c.args[0]) + expect(removed).to.include(path.join('./downloads', 'repo') + '.staging') + // The daemon tree a running container was built from survives a failed update. + expect(removed).to.not.include(path.join('./downloads', 'repo')) + expect(fsStub.renameSync.called).to.be.false } }) + it('extracts into a staging directory and swaps it over the previous version tree', async function () { + // Regression: extracting straight into the live tree left the new + // release nested beside the previous bin/ and share/, so the image + // was built from the OLD binaries under a NEW version file. + const axiosStub = makeAxiosStub() + axiosStub.get.resolves({ data: { tag_name: 'v1.0.0', assets: [] } }) + const fsStub = { + existsSync: sinon.stub().callsFake((p) => { + if (p.endsWith('hashes.json')) return true + return !p.endsWith('.staging') // previous tree present, no stale staging dir + }), + readFileSync: sinon.stub().callsFake((p) => { + if (p.endsWith('hashes.json')) return JSON.stringify(validHashesData) + return Buffer.from('data') + }), + writeFileSync: sinon.stub(), + rmSync: sinon.stub(), + renameSync: sinon.stub(), + mkdirSync: sinon.stub(), + createWriteStream: sinon.stub(), + statSync: sinon.stub().returns({ isFile: () => true, isDirectory: () => false }), + readdirSync: sinon.stub().returns([]) + } + const { GitHubDownloader } = loadDownloader({ fs: fsStub, axios: axiosStub }) + const dl = new GitHubDownloader('/test/hashes.json') + dl.downloadReleaseAsset = sinon.stub().resolves() + const live = path.join('./downloads', 'repo') + const staging = live + '.staging' + const result = await dl.downloadRepoVersion('owner', 'repo', 'v1.0.0', { verifyHash: false }) + + expect(dl.downloadReleaseAsset.firstCall.args[1]).to.equal(staging) + expect(fsStub.writeFileSync.firstCall.args[0]).to.equal(path.join(staging, '__VERSION__.txt')) + expect(fsStub.rmSync.calledWith(live)).to.be.true + expect(fsStub.renameSync.calledOnceWith(staging, live)).to.be.true + // Order: the old tree goes only after the new one is fully staged. + expect(dl.downloadReleaseAsset.calledBefore(fsStub.rmSync)).to.be.true + expect(result).to.equal(live) + }) + it('writes version file on successful download', async function () { // Use a custom downloader where downloadReleaseAsset is stubbed const axiosStub = makeAxiosStub() @@ -572,6 +617,7 @@ describe('GitHubDownloader', function () { }), writeFileSync: sinon.stub(), rmSync: sinon.stub(), + renameSync: sinon.stub(), mkdirSync: sinon.stub(), createWriteStream: sinon.stub(), statSync: sinon.stub().returns({ isFile: () => true, isDirectory: () => false }), diff --git a/test/unit/HubConsensusEnvGuard.test.js b/test/unit/HubConsensusEnvGuard.test.js index 6bd12aa..6bcab3e 100644 --- a/test/unit/HubConsensusEnvGuard.test.js +++ b/test/unit/HubConsensusEnvGuard.test.js @@ -17,6 +17,9 @@ const { DRIFT_OVERRIDE_ENV, DRIFT_ERROR_CODE, CONSENSUS_ENV_KEYS, + resolveHubNetwork, + isConsensusEnvKeyHonoredOn, + consensusEnvKeysForNetwork, describeConsensusEnvSupply, findHubConsensusEnvDrift, formatHubConsensusEnvDriftError, @@ -35,7 +38,15 @@ function missingContainerStub() { describe('HubConsensusEnvGuard', () => { - it('covers the five named consensus-shaped var groups', () => { + // The regtest-only XCHAIN/BTC derivation overrides: honored by + // XchainPriceSource.pinOffRegtest only when HUB_NETWORK is regtest, pinned to + // the constants.js values everywhere else. + const REGTEST_ONLY_KEYS = [ + 'XCHAIN_PRICE_WINDOW_BLOCKS', 'XCHAIN_PRICE_CONFIRMATION_BUFFER', + 'XCHAIN_PRICE_BOOTSTRAP_SATS', 'XCHAIN_PRICE_MIN_BTC_VOLUME' + ] + + it('covers the named consensus-shaped var groups', () => { // Pinned so a future edit to the group table cannot silently drop one of // the row's named vars without a red test. expect(CONSENSUS_ENV_KEYS).to.include.members([ @@ -48,6 +59,56 @@ describe('HubConsensusEnvGuard', () => { ]) }) + it('covers the four regtest-only XCHAIN/BTC derivation overrides too', () => { + expect(CONSENSUS_ENV_KEYS).to.include.members(REGTEST_ONLY_KEYS) + }) + + describe('network gating', () => { + + it('resolves the network from this deploy\'s own HUB_NETWORK first', () => { + expect(resolveHubNetwork({ HUB_NETWORK: 'Regtest' }, { HUB_NETWORK: 'mainnet' })).to.equal('regtest') + }) + + it('falls back to the running container when the invoking shell dropped HUB_NETWORK', () => { + // A recreate whose shell lacks HUB_NETWORK is still recreating the + // regtest hub that is running right now. + expect(resolveHubNetwork({}, { HUB_NETWORK: 'regtest' })).to.equal('regtest') + expect(resolveHubNetwork({ HUB_NETWORK: '' }, { HUB_NETWORK: 'regtest' })).to.equal('regtest') + }) + + it('resolves standalone (unset everywhere) to the empty network', () => { + expect(resolveHubNetwork({}, null)).to.equal('') + expect(resolveHubNetwork(undefined, undefined)).to.equal('') + }) + + it('honors the derivation overrides on regtest and nowhere else', () => { + for (const key of REGTEST_ONLY_KEYS) { + expect(isConsensusEnvKeyHonoredOn(key, 'regtest'), key).to.equal(true) + expect(isConsensusEnvKeyHonoredOn(key, 'testnet'), key).to.equal(false) + expect(isConsensusEnvKeyHonoredOn(key, 'mainnet'), key).to.equal(false) + // Standalone fails closed to the pin, exactly as the hub's other + // consensus-adjacent seams do. + expect(isConsensusEnvKeyHonoredOn(key, ''), key).to.equal(false) + } + }) + + it('leaves the ungated keys, including the per-operator DB source, honored on every network', () => { + for (const network of ['regtest', 'testnet', 'mainnet', '']) { + expect(isConsensusEnvKeyHonoredOn('HUB_NETWORK', network)).to.equal(true) + expect(isConsensusEnvKeyHonoredOn('ORACLE_MIN_SUBMISSIONS', network)).to.equal(true) + // Gating these would take every non-regtest hub off the pair. + expect(isConsensusEnvKeyHonoredOn('XCHAIN_PRICE_INDEXER_DB_HOST', network)).to.equal(true) + } + }) + + it('scopes the walked key list to the network', () => { + expect(consensusEnvKeysForNetwork('regtest')).to.deep.equal(CONSENSUS_ENV_KEYS) + const mainnetKeys = consensusEnvKeysForNetwork('mainnet') + for (const key of REGTEST_ONLY_KEYS) expect(mainnetKeys).to.not.include(key) + expect(mainnetKeys).to.include('XCHAIN_PRICE_INDEXER_DB_HOST') + }) + }) + describe('describeConsensusEnvSupply()', () => { it('splits supplied from defaulted', () => { @@ -68,7 +129,26 @@ describe('HubConsensusEnvGuard', () => { it('treats a null/undefined intended object as everything defaulted', () => { const { supplied, defaulted } = describeConsensusEnvSupply(undefined) expect(supplied).to.deep.equal([]) - expect(defaulted).to.deep.equal(CONSENSUS_ENV_KEYS) + // Scoped to the network, which is unset here: the regtest-only + // overrides are not "missing", they are inapplicable. + expect(defaulted).to.deep.equal(consensusEnvKeysForNetwork('')) + }) + + it('does not report a regtest-only override as missing on a mainnet deploy', () => { + const { defaulted } = describeConsensusEnvSupply({ HUB_NETWORK: 'mainnet' }) + for (const key of REGTEST_ONLY_KEYS) expect(defaulted).to.not.include(key) + }) + + it('does report a regtest-only override as missing on a regtest deploy', () => { + const { defaulted } = describeConsensusEnvSupply({ HUB_NETWORK: 'regtest' }) + expect(defaulted).to.include.members(REGTEST_ONLY_KEYS) + }) + + it('takes an explicit network, for the caller that resolved it from the live container', () => { + const { supplied } = describeConsensusEnvSupply({ XCHAIN_PRICE_BOOTSTRAP_SATS: '5000' }, 'regtest') + expect(supplied).to.deep.equal(['XCHAIN_PRICE_BOOTSTRAP_SATS']) + expect(describeConsensusEnvSupply({ XCHAIN_PRICE_BOOTSTRAP_SATS: '5000' }, 'mainnet').supplied) + .to.deep.equal([]) }) }) @@ -119,6 +199,51 @@ describe('HubConsensusEnvGuard', () => { const drift = findHubConsensusEnvDrift({}, { XCHAIN_PRICE_INDEXER_DB_PASS: 'topsecret' }) expect(drift).to.deep.equal([{ key: 'XCHAIN_PRICE_INDEXER_DB_PASS' }]) }) + + // The trap this row exists to avoid: guarding the derivation overrides + // everywhere would refuse the deploy that unsets a variable the hub has + // already been ignoring for its whole life. + it('does NOT call it drift when a mainnet deploy unsets an override the hub already ignores', () => { + const drift = findHubConsensusEnvDrift( + { HUB_NETWORK: 'mainnet' }, + { HUB_NETWORK: 'mainnet', XCHAIN_PRICE_BOOTSTRAP_SATS: '5000', XCHAIN_PRICE_WINDOW_BLOCKS: '10' } + ) + expect(drift).to.deep.equal([]) + }) + + it('does NOT call it drift when a testnet or standalone deploy unsets one either', () => { + expect(findHubConsensusEnvDrift( + { HUB_NETWORK: 'testnet' }, + { HUB_NETWORK: 'testnet', XCHAIN_PRICE_MIN_BTC_VOLUME: '0' } + )).to.deep.equal([]) + expect(findHubConsensusEnvDrift({}, { XCHAIN_PRICE_CONFIRMATION_BUFFER: '0' })).to.deep.equal([]) + }) + + it('does NOT call it drift when a mainnet deploy CHANGES an override the hub ignores', () => { + const drift = findHubConsensusEnvDrift( + { HUB_NETWORK: 'mainnet', XCHAIN_PRICE_WINDOW_BLOCKS: '99' }, + { HUB_NETWORK: 'mainnet', XCHAIN_PRICE_WINDOW_BLOCKS: '10' } + ) + expect(drift).to.deep.equal([]) + }) + + it('DOES flag a dropped override on a regtest venue, where the hub honors it', () => { + const drift = findHubConsensusEnvDrift( + { HUB_NETWORK: 'regtest' }, + { HUB_NETWORK: 'regtest', XCHAIN_PRICE_MIN_BTC_VOLUME: '0' } + ) + expect(drift).to.deep.equal([{ key: 'XCHAIN_PRICE_MIN_BTC_VOLUME' }]) + }) + + it('DOES flag a dropped override when only the RUNNING container says regtest', () => { + // The shell lost HUB_NETWORK too; that is drift in its own right, and + // the override it silently drops alongside must be named as well. + const drift = findHubConsensusEnvDrift( + {}, + { HUB_NETWORK: 'regtest', XCHAIN_PRICE_BOOTSTRAP_SATS: '5000' } + ) + expect(drift.map(d => d.key).sort()).to.deep.equal(['HUB_NETWORK', 'XCHAIN_PRICE_BOOTSTRAP_SATS']) + }) }) describe('formatHubConsensusEnvDriftError()', () => { @@ -201,6 +326,34 @@ describe('HubConsensusEnvGuard', () => { expect(isHubConsensusEnvDriftError(thrown)).to.equal(true) }) + it('lets a mainnet recreate unset a stale, already-ignored derivation override', async () => { + // End to end over the exact deploy the naive "just add the keys" + // change would have refused forever. + const drift = await assertNoHubConsensusEnvDrift( + { HUB_NETWORK: 'mainnet' }, + { + execFileAsync: inspectStub({ HUB_NETWORK: 'mainnet', XCHAIN_PRICE_BOOTSTRAP_SATS: '5000' }), + env: {} + } + ) + expect(drift).to.deep.equal([]) + }) + + it('still refuses a regtest recreate that would drop a honored derivation override', async () => { + let thrown = null + try { + await assertNoHubConsensusEnvDrift( + { HUB_NETWORK: 'regtest' }, + { + execFileAsync: inspectStub({ HUB_NETWORK: 'regtest', XCHAIN_PRICE_WINDOW_BLOCKS: '10' }), + env: {} + } + ) + } catch (err) { thrown = err } + expect(isHubConsensusEnvDriftError(thrown)).to.equal(true) + expect(thrown.drift).to.deep.equal([{ key: 'XCHAIN_PRICE_WINDOW_BLOCKS' }]) + }) + it('proceeds and logs when the override env is set', async () => { const drift = await assertNoHubConsensusEnvDrift( {}, diff --git a/test/unit/MariaDbStore.test.js b/test/unit/MariaDbStore.test.js index 62c9162..34fd1e4 100644 --- a/test/unit/MariaDbStore.test.js +++ b/test/unit/MariaDbStore.test.js @@ -444,18 +444,50 @@ describe('MariaDbStore registry scoping by NODE_PREFIX', function () { expect(statements[0]).to.match(/^CREATE TABLE IF NOT EXISTS modules \(/) }) + // A non-default prefix names its table with a readable head plus a digest of the + // RAW prefix, so the name is injective (see the MODULES_TABLE stanza). + const TABLE_RE = /\bmodules_[a-z0-9_]+_[0-9a-f]{12}\b/ + + function tableNameFrom(statements) { + const hit = statements[0].match(TABLE_RE) + expect(hit, statements[0]).to.not.equal(null) + return hit[0] + } + it('gives a second stack its own table, so neither upsert nor purge can reach the first', async function () { const statements = await statementsFor('stack-b') expect(statements.length).to.be.greaterThan(5) + const table = tableNameFrom(statements) + expect(table).to.match(/^modules_stack_b_/) // Every statement, DDL and DML alike: one missed site is a cross-stack write. for (const sql of statements) { - expect(sql, sql).to.match(/\bmodules_stack_b\b/) - expect(sql.replace(/modules_stack_b/g, ''), sql).to.not.match(/\bmodules\b/) + expect(sql, sql).to.contain(table) + expect(sql.split(table).join(''), sql).to.not.match(/\bmodules\b/) } }) it('sanitizes a prefix that is legal for docker but not for a MariaDB identifier', async function () { const statements = await statementsFor('node.1-alt') - for (const sql of statements) expect(sql, sql).to.match(/\bmodules_node_1_alt\b/) + for (const sql of statements) expect(sql, sql).to.match(/\bmodules_node_1_alt_[0-9a-f]{12}\b/) + }) + + // The sanitizer folds `-` and `.` onto `_`, and the head is truncated, so a + // head-only name put DISTINCT stacks back on ONE registry - the overwrite and + // orphan-purge failure this scoping exists to prevent (uuid:c8e46a8b). + it('never gives two distinct prefixes the same table, separator or length', async function () { + const separatorVariants = ['stack-a', 'stack.a', 'stack_a'] + const names = [] + for (const prefix of separatorVariants) names.push(tableNameFrom(await statementsFor(prefix))) + expect(new Set(names).size, names.join(', ')).to.equal(separatorVariants.length) + + // Two prefixes agreeing on a long head and differing only past the old + // 40-character truncation point. + const head = 'a'.repeat(45) + const longA = tableNameFrom(await statementsFor(head + '-one')) + const longB = tableNameFrom(await statementsFor(head + '-two')) + expect(longA).to.not.equal(longB) + + // Still legal MariaDB identifiers. + for (const name of names.concat([longA, longB])) expect(name.length).to.be.at.most(64) }) }) diff --git a/test/unit/ModuleService.test.js b/test/unit/ModuleService.test.js index 7b23323..91bbc3d 100644 --- a/test/unit/ModuleService.test.js +++ b/test/unit/ModuleService.test.js @@ -14,7 +14,7 @@ const sinon = require('sinon') const { expect } = require('chai') const proxyquire = require('proxyquire').noCallThru() -const { modulesUrls, XChainService, DEFAULT_NODE_PREFIX } = require('../../src/config/constants') +const { modulesUrls, XChainService, DEFAULT_NODE_PREFIX, DEPENDENCY_HEALTH_START_PERIOD } = require('../../src/config/constants') // --------------------------------------------------------------------------- // Helpers @@ -896,6 +896,68 @@ describe('ModuleService', function () { } }) + // A probe that judges a hard dependency's startup must be granted a window at + // least as long as the step it judges. These three each judge another + // container: the encoder's GET /status 503s until the utxo-tracker is synced, + // and the hub's `health` and the explorer's `ping` race a SELECT 1 against + // MariaDB. Pins that they cannot drift back one service at a time. + describe('dependency-derived healthcheck start periods', function () { + // '60s' / '900' / '2m' / '1500ms' all reach docker; compare in seconds. + function startPeriodSeconds(args) { + const i = args.indexOf('--health-start-period') + expect(i, 'no --health-start-period in the emitted args').to.be.greaterThan(-1) + const raw = String(args[i + 1]) + const m = /^(\d+)(ms|s|m|h)?$/.exec(raw) + expect(m, 'unparsable start period ' + JSON.stringify(raw)).to.not.equal(null) + const n = parseInt(m[1], 10) + const unit = m[2] || 's' + return unit === 'ms' ? n / 1000 : unit === 'm' ? n * 60 : unit === 'h' ? n * 3600 : n + } + + // Env overrides are per service and would mask the descriptor defaults + // these cases are about, so clear the four in play and restore after. + const overrideKeys = [ + 'XCHAIN_NODE_HEALTH_START_PERIOD_XCHAIN_ENCODER', + 'XCHAIN_NODE_HEALTH_START_PERIOD_XCHAIN_UTXO_TRACKER', + 'XCHAIN_NODE_HEALTH_START_PERIOD_XCHAIN_HUB', + 'XCHAIN_NODE_HEALTH_START_PERIOD_XCHAIN_EXPLORER' + ] + let savedOverrides = {} + beforeEach(function () { + savedOverrides = {} + for (const key of overrideKeys) { + savedOverrides[key] = process.env[key] + delete process.env[key] + } + }) + afterEach(function () { + for (const key of overrideKeys) { + if (savedOverrides[key] === undefined) delete process.env[key] + else process.env[key] = savedOverrides[key] + } + }) + + it('grants the encoder a window at least as long as the utxo-tracker it probes', function () { + const ms = loadModuleService(makeStubs()) + const encoder = ms.buildHealthcheckArgs('xchain-encoder', { ENCODER_API_PORT: '3003' }) + const tracker = ms.buildHealthcheckArgs('xchain-utxo-tracker', { UTXO_TRACKER_API_PORT: '3001' }) + expect(startPeriodSeconds(encoder), + 'the encoder probes GET /status, which 503s until the tracker is synced' + ).to.be.at.least(startPeriodSeconds(tracker)) + }) + + 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]) + const hub = ms.buildHealthcheckArgs('xchain-hub', { HUB_PORT: '10000' }) + const explorer = ms.buildHealthcheckArgs('xchain-explorer', { EXPLORER_API_PORT_HTTP: '80' }) + expect(startPeriodSeconds(hub), 'hub `health` 503s while MariaDB is still initializing') + .to.be.at.least(dbSeconds) + expect(startPeriodSeconds(explorer), 'explorer `ping` 503s while MariaDB is still initializing') + .to.be.at.least(dbSeconds) + }) + }) + it('returns [] with no warning for a module that has no healthcheck descriptor', function () { const stubs = makeStubs() const ms = loadModuleService(stubs) @@ -1803,7 +1865,7 @@ describe('ModuleService', function () { it('calls ensureBootstrapUtxoTracker when utxo-tracker volume was fresh', async function () { const sinon3 = require('sinon') const ensureBootstrapUtxoTrackerStub = sinon3.stub().resolves() - const utxoTrackerVolumeHasDataStub = sinon3.stub().resolves(false) // false → fresh + const utxoTrackerVolumeFreshnessStub = sinon3.stub().resolves('empty') // confirmed empty = fresh const containerId = 'f'.repeat(64) const execFileStub = sinon3.stub() execFileStub.callsFake((cmd, args, ...rest) => { @@ -1845,9 +1907,10 @@ describe('ModuleService', function () { './DockerService': { killContainer: sinon3.stub().resolves(true), removeContainer: sinon3.stub().resolves(true), forceRemoveContainerByName: sinon3.stub().resolves(true), getPublishedHostPorts: sinon3.stub().resolves(new Map()) }, './DatabaseService': { setDatabaseParameters: sinon3.stub().resolves(), setHubDatabaseParameters: sinon3.stub().resolves() }, './BootstrapService': { - utxoTrackerVolumeHasData: utxoTrackerVolumeHasDataStub, + utxoTrackerVolumeFreshness: utxoTrackerVolumeFreshnessStub, + FRESHNESS_EMPTY: 'empty', ensureBootstrapUtxoTracker: ensureBootstrapUtxoTrackerStub, - mariaDbModuleHasData: sinon3.stub().resolves(true), + mariaDbModuleFreshness: sinon3.stub().resolves('populated'), ensureBootstrapMariaDb: sinon3.stub().resolves() }, './VersionService': { getLocalNodeVersion: sinon3.stub().resolves(null), getLocalModuleVersion: sinon3.stub().resolves(null), checkRemoteNodeVersion: sinon3.stub().resolves() }, @@ -1855,7 +1918,7 @@ describe('ModuleService', function () { './ExplorerService': { installExplorerModule: sinon3.stub().resolves(true) } }) const result = await ms.installModule('xchain-utxo-tracker', 'bitcoin', 'mainnet', true) - expect(utxoTrackerVolumeHasDataStub.calledOnce).to.be.true + expect(utxoTrackerVolumeFreshnessStub.calledOnce).to.be.true expect(ensureBootstrapUtxoTrackerStub.calledOnce).to.be.true expect(result).to.equal(containerId) }) @@ -1863,7 +1926,7 @@ describe('ModuleService', function () { it('calls ensureBootstrapMariaDb when decoder DB was fresh', async function () { const sinon3 = require('sinon') const ensureBootstrapMariaDbStub = sinon3.stub().resolves() - const mariaDbModuleHasDataStub = sinon3.stub().resolves(false) // false → fresh + const mariaDbModuleFreshnessStub = sinon3.stub().resolves('empty') // confirmed empty = fresh const setDatabaseParametersStub = sinon3.stub().resolves() const containerId = 'a'.repeat(64) const execFileStub = sinon3.stub() @@ -1910,9 +1973,10 @@ describe('ModuleService', function () { // whatever containers the venue happens to be running. './DbCredentialDrift': { assertNoDbCredentialDrift: sinon3.stub().resolves([]) }, './BootstrapService': { - utxoTrackerVolumeHasData: sinon3.stub().resolves(true), + utxoTrackerVolumeFreshness: sinon3.stub().resolves('populated'), + FRESHNESS_EMPTY: 'empty', ensureBootstrapUtxoTracker: sinon3.stub().resolves(), - mariaDbModuleHasData: mariaDbModuleHasDataStub, + mariaDbModuleFreshness: mariaDbModuleFreshnessStub, ensureBootstrapMariaDb: ensureBootstrapMariaDbStub }, './VersionService': { getLocalNodeVersion: sinon3.stub().resolves(null), getLocalModuleVersion: sinon3.stub().resolves(null), checkRemoteNodeVersion: sinon3.stub().resolves() }, @@ -1920,12 +1984,76 @@ describe('ModuleService', function () { './ExplorerService': { installExplorerModule: sinon3.stub().resolves(true) } }) const result = await ms.installModule('xchain-decoder', 'bitcoin', 'mainnet', true) - expect(mariaDbModuleHasDataStub.calledOnce).to.be.true + expect(mariaDbModuleFreshnessStub.calledOnce).to.be.true expect(setDatabaseParametersStub.calledOnce).to.be.true expect(ensureBootstrapMariaDbStub.calledOnce).to.be.true expect(result).to.equal(containerId) }) + // uuid:7037604f: ensureBootstrapMariaDb reaches DROP DATABASE, so only a + // CONFIRMED empty store may authorise it. An inspection failure during a + // rolling update answers unknown, which must leave a populated store + // untouched. + it('does NOT call ensureBootstrapMariaDb when the decoder DB freshness is unknown', async function () { + const sinon3 = require('sinon') + const ensureBootstrapMariaDbStub = sinon3.stub().resolves() + const mariaDbModuleFreshnessStub = sinon3.stub().resolves('unknown') + const containerId = 'a'.repeat(64) + const execFileStub = sinon3.stub() + execFileStub.callsFake((cmd, args, ...rest) => { + const cb = typeof rest[0] === 'function' ? rest[0] : rest[1] + if (cmd === 'git') { cb(null) } + else if (cmd === 'docker' && args[0] === 'build') { cb(null) } + else if (cmd === 'docker' && args[0] === 'run') { cb(null, containerId + '\n') } + else { cb(null, '') } + }) + const configStub = { + getModuleDir: (mod) => '/modules/' + mod, + getModuleTmpDir: (mod) => '/tmp/' + mod, + moduleDirExists: sinon3.stub().returns(false), + checkIfModuleExists: sinon3.stub().returns(true), + removeModuleDir: sinon3.stub(), + removeModuleTmpDir: sinon3.stub(), + createModuleTmpDir: sinon3.stub(), + getDockerContainerImageName: (mod, coin, net) => `${coin}-${net}-${mod}`, + getDockerNetwork: (coin, net) => `net-${coin}-${net}`, + validatePort: () => true, + getDefaultConfig: sinon3.stub().resolves({ + DECODER_PORT: 3002, DECODER_API_PORT: 3002, + DECODER_BOOTSTRAP_VOLUME: '/bootstrap' + }) + } + const ms = proxyquireCallThru('../../src/services/ModuleService', { + 'child_process': { execFile: execFileStub }, + 'fs': { existsSync: sinon3.stub(), rmSync: sinon3.stub(), mkdirSync: sinon3.stub(), readFileSync: sinon3.stub(), cpSync: sinon3.stub(), renameSync: sinon3.stub() }, + '../state': { + db: { insertModuleContainer: sinon3.stub().resolves(true), getModuleContainer: sinon3.stub().resolves(null), removeModuleContainer: sinon3.stub().resolves(true) }, + getRemoteModuleVersions: () => ({}), + getLastStatus: () => null + }, + './ConfigService': configStub, + './StatusService': { statusChanged: sinon3.stub().resolves(), getStatus: sinon3.stub().resolves({}) }, + './DockerService': { killContainer: sinon3.stub().resolves(true), removeContainer: sinon3.stub().resolves(true), forceRemoveContainerByName: sinon3.stub().resolves(true), getPublishedHostPorts: sinon3.stub().resolves(new Map()) }, + './DatabaseService': { setDatabaseParameters: sinon3.stub().resolves() }, + './DbCredentialDrift': { assertNoDbCredentialDrift: sinon3.stub().resolves([]) }, + './BootstrapService': { + utxoTrackerVolumeFreshness: sinon3.stub().resolves('populated'), + FRESHNESS_EMPTY: 'empty', + ensureBootstrapUtxoTracker: sinon3.stub().resolves(), + mariaDbModuleFreshness: mariaDbModuleFreshnessStub, + ensureBootstrapMariaDb: ensureBootstrapMariaDbStub, + forceBootstrapRequested: () => false + }, + './VersionService': { getLocalNodeVersion: sinon3.stub().resolves(null), getLocalModuleVersion: sinon3.stub().resolves(null), checkRemoteNodeVersion: sinon3.stub().resolves() }, + './NodeService': { buildCryptoNode: sinon3.stub().resolves(true), getCryptoNode: sinon3.stub().resolves() }, + './ExplorerService': { installExplorerModule: sinon3.stub().resolves(true) } + }) + const result = await ms.installModule('xchain-decoder', 'bitcoin', 'mainnet', true) + expect(mariaDbModuleFreshnessStub.calledOnce).to.be.true + expect(ensureBootstrapMariaDbStub.called).to.be.false + expect(result).to.equal(containerId) + }) + // uuid:cb0bd3be: the drift guard used to run only inside // setDatabaseParameters, i.e. after buildAndUp had already killed and // replaced the container, so a refusal left the working decoder destroyed @@ -1974,9 +2102,10 @@ describe('ModuleService', function () { './DatabaseService': { setDatabaseParameters: setDatabaseParametersStub, setHubDatabaseParameters: sinon3.stub().resolves() }, './DbCredentialDrift': { assertNoDbCredentialDrift: assertNoDbCredentialDriftStub }, './BootstrapService': { - utxoTrackerVolumeHasData: sinon3.stub().resolves(true), + utxoTrackerVolumeFreshness: sinon3.stub().resolves('populated'), + FRESHNESS_EMPTY: 'empty', ensureBootstrapUtxoTracker: sinon3.stub().resolves(), - mariaDbModuleHasData: sinon3.stub().resolves(true), + mariaDbModuleFreshness: sinon3.stub().resolves('populated'), ensureBootstrapMariaDb: sinon3.stub().resolves() }, './VersionService': { getLocalNodeVersion: sinon3.stub().resolves(null), getLocalModuleVersion: sinon3.stub().resolves(null), checkRemoteNodeVersion: sinon3.stub().resolves() }, diff --git a/test/unit/NodeService.test.js b/test/unit/NodeService.test.js index fea89b4..4d78550 100644 --- a/test/unit/NodeService.test.js +++ b/test/unit/NodeService.test.js @@ -130,6 +130,8 @@ function loadNodeService(stubs) { './DockerService': { createDockerNetwork: sinon.stub().resolves(), forceRemoveContainerByName: stubs.forceRemoveContainerByName || sinon.stub().resolves(true), + // Graceful stop of the previous daemon before the force-remove. + stopContainerByName: stubs.stopContainerByName || sinon.stub().resolves(true), // Mount-drift guard. Default: no previous container. getContainerBindMounts: stubs.getContainerBindMounts || sinon.stub().resolves([]) }, @@ -145,7 +147,8 @@ function loadNodeService(stubs) { assertNoHostPortConflicts: stubs.assertNoHostPortConflicts || sinon.stub().resolves() }, './BootstrapService': { - utxoTrackerVolumeHasData: sinon.stub().resolves(true), + utxoTrackerVolumeFreshness: sinon.stub().resolves('populated'), + FRESHNESS_EMPTY: 'empty', ensureBootstrapUtxoTracker: sinon.stub().resolves(), forceBootstrapRequested: () => false } @@ -642,6 +645,30 @@ describe('NodeService: buildCryptoNode()', function () { expect(runArgs).to.include('8333:8332') }) + // No caller ever passed a version, so the container carried the literal + // string CRYPTO_NODE_VERSION=null and nothing anywhere read it + // (uuid:1d4208f4). The version answer is //__VERSION__.txt. + it('bakes no CRYPTO_NODE_VERSION env into the coin-node container', async function () { + const stubs = makeNodeServiceStubs() + let runArgs = null + + stubs.execFile.callsFake((cmd, args, opts, cb) => { + if (args[0] === 'build') return cb(null) + if (args[0] === 'run') { runArgs = args; return cb(null, 'f'.repeat(64) + '\n') } + }) + + const ns = loadNodeService(stubs) + await ns.buildCryptoNode('bitcoin', 'mainnet') + + expect(runArgs).to.not.be.null + expect(runArgs.some(a => String(a).startsWith('CRYPTO_NODE_VERSION'))).to.be.false + expect(runArgs.some(a => String(a).includes('null'))).to.be.false + // The image tag still closes the argv, so this is not passing on a + // truncated run call. + expect(runArgs[runArgs.length - 1]).to.equal('xchain-node-bitcoin-mainnet-node') + expect(runArgs[runArgs.length - 2]).to.equal('-t') + }) + it('aborts before the docker build when a host-port conflict is detected', async function () { const stubs = makeNodeServiceStubs() stubs.assertNoHostPortConflicts = sinon.stub().rejects( @@ -820,6 +847,26 @@ describe('NodeService: buildCryptoNode()', function () { expect(stubs.forceRemoveContainerByName.calledOnce).to.be.true }) + it('stops the previous daemon gracefully, with a flush budget, before force-removing it', async function () { + // Regression: `docker rm -f` alone is SIGKILL, and a killed daemon + // restarts at its last flushed block index (16 regtest blocks lost + // on the v0.21.5.6 litecoind rehearsal, 2026-09-03). + const stubs = makeNodeServiceStubs() + stubs.stopContainerByName = sinon.stub().resolves(true) + stubs.forceRemoveContainerByName = sinon.stub().resolves(true) + const args = await build(stubs, { envBlocksDir: null }) + + expect(stubs.stopContainerByName.calledOnce).to.be.true + const [name, budget] = stubs.stopContainerByName.firstCall.args + expect(name).to.equal('xchain-node-bitcoin-mainnet-node') + expect(budget).to.be.a('number').and.to.be.at.least(300) + expect(stubs.stopContainerByName.calledBefore(stubs.forceRemoveContainerByName)).to.be.true + // The same budget applies to an operator's `docker stop` / `restart`. + const stopTimeoutIdx = args.indexOf('--stop-timeout') + expect(stopTimeoutIdx).to.be.greaterThan(-1) + expect(args[stopTimeoutIdx + 1]).to.equal(String(budget)) + }) + it('treats an existing symlink at the blocks host path as provisioned (no mkdir)', async function () { // Regression: mkdirSync on an existing symlink surfaced a misleading // EACCES "failed to create"; ensureHostDir lstats first and skips. @@ -1128,7 +1175,7 @@ describe('NodeService: installNode()', function () { getLocalModuleVersion: sinon.stub().resolves('1.0.0'), getContainerModuleVersion: sinon.stub().resolves('1.0.0') }, - './DockerService': { createDockerNetwork: sinon.stub().resolves(), forceRemoveContainerByName: sinon.stub().resolves(true) }, + './DockerService': { createDockerNetwork: sinon.stub().resolves(), forceRemoveContainerByName: sinon.stub().resolves(true), stopContainerByName: sinon.stub().resolves(true) }, './DatabaseService': { buildDatabaseModule: sinon.stub().resolves(), setDatabaseParameters: sinon.stub().resolves() @@ -1139,7 +1186,8 @@ describe('NodeService: installNode()', function () { assertNoHostPortConflicts: sinon.stub().resolves() }, './BootstrapService': { - utxoTrackerVolumeHasData: sinon.stub().resolves(true), + utxoTrackerVolumeFreshness: sinon.stub().resolves('populated'), + FRESHNESS_EMPTY: 'empty', ensureBootstrapUtxoTracker: sinon.stub().resolves(), forceBootstrapRequested: () => false } @@ -1179,10 +1227,10 @@ describe('NodeService: installNode()', function () { './ConfigService': { getDockerContainerImageName: stubs.getDockerContainerImageName, getDockerNetwork: stubs.getDockerNetwork, getDefaultConfig: stubs.getDefaultConfig, validatePort: () => true, readSidecarValue: sinon.stub().resolves(undefined), upsertSidecarValues: sinon.stub() }, './StatusService': { statusChanged: stubs.statusChanged }, './VersionService': { checkRemoteNodeVersion: stubs.checkRemoteNodeVersion, getLocalNodeVersion: sinon.stub().resolves('27.0'), getContainerNodeVersion: sinon.stub().resolves('27.0'), getLocalModuleVersion: sinon.stub().resolves('1.0.0'), getContainerModuleVersion: sinon.stub().resolves('1.0.0') }, - './DockerService': { createDockerNetwork: sinon.stub().resolves(), forceRemoveContainerByName: sinon.stub().resolves(true) }, + './DockerService': { createDockerNetwork: sinon.stub().resolves(), forceRemoveContainerByName: sinon.stub().resolves(true), stopContainerByName: sinon.stub().resolves(true) }, './DatabaseService': { buildDatabaseModule: sinon.stub().resolves(), setDatabaseParameters: sinon.stub().resolves() }, './ModuleService': { cloneGit: cloneGitStub, buildAndUp: buildAndUpStub, assertNoHostPortConflicts: sinon.stub().resolves() }, - './BootstrapService': { utxoTrackerVolumeHasData: sinon.stub().resolves(true), ensureBootstrapUtxoTracker: sinon.stub().resolves(), forceBootstrapRequested: () => false } + './BootstrapService': { utxoTrackerVolumeFreshness: sinon.stub().resolves('populated'), FRESHNESS_EMPTY: 'empty', ensureBootstrapUtxoTracker: sinon.stub().resolves(), forceBootstrapRequested: () => false } }) const result = await ns.installNode('bitcoin', 'mainnet') @@ -1216,11 +1264,12 @@ describe('NodeService: installNode()', function () { './ConfigService': { getDockerContainerImageName: stubs.getDockerContainerImageName, getDockerNetwork: stubs.getDockerNetwork, getDefaultConfig: stubs.getDefaultConfig, validatePort: () => true, readSidecarValue: sinon.stub().resolves(undefined), upsertSidecarValues: sinon.stub() }, './StatusService': { statusChanged: stubs.statusChanged }, './VersionService': { checkRemoteNodeVersion: stubs.checkRemoteNodeVersion, getLocalNodeVersion: sinon.stub().resolves('27.0'), getContainerNodeVersion: sinon.stub().resolves('27.0'), getLocalModuleVersion: sinon.stub().resolves('1.0.0'), getContainerModuleVersion: sinon.stub().resolves('1.0.0') }, - './DockerService': { createDockerNetwork: sinon.stub().resolves(), forceRemoveContainerByName: sinon.stub().resolves(true) }, + './DockerService': { createDockerNetwork: sinon.stub().resolves(), forceRemoveContainerByName: sinon.stub().resolves(true), stopContainerByName: sinon.stub().resolves(true) }, './DatabaseService': { buildDatabaseModule: sinon.stub().resolves(), setDatabaseParameters: sinon.stub().resolves() }, './ModuleService': { cloneGit: sinon.stub().resolves(true), buildAndUp: sinon.stub().resolves('e'.repeat(64)), assertNoHostPortConflicts: sinon.stub().resolves() }, './BootstrapService': { - utxoTrackerVolumeHasData: sinon.stub().resolves(false), // fresh + utxoTrackerVolumeFreshness: sinon.stub().resolves('empty'), // confirmed fresh + FRESHNESS_EMPTY: 'empty', ensureBootstrapUtxoTracker: ensureBootstrap } }) diff --git a/test/unit/ValidatorService.test.js b/test/unit/ValidatorService.test.js index 0042427..f0515bf 100644 --- a/test/unit/ValidatorService.test.js +++ b/test/unit/ValidatorService.test.js @@ -64,10 +64,16 @@ function makeHubApiKeyStub(generated = true) { return sinon.stub().resolves({ path: FAKE_HUB_SIDECAR, generated }) } -function loadValidatorService(fsStub, ensureHubApiKey = makeHubApiKeyStub()) { +// The non-minting read a re-run uses. `present` is what the sidecar already holds. +function makeHubApiKeyReadStub(present = false) { + return sinon.stub().resolves({ path: FAKE_HUB_SIDECAR, present }) +} + +function loadValidatorService(fsStub, ensureHubApiKey = makeHubApiKeyStub(), + readHubApiKey = makeHubApiKeyReadStub()) { return proxyquire('../../src/services/ValidatorService', { 'fs': fsStub, - './ConfigService': { ensureHubApiKey }, + './ConfigService': { ensureHubApiKey, readHubApiKey }, '../config/constants': { configDir: FAKE_CONFIG_DIR } @@ -301,11 +307,14 @@ describe('ValidatorService', function () { expect(result.ORACLE_EPOCH_START).to.equal(1717200000000) }) - it('sets ORACLE_EPOCH_START to null when not provided', async function () { + // With no opts the port defaults to 10001, which names the mainnet + // federation, so the epoch defaults to that federation's ruled value + // rather than to null. Null is reserved for a port naming no federation. + it('falls back to the mainnet federation epoch when none is supplied', async function () { const fs = makeFs() const vs = loadValidatorService(fs) const result = await vs.initValidator() - expect(result.ORACLE_EPOCH_START).to.be.null + expect(result.ORACLE_EPOCH_START).to.equal(1788220800000) }) it('uses partial capabilities from opts.capabilities', async function () { @@ -386,13 +395,13 @@ describe('ValidatorService', function () { // Capture output rather than let assertions read the real console: these tests // are about what does and does not get printed. - function captureInit(fs, ensure) { + function captureInit(fs, ensure, read = makeHubApiKeyReadStub(), opts = {}) { const logged = [] const stub = sinon.stub(console, 'log').callsFake(m => logged.push(String(m))) return (async () => { try { - const vs = loadValidatorService(fs, ensure) - await vs.initValidator() + const vs = loadValidatorService(fs, ensure, read) + await vs.initValidator(opts) return logged } finally { stub.restore() @@ -400,6 +409,15 @@ describe('ValidatorService', function () { })() } + // An already-initialized node: settings and signing key both on disk. + function initializedFs() { + return makeFs({ + existsSync: sinon.stub().callsFake(p => + p === FAKE_SETTINGS_FILE || p === FAKE_KEY_FILE), + readFileSync: sinon.stub().returns(JSON.stringify(makeSettings())) + }) + } + it('ensures a hub API key exists as part of init', async function () { const ensure = makeHubApiKeyStub() await captureInit(makeFs(), ensure) @@ -424,27 +442,67 @@ describe('ValidatorService', function () { expect(output).to.not.match(/HUB_API_KEY=\S/) }) - // A node initialized before this existed is EXACTLY the node stuck at a refused - // boot, so re-running init has to repair it rather than return early. - it('repairs an already-initialized node that has no key yet', async function () { - const ensure = makeHubApiKeyStub() - const fs = makeFs({ - existsSync: sinon.stub().callsFake(p => - p === FAKE_SETTINGS_FILE || p === FAKE_KEY_FILE), - readFileSync: sinon.stub().returns(JSON.stringify(makeSettings())) - }) - const logged = await captureInit(fs, ensure) - expect(ensure.calledOnce).to.be.true - expect(logged.some(l => l.includes('already initialized'))).to.be.true - expect(logged.some(l => l.includes(FAKE_HUB_SIDECAR))).to.be.true - }) - it('reports a pre-existing key as reused rather than claiming a fresh one', async function () { const logged = await captureInit(makeFs(), makeHubApiKeyStub(false)) const line = logged.find(l => l.includes('hub API key')) expect(line).to.include('reused') expect(line).to.not.include('generated now') }) + + // A credential APPEARING is as breaking as one disappearing. A hub with no key + // runs keyless and every consumer pointed at it carries no key either, so a key + // minted by a re-run 401s all of them on the hub's next deploy while the hub + // itself still reports healthy. Measured on a regtest host: three indexers + // dropped off the hub-db sync socket behind a mirror-barrier timeout. + describe('a re-run over an already-initialized node', function () { + + it('does NOT mint a key on a keyless host', async function () { + const ensure = makeHubApiKeyStub() + const read = makeHubApiKeyReadStub(false) + await captureInit(initializedFs(), ensure, read) + expect(ensure.called).to.be.false + expect(read.calledOnce).to.be.true + }) + + it('names the consequence instead of minting silently', async function () { + const logged = await captureInit(initializedFs(), makeHubApiKeyStub(), makeHubApiKeyReadStub(false)) + const output = logged.join('\n') + expect(output).to.include('KEYLESS') + expect(output).to.include('401') + expect(output).to.include('--mint-hub-api-key') + expect(output).to.include(FAKE_HUB_SIDECAR) + }) + + // --force rotates the SIGNING KEY, which is this node's business alone. The + // hub credential is the whole host's, so it stays read-only there too. + it('does NOT mint under --force either', async function () { + const ensure = makeHubApiKeyStub() + const read = makeHubApiKeyReadStub(false) + const logged = await captureInit(initializedFs(), ensure, read, { force: true, wallets: false }) + expect(ensure.called).to.be.false + expect(logged.join('\n')).to.include('--mint-hub-api-key') + }) + + it('still reports an existing key, without rotating it', async function () { + const ensure = makeHubApiKeyStub() + const logged = await captureInit(initializedFs(), ensure, makeHubApiKeyReadStub(true)) + expect(ensure.called).to.be.false + const line = logged.find(l => l.includes('hub API key')) + expect(line).to.include(FAKE_HUB_SIDECAR) + expect(line).to.include('reused') + }) + + // The old install that really is stuck at a refused hub boot still has a + // repair path; it is now something the operator asks for by name. + it('mints when --mint-hub-api-key asks it to', async function () { + const ensure = makeHubApiKeyStub() + const read = makeHubApiKeyReadStub(false) + const logged = await captureInit(initializedFs(), ensure, read, { mintHubApiKey: true }) + expect(ensure.calledOnce).to.be.true + expect(read.called).to.be.false + expect(logged.find(l => l.includes('hub API key'))).to.include('generated now') + }) + }) }) it('creates the capability-config directory and writes the config inside it', async function () { @@ -803,6 +861,94 @@ describe('ValidatorService', function () { expect(signer.args[1]).to.include('async broadcast(payload)') }) + // The emitted signer runs both phases of the P2SH encoding, and phase 1 puts + // real DOGE on chain. A failure after that point must not reach the hub looking + // like a clean pre-send failure: the hub would requeue, re-enter broadcast(), + // run createTx over fresh UTXOs and fund the same payload a second time. So the + // template is driven for real here rather than grepped, with the SDK stubbed. + describe('the emitted signer marks post-funding failures', function () { + const vm = require('vm') + const PHASE1 = 'f'.repeat(64) + + // Compile the written template and hand it a stub SDK, so the two-phase + // pipeline can be exercised without a key, an encoder or a network. + function loadEmittedSigner(source, encoder) { + const mod = { exports: {} } + vm.runInNewContext(source, { + require: (id) => { + if (id === 'path') return path + if (id === 'dotenv') return { config: () => ({}) } + if (id === '@dankest-llc/xchain-sdk') return { XChainSDK: function () { + this._requireEncoder = () => encoder + this.wallet = { + signPsbt: () => ({ txHex: 'hex-1', txid: PHASE1 }), + signRevealPsbt: () => ({ txHex: 'hex-2', txid: 'e'.repeat(64) }) + } + } } + throw new Error('unexpected require in the emitted signer: ' + id) + }, + module: mod, exports: mod.exports, __dirname: FAKE_SIGNER_DIR, console, + process: { env: { + DOGE_NETWORK: 'dogecoin-testnet', + DOGE_WIF: 'test-wif', + DOGE_ADDRESS: 'test-address', + DOGE_ENCODER_URL: 'http://encoder.invalid' + } }, + Number, String, Error, Promise, Object + }, { filename: 'signer.js' }) + return mod.exports + } + + async function emitSigner() { + const fs = makeFs() + const vs = loadValidatorService(fs) + await vs.initValidator({ network: 'testnet' }) + return fs.writeFileSync.getCalls().find(c => c.args[0] === FAKE_SIGNER_FILE).args[1] + } + + function stubEncoder(overrides) { + return Object.assign({ + createTx: async () => ({ psbt: 'psbt-1', encoding: 'P2SH' }), + broadcastTx: async () => ({ txid: PHASE1 }), + spendP2sh: async () => ({ psbt: 'psbt-2' }) + }, overrides || {}) + } + + it('is valid JavaScript once the template literal is expanded', async function () { + const source = await emitSigner() + expect(() => new vm.Script(source, { filename: 'signer.js' })).to.not.throw() + }) + + it('tags a definitive phase-2 rejection with fundsCommitted and the phase-1 txid', async function () { + const signer = loadEmittedSigner(await emitSigner(), stubEncoder({ + spendP2sh: async () => { throw new Error('Encoder RPC error: bad-txns-inputs-missingorspent') } + })) + let caught = null + try { await signer.broadcast('wire') } catch (e) { caught = e } + expect(caught).to.exist + expect(caught.fundsCommitted).to.equal(true) + expect(caught.phase1Txid).to.equal(PHASE1) + // The SAME object is rethrown: the hub classifies on message and response. + expect(caught.message).to.equal('Encoder RPC error: bad-txns-inputs-missingorspent') + }) + + it('leaves a pre-funding failure untagged, so the round stays retryable', async function () { + const signer = loadEmittedSigner(await emitSigner(), stubEncoder({ + createTx: async () => { throw new Error('Encoder RPC error: no UTXOs available') } + })) + let caught = null + try { await signer.broadcast('wire') } catch (e) { caught = e } + expect(caught).to.exist + expect(caught.fundsCommitted).to.equal(undefined) + }) + + it('does not tag a successful two-phase publish', async function () { + const signer = loadEmittedSigner(await emitSigner(), stubEncoder()) + const res = await signer.broadcast('wire') + expect(res.phase1_txid).to.equal(PHASE1) + }) + }) + it('points the signer at the DOGE wallet and the public testnet encoder', async function () { const fs = makeFs() const vs = loadValidatorService(fs) @@ -856,13 +1002,35 @@ describe('ValidatorService', function () { expect(result.ORACLE_EPOCH_START).to.equal(1717200000000) }) - it('leaves ORACLE_EPOCH_START null on mainnet, where no federation value is known yet', async function () { + it('defaults ORACLE_EPOCH_START to the mainnet federation value', async function () { const vs = loadValidatorService(makeFs()) const result = await vs.initValidator({ p2pPort: '10001' }) expect(result.network).to.equal('mainnet') + expect(result.ORACLE_EPOCH_START).to.equal(1788220800000) + }) + + it('leaves ORACLE_EPOCH_START null when the network is unknown', async function () { + const vs = loadValidatorService(makeFs()) + const result = await vs.initValidator({ p2pPort: '10009' }) + expect(result.network).to.be.null expect(result.ORACLE_EPOCH_START).to.be.null }) + // Both federation defaults must sit in the PAST. A future epoch numbers + // every round negative and OracleRound drops peer submissions for + // round < 0, which is the failure testnet paid a federation-wide flag + // day for on 2026-08-28; mainnet is ruled past up front to avoid it. + // They must also differ, so a round number never lines up across the + // two federations. + it('both federation default epochs are in the past, and differ', async function () { + const vs = loadValidatorService(makeFs()) + const mainnet = await vs.initValidator({ p2pPort: '10001' }) + const testnet = await vs.initValidator({ p2pPort: '10002', force: true }) + expect(mainnet.ORACLE_EPOCH_START).to.be.a('number').and.to.be.lessThan(Date.now()) + expect(testnet.ORACLE_EPOCH_START).to.be.a('number').and.to.be.lessThan(Date.now()) + expect(mainnet.ORACLE_EPOCH_START).to.not.equal(testnet.ORACLE_EPOCH_START) + }) + it('skips wallets on a non-standard port and says so, without failing init', async function () { const fs = makeFs() const vs = loadValidatorService(fs) diff --git a/test/unit/cryptoNodeConfPlaceholders.test.js b/test/unit/cryptoNodeConfPlaceholders.test.js index 1496d5d..30ed1b0 100644 --- a/test/unit/cryptoNodeConfPlaceholders.test.js +++ b/test/unit/cryptoNodeConfPlaceholders.test.js @@ -14,6 +14,10 @@ function listConfFiles() { const dir = path.join(cryptoNodesDir, coin) if (!fs.statSync(dir).isDirectory()) continue for (const f of fs.readdirSync(dir)) { + // The build writes the credential-bearing copy as a gitignored + // `-.generated.conf` sibling (stageBuildScaffold); + // only the tracked templates are under test here. + if (f.endsWith('.generated.conf')) continue if (f.endsWith('.conf')) files.push(path.join(dir, f)) } } diff --git a/test/unit/moduleOperations.test.js b/test/unit/moduleOperations.test.js index 207222c..06780b8 100644 --- a/test/unit/moduleOperations.test.js +++ b/test/unit/moduleOperations.test.js @@ -28,6 +28,9 @@ function makeStubs() { updateHub: sinon.stub().resolves(true), db: { getModuleContainer: sinon.stub().resolves('container-id-123'), + // The non-swallowing read the destructive reset paths use: a registry + // failure throws here instead of answering "not installed". + getModuleContainerStrict: sinon.stub().resolves('container-id-123'), removeModuleContainer: sinon.stub().resolves(true), // Registry contents AFTER the per-coin uninstall pass. Empty by default = // nothing left for a shared service to serve, which is the full-teardown @@ -50,11 +53,20 @@ function makeStubs() { logContainer: sinon.stub().resolves(true), startDockerMonitor: sinon.stub().resolves(true), waitContainer: sinon.stub().resolves(0), + // The node container answers where its datadir really lives. + // Default: a host path that is NOT the env-derived one, which is the + // ordinary case on a stack whose datadir was relocated. + getContainerBindMounts: sinon.stub().resolves([ + { source: '/srv/xchain/data/node/bitcoin/mainnet', destination: '/root/.bitcoin' } + ]), saveContainerLogs: sinon.stub().resolves(true), buildDatabaseModule: sinon.stub().resolves(true), resetDatabases: sinon.stub().resolves(true), clearHubPriceIngestWatermark: sinon.stub().resolves(true), getDatabaseContainerId: sinon.stub().resolves('mariadb-container-id'), + // EXTERNAL_DB pre-wipe reachability probe. Reachable by default so it + // stays out of the way of every test that is not about it. + pingExternalDatabase: sinon.stub().resolves({ ok: true, host: 'db.example', port: 3306 }), cloneGit: sinon.stub().resolves(true), getModuleBranch: sinon.stub().resolves('master'), buildAndUp: sinon.stub().resolves('b'.repeat(64)), @@ -72,13 +84,26 @@ function makeStubs() { bootstrapService: { resetBootstrapOutcomes: sinon.stub(), reportBootstrapOutcomes: sinon.stub() + }, + // The reindex -> forced-republish ledger. Stubbed so a reset in these + // suites never writes the developer's real ~/.xchain-node; the ledger's + // own rules live in BootstrapRepublishLedger.test.js. + republishLedger: { + reindexAffectedModules: sinon.stub().callsFake( + require('../../src/services/BootstrapRepublishLedger').reindexAffectedModules), + recordReindex: sinon.stub().callsFake((modules, coin, network) => + (modules || []).map(m => `${m}:${coin}:${network}`)) } } } -function loadOperations(stubs) { +// `constantsOverrides` swaps individual config/constants values (EXTERNAL_DB is +// the one that matters here) without touching the rest of the module. +function loadOperations(stubs, constantsOverrides = null) { return proxyquire('../../src/operations/moduleOperations', { - '../config/constants': require('../../src/config/constants'), + '../config/constants': constantsOverrides + ? Object.assign({}, require('../../src/config/constants'), constantsOverrides) + : require('../../src/config/constants'), '../state': { db: stubs.db }, '../services/ConfigService': { getDockerContainerImageName: (mod, coin, net) => `${coin}-${net}-${mod}`, @@ -100,13 +125,15 @@ function loadOperations(stubs) { logContainer: stubs.logContainer, startDockerMonitor: stubs.startDockerMonitor, waitContainer: stubs.waitContainer, - saveContainerLogs: stubs.saveContainerLogs + saveContainerLogs: stubs.saveContainerLogs, + getContainerBindMounts: stubs.getContainerBindMounts }, '../services/DatabaseService': { buildDatabaseModule: stubs.buildDatabaseModule, resetDatabases: stubs.resetDatabases, clearHubPriceIngestWatermark: stubs.clearHubPriceIngestWatermark, getDatabaseContainerId: stubs.getDatabaseContainerId, + pingExternalDatabase: stubs.pingExternalDatabase, setDatabaseParameters: stubs.setDatabaseParameters, setHubDatabaseParameters: stubs.setHubDatabaseParameters }, @@ -134,6 +161,10 @@ function loadOperations(stubs) { statusChanged: stubs.statusChanged }, '../services/BootstrapService': stubs.bootstrapService, + '../services/BootstrapRepublishLedger': { + reindexAffectedModules: stubs.republishLedger.reindexAffectedModules, + recordReindex: stubs.republishLedger.recordReindex + }, 'child_process': { execFile: stubs.execFile }, 'fs': stubs.fs, 'util': { @@ -380,13 +411,16 @@ describe('moduleOperations', function () { expect(stubs.buildAndUp.called).to.be.false }) - it('tears down the existing node container by name before rebuilding', async function () { + it('leaves the running node container to buildCryptoNode instead of force-removing it up front', async function () { + // Regression: an up-front `docker rm -f` is SIGKILL, so the daemon + // restarted at its last flushed block index (16 regtest blocks lost, + // 2026-09-03). buildCryptoNode stops it gracefully and removes the + // stopped carcass itself, right before its `docker run`. const stubs = makeStubs() const ops = loadOperations(stubs) await ops.updateModules({ bitcoin: { mainnet: ['node'] } }) - // getDockerContainerImageName stub renders as `${coin}-${net}-${mod}` - expect(stubs.forceRemoveContainerByName.calledWith('bitcoin-mainnet-node')).to.be.true - expect(stubs.forceRemoveContainerByName.calledBefore(stubs.installModule)).to.be.true + expect(stubs.forceRemoveContainerByName.called).to.be.false + expect(stubs.installModule.calledWith('node', 'bitcoin', 'mainnet', true, null)).to.be.true }) it('recreates the node even when its container is missing (no silent no-op)', async function () { @@ -1147,6 +1181,190 @@ describe('moduleOperations', function () { // No bounce candidates for node-only reset }) + // The node datadir came from XCHAIN_NODE_DATA_DIR, and the wipe + // was guarded on fs.existsSync of that path. A reset run from a shell + // that never sourced the operator's profile therefore resolved a path + // the stack has never used, the guard went silently false, and the run + // wiped the decoder/indexer DBs, left the chain in place, and exited 0. + // The missing "Clearing node data" line was the only tell. + describe('node datadir resolution', function () { + + // The host side of every `docker run --rm -v :/data` this + // reset issued: what was actually wiped, in host paths. + function wipedHostPaths(execFileStub) { + return execFileStub.getCalls() + .filter(c => c.args[0] === 'docker' && Array.isArray(c.args[1]) && c.args[1][0] === 'run') + .map(c => c.args[1][c.args[1].indexOf('-v') + 1]) + } + + it('wipes the path the node container reports, not the env-derived one', async function () { + const stubs = makeStubs() + // Nothing at the env-derived path: the old guard's silent skip. + stubs.fs.existsSync.returns(false) + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + const ops = loadOperations(stubs) + const result = await ops.resetModules('node', 'bitcoin', 'mainnet', true) + expect(result).to.be.true + expect(wipedHostPaths(stubs.execFile)) + .to.include('/srv/xchain/data/node/bitcoin/mainnet:/data') + }) + + it('falls back to the configured datadir when the container reports no mount', async function () { + const stubs = makeStubs() + stubs.getContainerBindMounts.resolves([]) + stubs.fs.existsSync.returns(true) + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + const ops = loadOperations(stubs) + const result = await ops.resetModules('node', 'bitcoin', 'mainnet', true) + expect(result).to.be.true + const wiped = wipedHostPaths(stubs.execFile) + expect(wiped.some(p => p.endsWith('/node/bitcoin/mainnet:/data'))).to.be.true + }) + + it('refuses the whole reset when the datadir resolves to nothing', async function () { + const stubs = makeStubs() + stubs.getContainerBindMounts.resolves([]) + stubs.fs.existsSync.returns(false) + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + const ops = loadOperations(stubs) + const result = await ops.resetModules('all', 'bitcoin', 'mainnet', true) + expect(result).to.be.false + // Fails closed BEFORE anything is stopped or wiped: the whole + // point is that the DBs must not go without the chain. + expect(stubs.stopContainer.called).to.be.false + expect(stubs.resetDatabases.called).to.be.false + expect(wipedHostPaths(stubs.execFile)).to.be.empty + }) + + it('names the container, the configured path and the env var in the refusal', async function () { + const stubs = makeStubs() + stubs.getContainerBindMounts.resolves([]) + stubs.fs.existsSync.returns(false) + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + const ops = loadOperations(stubs) + const lines = [] + const logStub = sinon.stub(console, 'log').callsFake((...args) => lines.push(args.join(' '))) + try { + await ops.resetModules('all', 'bitcoin', 'mainnet', true) + } finally { + logStub.restore() + } + const output = lines.join('\n') + expect(output).to.include('Aborted: cannot resolve the bitcoin mainnet node datadir') + expect(output).to.include('No data was touched.') + expect(output).to.include('bitcoin-mainnet-node') + expect(output).to.include('XCHAIN_NODE_DATA_DIR') + }) + + it('skips the node wipe out loud, and completes, when no node is installed', async function () { + const stubs = makeStubs() + stubs.getContainerBindMounts.resolves([]) + stubs.fs.existsSync.returns(false) + stubs.db.getModuleContainer.withArgs('node', 'bitcoin', 'mainnet').resolves(null) + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + const ops = loadOperations(stubs) + const lines = [] + const logStub = sinon.stub(console, 'log').callsFake((...args) => lines.push(args.join(' '))) + let result + try { + result = await ops.resetModules('node', 'bitcoin', 'mainnet', true) + } finally { + logStub.restore() + } + expect(result).to.be.true + expect(lines.join('\n')).to.include('no node data to clear') + expect(wipedHostPaths(stubs.execFile)).to.be.empty + }) + }) + + // A pre-wipe MariaDB guard that is docker-mode only lets an + // EXTERNAL_DB reset reached the database for the first time at + // resetDatabases: after the stop loop, the datadir wipe and the tracker + // volume wipe, and before the restart pass. An unreachable host (or a + // partial XCHAIN_NODE_EXTERNAL_DB_* env) therefore left the operator + // with the chain destroyed, the databases untouched and every service + // down (uuid:41887889). + describe('EXTERNAL_DB pre-wipe reachability guard', function () { + + // Host side of every `docker run --rm -v :/data` this reset issued. + function wipedPaths(execFileStub) { + return execFileStub.getCalls() + .filter(c => c.args[0] === 'docker' && Array.isArray(c.args[1]) && c.args[1][0] === 'run') + .map(c => c.args[1][c.args[1].indexOf('-v') + 1]) + } + + it('aborts before anything is stopped or wiped when the external DB is unreachable', async function () { + const stubs = makeStubs() + stubs.pingExternalDatabase.resolves({ + ok: false, host: 'db.example', port: 3306, error: 'connect ECONNREFUSED' + }) + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + const ops = loadOperations(stubs, { EXTERNAL_DB: true }) + const lines = [] + const logStub = sinon.stub(console, 'log').callsFake((...args) => lines.push(args.join(' '))) + let result + try { + result = await ops.resetModules('all', 'bitcoin', 'mainnet', true) + } finally { + logStub.restore() + } + expect(result).to.be.false + expect(stubs.stopContainer.called).to.be.false + expect(stubs.resetDatabases.called).to.be.false + expect(wipedPaths(stubs.execFile)).to.be.empty + const output = lines.join('\n') + expect(output).to.include('cannot reach the external MariaDB at db.example:3306') + expect(output).to.include('connect ECONNREFUSED') + expect(output).to.include('No data was touched.') + }) + + it('aborts the same way when the external config cannot be resolved', async function () { + const stubs = makeStubs() + // getExternalDbConfig throws on a partial env with no TTY; the + // probe reports that instead of unwinding past the restart pass. + stubs.pingExternalDatabase.resolves({ + ok: false, host: null, port: null, error: 'External-DB connection details are needed' + }) + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + const ops = loadOperations(stubs, { EXTERNAL_DB: true }) + const logStub = sinon.stub(console, 'log') + let result + try { + result = await ops.resetModules('all', 'bitcoin', 'mainnet', true) + } finally { + logStub.restore() + } + expect(result).to.be.false + expect(stubs.stopContainer.called).to.be.false + expect(wipedPaths(stubs.execFile)).to.be.empty + }) + + it('proceeds to the reset when the external DB answers', async function () { + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + const ops = loadOperations(stubs, { EXTERNAL_DB: true }) + const clock = sinon.useFakeTimers() + const promise = ops.resetModules('all', 'bitcoin', 'mainnet', true) + await clock.tickAsync(6000) + clock.restore() + const result = await promise + expect(result).to.be.true + expect(stubs.pingExternalDatabase.calledOnce).to.be.true + expect(stubs.resetDatabases.called).to.be.true + // The container lookup is the docker-mode branch and must not run here. + expect(stubs.getDatabaseContainerId.called).to.be.false + }) + + it('does not probe the external DB when no database is being reset', async function () { + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + const ops = loadOperations(stubs, { EXTERNAL_DB: true }) + const result = await ops.resetModules('node', 'bitcoin', 'mainnet', true) + expect(result).to.be.true + expect(stubs.pingExternalDatabase.called).to.be.false + }) + }) + it('stops and resets utxo-tracker when service=xchain-utxo-tracker', async function () { const stubs = makeStubs() stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) @@ -1156,6 +1374,81 @@ describe('moduleOperations', function () { expect(stubs.stopContainer.called).to.be.true }) + // A reset rebuilds a store on a NEW lineage, so every bootstrap + // already published for that combo describes the old one and restoring + // it puts a fresh install on a chain this box no longer agrees with. + // Nothing forced a republish, and no age check caught it because the + // wrong archive was hours old. The reset itself has to arm the marker. + describe('marks the reindexed combos for a forced bootstrap republish', function () { + + it('marks the tracker combo when the tracker volume is wiped', async function () { + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + const ops = loadOperations(stubs) + expect(await ops.resetModules('xchain-utxo-tracker', 'bitcoin', 'testnet', true)).to.be.true + + expect(stubs.republishLedger.recordReindex.calledOnce).to.be.true + const [modules, coin, network, opts] = stubs.republishLedger.recordReindex.firstCall.args + expect(modules).to.deep.equal(['xchain-utxo-tracker']) + expect(coin).to.equal('bitcoin') + expect(network).to.equal('testnet') + expect(opts.reason).to.include('reset xchain-utxo-tracker') + }) + + // A re-genesis is run as `reset all`, and that is where all three + // derived archives really do go stale. + it('marks all three on a reset all', async function () { + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + const ops = loadOperations(stubs) + const clock = sinon.useFakeTimers() + const promise = ops.resetModules('all', 'bitcoin', 'testnet', true) + await clock.tickAsync(6000) // past the decoder/indexer bounce delay + clock.restore() + expect(await promise).to.be.true + + expect(stubs.republishLedger.recordReindex.calledOnce).to.be.true + expect(stubs.republishLedger.recordReindex.firstCall.args[0]) + .to.deep.equal(['xchain-utxo-tracker', 'xchain-decoder', 'xchain-indexer']) + }) + + // A node-only reset resyncs the same chain and leaves every derived + // store untouched, so warning about three combos there would be + // noise on an ordinary resync. + it('marks nothing for a node-only reset', async function () { + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + const ops = loadOperations(stubs) + expect(await ops.resetModules('node', 'bitcoin', 'testnet', true)).to.be.true + expect(stubs.republishLedger.recordReindex.called).to.be.false + }) + + // Nothing was wiped on an aborted reset, so the published archives + // are still the right lineage: arming here would force a pointless + // tracker republish (which costs downtime) on every refused reset. + it('marks nothing when the reset aborts before any wipe', async function () { + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + // The decoder/indexer pair is only coherent when both move + // together, so a decoder-only reset with the indexer installed + // is refused before anything is touched. + const ops = loadOperations(stubs) + expect(await ops.resetModules('xchain-decoder', 'bitcoin', 'testnet', true)).to.be.false + expect(stubs.republishLedger.recordReindex.called).to.be.false + }) + + // The wipes already happened by the time this runs, so a ledger + // failure must never abort the restart pass and leave the stack down. + it('does not abort the reset when the ledger cannot be written', async function () { + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + stubs.republishLedger.recordReindex.throws(new Error('read-only home')) + const ops = loadOperations(stubs) + expect(await ops.resetModules('xchain-utxo-tracker', 'bitcoin', 'testnet', true)).to.be.true + expect(stubs.startContainer.called).to.be.true + }) + }) + it('resets decoder: stops, resets DB, and bounces', async function () { const stubs = makeStubs() stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) @@ -1230,6 +1523,187 @@ describe('moduleOperations', function () { expect(stubs.execFile.called).to.be.false }) + // uuid:846cc40d: the stop loop resolved each target through the swallowing + // getModuleContainer, which answers null for a SQL error as well as for a + // miss. A registry blip after the reachability precheck therefore made a + // RUNNING indexer look uninstalled, the loop skipped stopping it, and + // resetDatabases dropped its database underneath it while the command + // reported success. A read that FAILED is not evidence of absence. + describe('the registry read that decides what to stop', function () { + + // Every `docker run --rm -v :/data` this reset issued. + function wipeRuns(execFileStub) { + return execFileStub.getCalls() + .filter(c => c.args[0] === 'docker' && Array.isArray(c.args[1]) && c.args[1][0] === 'run') + } + + it('aborts before any wipe when a target row cannot be read', async function () { + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + stubs.db.getModuleContainerStrict.callsFake(async (module) => { + if (module === 'xchain-utxo-tracker') throw new Error('ER_LOCK_WAIT_TIMEOUT') + return 'container-id-123' + }) + 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('all', 'bitcoin', 'mainnet', true) + } finally { + logStub.restore() + } + expect(result).to.be.false + expect(stubs.resetDatabases.called).to.be.false + expect(wipeRuns(stubs.execFile)).to.be.empty + const output = lines.join('\n') + expect(output).to.include('cannot read the xchain-utxo-tracker registry row') + expect(output).to.include('ER_LOCK_WAIT_TIMEOUT') + expect(output).to.include('No data was touched.') + }) + + it('reports a module the rollback cannot resolve as still down', async function () { + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + // node resolves and stops, the tracker read fails, and the + // rollback's own read fails the same way. + stubs.db.getModuleContainerStrict.onCall(0).resolves('container-id-123') + stubs.db.getModuleContainerStrict.rejects(new Error('registry unreachable')) + 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('all', 'bitcoin', 'mainnet', true) + } finally { + logStub.restore() + } + expect(result).to.be.false + expect(stubs.startContainer.called).to.be.false + expect(lines.join('\n')).to.include('STILL DOWN, start by hand: node') + }) + + // A successful read with no row is still an ordinary "not installed". + it('still skips a module that is genuinely absent from the registry', async function () { + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + stubs.db.getModuleContainerStrict.callsFake(async (module) => + module === 'xchain-regtest-miner' ? null : 'container-id-123') + const ops = loadOperations(stubs) + const clock = sinon.useFakeTimers() + const promise = ops.resetModules('all', 'bitcoin', 'mainnet', true) + await clock.tickAsync(6000) + clock.restore() + expect(await promise).to.be.true + expect(stubs.resetDatabases.calledOnce).to.be.true + }) + }) + + // uuid:e24c98d4: the tracker volume wipe swallowed EVERY failure as + // "the volume may not exist", so a permission error, an unreachable + // daemon or a failed alpine pull left stale tracker data in place while + // resetDatabases re-genesised the decoder and indexer around it, and the + // run returned true. + describe('the utxo-tracker volume wipe', function () { + + const VOLUME = 'xchain-utxo-tracker-bitcoin-mainnet-data' + + function volumeWipeRan(execFileStub) { + return execFileStub.getCalls().some(c => + c.args[1][0] === 'run' && c.args[1].join(' ').includes(VOLUME)) + } + + it('refuses the reset when the volume presence cannot be determined', async function () { + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, cb) => { + if (args[0] === 'volume') { + return cb(new Error('Cannot connect to the Docker daemon at unix:///var/run/docker.sock')) + } + cb(null, '', '') + }) + 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('all', 'bitcoin', 'mainnet', true) + } finally { + logStub.restore() + } + expect(result).to.be.false + expect(stubs.resetDatabases.called).to.be.false + expect(volumeWipeRan(stubs.execFile)).to.be.false + const output = lines.join('\n') + expect(output).to.include(`cannot determine whether the Docker volume ${VOLUME} exists`) + expect(output).to.include('No data was touched.') + }) + + // Docker SAYING "no such volume" is the only thing that means absent. + it('treats docker\'s own no-such-volume as absence and completes', async function () { + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, cb) => { + if (args[0] === 'volume') return cb(new Error(`Error: No such volume: ${VOLUME}`)) + cb(null, '', '') + }) + const ops = loadOperations(stubs) + const result = await ops.resetModules('xchain-utxo-tracker', 'bitcoin', 'mainnet', true) + expect(result).to.be.true + expect(volumeWipeRan(stubs.execFile)).to.be.false + }) + + it('aborts and restores the stack 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, '', '') + if (args.join(' ').includes(VOLUME)) return cb(new Error('permission denied')) + cb(null, '', '') + }) + 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('xchain-utxo-tracker', 'bitcoin', 'mainnet', true) + } finally { + logStub.restore() + } + expect(result).to.be.false + expect(stubs.resetDatabases.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.') + }) + + // On `reset all` the node datadir is already gone by the time the + // volume wipe runs, so the abort must not claim otherwise, must not + // let the decoder/indexer databases go, and must not restart services + // over a half-reset stack. + it('refuses to drop the databases after a failed wipe on reset all', async function () { + const stubs = makeStubs() + stubs.execFile.callsFake((cmd, args, cb) => { + if (args[0] === 'volume') return cb(null, '', '') + if (args.join(' ').includes(VOLUME)) return cb(new Error('permission denied')) + cb(null, '', '') + }) + 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('all', 'bitcoin', 'mainnet', true) + } finally { + logStub.restore() + } + 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('The node data for this stack WAS already cleared') + expect(output).to.not.include('No data was touched.') + }) + }) + it('clears the hub price ingest fence when the indexer DB is reset', async function () { const stubs = makeStubs() stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) diff --git a/test/unit/publishBootstrapsForcedRepublish.test.js b/test/unit/publishBootstrapsForcedRepublish.test.js new file mode 100644 index 0000000..0fab215 --- /dev/null +++ b/test/unit/publishBootstrapsForcedRepublish.test.js @@ -0,0 +1,211 @@ +'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 records a combo as due for republish when a reset rebuilds +// its store on a new lineage, but the marker only matters if the PUBLISHER acts +// on it: a due combo the schedule would have dropped has to enter the plan +// anyway, or the pre-reindex archive stays newest and every fresh install that +// takes it halts. These suites drive scripts/publish-bootstraps.sh in --dry-run +// against a fake xchain-node and pin the plan it builds. + +const fs = require('fs') +const os = require('os') +const path = require('path') +const { execFileSync, spawnSync } = require('child_process') +const { expect } = require('chai') + +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'publish-bootstraps.sh') + +// The script uses mapfile, which is bash 4+. Production runs it under +// /usr/bin/env bash on Linux; a box whose PATH bash is 3.2 (stock macOS) cannot +// run it at all, so skip rather than report a red that says nothing about the +// change under test. +function bashSupportsMapfile() { + const probe = spawnSync('bash', ['-c', 'mapfile -t x < /dev/null'], { encoding: 'utf8' }) + return probe.status === 0 +} + +describe('publish-bootstraps.sh: forced republish after a reindex', function () { + + this.timeout(10000) + + let workDir + let binDir + + before(function () { + if (!bashSupportsMapfile()) this.skip() + }) + + beforeEach(function () { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xchain-publish-')) + binDir = path.join(workDir, 'bin') + fs.mkdirSync(binDir) + }) + + afterEach(function () { + fs.rmSync(workDir, { recursive: true, force: true }) + }) + + /** + * Install a fake `xchain-node` that answers the two listing subcommands the + * planner uses and nothing else. --dry-run exits before any create, so the + * plan is the whole observable behaviour. + */ + function fakeNode({ combos = [], due = [] } = {}) { + // 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 script = [ + '#!/usr/bin/env bash', + 'case "$1" in', + ` bootstrap-combos) ${emit(combos)} ;;`, + ` bootstrap-republish-due) ${emit(due)} ;;`, + ' *) echo "unexpected: $*" >&2; exit 3 ;;', + 'esac', + 'exit 0', + '' + ].join('\n') + const p = path.join(binDir, 'xchain-node') + fs.writeFileSync(p, script, { mode: 0o755 }) + return p + } + + function runPlan(args) { + try { + return execFileSync(SCRIPT, args, { + 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') + } + }) + } catch (err) { + // Surface the script's own output on a non-zero exit; a bare + // "Command failed" says nothing about which precondition tripped. + throw new Error(`${err.message}\n--- stdout ---\n${err.stdout}\n--- stderr ---\n${err.stderr}`) + } + } + + function planLine(out) { + const line = out.split('\n').find(l => l.includes('publish plan (')) + return line || '' + } + + it('keeps the scheduled plan when nothing was reindexed', function () { + fakeNode({ + combos: [ + 'xchain-decoder:bitcoin:testnet', + 'xchain-indexer:bitcoin:testnet', + 'xchain-utxo-tracker:bitcoin:testnet' + ], + due: [] + }) + const out = runPlan(['--all', '--dry-run', '--allow-unsigned']) + expect(out).to.include('skip (tracker, needs --with-trackers): xchain-utxo-tracker:bitcoin:testnet') + expect(planLine(out)).to.include('publish plan (2)') + expect(out).to.not.include('FORCED') + }) + + // The forcing itself: --trackers-only would have dropped the decoder, but + // its published archive is from the pre-reset lineage, so it goes in anyway. + it('pulls a due combo into a plan that would have skipped it', function () { + fakeNode({ + combos: ['xchain-decoder:bitcoin:testnet', 'xchain-utxo-tracker:bitcoin:testnet'], + due: ['xchain-decoder:bitcoin:testnet'] + }) + const out = runPlan(['--all', '--trackers-only', '--dry-run', '--allow-unsigned']) + expect(out).to.include('FORCED (reindexed since last publish; overrides --trackers-only): xchain-decoder:bitcoin:testnet') + expect(planLine(out)).to.include('xchain-decoder:bitcoin:testnet') + }) + + // A due combo the registry no longer lists, or one an explicit invocation + // never named, still has a wrong archive standing as newest. + it('pulls in a due combo the resolved plan never contained', function () { + fakeNode({ + combos: ['xchain-decoder:bitcoin:testnet'], + due: ['xchain-indexer:litecoin:testnet'] + }) + const out = runPlan(['xchain-decoder:bitcoin:testnet', '--dry-run', '--allow-unsigned']) + expect(out).to.include('FORCED (reindexed since last publish; not in the resolved plan): xchain-indexer:litecoin:testnet') + expect(planLine(out)).to.include('publish plan (2)') + }) + + // A tracker create stops the container, so a nightly cron must not take the + // tracker down on its own initiative. It says so loudly instead, every run. + it('defers a due tracker but reports it on every run', function () { + fakeNode({ + combos: ['xchain-decoder:bitcoin:testnet', 'xchain-utxo-tracker:bitcoin:testnet'], + due: ['xchain-utxo-tracker:bitcoin:testnet'] + }) + const out = runPlan(['--all', '--dry-run', '--allow-unsigned']) + expect(out).to.include('DUE but DEFERRED (tracker create means downtime): xchain-utxo-tracker:bitcoin:testnet') + expect(out).to.include('serving a PRE-reindex archive') + expect(planLine(out)).to.include('publish plan (1)') + expect(planLine(out)).to.not.include('xchain-utxo-tracker') + }) + + it('republishes a due tracker when the operator accepts the downtime', function () { + fakeNode({ + combos: ['xchain-decoder:bitcoin:testnet', 'xchain-utxo-tracker:bitcoin:testnet'], + due: ['xchain-utxo-tracker:bitcoin:testnet'] + }) + const out = runPlan(['--all', '--dry-run', '--allow-unsigned', '--force-due-trackers']) + expect(out).to.include('FORCED (reindexed since last publish; overrides the tracker opt-in, DOWNTIME)') + expect(planLine(out)).to.include('xchain-utxo-tracker:bitcoin:testnet') + }) + + it('--no-forced-due falls back to the schedule alone', function () { + fakeNode({ + combos: ['xchain-decoder:bitcoin:testnet', 'xchain-utxo-tracker:bitcoin:testnet'], + due: ['xchain-utxo-tracker:bitcoin:testnet'] + }) + const out = runPlan(['--all', '--dry-run', '--allow-unsigned', '--no-forced-due']) + expect(out).to.include('skip (tracker, needs --with-trackers)') + expect(out).to.not.include('FORCED') + expect(out).to.not.include('DEFERRED') + }) + + // The due list is read from a file on disk and interpolated into the plan, + // so anything that is not a :: triple is dropped + // before it can reach a command line. + it('drops a due line that is not a plain combo triple', function () { + fakeNode({ + combos: ['xchain-decoder:bitcoin:testnet'], + due: [ + 'xchain-decoder:bitcoin:testnet; touch /tmp/xchain-pwned', + 'xchain-encoder:bitcoin:testnet', + 'not-a-combo' + ] + }) + const out = runPlan(['--all', '--dry-run', '--allow-unsigned']) + expect(out).to.not.include('pwned') + expect(out).to.not.include('xchain-encoder') + expect(planLine(out)).to.include('publish plan (1)') + }) + + it('does not fail the run when the node cannot list due combos', function () { + const p = path.join(binDir, 'xchain-node') + fs.writeFileSync(p, [ + '#!/usr/bin/env bash', + 'case "$1" in', + " bootstrap-combos) printf '%s\\n' 'xchain-decoder:bitcoin:testnet' ;;", + // An older pinned CLI on the fleet has no such subcommand. + ' *) echo "error: unknown command" >&2; exit 1 ;;', + 'esac', + '' + ].join('\n'), { mode: 0o755 }) + + const out = runPlan(['--all', '--dry-run', '--allow-unsigned']) + expect(planLine(out)).to.include('publish plan (1)') + }) +})