diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 6241305c..061bdbb5 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,5 +1,9 @@
+## Base branch
+
+- [ ] This pull request targets `develop`. `master` only takes release merges, and a pull request into it from a fork cannot be merged.
+
## What this changes
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 39e7fe8c..ae312a09 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,7 +15,7 @@ on:
# so the merge commit a release tag points at has its own green run.
branches: [master, develop]
pull_request:
- branches: [master]
+ branches: [master, develop]
jobs:
ci:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 10f441c6..dcc8cf4c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.24.0] - 2026-10-17
+
+### Added
+- Scoped module database and user names to a non-default node prefix and proved the scope end to end.
+- Showed NOT READY in the process list from the indexer and decoder readiness verdicts.
+
+### Changed
+- Defaulted the bundled MariaDB buffer pool to 1G and kept it overridable.
+- Refused cross-major release downgrades and decoder or indexer setups that would share the default external database name.
+
+### Fixed
+- Built and signed the DOGE signer's reveal before broadcasting its commit, and kept the signed reveal when only its broadcast fails.
+- Classified containers by name only and preserved unreadable command locks.
+
+### Security
+- Replaced vulnerable Bitcoin signing dependencies and bumped js-yaml and brace-expansion.
+
+### Changed
+- Started the bundled MariaDB with a 1G InnoDB buffer pool by default, settable with `XCHAIN_NODE_DB_BUFFER_POOL_SIZE`.
+
## [0.23.0] - 2026-10-11
### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6dd58834..4d6b118c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -112,6 +112,8 @@ Match the existing log style: a concise subject line, then a short body explaini
## Pull requests
+Fork the repository, branch off `develop`, and open your pull request against `develop`. Keep history linear (rebase, don't merge). `master` is what production runs and only ever receives release merges from `develop`; a pull request into `master` from a fork or any other branch fails the `base-branch guard` check and cannot be merged.
+
CI is the unit gate. Before opening a PR:
1. Run the no-external-services tiers (`npm run ci`, `npm run test:security`) and confirm they pass.
diff --git a/README.md b/README.md
index 4277fbf6..77254f5c 100644
--- a/README.md
+++ b/README.md
@@ -111,7 +111,7 @@ membership on the explorer, is `xchain-documentation/operations/run-a-validator.
## Host environment variables
-Five env vars override where xchain-node stores its filesystem state. Set them in the shell or systemd unit before running `xchain-node install`. Each falls back to a path inside this repo if unset, so existing installs are unaffected.
+Six env vars override where xchain-node stores its filesystem state. Set them in the shell or systemd unit before running `xchain-node install`. The data, temporary, modules, crypto-node, and config directories fall back to paths inside this repo if unset, so existing installs are unaffected. The command lock defaults to `~/.xchain-node/command.lock`.
| Variable | What goes here |
|---|---|
@@ -120,17 +120,18 @@ Five env vars override where xchain-node stores its filesystem state. Set them i
| `XCHAIN_NODE_MODULES_DIR` | Git clones of sibling xchain-* repos |
| `XCHAIN_NODE_CRYPTO_NODES_DIR` | Downloaded coin-node binaries |
| `XCHAIN_NODE_CONFIG_DIR` | Generated per-service `.env` files |
+| `XCHAIN_NODE_LOCK_DIR` | Directory for the cross-process command lock (`command.lock` and its reclaim marker) |
On boxes with a small `/` partition and a large data volume (e.g., OVH RISE-3 with `/misc`), point `DATA_DIR` and `TMP_DIR` at the large volume before installing. Full docs in [CONFIGURATION.md](https://github.com/XChain-Platform/xchain-documentation/blob/master/components/node/configuration.md).
### Bundled MariaDB tuning
-When xchain-node manages its own MariaDB container, these optional env vars override server defaults. They are applied as `mysqld` startup args at install time, so they **persist across a container recreate** (a `conf.d` file edited inside a running container does not). Each is unset by default so image defaults remain unchanged. Set them before `xchain-node install`.
+When xchain-node manages its own MariaDB container, these optional env vars override server defaults. They are applied as `mysqld` startup args at install time, so they **persist across a container recreate** (a `conf.d` file edited inside a running container does not). Each is unset by default so image defaults remain unchanged, except `XCHAIN_NODE_DB_BUFFER_POOL_SIZE`, which defaults to `1G` (a value that is not digits with an optional `K`, `M` or `G` suffix logs a warning and falls back to `1G`), and `XCHAIN_NODE_DB_MAX_CONNECTIONS`, which defaults to `1000`. Set them before `xchain-node install`.
| Variable | mysqld setting | When to set |
|---|---|---|
| `XCHAIN_NODE_DB_DATA_DIR` | datadir bind-mount (`-v
:/var/lib/mysql`) | Pin the datadir to a fast NVMe mount instead of the Docker data-root |
-| `XCHAIN_NODE_DB_BUFFER_POOL_SIZE` | `innodb-buffer-pool-size` (e.g. `16G`) | Large/multi-DB hosts where the stock 128 MB thrashes on multi-GB datasets |
+| `XCHAIN_NODE_DB_BUFFER_POOL_SIZE` | `innodb-buffer-pool-size` (e.g. `16G`) | Hosts that need more (or less) than the 1G default; the stock 128 MB thrashes on multi-GB datasets |
| `XCHAIN_NODE_DB_MAX_CONNECTIONS` | `max-connections` (e.g. `300`) | Many connection pools against one DB (replicas, shared services) |
| `XCHAIN_NODE_DB_FLUSH_LOG_AT_TRX_COMMIT` | `innodb-flush-log-at-trx-commit` (e.g. `2`) | Replica/cache DBs where a 1-second crash window is acceptable for speed |
@@ -138,13 +139,13 @@ When xchain-node manages its own MariaDB container, these optional env vars over
Every persistent service container carries a Docker healthcheck, but Docker itself takes no action on the `unhealthy` state (`--restart unless-stopped` only fires when the process exits). An alive-but-stalled service would otherwise stay wedged until an operator notices it in `docker ps`. `xchain-node autoheal` closes that loop: it restarts containers that have been continuously unhealthy past a grace window, for services opted in via `autoheal: true` in the healthcheck table (currently the decoder and indexer; the utxo-tracker is deliberately excluded because it halts on purpose rather than exiting, and a restart would just re-halt it; the encoder is deliberately excluded because its health probe reports utxo-tracker readiness (unreachable, halted, lagging or mempool not ready), which restarting the encoder cannot clear).
-The command is one-shot and never prompts or daemonizes. Unattended remediation requires wiring it to a cron entry or systemd timer, e.g. `*/5 * * * * xchain_node autoheal`. Detection-to-restart latency is the timer interval plus the health retry budget plus the grace window. Use `--dry-run` to see restart candidates without acting. Exit code is non-zero only when a restart was attempted and failed.
+The command is one-shot and never prompts or daemonizes. Unattended remediation requires wiring it to a cron entry or systemd timer, e.g. `*/5 * * * * xchain_node autoheal`. Detection-to-restart latency is the timer interval plus the health retry budget plus the grace window. Use `--dry-run` to see restart candidates without acting. Exit codes: `1` when a restart was attempted and failed, `2` when a container has been restarted twice this episode without recovering (a wedge restarts are not clearing; investigate), and `0` otherwise. The escalation also logs a warning on stderr, so a systemd `OnFailure=` hook trips on it and cron mails it even when stdout is discarded. It repeats on every pass until the container recovers.
| Variable | Default | What it controls |
|---|---|---|
| `XCHAIN_NODE_AUTOHEAL_GRACE_MS` | `120000` | How long a container must be continuously unhealthy before a restart is considered |
| `XCHAIN_NODE_AUTOHEAL_COOLDOWN_MS` | `600000` | Base wait before autoheal restarts the same container again (anti-flap). The first retry waits this long; each further restart of a container that has not recovered doubles the wait, up to `XCHAIN_NODE_AUTOHEAL_COOLDOWN_CEILING_MS` (with defaults: 10m, 20m, 40m and so on, capped at 6h). The doubling resets once the container recovers |
-| `XCHAIN_NODE_AUTOHEAL_COOLDOWN_CEILING_MS` | `21600000` | Upper bound on the doubled cooldown. A container that never recovers settles at one restart per ceiling; there is no attempt cap, and every skipped pass logs an "investigate" line |
+| `XCHAIN_NODE_AUTOHEAL_COOLDOWN_CEILING_MS` | `21600000` | Upper bound on the doubled cooldown. A container that never recovers settles at one restart per ceiling; there is no attempt cap, and from the second unrecovered restart on every pass logs an "investigate" warning and exits `2` |
| `XCHAIN_NODE_AUTOHEAL_PROBATION_MS` | `150000` | How long after an autoheal restart a passing health probe counts as that restart's own artifact rather than recovery, so it does not reset the backoff. If you widen a service's start period with `XCHAIN_NODE_HEALTH_START_PERIOD_`, widen this to match (start period plus interval times retries, plus margin) |
| `XCHAIN_NODE_AUTOHEAL_STATE_DIR` | `~/.xchain-node` | Where the state file (`autoheal-state.json`) lives: last restart times, unhealthy onsets and the per-container restart count that drives the backoff. Falls back to `XCHAIN_NODE_LOCK_DIR` when that is set, otherwise `~/.xchain-node` |
diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json
index 6ca99b22..a76c0117 100644
--- a/bin/pins/at1-suite-titles.json
+++ b/bin/pins/at1-suite-titles.json
@@ -772,12 +772,6 @@
"validator stake free-key rules resolves the reuse gate COIN:network first, then the bare network",
"validator stake free-key rules sums amounts exactly in 1e-8 units and refuses to guess on a malformed one"
],
- "332b1a20f0f85580": [
- "ValidatorService coin wallets the emitted signer marks post-funding failures does not tag a successful two-phase publish",
- "ValidatorService coin wallets the emitted signer marks post-funding failures is valid JavaScript once the template literal is expanded",
- "ValidatorService coin wallets the emitted signer marks post-funding failures leaves a pre-funding failure untagged, so the round stays retryable",
- "ValidatorService coin wallets the emitted signer marks post-funding failures tags a definitive phase-2 rejection with fundsCommitted and the phase-1 txid"
- ],
"332bea463a49f99c": [
"Regression Suite [regression:p0] Security R-SEC-001: branch name with shell metacharacters rejected",
"Regression Suite [regression:p0] Security R-SEC-002: branch name with backticks and $() rejected",
@@ -1490,6 +1484,18 @@
"makeBootstrap() consults the source health gate gates the utxo-tracker BEFORE stopping its container, so a refusal costs no downtime",
"makeBootstrap() consults the source health gate still rejects an unsupported module before consulting the gate"
],
+ "55d86c81bb4903d3": [
+ "ValidatorService coin wallets the emitted signer builds and signs the reveal before the commit broadcast broadcasts nothing when the encoder refuses the reveal",
+ "ValidatorService coin wallets the emitted signer builds and signs the reveal before the commit broadcast broadcasts nothing when the reveal cannot be signed",
+ "ValidatorService coin wallets the emitted signer builds and signs the reveal before the commit broadcast builds no reveal for a single-transaction encoding",
+ "ValidatorService coin wallets the emitted signer builds and signs the reveal before the commit broadcast builds the reveal from the signed commit, then broadcasts the commit and the reveal",
+ "ValidatorService coin wallets the emitted signer builds and signs the reveal before the commit broadcast hands back the signed reveal when its broadcast fails after the commit",
+ "ValidatorService coin wallets the emitted signer builds and signs the reveal before the commit broadcast leaves a commit broadcast failure untagged for the hub to classify",
+ "ValidatorService coin wallets the emitted signer marks post-funding failures does not tag a successful two-phase publish",
+ "ValidatorService coin wallets the emitted signer marks post-funding failures is valid JavaScript once the template literal is expanded",
+ "ValidatorService coin wallets the emitted signer marks post-funding failures marks a pre-funding failure neverSent and not fundsCommitted, so the round stays retryable",
+ "ValidatorService coin wallets the emitted signer marks post-funding failures tags a definitive phase-2 rejection with fundsCommitted, the phase-1 txid and the signed reveal"
+ ],
"56897dc4174a8788": [
"Chaos: Download Resilience Experiment 8c: SHA-256 hash mismatch succeeds when hash matches exactly",
"Chaos: Download Resilience Experiment 8c: SHA-256 hash mismatch throws with expected vs actual hash on mismatch"
@@ -5112,7 +5118,7 @@
"test/unit/validator_service.test/11_validator_dir.test.js": "26f27dbd3b14ec37",
"test/unit/validator_service.test/12_coin_wallets.test.js": "6d7114a33630b804",
"test/unit/validator_service.test/13_coin_wallet_options_and_mounts.test.js": "3670f7eb60a0bd6e",
- "test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js": "332b1a20f0f85580",
+ "test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js": "55d86c81bb4903d3",
"test/unit/validator_service.test/15_hub_env_wiring.test.js": "1dc33a33679f1947",
"test/unit/validator_service.test/16_rollcall_status_reporting.test.js": "cb99d238d302d2c2",
"test/unit/validator_service.test/17_capability_drift_probe.test.js": "1ee619322512f5f5",
@@ -5536,7 +5542,7 @@
"test/unit/validator_service.test/11_validator_dir.test.js": "26f27dbd3b14ec37",
"test/unit/validator_service.test/12_coin_wallets.test.js": "6d7114a33630b804",
"test/unit/validator_service.test/13_coin_wallet_options_and_mounts.test.js": "3670f7eb60a0bd6e",
- "test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js": "332b1a20f0f85580",
+ "test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js": "55d86c81bb4903d3",
"test/unit/validator_service.test/15_hub_env_wiring.test.js": "1dc33a33679f1947",
"test/unit/validator_service.test/16_rollcall_status_reporting.test.js": "cb99d238d302d2c2",
"test/unit/validator_service.test/17_capability_drift_probe.test.js": "1ee619322512f5f5",
diff --git a/bin/pins/identity.json b/bin/pins/identity.json
index a6bde403..90448167 100644
--- a/bin/pins/identity.json
+++ b/bin/pins/identity.json
@@ -2,8 +2,8 @@
"repo": "xchain-node",
"vendored": "src/coins (sync-coins.sh)",
"files": {
- "src/coins/BTC.js": "182baf5173e3846b94a5edc1ae15913a54fb266addfc45a3cf9a013bd9ec16c6",
- "src/coins/DOGE.js": "4659b45fb7a5c959ce2777ab9d5e7c193f3515f03cdd8cd23bb6b9c4d012fc11",
+ "src/coins/BTC.js": "6868afc2d18911ba112234c980c094fda71cfc3e744a00f493def781c7610f8d",
+ "src/coins/DOGE.js": "29ac02299b0c779f4e3eda579eeaa9f673c454b73cfe9978a35f03f0e9aed33c",
"src/coins/LTC.js": "6155195bfa5fdfeab77dec3802ce67b5cc47a77695e81a4a5545d980e4c421dd",
"src/coins/consensus_pin.js": "342bf7af224a19ea0a98ff9cfb2c9a70b3fd5be75de7ecef2db05936f82baa63",
"src/coins/index.js": "08b678d934fa647d3c7d14dc5ccdaeb86722ee32a3662de69e4a31cd7b09234c"
diff --git a/package-lock.json b/package-lock.json
index 738b327f..34419d64 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "xchain-node",
- "version": "0.23.0",
+ "version": "0.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xchain-node",
- "version": "0.23.0",
+ "version": "0.24.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@dankest-llc/xchain-sdk": "^0.18.0",
@@ -16,7 +16,7 @@
"dotenv": "^16.4.5",
"enquirer": "^2.4.1",
"follow-redirects": "^1.16.0",
- "mariadb": "3.5.3",
+ "mariadb": "3.5.4",
"semver": "^7.6.3"
},
"bin": {
@@ -800,6 +800,16 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@exodus/bitcoinerlab-secp256k1": {
+ "version": "1.0.5-exodus.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bitcoinerlab-secp256k1/-/bitcoinerlab-secp256k1-1.0.5-exodus.1.tgz",
+ "integrity": "sha512-vk4WBauCD9YmH4nO7loye6gpoFUAFlx9D+s3/1M5ayp9AD9yBRNTujb2lgWeZuJbyeJpiAJBEKwLS1QrVJdwrg==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "^1.1.5",
+ "@noble/secp256k1": "^1.7.1"
+ }
+ },
"node_modules/@hono/node-server": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz",
@@ -1426,6 +1436,18 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@noble/secp256k1": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.2.tgz",
+ "integrity": "sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1886,15 +1908,6 @@
"integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==",
"license": "MIT"
},
- "node_modules/bindings": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
- "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
- "license": "MIT",
- "dependencies": {
- "file-uri-to-path": "1.0.0"
- }
- },
"node_modules/bip174": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/bip174/-/bip174-2.1.1.tgz",
@@ -1904,15 +1917,6 @@
"node": ">=8.0.0"
}
},
- "node_modules/bip66": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/bip66/-/bip66-1.1.5.tgz",
- "integrity": "sha512-nemMHz95EmS38a26XbbdxIYj5csHd3RMP3H5bwQknX0WYHF01qhpufP42mLOwVICuH2JmhIhXiWs89MfUGL7Xw==",
- "license": "MIT",
- "dependencies": {
- "safe-buffer": "^5.0.1"
- }
- },
"node_modules/bitcoinjs-lib": {
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.7.tgz",
@@ -1931,20 +1935,20 @@
}
},
"node_modules/bitcoinjs-message": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/bitcoinjs-message/-/bitcoinjs-message-2.2.0.tgz",
- "integrity": "sha512-103Wy3xg8Y9o+pdhGP4M3/mtQQuUWs6sPuOp1mYphSUoSMHjHTlkj32K4zxU8qMH0Ckv23emfkGlFWtoWZ7YFA==",
+ "name": "@oipwg/bitcoinjs-message",
+ "version": "2.2.0-beta.0",
+ "resolved": "https://registry.npmjs.org/@oipwg/bitcoinjs-message/-/bitcoinjs-message-2.2.0-beta.0.tgz",
+ "integrity": "sha512-wh+9TYx8hGVcjjydM2XkwBuqqkE6EAs0yGb3xDzr1cl16vjo/X6JrmRl5Ti/SH46PnFDDD3MScNAKjzeNZdvpA==",
"license": "MIT",
"dependencies": {
"bech32": "^1.1.3",
"bs58check": "^2.1.2",
- "buffer-equals": "^1.0.3",
- "create-hash": "^1.1.2",
- "secp256k1": "^3.0.1",
- "varuint-bitcoin": "^1.0.1"
+ "create-hash": "^1.2.0",
+ "secp256k1": "^4.0.0",
+ "varuint-bitcoin": "^1.1.2"
},
"engines": {
- "node": ">=0.10"
+ "node": ">=0.12"
}
},
"node_modules/bitcoinjs-message/node_modules/base-x": {
@@ -2038,9 +2042,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "5.0.9",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
- "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2063,20 +2067,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/browserify-aes": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
- "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
- "license": "MIT",
- "dependencies": {
- "buffer-xor": "^1.0.3",
- "cipher-base": "^1.0.0",
- "create-hash": "^1.1.0",
- "evp_bytestokey": "^1.0.3",
- "inherits": "^2.0.1",
- "safe-buffer": "^5.0.1"
- }
- },
"node_modules/browserslist": {
"version": "4.28.7",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
@@ -2130,21 +2120,6 @@
"bs58": "^5.0.0"
}
},
- "node_modules/buffer-equals": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/buffer-equals/-/buffer-equals-1.0.4.tgz",
- "integrity": "sha512-99MsCq0j5+RhubVEtKQgKaD6EM+UP3xJgIvQqwJ3SOLDUekzxMX1ylXBng+Wa2sh7mGT0W6RUly8ojjr1Tt6nA==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/buffer-xor": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz",
- "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==",
- "license": "MIT"
- },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2562,20 +2537,6 @@
"sha.js": "^2.4.0"
}
},
- "node_modules/create-hmac": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
- "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
- "license": "MIT",
- "dependencies": {
- "cipher-base": "^1.0.3",
- "create-hash": "^1.1.0",
- "inherits": "^2.0.1",
- "ripemd160": "^2.0.0",
- "safe-buffer": "^5.0.1",
- "sha.js": "^2.4.8"
- }
- },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2724,20 +2685,6 @@
"url": "https://dotenvx.com"
}
},
- "node_modules/drbg.js": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/drbg.js/-/drbg.js-1.0.1.tgz",
- "integrity": "sha512-F4wZ06PvqxYLFEZKkFxTDcns9oFNk34hvmJSEwdzsxVQ8YI5YaxtACgQatkYgv2VI2CFkUd2Y+xosPQnHv809g==",
- "license": "MIT",
- "dependencies": {
- "browserify-aes": "^1.0.6",
- "create-hash": "^1.1.2",
- "create-hmac": "^1.1.4"
- },
- "engines": {
- "node": ">=0.10"
- }
- },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3149,16 +3096,6 @@
"node": ">=18.0.0"
}
},
- "node_modules/evp_bytestokey": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
- "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==",
- "license": "MIT",
- "dependencies": {
- "md5.js": "^1.3.4",
- "safe-buffer": "^5.1.1"
- }
- },
"node_modules/execa": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
@@ -3332,9 +3269,9 @@
}
},
"node_modules/fast-uri": {
- "version": "3.1.7",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
- "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [
{
"type": "github",
@@ -3386,12 +3323,6 @@
"node": ">=16.0.0"
}
},
- "node_modules/file-uri-to-path": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
- "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
- "license": "MIT"
- },
"node_modules/fill-keys": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz",
@@ -3859,9 +3790,9 @@
}
},
"node_modules/hono": {
- "version": "4.13.5",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz",
- "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==",
+ "version": "4.13.13",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz",
+ "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -3976,9 +3907,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
- "version": "10.7.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
- "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
+ "version": "10.7.4",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz",
+ "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -4240,9 +4171,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
- "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@@ -4471,9 +4402,9 @@
}
},
"node_modules/mariadb": {
- "version": "3.5.3",
- "resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.5.3.tgz",
- "integrity": "sha512-i053Kc0MgdUv/hu9mCyq67TYfPXFj3/MV8I7ZW5wvJNixIyXC0VztMPUjIVj/449nQo+BsxFD4Fdk/sA/uqKPQ==",
+ "version": "3.5.4",
+ "resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.5.4.tgz",
+ "integrity": "sha512-3m0vdfgRkjle5/9hHl4R7rlVvnDA7QfuFSNkdIAQNROWKOS0J3b9IQK9HaZSCGEVKiDUbFXFiINfC+cmR5P/aA==",
"license": "LGPL-2.1-or-later",
"dependencies": {
"@types/geojson": "^7946.0.16",
@@ -4736,12 +4667,6 @@
"node": "^20.17.0 || >=22.9.0"
}
},
- "node_modules/nan": {
- "version": "2.28.0",
- "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
- "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
- "license": "MIT"
- },
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -5342,23 +5267,18 @@
"license": "MIT"
},
"node_modules/secp256k1": {
- "version": "3.8.1",
- "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-3.8.1.tgz",
- "integrity": "sha512-tArjQw2P0RTdY7QmkNehgp6TVvQXq6ulIhxv8gaH6YubKG/wxxAoNKcbuXjDhybbc+b2Ihc7e0xxiGN744UIiQ==",
+ "name": "@exodus/secp256k1",
+ "version": "5.0.0-exodus.4.patch.2",
+ "resolved": "https://registry.npmjs.org/@exodus/secp256k1/-/secp256k1-5.0.0-exodus.4.patch.2.tgz",
+ "integrity": "sha512-GMHXCyLdwttG99gsDPL8qhPeGSXvzOTLgcCIg1CcsZHaarJQx8Le5q1w99dcUnEizS+uH6gQfumP1qD1BdvIRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
- "bindings": "^1.5.0",
- "bip66": "^1.1.5",
- "bn.js": "^4.11.8",
- "create-hash": "^1.2.0",
- "drbg.js": "^1.0.1",
- "elliptic": "^6.5.7",
- "nan": "^2.14.0",
- "safe-buffer": "^5.1.2"
+ "@exodus/bitcoinerlab-secp256k1": "^1.0.5-exodus.1",
+ "elliptic": "^6.5.2"
},
"engines": {
- "node": ">=4.0.0"
+ "node": ">=10.0.0"
}
},
"node_modules/seedrandom": {
diff --git a/package.json b/package.json
index f71ad457..a7c87548 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "xchain-node",
- "version": "0.23.0",
+ "version": "0.24.0",
"private": true,
"description": "xchain-node allows users to install, configure and run XChain platform nodes.",
"license": "AGPL-3.0-or-later",
@@ -17,7 +17,7 @@
"dotenv": "^16.4.5",
"enquirer": "^2.4.1",
"follow-redirects": "^1.16.0",
- "mariadb": "3.5.3",
+ "mariadb": "3.5.4",
"semver": "^7.6.3"
},
"scripts": {
@@ -67,7 +67,11 @@
"form-data": "^4.0.6",
"ip-address": "^10.3.1",
"decimal.js": "10.4.3",
- "@modelcontextprotocol/sdk": "^1.31.0"
+ "@modelcontextprotocol/sdk": "^1.31.0",
+ "bitcoinjs-message": {
+ ".": "npm:@oipwg/bitcoinjs-message@2.2.0-beta.0",
+ "secp256k1": "npm:@exodus/secp256k1@5.0.0-exodus.4.patch.2"
+ }
},
"bin": {
"xchain-node": "./src/index.js"
diff --git a/scripts/publish-bootstraps.sh b/scripts/publish-bootstraps.sh
index ac87919a..9932ddf3 100755
--- a/scripts/publish-bootstraps.sh
+++ b/scripts/publish-bootstraps.sh
@@ -26,8 +26,9 @@
# can verify provenance against the pinned public key.
# - Refuses an unhealthy source. `bootstrap create` runs a source health gate:
# a service that is stopped, crash-looping, reporting unhealthy,
-# materially behind its tip, or carrying a durable halt marker (a decoder
-# REORG_HALT row, an uncleared sync_halt) is NOT snapshotted. Such a combo is
+# materially behind its tip, or carrying a durable halt marker (a live
+# decoder REORG_HALT row that no newer REORG_HALT_CLEARED supersedes, an
+# uncleared sync_halt) is NOT snapshotted. Such a combo is
# summarised as SOURCE-UNHEALTHY and the run exits non-zero, leaving the last
# good archive in place as the newest. This exists because the weekly cron
# once published a halted litecoin/mainnet decoder as the newest "good"
diff --git a/src/cli.js b/src/cli.js
index 7740cb51..d393aadd 100644
--- a/src/cli.js
+++ b/src/cli.js
@@ -67,11 +67,15 @@ const loadModule = require
// problem with better context.
function refForPreCheck(commandName, actionCommand) {
if (commandName !== 'install' && commandName !== 'update') return null
+ let branch
try {
- return resolveArgs(actionCommand.args || [], { expectBranch: true, defaultBranch: null }).branch
+ branch = resolveArgs(actionCommand.args || [], { expectBranch: true, defaultBranch: null }).branch
} catch {
return null
}
+ // Stage a no-ref update's hub at the tag self-update handed on, the one updateModules installs at.
+ if (!branch && commandName === 'update') return config.XCHAIN_NODE_UPDATE_TARGET || null
+ return branch
}
// The verbs that can put the hub container back: they rebuild it, recreate it
@@ -165,8 +169,8 @@ async function maybeSelfUpdateBeforeUpdate(args, deps = {}) {
}
// Serialized like every mutator, so two concurrent updates cannot both
- // move the checkout. The parent releases before spawning; the re-exec child
- // takes the normal command lock and refuses if another mutator gets there first.
+ // move the checkout. The parent releases before spawning; the update run that re-takes
+ // the lock waits at least SELF_UPDATE_HANDOFF_WAIT_MS, refusing only if it is still held then.
const lock = deps.acquireCommandLock || acquireCommandLock
const release = lock({ command: 'update (self-update)', waitMs: dispatchSettings(config).MUTATING_LOCK_WAIT_MS })
let outcome
diff --git a/src/cli/commands.js b/src/cli/commands.js
index 6565491b..3b53b1e2 100644
--- a/src/cli/commands.js
+++ b/src/cli/commands.js
@@ -26,6 +26,7 @@ const { assertArchiveIdentity } = require('../services/bootstrap_service/restore
const { getWorkDir } = require('../services/bootstrap_service/workspace')
const database = require('../services/database_service')
const { getDefaultConfig, getModuleDatabaseName } = require('../services/config_service')
+const { configuredDatabaseName } = require('../utils/module_database_name')
const { dockerMariadbArgs, mariadbEnv } = require('../utils/docker_mariadb')
const { getLogger } = require('../observability/logger')
@@ -85,7 +86,6 @@ function showRowCount(stdout, table) {
}
return count
}
-
async function databaseCounts(context, databaseName) {
const count = async table => {
const { stdout } = await runMariaTool(context, 'mariadb-show', ['--count', databaseName, table])
@@ -97,7 +97,6 @@ async function databaseCounts(context, databaseName) {
issuances: await count('issues')
}
}
-
async function importRehearsal(context, innerArchive) {
const command = mariaTool(context, 'mariadb', [rehearsalDatabase], true)
const maria = spawn(command.file, command.args,
@@ -113,15 +112,18 @@ async function importRehearsal(context, innerArchive) {
closed
])
}
-
function countsMatch(source, restored) {
return source.rows === restored.rows && source.tokens === restored.tokens &&
source.issuances === restored.issuances
}
-
+async function rehearsalSourceDatabaseName(coin, network) {
+ const module = config.XChainService.XCHAIN_INDEXER
+ const moduleConfig = await getDefaultConfig(module, coin, network)
+ return configuredDatabaseName(module, moduleConfig, getModuleDatabaseName(module, coin, network))
+}
async function rehearseBootstrapRestore(coin, network, options) {
if (network !== 'mainnet') throw new Error('Bootstrap restore rehearsal requires mainnet')
- const sourceName = getModuleDatabaseName(config.XChainService.XCHAIN_INDEXER, coin, network)
+ const sourceName = await rehearsalSourceDatabaseName(coin, network)
const archive = await rehearsalArchive(coin, network, options.file)
const context = await rehearsalContext(coin, network)
let created = false
@@ -143,7 +145,6 @@ async function rehearseBootstrapRestore(coin, network, options) {
}
}
}
-
function registerBootstrapRestoreRehearsal(program, deps) {
program.command('bootstrap-restore-rehearsal')
.description('Restore a mainnet indexer bootstrap into a disposable database and compare counts')
@@ -396,4 +397,4 @@ function registerToolCommands(program, deps) {
registerBootstrapRestoreRehearsal(program, deps)
}
-module.exports = { registerPrimaryCommands, registerToolCommands, showRowCount, countsMatch }
+module.exports = { registerPrimaryCommands, registerToolCommands, showRowCount, countsMatch, rehearsalSourceDatabaseName }
diff --git a/src/cli/dispatch.js b/src/cli/dispatch.js
index bff001ca..a895bf7c 100644
--- a/src/cli/dispatch.js
+++ b/src/cli/dispatch.js
@@ -160,7 +160,8 @@ function commandLock(commandName, settings, acquireCommandLock,
// the action uses rather than "the first positional", because the
// args are order-independent and only resolveArgs knows which one
// is a ref (`install regtest` names a network, not a branch). A
-// command that names no ref, or an arg shape resolveArgs refuses,
+// no-ref update takes the tag its self-update step handed on; any
+// other command naming no ref, or an arg shape resolveArgs refuses,
// yields null and the previous default-branch behaviour.
//
// Whether this command could bring a crash-looping hub back, which
diff --git a/src/cli/options.js b/src/cli/options.js
index ccce9d22..4d2db95e 100644
--- a/src/cli/options.js
+++ b/src/cli/options.js
@@ -47,7 +47,7 @@ function registerSync(program, deps) {
}
function registerStart(program, deps) {
- const { filterCommandParameters, startModules } = deps
+ const { filterCommandParameters, startModules, redactSecrets } = deps
program
.command('start')
.description('Start XChain service')
@@ -56,14 +56,20 @@ function registerStart(program, deps) {
.argument('[network]', '(mainnet, testnet, regtest, all)')
.action(async (service, chain, network) => {
const serviceList = filterCommandParameters(null, service, chain, network)
- await startModules(serviceList)
+ // Every selected module is still attempted; a failed one only changes the exit status.
+ try {
+ await startModules(serviceList)
+ } catch (err) {
+ console.error('start failed: ' + redactSecrets(err && err.message ? err.message : err))
+ return process.exit(1)
+ }
return process.exit(0)
})
}
function registerStop(program, deps) {
- const { filterCommandParameters, stopModules } = deps
+ const { filterCommandParameters, stopModules, redactSecrets } = deps
program
.command('stop')
.description('Stop XChain service')
@@ -72,14 +78,20 @@ function registerStop(program, deps) {
.argument('[network]', '(mainnet, testnet, regtest, all)')
.action(async (service, chain, network) => {
const serviceList = filterCommandParameters(null, service, chain, network)
- await stopModules(serviceList)
+ // Every selected module is still attempted; a failed one only changes the exit status.
+ try {
+ await stopModules(serviceList)
+ } catch (err) {
+ console.error('stop failed: ' + redactSecrets(err && err.message ? err.message : err))
+ return process.exit(1)
+ }
return process.exit(0)
})
}
function registerRestart(program, deps) {
- const { filterCommandParameters, restartModules } = deps
+ const { filterCommandParameters, restartModules, redactSecrets } = deps
program
.command('restart')
.description('Restart XChain service')
@@ -88,7 +100,13 @@ function registerRestart(program, deps) {
.argument('[network]', '(mainnet, testnet, regtest, all)')
.action(async (service, chain, network) => {
const serviceList = filterCommandParameters(null, service, chain, network)
- await restartModules(serviceList)
+ // Every selected module is still attempted; a failed one only changes the exit status.
+ try {
+ await restartModules(serviceList)
+ } catch (err) {
+ console.error('restart failed: ' + redactSecrets(err && err.message ? err.message : err))
+ return process.exit(1)
+ }
return process.exit(0)
})
@@ -101,12 +119,12 @@ function registerAutoheal(program, deps) {
.description('Restart containers stuck in the Docker "unhealthy" state (opt-in per service); one-shot, cron/timer safe')
.option('--dry-run', 'report restart candidates without acting')
.action(async (options) => {
- const { runAutoheal } = loadModule('./services/autoheal_service')
+ const { runAutoheal, autohealExitCode } = loadModule('./services/autoheal_service')
const result = await runAutoheal({ dryRun: options.dryRun ?? false })
- // Exit non-zero ONLY when a restart was attempted and failed, so a
- // timer unit can alert on real remediation failures without paging
- // on "nothing to do" passes.
- return process.exit(result.failed.length > 0 ? 1 : 0)
+ // Exit 1 when a restart was attempted and failed, 2 when a container
+ // outlived two restarts this episode (the escalation), and 0 otherwise,
+ // so a timer unit alerts on both without paging on "nothing to do" passes.
+ return process.exit(autohealExitCode(result))
})
}
diff --git a/src/coins/BTC.js b/src/coins/BTC.js
index b50f3d4a..8dfbdfbc 100644
--- a/src/coins/BTC.js
+++ b/src/coins/BTC.js
@@ -162,10 +162,19 @@ module.exports = {
// plus airdropSetHash - the sha256 over the canonical `name:hash:amount` lines,
// which genesis.js verifies before it credits anything - followed by a
// bin/sync-coins.sh re-vendoring wave.
+ // Both hashes re-pinned 2026-10-13: the name registrations BASE,
+ // DASH, HBAR, HOOD, HYPE and NEAR left the CSV manifest so the bridge owns
+ // those chain roots, six rows out of 121,716. They were supply-0, balance-free
+ // name rows with no subassets and no post-genesis action on either chain, so
+ // nothing else in the manifest or the dump moved: the dump loses only those six
+ // names' rows and its ledger and contracts block hashes are unchanged.
+ // The six stay reserved names (RESERVED_FUTURE_ROOTS in xchain-indexer, operator
+ // ruling 2026-10-17): with no row left, a new ISSUE of any of them is refused
+ // wherever TICK_NAMESPACE_ACTIVATION binds (unarmed on mainnet today).
genesis: {
block: 950000,
- ledgerHash: 'f347a0499654b128dd0461441ed6341ac27a24b909d6ff6e3995db4e69dc23e5',
- dumpHash: 'e416c382dd6951ce5c4da7e053f6dbb407ca3d966a513209eb59886dbe95e738',
+ ledgerHash: '13aeeebb9f2e27d507ca96fb7c54a1bfa98b9cc80c3feebd5d1ad022ce7e2b73',
+ dumpHash: '85431c2c41d6442e38aa78af804148dd6ead207aca036f6e44fbf2bee8c2846e',
airdropPaths: [],
airdropHashes: [],
airdropAmounts: [],
diff --git a/src/coins/DOGE.js b/src/coins/DOGE.js
index 787327f3..41e51c0f 100644
--- a/src/coins/DOGE.js
+++ b/src/coins/DOGE.js
@@ -93,10 +93,19 @@ module.exports = {
// plus airdropSetHash - the sha256 over the canonical `name:hash:amount` lines,
// which genesis.js verifies before it credits anything - followed by a
// bin/sync-coins.sh re-vendoring wave.
+ // Both hashes re-pinned 2026-10-13: the name registrations BASE,
+ // DASH, HBAR, HOOD, HYPE and NEAR left the CSV manifest so the bridge owns
+ // those chain roots, six rows out of 42,704. They were supply-0, balance-free
+ // name rows with no subassets and no post-genesis action on either chain, so
+ // nothing else in the manifest or the dump moved: the dump loses only those six
+ // names' rows and its ledger and contracts block hashes are unchanged.
+ // The six stay reserved names (RESERVED_FUTURE_ROOTS in xchain-indexer, operator
+ // ruling 2026-10-17): with no row left, a new ISSUE of any of them is refused
+ // wherever TICK_NAMESPACE_ACTIVATION binds (unarmed on mainnet today).
genesis: {
block: 6240000,
- ledgerHash: '87abac0b03cbd24694f7c5e666425bdae6f9a9c51b826826bd4848bd4ade991b',
- dumpHash: '81d3d2cb4714bc40ec12c2d1c8573040858ddf66e4ba89a05d44d86d3bffa0f1',
+ ledgerHash: '888fc0be89d4ac58dff24ef5b02c8ccfb947cef9c3d4bc3b28db2202e68da5df',
+ dumpHash: '9aae4cdf2bc14de119e089c267d442aec4671e01f6139aff486460798bb96a92',
airdropPaths: [],
airdropHashes: [],
airdropAmounts: [],
diff --git a/src/config/env_views.js b/src/config/env_views.js
index 26bbfdde..ff600664 100644
--- a/src/config/env_views.js
+++ b/src/config/env_views.js
@@ -52,6 +52,7 @@ const LIST_VIEWS = {
"ORACLE_BATCH_GRACE_MS", "ORACLE_BATCH_SIGN_TIMEOUT_MS",
"ORACLE_BATCH_BUFFER_MAX_ROUNDS", "ORACLE_BATCH_LANDING_RESERVE_MS",
"ATTEST_RESPONSE_FORWARD_S_OVERRIDE", "ATTEST_BATCH_WINDOW_S_OVERRIDE",
+ "ORACLE_PUBLISH_ENABLED", "ATTEST_BATCH_PUBLISH_ENABLED",
"HUB_RATE_LIMIT_RPM", "HUB_RATE_LIMIT_EXEMPT_LOCAL", "XCHAIN_PRICE_INDEXER_DB_HOST",
"XCHAIN_PRICE_INDEXER_DB_PORT", "XCHAIN_PRICE_INDEXER_DB_NAME",
"XCHAIN_PRICE_INDEXER_DB_USER", "XCHAIN_PRICE_INDEXER_DB_PASS",
@@ -61,8 +62,11 @@ const LIST_VIEWS = {
"XC_ROLLCALL_REGTEST_ACTIVATION", "XC_ROLLCALL_GATES_REGTEST_ACTIVATION",
"XC_MIRROR_ADMISSION_ACTIVATION", "XC_ANCHOR_FOLD_REGTEST_ACTIVATION",
"XC_ANCHOR_STAKE_REGTEST_ACTIVATION", "XC_ANCHOR_SLASH_REGTEST_ACTIVATION",
- "XCHAIN_PRICE_INDEXER_DB_SECRET"
+ "XC_OFFER_LIST_REGTEST_ACTIVATION", "XCHAIN_PRICE_INDEXER_DB_SECRET"
]],
+ // The host names the xchain-sync server container is handed (defaults.js
+ // configureSharedAfterHubKey), so a recreate keeps the operator's value.
+ SYNC_PASSTHROUGH_ENV: ['sync passthrough', ["INDEX_MAP_PARITY_CHECK"]],
// The indexer's regtest genesis bootstrap (ConfigService genesisPassthroughVars).
INDEXER_GENESIS_ENV: ['indexer genesis', [
"XCHAIN_GENESIS_BLOCK", "XCHAIN_GENESIS_LEDGER_HASH", "XCHAIN_GENESIS_DUMP_HASH",
@@ -77,7 +81,8 @@ const LIST_VIEWS = {
"XC_ROLLCALL_GATES_REGTEST_ACTIVATION", "HUB_SYNC_ANCHOR_ATTEST_GRACE_S",
"HUB_PRICE_SYNC_TIMEOUT_MS", "XCHAIN_COINPAY_EXPIRATION_S",
"XC_MIRROR_ADMISSION_ACTIVATION", "XC_ANCHOR_FOLD_REGTEST_ACTIVATION",
- "XC_ANCHOR_STAKE_REGTEST_ACTIVATION", "XC_ANCHOR_SLASH_REGTEST_ACTIVATION"
+ "XC_ANCHOR_STAKE_REGTEST_ACTIVATION", "XC_ANCHOR_SLASH_REGTEST_ACTIVATION",
+ "EXACT_INTEGER_WIRE_FIELDS_REGTEST_TIME"
]],
// The hub-sync watermark graces a regtest indexer is handed (hubSyncRegtestGraceVars).
HUB_SYNC_GRACE_ENV: ['hub sync grace', [
diff --git a/src/db/migration_ledger.js b/src/db/migration_ledger.js
new file mode 100644
index 00000000..dd210fb9
--- /dev/null
+++ b/src/db/migration_ledger.js
@@ -0,0 +1,28 @@
+/*********************************************************************
+ *
+ * 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 - migration ledger reads
+ *
+ * The statement that lists the names a migration ledger records on its own,
+ * with no schema-satisfied names folded in. The migrate runner reads only
+ * the ledger, so this is the set an unscoped run treats as already applied.
+ *
+ ********************************************************************/
+
+// Every name the ledger table itself records.
+function recordedMigrationsSql(database, ledgerTable) {
+ return 'SELECT name FROM `' + database + '`.' + ledgerTable
+}
+
+module.exports = { recordedMigrationsSql }
diff --git a/src/operations/module_operations.js b/src/operations/module_operations.js
index 27f14de1..45187028 100644
--- a/src/operations/module_operations.js
+++ b/src/operations/module_operations.js
@@ -29,7 +29,7 @@ const { db } = require('../state')
const { sleep } = require('../utils/helpers')
const { getDockerContainerImageName, getUtxoTrackerVolumeName, getDockerNetwork } = require('../services/config_service')
const { createDockerNetwork, probeContainerPresenceByName, stopContainer, stopContainerByName, startContainer, restartContainer, execContainer, shellContainer, logContainer, startDockerMonitor, waitContainer, saveContainerLogs, getContainerBindMounts, getContainerStopSettings, removeContainer } = require('../services/docker_service')
-const { stopModuleContainer } = require('../services/stop_budget_service')
+const { stopModuleContainer, restartModuleContainer } = require('../services/stop_budget_service')
const { buildDatabaseModule, resetDatabases, clearHubPriceIngestWatermark, purgeHubCrossChainRows, manualHubCrossChainPurgeStatements, getDatabaseContainerId, pingExternalDatabase, askMariadbRootPassword } = require('../services/database_service')
const { getModuleBranch, installModule, uninstallModule } = require('../services/module_service')
const { assertHubNotBehind } = require('../services/skew_guard_service')
@@ -161,7 +161,7 @@ const dependencies = {
stopContainerByName, startContainer, restartContainer, execContainer,
shellContainer, logContainer, startDockerMonitor, waitContainer,
saveContainerLogs, getContainerBindMounts, getContainerStopSettings, removeContainer,
- stopModuleContainer, buildDatabaseModule, resetDatabases,
+ stopModuleContainer, restartModuleContainer, buildDatabaseModule, resetDatabases,
clearHubPriceIngestWatermark, purgeHubCrossChainRows,
manualHubCrossChainPurgeStatements, getDatabaseContainerId,
askMariadbRootPassword,
diff --git a/src/operations/module_operations/module_controls.js b/src/operations/module_operations/module_controls.js
index 35731f9f..5e62e1b1 100644
--- a/src/operations/module_operations/module_controls.js
+++ b/src/operations/module_operations/module_controls.js
@@ -1,9 +1,9 @@
'use strict'
-let XChainService, failureReason, sleep, db, execContainer, getDockerContainerImageName, logContainer, restartContainer, shellContainer, startContainer, startDockerMonitor, statusChanged, stopContainerByName, stopModuleContainer, getContainerStopSettings
+let XChainService, failureReason, sleep, db, execContainer, getDockerContainerImageName, logContainer, shellContainer, startContainer, startDockerMonitor, statusChanged, stopContainer, stopModuleContainer, restartModuleContainer, getContainerStopSettings, isNoSuchContainerError
function configure(dependencies) {
- ({ XChainService, failureReason, sleep, db, execContainer, getDockerContainerImageName, logContainer, restartContainer, shellContainer, startContainer, startDockerMonitor, statusChanged, stopContainerByName, stopModuleContainer, getContainerStopSettings } = dependencies)
+ ({ XChainService, failureReason, sleep, db, execContainer, getDockerContainerImageName, logContainer, shellContainer, startContainer, startDockerMonitor, statusChanged, stopContainer, stopModuleContainer, restartModuleContainer, getContainerStopSettings, isNoSuchContainerError } = dependencies)
}
async function logModules(servicesList, follow = true) {
@@ -71,60 +71,77 @@ async function monitorModules(servicesList, follow = true) {
return true
}
-async function restartModules(servicesList) {
- for (const nextCoin in servicesList) {
- for (const nextNetwork in servicesList[nextCoin]) {
- for (const nextModule of servicesList[nextCoin][nextNetwork]) {
- const containerId = await db.getModuleContainer(nextModule, nextCoin, nextNetwork)
+// Read a registry row strictly: a failed read is recorded as a failure, never taken for "not installed".
+async function readRegisteredContainer(target, failures) {
+ try {
+ return await db.getModuleContainerStrict(target.module, target.coin, target.network)
+ } catch (err) {
+ const reason = `registry lookup failed (${failureReason(err)})`
+ console.error(`${target.module} (${target.coin} ${target.network}): ${reason}`)
+ failures.push({ ...target, reason })
+ return null
+ }
+}
+
+// Run one docker action on every selected module that is installed, attempting all of them, and
+// throw one error naming each module that failed so the command can exit non-zero.
+async function controlModules(verb, servicesList, act) {
+ const failures = []
+ for (const coin in servicesList) {
+ for (const network in servicesList[coin]) {
+ for (const module of servicesList[coin][network]) {
+ const target = { module, coin, network }
+ const containerId = await readRegisteredContainer(target, failures)
if (!containerId) continue
try {
- await restartContainer(containerId)
- await statusChanged()
+ await act(target, containerId)
} catch (err) {
- console.log(err)
+ console.error(`${verb}: ${module} (${coin} ${network}) FAILED: ${failureReason(err)}`)
+ failures.push({ ...target, reason: failureReason(err) })
}
}
}
}
+ if (failures.length > 0) {
+ const detail = failures.map(f => `${f.module} (${f.coin} ${f.network}): ${f.reason}`).join('; ')
+ const err = new Error(`${verb} failed for ${failures.length} module${failures.length === 1 ? '' : 's'}: ${detail}`)
+ err.failures = failures
+ throw err
+ }
return true
}
+async function restartModules(servicesList) {
+ return controlModules('restart', servicesList, async ({ module, coin, network }, containerId) => {
+ // With the same stop budget as `stop`, so a restart cannot SIGKILL a service mid-block either.
+ // A container docker says is gone stays a failure here: restart promises a running service.
+ await restartModuleContainer(stopContainer, startContainer, module, coin, network, containerId,
+ undefined, getContainerStopSettings)
+ try {
+ await statusChanged()
+ } catch (err) {
+ throw new Error(`restarted, but the hub and explorer config refresh failed (${failureReason(err)})`)
+ }
+ })
+}
+
async function stopModules(servicesList) {
- for (const nextCoin in servicesList) {
- for (const nextNetwork in servicesList[nextCoin]) {
- for (const nextModule of servicesList[nextCoin][nextNetwork]) {
- const containerId = await db.getModuleContainer(nextModule, nextCoin, nextNetwork)
- if (!containerId) continue
- try {
- // With the service's budget, not docker's ten seconds: a bare
- // `docker stop` on a container created before the budget was
- // stamped on it is a coin flip for a service mid-block.
- await stopModuleContainer(stopContainerByName, nextModule, nextCoin, nextNetwork, containerId,
- undefined, getContainerStopSettings)
- } catch (err) {
- console.log(err)
- }
- }
+ return controlModules('stop', servicesList, async ({ module, coin, network }, containerId) => {
+ try {
+ // With the service's budget, not docker's ten seconds: a bare `docker stop` on a container
+ // created before the budget was stamped on it is a coin flip for a service mid-block.
+ await stopModuleContainer(stopContainer, module, coin, network, containerId,
+ undefined, getContainerStopSettings)
+ } catch (err) {
+ // Skip a container docker says is gone: it is not running, which is what stop promises.
+ if (!isNoSuchContainerError(err)) throw err
+ console.log(`${module} (${coin} ${network}): the registered container no longer exists; nothing to stop`)
}
- }
- return true
+ })
}
async function startModules(servicesList) {
- for (const nextCoin in servicesList) {
- for (const nextNetwork in servicesList[nextCoin]) {
- for (const nextModule of servicesList[nextCoin][nextNetwork]) {
- const containerId = await db.getModuleContainer(nextModule, nextCoin, nextNetwork)
- if (!containerId) continue
- try {
- await startContainer(containerId)
- } catch (err) {
- console.log(err)
- }
- }
- }
- }
- return true
+ return controlModules('start', servicesList, (target, containerId) => startContainer(containerId))
}
// The decoder's clear tool, relative to the decoder container's working directory.
@@ -289,15 +306,17 @@ async function bounceResetModules(context) {
continue
}
if (!containerId) continue /* not installed, skip */
- // restartContainer = docker stop + docker start; sufficient to re-enter Node's bootstrap with the freshly-created DB ready.
+ // A budgeted stop then a start, enough to re-enter Node's bootstrap with the freshly-created DB ready.
// A failed restart can leave the wiped service stopped, so it gets the start pass's one retry and is then reported, never swallowed.
+ const bounce = () => restartModuleContainer(stopContainer, startContainer, module, coin, network, containerId,
+ undefined, getContainerStopSettings)
try {
- await restartContainer(containerId)
+ await bounce()
} catch (firstErr) {
console.warn(`Failed to restart ${module} (${firstErr && firstErr.message}); retrying in 3s...`)
await sleep(3000)
try {
- await restartContainer(containerId)
+ await bounce()
} catch (retryErr) {
startFailures.push({ module, error: `post-reset restart failed (${(retryErr && retryErr.message) || String(retryErr)})` })
}
diff --git a/src/release-manifest.json b/src/release-manifest.json
index e24c7a2e..941ddb8e 100644
--- a/src/release-manifest.json
+++ b/src/release-manifest.json
@@ -1,6 +1,6 @@
{
"_comment": [
- "Pinned component set for XChain Platform v0.23.0.",
+ "Pinned component set for XChain Platform v0.24.0.",
"Generated by bin/write-release-manifest.js from the ACTUAL tagged master merge commits.",
"xchain-node is the carrier and is not listed: checking out its tag IS this manifest.",
"A train tags only the repos it touches. This one moves xchain-vm, xchain-decoder, xchain-indexer, xchain-hub, xchain-sync, xchain-encoder, xchain-utxo-tracker, xchain-explorer and xchain-sdk.",
@@ -12,44 +12,44 @@
"repo's origin/master, and is a GPG-signed annotated tag verifying against the",
"platform release key (fingerprint 1DA7C4896F56EA22CF491EDF4361611A82F90B70)."
],
- "platform_version": "0.23.0",
- "released": "2026-10-12",
+ "platform_version": "0.24.0",
+ "released": "2026-10-17",
"components": {
"xchain-vm": {
- "tag": "v0.23.0",
- "commit": "fbaf7827d8d52df669244c5f1c034a4cf1ad4a14"
+ "tag": "v0.24.0",
+ "commit": "67b41add8817db1523e0add492a872746952a874"
},
"xchain-decoder": {
- "tag": "v0.23.0",
- "commit": "a7fe2e19c6eaf5f7f884c4a1f55b174c85254ea7"
+ "tag": "v0.24.0",
+ "commit": "800cb43bac94d5d8b7632181e935fb3668e0c12c"
},
"xchain-indexer": {
- "tag": "v0.23.0",
- "commit": "ea04f6085b93e676db838bc559efd0cc57bb537e"
+ "tag": "v0.24.0",
+ "commit": "0b399defc7f44e2794379e6ddf37b803e282847b"
},
"xchain-hub": {
- "tag": "v0.23.0",
- "commit": "a44bb1328730b724ef39d3ea736f1f694887c97f"
+ "tag": "v0.24.0",
+ "commit": "fea89a7ff6bfa94ad6dede2318696acc7a2f66e8"
},
"xchain-sync": {
- "tag": "v0.23.0",
- "commit": "9fa183540e572b6702e3b9c00d30b21ba4fff4ab"
+ "tag": "v0.24.0",
+ "commit": "0ef3ba957b8f7418f0e54e80df831270c010d665"
},
"xchain-encoder": {
- "tag": "v0.23.0",
- "commit": "21221a42a8e7bd4f90c4d9fdbadeb0e1020257d5"
+ "tag": "v0.24.0",
+ "commit": "c246a23ba17710337724fb45fee941cb8e58ca6d"
},
"xchain-utxo-tracker": {
- "tag": "v0.23.0",
- "commit": "95a995d789a53f0471025fd82281703095d2ab18"
+ "tag": "v0.24.0",
+ "commit": "8e15897e25ab483acd0981dda193227b7a276402"
},
"xchain-explorer": {
- "tag": "v0.23.0",
- "commit": "2649b3e2618c7912ea6f339f060ae19d3ed1419a"
+ "tag": "v0.24.0",
+ "commit": "689949a8dcbdf79b037288f3efffecc0537857c0"
},
"xchain-sdk": {
- "tag": "v0.23.0",
- "commit": "60a86d1f34bd45abba6fb6df9b65ab7bec077560"
+ "tag": "v0.24.0",
+ "commit": "b521fa41587495529df50e5b7f1e3c6a911e4a15"
},
"xchain-e2e-test": {
"tag": "v0.21.0",
diff --git a/src/services/autoheal_service.js b/src/services/autoheal_service.js
index ee5a3cae..29ba924f 100644
--- a/src/services/autoheal_service.js
+++ b/src/services/autoheal_service.js
@@ -44,8 +44,11 @@
* watchdog on node containers, and a cap that stops retrying can leave a
* RECOVERABLE service down through an outage that would have self-cleared.
* Backing off ends the churn; giving up would trade it for an outage.
- * Every skipped pass still logs an "investigate" line, which is the
- * escalation path for a wedge that has stopped being transient.
+ * The escalation path instead: once a container has been restarted twice
+ * in one episode without recovering, every pass that sees it logs an
+ * "investigate" line at warn (stderr) and the command exits 2, so a
+ * systemd OnFailure= hook or cron mail can act on a wedge that has stopped
+ * being transient. A failed restart logs at error and exits 1.
*
* Detection-to-restart latency is the timer interval plus the health
* retry budget plus the grace window; this is deliberate, restarts
@@ -57,7 +60,8 @@
********************************************************************/
const { db } = require('../state')
-const { getStatusFromContainer, restartContainer } = require('./docker_service')
+const { getStatusFromContainer, stopContainer, startContainer, getContainerStopSettings } = require('./docker_service')
+const { restartModuleContainer } = require('./stop_budget_service')
const { SERVICE_HEALTHCHECK } = require('./module_service')
const {
DEFAULT_GRACE_MS,
@@ -112,8 +116,8 @@ async function inspectCandidate(candidate, result) {
// at its last value the moment a container stops (the probe goroutine
// runs only while the container is up), so a container that happened to
// be unhealthy when an operator stopped it keeps reporting `unhealthy`
-// while State.Status is `exited` - and `docker restart` on a stopped
-// container STARTS it, silently undoing the stop. Frozen health from a
+// while State.Status is `exited` - and a restart (stop, then start) of a
+// stopped container STARTS it, silently undoing the stop. Frozen health from a
// container that is no longer probing is not evidence of a wedge. This
// costs no healing either: autoheal exists for the ALIVE-but-stalled
// case (see the file header), because `--restart unless-stopped` already
@@ -152,7 +156,7 @@ function skipNonUnhealthy(candidate, health, state) {
// that DID recover starts its next episode at the base cooldown. Backing
// off is a response to a wedge restarts are not clearing; a recovery is
// the evidence they cleared it, and `!== 'unhealthy'` is not that
- // evidence: `docker restart` puts the container into `starting` for the
+ // evidence: a restart puts the container into `starting` for the
// descriptor's start period plus its retry budget (60s + 3x15s for the
// decoder/indexer, and an operator can widen it to minutes via
// XCHAIN_NODE_HEALTH_START_PERIOD_), so a pass landing in that
@@ -240,12 +244,20 @@ async function restartEligibleCandidate(candidate, since, timing, state, result,
// instead of one per cooldown forever.
const attempts = state.restartCount[containerId] || 0
const effectiveCooldownMs = restartBackoffMs(attempts, timing.cooldownMs, timing.ceilingMs)
+ // Two restarts that did not clear the wedge is where the backoff starts doubling, and where it stops being transient.
+ const escalated = attempts >= 2
+ // Escalate on the restart path too: a timer slower than the doubled cooldown never lands in the cooldown branch.
+ if (escalated) result.escalated.push({ module, coin, network, containerId, attempts })
const lastRestart = state.restarts[containerId]
if (typeof lastRestart === 'number' && now - lastRestart < effectiveCooldownMs) {
result.skipped.push({ module, coin, network, containerId, reason: 'inside restart cooldown' })
- logger.info(`autoheal: ${label} already restarted ${now - lastRestart}ms ago (${attempts} restart(s) this episode, backed-off cooldown ${effectiveCooldownMs}ms), a restart is not clearing this wedge; investigate`)
+ const log = escalated ? logger.warn : logger.info
+ log(`autoheal: ${label} already restarted ${now - lastRestart}ms ago (${attempts} restart(s) this episode, backed-off cooldown ${effectiveCooldownMs}ms), a restart is not clearing this wedge; investigate`)
return
}
+ if (escalated) {
+ logger.warn(`autoheal: ${label} is still unhealthy after ${attempts} restart(s) this episode, a restart is not clearing this wedge; investigate`)
+ }
result.candidates.push({ module, coin, network, containerId })
if (dryRun) {
@@ -254,14 +266,16 @@ async function restartEligibleCandidate(candidate, since, timing, state, result,
}
try {
- await restartContainer(containerId)
+ // With the stop budget `stop` uses, so the restart gives a block-bound drain its time
+ await restartModuleContainer(stopContainer, startContainer, module, coin, network, containerId,
+ undefined, getContainerStopSettings)
state.restarts[containerId] = now
state.restartCount[containerId] = attempts + 1
result.restarted.push({ module, coin, network, containerId })
logger.info(`autoheal: restarted ${label} (unhealthy for ${now - since}ms, restart #${attempts + 1} this episode)`)
} catch (err) {
result.failed.push({ module, coin, network, containerId, reason: String(err) })
- logger.info(`autoheal: FAILED to restart ${label}: ${err}`)
+ logger.error(`autoheal: FAILED to restart ${label}: ${err}`)
}
}
@@ -283,13 +297,13 @@ function persistState(stateFile, state, modules) {
// One autoheal pass over the module registry. Never throws for a single bad
// container (a vanished container id must not abort the whole sweep).
-// Returns { candidates, restarted, failed, skipped } where each array holds
-// { module, coin, network, containerId, reason? }.
+// Returns { candidates, restarted, failed, skipped, escalated } where each array holds
+// { module, coin, network, containerId, reason? }; escalated entries carry attempts instead.
async function runAutoheal({ dryRun = false, now = Date.now() } = {}) {
const timing = autohealTiming()
const stateFile = getStateFilePath()
const state = readState(stateFile)
- const result = { candidates: [], restarted: [], failed: [], skipped: [] }
+ const result = { candidates: [], restarted: [], failed: [], skipped: [], escalated: [] }
// Set whenever an episode-onset or attempt-count entry is recorded or cleared,
// so the pass persists it even when nothing was restarted. Without this the
// onset never survives to the next pass and the grace window can never be
@@ -331,8 +345,16 @@ async function runAutoheal({ dryRun = false, now = Date.now() } = {}) {
return result
}
+// The CLI exit code for one pass: 1 when a restart failed, 2 when a wedge outlived two restarts, else 0.
+function autohealExitCode(result) {
+ if (result.failed.length > 0) return 1
+ if (result.escalated && result.escalated.length > 0) return 2
+ return 0
+}
+
module.exports = {
runAutoheal,
+ autohealExitCode,
getUnhealthySinceMs,
getLastHealthyProbeMs,
isRecoveryEstablished,
diff --git a/src/services/bootstrap_archive_meta.js b/src/services/bootstrap_archive_meta.js
index d651450c..cd2a82fc 100644
--- a/src/services/bootstrap_archive_meta.js
+++ b/src/services/bootstrap_archive_meta.js
@@ -171,16 +171,24 @@ async function readBootstrapArchiveMeta(archivePath) {
const ARCHIVE_IDENTITY_FIELDS = ['module', 'coin', 'network']
+// An identity value trimmed and lowercased, or null when it is absent or blank: the rule the tracker's
+// restore_validation.js applies to the same bootstrap.json, so both readers judge one archive alike.
+function identityValue(value) {
+ if (typeof value !== 'string') return null
+ const v = value.trim().toLowerCase()
+ return v === '' ? null : v
+}
+
// Compare an archive's embedded identity with the restore target: { status: 'match' | 'unchecked' | 'mismatch', mismatches, unchecked }.
-// Only a present field that differs is a mismatch. A null meta or field is unchecked, not a mismatch: legacy archives carry none, and
-// the tracker's convert-bootstrap-to-classiclevel.sh writes coin/network null when BOOTSTRAP_COIN/BOOTSTRAP_NETWORK are unset.
+// Only a present field that differs, ignoring case and padding, is a mismatch; a target lacking a declared field is one too. A null
+// or blank meta field is unchecked: legacy archives carry none, and the converter writes coin/network null when unset.
function compareArchiveIdentity(meta, target) {
const mismatches = []
const unchecked = []
for (const field of ARCHIVE_IDENTITY_FIELDS) {
- const value = meta && typeof meta[field] === 'string' ? meta[field] : null
+ const value = identityValue(meta && meta[field])
if (value === null) unchecked.push(field)
- else if (value !== target[field]) mismatches.push({ field, archive: value, target: target[field] })
+ else if (value !== identityValue(target[field])) mismatches.push({ field, archive: meta[field], target: target[field] })
}
const status = mismatches.length > 0 ? 'mismatch' : unchecked.length > 0 ? 'unchecked' : 'match'
return { status, mismatches, unchecked }
diff --git a/src/services/bootstrap_health_gate.js b/src/services/bootstrap_health_gate.js
index f1df3bf7..e101d468 100644
--- a/src/services/bootstrap_health_gate.js
+++ b/src/services/bootstrap_health_gate.js
@@ -30,8 +30,9 @@
* The gate refuses to create a bootstrap from a source that is:
* - missing, not running, restarting, or crash-looping
* - reporting an unhealthy/halted status on its own health surface
- * - carrying a durable halt marker in its database: a decoder REORG_HALT row
- * (events.code = 'REORG_HALT') or an uncleared xchain-sync divergence halt
+ * - carrying a durable halt marker in its database: a live decoder REORG_HALT
+ * row (events.code = 'REORG_HALT' with no newer REORG_HALT_CLEARED row
+ * superseding it) or an uncleared xchain-sync divergence halt
* (sync_halt with cleared_at IS NULL). For an indexer source both its own
* database and the PAIRED DECODER's are read: REORG_HALT is written only in the
* decoder's (an indexer's own events table carries REORG and TRAIN_ACTIVATION_HALT
@@ -158,6 +159,19 @@ async function probeServiceStatus(containerId, port, runner) {
throw lastErr || new Error('no status probe succeeded')
}
+// One GET on a service's own readiness surface (the indexer's /status, the decoder's /live),
+// parsed, or null when the route cannot be read. A non-2xx answer (wget exits non-zero and
+// prints no body) reads as null on purpose: the container healthcheck already shows that fault.
+async function probeReadinessSurface(containerId, port, route, runner) {
+ try {
+ const { stdout } = await runner('docker', ['exec', containerId, 'wget', '-qO-', `http://localhost:${port}${route}`])
+ const parsed = JSON.parse(String(stdout || '').trim())
+ return (parsed && typeof parsed === 'object') ? parsed : null
+ } catch (_) {
+ return null
+ }
+}
+
// Refuse unless `module` on coin/network is a known-good bootstrap source.
// Resolves silently when the source passes; throws BootstrapSourceUnhealthyError
// (with every reason, not just the first) when it does not.
@@ -355,5 +369,6 @@ module.exports = {
// StatusService reads a decoder's health surface through the same probe so
// `xchain-node ps` can show a latent REORG_HALT.
probeServiceStatus,
+ probeReadinessSurface,
MODULE_API_PORT_KEY
}
diff --git a/src/services/bootstrap_health_gate/halt_markers.js b/src/services/bootstrap_health_gate/halt_markers.js
index c74112c7..1faed914 100644
--- a/src/services/bootstrap_health_gate/halt_markers.js
+++ b/src/services/bootstrap_health_gate/halt_markers.js
@@ -14,8 +14,9 @@
*
* XChain Node - Bootstrap source health gate halt marker probe
*
- * Reads the durable halt markers (a decoder REORG_HALT row, an uncleared
- * sync_halt row) and the dump-window watermarks straight out of the database
+ * Reads the durable halt markers (a live decoder REORG_HALT row, one no newer
+ * REORG_HALT_CLEARED row supersedes, and an uncleared sync_halt row) and the
+ * dump-window watermarks straight out of the database
* about to be dumped, and for an indexer source out of its paired decoder
* database as well. Every unreadable answer throws, and the gate turns a throw
* into a refusal.
diff --git a/src/services/bootstrap_health_gate/status_evaluation.js b/src/services/bootstrap_health_gate/status_evaluation.js
index e55573f6..38586a1a 100644
--- a/src/services/bootstrap_health_gate/status_evaluation.js
+++ b/src/services/bootstrap_health_gate/status_evaluation.js
@@ -67,9 +67,11 @@ function evaluateContainerState(raw, { now = Date.now() } = {}) {
// every known spelling is checked rather than one canonical key:
// decoder health: status, lag_blocks/blockLag, reorg_halted, reorg_halt_checked_at,
// reorg_halt_parked
-// indexer health: status, lag, decoderReorgHalted, stallClass, train_activation.status
-// tracker health: lag, synced, halted
-// any /status: status ('ok'|'healthy'|'halted'|'degraded'|'unhealthy')
+// indexer health: status, lag, decoderReorgHalted, stallClass (values: see stallReasons),
+// train_activation.status
+// tracker health: status, lag, halted, halt_reason, block_fetch_desync,
+// node_height_stale (synced is sent but not read here)
+// any /status: status ('ok'|'healthy'|'halted'|'degraded'|'stalled'|'unhealthy')
function evaluateStatusPayload(payload, { maxLag = DEFAULT_MAX_LAG_BLOCKS } = {}) {
const reasons = []
if (!payload || typeof payload !== 'object')
@@ -130,9 +132,13 @@ function statusFlagReasons(payload) {
// Refusal reasons from the indexer's wedged-counter verdict and a train-activation halt.
function stallReasons(payload) {
const reasons = []
- // The indexer's own single-field verdict on its block counter:
- // 'none' | 'future_block_wait' | 'barrier_defer' | 'wedged'. Only 'wedged' is a
- // refusal, and it needs its own leg: the indexer reports status "healthy"
+ // The indexer's own single-field verdict on its block counter: 'none' |
+ // 'future_block_wait' | 'bridge_proof_wait' | 'price_landing_wait' | 'barrier_defer' |
+ // 'wedged', plus the published refinements 'upstream_halted' (only beside
+ // decoderReorgHalted: true, refused above) | 'rollcall_proof_wait' | 'rollcall_proof_stuck'.
+ // The set is defined in xchain-indexer src/XChainIndexer/stall_health.js (stallClassOf,
+ // publishedStallClass). Only 'wedged' is a refusal, and it needs its own
+ // leg: the indexer reports status "healthy"
// whenever its process is up and its DB circuit is closed, so a freshly-wedged
// indexer whose lag is still inside the ceiling passes every other check here.
// NOT keyed on `degraded`, which stays true throughout the healthy
diff --git a/src/services/bootstrap_service/tracker_archive.js b/src/services/bootstrap_service/tracker_archive.js
index 9f2617b3..7796e1f7 100644
--- a/src/services/bootstrap_service/tracker_archive.js
+++ b/src/services/bootstrap_service/tracker_archive.js
@@ -38,7 +38,7 @@ const { redactSecrets } = require('../../utils/helpers')
let { maybeSignBootstrap } = require('./archive_signing')
let { startProgress, buildDateTimeString, getWorkDir, assertBootstrapCapacity, ensureDir, ensureDirWritable } = require('./workspace')
const trackerHeight = require('./tracker_height')
-const { readTrackerCommittedHeight, readTrackerHeightAfterRestart, chooseArchiveHeight, refusePreStopOnlyHeight } = trackerHeight
+const { readTrackerCommittedHeight, readTrackerHeightAfterRestart, readFrozenStoreHeight, chooseArchiveHeight, refusePreStopOnlyHeight } = trackerHeight
const { getLogger } = require('../../observability/logger')
let logger = getLogger()
@@ -190,9 +190,11 @@ async function makeBootstrapUtxoTracker(coin, network) {
`(exit code ${stop.exitCode}, ${stop.budget} s budget); no archive was built or signed`)
const snapshotTaken = await takeTrackerSnapshot(context, endMaintenanceWindow)
await compressTrackerArchive(context, snapshotTaken)
+ // The archived store's own height, read before the fallback restart (snapshot path: the snapshot is still pinned until the finally).
+ context.frozenHeight = await readFrozenStoreHeight(context.volumeName, snapshotTaken ? `/data/${TRACKER_SNAPSHOT_DIR}` : '/data')
// Fallback path: the store is archived, so bring the tracker back before the wrap and sign, and read its height there.
if (!snapshotTaken) await restartTrackerAndReadHeight(context, endMaintenanceWindow)
- context.archiveHeight = chooseArchiveHeight(context.preStopHeight, context.postRestartHeight)
+ context.archiveHeight = chooseArchiveHeight(context.preStopHeight, context.postRestartHeight, context.frozenHeight)
await finalizeTrackerArchive(coin, network, context)
} finally {
await cleanupTrackerArchive(context.volumeName, context.containerId, context.containerRestored, endMaintenanceWindow)
@@ -221,6 +223,7 @@ async function prepareTrackerArchive(coin, network) {
// refuses the publish (chooseArchiveHeight). With neither reading the archive carries no height (BootstrapNodeTipGuard: unknown).
context.preStopHeight = await readTrackerCommittedHeight(coin, network, context.containerId)
context.postRestartHeight = null
+ context.frozenHeight = null
// Staging and output dirs are prepared before the stop for the same reason as the capacity check: a read-only mount or a missing parent should not be
// discovered with the tracker already dark.
@@ -290,18 +293,13 @@ async function takeTrackerSnapshot(context, endMaintenanceWindow) {
snapshotTaken = await snapshotTrackerVolume(context.volumeName)
} catch (err) {
logger.info(`Volume snapshot unavailable (${err.message}); compressing with the tracker stopped.`)
- // A half-written snapshot must not be swept into the fallback
- // archive, and a volume we cannot clean is not one we can publish
- // from: let this throw into the outer finally, which restarts the
- // container.
+ // A half-written snapshot must not be swept into the fallback archive, and a volume we cannot clean is not one we can publish
+ // from: let this throw into the outer finally, which restarts the container.
await removeTrackerSnapshot(context.volumeName)
}
- // The whole point of the snapshot: the outage ends HERE, seconds after
- // the stop, instead of after the multi-hour compress below. Without it
- // the monthly cron took each mainnet encoder's tracker dark for the
- // full run (2026-08-01: 3h36m BTC, 1h04m LTC, 42m DOGE), which the
- // encoder correctly published as tracker_reachable:false.
+ // The whole point of the snapshot: the outage ends HERE, seconds after the stop, instead of after the multi-hour compress below.
+ // Without it the monthly cron took each mainnet encoder's tracker dark for the full run (2026-08-01: 3h36m BTC, 1h04m LTC, 42m DOGE).
if (snapshotTaken) {
logger.info(`Starting ${XChainService.XCHAIN_UTXO_TRACKER} container (compressing from the snapshot)...`)
await restartTrackerAndReadHeight(context, endMaintenanceWindow)
@@ -396,5 +394,6 @@ module.exports = {
readTrackerCommittedHeight,
// Exported for tests
chooseArchiveHeight,
+ readFrozenStoreHeight,
readTrackerHeightAfterRestart
}
diff --git a/src/services/bootstrap_service/tracker_height.js b/src/services/bootstrap_service/tracker_height.js
index b82b2517..9eec54e0 100644
--- a/src/services/bootstrap_service/tracker_height.js
+++ b/src/services/bootstrap_service/tracker_height.js
@@ -79,10 +79,36 @@ async function readTrackerHeightAfterRestart(coin, network, containerId, { attem
return null
}
+// File the tracker leaves in its store directory recording the height its last clean close committed. Plain text, one integer.
+const FROZEN_HEIGHT_MARKER = 'committed_height'
+
+// The height of the frozen store itself, read from the directory that was just archived (the hardlink snapshot, or /data on the
+// stopped-tracker fallback), or null. Unlike the live status surface this cannot run ahead of the archived data.
+async function readFrozenStoreHeight(volumeName, sourceDir) {
+ try {
+ const { stdout } = await execFileAsync('docker', [
+ 'run', '--rm', '-v', `${volumeName}:/data:ro`, 'alpine', 'cat', `${sourceDir}/${FROZEN_HEIGHT_MARKER}`
+ ], { timeout: 60000 })
+ const text = String(stdout).trim()
+ const value = Number(text)
+ if (/^\d+$/.test(text) && Number.isSafeInteger(value)) return value
+ logger.info(`The frozen tracker store reported an unusable height (${redactSecrets(text.slice(0, 40))}); falling back to the status surface.`)
+ return null
+ } catch (err) {
+ logger.info(`Could not read the frozen tracker store height (${redactSecrets(err.message)}); falling back to the status surface.`)
+ return null
+ }
+}
+
// Pick the height the archive records: never below the data it holds, since the restore guard treats nodeHeight >= archiveHeight as safe.
// max() keeps a rollback during the restart from lowering it. A pre-stop reading alone can miss blocks committed while the tracker
// stopped, so that case throws rather than sign an understated height; with no reading at all the archive honestly carries none.
-function chooseArchiveHeight(preStopHeight, postRestartHeight) {
+// A frozen-store reading is exact for the archived data, so it wins over both live readings.
+function chooseArchiveHeight(preStopHeight, postRestartHeight, frozenHeight = null) {
+ if (Number.isInteger(frozenHeight)) {
+ logger.info(`Archive height: ${frozenHeight} (read from the frozen store).`)
+ return frozenHeight
+ }
const pre = Number.isInteger(preStopHeight) ? preStopHeight : null
const post = Number.isInteger(postRestartHeight) ? postRestartHeight : null
if (post === null && pre !== null) {
@@ -95,7 +121,8 @@ function chooseArchiveHeight(preStopHeight, postRestartHeight) {
}
// Throw when only the pre-stop reading exists, naming the run, so the publish stops before any compress or signing.
-function refusePreStopOnlyHeight({ coin, network, preStopHeight, postRestartHeight }) {
+function refusePreStopOnlyHeight({ coin, network, preStopHeight, postRestartHeight, frozenHeight }) {
+ if (Number.isInteger(frozenHeight)) return
if (!Number.isInteger(preStopHeight) || Number.isInteger(postRestartHeight)) return
throw new Error(`utxo-tracker ${coin}/${network} did not report its committed height after the restart; ` +
`refusing to sign an archive whose recorded height could understate its data (pre-stop reading ${preStopHeight}). ` +
@@ -106,6 +133,7 @@ module.exports = {
configureDependencies,
readTrackerCommittedHeight,
readTrackerHeightAfterRestart,
+ readFrozenStoreHeight,
chooseArchiveHeight,
refusePreStopOnlyHeight
}
diff --git a/src/services/config_service.js b/src/services/config_service.js
index a86d0138..9e74689c 100644
--- a/src/services/config_service.js
+++ b/src/services/config_service.js
@@ -27,6 +27,7 @@ const {
moduleDir, tmpDir, cryptoNodesDir, bootstrapDir, configDir, EXTERNAL_DB
} = require('../config')
const { stringToCoin } = require('../utils/helpers')
+const { scopedNodePrefixSegment } = require('../utils/module_database_name')
const { preferredSecretEnvName, foldSecretEnvAliases, readSecretHostEnv, deprecatedSecretEnvNames } = require('../config/secret_env')
const { getCoinConfigByFullName } = require('../coins')
const config = require('../config')
@@ -148,11 +149,22 @@ function getModuleDatabaseName(module, coin, network) {
}
const moduleName = module.slice("xchain-".length)
return "XChain" + DB_SEP
+ + scopedNodePrefixSegment(NODE_PREFIX, DEFAULT_NODE_PREFIX, DB_SEP)
+ CoinTickerSymbol[coin] + DB_SEP
+ network.charAt(0).toUpperCase() + network.slice(1) + DB_SEP
+ moduleName.charAt(0).toUpperCase() + moduleName.slice(1)
}
+// Module DB account names carry the NODE_PREFIX like the database names, so two
+// stacks sharing one MariaDB never contend for one account. Default prefix: unchanged.
+function applyPrefixedModuleDbUsers(defaultValues, coin, network) {
+ const segment = scopedNodePrefixSegment(NODE_PREFIX, DEFAULT_NODE_PREFIX, DB_SEP)
+ if (segment === '') return
+ for (const [key, name] of [["DECODER_DB_USER", "decoder"], ["INDEXER_DB_USER", "indexer"]]) {
+ defaultValues[key] = "xchain" + DB_SEP + segment + name + DB_SEP + coin + DB_SEP + network
+ }
+}
+
sidecars.configure({
crypto, fs, path, readline, configDir, preferredSecretEnvName,
foldSecretEnvAliases, deprecatedSecretEnvNames, logger
@@ -191,7 +203,7 @@ const { resolveArgs } = argumentsService
async function getDefaultConfig(module, coin, network) {
let defaultValues; if (coin && network) {
- defaultValues = defaults.createCoinDefaults(module, coin, network); coins.applyCoinDefaults(defaultValues, module, coin, network)
+ defaultValues = defaults.createCoinDefaults(module, coin, network); applyPrefixedModuleDbUsers(defaultValues, coin, network); coins.applyCoinDefaults(defaultValues, module, coin, network)
services.configureE2e(defaultValues, module, coin)
if (module === XChainService.XCHAIN_E2E_TEST) await applyHubApiKeyFromSidecar(defaultValues)
services.configureIndexerBeforeHubKey(defaultValues, module, network)
diff --git a/src/services/config_service/coins.js b/src/services/config_service/coins.js
index 69216f34..dbd0a8fc 100644
--- a/src/services/config_service/coins.js
+++ b/src/services/config_service/coins.js
@@ -54,7 +54,8 @@ function configure(dependencies) {
// native-fee validation failing closed on every fee-bearing LTC/DOGE action (audit
// F-11). getCoinConfigByFullName applies the per-coin host-env override itself
// (ignored + warned on mainnet: fee acceptance is consensus and must not depend on
-// operator env); the generic FEE_DESTINATION host var is honored on non-mainnet only.
+// operator env); the generic FEE_DESTINATION host var is honored on regtest only, since
+// testnet is a multi-operator federation whose consensus pin hashes only the static bundle.
// Injected as a default, so a value in the - config file still wins.
// Address-deriving modules (encoder/decoder/utxo-tracker) resolve their bitcoinjs
// network via CryptoNetworks, which keys on the COIN-PREFIXED name (e.g.
@@ -97,7 +98,7 @@ function applyCoinDefaults(defaultValues, module, coin, network) {
}
const feeDestEnvName = 'XCHAIN_FEE_DESTINATION_' + CoinTickerSymbol[coin] + '_' + network.toUpperCase()
const registryFeeDestination = getCoinConfigByFullName(coin, network).addresses.FEE_DESTINATION
- const feeDestination = (network !== Network.MAINNET && !config.FEE_DESTINATION_ENV[feeDestEnvName] && config.FEE_DESTINATION)
+ const feeDestination = (network === Network.REGTEST && !config.FEE_DESTINATION_ENV[feeDestEnvName] && config.FEE_DESTINATION)
? config.FEE_DESTINATION
: registryFeeDestination
if (feeDestination) {
diff --git a/src/services/config_service/defaults.js b/src/services/config_service/defaults.js
index b51b4361..09b5b8df 100644
--- a/src/services/config_service/defaults.js
+++ b/src/services/config_service/defaults.js
@@ -295,11 +295,23 @@ function configureManagedHubDiscovery(defaultValues, module) {
}
}
+// Host INDEX_MAP_PARITY_CHECK reaches the xchain-sync server container only,
+// so a recreate keeps it instead of reverting to the image default.
+function configureSyncPassthrough(defaultValues, module) {
+ if (module !== SYNC_MODULE_NAME) return
+ for (const k of ["INDEX_MAP_PARITY_CHECK"]) {
+ if (config.SYNC_PASSTHROUGH_ENV[k] !== undefined && config.SYNC_PASSTHROUGH_ENV[k] !== "") {
+ defaultValues[k] = config.SYNC_PASSTHROUGH_ENV[k]
+ }
+ }
+}
+
function configureSharedAfterHubKey(defaultValues, module) {
if (config.CORS_ORIGIN !== undefined && config.CORS_ORIGIN !== "") {
defaultValues.CORS_ORIGIN = config.CORS_ORIGIN
}
configureExplorerHubFeed(defaultValues, module)
+ configureSyncPassthrough(defaultValues, module)
if (module === EXPLORER_MODULE_NAME) {
if (config.EXPLORER_VM_QUERY_ENABLED !== undefined && config.EXPLORER_VM_QUERY_ENABLED !== "") {
defaultValues.EXPLORER_VM_QUERY_ENABLED = config.EXPLORER_VM_QUERY_ENABLED
diff --git a/src/services/config_service/networks.js b/src/services/config_service/networks.js
index d1596118..d91d24a0 100644
--- a/src/services/config_service/networks.js
+++ b/src/services/config_service/networks.js
@@ -110,6 +110,50 @@ async function composeColocatedIndexerUrls(defaultValues, network, registeredSta
}
}
+const HUB_PASSTHROUGH_VARS = [
+ "HUB_API_KEY",
+ "BTC_INDEXER_URL", "LTC_INDEXER_URL", "DOGE_INDEXER_URL",
+ "BTC_INDEXER_API_KEY", "LTC_INDEXER_API_KEY", "DOGE_INDEXER_API_KEY",
+ "CHECKPOINT_ENABLED", "CHECKPOINT_INTERVAL_BLOCKS", "CHECKPOINT_CONFIRMATIONS",
+ "CHECKPOINT_POLL_MS", "CHECKPOINT_ROUND_TIMEOUT_MS", "CHECKPOINT_CHAINS",
+ "ANCHOR_ENABLED", "ANCHOR_INTERVAL_MS", "ANCHOR_MATCH_BATCH_SIZE",
+ "ANCHOR_MAX_BATCH", "ANCHOR_CHUNK_MAX_BYTES", "ANCHOR_ROUND_TIMEOUT_MS",
+ "ANCHOR_CHUNK_RETRY_MS", "ANCHOR_ELECTION_TOLERANCE_BLOCKS", "ANCHOR_REWARD_PER_PUBLISH",
+ "ANCHOR_CHECKPOINT_EVERY_N",
+ "DOGE_ENCODER_URL", "DOGE_ENCODER_API_KEY", "DOGE_ADDRESS",
+ "DOGE_PUBKEY_HEX", "DOGE_LOW_BALANCE_THRESHOLD",
+ "XDEX_SEED_LOCAL_VALIDATOR", "XDEX_SNAPSHOT_BLOCK",
+ "XCHAIN_CONFIRMATIONS_BTC", "XCHAIN_CONFIRMATIONS_LTC", "XCHAIN_CONFIRMATIONS_DOGE",
+ "HUB_TRUST_PROXY",
+ "HUB_NETWORK",
+ "ORACLE_MIN_SUBMISSIONS",
+ "ORACLE_ROUND_INTERVAL", "ORACLE_SUBMISSION_WINDOW",
+ "ATTESTATION_ROUND_TIMEOUT_MS", "ADMISSION_WATERMARK_SAMPLE_MS",
+ "ORACLE_BATCH_WINDOW_ROUNDS", "ORACLE_BATCH_GRACE_MS",
+ "ORACLE_BATCH_SIGN_TIMEOUT_MS", "ORACLE_BATCH_BUFFER_MAX_ROUNDS",
+ "ORACLE_BATCH_LANDING_RESERVE_MS",
+ "ATTEST_RESPONSE_FORWARD_S_OVERRIDE",
+ "ATTEST_BATCH_WINDOW_S_OVERRIDE", "ORACLE_PUBLISH_ENABLED", "ATTEST_BATCH_PUBLISH_ENABLED",
+ "HUB_RATE_LIMIT_RPM", "HUB_RATE_LIMIT_EXEMPT_LOCAL",
+ "XCHAIN_PRICE_INDEXER_DB_HOST", "XCHAIN_PRICE_INDEXER_DB_PORT",
+ "XCHAIN_PRICE_INDEXER_DB_NAME", "XCHAIN_PRICE_INDEXER_DB_USER",
+ "XCHAIN_PRICE_INDEXER_DB_PASS", "XCHAIN_PRICE_INDEXER_DB_COIN",
+ "XCHAIN_PRICE_WINDOW_BLOCKS", "XCHAIN_PRICE_CONFIRMATION_BUFFER",
+ "XCHAIN_PRICE_BOOTSTRAP_SATS",
+ "XCHAIN_PRICE_MIN_BTC_VOLUME",
+ "HUB_API_KEY", "HUB_ALLOW_UNAUTHENTICATED",
+ "XC_ROLLCALL_REGTEST_ACTIVATION",
+ "XC_ROLLCALL_GATES_REGTEST_ACTIVATION",
+ "XC_MIRROR_ADMISSION_ACTIVATION",
+ "XC_ANCHOR_FOLD_REGTEST_ACTIVATION",
+ "XC_ANCHOR_STAKE_REGTEST_ACTIVATION",
+ "XC_ANCHOR_SLASH_REGTEST_ACTIVATION",
+ "XC_OFFER_LIST_REGTEST_ACTIVATION"
+]
+const HUB_ADMISSION_TIMING_VARS = new Set([
+ "ATTESTATION_ROUND_TIMEOUT_MS", "ADMISSION_WATERMARK_SAMPLE_MS"
+])
+
// Hub telemetry secrets come from host env; unset, ip_hash stays null and admin is 401.
// BTC indexer JSON-RPC URL comes from host env for a hub not co-located with one. Left
// empty, it is composed from the co-located indexer, else the hub uses its configs table.
@@ -182,7 +226,6 @@ async function composeColocatedIndexerUrls(defaultValues, network, registeredSta
// 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.
-//
// 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
@@ -247,6 +290,7 @@ async function composeColocatedIndexerUrls(defaultValues, network, registeredSta
// mirror-admission gate module gates it for real.
// XC_ANCHOR_FOLD_REGTEST_ACTIVATION rides beside it: the hub and the regtest indexer arm the fold together,
// and the stake and slash arms ride the same way.
+// XC_OFFER_LIST_REGTEST_ACTIVATION arms the offer-list gate on the hub; the hub's own gate module gates it for real.
// Secret-bearing names in this list (XCHAIN_PRICE_INDEXER_DB_PASS) are also
// accepted from the host env under their redaction-safe `*_SECRET` spelling;
// everything else resolves to a plain process.env read.
@@ -258,50 +302,8 @@ function configureHubBeforeKey(defaultValues, module) {
defaultValues["TELEMETRY_IP_SALT"] = config.TELEMETRY_IP_SALT
defaultValues["TELEMETRY_ADMIN_KEY"] = config.TELEMETRY_ADMIN_KEY
defaultValues["BTC_INDEXER_API_URL"] = config.BTC_INDEXER_API_URL
- const hubPassthroughVars = [
- "HUB_API_KEY",
- "BTC_INDEXER_URL", "LTC_INDEXER_URL", "DOGE_INDEXER_URL",
- "BTC_INDEXER_API_KEY", "LTC_INDEXER_API_KEY", "DOGE_INDEXER_API_KEY",
- "CHECKPOINT_ENABLED", "CHECKPOINT_INTERVAL_BLOCKS", "CHECKPOINT_CONFIRMATIONS",
- "CHECKPOINT_POLL_MS", "CHECKPOINT_ROUND_TIMEOUT_MS", "CHECKPOINT_CHAINS",
- "ANCHOR_ENABLED", "ANCHOR_INTERVAL_MS", "ANCHOR_MATCH_BATCH_SIZE",
- "ANCHOR_MAX_BATCH", "ANCHOR_CHUNK_MAX_BYTES", "ANCHOR_ROUND_TIMEOUT_MS",
- "ANCHOR_CHUNK_RETRY_MS", "ANCHOR_ELECTION_TOLERANCE_BLOCKS", "ANCHOR_REWARD_PER_PUBLISH",
- "ANCHOR_CHECKPOINT_EVERY_N",
- "DOGE_ENCODER_URL", "DOGE_ENCODER_API_KEY", "DOGE_ADDRESS",
- "DOGE_PUBKEY_HEX", "DOGE_LOW_BALANCE_THRESHOLD",
- "XDEX_SEED_LOCAL_VALIDATOR", "XDEX_SNAPSHOT_BLOCK",
- "XCHAIN_CONFIRMATIONS_BTC", "XCHAIN_CONFIRMATIONS_LTC", "XCHAIN_CONFIRMATIONS_DOGE",
- "HUB_TRUST_PROXY",
- "HUB_NETWORK",
- "ORACLE_MIN_SUBMISSIONS",
- "ORACLE_ROUND_INTERVAL", "ORACLE_SUBMISSION_WINDOW",
- "ATTESTATION_ROUND_TIMEOUT_MS", "ADMISSION_WATERMARK_SAMPLE_MS",
- "ORACLE_BATCH_WINDOW_ROUNDS", "ORACLE_BATCH_GRACE_MS",
- "ORACLE_BATCH_SIGN_TIMEOUT_MS", "ORACLE_BATCH_BUFFER_MAX_ROUNDS",
- "ORACLE_BATCH_LANDING_RESERVE_MS",
- "ATTEST_RESPONSE_FORWARD_S_OVERRIDE",
- "ATTEST_BATCH_WINDOW_S_OVERRIDE",
- "HUB_RATE_LIMIT_RPM", "HUB_RATE_LIMIT_EXEMPT_LOCAL",
- "XCHAIN_PRICE_INDEXER_DB_HOST", "XCHAIN_PRICE_INDEXER_DB_PORT",
- "XCHAIN_PRICE_INDEXER_DB_NAME", "XCHAIN_PRICE_INDEXER_DB_USER",
- "XCHAIN_PRICE_INDEXER_DB_PASS", "XCHAIN_PRICE_INDEXER_DB_COIN",
- "XCHAIN_PRICE_WINDOW_BLOCKS", "XCHAIN_PRICE_CONFIRMATION_BUFFER",
- "XCHAIN_PRICE_BOOTSTRAP_SATS",
- "XCHAIN_PRICE_MIN_BTC_VOLUME",
- "HUB_API_KEY", "HUB_ALLOW_UNAUTHENTICATED",
- "XC_ROLLCALL_REGTEST_ACTIVATION",
- "XC_ROLLCALL_GATES_REGTEST_ACTIVATION",
- "XC_MIRROR_ADMISSION_ACTIVATION",
- "XC_ANCHOR_FOLD_REGTEST_ACTIVATION",
- "XC_ANCHOR_STAKE_REGTEST_ACTIVATION",
- "XC_ANCHOR_SLASH_REGTEST_ACTIVATION"
- ]
- const hubAdmissionTimingVars = new Set([
- "ATTESTATION_ROUND_TIMEOUT_MS", "ADMISSION_WATERMARK_SAMPLE_MS"
- ])
- for (const varName of hubPassthroughVars) {
- const value = hubAdmissionTimingVars.has(varName)
+ for (const varName of HUB_PASSTHROUGH_VARS) {
+ const value = HUB_ADMISSION_TIMING_VARS.has(varName)
? config.childProcessEnv()[varName]
: readSecretHostEnv(varName)
if (value !== undefined && value !== "") {
diff --git a/src/services/config_service/services.js b/src/services/config_service/services.js
index 1e70e340..7c62df0e 100644
--- a/src/services/config_service/services.js
+++ b/src/services/config_service/services.js
@@ -181,6 +181,7 @@ function configureE2e(defaultValues, module, coin) {
// admission-era canonical refuses a legacy-map row and halts the block loop.
// XC_ANCHOR_FOLD_REGTEST_ACTIVATION arms the ANCHOR v3 fold gate the same way; unarmed,
// a deployed regtest indexer rejects every v3 bundle as an unknown version.
+// EXACT_INTEGER_WIRE_FIELDS_REGTEST_TIME arms the exact-integer wire-field gate on a regtest indexer only.
// The indexer pushes chain tips to HUB_API_URL; when that hub enforces
// HUB_API_KEY, the indexer must present the same key or its writes 401.
// Sourced from host env so it persists across `update`, then from the shared
@@ -220,7 +221,8 @@ function configureIndexerBeforeHubKey(defaultValues, module, network) {
"XC_MIRROR_ADMISSION_ACTIVATION",
"XC_ANCHOR_FOLD_REGTEST_ACTIVATION",
"XC_ANCHOR_STAKE_REGTEST_ACTIVATION",
- "XC_ANCHOR_SLASH_REGTEST_ACTIVATION")
+ "XC_ANCHOR_SLASH_REGTEST_ACTIVATION",
+ "EXACT_INTEGER_WIRE_FIELDS_REGTEST_TIME")
for (const varName of rollcallPassthroughVars) {
if (config.INDEXER_ROLLCALL_ENV[varName] !== undefined && config.INDEXER_ROLLCALL_ENV[varName] !== "") {
defaultValues[varName] = config.INDEXER_ROLLCALL_ENV[varName]
diff --git a/src/services/credentials_service.js b/src/services/credentials_service.js
index 69785a79..9a7d7028 100644
--- a/src/services/credentials_service.js
+++ b/src/services/credentials_service.js
@@ -19,6 +19,8 @@ const fs = require('fs')
const os = require('os')
const path = require('path')
const crypto = require('crypto')
+const { getLogger } = require('../observability/logger')
+const logger = getLogger()
const XCHAIN_NODE_DB = "xchain_node"
const CREDENTIALS_DIR_NAME = ".xchain-node"
@@ -69,6 +71,50 @@ function loadCredentials() {
}
}
+// Read the current document for a save. A missing file is an empty one; any other read
+// error throws, because the rename in writeCredentialsAtomic() would replace a file this
+// user cannot read, where an in-place write would have refused to open it.
+function readCredentialsForUpdate(filePath) {
+ let content
+ try {
+ content = fs.readFileSync(filePath, 'utf8')
+ } catch (err) {
+ if (err && err.code === 'ENOENT') return {}
+ throw err
+ }
+ let parsed = null
+ try { parsed = JSON.parse(content) } catch { parsed = null }
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
+ // Move an unparseable file aside instead of saving over it, so the sibling blocks it held stay recoverable
+ const aside = filePath + '.corrupt-' + new Date().toISOString().replace(/[:.]/g, '-')
+ fs.renameSync(filePath, aside)
+ logger.warn(`WARNING: ${filePath} could not be parsed, so it was moved to ${aside} and a new one started. ` +
+ 'Any stored user/password, dbRootPassword or externalDb entry it held may need recovering from that copy.')
+ return {}
+}
+
+// Write the whole document to a 0600 sibling temp file, flush it, then rename it over the
+// original, so a crash or a full disk mid-write never leaves credentials.json torn.
+function writeCredentialsAtomic(filePath, doc) {
+ const tmpName = '.' + CREDENTIALS_FILE_NAME + '.' + process.pid + '.' + crypto.randomBytes(6).toString('hex') + '.tmp'
+ const tmpPath = path.join(path.dirname(filePath), tmpName)
+ try {
+ fs.writeFileSync(tmpPath, JSON.stringify(doc, null, 2), { mode: 0o600, flag: 'wx' })
+ const fd = fs.openSync(tmpPath, 'r+')
+ try { fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
+ fs.renameSync(tmpPath, filePath)
+ } catch (err) {
+ // Leave no temp file behind; the original is untouched until the rename
+ try { fs.unlinkSync(tmpPath) } catch { /* never created */ }
+ throw err
+ }
+ try {
+ fs.chmodSync(filePath, 0o600)
+ } catch {
+ // chmod may fail on Windows; file is in user's HOME so still protected by ACL
+ }
+}
+
// Read-modify-write, mirroring saveExternalDbConfig() below: a whole-file
// overwrite here would destroy the sibling `externalDb` block written moments
// earlier in the same provisioning run (uuid:7ed329f7).
@@ -78,14 +124,9 @@ function saveCredentials(creds) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
}
const filePath = getCredentialsPath()
- const existing = readCredentialsRaw() || {}
+ const existing = readCredentialsForUpdate(filePath)
Object.assign(existing, creds)
- fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), { mode: 0o600 })
- try {
- fs.chmodSync(filePath, 0o600)
- } catch {
- // chmod may fail on Windows; file is in user's HOME so still protected by ACL
- }
+ writeCredentialsAtomic(filePath, existing)
}
// --- External DB config (host-native MariaDB mode) ---
@@ -124,10 +165,9 @@ function saveDbRootPassword(password) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
}
const filePath = getCredentialsPath()
- const existing = readCredentialsRaw() || {}
+ const existing = readCredentialsForUpdate(filePath)
existing.dbRootPassword = String(password)
- fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), { mode: 0o600 })
- try { fs.chmodSync(filePath, 0o600) } catch {}
+ writeCredentialsAtomic(filePath, existing)
}
function hasExternalDbConfig() {
@@ -151,15 +191,14 @@ function saveExternalDbConfig(cfg) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
}
const filePath = getCredentialsPath()
- const existing = readCredentialsRaw() || {}
+ const existing = readCredentialsForUpdate(filePath)
existing.externalDb = {
host: String(cfg.host),
port: Number(cfg.port),
root_user: String(cfg.root_user),
root_password: String(cfg.root_password)
}
- fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), { mode: 0o600 })
- try { fs.chmodSync(filePath, 0o600) } catch {}
+ writeCredentialsAtomic(filePath, existing)
}
module.exports = {
diff --git a/src/services/database_service.js b/src/services/database_service.js
index 5da89d2d..49531636 100644
--- a/src/services/database_service.js
+++ b/src/services/database_service.js
@@ -62,6 +62,7 @@ const {
const { XCHAIN_NODE_DB_HOST, XCHAIN_NODE_DB_DEFAULT_PORT, getDatabaseContainerId, getDatabaseContainerPresence, getDatabaseHostPort, checkIfDatabaseModuleExists, checkIfDatabaseIsReady, configureDependencies: configureContainerAccess } = require('./database_service/container_access')
const { getExternalDbConfig, pingExternalDatabase, configureDependencies: configureExternalDb } = require('./database_service/external_db')
const { askMariadbRootPassword, executeDockerMariaDbCommand, executeNativeMariaDbCommand, configureDependencies: configureMariadbExec } = require('./database_service/mariadb_exec')
+const { configureDependencies: configurePersistedDatadir } = require('./database_service/persisted_datadir')
const { addUserPasswordToDatabase, configureDependencies: configureUserProvisioning } = require('./database_service/user_provisioning')
const { setDatabaseParameters, setHubDatabaseParameters, configureDependencies: configureDbParameters } = require('./database_service/db_parameters')
const { resetDatabases, clearHubPriceIngestWatermark, configureDependencies: configureResetDatabases } = require('./database_service/reset_databases')
@@ -72,6 +73,7 @@ const databaseServiceDependencies = { execFile, spawn, promisify, execFileAsync,
configureContainerAccess(databaseServiceDependencies)
configureExternalDb(databaseServiceDependencies)
configureMariadbExec(databaseServiceDependencies)
+configurePersistedDatadir(databaseServiceDependencies)
configureUserProvisioning(databaseServiceDependencies)
configureDbParameters(databaseServiceDependencies)
configureResetDatabases(databaseServiceDependencies)
diff --git a/src/services/database_service/buffer_pool_size.js b/src/services/database_service/buffer_pool_size.js
new file mode 100644
index 00000000..16cc6da1
--- /dev/null
+++ b/src/services/database_service/buffer_pool_size.js
@@ -0,0 +1,32 @@
+'use strict'
+
+// Copyright © 2025-2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC - https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+// The bundled MariaDB's InnoDB buffer pool. The image default of 128 MB re-reads the
+// per-chain schemas from disk on every block once they outgrow it, so the container
+// starts with 1G unless XCHAIN_NODE_DB_BUFFER_POOL_SIZE names another size.
+const DEFAULT_BUFFER_POOL_SIZE = '1G'
+
+// A byte count with an optional K, M or G suffix, the forms mysqld accepts as a plain size.
+const BUFFER_POOL_SIZE_PATTERN = /^\d+[KMG]?$/
+
+// The size to pass as --innodb-buffer-pool-size: the operator's value when it matches
+// BUFFER_POOL_SIZE_PATTERN, the default when it is unset, and the default with a warning
+// when it is set but malformed, so a typo never keeps the container from starting.
+function resolveBufferPoolSize(raw, warn) {
+ if (raw === undefined || raw === null || String(raw).trim() === '') return DEFAULT_BUFFER_POOL_SIZE
+ const value = String(raw).trim()
+ if (BUFFER_POOL_SIZE_PATTERN.test(value)) return value
+ warn('XCHAIN_NODE_DB_BUFFER_POOL_SIZE="' + value + '" is not a size such as 512M or 16G; using the default ' + DEFAULT_BUFFER_POOL_SIZE)
+ return DEFAULT_BUFFER_POOL_SIZE
+}
+
+module.exports = { resolveBufferPoolSize, DEFAULT_BUFFER_POOL_SIZE, BUFFER_POOL_SIZE_PATTERN }
diff --git a/src/services/database_service/database_module.js b/src/services/database_service/database_module.js
index 74b80eec..c033a4e9 100644
--- a/src/services/database_service/database_module.js
+++ b/src/services/database_service/database_module.js
@@ -30,6 +30,8 @@ let { getDefaultConfig, getDockerContainerImageName, getDockerNetwork, validateP
let { getStatusFromContainer, addContainerToNetwork, forceRemoveContainerByName, probeContainerPresenceByName } = require('../docker_service')
let { statusChanged } = require('../status_service')
const { createDatabaseContainerRunArgs } = require('./container_run_args')
+const { notRunningHint } = require('./not_running_hint')
+const { resolveBufferPoolSize } = require('./buffer_pool_size')
let config = require('../../config');
let peers = require('../peer_services').bindPeerServices((file) => require(path.join('..', file)))
let { getLogger } = require('../../observability/logger');
@@ -42,6 +44,7 @@ let {
let { XCHAIN_NODE_DB_HOST, XCHAIN_NODE_DB_DEFAULT_PORT, getDatabaseContainerId, checkIfDatabaseModuleExists, checkIfDatabaseIsReady } = require('./container_access')
let { getExternalDbConfig, pingMariaDb } = require('./external_db')
let { askMariadbRootPassword, executeNativeMariaDbCommand, executeDockerMariaDbCommand } = require('./mariadb_exec')
+let { dbDatadirState, confirmPersistedDatadirRootPassword } = require('./persisted_datadir')
const nativeExecFile = execFile
function configureDependencies(dependencies) {
@@ -77,7 +80,8 @@ async function verifyExternalDatabase() {
// (moduleOperations.js RECREATE/update paths) and from `recreate`
// (RECREATE_UNSUPPORTED_MODULES), so no automated fleet path re-applies
// it. An already-installed DB keeps 10m x 3 until an operator tears the
-// container down and reinstalls it inside a maintenance window.
+// container down and reinstalls it inside a maintenance window; a datadir
+// pinned with XCHAIN_NODE_DB_DATA_DIR keeps its data and root password then.
function createDatabaseRunArgs(containerPrefix, environmentVariables, coin, network) {
const runArgs = createDatabaseContainerRunArgs(containerPrefix, logger)
// A visibility-only probe makes a stalled-but-alive mariadbd observable
@@ -129,7 +133,7 @@ function createDatabaseRunArgs(containerPrefix, environmentVariables, coin, netw
// straight to mysqld, so they persist across a container *recreate*,
// unlike a conf.d file written into a running container (its /etc isn't
// a mounted volume, so a recreate drops it). Each is unset by default,
-// leaving image defaults unchanged. Size them to the host: a busy
+// leaving image defaults unchanged, except the buffer pool and max-connections below. Size them to the host: a busy
// multi-replica box (e.g. a shared services host serving xchain-sync
// replicas across 18 DBs) wants a large buffer pool + a higher connection ceiling and
// can relax durable-log flushing; a laptop or single-chain node should
@@ -137,14 +141,16 @@ function createDatabaseRunArgs(containerPrefix, environmentVariables, coin, netw
// as the container command, not as `docker run` options.
function addDatabaseTuningArgs(runArgs) {
const dbTuningArgs = {
- XCHAIN_NODE_DB_BUFFER_POOL_SIZE: 'innodb-buffer-pool-size', // e.g. 16G
- XCHAIN_NODE_DB_MAX_CONNECTIONS: 'max-connections', // e.g. 300
+ XCHAIN_NODE_DB_MAX_CONNECTIONS: 'max-connections', // e.g. 300
XCHAIN_NODE_DB_FLUSH_LOG_AT_TRX_COMMIT: 'innodb-flush-log-at-trx-commit' // e.g. 2 (faster, less durable)
}
for (const [envVar, mysqldFlag] of Object.entries(dbTuningArgs)) {
const value = config.DB_TUNING_ENV[envVar]
if (value) runArgs.push(`--${mysqldFlag}=${value}`)
}
+ // The buffer pool is the other exception: it defaults to 1G (see buffer_pool_size.js)
+ // and a malformed XCHAIN_NODE_DB_BUFFER_POOL_SIZE falls back to that with a warning.
+ runArgs.push(`--innodb-buffer-pool-size=${resolveBufferPoolSize(config.DB_TUNING_ENV.XCHAIN_NODE_DB_BUFFER_POOL_SIZE, (msg) => logger.warn(msg))}`)
// max_connections is the exception to "unset = image default": the image's 151
// saturates on a shared multi-chain container (three chains' services plus one
// e2e run sit just under it, and any extra consumer tips it over with misleading
@@ -191,7 +197,8 @@ async function prepareDatabaseContainer(containerPrefix, dbHostPort) {
containerPrefix + "', which is not a confirmed absence. This install path force-removes that container " +
"and would orphan the database volume holding every module's data. Run `docker inspect " + containerPrefix +
"` and `docker ps -a` to see what is actually there, then re-run once docker answers cleanly. " +
- "If the container is genuinely dead and you want it rebuilt from empty, remove it yourself first."
+ "If the container is genuinely dead and you want it rebuilt, remove it yourself first; a datadir pinned with " +
+ "XCHAIN_NODE_DB_DATA_DIR survives that removal, its data and root password included."
)
}
try {
@@ -236,7 +243,12 @@ async function createDatabaseModule(coin, network) {
const { runArgs, dbHostPort } = createDatabaseRunArgs(containerPrefix, environmentVariables, coin, network)
addDatabaseTuningArgs(runArgs)
await prepareDatabaseContainer(containerPrefix, dbHostPort)
- return startDatabaseContainer(runArgs, mariadbRootPassword)
+ // Read before the start: a new container initialises an empty datadir, which then holds mysql/.
+ const persisted = dbDatadirState() === 'initialised'
+ const containerId = await startDatabaseContainer(runArgs, mariadbRootPassword)
+ // The image ignored MYSQL_ROOT_PASSWORD over a datadir that already held a database, so store one only once it authenticates.
+ if (persisted) await confirmPersistedDatadirRootPassword(containerId, mariadbRootPassword)
+ return containerId
}
// Existing-container branch. checkIfDatabaseModuleExists returns the id on
@@ -262,7 +274,7 @@ async function reuseDatabaseModule(existingId, coin, network) {
} catch { /* inspect failed: fall through and let the existing path surface it */ }
if (dbState && dbState !== 'running') {
const name = getDockerContainerImageName(DB_MODULE_NAME, coin, network)
- throw new Error("MariaDB container " + name + " exists but is " + dbState + "; run: docker start " + name)
+ throw new Error("MariaDB container " + name + " exists but is " + dbState + "; " + notRunningHint(dbState, name))
}
try {
if (coin && network) {
diff --git a/src/services/database_service/mariadb_exec.js b/src/services/database_service/mariadb_exec.js
index d99e0657..39368cf4 100644
--- a/src/services/database_service/mariadb_exec.js
+++ b/src/services/database_service/mariadb_exec.js
@@ -30,6 +30,7 @@ let logger = getLogger()
let { loadDbRootPassword, saveDbRootPassword } = require('../credentials_service')
let { checkIfDatabaseModuleExists, getDatabaseContainerPresence } = require('./container_access')
let { getExternalDbConfig } = require('./external_db')
+let { dbDatadirState, rootPasswordForPersistedDatadir } = require('./persisted_datadir')
const nativeExecFile = execFile
function configureDependencies(dependencies) {
@@ -275,7 +276,10 @@ async function askMariadbRootPassword(coin, network) {
}
const dbContainerId = await checkIfDatabaseModuleExists(coin, network)
- if (!dbContainerId) await requireConfirmedDbAbsence()
+ if (!dbContainerId) {
+ await requireConfirmedDbAbsence()
+ if (dbDatadirState() === 'initialised') return rootPasswordForPersistedDatadir()
+ }
const configured = await rootPasswordFromConfig(dbContainerId)
if (configured) return configured
const fromContainer = await rootPasswordFromContainer(dbContainerId)
diff --git a/src/services/database_service/not_running_hint.js b/src/services/database_service/not_running_hint.js
new file mode 100644
index 00000000..4f26a898
--- /dev/null
+++ b/src/services/database_service/not_running_hint.js
@@ -0,0 +1,26 @@
+'use strict'
+
+// Copyright © 2025-2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC - https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+// Pick the recovery hint for a MariaDB container that exists but is not running, by its Docker state.
+// Every hint is read-only or a plain resume: none removes, recreates or auto-starts the container.
+function notRunningHint(dbState, name) {
+ // `docker start` acts only on a stopped (exited) or never-started (created) container.
+ if (dbState === 'exited' || dbState === 'created') return "run: docker start " + name
+ // A crash-loop under --restart unless-stopped: start does nothing, and the cause is in the logs.
+ if (dbState === 'restarting') return "it is crash-looping; find the cause with: docker logs " + name
+ // Docker refuses to start a paused container; it must be unpaused.
+ if (dbState === 'paused') return "run: docker unpause " + name
+ // Any other state (dead, removing) needs a look before anything is started or removed.
+ return "inspect it with: docker inspect " + name + " and docker logs " + name
+}
+
+module.exports = { notRunningHint }
diff --git a/src/services/database_service/persisted_datadir.js b/src/services/database_service/persisted_datadir.js
new file mode 100644
index 00000000..c69e1ba8
--- /dev/null
+++ b/src/services/database_service/persisted_datadir.js
@@ -0,0 +1,120 @@
+/*********************************************************************
+ *
+ * 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 - Database Service
+ * Root password for a DB container recreated over a persisted datadir
+ ********************************************************************/
+
+const fs = require('fs')
+const path = require('path')
+let { execFile } = require('child_process')
+const { promisify } = require('util')
+let execFileAsync = promisify(execFile)
+let { Password } = require('enquirer')
+let { setDbRootPassword } = require('../../state')
+let { dockerMariadbArgs, mariadbEnv } = require('../../utils/docker_mariadb')
+let { sleep } = require('../../utils/helpers')
+let config = require('../../config')
+let { getLogger } = require('../../observability/logger')
+let logger = getLogger()
+let { loadDbRootPassword, saveDbRootPassword } = require('../credentials_service')
+
+const nativeExecFile = execFile
+function configureDependencies(dependencies) {
+ if (dependencies.execFile === nativeExecFile) return
+ ;({ execFile, execFileAsync, Password, setDbRootPassword, dockerMariadbArgs, mariadbEnv, sleep, config, logger, loadDbRootPassword, saveDbRootPassword } = dependencies)
+}
+
+// Say whether the datadir a new DB container mounts already holds a database ('initialised') or
+// will be initialised from MYSQL_ROOT_PASSWORD ('fresh'). The mariadb image ignores that variable
+// when /mysql exists, so the old root password stays in force there.
+function dbDatadirState() {
+ const dir = config.XCHAIN_NODE_DB_DATA_DIR
+ if (!dir) return 'fresh'
+ // Count a docker named volume as initialised: it persists and cannot be inspected from here.
+ if (!path.isAbsolute(dir)) return 'initialised'
+ try {
+ fs.statSync(path.join(dir, 'mysql'))
+ return 'initialised'
+ } catch (err) {
+ // Only a confirmed absence is fresh; EACCES and the like may hide an initialised datadir.
+ return err && err.code === 'ENOENT' ? 'fresh' : 'initialised'
+ }
+}
+
+// Pick the root password for a container recreated over an initialised datadir, saving nothing:
+// it is checked against the running container (confirmPersistedDatadirRootPassword) before it is stored.
+async function rootPasswordForPersistedDatadir() {
+ const dir = config.XCHAIN_NODE_DB_DATA_DIR
+ const stored = loadDbRootPassword()
+ const envPassword = config.XCHAIN_NODE_DB_ROOT_PASSWORD
+ if (stored && envPassword && stored !== envPassword) {
+ logger.warn('WARNING: XCHAIN_NODE_DB_ROOT_PASSWORD differs from the stored root password; the stored copy is '
+ + 'tried first and both are checked once the MariaDB container is up.')
+ }
+ logger.info(`The MariaDB datadir at ${dir} already holds a database, so its existing root password stays in `
+ + 'force; the image ignores MYSQL_ROOT_PASSWORD on an initialised datadir.')
+ let candidate = stored || envPassword
+ if (!candidate) {
+ if (!process.stdin.isTTY) {
+ throw new Error(`The MariaDB datadir at ${dir} (XCHAIN_NODE_DB_DATA_DIR) already holds a database, so its `
+ + 'existing root password is needed, and neither credentials.json nor XCHAIN_NODE_DB_ROOT_PASSWORD '
+ + 'supplies one and there is no TTY to prompt on. Set XCHAIN_NODE_DB_ROOT_PASSWORD to that '
+ + 'datadir\'s root password and re-run. No root password was set or saved.')
+ }
+ candidate = await new Password({ name: 'password',
+ message: `The MariaDB datadir at ${dir} already holds a database. Type its existing root password` }).run()
+ }
+ setDbRootPassword(candidate)
+ return candidate
+}
+
+// Ask the DB container whether `password` is root's: 'yes', 'no' (the server refused it) or 'down'.
+async function probeRootPassword(containerId, password) {
+ try {
+ const { stdout, stderr } = await execFileAsync('docker',
+ dockerMariadbArgs(containerId, ['mariadb-admin', '-u', 'root', 'ping']), { env: mariadbEnv(password) })
+ if (String(stdout).includes('mysqld is alive')) return 'yes'
+ return /access denied/i.test(String(stderr) + String(stdout)) ? 'no' : 'down'
+ } catch (err) {
+ return /access denied/i.test(String(err && (err.stderr || err.message))) ? 'no' : 'down'
+ }
+}
+
+// Store a root password for a container created over an initialised datadir only once it
+// authenticates, trying the chosen one, then the stored copy, then XCHAIN_NODE_DB_ROOT_PASSWORD.
+async function confirmPersistedDatadirRootPassword(containerId, chosen, { tries = 30, retryDelay = 2000 } = {}) {
+ const candidates = [chosen, loadDbRootPassword(), config.XCHAIN_NODE_DB_ROOT_PASSWORD]
+ .filter((p, i, all) => p && all.indexOf(p) === i)
+ for (const candidate of candidates) {
+ let answer = 'down'
+ for (let left = tries; left > 0 && answer === 'down'; left--) {
+ answer = await probeRootPassword(containerId, candidate)
+ if (answer === 'down' && left > 1) await sleep(retryDelay)
+ }
+ if (answer === 'down') {
+ throw new Error('The new MariaDB container did not answer a root ping, so its root password could not be '
+ + 'checked; credentials.json was left untouched. Check `docker logs` for the database container.')
+ }
+ if (answer === 'yes') {
+ setDbRootPassword(candidate)
+ saveDbRootPassword(candidate)
+ return candidate
+ }
+ }
+ throw new Error(`The MariaDB datadir at ${config.XCHAIN_NODE_DB_DATA_DIR} already holds a database whose root `
+ + 'password is neither the stored copy nor XCHAIN_NODE_DB_ROOT_PASSWORD; credentials.json was left '
+ + 'untouched. Set XCHAIN_NODE_DB_ROOT_PASSWORD to that datadir\'s root password and re-run.')
+}
+
+module.exports = { dbDatadirState, rootPasswordForPersistedDatadir, confirmPersistedDatadirRootPassword, configureDependencies }
diff --git a/src/services/database_service/reset_databases.js b/src/services/database_service/reset_databases.js
index bd65dcdd..bab213f3 100644
--- a/src/services/database_service/reset_databases.js
+++ b/src/services/database_service/reset_databases.js
@@ -23,6 +23,7 @@ let { assertSafeDbIdentifier, escapeSqlStringLiteral } = require('../../utils/sq
let { tableExistsSql } = require('../../db/information_schema')
let { PRICE_FENCE_TABLE, FENCE_NETWORK_COLUMN, clearChainFenceSql, clearNetworkFenceSql, manualClearStatement } = require('../../db/price_fence')
let { getDefaultConfig, getModuleDatabaseName } = require('../config_service')
+const { DATABASE_NAME_KEY, configuredDatabaseName } = require('../../utils/module_database_name')
let { getLogger } = require('../../observability/logger')
let logger = getLogger()
let { getDatabaseContainerId } = require('./container_access')
@@ -54,20 +55,10 @@ function configureDependencies(dependencies) {
// throw with the first module's database already dropped. This also covers
// an operator-supplied name, which is the only untrusted one.
async function resolveResetTargets(coin, network, modules) {
- 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'))
+ const cfg = DATABASE_NAME_KEY[module] ? await getDefaultConfig(module, coin, network) : undefined
+ resetTargets.push(configuredDatabaseName(module, cfg, getModuleDatabaseName(module, coin, network)))
}
return resetTargets
}
@@ -282,4 +273,4 @@ function warnPriceFenceNotCleared(ticker, fenceNetwork, reason) {
+ ticker + " in place.")
}
-module.exports = { resetDatabases, clearHubPriceIngestWatermark, configureDependencies }
+module.exports = { resetDatabases, clearHubPriceIngestWatermark, configureDependencies, resolveResetTargets }
diff --git a/src/services/discovery_service.js b/src/services/discovery_service.js
index 49d071b3..79a39478 100644
--- a/src/services/discovery_service.js
+++ b/src/services/discovery_service.js
@@ -47,25 +47,21 @@ function logIfNotSilent(silent, message) {
if (!silent) logger.info(message)
}
+// Read a container's name the way docker reports it: first entry of an array, leading slash dropped.
+function containerName(container) {
+ return String((Array.isArray(container.Names) ? container.Names[0] : container.Names) || '').replace(/^\//, '')
+}
+
// Resolve a docker container to its registry identity { module, coin, network }
-// (coin/network are '' for shared modules), or null for non-xchain containers.
-//
-// Identity comes from the CONTAINER NAME, the stable key the CLI itself
-// assigns at creation (and what the file header above promises). The image
-// tag is NOT reliable: rebuilding a module's image while the old container is
-// still running steals the tag, docker then reports that container's image as
-// a bare ID, and an image-keyed scan goes blind to a live, healthy container.
-// Worse, the orphan purge below then DELETES its registry row, after which
-// update/start/stop silently no-op for it (a litecoin-mainnet
-// xchain-indexer incident, 2026-06-11). The image name remains a fallback for
-// containers that were renamed externally.
+// (coin/network are '' for shared modules), or null for any other container.
+// Identity is the container NAME the CLI assigns; the image tag can be stolen by a rebuild.
function classifyContainer(container) {
- const name = String((Array.isArray(container.Names) ? container.Names[0] : container.Names) || '').replace(/^\//, '')
- const image = String(container.Image || '')
- const identity = name.startsWith(NODE_PREFIX + SEP) ? name : image
- if (!identity.startsWith(NODE_PREFIX + SEP)) return null
+ const name = containerName(container)
+ // Refuse every name outside this install's scheme, whatever image it runs:
+ // another stack on the host may run the same module images under its own names.
+ if (!name.startsWith(NODE_PREFIX + SEP)) return null
- const rest = identity.substr(NODE_PREFIX.length + SEP.length)
+ const rest = name.substr(NODE_PREFIX.length + SEP.length)
if (SHARED_MODULES.includes(rest)) return { module: rest, coin: "", network: "" }
const parts = rest.split(SEP)
@@ -115,14 +111,9 @@ function moduleKey(module, coin, network) {
async function registerContainers(containers, silent, seen) {
let added = 0
let reconciled = 0
- // Sort containers so RUNNING ones come first. When multiple containers
- // share the same expected key (e.g. an old exited container and a fresh
- // running one), the running ID wins on the upsert.
- containers.sort((a, b) => {
- const ar = (a.State || '').toLowerCase() === 'running' ? 0 : 1
- const br = (b.State || '').toLowerCase() === 'running' ? 0 : 1
- return ar - br
- })
+ // Rank RUNNING first (stable, so docker's newest-first breaks ties) before first-seen wins.
+ const rank = (c) => ((c.State || '').toLowerCase() === 'running' ? 0 : 1)
+ containers.sort((a, b) => rank(a) - rank(b))
for (const nextContainer of containers) {
const identity = classifyContainer(nextContainer)
@@ -130,11 +121,9 @@ async function registerContainers(containers, silent, seen) {
const { module, coin, network } = identity
const label = (coin || network) ? (coin + SEP + network + SEP + module) : module
- // First-seen wins the upsert. Because the list is sorted RUNNING-first,
- // the running container reconciles the row first; any later duplicate
- // (e.g. a stopped container that classifies to the same key via the
- // image fallback) is skipped so it cannot overwrite the live ID with a
- // dead one. The key still records into `seen` for the orphan purge.
+ // First-seen wins the upsert, so the ranking above decides which ID holds the row;
+ // a later duplicate is skipped so it cannot overwrite it. The key still records
+ // into `seen` for the orphan purge.
const key = moduleKey(module, coin, network)
if (seen.has(key)) continue
seen.add(key)
diff --git a/src/services/migration_precondition_service.js b/src/services/migration_precondition_service.js
index d2dcaf4f..f3b0af1a 100644
--- a/src/services/migration_precondition_service.js
+++ b/src/services/migration_precondition_service.js
@@ -54,7 +54,9 @@
const { XChainService } = require('../config')
const { migrationsDirOf } = require('../utils/migration_files')
-const { getModuleTmpDir, getModuleDatabaseName, getDockerContainerImageName } = require('./config_service')
+const { getModuleTmpDir, getModuleDatabaseName, getDockerContainerImageName, getDefaultConfig } = require('./config_service')
+const { configuredDatabaseName, unscopedDefaultDatabaseName } = require('../utils/module_database_name')
+const appConfig = require('../config')
const config = require('../config');
const { getLogger } = require('../observability/logger');
const logger = getLogger();
@@ -151,6 +153,34 @@ async function readRefusalRemedy(module, coin, network, applied, deps) {
}
}
+// A non-default NODE_PREFIX stack still on the unprefixed default name shares that
+// database with the default stack; deploys refuse it, this surfaces it before then.
+function warnUnscopedDefaultName(module, coin, network, name) {
+ const { NODE_PREFIX, DEFAULT_NODE_PREFIX, CoinTickerSymbol, DB_SEP } = appConfig
+ if (NODE_PREFIX === DEFAULT_NODE_PREFIX || !CoinTickerSymbol || CoinTickerSymbol[coin] === undefined) return
+ if (name !== unscopedDefaultDatabaseName(module, CoinTickerSymbol[coin], network, DB_SEP)) return
+ logger.warn(`Migration precondition guard: ${module} uses the unprefixed default database ${name} ` +
+ `under NODE_PREFIX "${NODE_PREFIX}"; it is shared with the default "${DEFAULT_NODE_PREFIX}" stack.`)
+}
+
+/**
+ * The database the service reads: the operator-configured name when set, else the
+ * derived default. An unreadable config falls back to the default with a warning.
+ */
+async function resolveCheckedDatabaseName(module, coin, network, deps) {
+ const defaultName = getModuleDatabaseName(module, coin, network)
+ try {
+ const readConfig = deps.getDefaultConfig || getDefaultConfig
+ const name = configuredDatabaseName(module, await readConfig(module, coin, network), defaultName)
+ warnUnscopedDefaultName(module, coin, network, name)
+ return name
+ } catch (err) {
+ logger.warn(`Migration precondition guard: could not read ${module}'s configured database name ` +
+ `(${err && err.message ? err.message : err}); using the default ${defaultName}.`)
+ return defaultName
+ }
+}
+
/**
* Refuses (throws) when the source tree about to be deployed declares a migration
* as a startup precondition that the target database has not applied.
@@ -194,7 +224,7 @@ async function assertRequiredMigrationsApplied(module, coin, network, branch = n
}
if (!required.length) return { checked: false, reason: 'no-preconditions' }
- const dbName = getModuleDatabaseName(module, coin, network)
+ const dbName = await resolveCheckedDatabaseName(module, coin, network, deps)
const result = await readApplied({ database: dbName, coin, network })
if (result.state === 'empty-database') return emptyDatabaseResult(module, dbName, required)
@@ -202,13 +232,18 @@ async function assertRequiredMigrationsApplied(module, coin, network, branch = n
// See the ledger the way the target's runner will after its boot re-key, so a
// renamed file recorded under its old name is not refused as missing.
- const applied = rekeyApplied(result.applied, readTargetLedgerRenames(module, deps))
+ const renames = readTargetLedgerRenames(module, deps)
+ const applied = rekeyApplied(result.applied, renames)
const missing = required.filter(f => !applied.has(f))
if (missing.length) {
+ // The blast radius subtracts only recorded names: the runner still executes a file the schema matches,
+ // unless its MIGRATION_PRECONDITIONS entry baselines it (see recorded_ledger.js), so the list can overcount.
+ const recorded = result.ledger ? rekeyApplied(result.ledger, renames) : applied
// Only reached on the refusal path, so the probe costs a healthy deploy
// nothing and cannot introduce a new way for one to fail: both the probe
// and the pending-scan degrade to the cautious branch of the message.
- const remedy = await readRefusalRemedy(module, coin, network, applied, deps)
+ const remedy = await readRefusalRemedy(module, coin, network, recorded, deps)
+ remedy.schemaMatched = new Set([...applied].filter(f => !recorded.has(f)))
throw new Error(refusalMessage(module, coin, network, dbName, missing, remedy))
}
@@ -228,5 +263,6 @@ module.exports = {
// database returning `unreadable` rather than throwing past the guard, and
// that is a property of the real driver call, not of a stub.
readAppliedMigrations,
- assertRequiredMigrationsApplied
+ assertRequiredMigrationsApplied,
+ resolveCheckedDatabaseName
}
diff --git a/src/services/migration_precondition_service/migration_scan.js b/src/services/migration_precondition_service/migration_scan.js
index 68c0fe52..25beb408 100644
--- a/src/services/migration_precondition_service/migration_scan.js
+++ b/src/services/migration_precondition_service/migration_scan.js
@@ -18,6 +18,7 @@ const path = require('path')
const { EXTERNAL_DB, XChainService } = require('../../config')
const { tableCountSql, tableExistsSql } = require('../../db/information_schema')
const { appliedMigrationsSql } = require('../../db/migrations')
+const { readRecordedNames, wouldApplyNote } = require('./recorded_ledger')
const { migrationFiles } = require('../../utils/migration_files')
const { getDockerContainerImageName } = require('../config_service')
const { readMigrateCli, migrateCliPathFor, migrateCliPathsFor, decoderStatusRows } = require('../../utils/indexer_migrate_cli')
@@ -209,7 +210,8 @@ async function runningBuildSupportsPerFileMigrations(container, deps = {}, modul
/**
* The gated files an unscoped migrate run inside the running build would apply:
* its own pending rows (a row with no mode is the runners' manual default),
- * minus any whose end state the database already embodies.
+ * minus any the ledger records (re-keyed for renames). A schema-matched file with no
+ * ledger row still runs, unless its MIGRATION_PRECONDITIONS entry baselines it (recorded_ledger.js).
*/
function runningBuildPendingManual(rows, applied) {
return rows.filter(row => !row.applied && (row.mode == null || row.mode === 'manual') &&
@@ -241,7 +243,8 @@ function databaseLiteral(database) {
/**
* Read the applied-migration ledger of one module database.
*
- * Returns { state: 'ledger', applied: Set } when the ledger was read,
+ * Returns { state: 'ledger', applied: Set, ledger: Set|null } when
+ * the ledger was read (`applied` adds an indexer's schema-satisfied names, `ledger` does not),
* { state: 'empty-database' } when the schema holds no tables at all (or does
* not exist yet), and { state: 'unreadable', reason } for everything else. The
* three are deliberately distinct: only the middle one is safe to proceed on.
@@ -313,7 +316,8 @@ async function readAppliedMigrations({ database, coin, network }, deps = {}) {
const out = String(await runner(appliedMigrationsSql(database, LEDGER_TABLE)))
const applied = new Set(out.split('\n').map(s => s.trim()).filter(Boolean))
- return { state: 'ledger', applied }
+ // Keep the recorded names apart: the runner executes every pending file the ledger lacks unless MIGRATION_PRECONDITIONS baselines it.
+ return { state: 'ledger', applied, ledger: await readRecordedNames(runner, database, LEDGER_TABLE, applied) }
} catch (err) {
return { state: 'unreadable', reason: (err && err.message) ? err.message : String(err) }
}
@@ -363,7 +367,7 @@ function refusalMessage(module, coin, network, dbName, missing, remedy = {}) {
: 'Whether that container\'s build honours --file could not be read, and an unverified capability is not one: it may ignore --file') +
' and apply EVERY pending manual migration on ' + dbName + ', which is ' +
wouldApply.length + ' file(s)' + source + ':\n' +
- wouldApply.map(f => ' ' + f + (carried.includes(f) ? ' (the one you need)' : '')).join('\n') + '\n' +
+ wouldApply.map(f => ' ' + f + wouldApplyNote(f, carried, remedy)).join('\n') + '\n' +
' Apply ' + (plural ? 'the needed files' : 'the needed file') + ' with a build that supports ' +
'--file, or apply the statement by hand with the writer quiesced, then re-run the update.' +
notCarriedNote
diff --git a/src/services/migration_precondition_service/recorded_ledger.js b/src/services/migration_precondition_service/recorded_ledger.js
new file mode 100644
index 00000000..4001572f
--- /dev/null
+++ b/src/services/migration_precondition_service/recorded_ledger.js
@@ -0,0 +1,50 @@
+/*********************************************************************
+ *
+ * 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 - Migration precondition guard: the recorded ledger
+ *
+ * An indexer database can embody a manual migration's end state with no
+ * ledger row for it. The guard counts that as applied when deciding what is
+ * missing, but the indexer runner decides from the ledger, so an unscoped
+ * migrate run still executes such a file. The exception is a file listed in
+ * that runner's MIGRATION_PRECONDITIONS: when its predicate finds the end
+ * state already live, the runner records it as applied without running a
+ * statement, while an older build lacking that entry still executes it. The
+ * refusal's list of what that run would apply is therefore built from the
+ * names the ledger records, and can overcount by such a baselined file.
+ ********************************************************************/
+
+const { recordedMigrationsSql } = require('../../db/migration_ledger')
+
+// The names the ledger alone records, or null when that read fails (callers fall back to `applied`).
+// A non-indexer database has no schema-satisfied names, so its applied set already is the ledger.
+async function readRecordedNames(runner, database, ledgerTable, applied) {
+ if (!/_Indexer$/.test(database)) return applied
+ try {
+ const out = String(await runner(recordedMigrationsSql(database, ledgerTable)))
+ return new Set(out.split('\n').map(s => s.trim()).filter(Boolean))
+ } catch {
+ return null
+ }
+}
+
+// The note beside one file in the "an unscoped run would apply" list.
+function wouldApplyNote(file, carried, remedy) {
+ if (carried.includes(file)) return ' (the one you need)'
+ if (remedy.schemaMatched && remedy.schemaMatched.has(file)) {
+ return ' (the live schema already matches it; no ledger row records it)'
+ }
+ return ''
+}
+
+module.exports = { readRecordedNames, wouldApplyNote }
diff --git a/src/services/module_service/build_and_up.js b/src/services/module_service/build_and_up.js
index 0ad24457..7ac75be3 100644
--- a/src/services/module_service/build_and_up.js
+++ b/src/services/module_service/build_and_up.js
@@ -44,38 +44,32 @@ let logger = getLogger();
function configureDependencies(dependencies) {
;({
- execFile, execFileAsync, fs, path, HUB_MODULE_NAME, LIBRARY_BUNDLES, db,
- getModuleDir, checkIfModuleExists, getDockerContainerImageName,
- getDockerNetwork, getDefaultConfig, validatePort, stopContainerByName,
- removeContainer, forceRemoveContainerByName, checkBuildKitAvailable,
- stopModuleContainer, stopTimeoutArgs, statusChanged, setHubDatabaseParameters,
- redactSecrets, config, goLiveGate, memoryLimitService,
- hubConsensusEnvGuard, rollcallWiring, readCheckoutIdentityFromDisk,
- cloneGit, resolveBundledLibRef, assertNoHostPortConflicts,
- resolveObservabilityEnv, buildHealthcheckArgs, buildModuleDockerArgs,
- attachCrossChainNetworks, verifyContainerMemoryLimit,
+ execFile, execFileAsync, fs, path, HUB_MODULE_NAME, LIBRARY_BUNDLES, db, getModuleDir,
+ checkIfModuleExists, getDockerContainerImageName, getDockerNetwork, getDefaultConfig, validatePort,
+ stopContainerByName, removeContainer, forceRemoveContainerByName, checkBuildKitAvailable,
+ stopModuleContainer, stopTimeoutArgs, statusChanged, setHubDatabaseParameters, redactSecrets, config,
+ goLiveGate, memoryLimitService, hubConsensusEnvGuard, rollcallWiring, readCheckoutIdentityFromDisk,
+ cloneGit, resolveBundledLibRef, assertNoHostPortConflicts, resolveObservabilityEnv,
+ buildHealthcheckArgs, buildModuleDockerArgs, attachCrossChainNetworks, verifyContainerMemoryLimit,
logDockerCreateWarnings, logger
} = dependencies)
}
-/**
- * Build the module image and (re)create its container from the current config.
- *
- * `options.reuseImage` keeps the image that is already tagged for this container and
- * skips both the bundled-library re-clone and `docker build`. A container freezes its
- * env at `docker run`, so the ONLY way to correct a credential it carries is to
- * recreate it; without this flag that correction also drags in whatever GitHub HEAD
- * holds today, turning a credential repair into an unreviewed version bump.
- *
- * @param {string} module
- * @param {string|null} coin
- * @param {string|null} network
- * @param {string|null} [overwriteContainerId]
- * @param {boolean} [onlyExecution]
- * @param {string[]|null} [dockerCmdArgs]
- * @param {{reuseImage?: boolean}} [options]
- * @returns {Promise} the new container id
- */
+// Decoder/indexer DB names carry no NODE_PREFIX. On an external MariaDB a non-default-prefix
+// stack still on the unprefixed default name would share the default stack's data; refuse early.
+function assertNoLegacyDbNameCollision(module, coin, network, environmentVariables) {
+ const key = { 'xchain-decoder': 'DECODER_DB_NAME', 'xchain-indexer': 'INDEXER_DB_NAME' }[module]
+ if (!key || !config.EXTERNAL_DB || config.NODE_PREFIX === config.DEFAULT_NODE_PREFIX) return
+ if (!coin || !network || config.CoinTickerSymbol[coin] === undefined) return
+ const cap = (str) => str.charAt(0).toUpperCase() + str.slice(1)
+ const legacy = ['XChain', config.CoinTickerSymbol[coin], cap(network), cap(module.slice(7))].join(config.DB_SEP)
+ if (environmentVariables[key] !== legacy) return
+ throw new Error(`${key}=${legacy} is the unprefixed default database name, but NODE_PREFIX is "${config.NODE_PREFIX}" ` +
+ `and the database is external, so this stack would share it with the default "${config.DEFAULT_NODE_PREFIX}" stack. ` +
+ `Set ${key} to a name unique to this stack.`)
+}
+
async function assertDeploymentReady(module, coin, network, environmentVariables, dir, onlyExecution) {
+ assertNoLegacyDbNameCollision(module, coin, network, environmentVariables)
// Go-live pre-flight: warns pre-launch, refuses a mainnet write-surface
// deploy with un-armed settings once XCHAIN_NODE_GO_LIVE=1.
const { assertGoLiveReady } = goLiveGate
@@ -140,11 +134,7 @@ async function stageBundledLibraries(module, dir, reuseImage) {
fs.cpSync(libSrc, libDest, {
recursive: true,
force: true,
- filter: (src) => {
- const base = path.basename(src)
- return base !== "node_modules" && base !== ".git" &&
- base !== "test" && base !== "bench" && base !== "reports"
- }
+ filter: (src) => !["node_modules", ".git", "test", "bench", "reports"].includes(path.basename(src))
})
}
}
@@ -172,10 +162,7 @@ async function resolveMemoryOptions(module, coin, network, onlyExecution) {
const trackerCount = await countInstalledTrackers(db, { coin, network })
const memory = memoryArgsFor(module, { trackerCount, coin, network })
if (memory.note) logger.info(memory.note)
- return {
- memoryArgs: memory.args,
- memoryLimitMb: memory.args.length > 0 ? memory.mb : null
- }
+ return { memoryArgs: memory.args, memoryLimitMb: memory.args.length > 0 ? memory.mb : null }
}
// Validate all port values, parsed with the conflict check's grammar; refuse a
// host-interface (IP-scoped) spec, which no configured module port may carry.
@@ -198,10 +185,8 @@ async function assertReusableImage(reuseImage, containerPrefix, module, coin, ne
try {
await execFileAsync('docker', ['image', 'inspect', '--format', '{{.Id}}', containerPrefix])
} catch {
- throw new Error(
- "No local image tagged " + containerPrefix + " to reuse; run `update " + module +
- (coin && network ? " " + coin + " " + network : "") + "` to build one."
- )
+ throw new Error("No local image tagged " + containerPrefix + " to reuse; run `update " + module +
+ (coin && network ? " " + coin + " " + network : "") + "` to build one.")
}
}
@@ -367,13 +352,29 @@ function buildOrCreateContainer(context, resolve, reject) {
function launchContainer(context) {
return new Promise((resolve, reject) => buildOrCreateContainer(context, resolve, reject))
}
+/**
+ * Build the module image and (re)create its container from the current config.
+ *
+ * `options.reuseImage` keeps the image that is already tagged for this container and
+ * skips both the bundled-library re-clone and `docker build`. A container freezes its
+ * env at `docker run`, so the ONLY way to correct a credential it carries is to
+ * recreate it; without this flag that correction also drags in whatever GitHub HEAD
+ * holds today, turning a credential repair into an unreviewed version bump.
+ *
+ * @param {string} module
+ * @param {string|null} coin
+ * @param {string|null} network
+ * @param {string|null} [overwriteContainerId]
+ * @param {boolean} [onlyExecution]
+ * @param {string[]|null} [dockerCmdArgs]
+ * @param {{reuseImage?: boolean}} [options]
+ * @returns {Promise} the new container id
+ */
async function buildAndUp(module, coin, network, overwriteContainerId = null, onlyExecution = false, dockerCmdArgs = null, options = {}) {
if (!checkIfModuleExists(module)) throw "module not found"
const reuseImage = options.reuseImage === true
if (!reuseImage && typeof checkBuildKitAvailable === 'function') {
- try {
- await checkBuildKitAvailable()
- } catch (err) { throw "Error creating Docker image: " + err }
+ await checkBuildKitAvailable().catch((err) => { throw "Error creating Docker image: " + err })
}
let environmentVariables = await getDefaultConfig(module, coin, network)
const dir = getModuleDir(module)
diff --git a/src/services/module_service/docker_args.js b/src/services/module_service/docker_args.js
index ca08c8bb..5d98d40a 100644
--- a/src/services/module_service/docker_args.js
+++ b/src/services/module_service/docker_args.js
@@ -119,7 +119,7 @@ async function assertNoHostPortConflicts(portArgs, selfName) {
// (--health-start-period); set long enough for npm start + DB connect
// autoheal - opt-in flag consumed by AutohealService/`xchain-node autoheal`:
// when true, a container that stays unhealthy past the grace window
-// gets `docker restart`ed. Default OFF. Only set it where a restart
+// is restarted (a budgeted stop, then a start). Default OFF. Only set it where a restart
// plausibly clears the wedge (stalled event loop, dead DB pool).
// NEVER set it on xchain-utxo-tracker: that service deliberately
// enters a stable halted state (503 while halted) instead of
diff --git a/src/services/release_manifest_service.js b/src/services/release_manifest_service.js
index 01fba236..10a40e15 100644
--- a/src/services/release_manifest_service.js
+++ b/src/services/release_manifest_service.js
@@ -29,9 +29,12 @@
const fs = require('fs')
const path = require('path')
const axios = require('axios')
+const { version: installedNodeVersion } = require('../../package.json')
const { githubApiHeaders, githubRateLimitError, GITHUB_API_TIMEOUT_MS, GITHUB_DOWNLOAD_TIMEOUT_MS } = require('../utils/github_api')
const { verifyManifestForTag } = require('./release_signature_service')
+const { allowMajorDowngradeForRun, assertMajorDowngradeAllowed } = require('./self_update_service')
+const { hostEnv } = require('../config')
const { getLogger } = require('../observability/logger');
const logger = getLogger();
@@ -254,9 +257,15 @@ async function resolveLatestReleaseTag() {
* @returns {Promise<{kind:'release'|'branch', ref:string, tag:string|null,
* manifest:object|null, resolvedFrom:string}>}
*/
-async function resolveInstallTarget(ref, { defaultBranch = 'master', fallbackToBranch = true } = {}) {
+async function resolveInstallTarget(ref, {
+ defaultBranch = 'master', fallbackToBranch = true, env = hostEnv(),
+ logger: resolutionLogger = logger, installedVersion = installedNodeVersion
+} = {}) {
+ const allowMajorDowngrade = allowMajorDowngradeForRun({ env, logger: resolutionLogger })
+
if (isReleaseRef(ref)) {
const tag = ref.trim()
+ assertMajorDowngradeAllowed(installedVersion, tag, allowMajorDowngrade)
return {
kind: 'release',
ref: tag,
@@ -290,6 +299,8 @@ async function resolveInstallTarget(ref, { defaultBranch = 'master', fallbackToB
return { kind: 'branch', ref: defaultBranch, tag: null, manifest: null, resolvedFrom: 'no published release' }
}
+ assertMajorDowngradeAllowed(installedVersion, tag, allowMajorDowngrade)
+
return {
kind: 'release',
ref: tag,
diff --git a/src/services/self_update_service.js b/src/services/self_update_service.js
index ae3f3635..ae6b8c21 100644
--- a/src/services/self_update_service.js
+++ b/src/services/self_update_service.js
@@ -46,13 +46,14 @@ const { execFile, spawn } = require('child_process')
const { promisify } = require('util')
const execFileAsync = promisify(execFile)
-const { dataDir, SELF_UPDATE_ENV, childProcessEnv } = require('../config')
+const { dataDir, SELF_UPDATE_ENV, childProcessEnv, hostEnv } = require('../config')
const { getLogger } = require('../observability/logger')
const { adoptCommandLock } = require('../utils/command_lock')
const CARRIER_ROOT = path.join(__dirname, '../..')
const TARGET_ENV = 'XCHAIN_NODE_UPDATE_TARGET'
const NO_SELF_UPDATE_ENV = 'XCHAIN_NODE_NO_SELF_UPDATE'
+const ALLOW_MAJOR_DOWNGRADE_ENV = 'XCHAIN_NODE_ALLOW_MAJOR_DOWNGRADE'
const CHECK_CACHE_FILE = 'release-check.json'
const CHECK_TTL_MS = 60 * 60 * 1000
const CHECK_TIMEOUT_MS = 5000
@@ -70,6 +71,31 @@ function currentVersion() {
return require('../../package.json').version
}
+function versionMajor(version) {
+ const match = /^v?(\d+)(?:\.|$)/.exec(String(version || '').trim())
+ return match ? Number(match[1]) : null
+}
+
+function allowMajorDowngradeForRun({ env = hostEnv(), logger = defaultLogger() } = {}) {
+ const allowed = /^(1|true|yes)$/i.test(env[ALLOW_MAJOR_DOWNGRADE_ENV] || '')
+ if (allowed) {
+ logger.warn(`WARNING: ${ALLOW_MAJOR_DOWNGRADE_ENV}=1 is set; major-version downgrade protection is disabled for this run.`)
+ }
+ return allowed
+}
+
+function assertMajorDowngradeAllowed(installedVersion, targetVersion, allowed = false) {
+ const installedMajor = versionMajor(installedVersion)
+ const targetMajor = versionMajor(targetVersion)
+ if (allowed || installedMajor == null || targetMajor == null || targetMajor >= installedMajor) return
+
+ throw new Error(
+ `Refusing release ${targetVersion} because its major version ${targetMajor} is below the installed`
+ + ` xchain-node major version ${installedMajor} (v${versionOfTag(installedVersion)}). Set`
+ + ` ${ALLOW_MAJOR_DOWNGRADE_ENV}=1 to override this protection. Nothing was changed.`
+ )
+}
+
function versionOfTag(tag) {
return String(tag || '').trim().replace(/^v/, '')
}
@@ -182,6 +208,9 @@ async function selfUpdateAndReexec({ tag, childArgs, deps = {} }) {
const env = deps.env || SELF_UPDATE_ENV
const logger = deps.logger || defaultLogger()
const version = deps.currentVersion ? deps.currentVersion() : currentVersion()
+ const allowMajorDowngrade = allowMajorDowngradeForRun({ env: deps.env || hostEnv(), logger })
+
+ assertMajorDowngradeAllowed(version, tag, allowMajorDowngrade)
if (env[TARGET_ENV]) return { moved: false, reason: 'already-reexecuted' }
if (selfUpdateDisabled(env)) {
@@ -302,11 +331,15 @@ module.exports = {
CARRIER_ROOT,
TARGET_ENV,
NO_SELF_UPDATE_ENV,
+ ALLOW_MAJOR_DOWNGRADE_ENV,
CHECK_CACHE_FILE,
CHECK_TTL_MS,
currentVersion,
+ versionMajor,
versionOfTag,
compareVersions,
+ allowMajorDowngradeForRun,
+ assertMajorDowngradeAllowed,
selfUpdateDisabled,
describeCarrier,
selfUpdateAndReexec,
diff --git a/src/services/status_service.js b/src/services/status_service.js
index 90e86a67..bec716f5 100644
--- a/src/services/status_service.js
+++ b/src/services/status_service.js
@@ -43,7 +43,7 @@ const {
configureDependencies: configureInstalledModules
} = require('./status_service/installed_modules.js')
const {
- isContainerGoneError, probeServiceHealthPayload,
+ isContainerGoneError, probeServiceHealthPayload, probeReadinessPayload, reduceNotReady, describeNotReadyNote,
reduceNodeCatchingUp, describeNodeCatchingUpNote,
reduceNodeUnreachable, describeNodeUnreachableNote, describeDuration,
reduceDecoderReorgHalt, describeReorgHaltNote,
@@ -62,7 +62,8 @@ configureGetStatus({
getLocalNodeVersion, getContainerNodeVersion, getLocalModuleVersion,
getContainerModuleVersion, peers, getLogger, logger,
checkRemoteNodeVersionAdvisory, loadInstalledModules, isContainerGoneError,
- probeServiceHealthPayload, reduceNodeCatchingUp, describeNodeCatchingUpNote,
+ probeServiceHealthPayload, probeReadinessPayload, reduceNotReady, describeNotReadyNote,
+ reduceNodeCatchingUp, describeNodeCatchingUpNote,
reduceNodeUnreachable, describeNodeUnreachableNote, reduceDecoderReorgHalt,
describeReorgHaltNote, reduceIndexerStall, describeIndexerStallNote,
reduceTrackerHalt, describeTrackerHaltNote
@@ -119,5 +120,7 @@ module.exports = {
reduceIndexerStall,
describeIndexerStallNote,
reduceTrackerHalt,
- describeTrackerHaltNote
+ describeTrackerHaltNote,
+ reduceNotReady,
+ describeNotReadyNote
}
diff --git a/src/services/status_service/get_status.js b/src/services/status_service/get_status.js
index 2d24ca8f..d1980baa 100644
--- a/src/services/status_service/get_status.js
+++ b/src/services/status_service/get_status.js
@@ -30,7 +30,7 @@ let { getLogger } = require('../../observability/logger');
let logger = getLogger();
let { checkRemoteNodeVersionAdvisory, loadInstalledModules } = require('./installed_modules')
let {
- isContainerGoneError, probeServiceHealthPayload,
+ isContainerGoneError, probeServiceHealthPayload, probeReadinessPayload, reduceNotReady, describeNotReadyNote,
reduceNodeCatchingUp, describeNodeCatchingUpNote,
reduceNodeUnreachable, describeNodeUnreachableNote,
reduceDecoderReorgHalt, describeReorgHaltNote,
@@ -39,7 +39,7 @@ let {
} = require('./status_reducers')
function configureDependencies(dependencies) {
- ;({ NODE_MODULE_NAME, SEP, XChainService, db, getInstalledModules, resetInstalledModules, getRemoteModuleVersions, isStatusUpdated, setStatusUpdated, getLastStatus, setLastStatus, getLastPrintedStatus, setLastPrintedStatus, getStatusFromContainer, getLocalNodeVersion, getContainerNodeVersion, getLocalModuleVersion, getContainerModuleVersion, peers, getLogger, logger, checkRemoteNodeVersionAdvisory, loadInstalledModules, isContainerGoneError, probeServiceHealthPayload, reduceNodeCatchingUp, describeNodeCatchingUpNote, reduceNodeUnreachable, describeNodeUnreachableNote, reduceDecoderReorgHalt, describeReorgHaltNote, reduceIndexerStall, describeIndexerStallNote, reduceTrackerHalt, describeTrackerHaltNote } = dependencies)
+ ;({ NODE_MODULE_NAME, SEP, XChainService, db, getInstalledModules, resetInstalledModules, getRemoteModuleVersions, isStatusUpdated, setStatusUpdated, getLastStatus, setLastStatus, getLastPrintedStatus, setLastPrintedStatus, getStatusFromContainer, getLocalNodeVersion, getContainerNodeVersion, getLocalModuleVersion, getContainerModuleVersion, peers, getLogger, logger, checkRemoteNodeVersionAdvisory, loadInstalledModules, isContainerGoneError, probeServiceHealthPayload, probeReadinessPayload, reduceNotReady, describeNotReadyNote, reduceNodeCatchingUp, describeNodeCatchingUpNote, reduceNodeUnreachable, describeNodeUnreachableNote, reduceDecoderReorgHalt, describeReorgHaltNote, reduceIndexerStall, describeIndexerStallNote, reduceTrackerHalt, describeTrackerHaltNote } = dependencies)
}
async function recordVersions(moduleStatus, nextModule, nextCoin, nextCoinNetwork, containerId, remoteModuleVersions) {
@@ -178,6 +178,23 @@ async function annotatePublishedHealth(nextModule, containerId, nextCoin, nextCo
notes.push(describe(nextCoin, nextCoinNetwork, finding))
}
}
+ // The readiness verdict rides on the indexer's /status and the decoder's /live, which the
+ // JSON-RPC health read above does not carry. Advisory, like the rest: an unreadable route or
+ // an older image without the fields leaves the row as it was. Distinct word, same amber.
+ if (nextModule === XChainService.XCHAIN_INDEXER || nextModule === XChainService.XCHAIN_DECODER) {
+ let readiness = null
+ try {
+ readiness = typeof (payload && payload.ready) === 'boolean'
+ ? payload : await probeReadinessPayload(nextModule, containerId, nextCoin, nextCoinNetwork)
+ } catch { /* advisory: an unreadable route is not a finding */ }
+ const notReady = reduceNotReady(readiness)
+ if (notReady) {
+ display.state += " NOT READY"
+ display.isChurning = true
+ moduleStatus["not_ready"] = notReady
+ notes.push(describeNotReadyNote(nextCoin, nextCoinNetwork, nextModule, notReady))
+ }
+ }
const wait = reduceNodeCatchingUp(payload)
if (wait) {
display.state += " WAITING FOR NODE"
diff --git a/src/services/status_service/status_reducers.js b/src/services/status_service/status_reducers.js
index f32350a9..25137337 100644
--- a/src/services/status_service/status_reducers.js
+++ b/src/services/status_service/status_reducers.js
@@ -57,6 +57,23 @@ async function probeServiceHealthPayload(module, containerId, coin, network) {
return probeServiceStatus(containerId, port, runner)
}
+// Which route carries each module's readiness verdict. The JSON-RPC health method the
+// probe above reads first does not carry it.
+const READINESS_ROUTE = { 'xchain-indexer': '/status', 'xchain-decoder': '/live' }
+
+// The readiness fields of an indexer's /status or a decoder's /live, or null when the
+// route is unreadable or the image predates the verdict (an older image reads as ready).
+async function probeReadinessPayload(module, containerId, coin, network) {
+ const route = READINESS_ROUTE[module]
+ const { probeReadinessSurface, MODULE_API_PORT_KEY } = peers.bootstrapHealthGate
+ if (!route || typeof probeReadinessSurface !== 'function') return null
+ const config = await configService.getDefaultConfig(module, coin, network)
+ const port = config && config[MODULE_API_PORT_KEY[module]]
+ if (!port) return null
+ const runner = (cmd, args) => nodeUtil.promisify(childProcess.execFile)(cmd, args, { timeout: 15000 })
+ return probeReadinessSurface(containerId, port, route, runner)
+}
+
async function probeDecoderReorgHalt(containerId, coin, network) {
return reduceDecoderReorgHalt(await probeServiceHealthPayload(XChainService.XCHAIN_DECODER, containerId, coin, network))
}
@@ -172,13 +189,15 @@ function describeReorgHaltNote(coin, network, reorgHalt) {
// its grace window, which a BTC mainnet indexer's price mirror defers on
// almost every poll while the counter keeps advancing. A host fault (a
// missing DOGE read, a VM executor down) shows at once, and so does any stall
-// the indexer graded wedged, or one an older image left ungraded.
+// the indexer graded wedged, or one an older image left ungraded. A bridge
+// proof wait shows too: it never wedges, so nothing else would surface it.
function reduceIndexerStall(payload) {
if (!payload || typeof payload !== 'object') return null
const reason = typeof payload.stallReason === 'string' && payload.stallReason.trim() ? payload.stallReason.trim() : null
const stallClass = typeof payload.stallClass === 'string' ? payload.stallClass : null
+ // The price-landing wait is that same in-grace barrier defer, judged on its own longer window.
const healthyDefer = stallClass === 'future_block_wait'
- || (stallClass === 'barrier_defer' && /_barrier$/.test(reason || ''))
+ || ((stallClass === 'barrier_defer' || stallClass === 'price_landing_wait') && /_barrier$/.test(reason || ''))
return {
stalled: reason !== null && !healthyDefer,
reason,
@@ -189,6 +208,30 @@ function reduceIndexerStall(payload) {
}
}
+// The readiness verdict a service publishes: the indexer's `ready` / `notReadyReasons`
+// (/status) and `barrierCeilingExceeded`, the decoder's `ready` / `not_ready_reasons`
+// (/live). Strict on shape: only `ready === false` or `barrierCeilingExceeded === true`
+// is a finding, so an older image that lacks the fields reads as ready, as today. A hub
+// barrier held past its ceiling is NOT READY even though reduceIndexerStall calls a
+// barrier_defer healthy: the block is not advancing and the hub is why.
+function reduceNotReady(payload) {
+ if (!payload || typeof payload !== 'object') return null
+ const ceiling = payload.barrierCeilingExceeded === true
+ if (payload.ready !== false && !ceiling) return null
+ const listed = [payload.notReadyReasons, payload.not_ready_reasons].find(Array.isArray) || []
+ const reasons = listed.filter(r => typeof r === 'string' && r.trim()).map(r => r.trim())
+ if (ceiling && !reasons.includes('barrier_ceiling_exceeded')) reasons.push('barrier_ceiling_exceeded')
+ return { notReady: true, reasons }
+}
+
+// The line `ps` prints under the table for a service that is up but not ready.
+function describeNotReadyNote(coin, network, module, finding) {
+ return coin + "/" + network + " " + module + " is NOT READY"
+ + (finding.reasons.length ? " (" + finding.reasons.join(", ") + ")" : "")
+ + ": the process is alive and its healthcheck passes, but it is not serving the chain right now."
+ + " A restart does not clear this; `xchain-node logs " + module + " " + coin + " " + network + "` names the cause."
+}
+
// The indexer stamps its last commit in epoch milliseconds; the note wants an
// instant an operator can read. A string is passed through, anything else is null.
function describeCommitInstant(value) {
@@ -198,8 +241,9 @@ function describeCommitInstant(value) {
}
// What to do about each stall an indexer names, keyed by its reason word. A
-// reason with no entry gets the barrier line, since every hub-mirror barrier
-// is named `_barrier` and they all clear the same way.
+// reason with no entry gets the hub-mirror line only when its word ends in
+// `_barrier` (the indexer's name for a hub-mirror barrier), else the log line.
+// The bridge proof wait ends in `_barrier` but no hub resync shortens it, so it has its own entry.
const INDEXER_STALL_HINTS = {
rollcall_proof_unavailable: (coin, network) =>
"it cannot prove on Dogecoin who signed the roll call, so it defers the epoch close rather than read silence as absence."
@@ -214,15 +258,33 @@ const INDEXER_STALL_HINTS = {
train_activation_halt: (coin, network) =>
"the signed release manifest names a rule set this build does not implement; `xchain-node update xchain-indexer " + coin + " " + network + "`.",
reorg_rollback: () =>
- "a reorg rollback is in progress; it resumes on its own once the rolled-back range is re-parsed."
+ "a reorg rollback is in progress; it resumes on its own once the rolled-back range is re-parsed.",
+ bridge_proof_barrier: (coin, network) =>
+ "it is waiting for a bridge escrow proof and defers the block rather than mint unproven. This is not a hub-mirror fault,"
+ + " and neither a hub resync nor a restart shortens it: it clears once this node holds a quorum-established checkpoint"
+ + " at or after the transfer's snapshot block, or an origin-chain indexer endpoint is configured and serves the proof."
+ + " Look for BRIDGE ESCROW PROOF UNAVAILABLE in `xchain-node logs xchain-indexer " + coin + " " + network + "`.",
+ list_share_halt: (coin, network) =>
+ "it refuses to commit the block because a list version is missing or altered, and committing would fork it from its peers;"
+ + " this is not a hub-mirror wait. `xchain-node logs xchain-indexer " + coin + " " + network + "` shows the LIST SHARE HALT line and its reason.",
+ parse_exception: (coin, network) =>
+ "the same exception hit the same block twice, so the block is not committing;"
+ + " `xchain-node logs xchain-indexer " + coin + " " + network + "` shows the repeated parse error."
+}
+
+// Hub-mirror line for an unlisted `_barrier` word, neutral log line for any other unlisted
+// reason. Tests the word, not the reason, so exception prose ending in `_barrier` never matches.
+function fallbackIndexerStallHint(stall) {
+ if (/_barrier$/.test(stall.word || '')) return (c, n) =>
+ "a hub-mirror barrier (" + stall.reason + ") has held the head block past the grace window; check that the hub is answering"
+ + " and its mirror stream is advancing, then `xchain-node logs xchain-indexer " + c + " " + n + "`."
+ return (c, n) => "`ps` has no specific remedy for this stall; `xchain-node logs xchain-indexer " + c + " " + n + "` names the fault."
}
// The line `ps` prints under the table for a stalled indexer: the reason, how
// long no block has committed, and the remedy for that reason.
function describeIndexerStallNote(coin, network, stall) {
- const hint = INDEXER_STALL_HINTS[stall.word] || ((c, n) =>
- "a hub-mirror barrier (" + stall.reason + ") has held the head block past the grace window; check that the hub is answering"
- + " and its mirror stream is advancing, then `xchain-node logs xchain-indexer " + c + " " + n + "`.")
+ const hint = INDEXER_STALL_HINTS[stall.word] || fallbackIndexerStallHint(stall)
return coin + "/" + network + " xchain-indexer is STALLED (" + stall.reason + ")"
+ (stall.since ? ", no block committed since " + stall.since : "")
+ ": " + hint(coin, network)
@@ -256,6 +318,9 @@ function describeTrackerHaltNote(coin, network, halt) {
module.exports = {
isContainerGoneError,
probeServiceHealthPayload,
+ probeReadinessPayload,
+ reduceNotReady,
+ describeNotReadyNote,
probeDecoderReorgHalt,
reduceNodeCatchingUp,
describeNodeCatchingUpNote,
diff --git a/src/services/stop_budget_service.js b/src/services/stop_budget_service.js
index 21d06bd9..c4556d8b 100644
--- a/src/services/stop_budget_service.js
+++ b/src/services/stop_budget_service.js
@@ -261,6 +261,21 @@ async function stopModuleContainer(stopContainerByName, module, coin, network, c
return { ...outcome, budget }
}
+// Restart a service container as the budgeted stop above, then a start. A bare `docker restart`
+// honours only a stamped --stop-timeout (docker's ten seconds without one) and never reads the exit code.
+async function restartModuleContainer(stopContainerById, startContainerById, module, coin, network, containerRef,
+ env = MODULE_STOP_TIMEOUT_ENV, readStopSettings = readContainerStopSettings) {
+ const outcome = await stopModuleContainer(stopContainerById, module, coin, network, containerRef, env, readStopSettings)
+ try {
+ await startContainerById(containerRef)
+ } catch (err) {
+ // Say the service is now down, since the stop half already happened
+ const reason = err && err.message ? err.message : String(err)
+ throw new Error(`${module} was stopped but failed to start again (${reason})`)
+ }
+ return outcome
+}
+
module.exports = {
DEFAULT_MODULE_STOP_TIMEOUT_SECONDS,
MODULE_STOP_TIMEOUT_SECONDS,
@@ -274,5 +289,6 @@ module.exports = {
effectiveStopBudgetSeconds,
stoppedUnclean,
describeModuleStopOutcome,
- stopModuleContainer
+ stopModuleContainer,
+ restartModuleContainer
}
diff --git a/src/services/validator_service/signer.js b/src/services/validator_service/signer.js
index 3855020d..5820f935 100644
--- a/src/services/validator_service/signer.js
+++ b/src/services/validator_service/signer.js
@@ -121,6 +121,15 @@ function requireEncoder() {
return typeof sdk.requireEncoder === 'function' ? sdk.requireEncoder() : sdk['_requireEncoder']();
}
+// Set the hub's classification fields on a failure and return it. The SAME object
+// is mutated where there is one, because the hub 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.
+function tagFailure(err, prefix, fields) {
+ const tagged = (err && typeof err === 'object') ? err : new Error(prefix + String(err));
+ return Object.assign(tagged, fields);
+}
+
module.exports = {
// Full publish pipeline for a raw wire payload string -> { txid }. The
// returned txid is the phase-2 (reveal) txid when two-phase encoding is
@@ -131,44 +140,54 @@ module.exports = {
const txParams = { data: payload, pubkey: signerEnv.address, change: signerEnv.address, encoding: 'P2SH' };
if (signerEnv.feePerKb !== undefined) txParams.feePerKb = signerEnv.feePerKb;
- const encoded = await encoder.createTx(txParams);
- const signed = sdk.wallet.signPsbt(encoded.psbt, signerEnv.wif);
+ // Both transactions are built and signed before either is broadcast. The
+ // signed phase 1 already has its final txid, so the reveal is built from it
+ // here, and a reveal the encoder refuses or this key cannot sign stops the
+ // publish while nothing is on chain. neverSent tells the hub this round
+ // spent nothing and may be rebuilt.
+ let signed, spendSigned = null, twoPhase;
+ try {
+ const encoded = await encoder.createTx(txParams);
+ signed = sdk.wallet.signPsbt(encoded.psbt, signerEnv.wif);
+ twoPhase = encoded.encoding === 'P2SH' || encoded.encoding === 'P2WSH';
+ if (twoPhase) {
+ const spendParams = {
+ pubkey: signerEnv.address,
+ p2shHash: signed.txid,
+ p2shHex: signed.txHex,
+ data: payload,
+ encoding: encoded.encoding,
+ change: signerEnv.address
+ };
+ if (signerEnv.feePerKb !== undefined) spendParams.feePerKb = signerEnv.feePerKb;
+ const spendResult = await encoder.spendP2sh(spendParams);
+ spendSigned = sdk.wallet.signRevealPsbt(spendResult.psbt, signerEnv.wif);
+ }
+ } catch (err) {
+ throw tagFailure(err, 'doge-signer: publish failed before any broadcast: ', { neverSent: true });
+ }
+
await encoder.broadcastTx(signed.txHex);
- if (encoded.encoding !== 'P2SH' && encoded.encoding !== 'P2WSH')
- return { txid: signed.txid };
+ if (!twoPhase) return { 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
+ // Phase 1 has funded the P2SH outputs on chain, so a reveal broadcast failure
+ // 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.
+ // operator reconciles the funding transaction against, and revealTxHex is the
+ // signed reveal that spends it, ready to rebroadcast as it stands.
try {
- const spendParams = {
- pubkey: signerEnv.address,
- p2shHash: signed.txid,
- p2shHex: signed.txHex,
- data: payload,
- encoding: encoded.encoding,
- change: signerEnv.address
- };
- if (signerEnv.feePerKb !== undefined) spendParams.feePerKb = signerEnv.feePerKb;
- const spendResult = await encoder.spendP2sh(spendParams);
- const spendSigned = sdk.wallet.signRevealPsbt(spendResult.psbt, signerEnv.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;
+ throw tagFailure(err, 'doge-signer: reveal broadcast failed after funding: ', {
+ fundsCommitted: true,
+ phase1Txid: signed.txid,
+ revealTxid: spendSigned.txid,
+ revealTxHex: spendSigned.txHex
+ });
}
+ return { txid: spendSigned.txid, phase1_txid: signed.txid };
},
// Sign an encoder-built PSBT -> signed raw tx hex. The hub's built-in
diff --git a/src/services/validator_stake_service.js b/src/services/validator_stake_service.js
index d337e54b..3e0f4e53 100644
--- a/src/services/validator_stake_service.js
+++ b/src/services/validator_stake_service.js
@@ -35,9 +35,10 @@ const {
} = require('./validator_service')
const { getCoinConfigByFullName } = require('../coins')
const config = require('../config');
-const { readKeyHolders } = require('./validator_stake_service/free_key')
+const { readKeyHolders, amountUnits, fromUnits } = require('./validator_stake_service/free_key')
const { readSleepState } = require('./validator_stake_service/address_sleep')
const { readTokenRow } = require('./validator_stake_service/token_balance_read')
+const { readMintState, overAddressCap } = require('./validator_stake_service/mint_rules')
const STAKE_TICK = 'XCHAIN'
// One stake that clears every capability floor at once. The highest is the hub's llm attestation
@@ -174,12 +175,10 @@ async function readChainState(sdk, address, pubkey, keyCtx) {
if (!e || !e.incompleteRead) throw e
}
- // Read the faucet caps from the token's mints group; a token with no such
- // group is an unreadable answer, not a MAX_MINT of 0.
+ // Read the token's mint rules the indexer judges a MINT by; an unreported
+ // value is an unreadable answer, not an unset 0.
const token = entity(await getStakeToken(sdk))
- const mints = (token && token.mints && typeof token.mints === 'object') ? token.mints : null
- const mintUnreadable = mints ? null : 'the ' + STAKE_TICK + ' token read returned an unexpected shape (no mints group), ' +
- 'so its faucet caps are unknown'
+ const mintState = await readMintState(sdk, address, token, keyCtx.coins, STAKE_TICK)
// An existing stake on this pubkey means v1 would be rejected (the indexer
// refuses a pubkey that already carries one); say so instead of spending.
@@ -196,37 +195,57 @@ async function readChainState(sdk, address, pubkey, keyCtx) {
// Read whether the stake address put itself to sleep: the indexer rejects its MINTs and STAKE then.
const sleepState = await readSleepState(sdk, address, keyCtx.coins)
- return { coinBal, coinPending, tokenBal, mintMax: num(mints && mints.max),
- mintAddressMax: num(mints && mints.address_max), mintUnreadable, ...holders, ...sleepState }
+ return { coinBal, coinPending, tokenBal, ...mintState, ...holders, ...sleepState }
}
// Plan the mints: how many transactions, at what amount each, to lift the
// balance to `amount`. Pure, so it is testable without a network.
// `mintUnreadable` names why the caps could not be read, so it is not reported as MAX_MINT 0.
-function planMints(network, tokenBal, amount, mintMax, mintAddressMax, mintUnreadable) {
+// `mintRules` carries the read lock/window verdict (mintClosed), the address's
+// earlier self-mints (mintedSoFar) and a failed read of them (mintedUnknown).
+function planMints(network, tokenBal, amount, mintMax, mintAddressMax, mintUnreadable, mintRules = {}) {
if (tokenBal === null) {
return { short: null, mints: [], reason: 'the ' + STAKE_TICK + ' balance is unavailable (the explorer\'s ' +
'balance list could not be read to the end), so the shortfall is unknown. Retry, or check the ' +
'explorer; this does not mean the address holds none.' }
}
- const short = Math.max(0, amount - tokenBal)
- if (short === 0) return { short, mints: [], reason: null }
+ if (tokenBal >= amount) return { short: 0, mints: [], reason: null }
+ // Subtract and split in exact 1e-8 units: float arithmetic on a fractional balance yields
+ // MINT amounts past 8 places, which the indexer rejects as malformed after the fee is spent.
+ const balUnits = amountUnits(tokenBal)
+ const amountU = amountUnits(amount)
+ if (balUnits === null || amountU === null) {
+ return { short: null, mints: [], reason: 'the ' + STAKE_TICK + ' balance (' + tokenBal + ') or the stake amount (' +
+ amount + ') is not an amount of at most 8 decimal places, so the shortfall cannot be planned exactly.' }
+ }
+ if (balUnits >= amountU) return { short: 0, mints: [], reason: null }
+ const shortUnits = amountU - balUnits
+ const short = Number(fromUnits(shortUnits))
if (network === 'mainnet') {
- return { short, mints: [], reason: 'XCHAIN is not mintable on mainnet: acquire ' + short + ' more and re-run.' }
+ return { short, mints: [], reason: 'XCHAIN is not mintable on mainnet: acquire ' + fromUnits(shortUnits) + ' more and re-run.' }
}
if (mintUnreadable) {
return { short, mints: [], reason: mintUnreadable + '; cannot plan the mints for the shortfall. Retry, or check the explorer.' }
}
- if (!(mintMax > 0)) {
- return { short, mints: [], reason: 'the ' + STAKE_TICK + ' token reports no open mint (MAX_MINT is 0); cannot mint the shortfall.' }
+ if (!Number.isFinite(mintMax) || mintMax < 0) {
+ return { short, mints: [], reason: 'the ' + STAKE_TICK + ' token\'s MAX_MINT is unknown; cannot plan the mints for the shortfall. Retry, or check the explorer.' }
}
+ if (mintRules.mintClosed) return { short, mints: [], reason: mintRules.mintClosed }
+ if (mintRules.mintedUnknown) return { short, mints: [], reason: mintRules.mintedUnknown }
+ // MAX_MINT 0 is the indexer's "no per-transaction cap", so the whole shortfall goes in one MINT.
+ const capUnits = mintMax > 0 ? amountUnits(mintMax) : null
+ const perMint = capUnits !== null && capUnits > 0n ? capUnits : shortUnits
const mints = []
- let left = short
- while (left > 0) { const a = Math.min(mintMax, left); mints.push(a); left -= a }
+ for (let left = shortUnits; left > 0n; ) {
+ const a = left < perMint ? left : perMint
+ mints.push(Number(fromUnits(a)))
+ left -= a
+ }
+ const minted = mintRules.mintedSoFar || 0
let reason = null
- if (mintAddressMax > 0 && short > mintAddressMax) {
- reason = 'the shortfall (' + short + ') exceeds the per-address mint cap (' + mintAddressMax + '); ' +
- 'this address cannot mint enough on its own.'
+ if (overAddressCap(minted, short, mintAddressMax)) {
+ reason = 'the shortfall (' + short + ')' + (minted ? ' plus the ' + minted + ' this address already minted' : '') +
+ ' exceeds the per-address mint cap (' + mintAddressMax + '); this address cannot mint enough on its own.'
}
return { short, mints, reason }
}
diff --git a/src/services/validator_stake_service/free_key.js b/src/services/validator_stake_service/free_key.js
index 4e69b6df..19141323 100644
--- a/src/services/validator_stake_service/free_key.js
+++ b/src/services/validator_stake_service/free_key.js
@@ -128,9 +128,21 @@ function sumAmounts(rows) {
if (u === null) return null
total += u
}
+ return fromUnits(total)
+}
+
+// A non-negative amount, a number or a plain decimal string, as 1e-8 units; null when it is neither.
+// A number is read at 8 places, so a float that stands for an 8-place decimal comes back as that decimal.
+function amountUnits(amount) {
+ if (typeof amount === 'number') return Number.isFinite(amount) && amount >= 0 ? toUnits(amount.toFixed(UNIT_DIGITS)) : null
+ return toUnits(amount)
+}
+
+// Non-negative 1e-8 units as a plain decimal string, trailing zeros trimmed.
+function fromUnits(units) {
const scale = 10n ** BigInt(UNIT_DIGITS)
- const frac = (total % scale).toString().padStart(UNIT_DIGITS, '0').replace(/0+$/, '')
- return (total / scale).toString() + (frac ? '.' + frac : '')
+ const frac = (units % scale).toString().padStart(UNIT_DIGITS, '0').replace(/0+$/, '')
+ return (units / scale).toString() + (frac ? '.' + frac : '')
}
// The explorer's indexed tip for the stake chain; throws when it is unreadable.
@@ -245,6 +257,9 @@ module.exports = {
stakeKeyReuseHeight,
stakeRowsHoldingKey,
delegationRowsHoldingKey,
+ isUndeactivated,
+ amountUnits,
+ fromUnits,
sumAmounts,
readDelegationsByPubkey,
readKeyHolders,
diff --git a/src/services/validator_stake_service/mint_rules.js b/src/services/validator_stake_service/mint_rules.js
new file mode 100644
index 00000000..21e785e5
--- /dev/null
+++ b/src/services/validator_stake_service/mint_rules.js
@@ -0,0 +1,148 @@
+/*********************************************************************
+ *
+ * Copyright © 2025–2026 Dankest, LLC
+ * Based on XChain Platform by Dankest, LLC – https://dankest.llc
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This file is part of XChain Platform. Licensed under the GNU Affero
+ * General Public License v3.0 or later; see LICENSE.md. A commercial
+ * license (without AGPL source-disclosure terms) is available -
+ * contact legal@dankest.llc.
+ *
+ **********************************************************************
+ * XChain Node - Validator Stake Mint Rules
+ *
+ * The token rules the indexer judges a MINT by (xchain-indexer/src/actions/
+ * mint/validate.js), read before the stake run spends any MINT fee: LOCK_MINT,
+ * the MINT_START_BLOCK/MINT_STOP_BLOCK window at the block a MINT sent now can
+ * land in, MAX_MINT, and MINT_ADDRESS_MAX against what this address has
+ * already minted itself. A cap or block of 0 is unset, as in the indexer, and
+ * a value the explorer did not report is unknown, never 0. The CLI only reads
+ * these rules; consensus stays in the indexer.
+ ********************************************************************/
+
+const { readTip } = require('./free_key')
+
+// The explorer's per-method row cap, and the OFFSET ceiling that bounds a sweep.
+const PAGE_LIMIT = 100
+const MAX_PAGES = 1001
+// XCHAIN carries 8 decimal places; amounts are summed and compared in these integer units.
+const UNITS = 1e8
+
+// A mints-group value as a number, or null when the explorer did not report one.
+function reported(x) {
+ if (x === null || x === undefined || x === '') return null
+ const n = Number(x)
+ return Number.isFinite(n) && n >= 0 ? n : null
+}
+
+/**
+ * The token's mint caps, lock and window. `unreadable` names every value the
+ * explorer did not report, so a missing cap never reads as the unset 0.
+ */
+function readTokenMintRules(token, tick) {
+ const mints = (token && token.mints && typeof token.mints === 'object') ? token.mints : null
+ if (!mints) {
+ return { max: null, addressMax: null, unreadable: 'the ' + tick + ' token read returned an unexpected ' +
+ 'shape (no mints group), so its faucet caps are unknown' }
+ }
+ const locked = (token.locks && typeof token.locks === 'object') ? token.locks.mint : undefined
+ const rules = { max: reported(mints.max), addressMax: reported(mints.address_max),
+ startBlock: reported(mints.start_block), stopBlock: reported(mints.stop_block), locked }
+ const missing = [['max', 'mints.max'], ['addressMax', 'mints.address_max'], ['startBlock', 'mints.start_block'],
+ ['stopBlock', 'mints.stop_block']].filter(([key]) => rules[key] === null).map(([, name]) => name)
+ if (typeof locked !== 'boolean') missing.push('locks.mint')
+ rules.unreadable = missing.length
+ ? 'the ' + tick + ' token read did not report ' + missing.join(', ') + ', so its mint rules are unknown'
+ : null
+ return rules
+}
+
+/**
+ * Why the indexer would reject a MINT landing at landBlock for the token's
+ * lock or window, or null when the mint is open there.
+ */
+function mintClosedReason(rules, landBlock, tick) {
+ const none = 'the ' + tick + ' token has no open mint: '
+ if (rules.locked) return none + 'its mint is locked (LOCK_MINT), and the indexer rejects every MINT of it.'
+ if (rules.startBlock > 0 && landBlock < rules.startBlock) {
+ return none + 'minting opens at block ' + rules.startBlock + ' (MINT_START_BLOCK), and a MINT sent now ' +
+ 'lands at block ' + landBlock + ' at the earliest. Re-run from block ' + rules.startBlock + '.'
+ }
+ if (rules.stopBlock > 0 && landBlock > rules.stopBlock) {
+ return none + 'minting closed after block ' + rules.stopBlock + ' (MINT_STOP_BLOCK), and a MINT sent now ' +
+ 'lands at block ' + landBlock + ' at the earliest.'
+ }
+ return null
+}
+
+// One row's amount in units when it is a valid MINT of `tick` authored by `address`, else 0.
+function selfMintUnits(row, address, tick) {
+ if (!row || row.status !== 'valid' || String(row.source) !== String(address) || String(row.tick) !== tick) return 0
+ const n = Number(row.amount)
+ if (row.amount === null || row.amount === '' || !Number.isFinite(n) || n < 0) {
+ throw new Error('MINT action ' + row.action_index + ' has no readable amount')
+ }
+ return Math.round(n * UNITS)
+}
+
+/**
+ * What this address has already minted itself: its valid MINT rows of `tick`
+ * on the explorer's source lane, paged to the end, which is the total the
+ * indexer holds MINT_ADDRESS_MAX against. Throws on anything short of a
+ * complete answer, so a failed read never counts as nothing minted.
+ */
+async function readSelfMinted(sdk, address, tick) {
+ if (!sdk.explorer || typeof sdk.explorer.getMints !== 'function') throw new Error('this SDK has no getMints')
+ let units = 0
+ let seen = 0
+ for (let page = 1; ; page++) {
+ if (page > MAX_PAGES) throw new Error('mint read incomplete: more than ' + (MAX_PAGES * PAGE_LIMIT) + ' rows')
+ const v = await sdk.explorer.getMints(address, 'source', { page, limit: PAGE_LIMIT, sortorder: 'ASC' })
+ if (!v || !Array.isArray(v.data)) throw new Error('the mint lookup by source returned no row list')
+ for (const row of v.data) units += selfMintUnits(row, address, tick)
+ seen += v.data.length
+ const total = Number(v.total)
+ const known = v.total !== undefined && v.total !== null && Number.isInteger(total) && total >= 0
+ if (known ? seen >= total : v.data.length < PAGE_LIMIT) break
+ if (!v.data.length) throw new Error('mint read incomplete: saw ' + seen + ' of ' + total + ' rows')
+ }
+ return units / UNITS
+}
+
+/**
+ * The mint rules as plan state: { mintMax, mintAddressMax, mintUnreadable,
+ * mintClosed, mintedSoFar, mintedUnknown }. A failed tip or mint-history read
+ * comes back as a reason string, never as an open mint or zero minted.
+ */
+async function readMintState(sdk, address, token, coins, tick) {
+ const rules = readTokenMintRules(token, tick)
+ const state = { mintMax: rules.max, mintAddressMax: rules.addressMax, mintUnreadable: rules.unreadable,
+ mintClosed: null, mintedSoFar: null, mintedUnknown: null }
+ if (rules.unreadable) return state
+ try {
+ state.mintClosed = rules.locked ? mintClosedReason(rules, null, tick)
+ : mintClosedReason(rules, (await readTip(sdk, coins)) + 1, tick)
+ } catch (e) {
+ state.mintClosed = 'could not confirm the ' + tick + ' mint window is open (' + e.message + '). The ' +
+ 'indexer rejects a MINT outside it after the fee is spent, so nothing is minted until this can be ' +
+ 'read. Retry, or check the explorer.'
+ }
+ if (!(rules.addressMax > 0)) return state
+ try {
+ state.mintedSoFar = await readSelfMinted(sdk, address, tick)
+ } catch (e) {
+ state.mintedUnknown = 'could not read what ' + address + ' has already minted (' + e.message + '), so ' +
+ 'the per-address mint cap cannot be checked. The indexer counts every earlier MINT from this address ' +
+ 'against it and rejects an over-cap MINT after the fee is spent. Retry, or check the explorer.'
+ }
+ return state
+}
+
+/** Whether `minted` plus `short` passes the per-address cap `cap` (0 is no cap), compared in units. */
+function overAddressCap(minted, short, cap) {
+ return cap > 0 && Math.round((minted + short) * UNITS) > Math.round(cap * UNITS)
+}
+
+module.exports = { readTokenMintRules, mintClosedReason, readSelfMinted, readMintState, overAddressCap }
diff --git a/src/services/validator_stake_service/stake_operations.js b/src/services/validator_stake_service/stake_operations.js
index 43c5d98f..33d3898b 100644
--- a/src/services/validator_stake_service/stake_operations.js
+++ b/src/services/validator_stake_service/stake_operations.js
@@ -17,6 +17,13 @@
const { getLogger } = require('../../observability/logger')
const { sendLockFor, releaseOnIndexWait, holdForSend } = require('./send_lock')
const { sleepRefusal, sleepUnknownRefusal } = require('./address_sleep')
+const { amountUnits, fromUnits } = require('./free_key')
+
+// A planned MINT amount as the plain decimal the wire carries (at most 8 places, above 0), or null.
+function mintAmountText(amount) {
+ const units = amountUnits(amount)
+ return units === null || units === 0n ? null : fromUnits(units)
+}
function defaultLog() {
const logger = getLogger()
@@ -36,11 +43,16 @@ function logStakeBalances(log, network, coins, pubkey, address, amount, state, p
const tokenHeld = state.tokenBal === null ? 'unavailable (balance list read incomplete)' : state.tokenBal + ' held'
log(' ' + STAKE_TICK.padEnd(15) + ': ' + tokenHeld + ', ' + amount + ' to stake' +
(plan.short ? ', short ' + plan.short : ''))
- if (state.mintMax) log(' faucet caps : ' + state.mintMax + ' per MINT, ' + (state.mintAddressMax || 'no') + ' per address')
+ // A MAX_MINT of 0 is a token with no per-transaction cap, so it prints as such rather than vanishing.
+ if (Number.isFinite(state.mintMax)) {
+ log(' faucet caps : ' + (state.mintMax || 'no cap') + ' per MINT, ' + (state.mintAddressMax || 'no') + ' per address' +
+ (state.mintedSoFar ? ' (' + state.mintedSoFar + ' already minted)' : ''))
+ }
}
function logStakeSteps(log, amount, pubkey, plan, timing, STAKE_TICK, paren) {
- const steps = plan.mints.map((a, i) => 'MINT ' + (i + 1) + '/' + plan.mints.length + ': ' + a + ' ' + STAKE_TICK)
+ const steps = plan.mints.map((a, i) => 'MINT ' + (i + 1) + '/' + plan.mints.length + ': ' +
+ (mintAmountText(a) || a) + ' ' + STAKE_TICK)
steps.push('STAKE v1: ' + amount + ' ' + STAKE_TICK + ' to ' + pubkey)
log('')
log(' Steps:')
@@ -196,13 +208,20 @@ async function broadcastStake(context) {
// the whole run lands in a single block (see chainedInputs). --serialize
// restores the old one-action-per-block behaviour, which costs a block per
// step and is only worth it if chaining ever misbehaves on a venue.
+ // Check every MINT amount before the first send: one past 8 places is rejected after its fee is spent.
+ const mintTexts = plan.mints.map(mintAmountText)
+ const malformed = plan.mints.filter((a, i) => mintTexts[i] === null)
+ if (malformed.length) {
+ throw context.fail('the planned MINT amount ' + malformed.join(', ') + ' is not a ' + STAKE_TICK + ' amount above 0 ' +
+ 'with at most 8 decimal places, which the indexer rejects. Nothing was sent.')
+ }
const progress = { chain: !opts.serialize, sent: [], prevTxid: null, chainBroken: false }
for (let i = 0; i < plan.mints.length; i++) {
log('')
- log(' Sending MINT ' + (i + 1) + '/' + plan.mints.length + ' (' + plan.mints[i] + ' ' + STAKE_TICK + ')...')
+ log(' Sending MINT ' + (i + 1) + '/' + plan.mints.length + ' (' + mintTexts[i] + ' ' + STAKE_TICK + ')...')
const inputs = await linkInputs(context, progress)
await sendStakeAction(context, progress, 'MINT',
- { VERSION: 0, TICK: STAKE_TICK, AMOUNT: String(plan.mints[i]) }, opts.serialize === true, inputs)
+ { VERSION: 0, TICK: STAKE_TICK, AMOUNT: mintTexts[i] }, opts.serialize === true, inputs)
}
// Form the STAKE's own link to the last mint BEFORE deciding whether to
@@ -255,7 +274,6 @@ async function finishStake(context, progress, stakeInputs) {
}
// Resolve --amount: the default only when it is absent, else whole XCHAIN above 0, refused rather than coerced.
-// Whole units only: the mint split is float arithmetic, so a fractional shortfall would send malformed MINT amounts.
function resolveStakeAmount(raw, DEFAULT_STAKE_AMOUNT, fail, STAKE_TICK) {
if (raw === undefined || raw === null) return DEFAULT_STAKE_AMOUNT
const text = String(raw).trim()
@@ -304,7 +322,7 @@ function createStakeValidator(helpers) {
// Take the lock before reading the plan, so no other broadcast sends between this read and these sends.
sendLock.hold()
const state = await readChainState(sdk, address, pubkey, { network, coins, cooldownBlocks: timing.cooldownBlocks })
- const plan = planMints(network, state.tokenBal, amount, state.mintMax, state.mintAddressMax, state.mintUnreadable)
+ const plan = planMints(network, state.tokenBal, amount, state.mintMax, state.mintAddressMax, state.mintUnreadable, state)
logStakeBalances(log, network, coins, pubkey, address, amount, state, plan, STAKE_TICK)
if (state.existing || state.delegated) {
@@ -327,7 +345,7 @@ function createStakeValidator(helpers) {
}
return broadcastStake({ opts, sdk, session, address, coins, pubkey, amount, plan, log, timing,
- explorerUrl, chainedInputs, waitForBalance, paren, STAKE_TICK, sendLock })
+ explorerUrl, chainedInputs, waitForBalance, paren, STAKE_TICK, sendLock, fail })
}
}
diff --git a/src/services/validator_stake_service/unstake_operations.js b/src/services/validator_stake_service/unstake_operations.js
index 78c5a2cc..0d48e5eb 100644
--- a/src/services/validator_stake_service/unstake_operations.js
+++ b/src/services/validator_stake_service/unstake_operations.js
@@ -17,7 +17,8 @@
const { getLogger } = require('../../observability/logger')
// Reads /validators to the end: one unpaged read drops every stake past the newest 100 rows.
const { readValidatorSet } = require('./validator_set_read')
-const { sumAmounts } = require('./free_key')
+// The stake command's own tip read and withdrawn-row test, so both commands judge one key alike.
+const { sumAmounts, readTip, isUndeactivated } = require('./free_key')
const { sendLockFor, releaseOnIndexWait } = require('./send_lock')
const { readAddressSleep, sleepRefusal, sleepUnknownRefusal } = require('./address_sleep')
@@ -66,11 +67,6 @@ function logUnstakeSuccess(log, active, timing, coins, pubkey, STAKE_TICK, paren
log('')
}
-// An empty deactivation_block is a stake no UNSTAKE or ROLLCALL eviction has touched.
-function isUndeactivated(row) {
- return row.deactivation_block === null || row.deactivation_block === undefined || row.deactivation_block === ''
-}
-
// Sort rows as the indexer admits an UNSTAKE landing at landBlock: undeactivated AND activation_block <= landBlock.
// The explorer keeps status='valid' on deactivated rows, so status alone never means "active".
function classifyUnstakeRows(rows, landBlock) {
@@ -96,20 +92,13 @@ function withdrawnStake(admissible) {
}
// The explorer's indexed tip for the stake coin; unreadable means refuse, never guess.
-async function readTip(sdk, coins, fail) {
- let tip
+async function readTipOrRefuse(sdk, coins, fail) {
try {
- const status = await sdk.explorer.getStatus()
- tip = Number(status && status.last_block && status.last_block[coins.stakeCoin])
+ return await readTip(sdk, coins)
} catch (e) {
throw fail('could not read the chain tip (' + e.message + '), so this run cannot tell ' +
'whether the stake is active yet. Nothing was sent.')
}
- if (!Number.isInteger(tip) || tip < 0) {
- throw fail('the explorer reported no last block for ' + coins.stakeCoin + ', so this run cannot tell ' +
- 'whether the stake is active yet. Nothing was sent.')
- }
- return tip
}
// Refuse unless `address` owns every undeactivated row: the indexer rejects an UNSTAKE v0
@@ -181,7 +170,7 @@ async function resolveUnstakeTarget({ sdk, coins, pubkey, address, timing, log,
assertOwnsStake(rows.filter(isUndeactivated), address, fail)
// The UNSTAKE lands no earlier than the next block, so that is the block admission is judged at.
- const tip = await readTip(sdk, coins, fail)
+ const tip = await readTipOrRefuse(sdk, coins, fail)
const { admissible, pending } = classifyUnstakeRows(rows, tip + 1)
if (!admissible.length) {
const from = Math.min(...pending.map(r => Number(r.activation_block)))
diff --git a/src/utils/command_lock.js b/src/utils/command_lock.js
index 3a018dad..61960a22 100644
--- a/src/utils/command_lock.js
+++ b/src/utils/command_lock.js
@@ -39,8 +39,11 @@
* removes the file only when both the pid and the nonce still match, so
* neither a stale-lock takeover nor a recycled pid is clobbered on exit.
* A self-update releases its parent-owned lock before starting the re-exec
- * child. The child then competes for the lock normally, so another mutator
- * that acquires it first causes the child to refuse the update.
+ * child. The update run that re-takes it waits at least
+ * SELF_UPDATE_HANDOFF_WAIT_MS (cli/dispatch.js, 30s, even when
+ * XCHAIN_NODE_MUTATING_LOCK_WAIT_MS is lower or 0), so a mutator that
+ * slipped into the gap delays the update; it refuses only if that mutator
+ * still holds the lock when the wait runs out.
*
* Ops note: clearing a wedged lock by hand may mean deleting both
* command.lock and command.lock.reclaim.
@@ -61,6 +64,7 @@ const RECLAIM_FILE_SUFFIX = '.reclaim'
// live holder, or concedes one reclaim attempt to a contender.
const MAX_ACQUIRE_ATTEMPTS = 8
const RECLAIM_POLL_MS = 5
+const UNREADABLE_LOCK_HOLDER = Object.freeze({ unreadable: true })
function getLockFilePath() {
// XCHAIN_NODE_LOCK_DIR is a test/ops override; default matches the
@@ -116,7 +120,7 @@ function adoptCommandLock(release) {
// section, and removing it would restore the very race the marker prevents.
function breakAbandonedReclaimMarker(reclaimFile) {
const owner = readLockHolder(reclaimFile)
- if (owner && isPidAlive(owner.pid)) return
+ if (isLockHolderActive(owner)) return
try { fs.unlinkSync(reclaimFile) } catch { /* already gone */ }
}
@@ -141,7 +145,7 @@ function reclaimStaleLock(lockFile, reclaimFile, observed, pid, nonce) {
const current = statLock(lockFile)
if (!current) return
const holder = readLockHolder(lockFile)
- if (holder && isPidAlive(holder.pid)) return
+ if (isLockHolderActive(holder)) return
if (!sameFile(observed, current)) return
try { fs.unlinkSync(lockFile) } catch (unlinkErr) {
if (unlinkErr.code !== 'ENOENT') throw unlinkErr
@@ -152,9 +156,8 @@ function reclaimStaleLock(lockFile, reclaimFile, observed, pid, nonce) {
}
// One acquire pass with serialized stale-lock removal and bounded retries.
-// Returns a release() on success; throws a tagged ELOCKHELD error when a LIVE
-// holder owns the lock (the caller may then choose to wait and retry); throws any
-// other error (fs failure, or lost post-stale race) as fatal.
+// A live or unreadable holder throws ELOCKHELD, which callers can retry;
+// filesystem failures and lost races stay fatal.
function tryAcquireOnce(lockFile, payload, pid, nonce) {
const reclaimFile = lockFile + RECLAIM_FILE_SUFFIX
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
@@ -165,7 +168,16 @@ function tryAcquireOnce(lockFile, payload, pid, nonce) {
if (err.code !== 'EEXIST') throw err
const holder = readLockHolder(lockFile)
const observed = statLock(lockFile)
- if (holder && isPidAlive(holder.pid)) {
+ if (isLockHolderActive(holder)) {
+ if (holder === UNREADABLE_LOCK_HOLDER) {
+ const held = new Error(
+ `Another xchain-node instance holds the command lock ` +
+ `(${lockFile} cannot be read). Wait for it to finish, ` +
+ `or inspect the lock file permissions before removing it.`
+ )
+ held.code = 'ELOCKHELD'
+ throw held
+ }
const what = holder.command ? ` (running "${holder.command}")` : ''
const held = new Error(
`Another xchain-node instance${what} holds the command lock ` +
@@ -175,7 +187,7 @@ function tryAcquireOnce(lockFile, payload, pid, nonce) {
held.code = 'ELOCKHELD'
throw held
}
- // Holder pid is dead (or the file is unreadable garbage): the lock is
+ // Holder pid is dead (or the file is unparseable garbage): the lock is
// stale, likely from a crashed/killed run. The file may also have gone
// between the read and the stat, in which case the retry just creates it.
if (observed) reclaimStaleLock(lockFile, reclaimFile, observed, pid, nonce)
@@ -221,11 +233,16 @@ function readLockHolder(lockFile) {
try {
const parsed = JSON.parse(fs.readFileSync(lockFile, 'utf8'))
return (parsed && Number.isInteger(parsed.pid)) ? parsed : null
- } catch {
+ } catch (err) {
+ if (err.code === 'EACCES' || err.code === 'EPERM') return UNREADABLE_LOCK_HOLDER
return null
}
}
+function isLockHolderActive(holder) {
+ return holder === UNREADABLE_LOCK_HOLDER || !!holder && isPidAlive(holder.pid)
+}
+
function releaseCommandLock(lockFile, pid, nonce) {
// Only delete a lock we still own; never clobber a successor's lock. The nonce
// is what makes ownership provable: a successor that happens to run under a
diff --git a/src/utils/module_database_name.js b/src/utils/module_database_name.js
index ad141b19..8682aeb1 100644
--- a/src/utils/module_database_name.js
+++ b/src/utils/module_database_name.js
@@ -16,6 +16,7 @@
'use strict'
+const crypto = require('crypto')
const { XChainService } = require('../config/module_names')
const { assertSafeDbIdentifier } = require('./sql_safety')
@@ -35,4 +36,41 @@ function configuredDatabaseName(module, cfg, defaultName) {
return assertSafeDbIdentifier(name, 'database name')
}
-module.exports = { DATABASE_NAME_KEY, configuredDatabaseName }
+// MariaDB caps a database name at 64 characters and the indexer's mirror database adds
+// '_HubMirror'. The longest unscoped name is 'XChain_DOGE_Testnet_Indexer' (27), so the
+// scope segment (separator included) is capped to keep name plus mirror suffix inside 64.
+const DB_NAME_MAX = 64
+const HUB_MIRROR_SUFFIX = '_HubMirror'
+const SCOPE_SEGMENT_MAX = 24
+
+// Folds a NODE_PREFIX to its readable SQL-identifier segment: '' for the default prefix, else
+// the prefix with characters outside [A-Za-z0-9] folded to '_' plus sep (a SQL identifier
+// cannot carry hyphens or dots). Lossy and unbounded; derived names use scopedNodePrefixSegment.
+function nodePrefixSegment(nodePrefix, defaultPrefix, sep = '_') {
+ if (nodePrefix === defaultPrefix) return ''
+ return String(nodePrefix).replace(/[^A-Za-z0-9]/g, '_') + sep
+}
+
+// The segment derived database and DB user names carry. Folding is lossy ('a-b' and 'a_b'
+// collide) and long prefixes must be cut, so either case swaps the tail for a digest of the
+// RAW prefix: distinct prefixes keep distinct names inside the length budget.
+function scopedNodePrefixSegment(nodePrefix, defaultPrefix, sep = '_') {
+ if (nodePrefix === defaultPrefix) return ''
+ const raw = String(nodePrefix)
+ const readable = nodePrefixSegment(raw, defaultPrefix, sep)
+ if (readable.slice(0, -sep.length) === raw && readable.length <= SCOPE_SEGMENT_MAX) return readable
+ const digest = crypto.createHash('sha256').update(raw).digest('hex').substring(0, 8)
+ const head = readable.substring(0, SCOPE_SEGMENT_MAX - sep.length * 2 - digest.length)
+ return head + sep + digest + sep
+}
+
+// The unprefixed default name a pre-scoping install (or the default stack) uses.
+function unscopedDefaultDatabaseName(module, ticker, network, sep = '_') {
+ const cap = (str) => str.charAt(0).toUpperCase() + str.slice(1)
+ return ['XChain', ticker, cap(network), cap(module.slice('xchain-'.length))].join(sep)
+}
+
+module.exports = {
+ DATABASE_NAME_KEY, DB_NAME_MAX, HUB_MIRROR_SUFFIX, configuredDatabaseName, nodePrefixSegment,
+ scopedNodePrefixSegment, unscopedDefaultDatabaseName
+}
diff --git a/test/chaos/docker_resilience_chaos.test/docker_build_failure_chaos.test.js b/test/chaos/docker_resilience_chaos.test/docker_build_failure_chaos.test.js
index e6927b54..eb187308 100644
--- a/test/chaos/docker_resilience_chaos.test/docker_build_failure_chaos.test.js
+++ b/test/chaos/docker_resilience_chaos.test/docker_build_failure_chaos.test.js
@@ -73,7 +73,10 @@ function loadModuleService(stubs, opts = {}) {
'./docker_service': {
killContainer: opts.killContainer || sinon.stub().resolves(true),
removeContainer: opts.removeContainer || sinon.stub().resolves(true),
- getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } })
+ getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } }),
+ // An empty port map keeps the host-port conflict check off the real `docker ps`,
+ // which on a host running containers reports their ports as conflicts.
+ getPublishedHostPorts: sinon.stub().resolves(new Map())
},
'./database_service': { setDatabaseParameters: sinon.stub().resolves() }
})
diff --git a/test/chaos/docker_resilience_chaos.test/docker_run_failure_chaos.test.js b/test/chaos/docker_resilience_chaos.test/docker_run_failure_chaos.test.js
index 4edc2b66..9ff462f1 100644
--- a/test/chaos/docker_resilience_chaos.test/docker_run_failure_chaos.test.js
+++ b/test/chaos/docker_resilience_chaos.test/docker_run_failure_chaos.test.js
@@ -73,7 +73,10 @@ function loadModuleService(stubs, opts = {}) {
'./docker_service': {
killContainer: opts.killContainer || sinon.stub().resolves(true),
removeContainer: opts.removeContainer || sinon.stub().resolves(true),
- getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } })
+ getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } }),
+ // An empty port map keeps the host-port conflict check off the real `docker ps`,
+ // which on a host running containers reports their ports as conflicts.
+ getPublishedHostPorts: sinon.stub().resolves(new Map())
},
'./database_service': { setDatabaseParameters: sinon.stub().resolves() }
})
diff --git a/test/chaos/docker_resilience_chaos.test/invalid_port_in_config_during_build_and_up_chaos.test.js b/test/chaos/docker_resilience_chaos.test/invalid_port_in_config_during_build_and_up_chaos.test.js
index f09e0eb8..19289714 100644
--- a/test/chaos/docker_resilience_chaos.test/invalid_port_in_config_during_build_and_up_chaos.test.js
+++ b/test/chaos/docker_resilience_chaos.test/invalid_port_in_config_during_build_and_up_chaos.test.js
@@ -52,7 +52,7 @@ function loadModuleServiceWithInvalidPort(stubs) {
})
},
'./status_service': { statusChanged: sinon.stub().resolves(), getStatus: sinon.stub().resolves({}) },
- './docker_service': { killContainer: sinon.stub().resolves(), removeContainer: sinon.stub().resolves() },
+ './docker_service': { killContainer: sinon.stub().resolves(), removeContainer: sinon.stub().resolves(), getPublishedHostPorts: sinon.stub().resolves(new Map()) },
'./database_service': { setDatabaseParameters: sinon.stub().resolves() }
})
}
diff --git a/test/chaos/docker_resilience_chaos.test/state_storage_failure_chaos.test.js b/test/chaos/docker_resilience_chaos.test/state_storage_failure_chaos.test.js
index 16f16a15..993ab1c3 100644
--- a/test/chaos/docker_resilience_chaos.test/state_storage_failure_chaos.test.js
+++ b/test/chaos/docker_resilience_chaos.test/state_storage_failure_chaos.test.js
@@ -73,7 +73,10 @@ function loadModuleService(stubs, opts = {}) {
'./docker_service': {
killContainer: opts.killContainer || sinon.stub().resolves(true),
removeContainer: opts.removeContainer || sinon.stub().resolves(true),
- getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } })
+ getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } }),
+ // An empty port map keeps the host-port conflict check off the real `docker ps`,
+ // which on a host running containers reports their ports as conflicts.
+ getPublishedHostPorts: sinon.stub().resolves(new Map())
},
'./database_service': { setDatabaseParameters: sinon.stub().resolves() }
})
diff --git a/test/chaos/process_resilience_chaos.test/helpers/index.js b/test/chaos/process_resilience_chaos.test/helpers/index.js
index 4e32226e..de7281d5 100644
--- a/test/chaos/process_resilience_chaos.test/helpers/index.js
+++ b/test/chaos/process_resilience_chaos.test/helpers/index.js
@@ -75,7 +75,10 @@ function loadModuleService(stubs, opts = {}) {
stopped: true, seconds: 1, killed: false
}),
removeContainer: opts.removeContainer || sinon.stub().resolves(true),
- getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } })
+ getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } }),
+ // An empty port map keeps the host-port conflict check off the real `docker ps`,
+ // which on a host running containers reports their ports as conflicts.
+ getPublishedHostPorts: sinon.stub().resolves(new Map())
},
'./database_service': { setDatabaseParameters: sinon.stub().resolves() }
})
diff --git a/test/e2e/scenarios/exec_logs.test.js b/test/e2e/scenarios/exec_logs.test.js
index 6d1d5e8a..f837b75d 100644
--- a/test/e2e/scenarios/exec_logs.test.js
+++ b/test/e2e/scenarios/exec_logs.test.js
@@ -130,7 +130,7 @@ describe('E2E: Exec and Logs Commands (Scenarios 4.11, 4.12)', function () {
this.timeout(30000)
beforeEach(setupEnv)
afterEach(teardownEnv)
- // E2E-072: Restart calls docker restart for each module
+ // E2E-072: Restart stops each module within its stop budget, then starts it
describe('E2E-072: Restart command', function () {
it('calls docker restart for each installed module', async function () {
@@ -147,11 +147,17 @@ describe('E2E: Exec and Logs Commands (Scenarios 4.11, 4.12)', function () {
await cli.moduleOps.restartModules(serviceList)
- const restartCmds = env.capture.findCommands(/docker restart/)
- expect(restartCmds.length).to.be.greaterThanOrEqual(5)
-
- for (const cmd of restartCmds) {
- const restartedId = cmd.command.split(/\s+/).pop()
+ // A budgeted stop then a start per module, never a bare `docker restart` and docker's ten seconds
+ expect(env.capture.findCommands(/docker restart/)).to.have.length(0)
+ const stopCmds = env.capture.findCommands(/docker stop/)
+ expect(stopCmds.length).to.be.greaterThanOrEqual(5)
+ for (const cmd of stopCmds) {
+ expect(cmd.command, 'every restart stop carries a budget').to.match(/docker stop -t \d+ \S+$/)
+ }
+ const stoppedIds = stopCmds.map(cmd => cmd.command.split(/\s+/).pop())
+ const startedIds = env.capture.findCommands(/docker start/).map(cmd => cmd.command.split(/\s+/).pop())
+ expect(startedIds, 'every stopped module is started again').to.have.members(stoppedIds)
+ for (const restartedId of startedIds) {
expect(containerIds, `restarted ID ${restartedId} in LevelDB`).to.include(restartedId)
}
})
diff --git a/test/helpers/stake_harness.js b/test/helpers/stake_harness.js
index 922eebf8..d2c3ca0c 100644
--- a/test/helpers/stake_harness.js
+++ b/test/helpers/stake_harness.js
@@ -26,7 +26,14 @@ function makeExplorer(chain) {
? sinon.stub().rejects(Object.assign(
new Error('Explorer returned HTTP 404 for /RBTC/api/token/XCHAIN'),
{ code: 'EXPLORER_HTTP_404', details: { status: 404 } }))
- : sinon.stub().resolves({ mints: { max: chain.mintMax ?? 10000, address_max: chain.addressMax ?? 50000 } }),
+ : sinon.stub().resolves({
+ mints: { max: chain.mintMax ?? 10000, address_max: chain.addressMax ?? 50000,
+ start_block: chain.mintStart ?? 0, stop_block: chain.mintStop ?? 0 },
+ locks: { mint: chain.mintLocked ?? false } }),
+ // The MINT rows the explorer's source lane holds for the stake address; none by default.
+ getMints: chain.mintsThrow
+ ? sinon.stub().rejects(new Error(chain.mintsThrow))
+ : sinon.stub().resolves({ total: (chain.priorMints || []).length, data: chain.priorMints || [] }),
// The whole validator set, which is the method the SDK actually has.
// Carries an unrelated validator too, so the pubkey filter is exercised
// rather than "the only row wins".
diff --git a/test/integration/module_lifecycle.test.js b/test/integration/module_lifecycle.test.js
index 50b5eaa2..c0735b0a 100644
--- a/test/integration/module_lifecycle.test.js
+++ b/test/integration/module_lifecycle.test.js
@@ -219,7 +219,7 @@ describe('Integration: Module Lifecycle (LevelDB state)', function () {
const moduleOps = proxyquire('../../src/operations/module_operations', {
'../services/docker_service': {
- stopContainerByName: async (id, seconds) => {
+ stopContainer: async (id, seconds) => {
capture._history.push({ command: 'docker stop -t ' + seconds + ' ' + id, type: 'exec', options: {} })
return { stopped: true, seconds: 1, killed: false, exitCode: 0 }
},
@@ -263,7 +263,7 @@ describe('Integration: Module Lifecycle (LevelDB state)', function () {
const moduleOps = proxyquire('../../src/operations/module_operations', {
'../services/docker_service': {
- stopContainerByName: async (id, seconds) => {
+ stopContainer: async (id, seconds) => {
capture._history.push({ command: 'docker stop -t ' + seconds + ' ' + id, type: 'exec', options: {} })
return { stopped: true, seconds: 1, killed: false, exitCode: 0 }
},
@@ -330,15 +330,18 @@ describe('Integration: Module Lifecycle (LevelDB state)', function () {
describe('moduleOperations uses LevelDB for container lookups', function () {
it('restartModules reads IDs and calls docker restart', async function () {
const restartedIds = []
+ const stoppedWith = []
const containerId = TestEnv.fakeContainerId('r')
await env.insertModule('xchain-indexer', 'bitcoin', 'mainnet', containerId)
const moduleOps = proxyquire('../../src/operations/module_operations', {
'../services/docker_service': {
- restartContainer: async (id) => { restartedIds.push(id); return true },
+ // A restart is a budgeted stop then a start, never a bare docker restart
+ restartContainer: async () => { throw new Error('restart must not use a bare docker restart') },
createDockerNetwork: async () => true,
- startContainer: async () => true,
- stopContainer: async () => true,
+ startContainer: async (id) => { restartedIds.push(id); return true },
+ stopContainer: async (id, seconds) => { stoppedWith.push([id, seconds]); return true },
+ getContainerStopSettings: async () => null,
killContainer: async () => true,
removeContainer: async () => true,
execContainer: async () => '',
@@ -356,6 +359,7 @@ describe('Integration: Module Lifecycle (LevelDB state)', function () {
expect(restartedIds).to.have.length(1)
expect(restartedIds[0]).to.equal(containerId)
+ expect(stoppedWith).to.deep.equal([[containerId, 30]])
})
})
})
diff --git a/test/regression/regression.test/04_service_lifecycle.test.js b/test/regression/regression.test/04_service_lifecycle.test.js
index 21b85a21..2fc17ad9 100644
--- a/test/regression/regression.test/04_service_lifecycle.test.js
+++ b/test/regression/regression.test/04_service_lifecycle.test.js
@@ -145,12 +145,15 @@ describe('Regression Suite', function () {
await env.insertModule('xchain-indexer', 'bitcoin', 'mainnet', containerId)
const restartedIds = []
+ const stoppedWith = []
const moduleOps = proxyquire('../../../src/operations/module_operations', {
'../services/docker_service': {
- restartContainer: async (id) => { restartedIds.push(id); return true },
+ // A restart is a budgeted stop then a start, never a bare docker restart
+ restartContainer: async () => { throw new Error('restart must not use a bare docker restart') },
createDockerNetwork: async () => true,
- startContainer: async () => true,
- stopContainer: async () => true,
+ startContainer: async (id) => { restartedIds.push(id); return true },
+ stopContainer: async (id, seconds) => { stoppedWith.push([id, seconds]); return true },
+ getContainerStopSettings: async () => null,
killContainer: async () => true,
removeContainer: async () => true,
execContainer: async () => '',
@@ -165,6 +168,7 @@ describe('Regression Suite', function () {
await moduleOps.restartModules(serviceList)
expect(restartedIds).to.have.length(1)
expect(restartedIds[0]).to.equal(containerId)
+ expect(stoppedWith).to.deep.equal([[containerId, 30]])
})
})
})
diff --git a/test/unit/autoheal_service.test.js b/test/unit/autoheal_service.test.js
index e3aa3146..aa54e9eb 100644
--- a/test/unit/autoheal_service.test.js
+++ b/test/unit/autoheal_service.test.js
@@ -52,7 +52,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -61,7 +62,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../src/services/module_service').SERVICE_HEALTHCHECK }
@@ -111,7 +114,7 @@ describe('AutohealService', () => {
const result = await service.runAutoheal({ now: NOW })
- expect(stubs.restartContainer.calledOnceWith('aaa')).to.equal(true)
+ expect(stubs.startContainer.calledOnceWith('aaa')).to.equal(true)
expect(result.restarted).to.have.length(1)
expect(result.failed).to.have.length(0)
})
@@ -122,7 +125,7 @@ describe('AutohealService', () => {
const result = await service.runAutoheal({ now: NOW })
- expect(stubs.restartContainer.called).to.equal(false)
+ expect(stubs.stopContainer.called).to.equal(false)
expect(result.candidates).to.have.length(0)
})
})
diff --git a/test/unit/autoheal_service.test/01_health_state_guards.test.js b/test/unit/autoheal_service.test/01_health_state_guards.test.js
index 09a27ba2..e21c7dc5 100644
--- a/test/unit/autoheal_service.test/01_health_state_guards.test.js
+++ b/test/unit/autoheal_service.test/01_health_state_guards.test.js
@@ -59,7 +59,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -68,7 +69,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK }
@@ -104,7 +107,7 @@ describe('AutohealService', () => {
const result = await service.runAutoheal({ now: NOW })
- expect(stubs.restartContainer.called).to.equal(false)
+ expect(stubs.stopContainer.called).to.equal(false)
expect(result.skipped[0].reason).to.equal('inside grace window')
})
@@ -114,7 +117,7 @@ describe('AutohealService', () => {
const result = await service.runAutoheal({ now: NOW })
- expect(stubs.restartContainer.called).to.equal(false)
+ expect(stubs.stopContainer.called).to.equal(false)
expect(result.candidates).to.have.length(0)
})
@@ -128,7 +131,7 @@ describe('AutohealService', () => {
const result = await service.runAutoheal({ now: NOW })
- expect(stubs.restartContainer.called).to.equal(false)
+ expect(stubs.stopContainer.called).to.equal(false)
expect(result.candidates).to.have.length(0)
expect(result.skipped[0].reason).to.equal('not running (state: exited)')
})
diff --git a/test/unit/autoheal_service.test/02_fresh_grace_after_stop.test.js b/test/unit/autoheal_service.test/02_fresh_grace_after_stop.test.js
index 2b5c8232..73a82643 100644
--- a/test/unit/autoheal_service.test/02_fresh_grace_after_stop.test.js
+++ b/test/unit/autoheal_service.test/02_fresh_grace_after_stop.test.js
@@ -59,7 +59,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -68,7 +69,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK }
@@ -125,7 +128,7 @@ describe('AutohealService', () => {
stubs.getStatusFromContainer.resolves(inspectStatus('unhealthy', [freshFailure]))
const third = await service.runAutoheal({ now: at })
- expect(stubs.restartContainer.called).to.equal(false)
+ expect(stubs.stopContainer.called).to.equal(false)
expect(third.skipped[0].reason).to.equal('inside grace window')
})
})
diff --git a/test/unit/autoheal_service.test/11_dry_run_and_missing_timing.test.js b/test/unit/autoheal_service.test/11_dry_run_and_missing_timing.test.js
index ecae510c..7fd95aa5 100644
--- a/test/unit/autoheal_service.test/11_dry_run_and_missing_timing.test.js
+++ b/test/unit/autoheal_service.test/11_dry_run_and_missing_timing.test.js
@@ -52,7 +52,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -61,7 +62,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK }
@@ -98,7 +101,7 @@ describe('AutohealService', () => {
const dry = await service.runAutoheal({ dryRun: true, now: NOW })
expect(dry.candidates).to.have.length(1)
expect(dry.restarted).to.have.length(0)
- expect(stubs.restartContainer.called).to.equal(false)
+ expect(stubs.stopContainer.called).to.equal(false)
// A real pass right after must still restart (dry run wrote no state).
const real = await service.runAutoheal({ now: NOW })
@@ -111,7 +114,7 @@ describe('AutohealService', () => {
const result = await service.runAutoheal({ now: NOW })
- expect(stubs.restartContainer.called).to.equal(false)
+ expect(stubs.stopContainer.called).to.equal(false)
expect(result.skipped[0].reason).to.match(/no failing probe log/)
})
})
diff --git a/test/unit/autoheal_service.test/12_restart_and_inspection_errors.test.js b/test/unit/autoheal_service.test/12_restart_and_inspection_errors.test.js
index 176fb837..c808f9c6 100644
--- a/test/unit/autoheal_service.test/12_restart_and_inspection_errors.test.js
+++ b/test/unit/autoheal_service.test/12_restart_and_inspection_errors.test.js
@@ -52,7 +52,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -61,7 +62,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK }
@@ -97,8 +100,8 @@ describe('AutohealService', () => {
{ module: 'xchain-decoder', coin: 'litecoin', network: 'regtest', container_id: 'jjj' }
])
stubs.getStatusFromContainer.resolves(unhealthyPastGrace())
- stubs.restartContainer.withArgs('iii').rejects(new Error('docker daemon gone'))
- stubs.restartContainer.withArgs('jjj').resolves(true)
+ stubs.stopContainer.withArgs('iii').rejects(new Error('docker daemon gone'))
+ stubs.stopContainer.withArgs('jjj').resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 })
const result = await service.runAutoheal({ now: NOW })
diff --git a/test/unit/autoheal_service.test/13_ring_buffer_restart.test.js b/test/unit/autoheal_service.test/13_ring_buffer_restart.test.js
index b30b6fc8..4f10c10f 100644
--- a/test/unit/autoheal_service.test/13_ring_buffer_restart.test.js
+++ b/test/unit/autoheal_service.test/13_ring_buffer_restart.test.js
@@ -53,7 +53,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -62,7 +63,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK }
@@ -111,6 +114,6 @@ describe('AutohealService', () => {
stubs.getStatusFromContainer.resolves(unhealthyRingBuffer(NOW + 3 * 60000))
const second = await service.runAutoheal({ now: NOW + 3 * 60000 })
expect(second.restarted, 'a sustained wedge must eventually be restarted').to.have.length(1)
- expect(stubs.restartContainer.calledOnceWith('ring')).to.equal(true)
+ expect(stubs.startContainer.calledOnceWith('ring')).to.equal(true)
})
})
diff --git a/test/unit/autoheal_service.test/14_recovery_clears_onset.test.js b/test/unit/autoheal_service.test/14_recovery_clears_onset.test.js
index f7d65aef..9d77f2cd 100644
--- a/test/unit/autoheal_service.test/14_recovery_clears_onset.test.js
+++ b/test/unit/autoheal_service.test/14_recovery_clears_onset.test.js
@@ -64,7 +64,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -73,7 +74,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK }
diff --git a/test/unit/autoheal_service.test/15_retained_probe_recovery.test.js b/test/unit/autoheal_service.test/15_retained_probe_recovery.test.js
index 22b9408d..1ab80b10 100644
--- a/test/unit/autoheal_service.test/15_retained_probe_recovery.test.js
+++ b/test/unit/autoheal_service.test/15_retained_probe_recovery.test.js
@@ -65,7 +65,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -74,7 +75,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK }
@@ -127,7 +130,7 @@ describe('AutohealService', () => {
]))
const second = await service.runAutoheal({ now: later })
- expect(stubs.restartContainer.called, 'a relapse must serve its own grace window').to.equal(false)
+ expect(stubs.stopContainer.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'))
diff --git a/test/unit/autoheal_service.test/16_registry_state.test.js b/test/unit/autoheal_service.test/16_registry_state.test.js
index 2dda7629..8f8e656b 100644
--- a/test/unit/autoheal_service.test/16_registry_state.test.js
+++ b/test/unit/autoheal_service.test/16_registry_state.test.js
@@ -53,7 +53,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -62,7 +63,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK }
diff --git a/test/unit/autoheal_service.test/17_grace_override.test.js b/test/unit/autoheal_service.test/17_grace_override.test.js
index b03b3372..45a925ef 100644
--- a/test/unit/autoheal_service.test/17_grace_override.test.js
+++ b/test/unit/autoheal_service.test/17_grace_override.test.js
@@ -52,7 +52,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -61,7 +62,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK }
diff --git a/test/unit/autoheal_service.test/18_health_log_helpers.test.js b/test/unit/autoheal_service.test/18_health_log_helpers.test.js
index e4dae571..e775c49c 100644
--- a/test/unit/autoheal_service.test/18_health_log_helpers.test.js
+++ b/test/unit/autoheal_service.test/18_health_log_helpers.test.js
@@ -69,7 +69,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -78,7 +79,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK }
diff --git a/test/unit/autoheal_service.test/20_budgeted_restart.test.js b/test/unit/autoheal_service.test/20_budgeted_restart.test.js
new file mode 100644
index 00000000..90c6f8dd
--- /dev/null
+++ b/test/unit/autoheal_service.test/20_budgeted_restart.test.js
@@ -0,0 +1,120 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+// Pins the autoheal restart to the service's stop budget. A bare `docker restart` gave an
+// unstamped decoder docker's ten seconds, which SIGKILLs a drain waiting for a block boundary.
+
+const fs = require('fs')
+const os = require('os')
+const path = require('path')
+const sinon = require('sinon')
+const { expect } = require('chai')
+const proxyquire = require('proxyquire').noCallThru()
+
+const NOW = Date.parse('2026-07-21T12:00:00Z')
+
+// Health.Log entry helper: a probe that started `agoMs` before NOW.
+function logEntry(agoMs, exitCode) {
+ const start = new Date(NOW - agoMs)
+ return {
+ Start: start.toISOString(),
+ End: new Date(start.getTime() + 1000).toISOString(),
+ ExitCode: exitCode,
+ Output: exitCode === 0 ? 'ok' : 'wget: server returned error'
+ }
+}
+
+// Continuously unhealthy for ~10 minutes (well past the 2min default grace).
+function unhealthyPastGrace() {
+ return {
+ State: {
+ Status: 'running',
+ Health: {
+ Status: 'unhealthy',
+ FailingStreak: 5,
+ Log: [logEntry(11 * 60000, 0), logEntry(10 * 60000, 1), logEntry(5 * 60000, 1), logEntry(60000, 1)]
+ }
+ }
+ }
+}
+
+function makeStubs() {
+ return {
+ db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
+ getStatusFromContainer: sinon.stub(),
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true),
+ // An unstamped container: created before the stop budgets existed
+ getContainerStopSettings: sinon.stub().resolves({ stopTimeout: null, shutdownTimeoutMs: null })
+ }
+}
+
+function loadService(stubs) {
+ return proxyquire('../../../src/services/autoheal_service', {
+ '../state': { db: stubs.db },
+ './docker_service': {
+ getStatusFromContainer: stubs.getStatusFromContainer,
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: stubs.getContainerStopSettings
+ },
+ './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK }
+ })
+}
+
+describe('AutohealService', () => {
+ let stubs, service, stateDir, logStub, warnStub
+
+ beforeEach(() => {
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'autoheal-test-'))
+ process.env.XCHAIN_NODE_AUTOHEAL_STATE_DIR = stateDir
+ stubs = makeStubs()
+ service = loadService(stubs)
+ logStub = sinon.stub(console, 'log')
+ warnStub = sinon.stub(console, 'warn')
+ })
+
+ afterEach(() => {
+ logStub.restore()
+ warnStub.restore()
+ delete process.env.XCHAIN_NODE_AUTOHEAL_STATE_DIR
+ fs.rmSync(stateDir, { recursive: true, force: true })
+ })
+
+ it('restarts an unstamped decoder with its 120 s stop budget, then starts it', async () => {
+ stubs.db.getAllModuleContainers.resolves([
+ { module: 'xchain-decoder', coin: 'bitcoin', network: 'mainnet', container_id: 'dec1' }
+ ])
+ stubs.getStatusFromContainer.resolves(unhealthyPastGrace())
+
+ const result = await service.runAutoheal({ now: NOW })
+
+ expect(result.restarted).to.have.length(1)
+ expect(stubs.stopContainer.calledOnceWith('dec1', 120)).to.equal(true)
+ expect(stubs.startContainer.calledOnceWith('dec1')).to.equal(true)
+ expect(stubs.stopContainer.calledBefore(stubs.startContainer)).to.equal(true)
+ })
+
+ it('reports a restart whose start failed after the stop, naming the stopped service', async () => {
+ stubs.db.getAllModuleContainers.resolves([
+ { module: 'xchain-indexer', coin: 'bitcoin', network: 'mainnet', container_id: 'idx1' }
+ ])
+ stubs.getStatusFromContainer.resolves(unhealthyPastGrace())
+ stubs.startContainer.rejects(new Error('port is already allocated'))
+
+ const result = await service.runAutoheal({ now: NOW })
+
+ expect(result.restarted).to.have.length(0)
+ expect(result.failed).to.have.length(1)
+ expect(result.failed[0].reason).to.match(/xchain-indexer was stopped but failed to start again/)
+ })
+})
diff --git a/test/unit/autoheal_service.test/backoff/03_restart_cooldown.test.js b/test/unit/autoheal_service.test/backoff/03_restart_cooldown.test.js
index ba5b0c52..3a077bb2 100644
--- a/test/unit/autoheal_service.test/backoff/03_restart_cooldown.test.js
+++ b/test/unit/autoheal_service.test/backoff/03_restart_cooldown.test.js
@@ -52,7 +52,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -61,7 +62,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK }
@@ -103,7 +106,7 @@ describe('AutohealService', () => {
stubs.getStatusFromContainer.resolves(unhealthyPastGrace())
const second = await service.runAutoheal({ now: NOW + 3 * 60000 })
- expect(stubs.restartContainer.callCount).to.equal(1)
+ expect(stubs.startContainer.callCount).to.equal(1)
expect(second.skipped[0].reason).to.equal('inside restart cooldown')
})
@@ -114,7 +117,7 @@ describe('AutohealService', () => {
await service.runAutoheal({ now: NOW })
const later = await service.runAutoheal({ now: NOW + 11 * 60000 })
- expect(stubs.restartContainer.callCount).to.equal(2)
+ expect(stubs.startContainer.callCount).to.equal(2)
expect(later.restarted).to.have.length(1)
})
})
diff --git a/test/unit/autoheal_service.test/backoff/04_exponential_backoff.test.js b/test/unit/autoheal_service.test/backoff/04_exponential_backoff.test.js
index 07958652..3899fe6f 100644
--- a/test/unit/autoheal_service.test/backoff/04_exponential_backoff.test.js
+++ b/test/unit/autoheal_service.test/backoff/04_exponential_backoff.test.js
@@ -52,7 +52,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -61,7 +62,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK }
@@ -101,17 +104,17 @@ describe('AutohealService', () => {
await service.runAutoheal({ now: NOW }) // restart #1
await service.runAutoheal({ now: NOW + 11 * 60000 }) // restart #2, base cooldown
- expect(stubs.restartContainer.callCount).to.equal(2)
+ expect(stubs.startContainer.callCount).to.equal(2)
// 11 minutes after restart #2 clears the BASE cooldown but not the doubled
// one, so the old fixed-window behavior would have restarted here.
const throttled = await service.runAutoheal({ now: NOW + 22 * 60000 })
- expect(stubs.restartContainer.callCount).to.equal(2)
+ expect(stubs.startContainer.callCount).to.equal(2)
expect(throttled.skipped[0].reason).to.equal('inside restart cooldown')
// 21 minutes after restart #2 clears the doubled window.
const resumed = await service.runAutoheal({ now: NOW + 32 * 60000 })
- expect(stubs.restartContainer.callCount).to.equal(3)
+ expect(stubs.startContainer.callCount).to.equal(3)
expect(resumed.restarted).to.have.length(1)
const state = JSON.parse(fs.readFileSync(path.join(stateDir, 'autoheal-state.json'), 'utf8'))
diff --git a/test/unit/autoheal_service.test/backoff/05_recovery_resets_backoff.test.js b/test/unit/autoheal_service.test/backoff/05_recovery_resets_backoff.test.js
index c2ffcc04..70ab21b2 100644
--- a/test/unit/autoheal_service.test/backoff/05_recovery_resets_backoff.test.js
+++ b/test/unit/autoheal_service.test/backoff/05_recovery_resets_backoff.test.js
@@ -52,7 +52,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -61,7 +62,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK }
@@ -97,7 +100,7 @@ describe('AutohealService', () => {
await service.runAutoheal({ now: NOW }) // restart #1
await service.runAutoheal({ now: NOW + 11 * 60000 }) // restart #2 -> next wait doubles
- expect(stubs.restartContainer.callCount).to.equal(2)
+ expect(stubs.startContainer.callCount).to.equal(2)
// Container comes back healthy: the earned backoff is not a debt it carries
// into a later, unrelated wedge.
@@ -111,7 +114,7 @@ describe('AutohealService', () => {
// 20-minute window would still be blocking.
stubs.getStatusFromContainer.resolves(unhealthyPastGrace())
const again = await service.runAutoheal({ now: NOW + 22 * 60000 })
- expect(stubs.restartContainer.callCount).to.equal(3)
+ expect(stubs.startContainer.callCount).to.equal(3)
expect(again.restarted).to.have.length(1)
})
})
diff --git a/test/unit/autoheal_service.test/backoff/07_stopped_container_backoff.test.js b/test/unit/autoheal_service.test/backoff/07_stopped_container_backoff.test.js
index 9dd696fc..1b8a554b 100644
--- a/test/unit/autoheal_service.test/backoff/07_stopped_container_backoff.test.js
+++ b/test/unit/autoheal_service.test/backoff/07_stopped_container_backoff.test.js
@@ -59,7 +59,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -68,7 +69,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK }
@@ -106,7 +109,7 @@ describe('AutohealService', () => {
await service.runAutoheal({ now: NOW }) // restart #1
await service.runAutoheal({ now: NOW + 11 * 60000 }) // restart #2
- expect(stubs.restartContainer.callCount).to.equal(2)
+ expect(stubs.startContainer.callCount).to.equal(2)
stubs.getStatusFromContainer.resolves(stoppedWithFrozenUnhealthy())
const skipped = await service.runAutoheal({ now: NOW + 12 * 60000 })
diff --git a/test/unit/autoheal_service.test/backoff/10_backoff_calculations.test.js b/test/unit/autoheal_service.test/backoff/10_backoff_calculations.test.js
index 1b8db64e..349aa6cc 100644
--- a/test/unit/autoheal_service.test/backoff/10_backoff_calculations.test.js
+++ b/test/unit/autoheal_service.test/backoff/10_backoff_calculations.test.js
@@ -23,7 +23,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -32,7 +33,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK }
diff --git a/test/unit/autoheal_service.test/backoff/20_escalation_signal.test.js b/test/unit/autoheal_service.test/backoff/20_escalation_signal.test.js
new file mode 100644
index 00000000..a5e29695
--- /dev/null
+++ b/test/unit/autoheal_service.test/backoff/20_escalation_signal.test.js
@@ -0,0 +1,181 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+// A wedge two restarts did not clear is the "investigate" case the file header
+// names as the escalation path, so it must reach something a timer can act on:
+// a warn line on stderr and exit code 2, while a failed restart logs at error.
+
+const fs = require('fs')
+const os = require('os')
+const path = require('path')
+const sinon = require('sinon')
+const { expect } = require('chai')
+const { Command } = require('commander')
+const proxyquire = require('proxyquire').noCallThru()
+
+const NOW = Date.parse('2026-07-21T12:00:00Z')
+const MIN = 60000
+
+// Health.Log entry helper: a probe that started `agoMs` before NOW.
+function logEntry(agoMs, exitCode) {
+ const start = new Date(NOW - agoMs)
+ return {
+ Start: start.toISOString(),
+ End: new Date(start.getTime() + 1000).toISOString(),
+ ExitCode: exitCode,
+ Output: exitCode === 0 ? 'ok' : 'wget: server returned error'
+ }
+}
+
+// Continuously unhealthy for ~10 minutes (well past the 2min default grace).
+function unhealthyPastGrace() {
+ const log = [logEntry(11 * MIN, 0), logEntry(10 * MIN, 1), logEntry(5 * MIN, 1), logEntry(MIN, 1)]
+ return { State: { Status: 'running', Health: { Status: 'unhealthy', FailingStreak: 5, Log: log } } }
+}
+
+// Stubs both restart routes (a plain restart and a budgeted stop then start), so
+// the escalation contract is pinned whichever one the service takes.
+function makeStubs() {
+ return {
+ db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
+ getStatusFromContainer: sinon.stub(),
+ restartContainer: sinon.stub().resolves(true),
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
+ }
+}
+
+function loadService(stubs) {
+ return proxyquire('../../../../src/services/autoheal_service', {
+ '../state': { db: stubs.db },
+ './docker_service': {
+ getStatusFromContainer: stubs.getStatusFromContainer,
+ restartContainer: stubs.restartContainer,
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
+ },
+ './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK }
+ })
+}
+
+// Each suite gets a fresh state dir, a wedged indexer row and silenced console sinks.
+function escalationHarness() {
+ const h = {}
+ beforeEach(() => {
+ h.stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'autoheal-escalation-'))
+ process.env.XCHAIN_NODE_AUTOHEAL_STATE_DIR = h.stateDir
+ h.stubs = makeStubs()
+ h.stubs.db.getAllModuleContainers.resolves([{ module: 'xchain-indexer', coin: 'bitcoin', network: 'regtest', container_id: 'esc1' }])
+ h.stubs.getStatusFromContainer.resolves(unhealthyPastGrace())
+ h.service = loadService(h.stubs)
+ h.log = sinon.stub(console, 'log')
+ h.warn = sinon.stub(console, 'warn')
+ h.error = sinon.stub(console, 'error')
+ })
+ afterEach(() => {
+ h.log.restore()
+ h.warn.restore()
+ h.error.restore()
+ delete process.env.XCHAIN_NODE_AUTOHEAL_STATE_DIR
+ fs.rmSync(h.stateDir, { recursive: true, force: true })
+ })
+ return h
+}
+
+// Restart #1 at NOW and #2 after the base cooldown, leaving attempts at 2.
+async function twoRestartsThatDidNotClear(h) {
+ const first = await h.service.runAutoheal({ now: NOW })
+ const second = await h.service.runAutoheal({ now: NOW + 11 * MIN })
+ expect(first.restarted.length + second.restarted.length).to.equal(2)
+ expect(first.escalated.length + second.escalated.length).to.equal(0)
+}
+
+function warnedInvestigate(h) {
+ return h.warn.getCalls().some(c => /investigate/.test(String(c.args[0])))
+}
+
+describe('AutohealService escalation', () => {
+ const h = escalationHarness()
+
+ it('escalates a wedge that outlived two restarts at warn and exit code 2', async () => {
+ await twoRestartsThatDidNotClear(h)
+ const throttled = await h.service.runAutoheal({ now: NOW + 22 * MIN })
+ expect(throttled.skipped[0].reason).to.equal('inside restart cooldown')
+ expect(throttled.escalated).to.have.length(1)
+ expect(throttled.escalated[0]).to.include({ containerId: 'esc1', attempts: 2 })
+ expect(warnedInvestigate(h)).to.be.true
+ expect(h.service.autohealExitCode(throttled)).to.equal(2)
+ })
+
+ it('keeps the first restart cooldown an info line that does not escalate', async () => {
+ await h.service.runAutoheal({ now: NOW })
+ const first = await h.service.runAutoheal({ now: NOW + 5 * MIN })
+ expect(first.skipped[0].reason).to.equal('inside restart cooldown')
+ expect(first.escalated).to.deep.equal([])
+ expect(h.warn.called).to.be.false
+ expect(h.log.getCalls().some(c => /investigate/.test(String(c.args[0])))).to.be.true
+ expect(h.service.autohealExitCode(first)).to.equal(0)
+ })
+
+ it('escalates on a restart #3 pass too, not only on a cooldown skip', async () => {
+ await twoRestartsThatDidNotClear(h)
+ const resumed = await h.service.runAutoheal({ now: NOW + 32 * MIN })
+ expect(resumed.restarted).to.have.length(1)
+ expect(resumed.escalated).to.have.length(1)
+ expect(warnedInvestigate(h)).to.be.true
+ expect(h.service.autohealExitCode(resumed)).to.equal(2)
+ })
+
+ it('reports the escalation on a dry run without restarting', async () => {
+ await twoRestartsThatDidNotClear(h)
+ const dry = await h.service.runAutoheal({ now: NOW + 32 * MIN, dryRun: true })
+ expect(dry.restarted).to.deep.equal([])
+ expect(dry.candidates).to.have.length(1)
+ expect(dry.escalated).to.have.length(1)
+ })
+})
+
+describe('AutohealService escalation', () => {
+ const h = escalationHarness()
+
+ it('logs a failed restart at error and exits 1', async () => {
+ h.stubs.restartContainer.rejects(new Error('daemon gone'))
+ h.stubs.startContainer.rejects(new Error('daemon gone'))
+ const result = await h.service.runAutoheal({ now: NOW })
+ expect(result.failed).to.have.length(1)
+ expect(h.error.getCalls().some(c => /FAILED to restart/.test(String(c.args[0])))).to.be.true
+ expect(h.service.autohealExitCode(result)).to.equal(1)
+ })
+
+ it('ranks a failed restart over an escalation in the exit code', () => {
+ const { autohealExitCode } = h.service
+ expect(autohealExitCode({ failed: [{}], escalated: [{}] })).to.equal(1)
+ expect(autohealExitCode({ failed: [], escalated: [{}] })).to.equal(2)
+ expect(autohealExitCode({ failed: [], escalated: [] })).to.equal(0)
+ })
+
+ it('exits the autoheal command with the escalation code', async () => {
+ const { registerLifecycleCommands } = require('../../../../src/cli/options')
+ const program = new Command().exitOverride()
+ const result = { candidates: [], restarted: [], failed: [], skipped: [], escalated: [{ attempts: 2 }] }
+ const loadModule = () => ({ runAutoheal: async () => result, autohealExitCode: h.service.autohealExitCode })
+ registerLifecycleCommands(program, { loadModule })
+ const exit = sinon.stub(process, 'exit')
+ try {
+ await program.parseAsync(['node', 'xchain-node', 'autoheal'])
+ expect(exit.calledOnceWith(2)).to.be.true
+ } finally {
+ exit.restore()
+ }
+ })
+})
diff --git a/test/unit/autoheal_service.test/probation/06_starting_probation.test.js b/test/unit/autoheal_service.test/probation/06_starting_probation.test.js
index 89f87205..2d1d8909 100644
--- a/test/unit/autoheal_service.test/probation/06_starting_probation.test.js
+++ b/test/unit/autoheal_service.test/probation/06_starting_probation.test.js
@@ -52,7 +52,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -61,7 +62,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK }
@@ -102,7 +105,7 @@ describe('AutohealService', () => {
await service.runAutoheal({ now: NOW }) // restart #1
await service.runAutoheal({ now: NOW + 11 * 60000 }) // restart #2 -> next wait doubles
- expect(stubs.restartContainer.callCount).to.equal(2)
+ expect(stubs.startContainer.callCount).to.equal(2)
// A minute after restart #2: Docker still reports `starting`.
stubs.getStatusFromContainer.resolves(inspectStatus('starting', [logEntry(15000, 1)]))
@@ -116,7 +119,7 @@ describe('AutohealService', () => {
// still open, so nothing restarts; with the counter wiped it would have.
stubs.getStatusFromContainer.resolves(unhealthyPastGrace())
const throttled = await service.runAutoheal({ now: NOW + 22 * 60000 })
- expect(stubs.restartContainer.callCount).to.equal(2)
+ expect(stubs.startContainer.callCount).to.equal(2)
expect(throttled.skipped[0].reason).to.equal('inside restart cooldown')
})
})
diff --git a/test/unit/autoheal_service.test/probation/08_recovery_past_probation.test.js b/test/unit/autoheal_service.test/probation/08_recovery_past_probation.test.js
index 32f8cc55..afa0fa9f 100644
--- a/test/unit/autoheal_service.test/probation/08_recovery_past_probation.test.js
+++ b/test/unit/autoheal_service.test/probation/08_recovery_past_probation.test.js
@@ -81,7 +81,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -90,7 +91,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK }
@@ -130,7 +133,7 @@ describe('AutohealService', () => {
await service.runAutoheal({ now: NOW }) // restart #1
await service.runAutoheal({ now: NOW + 11 * 60000 }) // restart #2 -> next wait doubles
- expect(stubs.restartContainer.callCount).to.equal(2)
+ expect(stubs.startContainer.callCount).to.equal(2)
// A pass 7 minutes after restart #2, far outside the 150s probation, then a
// fresh failing run: the container recovered and relapsed between passes.
@@ -154,6 +157,6 @@ describe('AutohealService', () => {
stubs.getStatusFromContainer.resolves(unhealthyRingBuffer(NOW + 25 * 60000))
const resumed = await service.runAutoheal({ now: NOW + 25 * 60000 })
expect(resumed.restarted, 'the new episode must start at the base cooldown').to.have.length(1)
- expect(stubs.restartContainer.callCount).to.equal(3)
+ expect(stubs.startContainer.callCount).to.equal(3)
})
})
diff --git a/test/unit/autoheal_service.test/probation/09_pass_inside_probation.test.js b/test/unit/autoheal_service.test/probation/09_pass_inside_probation.test.js
index 9cc047c3..1a2f1878 100644
--- a/test/unit/autoheal_service.test/probation/09_pass_inside_probation.test.js
+++ b/test/unit/autoheal_service.test/probation/09_pass_inside_probation.test.js
@@ -81,7 +81,8 @@ function makeStubs() {
return {
db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() },
getStatusFromContainer: sinon.stub(),
- restartContainer: sinon.stub().resolves(true)
+ stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }),
+ startContainer: sinon.stub().resolves(true)
}
}
@@ -90,7 +91,9 @@ function loadService(stubs) {
'../state': { db: stubs.db },
'./docker_service': {
getStatusFromContainer: stubs.getStatusFromContainer,
- restartContainer: stubs.restartContainer
+ stopContainer: stubs.stopContainer,
+ startContainer: stubs.startContainer,
+ getContainerStopSettings: sinon.stub().resolves(null)
},
// Real descriptor table: asserts the actual opt-in flags too.
'./module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK }
@@ -130,7 +133,7 @@ describe('AutohealService', () => {
await service.runAutoheal({ now: NOW }) // restart #1
await service.runAutoheal({ now: NOW + 11 * 60000 }) // restart #2 -> next wait doubles
- expect(stubs.restartContainer.callCount).to.equal(2)
+ expect(stubs.startContainer.callCount).to.equal(2)
// The pass landed 60s after restart #2, inside the 150s probation window.
const t3 = NOW + 22 * 60000
@@ -149,7 +152,7 @@ describe('AutohealService', () => {
stubs.getStatusFromContainer.resolves(unhealthyRingBuffer(NOW + 25 * 60000))
const throttled = await service.runAutoheal({ now: NOW + 25 * 60000 })
- expect(stubs.restartContainer.callCount).to.equal(2)
+ expect(stubs.startContainer.callCount).to.equal(2)
expect(throttled.skipped[0].reason).to.equal('inside restart cooldown')
})
})
diff --git a/test/unit/bootstrap_archive_meta.test.js b/test/unit/bootstrap_archive_meta.test.js
index 703de388..61349513 100644
--- a/test/unit/bootstrap_archive_meta.test.js
+++ b/test/unit/bootstrap_archive_meta.test.js
@@ -266,3 +266,36 @@ describe('BootstrapArchiveMeta', function () {
})
})
})
+
+// The tracker's restorebootstrap reads the same bootstrap.json trimmed and lowercased; node must
+// reach the same verdict on it, or one archive restores on one path and is refused on the other.
+describe('BootstrapArchiveMeta identity normalization', function () {
+ describe('compareArchiveIdentity() normalization shared with the tracker', function () {
+ const target = { module: 'xchain-utxo-tracker', coin: 'bitcoin', network: 'mainnet' }
+ const full = { format: 1, module: 'xchain-utxo-tracker', coin: 'bitcoin', network: 'mainnet', height: 1, created: null }
+
+ it('matches an identity that differs only in case or padding', function () {
+ const result = meta.compareArchiveIdentity({ ...full, coin: 'Bitcoin', network: ' MAINNET ' }, target)
+ expect(result).to.deep.equal({ status: 'match', mismatches: [], unchecked: [] })
+ })
+
+ it('reads a blank identity field as undeclared, not as a mismatch', function () {
+ for (const blank of ['', ' ']) {
+ const result = meta.compareArchiveIdentity({ ...full, coin: blank }, target)
+ expect(result).to.deep.equal({ status: 'unchecked', mismatches: [], unchecked: ['coin'] })
+ }
+ })
+
+ it('still refuses a different coin whatever its case', function () {
+ const result = meta.compareArchiveIdentity({ ...full, coin: 'DogeCoin' }, target)
+ expect(result.status).to.equal('mismatch')
+ expect(result.mismatches).to.deep.equal([{ field: 'coin', archive: 'DogeCoin', target: 'bitcoin' }])
+ })
+
+ it('keeps a target with no value for a declared field a mismatch', function () {
+ const result = meta.compareArchiveIdentity(full, { ...target, coin: undefined })
+ expect(result.status).to.equal('mismatch')
+ expect(result.mismatches.map(m => m.field)).to.deep.equal(['coin'])
+ })
+ })
+})
diff --git a/test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js b/test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js
index bf1a3c31..e1144763 100644
--- a/test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js
+++ b/test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js
@@ -99,6 +99,33 @@ describe('BootstrapService', function () {
expect(probeLog.map(p => p.restarted)).to.deep.equal([false, true])
expect(recordedHeight(stubs)).to.equal(104)
})
+ })
+})
+
+describe('BootstrapService', function () {
+ beforeEach(saveRequireSignedBootstrapSetting)
+ afterEach(restoreRequireSignedBootstrapSetting)
+ describe('makeBootstrapUtxoTracker(): archive height comes from the archived store', function () {
+ it('records the frozen store height, read after the compress and before the fallback restart', async function () {
+ const { stubs, probeLog } = trackerStubsWithProbe([100, 104], { snapshotFails: true })
+ const base = stubs.execFile
+ let startsAtFrozenRead = null
+ stubs.execFile = sinon.stub().callsFake((cmd, args) => {
+ if (Array.isArray(args) && args[args.length - 1] === '/data/committed_height') {
+ startsAtFrozenRead = stubs.dockerService.startContainer.callCount
+ return Promise.resolve({ stdout: '103\n' })
+ }
+ return base(cmd, args)
+ })
+ makeAutoSpawn(stubs)
+
+ const bs = loadBootstrapService(stubs)
+ expect(await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER)).to.be.true
+
+ expect(startsAtFrozenRead).to.equal(0)
+ expect(probeLog.length).to.be.greaterThan(0)
+ expect(recordedHeight(stubs)).to.equal(103)
+ })
it('keeps the pre-stop height when a rollback leaves the post-restart reading lower', async function () {
const { stubs } = trackerStubsWithProbe([100, 98])
diff --git a/test/unit/cli/commands_configured_database_name.test.js b/test/unit/cli/commands_configured_database_name.test.js
new file mode 100644
index 00000000..859a24f2
--- /dev/null
+++ b/test/unit/cli/commands_configured_database_name.test.js
@@ -0,0 +1,44 @@
+'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
+//
+// Pins that the CLI command layer resolves the indexer database through
+// configuredDatabaseName, so an operator-configured INDEXER_DB_NAME is honoured.
+
+const sinon = require('sinon')
+const { expect } = require('chai')
+const proxyquire = require('proxyquire').noCallThru().noPreserveCache()
+
+const realConfigService = require('../../../src/services/config_service')
+
+function load(moduleConfig) {
+ const getDefaultConfig = sinon.stub().resolves(moduleConfig)
+ const getModuleDatabaseName = sinon.stub().returns('derived_default_db')
+ const commands = proxyquire('../../../src/cli/commands', {
+ '../services/config_service': Object.assign({}, realConfigService, { getDefaultConfig, getModuleDatabaseName })
+ })
+ return { commands, getDefaultConfig, getModuleDatabaseName }
+}
+
+describe('cli commands configured database name', () => {
+ it('uses the operator-configured INDEXER_DB_NAME', async () => {
+ const { commands, getDefaultConfig } = load({ INDEXER_DB_NAME: 'custom_indexer_db' })
+ expect(await commands.rehearsalSourceDatabaseName('bitcoin', 'mainnet')).to.equal('custom_indexer_db')
+ expect(getDefaultConfig.calledOnce).to.equal(true)
+ })
+
+ it('falls back to the derived name when none is configured', async () => {
+ const { commands } = load({ INDEXER_DB_NAME: ' ' })
+ expect(await commands.rehearsalSourceDatabaseName('bitcoin', 'mainnet')).to.equal('derived_default_db')
+ })
+
+ it('rejects an unsafe configured name', async () => {
+ const { commands } = load({ INDEXER_DB_NAME: 'bad name; drop' })
+ let error = null
+ try { await commands.rehearsalSourceDatabaseName('bitcoin', 'mainnet') } catch (err) { error = err }
+ expect(error).to.be.an('error')
+ })
+})
diff --git a/test/unit/cli_error_handling.test.js b/test/unit/cli_error_handling.test.js
index 66200af1..832ed91b 100644
--- a/test/unit/cli_error_handling.test.js
+++ b/test/unit/cli_error_handling.test.js
@@ -254,3 +254,35 @@ describe('CLI unhandled-rejection backstop', function () {
expect(exitStub.calledWith(1)).to.be.true
})
})
+
+// A runbook that stops a service before a backup reads the exit status, so a
+// start, stop or restart that failed must not leave the shell with success.
+describe('CLI `start`/`stop`/`restart` exit code', function () {
+
+ let exitStub, errorStub
+
+ beforeEach(function () {
+ exitStub = sinon.stub(process, 'exit')
+ errorStub = sinon.stub(console, 'error')
+ })
+
+ afterEach(function () { sinon.restore() })
+
+ for (const [verb, op] of [['start', 'startModules'], ['stop', 'stopModules'], ['restart', 'restartModules']]) {
+ it(`exits NON-ZERO and names the module when ${verb} fails`, async function () {
+ const failure = new Error(`${verb} failed for 1 module: xchain-indexer (bitcoin regtest): docker said no`)
+ const program = loadCli({ [op]: sinon.stub().rejects(failure) })
+ await program.parseAsync([verb, 'xchain-indexer', 'bitcoin', 'regtest'], { from: 'user' })
+ expect(errorStub.calledWithMatch(new RegExp(`${verb} failed: ${verb} failed for 1 module: xchain-indexer`))).to.be.true
+ expect(exitStub.calledWith(1)).to.be.true
+ expect(exitStub.calledWith(0)).to.be.false
+ })
+
+ it(`exits ZERO when ${verb} succeeds`, async function () {
+ const program = loadCli({ [op]: sinon.stub().resolves(true) })
+ await program.parseAsync([verb, 'xchain-indexer', 'bitcoin', 'regtest'], { from: 'user' })
+ expect(exitStub.calledWith(0)).to.be.true
+ expect(exitStub.calledWith(1)).to.be.false
+ })
+ }
+})
diff --git a/test/unit/cli_ref_for_pre_check.test.js b/test/unit/cli_ref_for_pre_check.test.js
index 836f977b..21ad9095 100644
--- a/test/unit/cli_ref_for_pre_check.test.js
+++ b/test/unit/cli_ref_for_pre_check.test.js
@@ -75,6 +75,49 @@ describe('cli refForPreCheck()', function () {
})
})
+// A no-ref `update` that the CLI did not move hands its resolved tag on through
+// XCHAIN_NODE_UPDATE_TARGET; the hub preCheck stages must come from that tag too,
+// not from a second releases/latest lookup that can answer differently.
+describe('cli refForPreCheck() with a self-update tag handed on', function () {
+ let saved
+
+ beforeEach(function () {
+ saved = process.env.XCHAIN_NODE_UPDATE_TARGET
+ process.env.XCHAIN_NODE_UPDATE_TARGET = 'v0.11.0'
+ })
+
+ afterEach(function () {
+ if (saved === undefined) delete process.env.XCHAIN_NODE_UPDATE_TARGET
+ else process.env.XCHAIN_NODE_UPDATE_TARGET = saved
+ })
+
+ it('stages a no-ref update from the handed-on tag', function () {
+ expect(refForPreCheck('update', cmd([]))).to.equal('v0.11.0')
+ expect(refForPreCheck('update', cmd(undefined))).to.equal('v0.11.0')
+ expect(refForPreCheck('update', cmd(['xchain-indexer']))).to.equal('v0.11.0')
+ })
+
+ it('keeps a ref named on the update command over the handed-on tag', function () {
+ expect(refForPreCheck('update', cmd(['develop', 'xchain-indexer']))).to.equal('develop')
+ expect(refForPreCheck('update', cmd(['v0.9.0']))).to.equal('v0.9.0')
+ })
+
+ it('never lets the handed-on tag pin an install or another command', function () {
+ expect(refForPreCheck('install', cmd([]))).to.equal(null)
+ expect(refForPreCheck('install', cmd(['all', 'bitcoin', 'regtest']))).to.equal(null)
+ expect(refForPreCheck('ps', cmd([]))).to.equal(null)
+ })
+
+ it('still yields null for a refused argument shape', function () {
+ expect(refForPreCheck('update', cmd(['xchain-node']))).to.equal(null)
+ })
+
+ it('yields null for a no-ref update when no tag was handed on', function () {
+ delete process.env.XCHAIN_NODE_UPDATE_TARGET
+ expect(refForPreCheck('update', cmd([]))).to.equal(null)
+ })
+})
+
// commandRepairsHub decides which commands survive a hub that is not answering.
//
// preCheck pushes local config to the hub before every state-changing command.
diff --git a/test/unit/command_lock.test.js b/test/unit/command_lock.test.js
index 9638091c..831a99f2 100644
--- a/test/unit/command_lock.test.js
+++ b/test/unit/command_lock.test.js
@@ -105,6 +105,35 @@ describe('commandLock', () => {
})
})
+describe('commandLock unreadable files', () => {
+ beforeEach(prepareCommandLock)
+ afterEach(cleanCommandLock)
+
+ it('treats an EACCES lock read as held and preserves the lock', () => {
+ const lockFile = getLockFilePath()
+ fs.writeFileSync(lockFile, JSON.stringify({ pid: findDeadPid(), command: 'install' }))
+ const before = fs.statSync(lockFile).ino
+ const realReadFile = fs.readFileSync
+ fs.readFileSync = function (file, ...args) {
+ if (file === lockFile) {
+ const err = new Error('permission denied')
+ err.code = 'EACCES'
+ throw err
+ }
+ return realReadFile.call(fs, file, ...args)
+ }
+ try {
+ assert.throws(
+ () => acquireCommandLock({ command: 'reset' }),
+ err => err.code === 'ELOCKHELD' && /cannot be read/.test(err.message)
+ )
+ } finally {
+ fs.readFileSync = realReadFile
+ }
+ assert.strictEqual(fs.statSync(lockFile).ino, before)
+ })
+})
+
describe('commandLock handoff', () => {
beforeEach(prepareCommandLock)
afterEach(cleanCommandLock)
@@ -258,6 +287,29 @@ describe('commandLock', () => {
release()
})
+ it('treats an EPERM reclaim marker read as held and preserves the marker', () => {
+ const lockFile = getLockFilePath()
+ const reclaimFile = lockFile + '.reclaim'
+ fs.writeFileSync(lockFile, JSON.stringify({ pid: findDeadPid(), command: 'install' }) + '\n')
+ fs.writeFileSync(reclaimFile, JSON.stringify({ pid: findDeadPid(), nonce: 'unknown' }) + '\n')
+ const realReadFile = fs.readFileSync
+ fs.readFileSync = function (file, ...args) {
+ if (file === reclaimFile) {
+ const err = new Error('operation not permitted')
+ err.code = 'EPERM'
+ throw err
+ }
+ return realReadFile.call(fs, file, ...args)
+ }
+ try {
+ assert.throws(() => acquireCommandLock({ command: 'update' }), /lost the race/)
+ } finally {
+ fs.readFileSync = realReadFile
+ }
+ assert.ok(fs.existsSync(lockFile))
+ assert.ok(fs.existsSync(reclaimFile))
+ })
+
it('release keeps a successor lock that reuses our pid under a different nonce', () => {
const lockFile = getLockFilePath()
const release = acquireCommandLock({ command: 'install' })
diff --git a/test/unit/config/regtest_gate_switch_passthrough.test.js b/test/unit/config/regtest_gate_switch_passthrough.test.js
new file mode 100644
index 00000000..68684042
--- /dev/null
+++ b/test/unit/config/regtest_gate_switch_passthrough.test.js
@@ -0,0 +1,43 @@
+'use strict'
+
+// The hub is handed the offer-list arm; a regtest indexer is handed the
+// exact-integer wire-field arm; shared-ledger indexers never see either.
+
+const { expect, makeServiceWithConfig } = require('../config_service.test/helpers.test')
+
+const OFFER = 'XC_OFFER_LIST_REGTEST_ACTIVATION'
+const WIRE = 'EXACT_INTEGER_WIRE_FIELDS_REGTEST_TIME'
+const VALUES = { [OFFER]: '20', [WIRE]: '1700000000' }
+
+describe('regtest gate switch passthrough', function () {
+ let saved
+
+ beforeEach(function () {
+ saved = {}
+ for (const name of Object.keys(VALUES)) { saved[name] = process.env[name]; process.env[name] = VALUES[name] }
+ })
+
+ afterEach(function () {
+ for (const name of Object.keys(VALUES)) {
+ if (saved[name] === undefined) delete process.env[name]; else process.env[name] = saved[name]
+ }
+ })
+
+ it('hands the hub the offer-list arm', async function () {
+ const hub = await makeServiceWithConfig('').getDefaultConfig('xchain-hub', null, null)
+ expect(hub[OFFER]).to.equal(VALUES[OFFER])
+ })
+
+ it('hands a regtest indexer the wire-field arm', async function () {
+ const indexer = await makeServiceWithConfig('').getDefaultConfig('xchain-indexer', 'dogecoin', 'regtest')
+ expect(indexer[WIRE]).to.equal(VALUES[WIRE])
+ })
+
+ it('never hands a shared-ledger indexer the wire-field arm', async function () {
+ const cs = makeServiceWithConfig('')
+ for (const net of ['mainnet', 'testnet']) {
+ const config = await cs.getDefaultConfig('xchain-indexer', 'dogecoin', net)
+ expect(config, net).to.not.have.property(WIRE)
+ }
+ })
+})
diff --git a/test/unit/config_service.test/arguments_module.test.js b/test/unit/config_service.test/arguments_module.test.js
new file mode 100644
index 00000000..7f1e44e6
--- /dev/null
+++ b/test/unit/config_service.test/arguments_module.test.js
@@ -0,0 +1,72 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// GENERATED
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+const { expect } = require('chai')
+const argumentsModule = require('../../../src/services/config_service/arguments')
+
+const dependencies = {
+ Coin: { BITCOIN: 'bitcoin', LITECOIN: 'litecoin' },
+ Network: { MAINNET: 'mainnet', REGTEST: 'regtest' },
+ XChainService: { XCHAIN_INDEXER: 'xchain-indexer', XCHAIN_ENCODER: 'xchain-encoder' },
+ NODE_MODULE_NAME: 'node',
+ DB_MODULE_NAME: 'database',
+ HUB_MODULE_NAME: 'xchain-hub',
+ EXPLORER_MODULE_NAME: 'xchain-explorer',
+ SYNC_MODULE_NAME: 'xchain-sync',
+ releaseManifestService: { isReleaseRef: ref => /^v\d/.test(ref || '') },
+ serviceAliases: () => ({ hub: 'xchain-hub', indexer: 'xchain-indexer' })
+}
+
+describe('config_service/arguments', function () {
+ beforeEach(function () {
+ argumentsModule.configure(dependencies)
+ })
+
+ after(function () {
+ delete require.cache[require.resolve('../../../src/services/config_service')]
+ require('../../../src/services/config_service')
+ })
+
+ it('returns all selectors when no arguments are supplied', function () {
+ expect(argumentsModule.resolveArgs([])).to.deep.equal({
+ service: 'all', chain: 'all', network: 'all', branch: null, isRelease: false
+ })
+ })
+
+ it('classifies service aliases, coins, networks, and a release ref', function () {
+ expect(argumentsModule.resolveArgs(['hub', 'bitcoin', 'regtest', 'v1.2.3'], { expectBranch: true }))
+ .to.deep.equal({
+ service: 'xchain-hub', chain: 'bitcoin', network: 'regtest',
+ branch: 'v1.2.3', isRelease: true
+ })
+ })
+
+ it('uses the requested default branch when a ref is expected', function () {
+ expect(argumentsModule.resolveArgs(['xchain-indexer'], {
+ expectBranch: true,
+ defaultBranch: 'develop'
+ })).to.include({ branch: 'develop', isRelease: false })
+ })
+
+ it('rejects the CLI package name instead of expanding it to all services', function () {
+ expect(() => argumentsModule.resolveArgs(['xchain-node']))
+ .to.throw("'xchain-node' is the CLI itself")
+ })
+
+ it('rejects unknown selectors and unsafe branch names', function () {
+ expect(() => argumentsModule.resolveArgs(['mystery']))
+ .to.throw("Unrecognized argument 'mystery'")
+ expect(() => argumentsModule.resolveArgs(['feature;rm'], { expectBranch: true }))
+ .to.throw('Invalid branch name')
+ })
+})
diff --git a/test/unit/config_service.test/coins_module.test.js b/test/unit/config_service.test/coins_module.test.js
new file mode 100644
index 00000000..6e9806ba
--- /dev/null
+++ b/test/unit/config_service.test/coins_module.test.js
@@ -0,0 +1,114 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// GENERATED
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+const { expect } = require('chai')
+const coinsModule = require('../../../src/services/config_service/coins')
+
+const XChainService = {
+ XCHAIN_ENCODER: 'xchain-encoder',
+ XCHAIN_DECODER: 'xchain-decoder',
+ XCHAIN_UTXO_TRACKER: 'xchain-utxo-tracker',
+ XCHAIN_REGTEST_MINER: 'xchain-regtest-miner'
+}
+
+function configure(config = {}) {
+ coinsModule.configure({
+ config: {
+ CORS_ORIGIN: undefined,
+ ENCODER_TRUST_PROXY: undefined,
+ ENCODER_RATE_LIMIT_RPM: undefined,
+ FEE_DESTINATION_ENV: {},
+ FEE_DESTINATION: undefined,
+ LEVELDB_CACHE_BYTES: undefined,
+ LEVELDB_WRITE_BUFFER_BYTES: undefined,
+ ...config
+ },
+ CoinTickerSymbol: { bitcoin: 'BTC' },
+ Network: { MAINNET: 'mainnet', TESTNET: 'testnet', REGTEST: 'regtest' },
+ XChainService,
+ getCoinConfigByFullName: () => ({ addresses: { FEE_DESTINATION: 'registry-fee-address' } }),
+ getDockerContainerImageName: (module, coin, network) => `${module}-${coin}-${network}`
+ })
+}
+
+describe('config_service/coins fee defaults', function () {
+ it('adds regtest miner, rate-limit, CORS, and canonical fee defaults', function () {
+ configure()
+ const defaults = {}
+
+ coinsModule.applyCoinDefaults(defaults, XChainService.XCHAIN_ENCODER, 'bitcoin', 'regtest')
+
+ expect(defaults).to.include({
+ REGTEST_MINER_URL: 'xchain-regtest-miner-bitcoin-regtest',
+ REGTEST_MINER_API_PORT: 3005,
+ ENCODER_RATE_LIMIT_RPM: 99999,
+ CORS_ORIGIN: '*',
+ FEE_DESTINATION: 'registry-fee-address',
+ XCHAIN_FEE_DESTINATION_BTC_REGTEST: 'registry-fee-address',
+ NETWORK: 'bitcoin-regtest'
+ })
+ })
+
+ it('lets explicit encoder settings override the regtest defaults', function () {
+ configure({ CORS_ORIGIN: 'https://wallet.test', ENCODER_RATE_LIMIT_RPM: '1200', ENCODER_TRUST_PROXY: '10.0.0.1' })
+ const defaults = {}
+
+ coinsModule.applyCoinDefaults(defaults, XChainService.XCHAIN_ENCODER, 'bitcoin', 'regtest')
+
+ expect(defaults).to.include({
+ CORS_ORIGIN: 'https://wallet.test',
+ ENCODER_RATE_LIMIT_RPM: '1200',
+ ENCODER_TRUST_PROXY: '10.0.0.1'
+ })
+ })
+
+ it('uses the generic fee destination only on regtest without a per-coin override', function () {
+ configure({ FEE_DESTINATION: 'generic-regtest-fee' })
+ const defaults = {}
+
+ coinsModule.applyCoinDefaults(defaults, XChainService.XCHAIN_DECODER, 'bitcoin', 'regtest')
+
+ expect(defaults.FEE_DESTINATION).to.equal('generic-regtest-fee')
+ expect(defaults.XCHAIN_FEE_DESTINATION_BTC_REGTEST).to.equal('generic-regtest-fee')
+ })
+
+ it('keeps the registry fee destination outside regtest', function () {
+ configure({ FEE_DESTINATION: 'generic-fee' })
+ const defaults = {}
+
+ coinsModule.applyCoinDefaults(defaults, XChainService.XCHAIN_DECODER, 'bitcoin', 'mainnet')
+
+ expect(defaults.FEE_DESTINATION).to.equal('registry-fee-address')
+ expect(defaults.XCHAIN_FEE_DESTINATION_BTC_MAINNET).to.equal('registry-fee-address')
+ })
+})
+
+describe('config_service/coins module tuning', function () {
+ it('passes non-empty LevelDB tuning only to the UTXO tracker', function () {
+ configure({ LEVELDB_CACHE_BYTES: '1024', LEVELDB_WRITE_BUFFER_BYTES: '' })
+ const tracker = {}
+ const encoder = {}
+
+ coinsModule.applyCoinDefaults(tracker, XChainService.XCHAIN_UTXO_TRACKER, 'bitcoin', 'mainnet')
+ coinsModule.applyCoinDefaults(encoder, XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet')
+
+ expect(tracker.LEVELDB_CACHE_BYTES).to.equal('1024')
+ expect(tracker).not.to.have.property('LEVELDB_WRITE_BUFFER_BYTES')
+ expect(encoder).not.to.have.property('LEVELDB_CACHE_BYTES')
+ })
+})
+
+after(function () {
+ delete require.cache[require.resolve('../../../src/services/config_service')]
+ require('../../../src/services/config_service')
+})
diff --git a/test/unit/config_service.test/database_module.test.js b/test/unit/config_service.test/database_module.test.js
new file mode 100644
index 00000000..3ff6a200
--- /dev/null
+++ b/test/unit/config_service.test/database_module.test.js
@@ -0,0 +1,100 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// GENERATED
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+const { expect } = require('chai')
+const sinon = require('sinon')
+const databaseModule = require('../../../src/services/config_service/database')
+
+const INDEXER = 'xchain-indexer'
+
+function configure({ persistedHubPass, generated = 'a'.repeat(48) } = {}) {
+ const sidecars = {
+ hubSidecarPath: sinon.stub().returns('/config/hub.local'),
+ readSidecarValue: sinon.stub().resolves(persistedHubPass),
+ upsertSidecarValues: sinon.stub()
+ }
+ const crypto = {
+ randomBytes: sinon.stub().returns({ toString: sinon.stub().withArgs('hex').returns(generated) })
+ }
+ databaseModule.configure({ crypto, XChainService: { XCHAIN_INDEXER: INDEXER }, sidecars })
+ return { crypto, sidecars }
+}
+
+describe('config_service/database credential generation', function () {
+ it('reuses a persisted hub password without generating a replacement', async function () {
+ const { crypto, sidecars } = configure({ persistedHubPass: 'persisted-pass' })
+
+ expect(await databaseModule.getOrCreateHubDbPass()).to.equal('persisted-pass')
+ expect(crypto.randomBytes.called).to.equal(false)
+ expect(sidecars.upsertSidecarValues.called).to.equal(false)
+ })
+
+ it('generates and persists a missing hub password', async function () {
+ const { sidecars } = configure({ generated: 'b'.repeat(48) })
+
+ expect(await databaseModule.getOrCreateHubDbPass()).to.equal('b'.repeat(48))
+ expect(sidecars.upsertSidecarValues.calledOnceWithExactly(
+ '/config/hub.local', { HUB_DB_PASS: 'b'.repeat(48) }
+ )).to.equal(true)
+ })
+
+ it('generates only missing module credentials and persists them together', function () {
+ const { sidecars } = configure()
+ const config = { DECODER_DB_PASS: 'operator-pass' }
+
+ databaseModule.generateDatabaseCredentials(config, '/config/bitcoin-mainnet.local')
+
+ expect(config).to.deep.equal({ DECODER_DB_PASS: 'operator-pass', INDEXER_DB_PASS: 'a'.repeat(48) })
+ expect(sidecars.upsertSidecarValues.calledOnceWithExactly(
+ '/config/bitcoin-mainnet.local', { INDEXER_DB_PASS: 'a'.repeat(48) }
+ )).to.equal(true)
+ })
+})
+
+describe('config_service/database config shaping', function () {
+ it('uses the indexer password for hub access and fails if it is absent', function () {
+ configure()
+ const config = { INDEXER_DB_PASS: 'indexer-pass' }
+
+ databaseModule.setIndexerHubPassword(config, {}, INDEXER)
+ expect(config.HUB_DB_PASS).to.equal('indexer-pass')
+ expect(() => databaseModule.setIndexerHubPassword({}, {}, INDEXER))
+ .to.throw('INDEXER_DB_PASS was not generated')
+ })
+
+ it('merges defaults while suppressing a single hub URL when seeds are configured', function () {
+ configure()
+ const config = { HUB_SEED_URLS: 'https://one.test,https://two.test' }
+
+ databaseModule.mergeDefaults(config, { HUB_API_URL: 'https://managed.test', HUB_PORT: 10000 })
+
+ expect(config).to.deep.equal({
+ HUB_SEED_URLS: 'https://one.test,https://two.test',
+ HUB_PORT: 10000
+ })
+ })
+
+ it('rewrites only database host and port keys already present', function () {
+ configure()
+ const config = { DECODER_DB_HOST: 'mariadb', DATABASE_PORT: 3306, OTHER: 'keep' }
+
+ databaseModule.applyExternalDatabase(config, { host: '172.18.0.1', port: 3307 })
+
+ expect(config).to.deep.equal({ DECODER_DB_HOST: '172.18.0.1', DATABASE_PORT: 3307, OTHER: 'keep' })
+ })
+})
+
+after(function () {
+ delete require.cache[require.resolve('../../../src/services/config_service')]
+ require('../../../src/services/config_service')
+})
diff --git a/test/unit/config_service.test/defaults_module.test.js b/test/unit/config_service.test/defaults_module.test.js
new file mode 100644
index 00000000..ca8120b5
--- /dev/null
+++ b/test/unit/config_service.test/defaults_module.test.js
@@ -0,0 +1,144 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// GENERATED
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+const { expect } = require('chai')
+const defaultsModule = require('../../../src/services/config_service/defaults')
+
+const modules = {
+ node: 'node',
+ hub: 'xchain-hub',
+ explorer: 'xchain-explorer',
+ sync: 'xchain-sync',
+ decoder: 'xchain-decoder',
+ encoder: 'xchain-encoder',
+ indexer: 'xchain-indexer',
+ tracker: 'xchain-utxo-tracker'
+}
+
+function configure(config = {}) {
+ defaultsModule.configure({
+ config: {
+ HUB_PORT_OVERRIDE: undefined,
+ EXPLORER_PORT_ENV: {},
+ ALLOW_NO_COLOCATED_HUB_DB: undefined,
+ HUB_API_KEY: undefined,
+ CORS_ORIGIN: undefined,
+ EXPLORER_CHECKPOINT_SELF_SYNC: undefined,
+ SYNC_PASSTHROUGH_ENV: {},
+ HUB_PUBLIC_API_URL: undefined,
+ ...config
+ },
+ Network: { MAINNET: 'mainnet', TESTNET: 'testnet', REGTEST: 'regtest' },
+ Coin: { BITCOIN: 'bitcoin' },
+ CoinTickerSymbol: { bitcoin: 'BTC' },
+ XChainService: {
+ XCHAIN_UTXO_TRACKER: modules.tracker,
+ XCHAIN_DECODER: modules.decoder,
+ XCHAIN_ENCODER: modules.encoder,
+ XCHAIN_INDEXER: modules.indexer
+ },
+ DB_SEP: '_',
+ bootstrapDir: '/bootstrap',
+ NODE_MODULE_NAME: modules.node,
+ HUB_MODULE_NAME: modules.hub,
+ EXPLORER_MODULE_NAME: modules.explorer,
+ SYNC_MODULE_NAME: modules.sync,
+ getDockerContainerImageName: (module, coin, network) =>
+ ['xchain-node', coin, network, module].filter(Boolean).join('-'),
+ getModuleDatabaseName: (module, coin, network) => `${module}:${coin}:${network}`
+ })
+}
+
+describe('config_service/defaults constructors', function () {
+ it('creates coin defaults with service addresses, ports, and database identities', function () {
+ configure()
+
+ const defaults = defaultsModule.createCoinDefaults(modules.encoder, 'bitcoin', 'testnet')
+
+ expect(defaults).to.include({
+ NETWORK: 'testnet',
+ NODE_URL: 'xchain-node-bitcoin-testnet-node',
+ NODE_PORT: 18332,
+ DECODER_DB_NAME: 'xchain-decoder:bitcoin:testnet',
+ INDEXER_DB_NAME: 'xchain-indexer:bitcoin:testnet',
+ HUB_API_URL: 'http://xchain-node-xchain-hub:10000',
+ EXPLORER_URL: 'xchain-node-xchain-explorer'
+ })
+ expect(defaults.DECODER_BOOTSTRAP_VOLUME)
+ .to.equal('/bootstrap/bitcoin/testnet/xchain-encoder/bootstrap/')
+ })
+
+ it('selects the correct node RPC port for every network', function () {
+ configure()
+
+ expect(defaultsModule.createCoinDefaults(modules.encoder, 'bitcoin', 'mainnet').NODE_PORT).to.equal(8332)
+ expect(defaultsModule.createCoinDefaults(modules.encoder, 'bitcoin', 'testnet').NODE_PORT).to.equal(18332)
+ expect(defaultsModule.createCoinDefaults(modules.encoder, 'bitcoin', 'regtest').NODE_PORT).to.equal(18444)
+ })
+
+ it('creates shared defaults with internal service names and published ports', function () {
+ configure()
+
+ expect(defaultsModule.createSharedDefaults()).to.include({
+ HUB_API_HOST: 'xchain-node-xchain-hub',
+ HUB_PORT: 10000,
+ EXPLORER_API_HOST: 'xchain-node-xchain-explorer',
+ EXPLORER_PORT_HTTP: 18080,
+ SYNC_API_HOST: 'xchain-node-xchain-sync',
+ SYNC_API_PORT: 3006
+ })
+ })
+})
+
+describe('config_service/defaults shared overrides', function () {
+ it('applies non-empty shared host overrides before hub-key resolution', function () {
+ configure({
+ HUB_PORT_OVERRIDE: '11000',
+ EXPLORER_PORT_ENV: { EXPLORER_PORT_HTTP: '19080', EXPLORER_PORT_HTTPS: '', EXPLORER_PORT: undefined },
+ ALLOW_NO_COLOCATED_HUB_DB: '1',
+ HUB_API_KEY: 'fixture-key'
+ })
+ const defaults = defaultsModule.createSharedDefaults()
+
+ defaultsModule.configureSharedBeforeHubKey(defaults)
+
+ expect(defaults).to.include({
+ HUB_PORT: '11000',
+ EXPLORER_PORT_HTTP: '19080',
+ EXPLORER_PORT_HTTPS: 18081,
+ ALLOW_NO_COLOCATED_HUB_DB: '1',
+ HUB_API_KEY: 'fixture-key'
+ })
+ })
+
+ it('applies shared CORS and module-specific passthroughs after hub-key resolution', function () {
+ configure({
+ CORS_ORIGIN: 'https://wallet.test',
+ SYNC_PASSTHROUGH_ENV: { INDEX_MAP_PARITY_CHECK: 'strict' },
+ HUB_PUBLIC_API_URL: 'https://hub.test'
+ })
+ const syncDefaults = {}
+ const hubDefaults = {}
+
+ defaultsModule.configureSharedAfterHubKey(syncDefaults, modules.sync)
+ defaultsModule.configureSharedAfterHubKey(hubDefaults, modules.hub)
+
+ expect(syncDefaults).to.deep.equal({ CORS_ORIGIN: 'https://wallet.test', INDEX_MAP_PARITY_CHECK: 'strict' })
+ expect(hubDefaults).to.deep.equal({ CORS_ORIGIN: 'https://wallet.test', HUB_PUBLIC_API_URL: 'https://hub.test' })
+ })
+})
+
+after(function () {
+ delete require.cache[require.resolve('../../../src/services/config_service')]
+ require('../../../src/services/config_service')
+})
diff --git a/test/unit/config_service.test/filters_module.test.js b/test/unit/config_service.test/filters_module.test.js
new file mode 100644
index 00000000..3dfebdbc
--- /dev/null
+++ b/test/unit/config_service.test/filters_module.test.js
@@ -0,0 +1,76 @@
+'use strict'
+
+// GENERATED
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+const { expect } = require('chai')
+const filters = require('../../../src/services/config_service/filters')
+
+const Coin = { BITCOIN: 'bitcoin', LITECOIN: 'litecoin' }
+const Network = { MAINNET: 'mainnet', REGTEST: 'regtest' }
+const XChainService = {
+ XCHAIN_INDEXER: 'xchain-indexer',
+ XCHAIN_ENCODER: 'xchain-encoder',
+ XCHAIN_E2E_TEST: 'xchain-e2e-test'
+}
+
+describe('config_service/filters', function () {
+ beforeEach(function () {
+ filters.configure({
+ Coin,
+ Network,
+ XChainService,
+ REGTEST_MODULES: ['regtest-miner'],
+ NODE_MODULE_NAME: 'node',
+ DB_MODULE_NAME: 'database',
+ HUB_MODULE_NAME: 'xchain-hub',
+ EXPLORER_MODULE_NAME: 'xchain-explorer',
+ SYNC_MODULE_NAME: 'xchain-sync'
+ })
+ })
+
+ it('returns aliases for shared services', function () {
+ expect(filters.serviceAliases()).to.deep.equal({
+ hub: 'xchain-hub',
+ sync: 'xchain-sync',
+ db: 'database'
+ })
+ })
+
+ it('resolves a shared-service alias under the empty coin and network key', function () {
+ expect(filters.filterCommandParameters('unused', 'hub', 'bitcoin', 'mainnet')).to.deep.equal({
+ '': { '': ['xchain-hub'] }
+ })
+ })
+
+ it('expands all with the node first, omits e2e-test, and adds the explorer once', function () {
+ const result = filters.filterCommandParameters('unused', 'all', 'bitcoin', 'mainnet')
+ expect(result).to.deep.equal({
+ '': { '': ['xchain-explorer'] },
+ bitcoin: { mainnet: ['node', 'xchain-indexer', 'xchain-encoder'] }
+ })
+ })
+
+ it('limits regtest-only modules to regtest while retaining every requested network', function () {
+ const result = filters.filterCommandParameters('unused', 'regtest-miner', 'bitcoin', 'all')
+ expect(result).to.deep.equal({
+ bitcoin: { mainnet: [], regtest: ['regtest-miner'] }
+ })
+ })
+
+ it('expands an unspecified module across every coin and network', function () {
+ const result = filters.filterCommandParameters('unused', 'custom-service')
+ expect(result).to.deep.equal({
+ bitcoin: { mainnet: ['custom-service'], regtest: ['custom-service'] },
+ litecoin: { mainnet: ['custom-service'], regtest: ['custom-service'] }
+ })
+ })
+})
diff --git a/test/unit/config_service.test/networks_module.test.js b/test/unit/config_service.test/networks_module.test.js
new file mode 100644
index 00000000..b8c16c12
--- /dev/null
+++ b/test/unit/config_service.test/networks_module.test.js
@@ -0,0 +1,145 @@
+'use strict'
+
+// GENERATED
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+const { expect } = require('chai')
+const sinon = require('sinon')
+const networks = require('../../../src/services/config_service/networks')
+
+const Coin = { BITCOIN: 'bitcoin', LITECOIN: 'litecoin', DOGECOIN: 'dogecoin' }
+const Network = { MAINNET: 'mainnet', TESTNET: 'testnet', REGTEST: 'regtest' }
+const XChainService = { XCHAIN_INDEXER: 'xchain-indexer' }
+
+function configure(overrides = {}) {
+ const logger = { warn: sinon.spy(), info: sinon.spy() }
+ const db = { getAllModuleContainers: sinon.stub().resolves([]) }
+ const config = {
+ TELEMETRY_ENABLED: true,
+ TELEMETRY_RETENTION_DAYS: 30,
+ TELEMETRY_IP_SALT: 'salt',
+ TELEMETRY_ADMIN_KEY: 'admin',
+ BTC_INDEXER_API_URL: 'http://bitcoin:3004',
+ childProcessEnv: () => ({}),
+ ...overrides.config
+ }
+ networks.configure({
+ stateModule: { db },
+ Coin,
+ Network,
+ XChainService,
+ logger,
+ config,
+ peers: {
+ validatorService: {
+ getValidatorEnv: () => ({}),
+ validatorModeReport: () => ({ mode: 'absent', dir: '/config/validator' })
+ }
+ },
+ readSecretHostEnv: () => undefined,
+ HUB_MODULE_NAME: 'xchain-hub',
+ getDockerContainerImageName: (module, coin, network) => [module, coin, network].join('-'),
+ ...overrides.dependencies
+ })
+ return { db, logger, config }
+}
+
+function networkResolutionTests() {
+ it('composes co-located LTC and DOGE indexer URLs without replacing unrelated defaults', async function () {
+ configure()
+ const defaults = { KEEP: 'value' }
+ await networks.composeColocatedIndexerUrls(defaults, 'testnet', [
+ { coin: 'litecoin', network: 'testnet', module: 'xchain-indexer' },
+ { coin: 'dogecoin', network: 'mainnet', module: 'xchain-indexer' }
+ ])
+ expect(defaults).to.deep.equal({
+ KEEP: 'value',
+ LTC_INDEXER_URL: 'http://xchain-indexer-litecoin-testnet:3004'
+ })
+ })
+
+ it('derives the single registered deployment network and ignores invalid rows', async function () {
+ const { db } = configure()
+ db.getAllModuleContainers.resolves([
+ { coin: 'bitcoin', network: 'mainnet', module: 'node' },
+ { coin: 'unknown', network: 'mainnet', module: 'node' },
+ null
+ ])
+ expect(await networks.resolveDeploymentHubNetwork()).to.equal('mainnet')
+ })
+
+ it('warns once when registered stacks span multiple networks', async function () {
+ const { db, logger } = configure()
+ db.getAllModuleContainers.resolves([
+ { coin: 'bitcoin', network: 'mainnet', module: 'node' },
+ { coin: 'litecoin', network: 'testnet', module: 'node' }
+ ])
+ expect(await networks.resolveDeploymentHubNetwork()).to.equal(null)
+ expect(await networks.resolveDeploymentHubNetwork()).to.equal(null)
+ expect(logger.warn.calledOnce).to.equal(true)
+ expect(logger.warn.firstCall.args[0]).to.include('mainnet, testnet')
+ })
+
+ it('recognizes a registered bitcoin indexer only on the requested network', async function () {
+ const { db } = configure()
+ db.getAllModuleContainers.resolves([
+ { coin: 'bitcoin', network: 'regtest', module: 'xchain-indexer' }
+ ])
+ expect(await networks.hasBitcoinIndexer('regtest')).to.equal(true)
+ expect(await networks.hasBitcoinIndexer('mainnet')).to.equal(false)
+ })
+}
+
+function hubConfigurationTests() {
+ it('declares a keyless non-mainnet hub and consumes HUB_NETWORK after access checks', function () {
+ const { logger } = configure()
+ const defaults = { HUB_NETWORK: 'REGTEST' }
+ networks.configureHubAccess(defaults)
+ expect(defaults).to.deep.equal({ HUB_ALLOW_UNAUTHENTICATED: 'true' })
+ expect(logger.warn.calledOnce).to.equal(true)
+ })
+
+ it('leaves a keyless mainnet hub fail-closed', function () {
+ const { logger } = configure()
+ const defaults = { HUB_NETWORK: 'mainnet' }
+ networks.configureHubAccess(defaults)
+ expect(defaults).to.deep.equal({})
+ expect(logger.warn.called).to.equal(false)
+ })
+
+ it('injects validator environment and reports validator mode', function () {
+ const logger = { warn: sinon.spy(), info: sinon.spy() }
+ configure({
+ config: { XCHAIN_NODE_HUB_SIGNER_DIR: '/host/signer' },
+ dependencies: {
+ logger,
+ peers: {
+ validatorService: {
+ getValidatorEnv: () => ({ P2P_VALIDATOR_ADDR: '127.0.0.1:9000' }),
+ validatorModeReport: () => ({ mode: 'validator', dir: '/config/validator' })
+ }
+ }
+ }
+ })
+ const defaults = {}
+ networks.configureHubValidator(defaults)
+ expect(defaults).to.deep.equal({
+ HUB_SIGNER_MODULE: '/XChainHub/operator-signer/signer.js',
+ P2P_VALIDATOR_ADDR: '127.0.0.1:9000'
+ })
+ expect(logger.info.calledWithMatch('VALIDATOR mode')).to.equal(true)
+ })
+}
+
+describe('config_service/networks', function () {
+ networkResolutionTests()
+ hubConfigurationTests()
+})
diff --git a/test/unit/config_service.test/services_module.test.js b/test/unit/config_service.test/services_module.test.js
new file mode 100644
index 00000000..308712d5
--- /dev/null
+++ b/test/unit/config_service.test/services_module.test.js
@@ -0,0 +1,142 @@
+'use strict'
+
+// GENERATED
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+const { expect } = require('chai')
+const services = require('../../../src/services/config_service/services')
+
+const Network = { MAINNET: 'mainnet', TESTNET: 'testnet', REGTEST: 'regtest' }
+const XChainService = { XCHAIN_INDEXER: 'xchain-indexer', XCHAIN_E2E_TEST: 'xchain-e2e-test' }
+
+function configure(configOverrides = {}, secretValues = {}, validatorSettings = null) {
+ const config = {
+ INDEXER_GENESIS_ENV: {},
+ INDEXER_ROLLCALL_ENV: {},
+ HUB_SYNC_GRACE_ENV: {},
+ ...configOverrides
+ }
+ services.configure({
+ config,
+ peers: { validatorService: { getValidatorSettings: () => validatorSettings } },
+ logger: { warn() {}, info() {} },
+ readSecretHostEnv: name => secretValues[name],
+ Coin: { BITCOIN: 'bitcoin' },
+ Network,
+ CoinTickerSymbol: { bitcoin: 'BTC' },
+ XChainService,
+ HUB_MODULE_NAME: 'xchain-hub',
+ EXPLORER_MODULE_NAME: 'xchain-explorer',
+ getDockerContainerImageName: (module, coin, network) => [module, coin, network].join('-')
+ })
+}
+
+function e2eServiceTests() {
+ it('configures the e2e container with public validator identity and hub access', function () {
+ configure({ HUB_API_KEY: 'hub-key' }, {}, { pubkey: '02abcdef' })
+ const defaults = {}
+ services.configureE2e(defaults, 'xchain-e2e-test', 'bitcoin')
+ expect(defaults).to.deep.equal({
+ COIN: 'bitcoin',
+ XCHAIN_CONTRACTS_DIR: '/XChainE2ETest/xchain-contracts',
+ VALIDATOR_PUBKEY: '02abcdef',
+ HUB_API_KEY: 'hub-key'
+ })
+ })
+
+ it('does not alter a non-e2e service', function () {
+ configure({ HUB_API_KEY: 'hub-key' }, {}, { pubkey: '02abcdef' })
+ const defaults = { KEEP: 'value' }
+ services.configureE2e(defaults, 'xchain-indexer', 'bitcoin')
+ expect(defaults).to.deep.equal({ KEEP: 'value' })
+ })
+}
+
+function indexerInputTests() {
+ it('passes genesis, origin-indexer, hub, and regtest-only settings to an indexer', function () {
+ configure({
+ INDEXER_GENESIS_ENV: { XCHAIN_GENESIS_BLOCK: '101', GENESIS_DUMP_PATH: '' },
+ INDEXER_ROLLCALL_ENV: {
+ DOGE_INDEXER_API_URL: 'http://doge:3004',
+ XC_ROLLCALL_REGTEST_ACTIVATION: '99'
+ },
+ HUB_API_KEY: 'private-key',
+ HUB_FEED_API_KEY: 'feed-key',
+ HUB_SEED_URLS: 'http://seed:10000'
+ }, {
+ LTC_INDEXER_URL: 'http://litecoin:3004',
+ LTC_INDEXER_API_KEY: ''
+ })
+ const defaults = {}
+ services.configureIndexerBeforeHubKey(defaults, 'xchain-indexer', 'regtest')
+ expect(defaults).to.include({
+ XCHAIN_GENESIS_BLOCK: '101',
+ DOGE_INDEXER_API_URL: 'http://doge:3004',
+ XC_ROLLCALL_REGTEST_ACTIVATION: '99',
+ LTC_INDEXER_URL: 'http://litecoin:3004',
+ HUB_API_KEY: 'private-key',
+ HUB_FEED_API_KEY: 'feed-key',
+ HUB_SEED_URLS: 'http://seed:10000'
+ })
+ expect(defaults).not.to.have.property('GENESIS_DUMP_PATH')
+ expect(defaults).not.to.have.property('LTC_INDEXER_API_KEY')
+ })
+
+ it('does not pass regtest-only activation settings to mainnet', function () {
+ configure({
+ INDEXER_ROLLCALL_ENV: {
+ DOGE_INDEXER_API_URL: 'http://doge:3004',
+ XC_ROLLCALL_REGTEST_ACTIVATION: '99'
+ }
+ })
+ const defaults = {}
+ services.configureIndexerBeforeHubKey(defaults, 'xchain-indexer', 'mainnet')
+ expect(defaults.DOGE_INDEXER_API_URL).to.equal('http://doge:3004')
+ expect(defaults).not.to.have.property('XC_ROLLCALL_REGTEST_ACTIVATION')
+ })
+}
+
+function indexerOutputTests() {
+ it('configures indexer database mirroring and defaults every regtest grace to zero', function () {
+ configure({ HUB_SYNC_GRACE_ENV: { HUB_SYNC_PRICE_GRACE_S: '7' } })
+ const defaults = {
+ HUB_API_KEY: 'private-key',
+ INDEXER_DB_NAME: 'indexer_bitcoin_regtest',
+ INDEXER_DB_USER: 'indexer_user'
+ }
+ services.configureIndexerAfterHubKey(defaults, 'xchain-indexer', 'regtest')
+ expect(defaults).to.include({
+ HUB_CONFIG_API_KEY: 'private-key',
+ INDEXER_ALLOW_UNAUTHENTICATED: 'true',
+ HUB_DB_NAME: 'indexer_bitcoin_regtest',
+ HUB_DB_USER: 'indexer_user',
+ HUB_DB_SYNC_ENABLED: 'true',
+ HUB_SYNC_PRICE_GRACE_S: '7',
+ HUB_SYNC_ORACLE_GRACE_S: '0',
+ HUB_SYNC_POLICY_GRACE_S: '0'
+ })
+ })
+
+ it('uses an explicit indexer key and leaves shared-network grace values untouched', function () {
+ configure({ INDEXER_API_KEY: 'indexer-key' })
+ const defaults = { INDEXER_DB_NAME: 'indexer', INDEXER_DB_USER: 'user' }
+ services.configureIndexerAfterHubKey(defaults, 'xchain-indexer', 'mainnet')
+ expect(defaults.INDEXER_API_KEY).to.equal('indexer-key')
+ expect(defaults).not.to.have.property('INDEXER_ALLOW_UNAUTHENTICATED')
+ expect(defaults).not.to.have.property('HUB_SYNC_PRICE_GRACE_S')
+ })
+}
+
+describe('config_service/services', function () {
+ e2eServiceTests()
+ indexerInputTests()
+ indexerOutputTests()
+})
diff --git a/test/unit/config_service.test/sidecar_and_bootstrap.test.js b/test/unit/config_service.test/sidecar_and_bootstrap.test.js
index 0e44959f..551f9874 100644
--- a/test/unit/config_service.test/sidecar_and_bootstrap.test.js
+++ b/test/unit/config_service.test/sidecar_and_bootstrap.test.js
@@ -180,6 +180,33 @@ function feeDestinationInjection() {
}
+// Testnet is a multi-operator federation whose consensus pin hashes only the
+// static bundle, so the generic host var must not reach either consumer there.
+function feeDestinationTestnet() {
+ const TESTNET_ENV = 'XCHAIN_FEE_DESTINATION_BTC_TESTNET'
+ let saved
+ beforeEach(function () {
+ saved = { perCoin: process.env[TESTNET_ENV], generic: process.env.FEE_DESTINATION }
+ delete process.env[TESTNET_ENV]
+ process.env.FEE_DESTINATION = 'mGenericTestnetAddr555'
+ })
+ afterEach(function () {
+ if (saved.perCoin === undefined) delete process.env[TESTNET_ENV]; else process.env[TESTNET_ENV] = saved.perCoin
+ if (saved.generic === undefined) delete process.env.FEE_DESTINATION; else process.env.FEE_DESTINATION = saved.generic
+ })
+
+ it('ignores the generic FEE_DESTINATION host env on testnet and injects the registry pin', async function () {
+ const { getCoinConfigByFullName } = require('../../../src/coins')
+ const pinned = getCoinConfigByFullName('bitcoin', 'testnet').addresses.FEE_DESTINATION
+ expect(pinned).to.be.a('string').and.not.equal('mGenericTestnetAddr555')
+ for (const module of ['xchain-decoder', 'xchain-indexer']) {
+ const config = await makeServiceWithConfig('').getDefaultConfig(module, 'bitcoin', 'testnet')
+ expect(config['FEE_DESTINATION'], module).to.equal(pinned)
+ expect(config[TESTNET_ENV], module).to.equal(pinned)
+ }
+ })
+}
+
function genesisBootstrapPassthrough() {
const GENESIS_VARS = [
'XCHAIN_GENESIS_BLOCK', 'XCHAIN_GENESIS_LEDGER_HASH', 'XCHAIN_GENESIS_DUMP_HASH',
@@ -255,6 +282,14 @@ describe('ConfigService', function () {
})
})
+describe('ConfigService', function () {
+ describe('getDefaultConfig()', function () {
+ describe('with coin and network (coin-specific config)', function () {
+ describe('native-coin fee destination injection', feeDestinationTestnet)
+ })
+ })
+})
+
describe('ConfigService', function () {
describe('getDefaultConfig()', function () {
describe('with coin and network (coin-specific config)', function () {
diff --git a/test/unit/config_service.test/sidecars_module.test.js b/test/unit/config_service.test/sidecars_module.test.js
new file mode 100644
index 00000000..aec3d257
--- /dev/null
+++ b/test/unit/config_service.test/sidecars_module.test.js
@@ -0,0 +1,139 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+const { expect } = require('chai')
+const crypto = require('crypto')
+const fs = require('fs')
+const path = require('path')
+const readline = require('readline')
+const sidecars = require('../../../src/services/config_service/sidecars')
+
+const scratchRoot = path.resolve(__dirname, '../../../tmp/config-service-sidecars-module')
+let configDir
+
+function preferredSecretEnvName(key) {
+ return key === 'NODE_PASSWORD' ? 'NODE_SECRET'
+ : key === 'HUB_API_KEY' ? 'HUB_API_SECRET'
+ : undefined
+}
+
+function foldSecretEnvAliases(config) {
+ if (config.NODE_SECRET) config.NODE_PASSWORD = config.NODE_SECRET
+ if (config.HUB_API_SECRET) config.HUB_API_KEY = config.HUB_API_SECRET
+ return config
+}
+
+function persistenceTests() {
+ it('creates and appends sidecar credentials with mode 0600 and valid line boundaries', function () {
+ const file = path.join(configDir, 'bitcoin-mainnet.local')
+ sidecars.persistSidecarCreds(file, { NODE_USER: 'alice' })
+ fs.appendFileSync(file, 'EXTRA=value')
+ sidecars.persistSidecarCreds(file, { NODE_PASSWORD: 'secret' })
+ expect(fs.readFileSync(file, 'utf8')).to.equal(
+ 'NODE_USER=alice\nEXTRA=value\nNODE_PASSWORD=secret\n')
+ expect(fs.statSync(file).mode & 0o777).to.equal(0o600)
+ })
+
+ it('upserts values without discarding existing keys and prefers safe secret names', function () {
+ const file = path.join(configDir, 'bitcoin-mainnet.local')
+ fs.writeFileSync(file, 'NODE_USER=alice\nKEEP=value\n')
+ sidecars.upsertSidecarValues(file, { NODE_PASSWORD: 'new-secret' })
+ expect(fs.readFileSync(file, 'utf8')).to.equal(
+ 'NODE_USER=alice\nKEEP=value\nNODE_SECRET=new-secret\n')
+ })
+
+ it('reads a safe secret alias in preference to the legacy name', async function () {
+ const file = path.join(configDir, 'hub.local')
+ fs.writeFileSync(file, 'HUB_API_KEY=legacy\nHUB_API_SECRET=preferred\n')
+ expect(await sidecars.readSidecarValue(file, 'HUB_API_KEY')).to.equal('preferred')
+ })
+}
+
+function hubKeyTests() {
+ it('generates a hub API key once and then reuses it', async function () {
+ const first = await sidecars.ensureHubApiKey()
+ const contents = fs.readFileSync(first.path, 'utf8')
+ const second = await sidecars.ensureHubApiKey()
+ expect(first.generated).to.equal(true)
+ expect(second).to.deep.equal({ path: first.path, generated: false })
+ expect(fs.readFileSync(first.path, 'utf8')).to.equal(contents)
+ expect(contents).to.match(/^HUB_API_SECRET=[0-9a-f]{64}\n$/)
+ })
+
+ it('applies a hub sidecar key only when the target has no key', async function () {
+ fs.writeFileSync(path.join(configDir, 'hub.local'), 'HUB_API_SECRET=sidecar-key\n')
+ const empty = {}
+ const explicit = { HUB_API_KEY: 'host-key' }
+ await sidecars.applyHubApiKeyFromSidecar(empty)
+ await sidecars.applyHubApiKeyFromSidecar(explicit)
+ expect(empty.HUB_API_KEY).to.equal('sidecar-key')
+ expect(explicit.HUB_API_KEY).to.equal('host-key')
+ })
+}
+
+function parsingAndGenerationTests() {
+ it('rejects coin names that escape the config directory', function () {
+ expect(() => sidecars.coinConfigPaths('../outside', '')).to.throw('Config path traversal detected')
+ })
+
+ it('recovers credentials glued to the end of a main-config value', function () {
+ const defaults = {}
+ const found = sidecars.readMainConfigLine(
+ defaults,
+ 'DUST_AMOUNT=546NODE_USER=aliceNODE_PASSWORD=secret')
+ expect(found).to.equal(true)
+ expect(defaults).to.deep.equal({
+ DUST_AMOUNT: '546',
+ NODE_USER: 'alice',
+ NODE_PASSWORD: 'secret'
+ })
+ })
+
+ it('generates only a missing RPC credential and persists its safe alias', function () {
+ const file = path.join(configDir, 'bitcoin-regtest.local')
+ const defaults = { NODE_USER: 'existing-user' }
+ sidecars.generateRpcCredentials(defaults, file)
+ expect(defaults.NODE_USER).to.equal('existing-user')
+ expect(defaults.NODE_PASSWORD).to.match(/^[0-9a-f]{48}$/)
+ expect(fs.readFileSync(file, 'utf8')).to.equal('NODE_SECRET=' + defaults.NODE_PASSWORD + '\n')
+ })
+}
+
+describe('config_service/sidecars', function () {
+ beforeEach(function () {
+ fs.mkdirSync(scratchRoot, { recursive: true })
+ configDir = fs.mkdtempSync(path.join(scratchRoot, 'case-'))
+ sidecars.configure({
+ crypto,
+ fs,
+ path,
+ readline,
+ configDir,
+ preferredSecretEnvName,
+ foldSecretEnvAliases,
+ deprecatedSecretEnvNames: () => [],
+ logger: { warn() {} }
+ })
+ })
+
+ afterEach(function () {
+ fs.rmSync(configDir, { recursive: true, force: true })
+ })
+
+ after(function () {
+ fs.rmSync(scratchRoot, { recursive: true, force: true })
+ })
+
+ persistenceTests()
+ hubKeyTests()
+ parsingAndGenerationTests()
+})
diff --git a/test/unit/config_service_sync_parity_passthrough.test.js b/test/unit/config_service_sync_parity_passthrough.test.js
new file mode 100644
index 00000000..2291491e
--- /dev/null
+++ b/test/unit/config_service_sync_parity_passthrough.test.js
@@ -0,0 +1,100 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+const { expect } = require('chai')
+const { bindEnvViews } = require('../../src/config/env_views')
+const {
+ makeServiceWithConfig,
+ DB_MODULE_NAME,
+ HUB_MODULE_NAME,
+ EXPLORER_MODULE_NAME,
+ SYNC_MODULE_NAME
+} = require('./config_service.test/helpers.test')
+
+function viewsFor(env) {
+ return bindEnvViews({ read: (n) => env[n], copy: () => ({ ...env }) })
+}
+
+async function withParityValue(value, fn) {
+ const previous = process.env.INDEX_MAP_PARITY_CHECK
+ if (value === undefined) delete process.env.INDEX_MAP_PARITY_CHECK
+ else process.env.INDEX_MAP_PARITY_CHECK = value
+ try {
+ await fn()
+ } finally {
+ if (previous === undefined) delete process.env.INDEX_MAP_PARITY_CHECK
+ else process.env.INDEX_MAP_PARITY_CHECK = previous
+ }
+}
+
+async function containerConfig(module) {
+ return makeServiceWithConfig('').getDefaultConfig(module, null, null)
+}
+
+describe('sync INDEX_MAP_PARITY_CHECK passthrough', () => {
+ it('the view answers the host value', () => {
+ expect(viewsFor({ INDEX_MAP_PARITY_CHECK: 'true' }).SYNC_PASSTHROUGH_ENV.INDEX_MAP_PARITY_CHECK).to.equal('true')
+ })
+
+ it('the view rejects an undeclared name', () => {
+ expect(() => viewsFor({}).SYNC_PASSTHROUGH_ENV.NOT_DECLARED).to.throw(/not declared/)
+ })
+
+ it('puts the host value in the sync container config', async () => {
+ await withParityValue('true', async () => {
+ const config = await containerConfig(SYNC_MODULE_NAME)
+ expect(config).to.have.property('INDEX_MAP_PARITY_CHECK', 'true')
+ })
+ })
+
+ it('does not put the host value in other shared container configs', async () => {
+ await withParityValue('true', async () => {
+ for (const module of [HUB_MODULE_NAME, EXPLORER_MODULE_NAME, DB_MODULE_NAME]) {
+ const config = await containerConfig(module)
+ expect(config).to.not.have.property('INDEX_MAP_PARITY_CHECK')
+ }
+ })
+ })
+
+ it('omits the value from the sync container config when unset or empty', async () => {
+ for (const value of [undefined, '']) {
+ await withParityValue(value, async () => {
+ const config = await containerConfig(SYNC_MODULE_NAME)
+ expect(config).to.not.have.property('INDEX_MAP_PARITY_CHECK')
+ })
+ }
+ })
+})
+
+describe('hub publish switches passthrough', () => {
+ const NAMES = ['ORACLE_PUBLISH_ENABLED', 'ATTEST_BATCH_PUBLISH_ENABLED']
+
+ it('the view answers the host values', () => {
+ const views = viewsFor({ ORACLE_PUBLISH_ENABLED: 'false', ATTEST_BATCH_PUBLISH_ENABLED: 'true' })
+ expect(views.HUB_PASSTHROUGH_ENV.ORACLE_PUBLISH_ENABLED).to.equal('false')
+ expect(views.HUB_PASSTHROUGH_ENV.ATTEST_BATCH_PUBLISH_ENABLED).to.equal('true')
+ })
+
+ it('puts both host values in the hub env', async () => {
+ const previous = {}
+ for (const n of NAMES) { previous[n] = process.env[n]; process.env[n] = 'false' }
+ try {
+ const config = await containerConfig(HUB_MODULE_NAME)
+ for (const n of NAMES) expect(config).to.have.property(n, 'false')
+ } finally {
+ for (const n of NAMES) {
+ if (previous[n] === undefined) delete process.env[n]
+ else process.env[n] = previous[n]
+ }
+ }
+ })
+})
diff --git a/test/unit/credentials_service.test/01_save_credentials.test.js b/test/unit/credentials_service.test/01_save_credentials.test.js
index 12ba5886..90801e36 100644
--- a/test/unit/credentials_service.test/01_save_credentials.test.js
+++ b/test/unit/credentials_service.test/01_save_credentials.test.js
@@ -12,7 +12,7 @@
const {
sinon, expect, CREDS_DIR, CREDS_FILE,
- loadCredentialsService, makeFs
+ loadCredentialsService, makeFs, expectAtomicSave
} = require('./support/helpers')
// saveCredentials
@@ -47,9 +47,7 @@ describe('CredentialsService', function () {
const cs = loadCredentialsService(fs)
const creds = { user: 'u', password: 'test-pass', database: 'xchain_node' }
cs.saveCredentials(creds)
- expect(fs.writeFileSync.calledOnce).to.be.true
- const [filePath, content, opts] = fs.writeFileSync.firstCall.args
- expect(filePath).to.equal(CREDS_FILE)
+ const { content, opts } = expectAtomicSave(fs)
expect(JSON.parse(content)).to.deep.equal(creds)
expect(opts.mode).to.equal(0o600)
})
@@ -93,9 +91,7 @@ describe('CredentialsService', function () {
const cs = loadCredentialsService(fs)
cs.saveCredentials({ user: 'u', password: 'test-pass', database: 'xchain_node' })
- expect(fs.writeFileSync.calledOnce).to.be.true
- const [filePath, content, opts] = fs.writeFileSync.firstCall.args
- expect(filePath).to.equal(CREDS_FILE)
+ const { content, opts } = expectAtomicSave(fs)
const written = JSON.parse(content)
expect(written.externalDb).to.deep.equal({ host: 'db.example.com', port: 3306, root_user: 'root', root_password: 'r' })
expect(written.user).to.equal('u')
diff --git a/test/unit/credentials_service.test/02_load_db_root_password_save_db_root_password.test.js b/test/unit/credentials_service.test/02_load_db_root_password_save_db_root_password.test.js
index f8e7a753..772c9b37 100644
--- a/test/unit/credentials_service.test/02_load_db_root_password_save_db_root_password.test.js
+++ b/test/unit/credentials_service.test/02_load_db_root_password_save_db_root_password.test.js
@@ -12,7 +12,7 @@
const {
sinon, expect, CREDS_FILE,
- loadCredentialsService, makeFs
+ loadCredentialsService, makeFs, expectAtomicSave
} = require('./support/helpers')
// loadDbRootPassword / saveDbRootPassword
@@ -67,9 +67,7 @@ describe('CredentialsService', function () {
const cs = loadCredentialsService(fs)
cs.saveDbRootPassword('new-root-pw')
- expect(fs.writeFileSync.calledOnce).to.be.true
- const [filePath, content, opts] = fs.writeFileSync.firstCall.args
- expect(filePath).to.equal(CREDS_FILE)
+ const { content, opts } = expectAtomicSave(fs)
const written = JSON.parse(content)
expect(written.dbRootPassword).to.equal('new-root-pw')
expect(written.user).to.equal('u')
diff --git a/test/unit/credentials_service.test/05_save_external_db_config.test.js b/test/unit/credentials_service.test/05_save_external_db_config.test.js
index b115c837..929bcf96 100644
--- a/test/unit/credentials_service.test/05_save_external_db_config.test.js
+++ b/test/unit/credentials_service.test/05_save_external_db_config.test.js
@@ -11,7 +11,7 @@
// contact legal@dankest.llc.
const {
- sinon, expect, loadCredentialsService, makeFs
+ sinon, expect, loadCredentialsService, makeFs, fsError
} = require('./support/helpers')
// saveExternalDbConfig
@@ -22,7 +22,7 @@ describe('CredentialsService', function () {
it('creates dir if it does not exist', function () {
const fs = makeFs({
existsSync: sinon.stub().returns(false),
- readFileSync: sinon.stub().throws(new Error('ENOENT'))
+ readFileSync: sinon.stub().throws(fsError('ENOENT'))
})
const cs = loadCredentialsService(fs)
const cfg = { host: '127.0.0.1', port: 3306, root_user: 'root', root_password: 'test-pass' }
@@ -61,7 +61,7 @@ describe('CredentialsService', function () {
it('coerces types: host/root_user/root_password to string, port to number', function () {
const fs = makeFs({
existsSync: sinon.stub().returns(true),
- readFileSync: sinon.stub().throws(new Error('ENOENT'))
+ readFileSync: sinon.stub().throws(fsError('ENOENT'))
})
const cs = loadCredentialsService(fs)
cs.saveExternalDbConfig({ host: 127, port: '3306', root_user: 42, root_password: true })
diff --git a/test/unit/credentials_service.test/07_atomic_save.test.js b/test/unit/credentials_service.test/07_atomic_save.test.js
new file mode 100644
index 00000000..4fb2dc23
--- /dev/null
+++ b/test/unit/credentials_service.test/07_atomic_save.test.js
@@ -0,0 +1,112 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+// Pins the save path of credentials.json: never truncated in place, and an existing file that
+// will not parse is moved aside, not silently rebuilt from {} with its sibling blocks dropped.
+
+const {
+ sinon, expect, CREDS_DIR, CREDS_FILE,
+ loadCredentialsService, makeFs, fsError, expectAtomicSave
+} = require('./support/helpers')
+
+const TORN = '{"user":"u","password":"s3cret-torn'
+
+// The three save helpers, each with the one field it writes.
+const SAVES = [
+ { name: 'saveCredentials', run: cs => cs.saveCredentials({ user: 'n', password: 'np' }), field: { user: 'n', password: 'np' } },
+ { name: 'saveDbRootPassword', run: cs => cs.saveDbRootPassword('rootpw'), field: { dbRootPassword: 'rootpw' } },
+ {
+ name: 'saveExternalDbConfig',
+ run: cs => cs.saveExternalDbConfig({ host: 'h', port: 3306, root_user: 'r', root_password: 'rp' }),
+ field: { externalDb: { host: 'h', port: 3306, root_user: 'r', root_password: 'rp' } }
+ }
+]
+
+let warnStub
+
+describe('CredentialsService', function () {
+ beforeEach(() => { warnStub = sinon.stub(console, 'warn') })
+ afterEach(() => warnStub.restore())
+
+ describe('atomic save of an unparseable credentials.json', function () {
+ for (const save of SAVES) {
+ it(`${save.name} moves a torn file aside, warns without secrets, and saves only its own field`, function () {
+ const fs = makeFs({ existsSync: sinon.stub().returns(true), readFileSync: sinon.stub().returns(TORN) })
+ save.run(loadCredentialsService(fs))
+ const aside = fs.renameSync.firstCall.args
+ expect(aside[0]).to.equal(CREDS_FILE)
+ expect(aside[1].startsWith(CREDS_FILE + '.corrupt-')).to.be.true
+ expect(warnStub.calledOnce).to.be.true
+ expect(String(warnStub.firstCall.args[0])).to.include(aside[1])
+ expect(String(warnStub.firstCall.args[0])).to.not.include('s3cret')
+ const { content } = expectAtomicSave(fs)
+ expect(JSON.parse(content)).to.deep.equal(save.field)
+ })
+ }
+
+ it('treats a document that parses to something other than an object as unparseable', function () {
+ for (const body of ['null', '[]', '"text"', '7']) {
+ const fs = makeFs({ existsSync: sinon.stub().returns(true), readFileSync: sinon.stub().returns(body) })
+ loadCredentialsService(fs).saveDbRootPassword('pw')
+ expect(fs.renameSync.firstCall.args[1].startsWith(CREDS_FILE + '.corrupt-'), body).to.be.true
+ }
+ })
+
+ it('refuses to save when the torn file cannot be moved aside, leaving it in place', function () {
+ const fs = makeFs({
+ existsSync: sinon.stub().returns(true),
+ readFileSync: sinon.stub().returns(TORN),
+ renameSync: sinon.stub().throws(fsError('EXDEV'))
+ })
+ expect(() => loadCredentialsService(fs).saveDbRootPassword('pw')).to.throw('EXDEV')
+ expect(fs.writeFileSync.called).to.be.false
+ })
+ })
+})
+
+describe('CredentialsService', function () {
+ beforeEach(() => { warnStub = sinon.stub(console, 'warn') })
+ afterEach(() => warnStub.restore())
+
+ describe('atomic save read and write failures', function () {
+ it('starts a fresh document, with nothing moved aside, when the file does not exist', function () {
+ const fs = makeFs({ readFileSync: sinon.stub().throws(fsError('ENOENT')) })
+ loadCredentialsService(fs).saveDbRootPassword('pw')
+ const { content } = expectAtomicSave(fs)
+ expect(JSON.parse(content)).to.deep.equal({ dbRootPassword: 'pw' })
+ expect(fs.renameSync.calledOnce).to.be.true
+ expect(warnStub.called).to.be.false
+ })
+
+ it('refuses to save over a file it cannot read, rather than replacing it by rename', function () {
+ const fs = makeFs({ existsSync: sinon.stub().returns(true), readFileSync: sinon.stub().throws(fsError('EACCES')) })
+ expect(() => loadCredentialsService(fs).saveDbRootPassword('pw')).to.throw('EACCES')
+ expect(fs.writeFileSync.called).to.be.false
+ expect(fs.renameSync.called).to.be.false
+ })
+
+ it('leaves credentials.json untouched and removes the temp file when the write fails', function () {
+ const fs = makeFs({ existsSync: sinon.stub().returns(true), writeFileSync: sinon.stub().throws(fsError('ENOSPC')) })
+ expect(() => loadCredentialsService(fs).saveDbRootPassword('pw')).to.throw('ENOSPC')
+ const tmpPath = fs.writeFileSync.firstCall.args[0]
+ expect(tmpPath.startsWith(CREDS_DIR)).to.be.true
+ expect(fs.renameSync.called).to.be.false
+ expect(fs.unlinkSync.calledOnceWith(tmpPath)).to.be.true
+ })
+
+ it('removes the temp file and reports the error when the rename over the original fails', function () {
+ const fs = makeFs({ existsSync: sinon.stub().returns(true), renameSync: sinon.stub().throws(fsError('EPERM')) })
+ expect(() => loadCredentialsService(fs).saveCredentials({ user: 'u', password: 'p' })).to.throw('EPERM')
+ expect(fs.unlinkSync.calledOnceWith(fs.writeFileSync.firstCall.args[0])).to.be.true
+ })
+ })
+})
diff --git a/test/unit/credentials_service.test/08_atomic_save_on_disk.test.js b/test/unit/credentials_service.test/08_atomic_save_on_disk.test.js
new file mode 100644
index 00000000..48411beb
--- /dev/null
+++ b/test/unit/credentials_service.test/08_atomic_save_on_disk.test.js
@@ -0,0 +1,72 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+// Runs the credentials.json save on a real filesystem, where rename, mode bits and the
+// quarantine copy are the operating system's, not a stub's.
+
+const fs = require('fs')
+const os = require('os')
+const path = require('path')
+const { sinon, expect, loadCredentialsService } = require('./support/helpers')
+
+let home, credsDir, credsFile, warnStub
+
+// Load the service with the real fs and a home directory inside a fresh temp dir.
+function loadOnDisk() {
+ return loadCredentialsService(fs, { homedir: () => home, userInfo: () => ({ username: 'testuser' }) })
+}
+
+describe('CredentialsService', function () {
+ beforeEach(() => {
+ home = fs.mkdtempSync(path.join(os.tmpdir(), 'xcn-creds-'))
+ credsDir = path.join(home, '.xchain-node')
+ credsFile = path.join(credsDir, 'credentials.json')
+ fs.mkdirSync(credsDir, { mode: 0o700 })
+ warnStub = sinon.stub(console, 'warn')
+ })
+
+ afterEach(() => {
+ warnStub.restore()
+ fs.rmSync(home, { recursive: true, force: true })
+ })
+
+ describe('atomic save on a real filesystem', function () {
+ it('keeps a torn file byte for byte beside a fresh 0600 credentials.json, with no temp file left', function () {
+ const torn = '{"user":"u","password":"p","externalDb":{"host":"h"'
+ fs.writeFileSync(credsFile, torn, { mode: 0o600 })
+ loadOnDisk().saveDbRootPassword('pw')
+
+ const names = fs.readdirSync(credsDir)
+ const aside = names.filter(n => n.startsWith('credentials.json.corrupt-'))
+ expect(aside).to.have.length(1)
+ expect(fs.readFileSync(path.join(credsDir, aside[0]), 'utf8')).to.equal(torn)
+ expect(JSON.parse(fs.readFileSync(credsFile, 'utf8'))).to.deep.equal({ dbRootPassword: 'pw' })
+ expect(names.filter(n => n.endsWith('.tmp'))).to.have.length(0)
+ if (process.platform !== 'win32') expect(fs.statSync(credsFile).mode & 0o777).to.equal(0o600)
+ })
+
+ it('keeps every sibling block of a valid file across saves from all three helpers', function () {
+ const cs = loadOnDisk()
+ cs.saveCredentials({ user: 'u', password: 'p', database: 'xchain_node' })
+ cs.saveExternalDbConfig({ host: 'h', port: 3306, root_user: 'r', root_password: 'rp' })
+ cs.saveDbRootPassword('pw')
+
+ expect(JSON.parse(fs.readFileSync(credsFile, 'utf8'))).to.deep.equal({
+ user: 'u', password: 'p', database: 'xchain_node',
+ externalDb: { host: 'h', port: 3306, root_user: 'r', root_password: 'rp' },
+ dbRootPassword: 'pw'
+ })
+ expect(fs.readdirSync(credsDir)).to.deep.equal(['credentials.json'])
+ expect(warnStub.called).to.be.false
+ })
+ })
+})
diff --git a/test/unit/credentials_service.test/support/helpers.js b/test/unit/credentials_service.test/support/helpers.js
index d569d58c..80501760 100644
--- a/test/unit/credentials_service.test/support/helpers.js
+++ b/test/unit/credentials_service.test/support/helpers.js
@@ -38,10 +38,35 @@ function makeFs(overrides = {}) {
writeFileSync: sinon.stub(),
mkdirSync: sinon.stub(),
chmodSync: sinon.stub(),
+ // The atomic save: temp file, flush, rename over the original
+ renameSync: sinon.stub(),
+ unlinkSync: sinon.stub(),
+ openSync: sinon.stub().returns(42),
+ fsyncSync: sinon.stub(),
+ closeSync: sinon.stub(),
...overrides
}
}
+// An fs error carrying its code, the way node's fs raises one
+function fsError(code) {
+ return Object.assign(new Error(code), { code })
+}
+
+// Assert a save went through a flushed 0600 temp file renamed over credentials.json, and return what it wrote
+function expectAtomicSave(fs) {
+ expect(fs.writeFileSync.calledOnce).to.be.true
+ const [tmpPath, content, opts] = fs.writeFileSync.firstCall.args
+ expect(path.dirname(tmpPath)).to.equal(CREDS_DIR)
+ expect(tmpPath).to.match(/\.tmp$/)
+ expect(opts).to.include({ mode: 0o600, flag: 'wx' })
+ expect(fs.fsyncSync.calledOnce).to.be.true
+ const renameOver = fs.renameSync.getCalls().find(c => c.args[0] === tmpPath && c.args[1] === CREDS_FILE)
+ expect(renameOver, 'the temp file is renamed over credentials.json').to.exist
+ expect(renameOver.calledAfter(fs.fsyncSync.firstCall)).to.be.true
+ return { content, opts }
+}
+
module.exports = {
sinon,
expect,
@@ -49,5 +74,7 @@ module.exports = {
CREDS_DIR,
CREDS_FILE,
loadCredentialsService,
- makeFs
+ makeFs,
+ fsError,
+ expectAtomicSave
}
diff --git a/test/unit/database_service.test/build_module/buffer_pool_size.test.js b/test/unit/database_service.test/build_module/buffer_pool_size.test.js
new file mode 100644
index 00000000..c3f35d83
--- /dev/null
+++ b/test/unit/database_service.test/build_module/buffer_pool_size.test.js
@@ -0,0 +1,86 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+const { sinon, expect, VALID_CONTAINER_ID, makeStubs, loadDatabaseService } = require('../helpers/harness')
+const { resolveBufferPoolSize, DEFAULT_BUFFER_POOL_SIZE } = require('../../../../src/services/database_service/buffer_pool_size')
+
+// The mysqld args buildDatabaseModule hands `docker run` for a given env value
+// (undefined leaves the variable unset).
+async function runArgsWithPoolEnv(value) {
+ const saved = process.env.XCHAIN_NODE_DB_BUFFER_POOL_SIZE
+ if (value === undefined) delete process.env.XCHAIN_NODE_DB_BUFFER_POOL_SIZE
+ else process.env.XCHAIN_NODE_DB_BUFFER_POOL_SIZE = value
+ try {
+ const stubs = makeStubs()
+ stubs.execFileAsync.onFirstCall().rejects(new Error('No such container'))
+ stubs.execFileAsync.resolves({ stdout: VALID_CONTAINER_ID + '\n' })
+ await loadDatabaseService(stubs).buildDatabaseModule('bitcoin', 'mainnet')
+ const call = stubs.execFileAsync.getCalls().find(c =>
+ c.args[0] === 'docker' && Array.isArray(c.args[1]) && c.args[1][0] === 'run')
+ expect(call, 'docker run call not found').to.exist
+ return call.args[1]
+ } finally {
+ if (saved === undefined) delete process.env.XCHAIN_NODE_DB_BUFFER_POOL_SIZE
+ else process.env.XCHAIN_NODE_DB_BUFFER_POOL_SIZE = saved
+ }
+}
+
+describe('resolveBufferPoolSize()', function () {
+ it('defaults to 1G when the variable is unset or blank', function () {
+ const warn = sinon.spy()
+ expect(DEFAULT_BUFFER_POOL_SIZE).to.equal('1G')
+ for (const raw of [undefined, null, '', ' ']) expect(resolveBufferPoolSize(raw, warn)).to.equal('1G')
+ expect(warn.called).to.be.false
+ })
+
+ it('accepts a byte count with an optional K, M or G suffix', function () {
+ const warn = sinon.spy()
+ for (const raw of ['134217728', '512K', '512M', '16G', ' 2G ']) {
+ expect(resolveBufferPoolSize(raw, warn)).to.equal(raw.trim())
+ }
+ expect(warn.called).to.be.false
+ })
+
+ it('falls back to the default with a warning naming the bad value', function () {
+ for (const raw of ['16GB', '1.5G', 'big', '-1G', '16g', '1G;--evil', 'G']) {
+ const warn = sinon.spy()
+ expect(resolveBufferPoolSize(raw, warn), raw).to.equal('1G')
+ expect(warn.calledOnce, raw).to.be.true
+ expect(warn.firstCall.args[0]).to.include(raw.trim())
+ }
+ })
+})
+
+describe('DatabaseService buffer pool argument', function () {
+ it('passes --innodb-buffer-pool-size=1G after the image when the variable is unset', async function () {
+ const runArgs = await runArgsWithPoolEnv(undefined)
+ expect(runArgs.indexOf('--innodb-buffer-pool-size=1G')).to.be.greaterThan(runArgs.indexOf('xchain-node-database'))
+ expect(runArgs.filter(a => String(a).startsWith('--innodb-buffer-pool-size'))).to.have.lengthOf(1)
+ })
+
+ it('passes the operator value when it is valid', async function () {
+ const runArgs = await runArgsWithPoolEnv('512M')
+ expect(runArgs).to.include('--innodb-buffer-pool-size=512M')
+ expect(runArgs).to.not.include('--innodb-buffer-pool-size=1G')
+ })
+
+ it('passes the default when the value is malformed', async function () {
+ const runArgs = await runArgsWithPoolEnv('lots')
+ expect(runArgs).to.include('--innodb-buffer-pool-size=1G')
+ expect(runArgs.some(a => String(a).includes('lots'))).to.be.false
+ })
+
+ it('does not add the flush-log setting', async function () {
+ const runArgs = await runArgsWithPoolEnv(undefined)
+ expect(runArgs.some(a => String(a).startsWith('--innodb-flush-log-at-trx-commit'))).to.be.false
+ })
+})
diff --git a/test/unit/database_service.test/build_module/build_database_module.test.js b/test/unit/database_service.test/build_module/build_database_module.test.js
index 67b47e67..7aea2c3d 100644
--- a/test/unit/database_service.test/build_module/build_database_module.test.js
+++ b/test/unit/database_service.test/build_module/build_database_module.test.js
@@ -337,7 +337,7 @@ describe('DatabaseService', function () {
const runArgs = findDockerRunArgs(stubs.execFileAsync)
expect(runArgs, 'docker run call not found').to.not.be.null
- expect(runArgs.some(a => a.startsWith('--innodb-buffer-pool-size'))).to.be.false
+ expect(runArgs).to.include('--innodb-buffer-pool-size=1G')
// max-connections is deliberately NOT omitted when unset: it falls back to
// the 1000 default (see the saturation-guard test above).
expect(runArgs).to.include('--max-connections=1000')
@@ -351,3 +351,46 @@ describe('DatabaseService', function () {
})
})
})
+
+// The not-running guard's hint must work for the state Docker actually reports:
+// `docker start` does nothing for a crash-loop and Docker refuses it for a paused container.
+async function notRunningError(state) {
+ const stubs = makeStubs({
+ getStatusFromContainer: sinon.stub().resolves({ State: { Status: state } })
+ })
+ let threw = null
+ try {
+ await loadDatabaseService(stubs).buildDatabaseModule('bitcoin', 'mainnet')
+ } catch (err) { threw = err }
+ expect(threw, state).to.be.an.instanceOf(Error)
+ // No network mutation and no recreate attempt, whatever the state.
+ expect(stubs.addContainerToNetwork.called, state).to.be.false
+ return threw.message
+}
+
+describe('DatabaseService', function () {
+ describe('buildDatabaseModule()', function () {
+ it('points a crash-looping DB container at its logs, not docker start', async function () {
+ const message = await notRunningError('restarting')
+ expect(message).to.match(/exists but is restarting/)
+ expect(message).to.match(/docker logs/)
+ expect(message).to.not.match(/docker start/)
+ })
+
+ it('tells the operator to unpause a paused DB container', async function () {
+ const message = await notRunningError('paused')
+ expect(message).to.match(/docker unpause/)
+ expect(message).to.not.match(/docker start/)
+ })
+
+ it('gives a dead DB container a non-destructive inspect hint', async function () {
+ const message = await notRunningError('dead')
+ expect(message).to.match(/docker inspect/)
+ expect(message).to.not.match(/docker start|docker rm/)
+ })
+
+ it('keeps the docker start hint for a created DB container', async function () {
+ expect(await notRunningError('created')).to.match(/run: docker start /)
+ })
+ })
+})
diff --git a/test/unit/database_service.test/mariadb_exec/root_password_persisted_datadir.test.js b/test/unit/database_service.test/mariadb_exec/root_password_persisted_datadir.test.js
new file mode 100644
index 00000000..7d7072a4
--- /dev/null
+++ b/test/unit/database_service.test/mariadb_exec/root_password_persisted_datadir.test.js
@@ -0,0 +1,218 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+const fs = require('fs')
+const os = require('os')
+const path = require('path')
+const { sinon, expect, VALID_CONTAINER_ID, makeStubs, loadDatabaseService } = require('../helpers/harness')
+
+// A datadir pinned with XCHAIN_NODE_DB_DATA_DIR survives `docker rm`, and the mariadb image
+// ignores MYSQL_ROOT_PASSWORD once /mysql exists, so a recreated container keeps the
+// old root password. No password may be stored for it until it authenticates.
+
+const DENIED = "mariadb-admin: connect to server at 'localhost' failed\nerror: 'Access denied for user 'root'@'localhost''"
+
+const madeDirs = []
+after(() => { for (const dir of madeDirs) fs.rmSync(dir, { recursive: true, force: true }) })
+
+// Make a temporary datadir, already initialised (holding mysql/) unless told otherwise.
+function makeDatadir({ initialised = true } = {}) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xn-datadir-'))
+ madeDirs.push(dir)
+ if (initialised) fs.mkdirSync(path.join(dir, 'mysql'))
+ return dir
+}
+
+// Answer docker the way a host with no DB container, a working run, and a server whose root
+// password is `realRoot` would; `down` makes every ping fail as a server that never came up.
+function fakeDocker(stubs, { realRoot, down = false }) {
+ stubs.execFileAsync.callsFake(async (cmd, args, opts) => {
+ if (args[0] === 'inspect') throw new Error('Error: No such container: xchain-node-database')
+ if (args[0] === 'run') return { stdout: VALID_CONTAINER_ID + '\n', stderr: '' }
+ if (args[0] === 'exec' && args.includes('mariadb-admin')) {
+ if (down) throw Object.assign(new Error('ping failed'), { stderr: "Can't connect to local server through socket" })
+ if (opts && opts.env && opts.env.MYSQL_PWD === realRoot) return { stdout: 'mysqld is alive\n', stderr: '' }
+ return { stdout: '', stderr: DENIED }
+ }
+ return { stdout: '', stderr: '' }
+ })
+}
+
+// Run `fn` with the datadir, env override and stdin TTY set as asked, restoring all three after.
+async function withEnv({ datadir, envPassword = null, tty = false }, fn) {
+ const saved = { dir: process.env.XCHAIN_NODE_DB_DATA_DIR, pw: process.env.XCHAIN_NODE_DB_ROOT_PASSWORD }
+ const savedIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY')
+ if (datadir === undefined) delete process.env.XCHAIN_NODE_DB_DATA_DIR
+ else process.env.XCHAIN_NODE_DB_DATA_DIR = datadir
+ if (envPassword === null) delete process.env.XCHAIN_NODE_DB_ROOT_PASSWORD
+ else process.env.XCHAIN_NODE_DB_ROOT_PASSWORD = envPassword
+ Object.defineProperty(process.stdin, 'isTTY', { value: tty, configurable: true })
+ try {
+ return await fn()
+ } finally {
+ if (savedIsTTY) Object.defineProperty(process.stdin, 'isTTY', savedIsTTY)
+ else delete process.stdin.isTTY
+ if (saved.dir === undefined) delete process.env.XCHAIN_NODE_DB_DATA_DIR
+ else process.env.XCHAIN_NODE_DB_DATA_DIR = saved.dir
+ if (saved.pw === undefined) delete process.env.XCHAIN_NODE_DB_ROOT_PASSWORD
+ else process.env.XCHAIN_NODE_DB_ROOT_PASSWORD = saved.pw
+ }
+}
+
+// Resolve the root password with no DB container, docker confirming it gone.
+async function resolveRoot({ datadir, stored = null, envPassword = null, tty = false, realRoot = 'old-root' }) {
+ const stubs = makeStubs()
+ stubs.getDbRootPassword.returns(null)
+ stubs.loadDbRootPassword.returns(stored)
+ fakeDocker(stubs, { realRoot })
+ return withEnv({ datadir, envPassword, tty }, async () => {
+ const ds = loadDatabaseService(stubs)
+ let err = null
+ let result = null
+ try { result = await ds.askMariadbRootPassword('', '') } catch (e) { err = e }
+ return { stubs, err, result }
+ })
+}
+
+describe('DatabaseService', function () {
+ afterEach(() => sinon.restore())
+
+ describe('askMariadbRootPassword() over a datadir that already holds a database', function () {
+
+ it('uses the stored copy over a different env override and saves nothing, naming no value', async function () {
+ const warn = sinon.stub(console, 'warn')
+ const { stubs, err, result } = await resolveRoot({ datadir: makeDatadir(), stored: 'old-root', envPassword: 'new-root' })
+ expect(err).to.equal(null)
+ expect(result).to.equal('old-root')
+ expect(stubs.saveDbRootPassword.called).to.equal(false)
+ const text = warn.args.map(a => a.join(' ')).join('\n')
+ expect(text).to.include('XCHAIN_NODE_DB_ROOT_PASSWORD differs')
+ expect(text).to.not.include('new-root')
+ expect(text).to.not.include('old-root')
+ })
+
+ it('uses the stored copy with no env override, without prompting or saving', async function () {
+ const { stubs, result } = await resolveRoot({ datadir: makeDatadir(), stored: 'old-root' })
+ expect(result).to.equal('old-root')
+ expect(stubs.saveDbRootPassword.called).to.equal(false)
+ })
+
+ it('uses the env override when nothing is stored, without saving it', async function () {
+ const { stubs, result } = await resolveRoot({ datadir: makeDatadir(), envPassword: 'env-root' })
+ expect(result).to.equal('env-root')
+ expect(stubs.saveDbRootPassword.called).to.equal(false)
+ })
+
+ it('refuses with no candidate and no TTY, naming the datadir and the variable', async function () {
+ const datadir = makeDatadir()
+ const { stubs, err } = await resolveRoot({ datadir })
+ expect(err).to.be.an('error')
+ expect(err.message).to.include(datadir)
+ expect(err.message).to.include('XCHAIN_NODE_DB_ROOT_PASSWORD')
+ expect(stubs.setDbRootPassword.called).to.equal(false)
+ expect(stubs.saveDbRootPassword.called).to.equal(false)
+ })
+
+ })
+})
+
+describe('DatabaseService', function () {
+ afterEach(() => sinon.restore())
+
+ describe('askMariadbRootPassword() over a datadir that already holds a database', function () {
+
+ it('treats a named volume as initialised, since it persists', async function () {
+ const { stubs, result } = await resolveRoot({ datadir: 'xn-db-volume', envPassword: 'env-root' })
+ expect(result).to.equal('env-root')
+ expect(stubs.saveDbRootPassword.called).to.equal(false)
+ })
+
+ it('treats a datadir it cannot read as initialised', async function () {
+ if (typeof process.getuid === 'function' && process.getuid() === 0) this.skip()
+ const datadir = makeDatadir()
+ fs.chmodSync(datadir, 0o000)
+ try {
+ const { stubs, result } = await resolveRoot({ datadir, envPassword: 'env-root' })
+ expect(result).to.equal('env-root')
+ expect(stubs.saveDbRootPassword.called).to.equal(false)
+ } finally {
+ fs.chmodSync(datadir, 0o700)
+ }
+ })
+
+ it('keeps the fresh-install path for a datadir with no mysql/ in it', async function () {
+ const { stubs, result } = await resolveRoot({ datadir: makeDatadir({ initialised: false }), envPassword: 'env-root' })
+ expect(result).to.equal('env-root')
+ expect(stubs.saveDbRootPassword.calledOnceWith('env-root')).to.equal(true)
+ })
+ })
+})
+
+// Install the DB container with no container present and docker confirming it gone.
+async function install({ datadir, stored = null, envPassword = null, realRoot, down = false }) {
+ const stubs = makeStubs()
+ stubs.getDbRootPassword.returns(null)
+ stubs.loadDbRootPassword.returns(stored)
+ fakeDocker(stubs, { realRoot, down })
+ return withEnv({ datadir, envPassword }, async () => {
+ const ds = loadDatabaseService(stubs)
+ let err = null
+ let result = null
+ try { result = await ds.buildDatabaseModule('', '') } catch (e) { err = e }
+ return { stubs, err, result }
+ })
+}
+
+describe('DatabaseService', function () {
+ afterEach(() => sinon.restore())
+
+ describe('buildDatabaseModule() over a datadir that already holds a database', function () {
+
+ it('stores the chosen root password once it authenticates against the new container', async function () {
+ const { stubs, err, result } = await install({ datadir: makeDatadir(), stored: 'old-root', envPassword: 'new-root', realRoot: 'old-root' })
+ expect(err).to.equal(null)
+ expect(result).to.equal(VALID_CONTAINER_ID)
+ expect(stubs.saveDbRootPassword.calledOnceWithExactly('old-root')).to.equal(true)
+ })
+
+ it('falls back to the env override when the stored copy is refused, and stores that one', async function () {
+ const { stubs, err } = await install({ datadir: makeDatadir(), stored: 'stale-root', envPassword: 'real-root', realRoot: 'real-root' })
+ expect(err).to.equal(null)
+ expect(stubs.setDbRootPassword.calledWith('real-root')).to.equal(true)
+ expect(stubs.saveDbRootPassword.calledOnceWithExactly('real-root')).to.equal(true)
+ })
+
+ it('refuses and stores nothing when no candidate authenticates', async function () {
+ const datadir = makeDatadir()
+ const { stubs, err } = await install({ datadir, stored: 'stale-root', envPassword: 'other-root', realRoot: 'real-root' })
+ expect(err).to.be.an('error')
+ expect(err.message).to.include(datadir)
+ expect(err.message).to.include('credentials.json was left untouched')
+ expect(stubs.saveDbRootPassword.called).to.equal(false)
+ })
+
+ it('refuses and stores nothing when the new container never answers a ping', async function () {
+ const { stubs, err } = await install({ datadir: makeDatadir(), stored: 'old-root', realRoot: 'old-root', down: true })
+ expect(err).to.be.an('error')
+ expect(err.message).to.match(/did not answer a root ping/)
+ expect(stubs.saveDbRootPassword.called).to.equal(false)
+ })
+
+ it('checks nothing after the start on a fresh datadir, which takes the password it was given', async function () {
+ const { stubs, err } = await install({ datadir: makeDatadir({ initialised: false }), envPassword: 'env-root', realRoot: 'env-root' })
+ expect(err).to.equal(null)
+ expect(stubs.saveDbRootPassword.calledOnceWithExactly('env-root')).to.equal(true)
+ const pings = stubs.execFileAsync.getCalls().filter(c => c.args[1].includes('mariadb-admin'))
+ expect(pings).to.have.lengthOf(0)
+ })
+ })
+})
diff --git a/test/unit/discovery_service.test.js b/test/unit/discovery_service.test.js
index 3638d9f3..2d61ebfd 100644
--- a/test/unit/discovery_service.test.js
+++ b/test/unit/discovery_service.test.js
@@ -19,7 +19,7 @@
* image as a bare ID, so the scan went blind to a live container, and the
* orphan purge then DELETED its registry row. From then on update/start/stop
* silently no-op'd for that module while `sync` reported "already in sync".
- * Identity now comes from the CONTAINER NAME (image as fallback).
+ * Identity now comes from the CONTAINER NAME only; the image never decides it.
********************************************************************/
const sinon = require('sinon')
@@ -57,11 +57,24 @@ describe('DiscoveryService.classifyContainer', function () {
})).to.deep.equal({ module: 'xchain-indexer', coin: 'litecoin', network: 'mainnet' })
})
- it('falls back to the image name when the container was renamed externally', function () {
+ it('never classifies a container outside the naming scheme by its module image', function () {
+ // Another stack on the same host (here a compose project) runs the same
+ // module images; claiming its containers would point every command at them.
+ for (const [name, image] of [
+ ['other-stack-hub-1', 'xchain-node-xchain-hub'],
+ ['other-stack-db-1', 'xchain-node-database'],
+ ['other-stack-doge-miner-1', 'xchain-node-dogecoin-regtest-xchain-regtest-miner'],
+ ['my-custom-name', 'xchain-node-bitcoin-regtest-xchain-decoder'],
+ ]) {
+ expect(svc.classifyContainer({ Names: name, Image: image }), name).to.equal(null)
+ }
+ // This install's own names still classify whatever image they run.
+ expect(svc.classifyContainer({ Names: 'xchain-node-xchain-hub', Image: 'xchain-node-xchain-hub' }))
+ .to.deep.equal({ module: 'xchain-hub', coin: '', network: '' })
expect(svc.classifyContainer({
- Names: 'my-custom-name',
- Image: 'xchain-node-bitcoin-regtest-xchain-decoder',
- })).to.deep.equal({ module: 'xchain-decoder', coin: 'bitcoin', network: 'regtest' })
+ Names: 'xchain-node-bitcoin-regtest-xchain-utxo-tracker',
+ Image: 'xchain-node-bitcoin-regtest-xchain-utxo-tracker',
+ })).to.deep.equal({ module: 'xchain-utxo-tracker', coin: 'bitcoin', network: 'regtest' })
})
it('classifies shared modules (no coin/network)', function () {
@@ -268,3 +281,50 @@ describe('DiscoveryService.scanAndRegisterModules fail-closed registry', functio
expect(err.message).to.match(/Couldn't reconcile database/)
})
})
+
+// A container started by hand from a module image under another name is not a
+// module, so it must never take the row from the container holding the CLI name.
+describe('DiscoveryService.scanAndRegisterModules name-over-image ranking', function () {
+
+ afterEach(() => sinon.restore())
+
+ const IMAGE = 'xchain-node-bitcoin-mainnet-xchain-indexer'
+ const debug = { Names: 'some-debug-name', Image: IMAGE, ID: 'debugdebugdebugdebug', State: 'running' }
+ const real = { Names: IMAGE, Image: IMAGE, ID: 'realrealrealrealreal', State: 'running' }
+
+ it('gives the row to the named container when a newer image-only container also runs', async function () {
+ const { svc, db } = loadService({ containers: [debug, real] })
+ await svc.scanAndRegisterModules({ silent: true })
+ sinon.assert.calledWith(db.setModuleContainer, 'xchain-indexer', 'bitcoin', 'mainnet', 'realrealrealrealreal')
+ sinon.assert.neverCalledWith(db.setModuleContainer, 'xchain-indexer', 'bitcoin', 'mainnet', 'debugdebugdebugdebug')
+ })
+
+ it('leaves a row already holding the named container alone', async function () {
+ const { svc, db } = loadService({
+ containers: [debug, real],
+ db: {
+ getModuleContainer: sinon.stub().resolves('realrealrealrealreal'),
+ getAllModuleContainers: sinon.stub().resolves([
+ { module: 'xchain-indexer', coin: 'bitcoin', network: 'mainnet', container_id: 'realrealrealrealreal' },
+ ]),
+ },
+ })
+ const changed = await svc.scanAndRegisterModules({ silent: true })
+ expect(changed).to.equal(0)
+ sinon.assert.notCalled(db.setModuleContainer)
+ })
+
+ it('keeps the row on a stopped named container over a running image-only one', async function () {
+ const { svc, db } = loadService({ containers: [debug, { ...real, State: 'exited' }] })
+ await svc.scanAndRegisterModules({ silent: true })
+ sinon.assert.calledOnceWithExactly(db.setModuleContainer, 'xchain-indexer', 'bitcoin', 'mainnet', 'realrealrealrealreal')
+ })
+
+ it('registers nothing when only containers outside the naming scheme run module images', async function () {
+ const stale = { Names: 'renamed-old', Image: IMAGE, ID: 'staleSTALEstaleSTALE', State: 'exited' }
+ const live = { Names: 'other-stack-indexer-1', Image: IMAGE, ID: 'liveLIVEliveLIVElive', State: 'running' }
+ const { svc, db } = loadService({ containers: [stale, live] })
+ await svc.scanAndRegisterModules({ silent: true })
+ sinon.assert.notCalled(db.setModuleContainer)
+ })
+})
diff --git a/test/unit/downgrade_floor.test.js b/test/unit/downgrade_floor.test.js
new file mode 100644
index 00000000..d8c0fa75
--- /dev/null
+++ b/test/unit/downgrade_floor.test.js
@@ -0,0 +1,144 @@
+'use strict'
+
+const sinon = require('sinon')
+const { expect } = require('chai')
+const proxyquire = require('proxyquire').noCallThru()
+
+const selfUpdate = require('../../src/services/self_update_service')
+
+function testLogger() {
+ return { log: sinon.stub(), warn: sinon.stub(), error: sinon.stub() }
+}
+
+function loadManifestService(axiosGet = sinon.stub()) {
+ return proxyquire('../../src/services/release_manifest_service', {
+ axios: { get: axiosGet },
+ './release_signature_service': { verifyManifestForTag: sinon.stub().resolves({ verified: true }) }
+ })
+}
+
+afterEach(function () {
+ sinon.restore()
+})
+
+describe('major-version downgrade floor: self-update', function () {
+ it('refuses to move a 1.0.0 CLI to release v0.23.0 before touching git', async function () {
+ const describeCarrier = sinon.stub().resolves({ isRepo: true, commit: 'a'.repeat(40), dirty: [] })
+
+ await selfUpdate.selfUpdateAndReexec({
+ tag: 'v0.23.0',
+ childArgs: ['update'],
+ deps: {
+ env: {},
+ logger: testLogger(),
+ currentVersion: () => '1.0.0',
+ describeCarrier
+ }
+ }).then(
+ () => { throw new Error('should have rejected') },
+ err => {
+ expect(err.message).to.match(/major version 0 is below.*major version 1/)
+ expect(err.message).to.include('XCHAIN_NODE_ALLOW_MAJOR_DOWNGRADE=1')
+ expect(err.message).to.include('Nothing was changed')
+ }
+ )
+
+ expect(describeCarrier.called).to.equal(false)
+ })
+
+ it('does not turn a same-major version decrease into a refusal', function () {
+ expect(() => selfUpdate.assertMajorDowngradeAllowed('1.9.0', 'v1.0.0')).not.to.throw()
+ })
+
+})
+
+describe('major-version downgrade floor: manifest refusals', function () {
+ it('refuses an explicit lower-major install before fetching its manifest', async function () {
+ const axiosGet = sinon.stub()
+ const service = loadManifestService(axiosGet)
+
+ await service.resolveInstallTarget('v0.23.0', {
+ env: {},
+ logger: testLogger(),
+ installedVersion: '1.0.0'
+ }).then(
+ () => { throw new Error('should have rejected') },
+ err => expect(err.message).to.match(/Refusing release v0\.23\.0/)
+ )
+
+ expect(axiosGet.called).to.equal(false)
+ })
+
+ it('refuses a latest lower-major update before fetching its manifest', async function () {
+ const axiosGet = sinon.stub().resolves({ data: { tag_name: 'v0.23.0' } })
+ const service = loadManifestService(axiosGet)
+
+ await service.resolveInstallTarget(null, {
+ fallbackToBranch: false,
+ env: {},
+ logger: testLogger(),
+ installedVersion: '1.0.0'
+ }).then(
+ () => { throw new Error('should have rejected') },
+ err => expect(err.message).to.match(/Refusing release v0\.23\.0/)
+ )
+
+ expect(axiosGet.calledOnce).to.equal(true)
+ })
+
+})
+
+describe('major-version downgrade floor: overrides', function () {
+ it('allows the self-update override and announces it even when self-update is disabled', async function () {
+ const logger = testLogger()
+ const out = await selfUpdate.selfUpdateAndReexec({
+ tag: 'v0.23.0',
+ childArgs: ['update'],
+ deps: {
+ env: {
+ XCHAIN_NODE_ALLOW_MAJOR_DOWNGRADE: '1',
+ XCHAIN_NODE_NO_SELF_UPDATE: '1'
+ },
+ logger,
+ currentVersion: () => '1.0.0'
+ }
+ })
+
+ expect(out.reason).to.equal('disabled')
+ expect(logger.warn.calledOnce).to.equal(true)
+ expect(logger.warn.firstCall.args[0]).to.include('major-version downgrade protection is disabled')
+ })
+
+ it('announces the manifest override on every target-resolution run', async function () {
+ const service = loadManifestService()
+ const logger = testLogger()
+ const options = {
+ env: { XCHAIN_NODE_ALLOW_MAJOR_DOWNGRADE: 'yes' },
+ logger,
+ installedVersion: '1.0.0'
+ }
+
+ await service.resolveInstallTarget('develop', options)
+ await service.resolveInstallTarget('master', options)
+
+ expect(logger.warn.callCount).to.equal(2)
+ expect(logger.warn.alwaysCalledWithMatch(/XCHAIN_NODE_ALLOW_MAJOR_DOWNGRADE=1/)).to.equal(true)
+ })
+
+ it('allows a lower-major release target when the manifest override is set', async function () {
+ // The bundled manifest's own release resolves without a network read, so
+ // the target follows that manifest across release bumps.
+ const bundledTag = `v${require('../../src/release-manifest.json').platform_version}`
+ const service = loadManifestService()
+ const logger = testLogger()
+ const target = await service.resolveInstallTarget(bundledTag, {
+ env: { XCHAIN_NODE_ALLOW_MAJOR_DOWNGRADE: 'true' },
+ logger,
+ installedVersion: '1.0.0'
+ })
+
+ expect(target.kind).to.equal('release')
+ expect(target.tag).to.equal(bundledTag)
+ expect(logger.warn.calledOnce).to.equal(true)
+ })
+})
diff --git a/test/unit/migration_precondition_configured_database_name.test.js b/test/unit/migration_precondition_configured_database_name.test.js
new file mode 100644
index 00000000..535a0cb6
--- /dev/null
+++ b/test/unit/migration_precondition_configured_database_name.test.js
@@ -0,0 +1,57 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// The precondition guard must read the ledger of the operator-configured
+// database, not the derived default name.
+
+const sinon = require('sinon')
+const { expect } = require('chai')
+
+const { XChainService } = require('../../src/config')
+const { assertRequiredMigrationsApplied } = require('../../src/services/migration_precondition_service')
+const { getModuleDatabaseName } = require('../../src/services/config_service')
+
+const GATED = '2026-07-24-pubkeys-widen-uncompressed.sql'
+
+function deps(cfg) {
+ return {
+ cloneGit: sinon.stub().resolves(),
+ listDeployPreconditionMigrations: sinon.stub().returns([GATED]),
+ readAppliedMigrations: sinon.stub().resolves({ state: 'ledger', applied: new Set([GATED]) }),
+ getDefaultConfig: sinon.stub().resolves(cfg)
+ }
+}
+
+describe('migration precondition configured database name', () => {
+ it('reads the ledger of INDEXER_DB_NAME when configured', async () => {
+ const d = deps({ INDEXER_DB_NAME: 'custom_idx' })
+ await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', null, d)
+ expect(d.readAppliedMigrations.firstCall.args[0].database).to.equal('custom_idx')
+ })
+
+ it('reads the ledger of DECODER_DB_NAME when configured', async () => {
+ const d = deps({ DECODER_DB_NAME: 'custom_dec' })
+ await assertRequiredMigrationsApplied(XChainService.XCHAIN_DECODER, 'bitcoin', 'mainnet', null, d)
+ expect(d.readAppliedMigrations.firstCall.args[0].database).to.equal('custom_dec')
+ })
+
+ it('falls back to the derived default name when none is configured', async () => {
+ const d = deps({})
+ await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', null, d)
+ expect(d.readAppliedMigrations.firstCall.args[0].database)
+ .to.equal(getModuleDatabaseName(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet'))
+ })
+
+ it('names the configured database in the refusal', async () => {
+ const d = deps({ INDEXER_DB_NAME: 'custom_idx' })
+ d.readAppliedMigrations.resolves({ state: 'unreadable', reason: 'down' })
+ try {
+ await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', null, d)
+ expect.fail('should refuse')
+ } catch (err) {
+ expect(err.message).to.contain('custom_idx')
+ }
+ })
+})
diff --git a/test/unit/migration_precondition_service.test/10_refusal_lists_recorded_ledger.test.js b/test/unit/migration_precondition_service.test/10_refusal_lists_recorded_ledger.test.js
new file mode 100644
index 00000000..40fb48f6
--- /dev/null
+++ b/test/unit/migration_precondition_service.test/10_refusal_lists_recorded_ledger.test.js
@@ -0,0 +1,112 @@
+'use strict'
+
+// Copyright © 2025-2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC - https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// An indexer database can embody a manual migration's end state with no ledger
+// row for it. That counts as applied when deciding what is missing, but the
+// indexer runner decides from the ledger, so an unscoped migrate run still executes
+// the file (unless its MIGRATION_PRECONDITIONS entry baselines it, which this
+// fixture's file has none of): the refusal's "would apply" list has to name it.
+
+const sinon = require('sinon')
+const { expect } = require('chai')
+
+const { XChainService } = require('../../../src/config')
+const { assertRequiredMigrationsApplied } = require('../../../src/services/migration_precondition_service')
+const { readAppliedMigrations } = require('../../../src/services/migration_precondition_service/migration_scan')
+
+const REQUIRED = '2026-09-13-gated.sql'
+const RECORDED = '2026-05-01-recorded.sql'
+const SATISFIED = '2026-07-15-markets-dedup-unique-pair.sql'
+
+// Batch-mode answers by query: the counts, the ledger-plus-schema read, and the ledger-only read.
+function runnerFor({ union, ledger, ledgerThrows = false }) {
+ return async (sql) => {
+ if (/TABLE_NAME = 'schema_migrations'/.test(sql)) return '1'
+ if (/COUNT\(\*\)/.test(sql)) return '40'
+ if (/ UNION /.test(sql)) return union.join('\n')
+ if (ledgerThrows) throw new Error('Lost connection')
+ return ledger.join('\n')
+ }
+}
+
+function guardDeps({ applied, ledger, pendingManual }) {
+ return {
+ cloneGit: sinon.stub().resolves(),
+ listDeployPreconditionMigrations: sinon.stub().returns([REQUIRED]),
+ readAppliedMigrations: sinon.stub().resolves({ state: 'ledger', applied: new Set(applied),
+ ...(ledger ? { ledger: new Set(ledger) } : {}) }),
+ runningBuildSupportsPerFileMigrations: sinon.stub().resolves(false),
+ pendingManualMigrations: sinon.stub().returns(pendingManual),
+ readLedgerRenames: sinon.stub().returns({ state: 'absent', renames: {} })
+ }
+}
+
+async function refusalOf(deps) {
+ try {
+ await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', 'master', deps)
+ } catch (err) { return err }
+ return null
+}
+
+describe('migration precondition guard: refusal lists against the recorded ledger', () => {
+ let warn
+ beforeEach(() => { warn = sinon.stub(console, 'warn') })
+ afterEach(() => { warn.restore() })
+
+ describe('readAppliedMigrations', () => {
+ const indexer = { database: 'XChain_BTC_Mainnet_Indexer', coin: 'bitcoin', network: 'mainnet' }
+
+ it('keeps the recorded names apart from the schema-satisfied ones on an indexer database', async () => {
+ const res = await readAppliedMigrations(indexer, { runner: runnerFor({ union: [RECORDED, SATISFIED], ledger: [RECORDED] }) })
+ expect(res.state).to.equal('ledger')
+ expect([...res.applied]).to.have.members([RECORDED, SATISFIED])
+ expect([...res.ledger]).to.have.members([RECORDED])
+ })
+
+ it('reads a decoder ledger once and reports it as both sets', async () => {
+ const decoder = { database: 'XChain_BTC_Mainnet_Decoder', coin: 'bitcoin', network: 'mainnet' }
+ const res = await readAppliedMigrations(decoder, { runner: async (sql) =>
+ /TABLE_NAME = 'schema_migrations'/.test(sql) ? '1' : /COUNT\(\*\)/.test(sql) ? '40' : RECORDED })
+ expect([...res.applied]).to.have.members([RECORDED])
+ expect(res.ledger).to.equal(res.applied)
+ })
+
+ it('reports a failed ledger-only read as null without failing the applied read', async () => {
+ const res = await readAppliedMigrations(indexer,
+ { runner: runnerFor({ union: [RECORDED, SATISFIED], ledger: [], ledgerThrows: true }) })
+ expect(res.state).to.equal('ledger')
+ expect([...res.applied]).to.have.members([RECORDED, SATISFIED])
+ expect(res.ledger).to.equal(null)
+ })
+ })
+
+ describe('assertRequiredMigrationsApplied', () => {
+ it('lists a schema-satisfied file with no ledger row among what an unscoped run applies', async () => {
+ const deps = guardDeps({ applied: [RECORDED, SATISFIED], ledger: [RECORDED], pendingManual: [REQUIRED, SATISFIED] })
+ const err = await refusalOf(deps)
+ expect(err).to.be.an('error')
+ const subtracted = deps.pendingManualMigrations.firstCall.args[1]
+ expect(subtracted.has(RECORDED)).to.equal(true)
+ expect(subtracted.has(SATISFIED)).to.equal(false)
+ expect(err.message).to.contain('which is 2 file(s)')
+ expect(err.message).to.contain(SATISFIED + ' (the live schema already matches it; no ledger row records it)')
+ expect(err.message).to.contain(REQUIRED + ' (the one you need)')
+ })
+
+ it('still passes a required file the live schema already satisfies', async () => {
+ const deps = guardDeps({ applied: [RECORDED, REQUIRED], ledger: [RECORDED], pendingManual: [] })
+ expect(await refusalOf(deps)).to.equal(null)
+ })
+
+ it('falls back to the applied set when no recorded ledger was read', async () => {
+ const deps = guardDeps({ applied: [RECORDED, SATISFIED], pendingManual: [REQUIRED] })
+ const err = await refusalOf(deps)
+ expect(err).to.be.an('error')
+ expect(deps.pendingManualMigrations.firstCall.args[1].has(SATISFIED)).to.equal(true)
+ })
+ })
+})
diff --git a/test/unit/module_database_name.test.js b/test/unit/module_database_name.test.js
index 6ca6657c..53a29ac4 100644
--- a/test/unit/module_database_name.test.js
+++ b/test/unit/module_database_name.test.js
@@ -12,7 +12,7 @@
const { expect } = require('chai')
const { XChainService } = require('../../src/config')
-const { configuredDatabaseName } = require('../../src/utils/module_database_name')
+const { configuredDatabaseName, nodePrefixSegment } = require('../../src/utils/module_database_name')
const DEFAULT_DECODER = 'XChain_BTC_Mainnet_Decoder'
const DEFAULT_INDEXER = 'XChain_BTC_Mainnet_Indexer'
@@ -45,3 +45,13 @@ describe('configuredDatabaseName()', function () {
.to.throw(/Unsafe MariaDB database name/)
})
})
+
+describe('nodePrefixSegment()', function () {
+ it('is empty for the default prefix', function () {
+ expect(nodePrefixSegment('xchain-node', 'xchain-node')).to.equal('')
+ })
+
+ it('folds non-alphanumerics to underscores and appends the separator', function () {
+ expect(nodePrefixSegment('my-stack.1', 'xchain-node')).to.equal('my_stack_1_')
+ })
+})
diff --git a/test/unit/module_database_name_prefix_scope.test.js b/test/unit/module_database_name_prefix_scope.test.js
new file mode 100644
index 00000000..7a49cbe6
--- /dev/null
+++ b/test/unit/module_database_name_prefix_scope.test.js
@@ -0,0 +1,222 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+const { expect } = require('chai')
+const path = require('path')
+
+const SRC = path.resolve(__dirname, '../../src') + path.sep
+
+// NODE_PREFIX is read once when src/config loads, so a prefixed stack needs a fresh load
+// of every src module. Snapshot the require cache and the env var, then put both back so
+// later suites keep their default-prefix singletons.
+function isSrc(id) { return id.startsWith(SRC) }
+
+const { scopedNodePrefixSegment, DB_NAME_MAX, HUB_MIRROR_SUFFIX, unscopedDefaultDatabaseName } = require('../../src/utils/module_database_name')
+const seg = scopedNodePrefixSegment('stack-b.test', 'xchain-node', '_')
+const scopedDecoder = 'XChain_' + seg + 'BTC_Mainnet_Decoder'
+const scopedDecoderUser = 'xchain_' + seg + 'decoder_bitcoin_mainnet'
+const scopedIndexerUser = 'xchain_' + seg + 'indexer_bitcoin_mainnet'
+
+describe('prefix segment budget', function () {
+ it('is empty for the default prefix', function () {
+ expect(scopedNodePrefixSegment('xchain-node', 'xchain-node')).to.equal('')
+ })
+ it('keeps prefixes that fold to the same text distinct', function () {
+ expect(scopedNodePrefixSegment('a-b', 'x')).to.not.equal(scopedNodePrefixSegment('a_b', 'x'))
+ expect(scopedNodePrefixSegment('stack-b.test', 'x')).to.not.equal(scopedNodePrefixSegment('stack_b_test', 'x'))
+ })
+ it('keeps the longest indexer name plus _HubMirror within 64 characters', function () {
+ const long = 'a-very-long-node-prefix-that-overflows-every-budget'
+ const name = unscopedDefaultDatabaseName('xchain-indexer', 'DOGE', 'testnet').replace('XChain_', 'XChain_' + scopedNodePrefixSegment(long, 'x'))
+ expect(name.length + HUB_MIRROR_SUFFIX.length).to.be.at.most(DB_NAME_MAX)
+ expect(scopedNodePrefixSegment(long, 'x')).to.not.equal(scopedNodePrefixSegment(long + '2', 'x'))
+ })
+})
+
+const EXTERNAL_ENV = { XCHAIN_NODE_EXTERNAL_DB: '1', XCHAIN_NODE_EXTERNAL_DB_HOST: '127.0.0.1',
+ XCHAIN_NODE_EXTERNAL_DB_PORT: '3306', XCHAIN_NODE_EXTERNAL_DB_ROOT_USER: 'root',
+ XCHAIN_NODE_EXTERNAL_DB_ROOT_PASSWORD: 'unit-test-only' }
+const prefixState = {}
+
+// Reload every src module under NODE_PREFIX=stack-b.test in external-DB mode, saving what it replaces.
+function loadPrefixedModules() {
+ prefixState.savedPrefix = process.env.NODE_PREFIX
+ prefixState.savedCache = {}
+ for (const id of Object.keys(require.cache)) {
+ if (isSrc(id)) { prefixState.savedCache[id] = require.cache[id]; delete require.cache[id] }
+ }
+ prefixState.savedExternal = {}
+ for (const k of Object.keys(EXTERNAL_ENV)) prefixState.savedExternal[k] = process.env[k]
+ process.env.NODE_PREFIX = 'stack-b.test'
+ Object.assign(process.env, EXTERNAL_ENV)
+ prefixState.prefixed = require('../../src/services/config_service')
+}
+
+// Put the env and the default-prefix module cache back.
+function restoreDefaultModules() {
+ if (prefixState.savedPrefix === undefined) delete process.env.NODE_PREFIX
+ else process.env.NODE_PREFIX = prefixState.savedPrefix
+ for (const [k, v] of Object.entries(prefixState.savedExternal)) {
+ if (v === undefined) delete process.env[k]
+ else process.env[k] = v
+ }
+ for (const id of Object.keys(require.cache)) {
+ if (isSrc(id)) delete require.cache[id]
+ }
+ Object.assign(require.cache, prefixState.savedCache)
+}
+
+// The deploy path refuses a scoped stack's unprefixed default names before any other step.
+function legacyCollisionSuite(getPrefixed) {
+ const legacy = { 'xchain-decoder': ['DECODER_DB_NAME', 'XChain_BTC_Mainnet_Decoder'],
+ 'xchain-indexer': ['INDEXER_DB_NAME', 'XChain_BTC_Mainnet_Indexer'] }
+ const reached = new Error('past the refusal')
+
+ function wire(envFor) {
+ const { configureDependencies, buildAndUp } = require('../../src/services/module_service/build_and_up')
+ configureDependencies({
+ checkIfModuleExists: () => true, checkBuildKitAvailable: async () => {},
+ getDefaultConfig: async (m) => envFor(m), getModuleDir: () => '/nonexistent',
+ getDockerContainerImageName: (m) => m, config: require('../../src/config'),
+ execFileAsync: async () => ({ stdout: '' }),
+ // First step after the refusal in assertDeploymentReady; proves ordering.
+ goLiveGate: { assertGoLiveReady: () => { throw reached } }
+ })
+ return buildAndUp
+ }
+
+ for (const module of Object.keys(legacy)) {
+ it('refuses the unprefixed default name for ' + module + ' before any other deploy step', async function () {
+ const [key, name] = legacy[module]
+ const buildAndUp = wire(() => ({ [key]: name }))
+ let err
+ try { await buildAndUp(module, 'bitcoin', 'mainnet') } catch (e) { err = e }
+ expect(err).to.be.instanceOf(Error)
+ expect(err).to.not.equal(reached)
+ expect(err.message).to.include(key + '=' + name)
+ })
+ it('lets the scoped name for ' + module + ' through to the next deploy step', async function () {
+ const [key] = legacy[module]
+ const cfg = (await getPrefixed().getDefaultConfig(module, 'bitcoin', 'mainnet'))
+ const buildAndUp = wire(() => ({ [key]: cfg[key] }))
+ let err
+ try { await buildAndUp(module, 'bitcoin', 'mainnet') } catch (e) { err = e }
+ expect(err).to.equal(reached)
+ })
+ }
+
+ it('does not refuse other modules', async function () {
+ const buildAndUp = wire(() => ({}))
+ let err
+ try { await buildAndUp('xchain-encoder', 'bitcoin', 'mainnet') } catch (e) { err = e }
+ expect(err).to.equal(reached)
+ })
+}
+
+// The migration precheck warns on an unprefixed default name under a non-default prefix.
+function defaultNamePrecheckSuite() {
+ it('warns when the configured name is the unprefixed default', async function () {
+ const { getLogger } = require('../../src/observability/logger')
+ const logger = getLogger()
+ const original = logger.warn
+ const warnings = []
+ logger.warn = (m) => { warnings.push(String(m)) }
+ try {
+ const { resolveCheckedDatabaseName } = require('../../src/services/migration_precondition_service')
+ const name = await resolveCheckedDatabaseName('xchain-decoder', 'bitcoin', 'mainnet',
+ { getDefaultConfig: async () => ({ DECODER_DB_NAME: 'XChain_BTC_Mainnet_Decoder' }) })
+ expect(name).to.equal('XChain_BTC_Mainnet_Decoder')
+ expect(warnings.some(w => w.includes('unprefixed default database'))).to.equal(true)
+ warnings.length = 0
+ await resolveCheckedDatabaseName('xchain-decoder', 'bitcoin', 'mainnet',
+ { getDefaultConfig: async () => ({ DECODER_DB_NAME: scopedDecoder }) })
+ expect(warnings.some(w => w.includes('unprefixed default database'))).to.equal(false)
+ } finally { logger.warn = original }
+ })
+}
+
+describe('module database names under a non-default NODE_PREFIX', function () {
+ before(loadPrefixedModules)
+ after(restoreDefaultModules)
+
+ it('carries the sanitized prefix in the derived database name', function () {
+ expect(prefixState.prefixed.getModuleDatabaseName('xchain-decoder', 'bitcoin', 'mainnet'))
+ .to.equal(scopedDecoder)
+ })
+
+ it('carries the prefix in the module DB user names', async function () {
+ const decoder = await prefixState.prefixed.getDefaultConfig('xchain-decoder', 'bitcoin', 'mainnet')
+ const indexer = await prefixState.prefixed.getDefaultConfig('xchain-indexer', 'bitcoin', 'mainnet')
+ expect(decoder.DECODER_DB_USER).to.equal(scopedDecoderUser)
+ expect(indexer.INDEXER_DB_USER).to.equal(scopedIndexerUser)
+ expect(decoder.DECODER_DB_NAME).to.equal(scopedDecoder)
+ })
+
+ it('lets a configured name win over the scoped default in reset targets', async function () {
+ const { configuredDatabaseName } = require('../../src/utils/module_database_name')
+ const cs = prefixState.prefixed
+ const def = cs.getModuleDatabaseName('xchain-decoder', 'bitcoin', 'mainnet')
+ expect(configuredDatabaseName('xchain-decoder', { DECODER_DB_NAME: 'Custom_Dec' }, def)).to.equal('Custom_Dec')
+ expect(configuredDatabaseName('xchain-decoder', {}, def)).to.equal(def)
+ })
+
+ it('resolves reset targets through the configured name, not the raw derived name', async function () {
+ const { resolveResetTargets } = require('../../src/services/database_service/reset_databases')
+ const targets = await resolveResetTargets('bitcoin', 'mainnet', ['xchain-decoder', 'xchain-indexer'])
+ expect(targets).to.deep.equal([scopedDecoder, 'XChain_' + seg + 'BTC_Mainnet_Indexer'])
+ expect(targets[0]).to.not.equal('XChain_BTC_Mainnet_Decoder')
+ })
+
+ describe('legacy-collision refusal in buildAndUp', () => legacyCollisionSuite(() => prefixState.prefixed))
+
+ describe('precheck for default names under a non-default prefix', defaultNamePrecheckSuite)
+
+ it('restores the default-prefix modules after the suite', function () {
+ // Runs while the prefixed load is still cached; the real check is that after()
+ // reinstates the snapshot, asserted by the sibling describe below.
+ expect(process.env.NODE_PREFIX).to.equal('stack-b.test')
+ })
+})
+
+describe('module database names after the prefix suite', function () {
+ it('leave the default-prefix names unchanged and the env restored', async function () {
+ const cs = require('../../src/services/config_service')
+ expect(cs.getModuleDatabaseName('xchain-decoder', 'bitcoin', 'mainnet')).to.equal('XChain_BTC_Mainnet_Decoder')
+ const cfg = await cs.getDefaultConfig('xchain-decoder', 'bitcoin', 'mainnet')
+ expect(cfg.DECODER_DB_USER).to.equal('xchain_decoder_bitcoin_mainnet')
+ })
+})
+
+describe('raw getModuleDatabaseName callers', function () {
+ const fs = require('fs')
+ const files = []
+ ;(function walk(dir) {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, e.name)
+ if (e.isDirectory()) walk(full)
+ else if (e.name.endsWith('.js')) files.push(full)
+ }
+ })(SRC)
+ const allowed = new Set(['services/config_service.js', 'services/config_service/defaults.js'])
+
+ it('resolve the name through configuredDatabaseName wherever they call it', function () {
+ const offenders = []
+ for (const file of files) {
+ const rel = path.relative(SRC, file)
+ if (allowed.has(rel)) continue
+ const text = fs.readFileSync(file, 'utf8')
+ const calls = text.split('\n').filter(l => /getModuleDatabaseName\(/.test(l) && !/^\s*(\/\/|\*)/.test(l))
+ if (calls.length > 0 && !/configuredDatabaseName\(/.test(text)) offenders.push(rel)
+ }
+ expect(offenders).to.deep.equal([])
+ })
+})
diff --git a/test/unit/module_operations.test/03_module_controls.test.js b/test/unit/module_operations.test/03_module_controls.test.js
index 9fb2a82a..8fca6772 100644
--- a/test/unit/module_operations.test/03_module_controls.test.js
+++ b/test/unit/module_operations.test/03_module_controls.test.js
@@ -103,13 +103,13 @@ describe('moduleOperations', function () {
const stubs = makeStubs()
const ops = loadOperations(stubs)
await ops.startModules({ bitcoin: { mainnet: ['xchain-encoder'] } })
- expect(stubs.db.getModuleContainer.calledOnce).to.be.true
+ expect(stubs.db.getModuleContainerStrict.calledOnce).to.be.true
expect(stubs.startContainer.calledWith('container-id-123')).to.be.true
})
it('skips module when container ID is not found', async function () {
const stubs = makeStubs()
- stubs.db.getModuleContainer.resolves(null)
+ stubs.db.getModuleContainerStrict.resolves(null)
const ops = loadOperations(stubs)
const result = await ops.startModules({ bitcoin: { mainnet: ['xchain-encoder'] } })
expect(result).to.be.true
@@ -135,8 +135,8 @@ describe('moduleOperations', function () {
const stubs = makeStubs()
const ops = loadOperations(stubs)
await ops.stopModules({ bitcoin: { mainnet: ['xchain-encoder'] } })
- expect(stubs.stopContainerByName.calledWith('container-id-123', 30)).to.be.true
- expect(stubs.stopContainer.called).to.be.false
+ expect(stubs.stopContainer.calledOnceWithExactly('container-id-123', 30)).to.be.true
+ expect(stubs.stopContainerByName.called).to.be.false
})
it('reads the container before the stop so a drain that predates the budget is named', async function () {
@@ -146,7 +146,7 @@ describe('moduleOperations', function () {
try {
await loadOperations(stubs).stopModules({ bitcoin: { mainnet: ['xchain-decoder'] } })
expect(stubs.getContainerStopSettings.calledOnceWith('container-id-123')).to.be.true
- expect(stubs.getContainerStopSettings.calledBefore(stubs.stopContainerByName)).to.be.true
+ expect(stubs.getContainerStopSettings.calledBefore(stubs.stopContainer)).to.be.true
expect(warn.args.some(a => /xchain-decoder \(bitcoin mainnet\) was created under a 300 s stop budget/.test(String(a[0])))).to.be.true
} finally {
warn.restore()
@@ -172,7 +172,11 @@ describe('moduleOperations', function () {
const stubs = makeStubs()
const ops = loadOperations(stubs)
await ops.restartModules({ bitcoin: { mainnet: ['xchain-encoder'] } })
- expect(stubs.restartContainer.calledWith('container-id-123')).to.be.true
+ // A budgeted stop then a start, never docker's bare restart and its ten second default.
+ expect(stubs.stopContainer.firstCall.args).to.deep.equal(['container-id-123', 30])
+ expect(stubs.startContainer.calledOnceWith('container-id-123')).to.be.true
+ expect(stubs.stopContainer.calledBefore(stubs.startContainer)).to.be.true
+ expect(stubs.restartContainer.called).to.be.false
expect(stubs.statusChanged.calledOnce).to.be.true
})
})
diff --git a/test/unit/module_operations.test/17_module_error_paths.test.js b/test/unit/module_operations.test/17_module_error_paths.test.js
index ff57812a..aa7ead5d 100644
--- a/test/unit/module_operations.test/17_module_error_paths.test.js
+++ b/test/unit/module_operations.test/17_module_error_paths.test.js
@@ -105,10 +105,13 @@ describe('moduleOperations', function () {
it('continues after restartContainer error', async function () {
const stubs = makeStubs()
- stubs.restartContainer.rejects(new Error('restart failed'))
+ stubs.stopContainer.onFirstCall().rejects(new Error('restart failed'))
const ops = loadOperations(stubs)
- const result = await ops.restartModules({ bitcoin: { mainnet: ['xchain-encoder', 'xchain-decoder'] } })
- expect(result).to.be.true
+ const err = await ops.restartModules({ bitcoin: { mainnet: ['xchain-encoder', 'xchain-decoder'] } }).then(() => null, e => e)
+ expect(stubs.stopContainer.callCount, 'the second module is still attempted').to.equal(2)
+ expect(stubs.startContainer.callCount, 'a failed stop is never followed by a start').to.equal(1)
+ expect(err, 'a failed restart must not resolve').to.be.an('error')
+ expect(err.message).to.match(/restart failed for 1 module: xchain-encoder \(bitcoin mainnet\): restart failed/)
})
})
})
@@ -128,7 +131,7 @@ describe('moduleOperations', function () {
it('skips when container ID is not found', async function () {
const stubs = makeStubs()
- stubs.db.getModuleContainer.resolves(null)
+ stubs.db.getModuleContainerStrict.resolves(null)
const ops = loadOperations(stubs)
const result = await ops.stopModules({ bitcoin: { mainnet: ['xchain-encoder'] } })
expect(result).to.be.true
@@ -137,10 +140,12 @@ describe('moduleOperations', function () {
it('continues after stopContainer error', async function () {
const stubs = makeStubs()
- stubs.stopContainer.rejects(new Error('stop failed'))
+ stubs.stopContainer.onFirstCall().rejects(new Error('stop failed'))
const ops = loadOperations(stubs)
- const result = await ops.stopModules({ bitcoin: { mainnet: ['xchain-encoder'] } })
- expect(result).to.be.true
+ const err = await ops.stopModules({ bitcoin: { mainnet: ['xchain-encoder', 'xchain-decoder'] } }).then(() => null, e => e)
+ expect(stubs.stopContainer.callCount, 'the second module is still attempted').to.equal(2)
+ expect(err, 'a failed stop must not resolve').to.be.an('error')
+ expect(err.message).to.match(/stop failed for 1 module: xchain-encoder \(bitcoin mainnet\): stop failed/)
})
})
})
@@ -156,10 +161,12 @@ describe('moduleOperations', function () {
it('continues after startContainer error', async function () {
const stubs = makeStubs()
- stubs.startContainer.rejects(new Error('start failed'))
+ stubs.startContainer.onFirstCall().rejects(new Error('start failed'))
const ops = loadOperations(stubs)
- const result = await ops.startModules({ bitcoin: { mainnet: ['xchain-encoder'] } })
- expect(result).to.be.true
+ const err = await ops.startModules({ bitcoin: { mainnet: ['xchain-encoder', 'xchain-decoder'] } }).then(() => null, e => e)
+ expect(stubs.startContainer.callCount, 'the second module is still attempted').to.equal(2)
+ expect(err, 'a failed start must not resolve').to.be.an('error')
+ expect(err.message).to.match(/start failed for 1 module: xchain-encoder \(bitcoin mainnet\): start failed/)
})
})
})
@@ -223,21 +230,28 @@ describe('moduleOperations', function () {
it('reports a bounce restart that fails twice instead of returning success', async function () {
const stubs = makeStubs()
stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', ''))
- stubs.restartContainer.rejects(new Error('docker restart: container failed to start'))
- const outcome = await runBounceReset(stubs)
+ let startsBefore = 0
+ const outcome = await runBounceReset(stubs, { duringSettle: () => {
+ startsBefore = stubs.startContainer.callCount
+ stubs.startContainer.rejects(new Error('docker start: container failed to start'))
+ } })
expect(outcome.error, 'reset must not resolve with a bounced service down').to.be.an('error')
expect(outcome.error.message).to.match(/failed to restart/)
- expect(outcome.error.message).to.match(/post-reset restart failed \(docker restart: container failed to start\)/)
+ expect(outcome.error.message).to.match(/post-reset restart failed \(xchain-\w+ was stopped but failed to start again \(docker start: container failed to start\)\)/)
// One try plus one retry per bounced module (decoder and indexer).
- expect(stubs.restartContainer.callCount).to.equal(4)
+ expect(stubs.startContainer.callCount - startsBefore).to.equal(4)
})
it('recovers silently when the retry succeeds', async function () {
const stubs = makeStubs()
stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', ''))
- stubs.restartContainer.onCall(0).rejects(new Error('transient'))
- stubs.restartContainer.resolves(true)
- const outcome = await runBounceReset(stubs)
+ let stopsBefore = 0
+ const outcome = await runBounceReset(stubs, { duringSettle: () => {
+ stopsBefore = stubs.stopContainer.callCount
+ stubs.stopContainer.onCall(stopsBefore).rejects(new Error('transient'))
+ } })
+ // The first bounce stop failed and was retried once; the second module bounced first time.
+ expect(stubs.stopContainer.callCount - stopsBefore).to.equal(3)
expect(outcome.error).to.equal(undefined)
expect(outcome.value).to.equal(true)
})
@@ -246,16 +260,66 @@ describe('moduleOperations', function () {
const stubs = makeStubs()
stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', ''))
let bouncing = false
+ let stopsBefore = 0
stubs.db.getModuleContainerStrict.callsFake(async () => {
if (bouncing) throw new Error('ER_LOCK_WAIT_TIMEOUT')
return 'container-id-123'
})
- const outcome = await runBounceReset(stubs, { duringSettle: () => { bouncing = true } })
+ const outcome = await runBounceReset(stubs, { duringSettle: () => {
+ bouncing = true
+ stopsBefore = stubs.stopContainer.callCount
+ } })
expect(stubs.startContainer.called, 'the start pass must finish before the flip').to.equal(true)
expect(outcome.error).to.be.an('error')
expect(outcome.error.message).to.match(/registry lookup failed before the post-reset bounce/)
- expect(stubs.restartContainer.called).to.equal(false)
+ expect(stubs.stopContainer.callCount, 'no bounce stop after a failed registry read').to.equal(stopsBefore)
})
})
})
})
+
+// start, stop and restart attempt every selected module and then fail together,
+// so a runbook can trust the exit status the CLI derives from the rejection.
+describe('moduleOperations', function () {
+ registerLifecycleHooks(() => {})
+
+ describe('start/stop/restart: failures are reported, not swallowed', function () {
+
+ it('stop treats a container docker says is gone as stopped, not as a failure', async function () {
+ const stubs = makeStubs()
+ const gone = new Error('Error response from daemon: No such container: container-id-123')
+ stubs.stopContainer.rejects(gone)
+ const result = await loadOperations(stubs).stopModules({ bitcoin: { mainnet: ['xchain-encoder'] } })
+ expect(result).to.be.true
+ })
+
+ it('stop fails on a bare-string rejection, which is a failed stop rather than a miss', async function () {
+ const stubs = makeStubs()
+ stubs.stopContainer.callsFake(() => Promise.reject('There was an error trying to stop the docker container (container-id-123)'))
+ const err = await loadOperations(stubs).stopModules({ bitcoin: { mainnet: ['xchain-encoder'] } }).then(() => null, e => e)
+ expect(err).to.be.an('error')
+ expect(err.failures).to.have.lengthOf(1)
+ expect(err.failures[0].reason).to.match(/error trying to stop the docker container/)
+ })
+
+ it('fails on a registry read that errored instead of skipping the module as not installed', async function () {
+ for (const op of ['startModules', 'stopModules', 'restartModules']) {
+ const stubs = makeStubs()
+ stubs.db.getModuleContainerStrict.rejects(new Error('pool closed'))
+ const err = await loadOperations(stubs)[op]({ bitcoin: { mainnet: ['xchain-encoder'] } }).then(() => null, e => e)
+ expect(err, op).to.be.an('error')
+ expect(err.message, op).to.match(/registry lookup failed \(pool closed\)/)
+ expect(stubs.db.getModuleContainer.called, op).to.be.false
+ }
+ })
+
+ it('restart fails when the hub and explorer config refresh after it fails', async function () {
+ const stubs = makeStubs()
+ stubs.statusChanged.rejects(new Error('hub unreachable'))
+ const err = await loadOperations(stubs).restartModules({ bitcoin: { mainnet: ['xchain-encoder'] } }).then(() => null, e => e)
+ expect(stubs.stopContainer.calledOnce).to.be.true
+ expect(stubs.startContainer.calledOnce).to.be.true
+ expect(err.message).to.match(/restarted, but the hub and explorer config refresh failed \(hub unreachable\)/)
+ })
+ })
+})
diff --git a/test/unit/module_operations.test/reset/09_reset_modules.test.js b/test/unit/module_operations.test/reset/09_reset_modules.test.js
index 9f1a8c4b..38ecdbaf 100644
--- a/test/unit/module_operations.test/reset/09_reset_modules.test.js
+++ b/test/unit/module_operations.test/reset/09_reset_modules.test.js
@@ -82,7 +82,11 @@ describe('moduleOperations', function () {
const result = await promise
expect(result).to.be.true
expect(stubs.resetDatabases.calledOnce).to.be.true
- expect(stubs.restartContainer.called).to.be.true // bounce
+ // The bounce is a budgeted stop after the start pass (decoder 120 s, indexer 30 s), then a start
+ const bounceStops = stubs.stopContainer.getCalls().filter(c => c.calledAfter(stubs.startContainer.firstCall))
+ expect(bounceStops.map(c => c.args[1])).to.deep.equal([120, 30])
+ expect(stubs.startContainer.lastCall.calledAfter(stubs.stopContainer.lastCall)).to.be.true
+ expect(stubs.restartContainer.called).to.be.false
})
// Wiping the indexer DB restarts its push_generations at 0, which the
diff --git a/test/unit/module_service.test/19_buildandup_legacy_db_name_collision.test.js b/test/unit/module_service.test/19_buildandup_legacy_db_name_collision.test.js
new file mode 100644
index 00000000..30d2f6e5
--- /dev/null
+++ b/test/unit/module_service.test/19_buildandup_legacy_db_name_collision.test.js
@@ -0,0 +1,89 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+// A decoder or indexer buildAndUp under a non-default NODE_PREFIX on an
+// external database refuses while the DB name is still the unprefixed default,
+// before any docker call is made.
+
+const {
+ sinon, expect, makeStubs, makeConfigServiceStub, loadModuleService, moduleSuite, stubDockerCreate
+} = require('./support/helpers')
+
+const LEGACY = {
+ 'xchain-decoder': ['DECODER_DB_NAME', 'XChain_BTC_Mainnet_Decoder'],
+ 'xchain-indexer': ['INDEXER_DB_NAME', 'XChain_BTC_Mainnet_Indexer']
+}
+const REFUSAL = 'unprefixed default database name'
+
+function load(stubs, module, dbName, overrides) {
+ const base = makeConfigServiceStub().getDefaultConfig
+ const constants = { NODE_PREFIX: 'stack2', DEFAULT_NODE_PREFIX: 'xchain-node', EXTERNAL_DB: true, ...overrides }
+ return loadModuleService(stubs, constants, {
+ './config_service': {
+ ...makeConfigServiceStub(constants),
+ getDefaultConfig: sinon.stub().callsFake(async (...a) => ({ ...(await base(...a)), [LEGACY[module][0]]: dbName }))
+ }
+ })
+}
+
+// The docker fake answers the whole create path, so a run that clears the
+// guard completes instead of hanging on an execFile that never calls back.
+function fakeDocker(stubs) {
+ stubs.fs.cpSync = sinon.stub()
+ return stubDockerCreate(stubs)
+}
+
+async function attempt(ms, module) {
+ try { await ms.buildAndUp(module, 'bitcoin', 'mainnet') } catch (e) { return e }
+ return null
+}
+
+function messageOf(err) {
+ return String(err && err.message ? err.message : err)
+}
+
+moduleSuite('buildAndUp() legacy DB name collision', function () {
+ for (const module of Object.keys(LEGACY)) {
+ const [key, legacyName] = LEGACY[module]
+
+ it(`REFUSES ${module} under a non-default prefix on an external DB with the unprefixed ${key}`, async function () {
+ const stubs = makeStubs()
+ const seen = fakeDocker(stubs)
+ const err = await attempt(load(stubs, module, legacyName), module)
+ expect(err).to.be.an('error')
+ expect(err.message).to.include(key)
+ expect(err.message).to.include(legacyName)
+ expect(seen).to.have.length(0)
+ })
+
+ it(`lets ${module} past the guard when ${key} is a stack-unique name`, async function () {
+ const stubs = makeStubs()
+ const seen = fakeDocker(stubs)
+ const err = await attempt(load(stubs, module, 'stack2_' + legacyName), module)
+ expect(messageOf(err)).to.not.include(REFUSAL)
+ })
+
+ it(`does not refuse ${module} under the default prefix`, async function () {
+ const stubs = makeStubs()
+ const seen = fakeDocker(stubs)
+ const err = await attempt(load(stubs, module, legacyName, { NODE_PREFIX: 'xchain-node' }), module)
+ expect(messageOf(err)).to.not.include(REFUSAL)
+ })
+
+ it(`does not refuse ${module} when the database is not external`, async function () {
+ const stubs = makeStubs()
+ const seen = fakeDocker(stubs)
+ const err = await attempt(load(stubs, module, legacyName, { EXTERNAL_DB: false }), module)
+ expect(messageOf(err)).to.not.include(REFUSAL)
+ })
+ }
+})
diff --git a/test/unit/publish_bootstraps_forced_republish.test.js b/test/unit/publish_bootstraps_forced_republish.test.js
index 4fa4ab9c..2c1d77e7 100644
--- a/test/unit/publish_bootstraps_forced_republish.test.js
+++ b/test/unit/publish_bootstraps_forced_republish.test.js
@@ -49,6 +49,11 @@ function createWorkDir() {
}
function removeWorkDir() {
+ // Any gpg the script under test runs under this work dir may have started an
+ // agent bound to it; stop it before the directory goes.
+ try {
+ execFileSync('gpgconf', ['--homedir', workDir, '--kill', 'gpg-agent'], { stdio: 'ignore' })
+ } catch { /* gpgconf missing or no agent running */ }
fs.rmSync(workDir, { recursive: true, force: true })
}
diff --git a/test/unit/release_signature_service.test/verify_detached_signature.test.js b/test/unit/release_signature_service.test/verify_detached_signature.test.js
index 23037461..4dbfb05f 100644
--- a/test/unit/release_signature_service.test/verify_detached_signature.test.js
+++ b/test/unit/release_signature_service.test/verify_detached_signature.test.js
@@ -58,7 +58,14 @@ function makeKeyring(name) {
'--local-user', fingerprint, '--armor', '--detach-sign', '--output', '-', dataPath
])
},
- cleanup() { fs.rmSync(home, { recursive: true, force: true }) }
+ cleanup() {
+ // gpg spawns a gpg-agent bound to this homedir; kill it first or it
+ // outlives the run and leaks a socket and a process per keyring.
+ try {
+ execFileSync('gpgconf', ['--homedir', home, '--kill', 'gpg-agent'], { stdio: 'ignore' })
+ } catch { /* gpgconf missing or no agent running */ }
+ fs.rmSync(home, { recursive: true, force: true })
+ }
}
}
diff --git a/test/unit/release_signature_service.test/verify_git_tag_signature.test.js b/test/unit/release_signature_service.test/verify_git_tag_signature.test.js
index 89d52bea..d317072c 100644
--- a/test/unit/release_signature_service.test/verify_git_tag_signature.test.js
+++ b/test/unit/release_signature_service.test/verify_git_tag_signature.test.js
@@ -58,7 +58,14 @@ function makeKeyring(name) {
'--local-user', fingerprint, '--armor', '--detach-sign', '--output', '-', dataPath
])
},
- cleanup() { fs.rmSync(home, { recursive: true, force: true }) }
+ cleanup() {
+ // gpg spawns a gpg-agent bound to this homedir; kill it first or it
+ // outlives the run and leaks a socket and a process per keyring.
+ try {
+ execFileSync('gpgconf', ['--homedir', home, '--kill', 'gpg-agent'], { stdio: 'ignore' })
+ } catch { /* gpgconf missing or no agent running */ }
+ fs.rmSync(home, { recursive: true, force: true })
+ }
}
}
diff --git a/test/unit/release_signature_service.test/verify_manifest_for_tag.test.js b/test/unit/release_signature_service.test/verify_manifest_for_tag.test.js
index da7fc10d..ec07db1a 100644
--- a/test/unit/release_signature_service.test/verify_manifest_for_tag.test.js
+++ b/test/unit/release_signature_service.test/verify_manifest_for_tag.test.js
@@ -60,7 +60,14 @@ function makeKeyring(name) {
'--local-user', fingerprint, '--armor', '--detach-sign', '--output', '-', dataPath
])
},
- cleanup() { fs.rmSync(home, { recursive: true, force: true }) }
+ cleanup() {
+ // gpg spawns a gpg-agent bound to this homedir; kill it first or it
+ // outlives the run and leaks a socket and a process per keyring.
+ try {
+ execFileSync('gpgconf', ['--homedir', home, '--kill', 'gpg-agent'], { stdio: 'ignore' })
+ } catch { /* gpgconf missing or no agent running */ }
+ fs.rmSync(home, { recursive: true, force: true })
+ }
}
}
diff --git a/test/unit/services/bootstrap_service/tracker_height.test.js b/test/unit/services/bootstrap_service/tracker_height.test.js
index 0eb6fb18..35506006 100644
--- a/test/unit/services/bootstrap_service/tracker_height.test.js
+++ b/test/unit/services/bootstrap_service/tracker_height.test.js
@@ -63,6 +63,51 @@ describe('tracker_height', function () {
expect(trackerHeight.chooseArchiveHeight(null, null)).to.equal(null)
})
+})
+
+describe('tracker_height frozen store', function () {
+ let fakes
+
+ beforeEach(function () {
+ fakes = configureTrackerHeight(sinon.stub())
+ })
+
+ it('readFrozenStoreHeight reads the height of the archived directory', async function () {
+ fakes.execFile.callsFake((command, args, options, callback) => callback(null, { stdout: '4242\n', stderr: '' }))
+
+ const height = await trackerHeight.readFrozenStoreHeight('vol', '/data/.snap')
+
+ expect(height).to.equal(4242)
+ const args = fakes.execFile.firstCall.args[1]
+ expect(args).to.include('vol:/data:ro')
+ expect(args[args.length - 1]).to.equal('/data/.snap/committed_height')
+ })
+
+ it('readFrozenStoreHeight returns null on junk output or a failed read', async function () {
+ fakes.execFile.callsFake((command, args, options, callback) => callback(null, { stdout: '12abc', stderr: '' }))
+ expect(await trackerHeight.readFrozenStoreHeight('vol', '/data')).to.equal(null)
+ fakes.execFile.callsFake((command, args, options, callback) => callback(new Error('no such file')))
+ expect(await trackerHeight.readFrozenStoreHeight('vol', '/data')).to.equal(null)
+ })
+
+ it('a frozen-store height wins over the live readings and lifts the pre-stop-only refusal', function () {
+ expect(trackerHeight.chooseArchiveHeight(100, null, 99)).to.equal(99)
+ expect(trackerHeight.chooseArchiveHeight(100, 105, 99)).to.equal(99)
+ expect(() => trackerHeight.refusePreStopOnlyHeight({ coin: 'bitcoin', network: 'mainnet', preStopHeight: 100, postRestartHeight: null, frozenHeight: 99 })).to.not.throw()
+ expect(() => trackerHeight.refusePreStopOnlyHeight({ coin: 'bitcoin', network: 'mainnet', preStopHeight: 100, postRestartHeight: null, frozenHeight: null })).to.throw()
+ })
+
+})
+
+describe('tracker_height live readings', function () {
+ let probeServiceStatus
+ let fakes
+
+ beforeEach(function () {
+ probeServiceStatus = sinon.stub()
+ fakes = configureTrackerHeight(probeServiceStatus)
+ })
+
it('reads the committed height through configured fakes', async function () {
probeServiceStatus.resolves({ committed_height: 321 })
diff --git a/test/unit/status_reorg_halt.test.js b/test/unit/status_reorg_halt.test.js
index 302ded01..905e3928 100644
--- a/test/unit/status_reorg_halt.test.js
+++ b/test/unit/status_reorg_halt.test.js
@@ -60,6 +60,13 @@ describe('ps: indexer STALL surface', function () {
expect(reduceIndexerStall({ stallReason: 'price_sync_barrier', stallClass: 'wedged' }).stalled).to.equal(true)
})
+ it('does not show a price-landing wait inside its own grace window, and shows it once wedged', function () {
+ expect(reduceIndexerStall({ stallReason: 'price_landing_barrier', stallClass: 'price_landing_wait' }).stalled).to.equal(false)
+ expect(reduceIndexerStall({ stallReason: 'price_landing_barrier', stallClass: 'wedged' }).stalled).to.equal(true)
+ expect(reduceIndexerStall({ stallReason: 'vm_executor_unavailable', stallClass: 'price_landing_wait' }).stalled).to.equal(true)
+ expect(reduceIndexerStall({ stallReason: 'price_landing_barrier', stallClass: 'some_future_class' }).stalled).to.equal(true)
+ })
+
it('reads an older image that names a reason but grades nothing as stalled, and no reason as advancing', function () {
expect(reduceIndexerStall({ stallReason: 'oracle_sync_barrier' }).stalled).to.equal(true)
expect(reduceIndexerStall({ status: 'healthy', stallReason: null }).stalled).to.equal(false)
@@ -87,6 +94,50 @@ describe('ps: indexer STALL surface', function () {
})
})
+// Only a hub-mirror barrier gets the hub-mirror line. A consensus halt, a
+// repeated parse error and a bridge proof wait each name their own remedy, and
+// an unknown reason that is not a barrier gets the neutral log line.
+describe('ps: indexer STALL surface', function () {
+
+ const note = (reason, stallClass) => describeIndexerStallNote('bitcoin', 'mainnet',
+ reduceIndexerStall({ stallReason: reason, stallClass }))
+
+ it('keeps a bridge proof wait STALLED, since it never wedges and nothing else shows it', function () {
+ const r = reduceIndexerStall({ stallReason: 'bridge_proof_barrier', stallClass: 'bridge_proof_wait' })
+ expect(r).to.include({ stalled: true, word: 'bridge_proof_barrier', stall_class: 'bridge_proof_wait' })
+ })
+
+ it('a bridge proof wait names the checkpoint and origin proof, not the hub mirror', function () {
+ const n = note('bridge_proof_barrier', 'bridge_proof_wait')
+ expect(n).to.match(/^bitcoin\/mainnet xchain-indexer is STALLED \(bridge_proof_barrier\): /)
+ expect(n).to.not.match(/hub-mirror barrier|grace window/)
+ expect(n).to.match(/escrow proof/).and.to.match(/checkpoint/).and.to.match(/origin-chain indexer/)
+ expect(n).to.match(/xchain-node logs xchain-indexer bitcoin mainnet/)
+ })
+
+ it('a list share halt and a repeated parse error point at the indexer log, not the hub', function () {
+ const halt = note('list_share_halt', 'wedged')
+ expect(halt).to.not.match(/hub-mirror barrier/)
+ expect(halt).to.match(/would fork/).and.to.match(/xchain-node logs xchain-indexer bitcoin mainnet/)
+ const parse = note('parse_exception: TypeError: boom', 'wedged')
+ expect(parse).to.not.match(/hub-mirror barrier/)
+ expect(parse).to.match(/same exception hit the same block twice/).and.to.match(/xchain-node logs xchain-indexer bitcoin mainnet/)
+ })
+
+ it('an unknown reason gets the hub-mirror line only when its word ends in _barrier', function () {
+ expect(note('foo_sync_barrier', 'wedged')).to.match(/: a hub-mirror barrier \(foo_sync_barrier\)/)
+ const other = note('some_new_halt', 'wedged')
+ expect(other).to.not.match(/hub-mirror barrier/)
+ expect(other).to.match(/no specific remedy for this stall; `xchain-node logs xchain-indexer bitcoin mainnet` names the fault/)
+ })
+
+ it('reads the word, not the reason prose, so an exception ending in _barrier is not a barrier', function () {
+ const n = describeIndexerStallNote('bitcoin', 'mainnet',
+ { reason: 'odd_thing: lookup failed near price_sync_barrier', word: 'odd_thing', since: null })
+ expect(n).to.not.match(/hub-mirror barrier/)
+ })
+})
+
// The tracker's surface carries `halted` and `halt_reason` after an
// unrecoverable reorg. The flag is in memory, /status answers 503, and no
// restart clears it; the docker healthcheck failing is all `ps` showed.
diff --git a/test/unit/status_service.test/get_status_not_ready_column.test.js b/test/unit/status_service.test/get_status_not_ready_column.test.js
new file mode 100644
index 00000000..e749fd44
--- /dev/null
+++ b/test/unit/status_service.test/get_status_not_ready_column.test.js
@@ -0,0 +1,127 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+// `ps` shows NOT READY for a service that is up and passing its healthcheck but reports
+// ready false (indexer /status, decoder /live), or an indexer holding a hub barrier past
+// its ceiling, which reduceIndexerStall still calls a healthy defer. An older image that
+// carries none of the fields keeps the plain running row.
+
+const sinon = require('sinon')
+const { configStub } = require('../../helpers/config_stub')
+const { expect } = require('chai')
+const proxyquire = require('proxyquire').noCallThru()
+
+function makeContainerStatus() {
+ return { State: { Status: 'running', Health: { Status: 'healthy' } }, NetworkSettings: { Ports: {} }, Config: { Labels: {} } }
+}
+
+// `health` answers the JSON-RPC probe, `surfaces` the readiness route, both by container id.
+function load(rows, health, surfaces) {
+ const installedModulesObj = {}
+ let printed = ''
+ const state = {
+ db: { isReady: sinon.stub().returns(true), getAllModuleContainers: sinon.stub().resolves(rows) },
+ getInstalledModules: () => installedModulesObj,
+ setInstalledModules: sinon.stub(),
+ resetInstalledModules: () => { for (const k of Object.keys(installedModulesObj)) delete installedModulesObj[k] },
+ getRemoteModuleVersions: () => ({}),
+ isStatusUpdated: () => false,
+ setStatusUpdated: sinon.stub(),
+ getLastStatus: () => null,
+ setLastStatus: sinon.stub(),
+ getLastPrintedStatus: () => printed,
+ setLastPrintedStatus: (s) => { printed = s },
+ appendLastPrintedStatus: sinon.stub()
+ }
+ const routes = []
+ const ss = proxyquire('../../../src/services/status_service', {
+ '../config': configStub({ NODE_MODULE_NAME: 'node', SEP: '-',
+ Coin: { BITCOIN: 'bitcoin', LITECOIN: 'litecoin' },
+ Network: { MAINNET: 'mainnet', TESTNET: 'testnet', REGTEST: 'regtest' },
+ XChainService: { XCHAIN_DECODER: 'xchain-decoder', XCHAIN_INDEXER: 'xchain-indexer', XCHAIN_UTXO_TRACKER: 'xchain-utxo-tracker' } }),
+ '../state': state,
+ './docker_service': { getStatusFromContainer: sinon.stub().resolves(makeContainerStatus()) },
+ './version_service': {
+ checkRemoteNodeVersion: sinon.stub().resolves(), 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')
+ },
+ './config_service': { getDefaultConfig: async () => ({ INDEXER_API_PORT: '3004', DECODER_API_PORT: '3002' }) },
+ './peer_services': { bindPeerServices: () => ({
+ bootstrapHealthGate: {
+ probeServiceStatus: async (id) => health[id],
+ probeReadinessSurface: async (id, port, route) => { routes.push(route); return surfaces[id] || null },
+ MODULE_API_PORT_KEY: { 'xchain-decoder': 'DECODER_API_PORT', 'xchain-indexer': 'INDEXER_API_PORT' } },
+ moduleService: { getModuleBranch: sinon.stub().resolves('master') }
+ }) }
+ })
+ return { ss, routes, printed: () => printed }
+}
+
+const IDX = { module: 'xchain-indexer', coin: 'bitcoin', network: 'mainnet', container_id: 'idx' }
+const DEC = { module: 'xchain-decoder', coin: 'bitcoin', network: 'mainnet', container_id: 'dec' }
+
+describe('StatusService: getStatus() NOT READY column', function () {
+
+ it('prints NOT READY and lists the reasons for an indexer whose /status says ready false', async function () {
+ const { ss, routes, printed } = load([IDX], { idx: { status: 'healthy', stallReason: null } },
+ { idx: { ready: false, notReadyReasons: ['decoder_reorg_halted', 'behind_chain_tip'] } })
+ const result = await ss.getStatus('bitcoin', 'mainnet', false)
+ expect(routes).to.deep.equal(['/status'])
+ expect(result.bitcoin.mainnet['xchain-indexer'].not_ready.reasons).to.deep.equal(['decoder_reorg_halted', 'behind_chain_tip'])
+ expect(printed()).to.match(/xchain-indexer.*running NOT READY/)
+ expect(printed()).to.match(/! bitcoin\/mainnet xchain-indexer is NOT READY \(decoder_reorg_halted, behind_chain_tip\)/)
+ })
+
+ it('prints NOT READY for an indexer past its barrier ceiling although the stall reducer calls it a healthy defer', async function () {
+ const { ss, printed } = load([IDX],
+ { idx: { status: 'healthy', stallReason: 'price_sync_barrier', stallClass: 'barrier_defer' } },
+ { idx: { ready: true, barrierCeilingExceeded: true } })
+ const result = await ss.getStatus('bitcoin', 'mainnet', false)
+ expect(result.bitcoin.mainnet['xchain-indexer'].stall).to.equal(undefined)
+ expect(result.bitcoin.mainnet['xchain-indexer'].not_ready.reasons).to.deep.equal(['barrier_ceiling_exceeded'])
+ expect(printed()).to.match(/xchain-indexer.*running NOT READY/)
+ expect(printed()).to.not.match(/STALL/)
+ })
+
+ it('prints NOT READY for a decoder whose /live body says ready false, reading /live', async function () {
+ const { ss, routes, printed } = load([DEC], { dec: { status: 'healthy', reorg_halted: true } },
+ { dec: { status: 'healthy', ready: false, not_ready_reasons: ['reorg_halt'] } })
+ const result = await ss.getStatus('bitcoin', 'mainnet', false)
+ expect(routes).to.deep.equal(['/live'])
+ expect(result.bitcoin.mainnet['xchain-decoder'].not_ready.reasons).to.deep.equal(['reorg_halt'])
+ expect(printed()).to.match(/xchain-decoder.*running REORG_HALT NOT READY/)
+ expect(printed()).to.match(/xchain-decoder is NOT READY \(reorg_halt\)/)
+ })
+
+ it('keeps today\'s plain running row when the fields are absent, ready is true or the route is unreadable', async function () {
+ const { ss, printed } = load([IDX, DEC, { ...IDX, coin: 'litecoin', container_id: 'ltc' }],
+ { idx: { status: 'healthy' }, dec: { status: 'healthy' }, ltc: { status: 'healthy' } },
+ { idx: { indexerBlock: 5, stallClass: 'none' }, dec: { status: 'healthy', ready: true, not_ready_reasons: [] } })
+ const result = await ss.getStatus(null, null, false)
+ expect(result.bitcoin.mainnet['xchain-indexer'].not_ready).to.equal(undefined)
+ expect(result.bitcoin.mainnet['xchain-decoder'].not_ready).to.equal(undefined)
+ expect(result.litecoin.mainnet['xchain-indexer'].not_ready).to.equal(undefined)
+ expect(printed()).to.not.match(/NOT READY/)
+ expect(printed()).to.not.match(/^!/m)
+ })
+
+ it('reduceNotReady reads only an explicit ready false or a true ceiling flag', function () {
+ const { ss } = load([], {}, {})
+ expect(ss.reduceNotReady(null)).to.equal(null)
+ expect(ss.reduceNotReady({})).to.equal(null)
+ expect(ss.reduceNotReady({ ready: 'no' })).to.equal(null)
+ expect(ss.reduceNotReady({ ready: false })).to.deep.equal({ notReady: true, reasons: [] })
+ expect(ss.reduceNotReady({ ready: false, barrierCeilingExceeded: true, notReadyReasons: ['x'] }).reasons)
+ .to.deep.equal(['x', 'barrier_ceiling_exceeded'])
+ })
+})
diff --git a/test/unit/validator_sdk_encoder_compat.test.js b/test/unit/validator_sdk_encoder_compat.test.js
index 6d5be3b3..3e7fa0d7 100644
--- a/test/unit/validator_sdk_encoder_compat.test.js
+++ b/test/unit/validator_sdk_encoder_compat.test.js
@@ -106,7 +106,10 @@ function stubEncoder() {
function makeExplorer(chain) {
return {
getAddress: sinon.stub().resolves({ balances: { confirmed: chain.coin, pending: '0' } }),
- getToken: sinon.stub().resolves({ mints: { max: 10000, address_max: 50000 } }),
+ // An open mint: caps set, no window, no lock, and no earlier MINT from the stake address.
+ getToken: sinon.stub().resolves({ mints: { max: 10000, address_max: 50000, start_block: 0, stop_block: 0 },
+ locks: { mint: false } }),
+ getMints: sinon.stub().resolves({ total: 0, data: [] }),
getValidators: sinon.stub().resolves({ data: [] }),
// No delegation holds the key; without an answer the stake command refuses to send.
getDelegations: sinon.stub().resolves({ total: 0, data: [] }),
diff --git a/test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js b/test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js
index 07c7a87b..ca05d0d3 100644
--- a/test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js
+++ b/test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js
@@ -70,10 +70,12 @@ function makeFs(overrides = {}) {
const vm = require('vm')
const PHASE1 = 'f'.repeat(64)
+const REVEAL = 'e'.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) {
+// pipeline can be exercised without a key, an encoder or a network. `wallet`
+// replaces the stub signing methods.
+function loadEmittedSigner(source, encoder, wallet) {
const mod = { exports: {} }
vm.runInNewContext(source, {
require: (id) => {
@@ -81,10 +83,10 @@ function loadEmittedSigner(source, encoder) {
if (id === 'dotenv') return { config: () => ({}) }
if (id === '@dankest-llc/xchain-sdk') return { XChainSDK: function () {
this.requireEncoder = () => encoder
- this.wallet = {
+ this.wallet = Object.assign({
signPsbt: () => ({ txHex: 'hex-1', txid: PHASE1 }),
- signRevealPsbt: () => ({ txHex: 'hex-2', txid: 'e'.repeat(64) })
- }
+ signRevealPsbt: () => ({ txHex: 'hex-2', txid: REVEAL })
+ }, wallet || {})
} }
throw new Error('unexpected require in the emitted signer: ' + id)
},
@@ -107,6 +109,40 @@ async function emitSigner() {
return fs.writeFileSync.getCalls().find(c => c.args[0] === FAKE_SIGNER_FILE).args[1]
}
+// An encoder and wallet whose every call is recorded in order, with the
+// broadcast that `failBroadcast` names (1 = the commit, 2 = the reveal) refused
+// by the node.
+function recordingSigner(source, { encoding = 'P2SH', failBroadcast = 0, encoder = {}, wallet = {} } = {}) {
+ const calls = []
+ let broadcasts = 0
+ const record = (name, fn) => async (...args) => { calls.push(name); return fn(...args) }
+ const signer = loadEmittedSigner(source, {
+ createTx: record('createTx', encoder.createTx || (async () => ({ psbt: 'psbt-1', encoding }))),
+ spendP2sh: record('spendP2sh', encoder.spendP2sh || (async () => ({ psbt: 'psbt-2' }))),
+ broadcastTx: async (hex) => {
+ calls.push('broadcast:' + hex)
+ broadcasts += 1
+ if (broadcasts === failBroadcast) throw new Error('Encoder RPC error: min relay fee not met')
+ return { txid: broadcasts === 1 ? PHASE1 : REVEAL }
+ }
+ }, {
+ signPsbt: (...args) => {
+ calls.push('sign')
+ return (wallet.signPsbt || (() => ({ txHex: 'hex-1', txid: PHASE1 })))(...args)
+ },
+ signRevealPsbt: (...args) => {
+ calls.push('sign-reveal')
+ return (wallet.signRevealPsbt || (() => ({ txHex: 'hex-2', txid: REVEAL })))(...args)
+ }
+ })
+ return { signer, calls }
+}
+
+async function rejection(promise) {
+ try { await promise } catch (e) { return e }
+ throw new Error('expected the publish to fail')
+}
+
function stubEncoder(overrides) {
return Object.assign({
createTx: async () => ({ psbt: 'psbt-1', encoding: 'P2SH' }),
@@ -115,11 +151,111 @@ function stubEncoder(overrides) {
}, overrides || {})
}
+// Each case is a named step, so the suite below reads as the list of what the
+// emitted signer guarantees.
+const cases = {
+ async validJs() {
+ const source = await emitSigner()
+ expect(() => new vm.Script(source, { filename: 'signer.js' })).to.not.throw()
+ },
+
+ async revealRejected() {
+ let broadcasts = 0
+ const signer = loadEmittedSigner(await emitSigner(), stubEncoder({
+ broadcastTx: async () => {
+ broadcasts += 1
+ if (broadcasts === 2) throw new Error('Encoder RPC error: bad-txns-inputs-missingorspent')
+ return { txid: PHASE1 }
+ }
+ }))
+ const caught = await rejection(signer.broadcast('wire'))
+ expect(caught).to.include({ fundsCommitted: true, phase1Txid: PHASE1, revealTxid: REVEAL, revealTxHex: 'hex-2' })
+ expect(caught.neverSent).to.equal(undefined)
+ // The SAME object is rethrown: the hub classifies on message and response.
+ expect(caught.message).to.equal('Encoder RPC error: bad-txns-inputs-missingorspent')
+ },
+
+ async preFundingFailure() {
+ const signer = loadEmittedSigner(await emitSigner(), stubEncoder({
+ createTx: async () => { throw new Error('Encoder RPC error: no UTXOs available') }
+ }))
+ const caught = await rejection(signer.broadcast('wire'))
+ expect(caught.fundsCommitted).to.equal(undefined)
+ expect(caught.neverSent).to.equal(true)
+ },
+
+ async twoPhaseSuccess() {
+ const signer = loadEmittedSigner(await emitSigner(), stubEncoder())
+ const res = await signer.broadcast('wire')
+ expect(res.phase1_txid).to.equal(PHASE1)
+ },
+
+ async encoderRefusesReveal() {
+ const { signer, calls } = recordingSigner(await emitSigner(), { encoder: {
+ spendP2sh: async () => { throw new Error('Encoder RPC error: reveal leg below dust') }
+ } })
+ const caught = await rejection(signer.broadcast('wire'))
+ expect(calls).to.deep.equal(['createTx', 'sign', 'spendP2sh'])
+ expect(caught.neverSent).to.equal(true)
+ expect(caught.fundsCommitted).to.equal(undefined)
+ expect(caught.message).to.equal('Encoder RPC error: reveal leg below dust')
+ },
+
+ async revealUnsignable() {
+ const { signer, calls } = recordingSigner(await emitSigner(), { wallet: {
+ signRevealPsbt: () => { throw new Error('Reveal PSBT finalization failed') }
+ } })
+ const caught = await rejection(signer.broadcast('wire'))
+ expect(calls).to.deep.equal(['createTx', 'sign', 'spendP2sh', 'sign-reveal'])
+ expect(caught.neverSent).to.equal(true)
+ expect(caught.fundsCommitted).to.equal(undefined)
+ },
+
+ async revealBuiltFirst() {
+ let spendParams = null
+ const { signer, calls } = recordingSigner(await emitSigner(), { encoder: {
+ spendP2sh: async (p) => { spendParams = p; return { psbt: 'psbt-2' } }
+ } })
+ const res = await signer.broadcast('wire')
+ expect(calls).to.deep.equal(['createTx', 'sign', 'spendP2sh', 'sign-reveal', 'broadcast:hex-1', 'broadcast:hex-2'])
+ expect(spendParams).to.include({ p2shHash: PHASE1, p2shHex: 'hex-1', data: 'wire', encoding: 'P2SH' })
+ expect(res).to.deep.equal({ txid: REVEAL, phase1_txid: PHASE1 })
+ },
+
+ async revealBroadcastFails() {
+ const { signer, calls } = recordingSigner(await emitSigner(), { failBroadcast: 2 })
+ const caught = await rejection(signer.broadcast('wire'))
+ expect(calls).to.deep.equal(['createTx', 'sign', 'spendP2sh', 'sign-reveal', 'broadcast:hex-1', 'broadcast:hex-2'])
+ expect(caught).to.include({ fundsCommitted: true, phase1Txid: PHASE1, revealTxid: REVEAL, revealTxHex: 'hex-2' })
+ expect(caught.neverSent).to.equal(undefined)
+ },
+
+ async commitBroadcastFails() {
+ const { signer, calls } = recordingSigner(await emitSigner(), { failBroadcast: 1 })
+ const caught = await rejection(signer.broadcast('wire'))
+ expect(calls).to.deep.equal(['createTx', 'sign', 'spendP2sh', 'sign-reveal', 'broadcast:hex-1'])
+ expect(caught.neverSent).to.equal(undefined)
+ expect(caught.fundsCommitted).to.equal(undefined)
+ },
+
+ async singleTransaction() {
+ const { signer, calls } = recordingSigner(await emitSigner(), { encoding: 'OP_RETURN' })
+ const res = await signer.broadcast('wire')
+ expect(calls).to.deep.equal(['createTx', 'sign', 'broadcast:hex-1'])
+ expect(res).to.deep.equal({ txid: PHASE1 })
+ }
+}
+
// 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.
+//
+// A P2SH commit whose reveal is then refused leaves its legs locked to a script
+// only that reveal spends, and the hub dead-letters such a round rather than
+// fund it again. So the reveal is built from the signed commit and signed before
+// the commit is broadcast, and a refusal there sends nothing.
describe('ValidatorService', function () {
describe('coin wallets', function () {
@@ -133,40 +269,19 @@ describe('ValidatorService', function () {
})
describe('the emitted signer marks post-funding failures', function () {
+ it('is valid JavaScript once the template literal is expanded', cases.validJs)
+ it('tags a definitive phase-2 rejection with fundsCommitted, the phase-1 txid and the signed reveal', cases.revealRejected)
+ it('marks a pre-funding failure neverSent and not fundsCommitted, so the round stays retryable', cases.preFundingFailure)
+ it('does not tag a successful two-phase publish', cases.twoPhaseSuccess)
+ })
- 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)
- })
+ describe('the emitted signer builds and signs the reveal before the commit broadcast', function () {
+ it('broadcasts nothing when the encoder refuses the reveal', cases.encoderRefusesReveal)
+ it('broadcasts nothing when the reveal cannot be signed', cases.revealUnsignable)
+ it('builds the reveal from the signed commit, then broadcasts the commit and the reveal', cases.revealBuiltFirst)
+ it('hands back the signed reveal when its broadcast fails after the commit', cases.revealBroadcastFails)
+ it('leaves a commit broadcast failure untagged for the hub to classify', cases.commitBroadcastFails)
+ it('builds no reveal for a single-transaction encoding', cases.singleTransaction)
})
})
})
diff --git a/test/unit/validator_stake_service.test.js b/test/unit/validator_stake_service.test.js
index 0f5edb6a..89fa486d 100644
--- a/test/unit/validator_stake_service.test.js
+++ b/test/unit/validator_stake_service.test.js
@@ -13,6 +13,7 @@
const { expect } = require('chai')
const { planMints, stakeTiming } = require('../../src/services/validator_stake_service')
+const { mintClosedReason } = require('../../src/services/validator_stake_service/mint_rules')
// The authoritative source for both stake clocks. Read here too, so a test that
// asserts the CLI's numbers cannot itself become the place they are frozen.
const { getCoinConfig } = require('../../src/coins')
@@ -48,7 +49,58 @@ describe('ValidatorStakeService', function () {
})
it('flags a token with no open mint', function () {
- expect(planMints('testnet', 0, 25000, 0, 0).reason).to.match(/no open mint/)
+ const mintClosed = mintClosedReason({ locked: true }, 200001, 'XCHAIN')
+ const p = planMints('testnet', 0, 25000, 10000, 50000, null, { mintClosed })
+ expect(p.mints).to.deep.equal([])
+ expect(p.reason).to.match(/no open mint/)
+ })
+
+ it('reads MAX_MINT 0 as no per-transaction cap and plans one MINT of the shortfall', function () {
+ expect(planMints('testnet', 0, 25000, 0, 0)).to.deep.equal({ short: 25000, mints: [25000], reason: null })
+ expect(planMints('testnet', 0, 25000, 0, 50000).mints).to.deep.equal([25000])
+ })
+
+ it('counts what the address already minted against the per-address cap', function () {
+ expect(planMints('testnet', 0, 25000, 10000, 50000, null, { mintedSoFar: 30000 }).reason)
+ .to.match(/plus the 30000 this address already minted exceeds the per-address mint cap/)
+ expect(planMints('testnet', 0, 25000, 10000, 50000, null, { mintedSoFar: 25000 }).reason).to.equal(null)
+ })
+
+ it('refuses when what the address already minted could not be read', function () {
+ const p = planMints('testnet', 0, 25000, 10000, 50000, null, { mintedUnknown: 'could not read what X has already minted' })
+ expect(p.mints).to.deep.equal([])
+ expect(p.reason).to.match(/could not read what X has already minted/)
+ })
+ })
+})
+
+// A fractional XCHAIN balance is the normal state after an earlier run spent from the address,
+// and the indexer refuses a MINT AMOUNT past 8 decimal places as malformed.
+describe('ValidatorStakeService exact mint amounts', function () {
+ describe('planMints() from a fractional balance', function () {
+ const EIGHT_PLACES = /^\d+(\.\d{1,8})?$/
+
+ it('plans the exact 8-place shortfall, not float noise', function () {
+ const p = planMints('testnet', 24999.99877, 25000, 10000, 50000)
+ expect(p).to.deep.equal({ short: 0.00123, mints: [0.00123], reason: null })
+ for (const m of p.mints) expect(m.toFixed(8).replace(/\.?0+$/, '')).to.match(EIGHT_PLACES)
+ expect(String(p.mints[0])).to.equal('0.00123')
+ })
+
+ it('splits a fractional shortfall into cap-sized mints that sum to it exactly', function () {
+ const p = planMints('testnet', 12000.5, 25000, 10000, 50000)
+ expect(p.mints).to.deep.equal([10000, 2999.5])
+ expect(p.short).to.equal(12999.5)
+ const p2 = planMints('testnet', 0.1 + 0.2, 25000, 10000, 50000)
+ expect(p2.mints).to.deep.equal([10000, 10000, 4999.7])
+ })
+
+ it('names the exact shortfall on mainnet', function () {
+ expect(planMints('mainnet', 24999.99877, 25000, 10000, 50000).reason).to.match(/acquire 0\.00123 more/)
+ })
+
+ it('needs no mint when the balance rounds to the stake at 8 places', function () {
+ expect(planMints('testnet', 24999.999999999, 25000, 10000, 50000)).to.deep.equal({ short: 0, mints: [], reason: null })
})
})
})
diff --git a/test/unit/validator_stake_service.test/mint_rules.test.js b/test/unit/validator_stake_service.test/mint_rules.test.js
new file mode 100644
index 00000000..7c01afbf
--- /dev/null
+++ b/test/unit/validator_stake_service.test/mint_rules.test.js
@@ -0,0 +1,160 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+// The token rules the indexer judges a MINT by, read before the stake run spends a MINT fee:
+// MAX_MINT 0 is no per-transaction cap, LOCK_MINT and the start/stop window refuse, and
+// MINT_ADDRESS_MAX counts what the address already minted itself.
+
+const { expect } = require('chai')
+
+const { readChainState, planMints } = require('../../../src/services/validator_stake_service')
+const { readTokenMintRules, mintClosedReason, readSelfMinted } =
+ require('../../../src/services/validator_stake_service/mint_rules')
+const { ADDRESS, run } = require('../../helpers/stake_harness')
+
+const TIP = 200000
+const COINS = { stake: 'bitcoin-testnet', stakeCoin: 'TBTC' }
+const KEY_CTX = { network: 'testnet', coins: COINS, cooldownBlocks: 1000 }
+const OPEN = { max: '10000.00000000', address_max: '50000.00000000', start_block: 0, stop_block: 0 }
+const MINTED = (amount, extra = {}) =>
+ ({ source: ADDRESS, tick: 'XCHAIN', amount, status: 'valid', action_index: 7, ...extra })
+
+// An SDK whose token, tip and source-lane MINT reads answer with the given bodies.
+function fakeSdk({ mints = OPEN, locks = { mint: false }, tip = TIP, getMints } = {}) {
+ return {
+ explorer: {
+ getAddress: async () => ({ balances: { confirmed: '0.002', pending: '0' } }),
+ getToken: async () => ({ info: { tick: 'XCHAIN' }, mints, locks }),
+ getValidators: async () => ({ data: [] }),
+ getDelegations: async () => ({ total: 0, data: [] }),
+ getStatus: async () => (tip === null ? {} : { last_block: { TBTC: tip } }),
+ getSleeps: async () => ({ total: 0, data: [] }),
+ getMints: getMints || (async () => ({ total: 0, data: [] }))
+ },
+ getBalances: async () => ({ data: [{ tick: 'XCHAIN', amount: '0' }] })
+ }
+}
+
+// Plan a 25000 stake from a zero balance against what readChainState read.
+async function planFor(sdk) {
+ const s = await readChainState(sdk, ADDRESS, 'ab'.repeat(32), KEY_CTX)
+ return { state: s, plan: planMints('testnet', s.tokenBal, 25000, s.mintMax, s.mintAddressMax, s.mintUnreadable, s) }
+}
+
+describe('validator stake mint rules', function () {
+
+ it('reads the explorer\'s MAX_MINT of "0.00000000" as no per-transaction cap', async function () {
+ const { state, plan } = await planFor(fakeSdk({ mints: { ...OPEN, max: '0.00000000', address_max: '0.00000000' } }))
+ expect(state.mintMax).to.equal(0)
+ expect(state.mintUnreadable).to.equal(null)
+ expect(plan).to.deep.equal({ short: 25000, mints: [25000], reason: null })
+ })
+
+ it('reads an unreported cap, window or lock as unknown, never as an unset 0', function () {
+ const nullMax = readTokenMintRules({ mints: { ...OPEN, max: null }, locks: { mint: false } }, 'XCHAIN')
+ expect(nullMax.unreadable).to.include('mints.max')
+ const noWindow = readTokenMintRules({ mints: { max: '1', address_max: '1' }, locks: { mint: false } }, 'XCHAIN')
+ expect(noWindow.unreadable).to.include('mints.start_block').and.to.include('mints.stop_block')
+ expect(readTokenMintRules({ mints: OPEN }, 'XCHAIN').unreadable).to.include('locks.mint')
+ expect(readTokenMintRules({ mints: OPEN, locks: { mint: false } }, 'XCHAIN').unreadable).to.equal(null)
+ })
+
+ it('refuses a locked mint before any MINT is planned', async function () {
+ const { plan } = await planFor(fakeSdk({ locks: { mint: true } }))
+ expect(plan.mints).to.deep.equal([])
+ expect(plan.reason).to.match(/no open mint: its mint is locked \(LOCK_MINT\)/)
+ })
+
+ it('refuses a window that opens after the block a MINT sent now lands in', async function () {
+ const { plan } = await planFor(fakeSdk({ mints: { ...OPEN, start_block: 999999999 } }))
+ expect(plan.mints).to.deep.equal([])
+ expect(plan.reason).to.match(/minting opens at block 999999999 \(MINT_START_BLOCK\)/)
+ })
+
+ it('refuses a window that closed before the block a MINT sent now lands in', async function () {
+ const { plan } = await planFor(fakeSdk({ mints: { ...OPEN, stop_block: TIP } }))
+ expect(plan.reason).to.match(/minting closed after block 200000 \(MINT_STOP_BLOCK\)/)
+ })
+
+ it('judges the window at tip + 1, the indexer\'s inclusive bounds', function () {
+ expect(mintClosedReason({ startBlock: TIP + 1, stopBlock: 0 }, TIP + 1, 'XCHAIN')).to.equal(null)
+ expect(mintClosedReason({ startBlock: TIP + 2, stopBlock: 0 }, TIP + 1, 'XCHAIN')).to.match(/opens at block/)
+ expect(mintClosedReason({ startBlock: 0, stopBlock: TIP + 1 }, TIP + 1, 'XCHAIN')).to.equal(null)
+ })
+
+})
+
+describe('validator stake mint rules', function () {
+
+ it('refuses when the tip cannot be read, instead of reading the window as open', async function () {
+ const { plan } = await planFor(fakeSdk({ tip: null, mints: { ...OPEN, stop_block: TIP + 5 } }))
+ expect(plan.mints).to.deep.equal([])
+ expect(plan.reason).to.match(/could not confirm the XCHAIN mint window is open/)
+ })
+
+ it('sums only valid XCHAIN mints this address authored', async function () {
+ const rows = [MINTED('20000.00000000'), MINTED('10000'), MINTED('5000', { status: 'invalid: MINT_STOP_BLOCK' }),
+ MINTED('5000', { tick: 'OTHER' }), MINTED('5000', { source: 'mSomeoneElse' })]
+ const sdk = fakeSdk({ getMints: async () => ({ total: rows.length, data: rows }) })
+ expect(await readSelfMinted(sdk, ADDRESS, 'XCHAIN')).to.equal(30000)
+ const { plan } = await planFor(sdk)
+ expect(plan.reason).to.match(/plus the 30000 this address already minted exceeds the per-address mint cap \(50000\)/)
+ })
+
+ it('pages the source lane to the end and refuses a short read', async function () {
+ const pages = []
+ const full = Array.from({ length: 100 }, () => MINTED('100'))
+ const sdk = fakeSdk({ getMints: async (q, type, opts) => {
+ pages.push([type, opts.page])
+ return opts.page === 1 ? { total: 101, data: full } : { total: 101, data: opts.page === 2 ? [MINTED('1')] : [] }
+ } })
+ expect(await readSelfMinted(sdk, ADDRESS, 'XCHAIN')).to.equal(10001)
+ expect(pages).to.deep.equal([['source', 1], ['source', 2]])
+ const short = fakeSdk({ getMints: async (q, t, opts) => (opts.page === 1 ? { total: 101, data: full } : { total: 101, data: [] }) })
+ const { plan } = await planFor(short)
+ expect(plan.mints).to.deep.equal([])
+ expect(plan.reason).to.match(/could not read what mStakeAddress has already minted \(mint read incomplete/)
+ })
+
+ it('skips the mint-history read when the token sets no per-address cap', async function () {
+ let called = 0
+ const sdk = fakeSdk({ mints: { ...OPEN, address_max: '0' }, getMints: async () => { called++; throw new Error('no') } })
+ const { plan } = await planFor(sdk)
+ expect(called).to.equal(0)
+ expect(plan.reason).to.equal(null)
+ })
+})
+
+describe('validator stake --broadcast against the mint rules', function () {
+
+ it('sends no MINT and no STAKE when earlier mints leave too little of the per-address cap', async function () {
+ const { result, calls } = await run({ broadcast: true }, { xchain: 0, priorMints: [MINTED('40000')] })
+ expect(result.staked).to.be.false
+ expect(result.blockers.join(' ')).to.match(/already minted exceeds the per-address mint cap/)
+ expect(calls.mint).to.have.length(0)
+ expect(calls.stake).to.have.length(0)
+ })
+
+ it('sends nothing when the mint is locked or its window is closed', async function () {
+ for (const chain of [{ mintLocked: true }, { mintStart: 999999999 }, { mintStop: 100 }]) {
+ const { result, calls } = await run({ broadcast: true }, { xchain: 0, ...chain })
+ expect(result.blockers.join(' '), JSON.stringify(chain)).to.match(/no open mint/)
+ expect(calls.mint.length + calls.stake.length, JSON.stringify(chain)).to.equal(0)
+ }
+ })
+
+ it('mints the whole shortfall in one MINT when the token has no per-transaction cap', async function () {
+ const { result, calls } = await run({ broadcast: true }, { xchain: 0, mintMax: '0.00000000', addressMax: '0' })
+ expect(result.staked).to.equal(true)
+ expect(calls.mint.map(c => Number(c.params.AMOUNT))).to.deep.equal([25000])
+ })
+})
diff --git a/test/unit/validator_stake_service.test/send_lock.test.js b/test/unit/validator_stake_service.test/send_lock.test.js
index 82206c93..7329501e 100644
--- a/test/unit/validator_stake_service.test/send_lock.test.js
+++ b/test/unit/validator_stake_service.test/send_lock.test.js
@@ -52,7 +52,10 @@ function otherCommandGetsLock(command = 'update') {
function makeExplorer(chain) {
return {
getAddress: sinon.stub().resolves({ balances: { confirmed: '0.001', pending: '0' } }),
- getToken: sinon.stub().resolves({ mints: { max: 10000, address_max: 50000 } }),
+ // An open mint: caps set, no window, no lock, and no earlier MINT from the stake address.
+ getToken: sinon.stub().resolves({ mints: { max: 10000, address_max: 50000, start_block: 0, stop_block: 0 },
+ locks: { mint: false } }),
+ getMints: sinon.stub().resolves({ total: 0, data: [] }),
getValidators: sinon.stub().resolves({ data: chain.existing ? [chain.existing] : [] }),
getDelegations: sinon.stub().resolves({ total: 0, data: [] }),
getStatus: sinon.stub().resolves({ last_block: Object.fromEntries(
diff --git a/test/unit/validator_stake_service.test/stake_validator.test.js b/test/unit/validator_stake_service.test/stake_validator.test.js
index 54f69122..58d051c8 100644
--- a/test/unit/validator_stake_service.test/stake_validator.test.js
+++ b/test/unit/validator_stake_service.test/stake_validator.test.js
@@ -273,7 +273,7 @@ describe('ValidatorStakeService', function () {
it('calls explorer methods that the published SDK actually exposes', function () {
const { XChainSDK } = require('@dankest-llc/xchain-sdk')
const sdk = new XChainSDK({ network: 'bitcoin-testnet' }) // offline: no network I/O in the constructor
- for (const m of ['getValidators', 'getAddress', 'getToken', 'getDelegations', 'getStatus', 'getSleeps'])
+ for (const m of ['getValidators', 'getAddress', 'getToken', 'getDelegations', 'getStatus', 'getSleeps', 'getMints'])
expect(sdk.explorer[m], 'sdk.explorer.' + m).to.be.a('function')
for (const m of ['getBalances', 'session'])
expect(sdk[m], 'sdk.' + m).to.be.a('function')
@@ -312,4 +312,21 @@ describe('ValidatorStakeService', function () {
expect(result.blockers).to.not.be.empty
})
})
+
+ // An address an earlier run spent from holds a fractional XCHAIN balance; the MINT that
+ // tops it up must carry the exact 8-place shortfall, never float noise the indexer refuses.
+ describe('stakeValidator() from a fractional XCHAIN balance', function () {
+
+ it('broadcasts the exact 8-place shortfall as the MINT amount', async function () {
+ const { calls, logged } = await run({ broadcast: true }, { xchain: 24999.99877, coin: '0.001' })
+ expect(calls.mint.map(c => c.params)).to.deep.equal([{ VERSION: 0, TICK: 'XCHAIN', AMOUNT: '0.00123' }])
+ expect(logged.join('\n')).to.include('Sending MINT 1/1 (0.00123 XCHAIN)')
+ expect(calls.stake).to.have.length(1)
+ })
+
+ it('prints a sub-micro shortfall as a plain decimal in the dry run', async function () {
+ const { logged } = await run({}, { xchain: 24999.9999999, coin: '0.001' })
+ expect(logged.join('\n')).to.include('MINT 1/1: 0.0000001 XCHAIN')
+ })
+ })
})
diff --git a/test/unit/validator_stake_service.test/unstake_validator.test.js b/test/unit/validator_stake_service.test/unstake_validator.test.js
index 8e32ac67..6dc7fc33 100644
--- a/test/unit/validator_stake_service.test/unstake_validator.test.js
+++ b/test/unit/validator_stake_service.test/unstake_validator.test.js
@@ -323,6 +323,11 @@ describe('ValidatorStakeService', function () {
expect(err.message).to.match(/could not read the chain tip/)
})
+ it('refuses before sending when the explorer reports no usable last block', async function () {
+ const err = await runUnstake({ broadcast: true }, { existing: STAKED, tip: -1 }).then(() => null, e => e)
+ expect(err && err.message).to.match(/could not read the chain tip \(.*no last block.*Nothing was sent/)
+ })
+
it('withdraws only the admissible rows and reports their sum', async function () {
const second = { ...STAKED, action_index: '61', amount: '5000', activation_block: '150320' }
const { result, unstakeCalls, logged } = await runUnstake({ broadcast: true },
diff --git a/test/unit/validator_stake_service.test/upstream_reads.test.js b/test/unit/validator_stake_service.test/upstream_reads.test.js
index 31fcfc9f..64144c1d 100644
--- a/test/unit/validator_stake_service.test/upstream_reads.test.js
+++ b/test/unit/validator_stake_service.test/upstream_reads.test.js
@@ -38,7 +38,9 @@ function fakeSdk(addressBody, tokenBody) {
}
}
-const TOKEN = { info: { tick: 'XCHAIN' }, mints: { max: '10000', address_max: '50000' } }
+// The full mints and locks groups the explorer serves for a token with an open mint.
+const TOKEN = { info: { tick: 'XCHAIN' }, mints: { max: '10000', address_max: '50000', start_block: 0, stop_block: 0 },
+ locks: { mint: false } }
describe('validator stake upstream reads', function () {