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 () {