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
-
-
+
+
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)')
+ })
+})