diff --git a/.gitignore b/.gitignore
index c7e7457..0b9e720 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,8 @@ crypto_nodes/*/*.tar.gz
crypto_nodes/bitcoin/bitcoin/
crypto_nodes/dogecoin/dogecoin/
crypto_nodes/litecoin/litecoin/
+# The build writes the credential-injected conf here, beside the tracked template.
+crypto_nodes/*/*.generated.conf
# Per-coin/network node configs - never track (generated at install). The main files hold
# only non-secret operator overrides now, but stay ignored to protect any leftover files
@@ -38,3 +40,7 @@ config/*.bak*
# Coverage output (c8/nyc) - generated, not source
coverage/
.nyc_output/
+
+# Validator identity, coin wallets and the DOGE signer written by `validator init`.
+# Private keys live here (signing.key, wallets.env, signer/.env): never a repo artifact.
+config/validator/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e2a1346..03168e4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.12.2] - 2026-09-01
+
+### Added
+- `validator stake` and `validator unstake` mint, stake and withdraw against the public network, so an operator can join before installing any stack.
+- `validator init` builds the stake and publisher wallets and prints the two addresses to fund.
+
+### Fixed
+- An unrecognised service name is refused with the list of valid ones, instead of silently expanding to every service on every coin and network.
+- A coin image builds from a context that holds its Dockerfile, and never writes live credentials into the tracked config template.
+- A mutating command waits out a busy lock instead of losing the run, and a locked bootstrap create is retried by the publisher.
+- The next step printed after `validator init` names the release rather than a branch.
+
## [0.12.1] - 2026-08-31
### Fixed
diff --git a/README.md b/README.md
index 73f8959..5aa3cc5 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
-
+
@@ -84,26 +84,31 @@ as a full validator (P2P + PBFT + capability staking), generate a validator
identity first. This is offline and needs no running stack:
```bash
-xchain-node validator init \
- --seed-nodes seed1.example:10001,seed2.example:10001 \
- --p2p-addr :10001 \
- --oracle-epoch-start \
- --capabilities price,cross_chain,oracle_publish,attestation
+xchain-node validator init --network testnet --p2p-addr :10002
```
-It generates an Ed25519 signing key (saved `0600` under `config/validator/`),
-prints the **pubkey to stake XCHAIN to**, and writes a starter `capabilities.json`
-under `config/validator/hub-caps/` (its own subdirectory, mounted read-only into
-the hub container; the signing key is never mounted). Edit that file to set real
-`cross_chain` RPC endpoints and `oracle_publish` DOGE values, then install/start
-the hub. It will boot in validator mode with your key and capability config
-mounted automatically:
+It generates an Ed25519 signing key, a BTC **stake wallet** and a DOGE
+**publisher wallet** (all `0600` under `config/validator/`, git-ignored), prints
+the **pubkey to stake XCHAIN to** and the **two addresses to fund**, and writes
+`capabilities.json` under `config/validator/hub-caps/` already pointed at the
+DOGE wallet, plus the signer module the hub loads to publish price rounds and
+anchors from it. Seed nodes, network and the testnet oracle epoch default to
+the federation's values. To use keys you already hold (a vanity address, say),
+add `--import-stake-key` / `--import-doge-key`; each prompts for the WIF with
+echo off.
+
+Fund the two addresses, then stake and start:
```bash
-xchain-node install master xchain-hub
-xchain-node validator status # show pubkey, peers, capabilities
+xchain-node validator stake # dry run: balances and the plan
+xchain-node validator stake --broadcast # mints XCHAIN on testnet if short, then STAKEs
+xchain-node install master xchain-hub # boots in validator mode, signer mounted
+xchain-node validator status # pubkey, network, wallets, peers, capabilities
```
+The full walkthrough, including the BTC indexer the hub needs and how to verify
+membership on the explorer, is `xchain-documentation/operations/run-a-validator.md`.
+
## 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.
@@ -151,11 +156,11 @@ Turn it off with any of: `--no-telemetry` on any command (sticks for future runs
| Command | Description |
|---|---|
-| `npm test` | Unit tests (1,676 tests) |
+| `npm test` | Unit tests (1,766 tests) |
| `npm run test:integration` | Integration tests (103 tests) |
| `npm run test:smoke` | Smoke tests (159 tests) |
| `npm run test:boundary` | Boundary condition tests (57 tests) |
-| `npm run test:security` | Security tests (73 tests) |
+| `npm run test:security` | Security tests (74 tests) |
| `npm run test:e2e` | End-to-end tests (57 tests) |
| `npm run test:fuzz` | Fuzz tests (264 tests) |
| `npm run test:chaos` | Chaos engineering tests (121 tests) |
@@ -163,7 +168,7 @@ Turn it off with any of: `--no-telemetry` on any command (sticks for future runs
| `npm run test:regression:p0` | Regression P0: critical gate (33 tests) |
| `npm run test:regression:p0p1` | Regression P0+P1: standard gate (51 tests) |
| `npm run test:mutation` | Mutation testing (Stryker Mutator) |
-| `npm run test:all` | All tests (~2,438 tests; excludes security/boundary) |
+| `npm run test:all` | All tests (~2,528 tests; excludes security/boundary) |
| `npm run benchmark` | Performance benchmarks (5 scenarios) |
| `npm run benchmark:quick` | Quick benchmarks |
diff --git a/package-lock.json b/package-lock.json
index 93b2660..b9fb991 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.12.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
+ "@dankest-llc/xchain-sdk": "^0.11.1",
"axios": "^1.18.1",
"blessed": "^0.1.81",
"chalk": "^5.6.0",
@@ -518,6 +519,15 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -575,6 +585,65 @@
"node": ">=18"
}
},
+ "node_modules/@bitcoinerlab/secp256k1": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.2.0.tgz",
+ "integrity": "sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/curves": "^1.7.0"
+ }
+ },
+ "node_modules/@brandonblack/musig": {
+ "version": "0.0.1-alpha.1",
+ "resolved": "https://registry.npmjs.org/@brandonblack/musig/-/musig-0.0.1-alpha.1.tgz",
+ "integrity": "sha512-00RbByQG85lSzrkDjCblzrUc2n1LJAPPrEMHS4oMg+QckE0kzjd26JytT6yx6tNU2+aOXfK7O4kGW/sKVL67cw==",
+ "license": "MIT"
+ },
+ "node_modules/@dankest-llc/xchain-sdk": {
+ "version": "0.11.1",
+ "resolved": "https://registry.npmjs.org/@dankest-llc/xchain-sdk/-/xchain-sdk-0.11.1.tgz",
+ "integrity": "sha512-9s3TQwFiadltaKYPo42FyR/EJUA7oUDOM+1D2FKL7tuou2i3yy3Vxs7hT2Q3aARbPe+ED/eNtyAEGQXS2HwYNw==",
+ "license": "AGPL-3.0-or-later",
+ "dependencies": {
+ "@bitcoinerlab/secp256k1": "^1.2.0",
+ "@brandonblack/musig": "0.0.1-alpha.1",
+ "@modelcontextprotocol/sdk": "^1.29.0",
+ "@noble/curves": "1.9.1",
+ "@noble/hashes": "^1.8.0",
+ "acorn": "8.16.0",
+ "acorn-walk": "8.3.5",
+ "astring": "1.9.0",
+ "axios": "^1.18.1",
+ "bip174": "2.1.1",
+ "bitcoinjs-lib": "6.1.7",
+ "bitcoinjs-message": "^2.2.0",
+ "cors": "^2.8.5",
+ "dotenv": "^16.4.5",
+ "ecpair": "2.1.0",
+ "express": "^5.2.1",
+ "express-json-rpc-router": "^1.4.0",
+ "helmet": "^8.2.0",
+ "mathjs": "15.2.0",
+ "ws": "^8.21.0",
+ "zod": "^4.4.3"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@hono/node-server": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz",
+ "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "hono": "^4"
+ }
+ },
"node_modules/@inquirer/ansi": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz",
@@ -1056,6 +1125,73 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@modelcontextprotocol/sdk": {
+ "version": "1.30.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
+ "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
+ "license": "MIT",
+ "dependencies": {
+ "@hono/node-server": "^1.19.9 || ^2.0.5",
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "content-type": "^1.0.5",
+ "cors": "^2.8.5",
+ "cross-spawn": "^7.0.5",
+ "eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.2.1",
+ "hono": "^4.11.4",
+ "jose": "^6.1.3",
+ "json-schema-typed": "^8.0.2",
+ "pkce-challenge": "^5.0.0",
+ "raw-body": "^3.0.0",
+ "zod": "^3.25 || ^4.0",
+ "zod-to-json-schema": "^3.25.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@cfworker/json-schema": "^4.1.1",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "@cfworker/json-schema": {
+ "optional": true
+ },
+ "zod": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/@noble/curves": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz",
+ "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "1.8.0"
+ },
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1247,6 +1383,68 @@
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
"dev": true
},
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.5",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
+ "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -1263,7 +1461,6 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -1276,6 +1473,23 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
"node_modules/angular-html-parser": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/angular-html-parser/-/angular-html-parser-10.4.0.tgz",
@@ -1337,12 +1551,36 @@
"node": "*"
}
},
+ "node_modules/astring": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz",
+ "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==",
+ "license": "MIT",
+ "bin": {
+ "astring": "bin/astring"
+ }
+ },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/axios": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
@@ -1365,6 +1603,12 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/base-x": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz",
+ "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==",
+ "license": "MIT"
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz",
@@ -1378,6 +1622,108 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bech32": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz",
+ "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",
+ "integrity": "sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ==",
+ "license": "MIT",
+ "engines": {
+ "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",
+ "integrity": "sha512-tlf/r2DGMbF7ky1MgUqXHzypYHakkEnm0SZP23CJKIqNY/5uNAnMbFhMJdhjrL/7anfb/U8+AlpdjPWjPnAalg==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "^1.2.0",
+ "bech32": "^2.0.0",
+ "bip174": "^2.1.1",
+ "bs58check": "^3.0.1",
+ "typeforce": "^1.11.3",
+ "varuint-bitcoin": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "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==",
+ "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"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/bitcoinjs-message/node_modules/base-x": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz",
+ "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/bitcoinjs-message/node_modules/bech32": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz",
+ "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==",
+ "license": "MIT"
+ },
+ "node_modules/bitcoinjs-message/node_modules/bs58": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz",
+ "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==",
+ "license": "MIT",
+ "dependencies": {
+ "base-x": "^3.0.2"
+ }
+ },
+ "node_modules/bitcoinjs-message/node_modules/bs58check": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-2.1.2.tgz",
+ "integrity": "sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==",
+ "license": "MIT",
+ "dependencies": {
+ "bs58": "^4.0.0",
+ "create-hash": "^1.1.0",
+ "safe-buffer": "^5.1.2"
+ }
+ },
"node_modules/blessed": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz",
@@ -1390,6 +1736,49 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/bn.js": {
+ "version": "4.12.5",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
+ "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+ "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^2.0.0",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.1",
+ "iconv-lite": "^0.7.2",
+ "on-finished": "^2.4.1",
+ "qs": "^6.15.2",
+ "raw-body": "^3.0.2",
+ "type-is": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
@@ -1403,6 +1792,12 @@
"node": "20 || >=22"
}
},
+ "node_modules/brorand": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
+ "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==",
+ "license": "MIT"
+ },
"node_modules/browser-stdout": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
@@ -1410,6 +1805,20 @@
"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",
@@ -1444,6 +1853,49 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/bs58": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz",
+ "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "base-x": "^4.0.0"
+ }
+ },
+ "node_modules/bs58check": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz",
+ "integrity": "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "^1.2.0",
+ "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",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/c8": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz",
@@ -1477,6 +1929,24 @@
}
}
},
+ "node_modules/call-bind": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
+ "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "get-intrinsic": "^1.3.0",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1494,7 +1964,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -1608,6 +2077,20 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/cipher-base": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz",
+ "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.4",
+ "safe-buffer": "^5.2.1",
+ "to-buffer": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/cli-width": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
@@ -1714,18 +2197,120 @@
"node": ">=20"
}
},
- "node_modules/convert-source-map": {
- "version": "2.0.0",
+ "node_modules/complex.js": {
+ "version": "2.4.3",
+ "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.3.tgz",
+ "integrity": "sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/create-hash": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
+ "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
+ "license": "MIT",
+ "dependencies": {
+ "cipher-base": "^1.0.1",
+ "inherits": "^2.0.1",
+ "md5.js": "^1.3.4",
+ "ripemd160": "^2.0.1",
+ "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",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -1766,6 +2351,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "license": "MIT"
+ },
"node_modules/deep-eql": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
@@ -1779,6 +2370,23 @@
"node": ">=6"
}
},
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1797,6 +2405,15 @@
"node": ">=0.10"
}
},
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/des.js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz",
@@ -1837,6 +2454,20 @@
"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",
@@ -1858,6 +2489,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/ecpair": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/ecpair/-/ecpair-2.1.0.tgz",
+ "integrity": "sha512-cL/mh3MtJutFOvFc27GPZE2pWL3a3k4YvzUWEOvilnfZVlH3Jwgx/7d6tlD7/75tNk8TG2m+7Kgtz0SI1tWcqw==",
+ "license": "MIT",
+ "dependencies": {
+ "randombytes": "^2.1.0",
+ "typeforce": "^1.18.0",
+ "wif": "^2.0.6"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.397",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.397.tgz",
@@ -1865,6 +2516,21 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/elliptic": {
+ "version": "6.6.1",
+ "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz",
+ "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==",
+ "license": "MIT",
+ "dependencies": {
+ "bn.js": "^4.11.9",
+ "brorand": "^1.1.0",
+ "hash.js": "^1.0.0",
+ "hmac-drbg": "^1.0.1",
+ "inherits": "^2.0.4",
+ "minimalistic-assert": "^1.0.1",
+ "minimalistic-crypto-utils": "^1.0.1"
+ }
+ },
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
@@ -1872,6 +2538,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/enquirer": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz",
@@ -1940,6 +2615,18 @@
"node": ">=6"
}
},
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/escape-latex": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz",
+ "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==",
+ "license": "MIT"
+ },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -1953,6 +2640,46 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/eventsource": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+ "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+ "license": "MIT",
+ "dependencies": {
+ "eventsource-parser": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/eventsource-parser": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
+ "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
+ "license": "MIT",
+ "engines": {
+ "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",
@@ -1980,11 +2707,118 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-json-rpc-router": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/express-json-rpc-router/-/express-json-rpc-router-1.4.0.tgz",
+ "integrity": "sha512-j8GvAu0wEk3AFBGNvbOPIULUUUAM6urEWujYJKUTLKe9P43vRBHVme6/9YHqRkdbUYeGnPTcgPhmpbpCtlIsyg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8.0"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "8.7.0",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
+ "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "ip-address": "^10.2.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/express/node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/express/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true,
"license": "MIT"
},
"node_modules/fast-string-truncated-width": {
@@ -2008,7 +2842,6 @@
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -2047,6 +2880,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "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",
@@ -2061,6 +2900,27 @@
"node": ">=0.10.0"
}
},
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -2108,6 +2968,21 @@
}
}
},
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -2153,6 +3028,37 @@
"node": ">= 0.4"
}
},
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -2290,6 +3196,18 @@
"node": ">=8"
}
},
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -2317,6 +3235,31 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/hash-base": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz",
+ "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.4",
+ "readable-stream": "^2.3.8",
+ "safe-buffer": "^5.2.1",
+ "to-buffer": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/hash.js": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
+ "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "minimalistic-assert": "^1.0.1"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
@@ -2339,12 +3282,64 @@
"he": "bin/he"
}
},
+ "node_modules/helmet": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
+ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/EvanHahn"
+ }
+ },
+ "node_modules/hmac-drbg": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
+ "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==",
+ "license": "MIT",
+ "dependencies": {
+ "hash.js": "^1.0.3",
+ "minimalistic-assert": "^1.0.0",
+ "minimalistic-crypto-utils": "^1.0.1"
+ }
+ },
+ "node_modules/hono": {
+ "version": "4.13.5",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz",
+ "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true
},
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -2388,9 +3383,38 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true,
"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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@@ -2450,6 +3474,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
"node_modules/is-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
@@ -2463,6 +3493,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-typed-array": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+ "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-unicode-supported": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
@@ -2476,11 +3521,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
@@ -2547,6 +3597,21 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
+ "node_modules/javascript-natural-sort": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
+ "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==",
+ "license": "MIT"
+ },
+ "node_modules/jose": {
+ "version": "6.2.10",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz",
+ "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-md4": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz",
@@ -2608,9 +3673,14 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
- "dev": true,
"license": "MIT"
},
+ "node_modules/json-schema-typed": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -2789,6 +3859,53 @@
"node": ">= 0.4"
}
},
+ "node_modules/mathjs": {
+ "version": "15.2.0",
+ "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.2.0.tgz",
+ "integrity": "sha512-UAQzSVob9rNLdGpqcFMYmSu9dkuLYy7Lr2hBEQS5SHQdknA9VppJz3cy2KkpMzTODunad6V6cNv+5kOLsePLow==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@babel/runtime": "^7.26.10",
+ "complex.js": "^2.2.5",
+ "decimal.js": "^10.4.3",
+ "escape-latex": "^1.2.0",
+ "fraction.js": "^5.2.1",
+ "javascript-natural-sort": "^0.7.1",
+ "seedrandom": "^3.0.5",
+ "tiny-emitter": "^2.1.0",
+ "typed-function": "^4.2.1"
+ },
+ "bin": {
+ "mathjs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/md5.js": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
+ "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==",
+ "license": "MIT",
+ "dependencies": {
+ "hash-base": "^3.0.0",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
+ "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
@@ -2824,9 +3941,14 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
- "dev": true,
"license": "ISC"
},
+ "node_modules/minimalistic-crypto-utils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
+ "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==",
+ "license": "MIT"
+ },
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -2950,6 +4072,41 @@
"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/negotiator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
+ "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/negotiator/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.51",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
@@ -2990,11 +4147,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3003,6 +4168,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -3055,6 +4241,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -3069,7 +4264,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3106,6 +4300,16 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/pathval": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
@@ -3123,6 +4327,24 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/pkce-challenge": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+ "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/pretty-ms": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
@@ -3139,6 +4361,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -3149,6 +4377,19 @@
"node": ">=0.4.0"
}
},
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -3174,7 +4415,6 @@
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
- "dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
@@ -3187,6 +4427,64 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/randombytes": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+ "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/readable-stream/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -3215,7 +4513,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3237,10 +4534,39 @@
"resolve": "bin/resolve"
},
"engines": {
- "node": ">= 0.4"
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/ripemd160": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz",
+ "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==",
+ "license": "MIT",
+ "dependencies": {
+ "hash-base": "^3.1.2",
+ "inherits": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
},
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "engines": {
+ "node": ">= 18"
}
},
"node_modules/rxjs": {
@@ -3253,12 +4579,58 @@
"tslib": "^2.1.0"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"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==",
+ "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"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/seedrandom": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
+ "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
+ "license": "MIT"
+ },
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -3271,6 +4643,57 @@
"node": ">=10"
}
},
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/send/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/send/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/serialize-javascript": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz",
@@ -3281,11 +4704,72 @@
"node": ">=20.0.0"
}
},
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/sha.js": {
+ "version": "2.4.12",
+ "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz",
+ "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==",
+ "license": "(MIT AND BSD-3-Clause)",
+ "dependencies": {
+ "inherits": "^2.0.4",
+ "safe-buffer": "^5.2.1",
+ "to-buffer": "^1.2.0"
+ },
+ "bin": {
+ "sha.js": "bin.js"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -3298,7 +4782,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3308,7 +4791,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3328,7 +4810,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3345,7 +4826,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -3364,7 +4844,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -3430,6 +4909,30 @@
"node": ">= 12"
}
},
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/string_decoder/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -3644,6 +5147,41 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/tiny-emitter": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
+ "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
+ "license": "MIT"
+ },
+ "node_modules/to-buffer": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
+ "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==",
+ "license": "MIT",
+ "dependencies": {
+ "isarray": "^2.0.5",
+ "safe-buffer": "^5.2.1",
+ "typed-array-buffer": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/to-buffer/node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "license": "MIT"
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -3681,6 +5219,85 @@
"node": ">=4"
}
},
+ "node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/type-is/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/typed-array-buffer": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
+ "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/typed-function": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz",
+ "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/typed-inject": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/typed-inject/-/typed-inject-5.0.0.tgz",
@@ -3708,6 +5325,12 @@
"node": ">= 16.0.0"
}
},
+ "node_modules/typeforce": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz",
+ "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==",
+ "license": "MIT"
+ },
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
@@ -3728,6 +5351,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -3759,6 +5391,12 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@@ -3773,6 +5411,24 @@
"node": ">=10.12.0"
}
},
+ "node_modules/varuint-bitcoin": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-1.1.2.tgz",
+ "integrity": "sha512-4EVb+w4rx+YfVM32HQX42AbbT7/1f5zwAYhIujKXKk8NQK+JfRVl3pqT3hjNn/L+RstigmGGKVwHA/P0wgITZw==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "^5.1.1"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/weapon-regex": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/weapon-regex/-/weapon-regex-1.3.6.tgz",
@@ -3784,7 +5440,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -3796,6 +5451,65 @@
"node": ">= 8"
}
},
+ "node_modules/which-typed-array": {
+ "version": "1.1.22",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
+ "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==",
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/wif": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/wif/-/wif-2.0.6.tgz",
+ "integrity": "sha512-HIanZn1zmduSF+BQhkE+YXIbEiH0xPr1012QbFEGB0xsKqJii0/SqJjyn8dFv6y36kOznMgMB+LGcbZTJ1xACQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bs58check": "<3.0.0"
+ }
+ },
+ "node_modules/wif/node_modules/base-x": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz",
+ "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/wif/node_modules/bs58": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz",
+ "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==",
+ "license": "MIT",
+ "dependencies": {
+ "base-x": "^3.0.2"
+ }
+ },
+ "node_modules/wif/node_modules/bs58check": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-2.1.2.tgz",
+ "integrity": "sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==",
+ "license": "MIT",
+ "dependencies": {
+ "bs58": "^4.0.0",
+ "create-hash": "^1.1.0",
+ "safe-buffer": "^5.1.2"
+ }
+ },
"node_modules/workerpool": {
"version": "9.3.4",
"resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz",
@@ -3844,6 +5558,33 @@
"node": ">=8"
}
},
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/ws": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -3968,11 +5709,19 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
- "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+ },
+ "node_modules/zod-to-json-schema": {
+ "version": "3.25.2",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+ "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "zod": "^3.25.28 || ^4"
+ }
}
}
}
diff --git a/package.json b/package.json
index ccfa947..6645910 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "xchain-node",
- "version": "0.12.1",
+ "version": "0.12.2",
"description": "xchain-node allows users to install, configure and run XChain platform nodes.",
"license": "AGPL-3.0-or-later",
"repository": {
@@ -9,6 +9,7 @@
},
"main": "src/index.js",
"dependencies": {
+ "@dankest-llc/xchain-sdk": "^0.11.1",
"axios": "^1.18.1",
"blessed": "^0.1.81",
"chalk": "^5.6.0",
@@ -60,7 +61,8 @@
"serialize-javascript": "^7.0.5",
"tmp": "^0.2.7",
"js-yaml": "^4.3.1",
- "form-data": "^4.0.6"
+ "form-data": "^4.0.6",
+ "ip-address": "^10.3.1"
},
"bin": {
"xchain-node": "./src/index.js"
diff --git a/scripts/publish-bootstraps.sh b/scripts/publish-bootstraps.sh
index 9810283..245d281 100755
--- a/scripts/publish-bootstraps.sh
+++ b/scripts/publish-bootstraps.sh
@@ -32,6 +32,11 @@
# good archive in place as the newest. This exists because the weekly cron
# once published a halted litecoin/mainnet decoder as the newest "good"
# archive, which every "take the latest" path then selected.
+# - Waits out a concurrent xchain-node command rather than losing the run.
+# `bootstrap create` is MUTATING, so any update/reset holds the command lock
+# and every combo fails at once; a create is retried for $LOCK_WAIT_MIN
+# minutes, then reported LOCKED (busy box, not a broken source). The retry
+# is here rather than in the CLI so it works against a pinned fleet too.
# - Redirects the (large) work + output dirs onto a roomy volume via
# XCHAIN_NODE_DATA_DIR / XCHAIN_NODE_TMP_DIR - the defaults live under the
# repo on the small root fs and WILL fill it mid-create otherwise.
@@ -88,6 +93,8 @@ SYNC_HOST="${SYNC_HOST:-user@your-sync-host}"
SYNC_DIR="${SYNC_DIR:-/misc/backups/bootstraps}"
KEEP="${KEEP:-2}" # archives to retain per combo (local + remote)
LOCK_FILE="${LOCK_FILE:-/tmp/publish-bootstraps.lock}"
+LOCK_WAIT_MIN="${LOCK_WAIT_MIN:-30}" # minutes to wait out a concurrent xchain-node command (0 = refuse at once)
+LOCK_POLL_SEC="${LOCK_POLL_SEC:-30}" # seconds between those attempts
TRACKER_SVC="xchain-utxo-tracker"
ALL_SERVICES=(xchain-decoder xchain-indexer xchain-utxo-tracker)
@@ -241,15 +248,35 @@ for c in "${SELECTED[@]}"; do
# two need different operator responses: a refusal means the SERVICE is broken
# (and the last good archive correctly stays newest), a create-fail means the
# publish machinery is broken.
- create_log="$(mktemp)"
- create_rc=0
- XCHAIN_NODE_BOOTSTRAP_SIGNING_KEY="$SIGNING_KEY" \
- XCHAIN_NODE_DATA_DIR="$STAGE_DIR" \
- XCHAIN_NODE_TMP_DIR="$TMP_DIR" \
- "$XCHAIN_NODE_BIN" bootstrap create "$svc" "$coin" "$net" >"$create_log" 2>&1 || create_rc=$?
+ # The wait for a lock holder lives HERE, not in the CLI, so it works against
+ # any CLI the box happens to carry (a fleet pinned to a release predates the
+ # CLI-side wait). XCHAIN_NODE_MUTATING_LOCK_WAIT_MS is pinned to 0 so a newer
+ # CLI does not also wait and multiply the budget.
+ lock_deadline=$(( $(date +%s) + LOCK_WAIT_MIN * 60 ))
+ while : ; do
+ create_log="$(mktemp)"
+ create_rc=0
+ XCHAIN_NODE_BOOTSTRAP_SIGNING_KEY="$SIGNING_KEY" \
+ XCHAIN_NODE_DATA_DIR="$STAGE_DIR" \
+ XCHAIN_NODE_TMP_DIR="$TMP_DIR" \
+ XCHAIN_NODE_MUTATING_LOCK_WAIT_MS=0 \
+ "$XCHAIN_NODE_BIN" bootstrap create "$svc" "$coin" "$net" >"$create_log" 2>&1 || create_rc=$?
+ if [ "$create_rc" = 0 ]; then break; fi
+ if ! grep -q 'holds the command lock' "$create_log"; then break; fi
+ if [ "$(date +%s)" -ge "$lock_deadline" ]; then break; fi
+ log " locked by another xchain-node command; retrying in ${LOCK_POLL_SEC}s (budget ${LOCK_WAIT_MIN}m)"
+ rm -f "$create_log"
+ sleep "$LOCK_POLL_SEC"
+ done
cat "$create_log"
if [ "$create_rc" != 0 ]; then
- if grep -q 'Refusing to create a bootstrap' "$create_log"; then
+ # Contention is neither a broken publisher nor a broken source: the box was
+ # busy. Name it so, or CREATE-FAIL sends the operator hunting a fault that
+ # does not exist.
+ if grep -q 'holds the command lock' "$create_log"; then
+ log " LOCKED: $c - another xchain-node command still held the command lock after ${LOCK_WAIT_MIN}m. Nothing published; the previous archive stays newest."
+ SUMMARY+=("$c: LOCKED")
+ elif grep -q 'Refusing to create a bootstrap' "$create_log"; then
log " REFUSED: $c source is not known-good (reasons above). Nothing published; the previous archive stays newest."
SUMMARY+=("$c: SOURCE-UNHEALTHY")
else
diff --git a/src/cli.js b/src/cli.js
index bdb284c..5596787 100644
--- a/src/cli.js
+++ b/src/cli.js
@@ -41,7 +41,10 @@ const { getStatus } = require('./services/StatusService')
const { scanAndRegisterModules } = require('./services/DiscoveryService')
const { maybeReportTelemetry } = require('./services/TelemetryService')
const { makeBootstrap, listServedBootstrapCombos } = require('./services/BootstrapService')
-const { initValidator, getValidatorSettings, isInitialized, getCapabilityConfigHostPath } = require('./services/ValidatorService')
+const { initValidator, getValidatorSettings, isInitialized, getCapabilityConfigHostPath,
+ readWallets, publicWalletInfo, getSignerMountDir, COIN_NETWORKS, WALLETS_FILE,
+ getRollcallStatus } = require('./services/ValidatorService')
+const { stakeValidator, unstakeValidator } = require('./services/ValidatorStakeService')
const { restoreBootstrapInterface, startInterface } = require('./ui/menu')
const { acquireCommandLock } = require('./utils/commandLock')
@@ -117,6 +120,10 @@ async function parseCommand() {
// giving up (bounded so a read-only command pauses, then errors clearly,
// rather than corrupting the stack by provisioning concurrently). Tunable.
const LOCK_WAIT_MS = parseInt(process.env.XCHAIN_NODE_LOCK_WAIT_MS || '15000', 10) || 15000
+ // How long a MUTATING command blocks for a lock holder before refusing. Zero
+ // keeps the interactive contract below; an unattended caller sets it so a
+ // scheduled run waits out a deploy instead of losing its work.
+ const MUTATING_LOCK_WAIT_MS = parseInt(process.env.XCHAIN_NODE_MUTATING_LOCK_WAIT_MS || '0', 10) || 0
program.hook('preAction', async (thisCommand, actionCommand) => {
setVerbose(thisCommand.opts().verbose ?? false)
if (thisCommand.opts().verbose) console.log("Checking xchain-node structure")
@@ -136,7 +143,7 @@ async function parseCommand() {
//
// A mutating command keeps the lock through its whole action (released on
// process exit, since actions terminate via process.exit()) and refuses
- // immediately if another instance holds it. A non-mutating command holds
+ // a held lock unless asked to wait. A non-mutating command holds
// the lock only across preCheck, releasing it right after, so a
// long-running `monitor`/`tail`/`logs` does not pin the lock for its
// lifetime; it waits a bounded time for a busy mutator, then errors.
@@ -145,7 +152,7 @@ async function parseCommand() {
try {
release = acquireCommandLock({
command: commandName,
- waitMs: holdThroughAction ? 0 : LOCK_WAIT_MS
+ waitMs: holdThroughAction ? MUTATING_LOCK_WAIT_MS : LOCK_WAIT_MS
})
} catch (err) {
console.error(err.message)
@@ -595,18 +602,64 @@ Notes:
.description('Generate a validator signing key + config so the hub runs in validator mode')
.option('--seed-nodes ', 'comma-separated peer addresses (host:port,host:port)')
.option('--p2p-addr ', 'this validator\'s public address (host:port)')
- .option('--p2p-port ', 'P2P listen port (default 10001)')
- .option('--oracle-epoch-start ', 'shared oracle epoch start (unix ms); must match the federation')
+ .option('--p2p-port ', 'P2P listen port (10002 testnet, 10001 mainnet; default 10001)')
+ .option('--network ', 'federation to join: testnet or mainnet (default: implied by --p2p-port)')
+ .option('--oracle-epoch-start ', 'shared oracle epoch start (unix ms); defaults to the known federation value')
.option('--capabilities ', 'enabled capabilities (default price,cross_chain,oracle_publish,attestation)')
- .option('--force', 'overwrite existing validator config (generates a NEW key)')
+ .option('--import-stake-key', 'use your own BTC stake key: prompts for the WIF (or set XCHAIN_NODE_STAKE_WIF)')
+ .option('--import-doge-key', 'use your own DOGE publisher key: prompts for the WIF (or set XCHAIN_NODE_DOGE_WIF)')
+ .option('--no-wallets', 'skip wallet generation (you run your own signer via XCHAIN_NODE_HUB_SIGNER_DIR)')
+ .option('--force', 'overwrite existing validator config (generates a NEW signing key; wallets are kept)')
+ .option('--force-wallets', 'also replace existing wallets (the old addresses and any coin at them are abandoned)')
.action(async (opts) => {
- await initValidator(opts)
+ try {
+ await initValidator(opts)
+ } catch (e) {
+ console.error('\nERROR: ' + e.message + '\n')
+ return process.exit(1)
+ }
+ return process.exit(0)
+ })
+
+ validator
+ .command('stake')
+ .description('Mint XCHAIN if short (testnet) and broadcast the STAKE naming this validator\'s pubkey; dry run without --broadcast')
+ .option('--amount ', 'amount to stake (default 25000, clears every capability floor)')
+ .option('--broadcast', 'actually send the transactions (default: print the plan only)')
+ .option('--no-wait', 'return once the STAKE is broadcast instead of waiting for it to index')
+ .option('--serialize', 'send one action per block (default: chained back to back into one block)')
+ .option('--fee-per-kb ', 'fee rate in coin per kB (default: the encoder\'s estimate)')
+ .option('--timeout ', 'how long to wait for the stake to index (default 120)')
+ .action(async (opts) => {
+ try {
+ await stakeValidator(opts)
+ } catch (e) {
+ console.error('\nERROR: ' + e.message + '\n')
+ return process.exit(1)
+ }
+ return process.exit(0)
+ })
+
+ validator
+ .command('unstake')
+ .description('Withdraw this validator\'s stake and leave the active set; dry run without --broadcast')
+ .option('--broadcast', 'actually send the transaction (default: print the plan only)')
+ .option('--no-wait', 'return once broadcast instead of waiting for it to index')
+ .option('--fee-per-kb ', 'fee rate in coin per kB (default: the encoder\'s estimate)')
+ .option('--timeout ', 'how long to wait for it to index (default 120)')
+ .action(async (opts) => {
+ try {
+ await unstakeValidator(opts)
+ } catch (e) {
+ console.error('\nERROR: ' + e.message + '\n')
+ return process.exit(1)
+ }
return process.exit(0)
})
validator
.command('status')
- .description('Show this node\'s validator configuration (pubkey, peers, capabilities)')
+ .description('Show this node\'s validator configuration (pubkey, wallets, peers, capabilities)')
.action(async () => {
const s = getValidatorSettings()
if (!s) {
@@ -616,6 +669,7 @@ Notes:
} else {
console.log('Validator enabled.')
console.log(' pubkey : ' + s.pubkey)
+ console.log(' network : ' + (s.network || '(unset; set HUB_NETWORK in .env)'))
console.log(' p2p address : ' + s.P2P_VALIDATOR_ADDR)
console.log(' seed nodes : ' + ((s.SEED_NODES || []).join(', ') || '(none)'))
console.log(' oracle epoch : ' + (s.ORACLE_EPOCH_START || '(unset, required before oracle runs)'))
@@ -624,6 +678,56 @@ Notes:
// bind mount cannot break `docker cp`), and this is where an operator
// coming from an older install finds it after the migration.
console.log(' caps config : ' + (getCapabilityConfigHostPath() || '(missing; re-run validator init)'))
+ // Addresses only. The keys stay in the 0600 file.
+ const w = publicWalletInfo(readWallets())
+ const coins = COIN_NETWORKS[(w && w.network) || s.network] || { stakeCoin: 'coin', dogeCoin: 'DOGE' }
+ if (w) {
+ console.log(' stake wallet : ' + w.stakeAddress + ' (' + coins.stakeCoin + ' for fees, holds the XCHAIN stake)')
+ console.log(' DOGE wallet : ' + w.dogeAddress + ' (' + coins.dogeCoin + ' for price rounds and anchors)')
+ console.log(' keys file : ' + WALLETS_FILE + ' (mode 0600; back it up)')
+ console.log(' DOGE signer : ' + (process.env.XCHAIN_NODE_HUB_SIGNER_DIR
+ ? process.env.XCHAIN_NODE_HUB_SIGNER_DIR + ' (operator-supplied, XCHAIN_NODE_HUB_SIGNER_DIR)'
+ : (getSignerMountDir() || '(missing; re-run validator init)')))
+ } else {
+ console.log(' wallets : (none; re-run validator init, or run your own signer via XCHAIN_NODE_HUB_SIGNER_DIR)')
+ }
+ // ROLLCALL reporting: the DOGE runway (reusing the address read above,
+ // never a second fetch), whether the configured signer can PUBLISH a
+ // roll call rather than only sign one, and this key's BTC-side absence
+ // streak. Each degrades to its own "unavailable" line instead of
+ // crashing the whole command or printing a reassuring zero.
+ const rollcall = await getRollcallStatus(w, (w && w.network) || s.network)
+ if (rollcall.doge) {
+ console.log(rollcall.doge.unavailable
+ ? ' DOGE runway : unavailable (could not read the DOGE wallet balance' +
+ (rollcall.doge.error ? ': ' + rollcall.doge.error : '') + ')'
+ : ' DOGE runway : ' + rollcall.doge.balance + ' ' + coins.dogeCoin + ' confirmed, ~' +
+ rollcall.doge.rollcalls + ' roll call(s) of runway (~0.006 ' + coins.dogeCoin +
+ ' each: two ~0.003 ' + coins.dogeCoin + ' transactions)')
+ }
+ if (rollcall.broadcast) {
+ console.log(rollcall.broadcast.exportsBroadcast
+ ? ' roll call : this signer exports broadcast, so it can publish roll calls'
+ : ' roll call : NO broadcast export in ' + rollcall.broadcast.file +
+ ' - it can SIGN a roll call but never PUBLISH one, silently. Add broadcast(payload) ' +
+ 'or use the CLI-generated signer.')
+ }
+ if (rollcall.absences) {
+ if (rollcall.absences.unavailable) {
+ console.log(' roll call absences (BTC): unavailable (' +
+ (rollcall.absences.error || rollcall.absences.reason || 'indexer read failed') +
+ '); check the explorer before assuming this key is safe')
+ } else if (rollcall.absences.streak === 0) {
+ console.log(' roll call absences (BTC): none on record')
+ } else if (rollcall.absences.streak === 1) {
+ console.log(' roll call absences (BTC): 1 (warning shot; one more consecutive miss evicts this stake)')
+ } else {
+ console.log(' roll call absences (BTC): ' + rollcall.absences.streak +
+ (rollcall.absences.evictedNow ? ' EVICTED - dropped from every capability set' : ''))
+ }
+ }
+ console.log('')
+ console.log(' On-chain membership: xchain-node validator stake (dry run shows balances and the stake)')
}
return process.exit(0)
})
diff --git a/src/release-manifest.json b/src/release-manifest.json
index ce77822..210d000 100644
--- a/src/release-manifest.json
+++ b/src/release-manifest.json
@@ -1,65 +1,66 @@
{
- "_comment": [
- "Pinned component set for XChain Platform v0.12.1.",
- "Written at ceremony step 6 from the ACTUAL tagged master merge commits.",
- "xchain-node is the carrier and is not listed: checking out its tag IS this manifest.",
- "Every module in constants.js modulesUrls is pinned here, all 12 of them.",
- "A patch train tags only the repos it touches, so every component except the",
- "patched one still pins its v0.12.0 tag, and each of those tags is unchanged.",
- "Each commit below is the master MERGE commit its tag names, and every tag is",
- "GPG-signed by the platform release key and reports verified against the",
- "tagger identity releases@xchain.io."
- ],
- "platform_version": "0.12.1",
- "released": "2026-08-31",
- "components": {
- "xchain-vm": {
- "tag": "v0.12.0",
- "commit": "d58db3af879e072d0255cb5b6bb9cfd3e78cbd0c"
- },
- "xchain-decoder": {
- "tag": "v0.12.0",
- "commit": "9f333337c1362ff2651aa8877d7a5445476b5b12"
- },
- "xchain-indexer": {
- "tag": "v0.12.1",
- "commit": "aee2bf21ecd6dc0fa90c26e7108dbedceb609bda"
- },
- "xchain-hub": {
- "tag": "v0.12.0",
- "commit": "638f2e13b2d9b4b86f6dda9e5dc88455f27a6c55"
- },
- "xchain-sync": {
- "tag": "v0.12.0",
- "commit": "62ff717444a302ce27b9fdcd167d62446a29a6ee"
- },
- "xchain-encoder": {
- "tag": "v0.12.0",
- "commit": "7b88df99958016e6f993fcd31a854cb5f985af20"
- },
- "xchain-utxo-tracker": {
- "tag": "v0.12.0",
- "commit": "34949dee6e2a94f5e0557d22296409555e6867cf"
- },
- "xchain-explorer": {
- "tag": "v0.12.0",
- "commit": "b13d41e63f5ab4250da0023b6f12ae5c9e585b69"
- },
- "xchain-sdk": {
- "tag": "v0.12.0",
- "commit": "ba223637c77230c671e2f35f40a43a8b33b4220c"
- },
- "xchain-e2e-test": {
- "tag": "v0.12.0",
- "commit": "4da014da256e78b7b00982e122492f2bb5ede703"
- },
- "xchain-contracts": {
- "tag": "v0.12.0",
- "commit": "426067b26c66377e8875ca9826a8913fa32e9699"
- },
- "xchain-regtest-miner": {
- "tag": "v0.12.0",
- "commit": "ac0038f876d99435d4e82ade8ce3b7dfc6214e36"
+ "_comment": [
+ "Pinned component set for XChain Platform v0.12.2.",
+ "Written at ceremony step 6 from the ACTUAL tagged master merge commits.",
+ "xchain-node is the carrier and is not listed: checking out its tag IS this manifest.",
+ "A patch train tags only the repos it touches. This one moves the CARRIER ONLY:",
+ "every component below is unchanged from v0.12.1 and keeps the tag it already",
+ "carries, which is what section 4 means by a version being the platform version",
+ "at which a component last changed. A gap is unchanged, not skipped.",
+ "Each commit below is the master MERGE commit its tag names, and every tag is",
+ "GPG-signed by the platform release key and reports verified against the",
+ "tagger identity releases@xchain.io."
+ ],
+ "platform_version": "0.12.2",
+ "released": "2026-09-01",
+ "components": {
+ "xchain-vm": {
+ "tag": "v0.12.0",
+ "commit": "d58db3af879e072d0255cb5b6bb9cfd3e78cbd0c"
+ },
+ "xchain-decoder": {
+ "tag": "v0.12.0",
+ "commit": "9f333337c1362ff2651aa8877d7a5445476b5b12"
+ },
+ "xchain-indexer": {
+ "tag": "v0.12.1",
+ "commit": "aee2bf21ecd6dc0fa90c26e7108dbedceb609bda"
+ },
+ "xchain-hub": {
+ "tag": "v0.12.0",
+ "commit": "638f2e13b2d9b4b86f6dda9e5dc88455f27a6c55"
+ },
+ "xchain-sync": {
+ "tag": "v0.12.0",
+ "commit": "62ff717444a302ce27b9fdcd167d62446a29a6ee"
+ },
+ "xchain-encoder": {
+ "tag": "v0.12.0",
+ "commit": "7b88df99958016e6f993fcd31a854cb5f985af20"
+ },
+ "xchain-utxo-tracker": {
+ "tag": "v0.12.0",
+ "commit": "34949dee6e2a94f5e0557d22296409555e6867cf"
+ },
+ "xchain-explorer": {
+ "tag": "v0.12.0",
+ "commit": "b13d41e63f5ab4250da0023b6f12ae5c9e585b69"
+ },
+ "xchain-sdk": {
+ "tag": "v0.12.0",
+ "commit": "ba223637c77230c671e2f35f40a43a8b33b4220c"
+ },
+ "xchain-e2e-test": {
+ "tag": "v0.12.0",
+ "commit": "4da014da256e78b7b00982e122492f2bb5ede703"
+ },
+ "xchain-contracts": {
+ "tag": "v0.12.0",
+ "commit": "426067b26c66377e8875ca9826a8913fa32e9699"
+ },
+ "xchain-regtest-miner": {
+ "tag": "v0.12.0",
+ "commit": "ac0038f876d99435d4e82ade8ce3b7dfc6214e36"
+ }
}
- }
}
diff --git a/src/services/ConfigService.js b/src/services/ConfigService.js
index c0072b0..28f6b91 100644
--- a/src/services/ConfigService.js
+++ b/src/services/ConfigService.js
@@ -1114,10 +1114,25 @@ async function getDefaultConfig(module, coin, network) {
return defaultConfig
}
+// Short names operators actually type for the shared services, whose canonical
+// names carry an `xchain-` prefix that is easy to omit. 'explorer' already had
+// this treatment; the others did not, so `recreate hub` matched no container and
+// looked like the hub was simply unsupported.
+const SERVICE_ALIASES = {
+ hub: HUB_MODULE_NAME,
+ sync: SYNC_MODULE_NAME,
+ db: DB_MODULE_NAME
+}
+
function filterCommandParameters(branch, modules, coins, networks) {
const servicesList = {}
let addExplorer = false
+ // Callers that bypass resolveArgs (recreate, start/stop/restart, logs) hand
+ // the operator's raw token straight through, so the alias map has to apply
+ // here too.
+ if (modules && SERVICE_ALIASES[modules]) modules = SERVICE_ALIASES[modules]
+
if (coins && coins !== "all") {
coins = [coins]
} else {
@@ -1194,14 +1209,27 @@ function resolveArgs(args, { expectBranch = false, defaultBranch = 'master' } =
throw new Error("'xchain-node' is the CLI itself, not an installable service. Omit it to operate on all services, or name a specific one (e.g. xchain-indexer, xchain-decoder, xchain-hub).")
}
+ const aliased = SERVICE_ALIASES[arg]
+
if (knownChains.includes(arg)) {
chain = arg
} else if (knownNetworks.includes(arg)) {
network = arg
} else if (knownServices.includes(arg)) {
service = arg
+ } else if (aliased) {
+ service = aliased
} else if (expectBranch && !branch) {
branch = arg
+ } else {
+ // Nothing claimed this token. The 'xchain-node' guard above exists
+ // because a dropped token leaves service='all', and that trap is not
+ // specific to that one name: `install master hub` silently expanded to
+ // EVERY service on every coin and network rather than refusing. An
+ // operator reaching for one service must never get all of them.
+ throw new Error(`Unrecognized argument '${arg}'. Valid services: ` +
+ knownServices.filter(s => s !== 'explorer').sort().join(', ') + '. ' +
+ `Valid coins: ${knownChains.join(', ')}. Networks: ${knownNetworks.join(', ')}.`)
}
}
diff --git a/src/services/ModuleService.js b/src/services/ModuleService.js
index 8908b73..98feca4 100644
--- a/src/services/ModuleService.js
+++ b/src/services/ModuleService.js
@@ -736,6 +736,22 @@ function buildModuleDockerArgs(module, environmentVariables, coin, network) {
// in-container path. No-op when unconfigured.
if (process.env.XCHAIN_NODE_HUB_SIGNER_DIR && fs.existsSync(process.env.XCHAIN_NODE_HUB_SIGNER_DIR)) {
volumeArgs.push('-v', `${process.env.XCHAIN_NODE_HUB_SIGNER_DIR}:/XChainHub/operator-signer:ro`)
+ } else {
+ // The signer `validator init` wrote. Its signer.js requires the
+ // SDK, resolved from a node_modules mounted beside it: this
+ // package's own node_modules, so init never has to run npm and
+ // the container sees exactly the SDK the CLI uses. The SDK mount
+ // lands INSIDE the read-only signer mount, which docker can only
+ // do when the mountpoint already exists on the host (otherwise
+ // container creation fails outright); getSignerMountDir()
+ // guarantees that directory before naming the mount.
+ const { getSignerMountDir, SIGNER_CONTAINER_DIR } = require('./ValidatorService')
+ const signerDir = getSignerMountDir()
+ if (signerDir) {
+ const nodeModules = path.resolve(__dirname, '..', '..', 'node_modules')
+ volumeArgs.push('-v', `${signerDir}:${SIGNER_CONTAINER_DIR}:ro`)
+ volumeArgs.push('-v', `${nodeModules}:${SIGNER_CONTAINER_DIR}/node_modules:ro`)
+ }
}
}
}
diff --git a/src/services/NodeService.js b/src/services/NodeService.js
index e3eac56..122ba8b 100644
--- a/src/services/NodeService.js
+++ b/src/services/NodeService.js
@@ -31,6 +31,14 @@ const nodeVersion = process.versions.node
const { gitHubDownloader, db, getRemoteModuleVersions } = require('../state')
const { decompressTarGz } = require('../utils/helpers')
const { cryptoNodesDir } = require('../config/constants')
+
+// The repo's own crypto_nodes tree, which ships each coin's Dockerfile and conf
+// templates in git. Deliberately NOT a constant sourced from config/constants and
+// NOT env-overridable: cryptoNodesDir says where a node is BUILT and may point at
+// a separate volume, while this says where the build scaffold is READ FROM, which
+// is always the source tree. Conflating the two is the bug this pair exists to
+// prevent.
+const bundledCryptoNodesDir = path.join(__dirname, '..', '..', 'crypto_nodes')
const { getDockerContainerImageName, getDockerNetwork, getDefaultConfig, validatePort } = require('./ConfigService')
const { statusChanged } = require('./StatusService')
const { checkRemoteNodeVersion } = require('./VersionService')
@@ -283,6 +291,58 @@ function ensureHostDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true })
}
+// The image is built with the coin's own directory as the Docker context, so
+// that directory must hold BOTH the downloaded daemon tree and the Dockerfile +
+// conf template the repo ships in git. Only the daemon tree is downloaded there,
+// so on the default layout the scaffold is present by accident: cryptoNodesDir
+// already IS the repo's crypto_nodes. Point XCHAIN_NODE_CRYPTO_NODES_DIR at a
+// separate volume (which the README recommends for a small root partition) and
+// the build context holds nothing but the tarball, so `docker build` reports
+// "failed to read dockerfile" after the whole download has already run.
+//
+// Copy the scaffold in before every build. Returns the conf file's basename for
+// the CONF_FILE build arg. Fails closed with the missing path named, rather than
+// letting docker report a two-byte build context.
+function stageBuildScaffold(coin, network, nodeDir, defaultConfig) {
+ const bundledCoinDir = path.join(bundledCryptoNodesDir, coin)
+ const confFileName = `${coin}-${network}.conf`
+
+ const dockerfileSrc = path.join(bundledCoinDir, 'Dockerfile')
+ const confSrc = path.join(bundledCoinDir, confFileName)
+ for (const src of [dockerfileSrc, confSrc]) {
+ if (!fs.existsSync(src)) {
+ throw new Error(`Missing build scaffold ${src}. The repo ships crypto_nodes/${coin}/ in git; ` +
+ `this install appears to be running from an incomplete checkout.`)
+ }
+ }
+
+ fs.mkdirSync(nodeDir, { recursive: true })
+ if (path.resolve(nodeDir) !== path.resolve(bundledCoinDir)) {
+ fs.copyFileSync(dockerfileSrc, path.join(nodeDir, 'Dockerfile'))
+ }
+
+ // The conf template ships __XCHAIN_NODE_RPC_USER__/__XCHAIN_NODE_RPC_PASSWORD__
+ // placeholders. Fail closed when the provisioned credentials are missing rather
+ // than baking a placeholder (or "undefined") into the image: an image whose RPC
+ // credentials are the literal placeholder tokens is unreachable by every service
+ // that shares them, and it fails at runtime rather than here.
+ if (!defaultConfig['NODE_USER'] || !defaultConfig['NODE_PASSWORD']) {
+ throw new Error(`Missing NODE_USER/NODE_PASSWORD for ${coin} ${network}; refusing to build node with placeholder RPC credentials`)
+ }
+
+ // Substitute into a generated sibling, never back into the template. The
+ // template is tracked in git, so injecting in place wrote live RPC credentials
+ // into a tracked file and left every built-from host with a dirty worktree
+ // holding a secret. The generated name is gitignored.
+ const generatedName = `${coin}-${network}.generated.conf`
+ let confContent = fs.readFileSync(confSrc, 'utf8')
+ confContent = confContent.replace(/^rpcuser=.*$/m, `rpcuser=${defaultConfig['NODE_USER']}`)
+ confContent = confContent.replace(/^rpcpassword=.*$/m, `rpcpassword=${defaultConfig['NODE_PASSWORD']}`)
+ fs.writeFileSync(path.join(nodeDir, generatedName), confContent, { mode: 0o600 })
+
+ return generatedName
+}
+
async function buildCryptoNode(coin, network, bitcoinVer = null) {
const defaultConfig = await getDefaultConfig(NODE_MODULE_NAME, coin, network)
const defaultExposedPort = defaultConfig["NODE_EXPOSED_PORT"]
@@ -290,22 +350,7 @@ async function buildCryptoNode(coin, network, bitcoinVer = null) {
const containerPrefix = getDockerContainerImageName(NODE_MODULE_NAME, coin, network)
const nodeDir = cryptoNodesDir + "/" + coin
- // Inject the provisioned RPC credentials into the coin-node conf file
- // so the daemon and the xchain services share the same credentials.
- const confFileName = `${coin}-${network}.conf`
- const confFilePath = path.join(nodeDir, confFileName)
- if (fs.existsSync(confFilePath)) {
- // The bundled conf ships __XCHAIN_NODE_RPC_USER__/__XCHAIN_NODE_RPC_PASSWORD__
- // placeholder tokens; fail closed if the provisioned credentials are
- // missing rather than baking the placeholder (or "undefined") into the image.
- if (!defaultConfig['NODE_USER'] || !defaultConfig['NODE_PASSWORD']) {
- throw new Error(`Missing NODE_USER/NODE_PASSWORD for ${coin} ${network}; refusing to build node with placeholder RPC credentials`)
- }
- let confContent = fs.readFileSync(confFilePath, 'utf8')
- confContent = confContent.replace(/^rpcuser=.*$/m, `rpcuser=${defaultConfig['NODE_USER']}`)
- confContent = confContent.replace(/^rpcpassword=.*$/m, `rpcpassword=${defaultConfig['NODE_PASSWORD']}`)
- fs.writeFileSync(confFilePath, confContent)
- }
+ const confFileName = stageBuildScaffold(coin, network, nodeDir, defaultConfig)
// Pre-flight host-port collision check (multi-stack hosts): same guard
// the service- and DB-install paths use. Two different-NODE_PREFIX stacks
@@ -320,7 +365,7 @@ async function buildCryptoNode(coin, network, bitcoinVer = null) {
return new Promise((resolve, reject) => {
console.log("Building image of " + coin + " " + network + " node")
- execFile('docker', ['build', '.', '--build-arg', 'CONF_FILE=' + coin + '-' + network + '.conf', '-t', containerPrefix], { cwd: nodeDir }, async (error) => {
+ execFile('docker', ['build', '.', '--build-arg', 'CONF_FILE=' + confFileName, '-t', containerPrefix], { cwd: nodeDir }, async (error) => {
if (error) {
console.error("Error creating Docker image: " + error.message)
reject("Error creating Docker image: " + error.message)
@@ -608,6 +653,7 @@ async function installNode(coin, network) {
module.exports = {
getCryptoNode,
buildCryptoNode,
+ stageBuildScaffold,
installNode,
resolveBlocksDir,
resolveNodeVersionPin,
diff --git a/src/services/ValidatorService.js b/src/services/ValidatorService.js
index de3f2f3..9e7be6b 100644
--- a/src/services/ValidatorService.js
+++ b/src/services/ValidatorService.js
@@ -23,6 +23,20 @@
* validator.json P2P + oracle settings + enabled flag + pubkey
* hub-caps/capabilities.json HUB_CAPABILITY_CONFIG: MIN_STAKE thresholds +
* self-test blocks
+ * wallets.env the two coin wallets (mode 0600): the BTC stake
+ * wallet (funds fees, mints and holds the STAKE)
+ * and the DOGE publisher wallet (pays for price
+ * rounds and anchors). Generated by init, or
+ * imported from operator-supplied WIFs.
+ * signer/signer.js + .env the HUB_SIGNER_MODULE the hub loads to sign
+ * DOGE publishes. Mounted read-only into the
+ * container; the WIF stays in this directory.
+ *
+ * Both wallets are ordinary single-key P2PKH wallets, so an operator who wants
+ * a branded (vanity) address generates the key elsewhere and imports the WIF:
+ * `--import-stake-key` / `--import-doge-key` prompt for it with echo off, and
+ * XCHAIN_NODE_STAKE_WIF / XCHAIN_NODE_DOGE_WIF carry it non-interactively.
+ * Never as an argv value: a WIF in argv is a WIF in every process listing.
*
* The hub's API key is deliberately NOT one of these. A hub refuses to boot
* without HUB_API_KEY unless keyless operation is declared, so init mints one,
@@ -55,6 +69,13 @@ const { configDir } = require('../config/constants')
const { ensureHubApiKey } = require('./ConfigService')
const VALIDATOR_DIR = path.join(configDir, 'validator')
+// The stack ref this CLI tells an operator to install. xchain-node's own
+// version IS the platform version, so deriving the hint here keeps it on the
+// release the operator is actually holding, and it cannot rot the way a
+// hardcoded ref does. It said `master` until 2026-09-01: an unpinned tracking
+// install, contradicting every other instruction we give, printed at the exact
+// moment a new validator is deciding what to run.
+const RELEASE_REF = 'v' + require('../../package.json').version
const KEY_FILE = path.join(VALIDATOR_DIR, 'signing.key')
const SETTINGS_FILE = path.join(VALIDATOR_DIR, 'validator.json')
@@ -75,6 +96,364 @@ const LEGACY_CAPS_FILE = path.join(VALIDATOR_DIR, CAPS_BASENAME)
const CAPS_CONTAINER_DIR = '/validator'
const CAPS_CONTAINER_PATH = CAPS_CONTAINER_DIR + '/' + CAPS_BASENAME
+// The two coin wallets and the DOGE signer module. The signer directory is
+// what the hub container mounts (read-only, at SIGNER_CONTAINER_DIR), so the
+// stake WIF deliberately lives OUTSIDE it in wallets.env: the hub never needs
+// the stake key, so the hub never sees it.
+const WALLETS_FILE = path.join(VALIDATOR_DIR, 'wallets.env')
+const SIGNER_DIR = path.join(VALIDATOR_DIR, 'signer')
+const SIGNER_FILE = path.join(SIGNER_DIR, 'signer.js')
+const SIGNER_ENV_FILE = path.join(SIGNER_DIR, '.env')
+
+// The mountpoint the SDK is mounted onto, INSIDE the read-only signer mount.
+// It must exist on the host before the container starts: docker mounts the
+// parent first, and creating a missing mountpoint under a read-only bind mount
+// fails with "make mountpoint: read-only file system", which does not degrade
+// to a hub without a signer, it aborts container creation outright. Measured
+// against the real hub image.
+const SIGNER_MODULES_DIR = path.join(SIGNER_DIR, 'node_modules')
+const SIGNER_CONTAINER_DIR = '/XChainHub/operator-signer'
+const SIGNER_CONTAINER_PATH = SIGNER_CONTAINER_DIR + '/signer.js'
+
+// The P2P port names the federation: one host can serve both networks later,
+// so the port is the declared split rather than anything the protocol enforces.
+const NETWORK_BY_P2P_PORT = { 10001: 'mainnet', 10002: 'testnet' }
+const P2P_PORT_BY_NETWORK = { mainnet: 10001, testnet: 10002 }
+
+// Oracle round-numbering anchor per federation. A hub with a different value
+// computes different round numbers and its submissions never line up, so the
+// known federations' values are defaults here; --oracle-epoch-start overrides.
+// testnet: read from the live validator01-05 containers on 2026-08-29.
+const ORACLE_EPOCH_START_BY_NETWORK = { testnet: 1787875200000 }
+
+// SDK network names and public encoder coin prefixes per hub network.
+const COIN_NETWORKS = {
+ mainnet: { stake: 'bitcoin-mainnet', doge: 'dogecoin-mainnet', stakeCoin: 'BTC', dogeCoin: 'DOGE' },
+ testnet: { stake: 'bitcoin-testnet', doge: 'dogecoin-testnet', stakeCoin: 'TBTC', dogeCoin: 'TDOGE' },
+ regtest: { stake: 'bitcoin-regtest', doge: 'dogecoin-regtest', stakeCoin: 'BTC', dogeCoin: 'DOGE' }
+}
+const PUBLIC_ENCODER_BASE = 'https://encoder.xchain.io/'
+
+// ROLLCALL: every epoch (1008 BTC blocks, 30 on regtest) each validator signs
+// a message over that epoch's ledger_hash; the elected leader publishes the
+// collected signatures on DOGE, and the BTC indexer closes the epoch and
+// evicts a staking source absent for K=2 consecutive rolled epochs. Every
+// ROLLCALL payload exceeds the 76-byte OP_RETURN cap, so it always rides the
+// chunked two-phase P2SH lane - two transactions, ~0.003 DOGE each - which is
+// where the per-call DOGE cost below comes from. It spends from the same
+// publisher wallet anchors already use; no separate wallet or key.
+const ROLLCALL_DOGE_COST_PER_CALL = 0.006
+// BTC blocks per ROLLCALL epoch, network-keyed. An absence read only ever
+// returns absent epochs, so two rows are a true back-to-back streak only when
+// their epoch_height values are exactly one epoch apart; any other gap means
+// a rolled (present) epoch fell between them, however close the rows sit in
+// the array.
+const ROLLCALL_EPOCH_BLOCKS = { regtest: 30, testnet: 1008, mainnet: 1008 }
+const ROLLCALL_EPOCH_BLOCKS_DEFAULT = 1008
+
+// Lazy: the SDK pulls in the whole bitcoinjs stack, and every validator
+// subcommand that does not touch a wallet (status) should not pay for it.
+function loadSdk() {
+ return require('@dankest-llc/xchain-sdk')
+}
+
+// Generate a fresh single-key wallet on the named SDK network, or import one
+// from a WIF. Returns address + public key + WIF; the caller decides where the
+// WIF is written and it is never printed.
+function makeCoinWallet(sdkNetwork, wif) {
+ const { XChainSDK } = loadSdk()
+ const sdk = new XChainSDK({ network: sdkNetwork })
+ const keys = wif ? sdk.wallet.importWIF(wif) : sdk.wallet.generateKeyPair()
+ return {
+ address: sdk.wallet.deriveAddress(keys.publicKey),
+ pubkeyHex: keys.publicKeyHex,
+ wif: keys.wif
+ }
+}
+
+// Read a secret from the CONTROLLING TERMINAL with echo off. /dev/tty rather
+// than stdin, so a pasted multi-line block cannot race the prompt. Returns
+// null when there is no terminal, so a non-interactive caller gets a clear
+// error naming the env var instead of a hang.
+function promptSecret(promptText) {
+ const { execFileSync } = require('child_process')
+ let fd
+ try { fd = fs.openSync('/dev/tty', 'rs') } catch { return null }
+ let echoOff = false
+ try { execFileSync('stty', ['-echo'], { stdio: [fd, 'ignore', 'ignore'] }); echoOff = true } catch { /* not a tty */ }
+ process.stderr.write(promptText)
+ const buf = Buffer.alloc(1)
+ let out = ''
+ try {
+ for (;;) {
+ let n = 0
+ try { n = fs.readSync(fd, buf, 0, 1, null) }
+ catch (e) { if (e.code === 'EAGAIN') continue; throw e }
+ if (n === 0) break
+ const ch = buf.toString('utf8')
+ if (ch === '\n' || ch === '\r') break
+ if (ch === '\u0003') { out = ''; break }
+ if (ch === '\u007f') { out = out.slice(0, -1); continue }
+ out += ch
+ }
+ } finally {
+ if (echoOff) { try { execFileSync('stty', ['echo'], { stdio: [fd, 'ignore', 'ignore'] }) } catch { /* best effort */ } }
+ process.stderr.write('\n')
+ try { fs.closeSync(fd) } catch { /* closed */ }
+ }
+ return out.trim()
+}
+
+// Resolve an operator-supplied WIF for one wallet: the env var first (the
+// non-interactive path), then a hidden prompt. Throws when asked to import and
+// neither is available, because silently generating instead would strand the
+// coin the operator already funded at their own address.
+function resolveImportedWif(label, envName, wantImport) {
+ const fromEnv = process.env[envName]
+ if (fromEnv) return fromEnv
+ if (!wantImport) return null
+ const typed = promptSecret('WIF for the ' + label + ' wallet (input hidden): ')
+ if (!typed) {
+ throw new Error('no WIF supplied for the ' + label + ' wallet. Type it at the prompt on a terminal, ' +
+ 'or set ' + envName + ' in the environment (never on the command line).')
+ }
+ return typed
+}
+
+// Parse a KEY=VALUE sidecar into an object. Comments and blank lines skipped.
+function parseEnvText(text) {
+ const out = {}
+ for (const line of String(text || '').split(/\r?\n/)) {
+ if (!line || line.startsWith('#')) continue
+ const eq = line.indexOf('=')
+ if (eq <= 0) continue
+ out[line.substring(0, eq).trim()] = line.substring(eq + 1).trim()
+ }
+ return out
+}
+
+// The wallets file, or null when this validator has none (a pre-wallets init,
+// or an operator running their own signer via XCHAIN_NODE_HUB_SIGNER_DIR).
+function readWallets() {
+ if (!isRegularFile(WALLETS_FILE)) return null
+ try { return parseEnvText(fs.readFileSync(WALLETS_FILE, 'utf8')) } catch { return null }
+}
+
+// Addresses and public keys only: what `status` prints and what the hub env
+// carries. The WIFs never leave the file through this path.
+function publicWalletInfo(wallets) {
+ if (!wallets) return null
+ return {
+ network: wallets.NETWORK || null,
+ stakeAddress: wallets.STAKE_ADDRESS || null,
+ stakePubkeyHex: wallets.STAKE_PUBKEY_HEX || null,
+ dogeAddress: wallets.DOGE_ADDRESS || null,
+ dogePubkeyHex: wallets.DOGE_PUBKEY_HEX || null
+ }
+}
+
+function writeWalletsFile(network, stake, doge) {
+ const body = [
+ '# xchain-node validator wallets, written by `xchain-node validator init`.',
+ '# Mode 0600. BACK THIS FILE UP OFFLINE: there is no recovery for a lost key.',
+ '# STAKE_* is the BTC wallet that funds fees, mints XCHAIN and holds the STAKE.',
+ '# DOGE_* is the wallet the hub spends from to publish price rounds and anchors.',
+ 'NETWORK=' + network,
+ 'STAKE_ADDRESS=' + stake.address,
+ 'STAKE_PUBKEY_HEX=' + stake.pubkeyHex,
+ 'STAKE_WIF_SECRET=' + stake.wif,
+ 'DOGE_ADDRESS=' + doge.address,
+ 'DOGE_PUBKEY_HEX=' + doge.pubkeyHex,
+ 'DOGE_WIF_SECRET=' + doge.wif,
+ ''
+ ].join('\n')
+ fs.writeFileSync(WALLETS_FILE, body, { mode: 0o600 })
+ fs.chmodSync(WALLETS_FILE, 0o600)
+}
+
+// The HUB_SIGNER_MODULE the hub loads. Same contract as xchain-hub's
+// examples/doge-signer.example.js (walletSign + the two-phase broadcast); kept
+// here as a template so init works offline and the operator has one file to
+// read if they want to replace it with their own signer.
+const SIGNER_TEMPLATE = `/*********************************************************************
+ *
+ * Copyright © 2025–2026 Dankest, LLC
+ * Based on XChain Platform by Dankest, LLC – https://dankest.llc
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ **********************************************************************
+ *
+ * HUB_SIGNER_MODULE written by \`xchain-node validator init\`.
+ *
+ * The hub's on-chain publishers (PRICE rounds, ANCHOR checkpoints) hand this
+ * module a raw wire payload. It funds and reveals the two-phase P2SH encoding
+ * through the encoder named in .env, signing with the DOGE publisher key that
+ * lives beside this file. The key never enters the hub container's
+ * environment: this directory is mounted read-only and only this module
+ * reads it.
+ *
+ * Replace this file with your own signer if you keep keys elsewhere (an HSM,
+ * a hardware wallet, a remote signing service). The contract is:
+ * walletSign(psbtHex) -> Promise REQUIRED
+ * broadcast(payload) -> Promise<{txid}> optional, replaces the default pipeline
+ * getBalance() -> Promise optional, low-balance warnings
+ *
+ ********************************************************************/
+
+const path = require('path');
+require('dotenv').config({ path: path.join(__dirname, '.env') });
+
+const { XChainSDK } = require('@dankest-llc/xchain-sdk');
+
+const NETWORK = process.env.DOGE_NETWORK || '';
+const WIF = process.env.DOGE_WIF || '';
+const ADDRESS = process.env.DOGE_ADDRESS || '';
+const ENCODER = process.env.DOGE_ENCODER_URL || '';
+const FEE_PER_KB = process.env.DOGE_FEE_PER_KB ? Number(process.env.DOGE_FEE_PER_KB) : undefined;
+
+// Fail at load time: the hub treats a configured-but-broken signer as fatal,
+// and a signer that cannot sign must not boot quietly.
+for (const [name, value] of [['DOGE_NETWORK', NETWORK], ['DOGE_WIF', WIF], ['DOGE_ADDRESS', ADDRESS], ['DOGE_ENCODER_URL', ENCODER]]) {
+ if (!value) throw new Error('doge-signer: ' + name + ' is not set in ' + path.join(__dirname, '.env'));
+}
+
+const sdk = new XChainSDK({ network: NETWORK, encoderUrl: ENCODER });
+
+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
+ // used; that is the transaction indexers decode.
+ async broadcast(payload) {
+ const encoder = sdk._requireEncoder();
+
+ const txParams = { data: payload, pubkey: ADDRESS, change: ADDRESS, encoding: 'P2SH' };
+ if (FEE_PER_KB !== undefined) txParams.feePerKb = FEE_PER_KB;
+
+ const encoded = await encoder.createTx(txParams);
+ const signed = sdk.wallet.signPsbt(encoded.psbt, WIF);
+ await encoder.broadcastTx(signed.txHex);
+ if (encoded.encoding !== 'P2SH' && encoded.encoding !== 'P2WSH')
+ return { txid: signed.txid };
+
+ const spendParams = {
+ pubkey: ADDRESS,
+ p2shHash: signed.txid,
+ p2shHex: signed.txHex,
+ data: payload,
+ encoding: encoded.encoding,
+ change: ADDRESS
+ };
+ if (FEE_PER_KB !== undefined) spendParams.feePerKb = FEE_PER_KB;
+ const spendResult = await encoder.spendP2sh(spendParams);
+ const spendSigned = sdk.wallet.signRevealPsbt(spendResult.psbt, WIF);
+ await encoder.broadcastTx(spendSigned.txHex);
+
+ return { txid: spendSigned.txid, phase1_txid: signed.txid };
+ },
+
+ // Sign an encoder-built PSBT -> signed raw tx hex. The hub's built-in
+ // phase-1 pipeline and balance plumbing use this; broadcast() above takes
+ // precedence when both are exported.
+ async walletSign(psbtHex) {
+ return sdk.wallet.signPsbt(psbtHex, WIF).txHex;
+ }
+};
+`
+
+function writeSignerDir(network, doge) {
+ if (!fs.existsSync(SIGNER_DIR)) fs.mkdirSync(SIGNER_DIR, { recursive: true })
+ ensureSignerModulesMountpoint()
+ const coins = COIN_NETWORKS[network]
+ fs.writeFileSync(SIGNER_FILE, SIGNER_TEMPLATE, { mode: 0o644 })
+ const env = [
+ '# DOGE publisher signer config, written by `xchain-node validator init`. Mode 0600.',
+ '# DOGE_ENCODER_URL: the encoder that builds and relays the DOGE transactions.',
+ '# The public XChain encoder is the default; point it at your own if you run one.',
+ 'DOGE_NETWORK=' + coins.doge,
+ 'DOGE_ADDRESS=' + doge.address,
+ 'DOGE_ENCODER_URL=' + (process.env.DOGE_ENCODER_URL || (PUBLIC_ENCODER_BASE + coins.dogeCoin)),
+ 'DOGE_WIF=' + doge.wif,
+ ''
+ ].join('\n')
+ fs.writeFileSync(SIGNER_ENV_FILE, env, { mode: 0o600 })
+ fs.chmodSync(SIGNER_ENV_FILE, 0o600)
+}
+
+function signerDirExists() {
+ return isRegularFile(SIGNER_FILE) && isRegularFile(SIGNER_ENV_FILE)
+}
+
+// Create the empty node_modules directory the SDK mount lands on (see
+// SIGNER_MODULES_DIR). Idempotent, and re-checked at mount time rather than
+// only at init, so a config directory written before this existed still starts.
+// An empty node_modules on the host is harmless outside the container: node
+// keeps walking up the tree when a package is not found in it.
+function ensureSignerModulesMountpoint() {
+ if (!fs.existsSync(SIGNER_MODULES_DIR)) fs.mkdirSync(SIGNER_MODULES_DIR, { recursive: true })
+}
+
+// Host directory to mount at SIGNER_CONTAINER_DIR, or null. An operator who
+// set XCHAIN_NODE_HUB_SIGNER_DIR runs their own signer and wins; the generated
+// one is the default for everyone else. The signer requires the SDK, which is
+// resolved from a node_modules mounted BESIDE it (ModuleService mounts this
+// package's own node_modules there), so init never has to run npm.
+function getSignerMountDir() {
+ if (process.env.XCHAIN_NODE_HUB_SIGNER_DIR) return null
+ if (!signerDirExists()) return null
+ ensureSignerModulesMountpoint()
+ return SIGNER_DIR
+}
+
+// The signer.js file actually in force: an operator-supplied module wins
+// (same precedence as getSignerMountDir), else the generated one once init has
+// written it. Null when neither is configured. Used for the roll-call
+// broadcast check below, which reads this path rather than the mount dir.
+function getActiveSignerFile() {
+ if (process.env.XCHAIN_NODE_HUB_SIGNER_DIR) return path.join(process.env.XCHAIN_NODE_HUB_SIGNER_DIR, 'signer.js')
+ return signerDirExists() ? SIGNER_FILE : null
+}
+
+// Strip comments before scanning a signer module's source, so a doc comment
+// that merely mentions `broadcast(...)` (the generated template's own
+// contract comment does exactly that, right above its module.exports) cannot
+// be mistaken for the export itself.
+function stripJsComments(src) {
+ return String(src || '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
+}
+
+// Whether a signer module's module.exports names a `broadcast` export. Static
+// only: this CLI never requires an operator-supplied signer file just to ask
+// it a question, because that module's top-level code is meant to run inside
+// the hub container (with its own env, its own SDK session) - not at
+// `validator status` time, under whatever privileges ran the CLI. Returns
+// null when there is no file to check.
+function signerModuleExportsBroadcast(signerFilePath) {
+ if (!signerFilePath || !isRegularFile(signerFilePath)) return null
+ let src
+ try { src = fs.readFileSync(signerFilePath, 'utf8') } catch { return null }
+ const stripped = stripJsComments(src)
+ if (/\bexports\.broadcast\s*=/.test(stripped)) return true
+ // Scope the object-literal form (`broadcast(payload) {...}` or
+ // `broadcast: ...` inside `module.exports = { ... }`) to text from the
+ // LAST module.exports onward, so a `broadcast` named only in prose or a
+ // helper earlier in the file cannot false-positive.
+ const idx = stripped.lastIndexOf('module.exports')
+ const tail = idx >= 0 ? stripped.slice(idx) : ''
+ return /\bbroadcast\s*(?:\(|:)/.test(tail)
+}
+
+// The network this validator was initialized for: recorded on init, derived
+// from the P2P port for installs that predate the field.
+function resolveNetwork(opts, p2pPort) {
+ const explicit = opts.network ? String(opts.network).toLowerCase() : null
+ if (explicit && !COIN_NETWORKS[explicit]) {
+ throw new Error('--network must be one of mainnet, testnet, regtest (got ' + opts.network + ')')
+ }
+ if (explicit) return explicit
+ return NETWORK_BY_P2P_PORT[p2pPort] || null
+}
+
// ASN.1 DER prefixes for Ed25519 (same as xchain-hub/src/ValidatorIdentity.js),
// so the pubkey we print matches what the hub derives from the same seed.
const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex')
@@ -91,8 +470,12 @@ function pubkeyFromSeedHex(seedHex) {
// Default capability config. MIN_STAKE thresholds mirror the indexer's
// authoritative governance config; the per-capability blocks satisfy the hub's
// self-tests. Operators tune capabilities.json after init (e.g. real RPC URLs).
-function defaultCapabilityConfig(capabilities) {
+function defaultCapabilityConfig(capabilities, wallets) {
const enabled = new Set(capabilities)
+ // The publisher block is filled from the DOGE wallet when init made one;
+ // otherwise the placeholders stay and the operator edits them by hand.
+ const dogeAddress = (wallets && wallets.dogeAddress) || 'REPLACE_WITH_DOGE_ADDRESS'
+ const dogeWallet = (wallets && wallets.dogeAddress) ? (SIGNER_CONTAINER_DIR + '/.env') : 'REPLACE_WITH_DOGE_WALLET_PATH'
const allCaps = ['price', 'cross_chain', 'oracle_publish', 'attestation']
return {
// These MUST equal src/coins/BTC.js STAKING.CAPABILITIES MIN_STAKE
@@ -104,7 +487,14 @@ function defaultCapabilityConfig(capabilities) {
price: { MIN_STAKE: '1000.00000000' },
cross_chain: { MIN_STAKE: '5000.00000000' },
oracle_publish: { MIN_STAKE: '500.00000000' },
- attestation: { MIN_STAKE: '1000.00000000' }
+ attestation: { MIN_STAKE: '1000.00000000' },
+ // full_node is absent from allCaps below (a NODEPROOF tier, not an
+ // opt-in capability) but its threshold still belongs here: omitting
+ // one threshold makes the hub refuse to build ANY capability
+ // snapshot, so every capability reads N=0 and the transport signer
+ // set stays empty. Fail-closed, because a locally-chosen threshold
+ // could qualify different validator sets for one round and fork.
+ full_node: { MIN_STAKE: '2000.00000000' }
},
// Capabilities the operator opted OUT of (qualified but won't serve).
DISABLED_CAPABILITIES: allCaps.filter(c => !enabled.has(c)),
@@ -112,7 +502,7 @@ function defaultCapabilityConfig(capabilities) {
// happens during normal operation).
price: { sources: ['coingecko'], fiats: ['USD'] },
cross_chain: { chains: { BTC: { rpc: 'http://node:8332' } } },
- oracle_publish: { doge_address: 'REPLACE_WITH_DOGE_ADDRESS', doge_wallet: 'REPLACE_WITH_DOGE_WALLET_PATH' },
+ oracle_publish: { doge_address: dogeAddress, doge_wallet: dogeWallet },
attestation: { providers: {} }
}
}
@@ -187,6 +577,61 @@ function reportHubApiKey(hubApiKey) {
+ ' (mode 0600, key HUB_API_KEY, ' + (hubApiKey.generated ? 'generated now' : 'already present, reused') + ')')
}
+/**
+ * Set up the two coin wallets and the DOGE signer, or report why not.
+ *
+ * Shared by a fresh init and by a re-run over an already-initialized
+ * validator, for the same reason ensureHubApiKey runs before the
+ * already-initialized early return: a node initialized BEFORE wallets existed
+ * is exactly the node that needs them, and making it rotate its signing key
+ * (and therefore re-stake, and wait out the activation delay again) to get
+ * them would be a punishing upgrade path for a working validator.
+ *
+ * An existing wallets.env is KEPT unless --force-wallets: the signing key is
+ * cheap to replace, but a funded stake or publisher address is not, and
+ * regenerating it strands the coin.
+ */
+function setupWallets(network, opts) {
+ const out = { wallets: readWallets(), generated: false, imported: [], skippedNoNetwork: false }
+ if (opts.wallets === false) return out
+ if (!network) {
+ // A non-standard port is a custom federation, and a wallet is only
+ // meaningful on a known coin network. Importing a key is an explicit
+ // ask, so that case fails loud; plain init just says what it skipped.
+ if (opts.importStakeKey || opts.importDogeKey || opts.forceWallets) {
+ throw new Error('cannot set up wallets without knowing the network: pass --network testnet|mainnet, ' +
+ 'or --p2p-port 10002 (testnet) / 10001 (mainnet).')
+ }
+ out.skippedNoNetwork = true
+ return out
+ }
+ if (out.wallets && !opts.forceWallets) return out
+
+ const coins = COIN_NETWORKS[network]
+ const stakeWif = resolveImportedWif('stake (BTC)', 'XCHAIN_NODE_STAKE_WIF', !!opts.importStakeKey)
+ const dogeWif = resolveImportedWif('DOGE publisher', 'XCHAIN_NODE_DOGE_WIF', !!opts.importDogeKey)
+ const stake = makeCoinWallet(coins.stake, stakeWif)
+ const doge = makeCoinWallet(coins.doge, dogeWif)
+ if (stakeWif) out.imported.push('stake')
+ if (dogeWif) out.imported.push('DOGE')
+ writeWalletsFile(network, stake, doge)
+ writeSignerDir(network, doge)
+ out.wallets = readWallets() || {
+ NETWORK: network, STAKE_ADDRESS: stake.address, STAKE_PUBKEY_HEX: stake.pubkeyHex,
+ DOGE_ADDRESS: doge.address, DOGE_PUBKEY_HEX: doge.pubkeyHex
+ }
+ out.generated = true
+ return out
+}
+
+// Print the two funding addresses. Addresses only: the keys stay in the file.
+function reportWallets(walletInfo, network, verb) {
+ const coins = COIN_NETWORKS[(walletInfo && walletInfo.network) || network] || COIN_NETWORKS.mainnet
+ console.log(' Wallets ' + verb + '. Fund these two addresses:')
+ console.log(' stake / fees (' + coins.stakeCoin + ') : ' + walletInfo.stakeAddress)
+ console.log(' price + anchor (' + coins.dogeCoin + ') : ' + walletInfo.dogeAddress)
+}
+
// Generate a key + write all validator files. Idempotent guard via `force`.
async function initValidator(opts = {}) {
// A validator-mode hub REFUSES TO BOOT with no HUB_API_KEY, so init has to leave one
@@ -199,7 +644,43 @@ async function initValidator(opts = {}) {
const existing = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'))
console.log('Validator already initialized. Pubkey: ' + existing.pubkey)
reportHubApiKey(hubApiKey)
- console.log('Re-run with --force to regenerate (this creates a NEW key; you would need to re-stake).')
+
+ // REPAIR, never rotate. Everything below is additive: it fills in what
+ // a validator from an older version is missing and leaves the signing
+ // key, and therefore the stake, alone.
+ const network = resolveNetwork(opts, parseInt(opts.p2pPort) || existing.P2P_PORT || 0)
+ || existing.network || NETWORK_BY_P2P_PORT[existing.P2P_PORT] || null
+
+ // The hub refuses to boot in validator mode without HUB_NETWORK, and
+ // getValidatorEnv can only supply it from a recorded network.
+ if (!existing.network && network) {
+ existing.network = network
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(existing, null, 2))
+ console.log(' network : recorded as ' + network + ' (derived from P2P port ' + existing.P2P_PORT + ')')
+ }
+
+ const w = setupWallets(network, opts)
+ const walletInfo = publicWalletInfo(w.wallets)
+ if (w.generated) {
+ ensureCapabilityConfigLayout()
+ fillPublisherConfig(walletInfo)
+ }
+ console.log('')
+ if (walletInfo) {
+ reportWallets(walletInfo, network, w.generated
+ ? (w.imported.length ? 'imported (' + w.imported.join(', ') + ')' : 'generated now')
+ : 'already present')
+ if (w.generated) {
+ console.log('')
+ console.log(' Next: xchain-node validator stake (dry run shows balances and the plan)')
+ }
+ } else if (w.skippedNoNetwork) {
+ console.log(' No wallets, and the network is unknown for port ' + existing.P2P_PORT + '.')
+ console.log(' Re-run with --network testnet|mainnet to generate them.')
+ }
+ console.log('')
+ console.log('Re-run with --force to regenerate the SIGNING KEY (that creates a NEW key; you would need to re-stake).')
+ console.log('Wallets are never replaced by --force; --force-wallets does that, abandoning the old addresses.')
return existing
}
@@ -214,49 +695,130 @@ async function initValidator(opts = {}) {
const capabilities = String(opts.capabilities || 'price,cross_chain,oracle_publish,attestation')
.split(',').map(s => s.trim()).filter(Boolean)
- const seedNodes = String(opts.seedNodes || '')
+ let seedNodes = String(opts.seedNodes || '')
.split(',').map(s => s.trim()).filter(Boolean)
- const p2pPort = parseInt(opts.p2pPort) || 10001
+ // --network names the federation; the P2P port follows it (or names it, for
+ // callers who only passed a port). Neither given keeps the historical
+ // mainnet default of 10001.
+ const explicitNetwork = resolveNetwork(opts, parseInt(opts.p2pPort) || 0)
+ const p2pPort = parseInt(opts.p2pPort)
+ || (explicitNetwork && P2P_PORT_BY_NETWORK[explicitNetwork])
+ || 10001
+ const network = explicitNetwork || NETWORK_BY_P2P_PORT[p2pPort] || null
+
+ // A validator with no seeds dials nobody and joins no gossip mesh, while
+ // looking healthy. Default to the bootstrap set; --seed-nodes overrides.
+ // The PORT selects the network, so a wrong port reaches the wrong federation.
+ let seedNodesDefaulted = false
+ if (seedNodes.length === 0 && (p2pPort === 10001 || p2pPort === 10002)) {
+ seedNodes = ['01','02','03','04','05'].map(n => 'ws://validator' + n + '.xchain.io:' + p2pPort)
+ seedNodesDefaulted = true
+ }
+
+ // Oracle round numbering anchor. MUST match across the federation, so a
+ // known federation's value is the default and --oracle-epoch-start overrides.
+ let oracleEpochStart = opts.oracleEpochStart ? parseInt(opts.oracleEpochStart) : null
+ let oracleEpochDefaulted = false
+ if (!oracleEpochStart && network && ORACLE_EPOCH_START_BY_NETWORK[network]) {
+ oracleEpochStart = ORACLE_EPOCH_START_BY_NETWORK[network]
+ oracleEpochDefaulted = true
+ }
+
const settings = {
enabled: true,
pubkey: pubkey,
+ network: network,
P2P_VALIDATOR_ADDR: opts.p2pAddr || ('0.0.0.0:' + p2pPort),
P2P_PORT: p2pPort,
SEED_NODES: seedNodes,
- // Oracle round numbering anchor. MUST match across the federation.
- ORACLE_EPOCH_START: opts.oracleEpochStart ? parseInt(opts.oracleEpochStart) : null,
+ ORACLE_EPOCH_START: oracleEpochStart,
capabilities: capabilities
}
+ // The two coin wallets and the DOGE signer (see setupWallets).
+ const walletSetup = setupWallets(network, opts)
+ const walletsGenerated = walletSetup.generated
+ const walletsImported = walletSetup.imported
+ const walletsSkippedNoNetwork = walletSetup.skippedNoNetwork
+ const walletInfo = publicWalletInfo(walletSetup.wallets)
+
// Write the secret key first, locked down, and never echo it.
fs.writeFileSync(KEY_FILE, seedHex, { mode: 0o600 })
fs.chmodSync(KEY_FILE, 0o600)
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2))
if (!fs.existsSync(CAPS_FILE) || opts.force) {
- fs.writeFileSync(CAPS_FILE, JSON.stringify(defaultCapabilityConfig(capabilities), null, 2))
+ fs.writeFileSync(CAPS_FILE, JSON.stringify(defaultCapabilityConfig(capabilities, walletInfo), null, 2))
+ } else if (walletsGenerated) {
+ fillPublisherConfig(walletInfo)
}
console.log('')
- console.log('Validator initialized.')
+ console.log('Validator initialized' + (network ? ' for ' + network : '') + '.')
console.log(' signing key : ' + KEY_FILE + ' (mode 0600, keep this secret and back it up)')
console.log(' settings : ' + SETTINGS_FILE)
console.log(' capabilities: ' + CAPS_FILE)
reportHubApiKey(hubApiKey)
+ if (walletInfo) {
+ console.log(' wallets : ' + WALLETS_FILE + ' (mode 0600, holds both private keys: BACK IT UP)')
+ console.log(' DOGE signer : ' + SIGNER_DIR + ' (mounted read-only into the hub)')
+ }
console.log('')
console.log(' PUBKEY (stake XCHAIN to this to qualify capabilities):')
console.log(' ' + pubkey)
console.log('')
+ if (walletInfo) {
+ reportWallets(walletInfo, network, walletsGenerated
+ ? (walletsImported.length ? 'imported (' + walletsImported.join(', ') + ')' : 'generated')
+ : 'already present, kept')
+ console.log('')
+ console.log(' Then: xchain-node validator stake (mints XCHAIN on testnet, then stakes)')
+ console.log(' xchain-node install ' + RELEASE_REF + ' xchain-hub (starts the validator)')
+ } else if (walletsSkippedNoNetwork) {
+ console.log(' Wallets skipped: the network is unknown for port ' + p2pPort + '. Re-run with --network testnet|mainnet')
+ console.log(' to generate them, or run your own signer via XCHAIN_NODE_HUB_SIGNER_DIR.')
+ } else {
+ console.log(' Wallets skipped (--no-wallets). Run your own signer via XCHAIN_NODE_HUB_SIGNER_DIR')
+ console.log(' and set oracle_publish.doge_address in ' + CAPS_FILE + ' by hand.')
+ console.log(' Then: xchain-node install ' + RELEASE_REF + ' xchain-hub')
+ }
+ console.log('')
if (!settings.ORACLE_EPOCH_START)
console.log(' NOTE: set ORACLE_EPOCH_START (--oracle-epoch-start ) to the value shared by your federation before running the oracle.')
+ else if (oracleEpochDefaulted)
+ console.log(' ORACLE_EPOCH_START: defaulted to the ' + network + ' federation value ' + oracleEpochStart + '.')
if (seedNodes.length === 0)
console.log(' NOTE: no SEED_NODES set. Add peer addresses (--seed-nodes host:port,...) to join the gossip mesh.')
- console.log(' Edit ' + CAPS_FILE + ' to set real cross_chain RPC + oracle_publish DOGE values, then run: xchain-node install master xchain-hub')
+ else if (seedNodesDefaulted)
+ console.log(' SEED_NODES: defaulted to the five validator01-05.xchain.io bootstrap peers on port ' +
+ p2pPort + '. Override with --seed-nodes host:port,... if you peer elsewhere.')
+ if (!network)
+ console.log(' NOTE: network unknown (non-standard port). The hub needs HUB_NETWORK in the host .env.')
+ console.log(' cross_chain needs a BTC RPC endpoint in ' + CAPS_FILE + ' or must be listed under DISABLED_CAPABILITIES.')
console.log('')
return settings
}
+// Point an existing capabilities.json at the DOGE wallet init just made,
+// leaving every other operator edit alone. Only the two placeholder values
+// are touched: a hand-set address is the operator's and stays.
+function fillPublisherConfig(walletInfo) {
+ if (!walletInfo || !walletInfo.dogeAddress || !isRegularFile(CAPS_FILE)) return false
+ let caps
+ try { caps = JSON.parse(fs.readFileSync(CAPS_FILE, 'utf8')) } catch { return false }
+ const entry = caps.oracle_publish || (caps.oracle_publish = {})
+ let changed = false
+ if (!entry.doge_address || entry.doge_address === 'REPLACE_WITH_DOGE_ADDRESS') {
+ entry.doge_address = walletInfo.dogeAddress; changed = true
+ }
+ if (!entry.doge_wallet || entry.doge_wallet === 'REPLACE_WITH_DOGE_WALLET_PATH') {
+ entry.doge_wallet = SIGNER_CONTAINER_DIR + '/.env'; changed = true
+ }
+ if (changed) fs.writeFileSync(CAPS_FILE, JSON.stringify(caps, null, 2))
+ return changed
+}
+
// Read persisted validator settings (or null if not initialized / disabled).
function getValidatorSettings() {
if (!isInitialized()) return null
@@ -280,6 +842,26 @@ function getValidatorEnv() {
HUB_CAPABILITY_CONFIG: CAPS_CONTAINER_PATH
}
if (s.ORACLE_EPOCH_START) env.ORACLE_EPOCH_START = s.ORACLE_EPOCH_START
+
+ // The hub refuses to boot in validator mode without HUB_NETWORK. Host env
+ // wins (the passthrough list already copied it); the recorded network
+ // fills the gap so a fresh install needs no .env edit for it.
+ if (!process.env.HUB_NETWORK && s.network) env.HUB_NETWORK = s.network
+
+ // DOGE publisher wiring, from the wallet init made. Every value here is
+ // public (address, pubkey, encoder URL); the WIF stays in the mounted
+ // signer directory. Host env wins on each, so an operator running their
+ // own publisher is not overridden.
+ const w = publicWalletInfo(readWallets())
+ if (w && w.dogeAddress) {
+ const coins = COIN_NETWORKS[w.network || s.network] || null
+ env.DOGE_ADDRESS = process.env.DOGE_ADDRESS || w.dogeAddress
+ env.DOGE_PUBKEY_HEX = process.env.DOGE_PUBKEY_HEX || w.dogePubkeyHex
+ env.DOGE_ENCODER_URL = process.env.DOGE_ENCODER_URL
+ || (coins ? PUBLIC_ENCODER_BASE + coins.dogeCoin : '')
+ if (!env.DOGE_ENCODER_URL) delete env.DOGE_ENCODER_URL
+ }
+ if (getSignerMountDir()) env.HUB_SIGNER_MODULE = SIGNER_CONTAINER_PATH
return env
}
@@ -301,6 +883,104 @@ function getCapabilityConfigMountDir() {
return CAPS_DIR
}
+function rollcallEpochBlocks(network) {
+ return ROLLCALL_EPOCH_BLOCKS[network] || ROLLCALL_EPOCH_BLOCKS_DEFAULT
+}
+
+// Count the consecutive absences from the HEAD of an epoch_height-DESC
+// absence list (the read is ordered that way; see getRollcallStatus). The
+// read only ever returns absent epochs, so two rows are truly back-to-back
+// in time only when they are exactly one epoch's worth of blocks apart;
+// anything else means a rolled (present) epoch fell between them and the
+// streak broke there, no matter how close together the two rows sit in the
+// array. Only the run starting at index 0 counts - an old streak buried
+// deeper in the history is not this key's CURRENT exposure.
+function rollcallAbsenceStreak(absences, epochBlocks) {
+ const rows = Array.isArray(absences) ? absences : []
+ if (!rows.length) return 0
+ let streak = 1
+ for (let i = 1; i < rows.length; i++) {
+ const gap = Number(rows[i - 1].epoch_height) - Number(rows[i].epoch_height)
+ if (gap !== epochBlocks) break
+ streak++
+ }
+ return streak
+}
+
+/**
+ * Everything `validator status` needs to report ROLLCALL health, gathered in
+ * one pass:
+ * - the DOGE publisher's roll-call runway, from the SAME balance read
+ * anchors already report (never a second fetch);
+ * - whether the configured signer can PUBLISH a roll call rather than only
+ * sign one (the trap: every ROLLCALL payload exceeds the 76-byte
+ * OP_RETURN cap, so it always needs the chunked two-phase P2SH lane, and
+ * the hub's built-in broadcast pipeline only finishes phase 1 there);
+ * - this key's BTC-side absence streak, read from the indexer's public
+ * getrollcallabsences({source, limit}) -> {absences: [...]}.
+ *
+ * Each of the three degrades independently to an "unavailable" shape rather
+ * than throwing: one read failing (an unreachable indexer, an older indexer
+ * without this read yet) must not hide the other two, and must never read as
+ * a reassuring zero.
+ *
+ * `deps.makeSdk(sdkNetwork)` lets tests inject a fake SDK without touching the
+ * network, the same shape ValidatorStakeService's tests already use.
+ */
+async function getRollcallStatus(walletInfo, network, deps = {}) {
+ const coins = COIN_NETWORKS[network] || null
+ const out = { doge: null, broadcast: null, absences: null }
+
+ const signerFile = deps.getActiveSignerFile ? deps.getActiveSignerFile() : getActiveSignerFile()
+ if (signerFile) out.broadcast = { file: signerFile, exportsBroadcast: signerModuleExportsBroadcast(signerFile) }
+
+ if (!walletInfo || !coins) return out
+
+ const { XChainSDK } = deps.sdk || loadSdk()
+ const makeSdk = deps.makeSdk || (sdkNetwork => new XChainSDK({ network: sdkNetwork }))
+
+ if (walletInfo.dogeAddress) {
+ try {
+ const dogeSdk = makeSdk(coins.doge)
+ const addr = await dogeSdk.explorer.getAddress(walletInfo.dogeAddress)
+ const balance = Number(addr && addr.balances && addr.balances.confirmed)
+ const safeBalance = Number.isFinite(balance) ? balance : 0
+ out.doge = {
+ unavailable: false,
+ balance: safeBalance,
+ rollcalls: Math.floor(safeBalance / ROLLCALL_DOGE_COST_PER_CALL)
+ }
+ } catch (e) {
+ out.doge = { unavailable: true, error: e.message }
+ }
+ }
+
+ if (walletInfo.stakeAddress) {
+ try {
+ const btcSdk = makeSdk(coins.stake)
+ if (typeof btcSdk.explorer.getRollcallAbsences !== 'function') {
+ // Older indexer: the read is not there yet, not "no absences".
+ out.absences = { unavailable: true, reason: 'this indexer does not expose roll-call absence reads yet' }
+ } else {
+ const r = await btcSdk.explorer.getRollcallAbsences({ source: walletInfo.stakeAddress, limit: 20 })
+ const rows = (r && Array.isArray(r.absences)) ? r.absences : []
+ out.absences = {
+ unavailable: false,
+ streak: rollcallAbsenceStreak(rows, rollcallEpochBlocks(network)),
+ // The chain's own flag on the most recent absence, read directly
+ // rather than re-derived from the streak count, since it is the
+ // authoritative answer to "did THIS absence complete the K=2 run".
+ evictedNow: rows.length > 0 && Number(rows[0].evicted) === 1
+ }
+ }
+ } catch (e) {
+ out.absences = { unavailable: true, error: e.message }
+ }
+ }
+
+ return out
+}
+
module.exports = {
initValidator,
// Exported so a multi-validator deployment (the project's own colocated seed
@@ -317,8 +997,28 @@ module.exports = {
ensureCapabilityConfigLayout,
isInitialized,
pubkeyFromSeedHex,
+ // Wallets: addresses out, WIFs only to the caller that must sign.
+ readWallets,
+ publicWalletInfo,
+ getSignerMountDir,
+ ensureSignerModulesMountpoint,
+ fillPublisherConfig,
+ promptSecret,
+ loadSdk,
+ COIN_NETWORKS,
+ PUBLIC_ENCODER_BASE,
CAPS_CONTAINER_PATH,
CAPS_CONTAINER_DIR,
CAPS_DIR,
- VALIDATOR_DIR
+ VALIDATOR_DIR,
+ WALLETS_FILE,
+ SIGNER_DIR,
+ SIGNER_CONTAINER_DIR,
+ // Roll-call status reporting (`validator status`).
+ getRollcallStatus,
+ getActiveSignerFile,
+ signerModuleExportsBroadcast,
+ rollcallAbsenceStreak,
+ rollcallEpochBlocks,
+ ROLLCALL_DOGE_COST_PER_CALL
}
diff --git a/src/services/ValidatorStakeService.js b/src/services/ValidatorStakeService.js
new file mode 100644
index 0000000..012932a
--- /dev/null
+++ b/src/services/ValidatorStakeService.js
@@ -0,0 +1,446 @@
+/*********************************************************************
+ *
+ * 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 Service
+ *
+ * `xchain-node validator stake`: put this validator's signing key on chain.
+ * Reads the stake wallet init wrote, checks the address's coin and XCHAIN
+ * balances against the public explorer, mints XCHAIN on testnet when the
+ * balance is short (the faucet token's per-transaction cap is read live from
+ * the token, never assumed), then broadcasts STAKE v1 naming the pubkey.
+ *
+ * Dry run by default: without --broadcast it prints the plan (balances, the
+ * mints it would send, the STAKE it would send) and exits, which doubles as
+ * the "have I funded it enough yet" check. Every on-chain step waits for the
+ * indexer to see the previous one, because a STAKE that races its own MINT
+ * into a block is rejected for insufficient balance.
+ *
+ * Nothing here prints a WIF. The wallet file is 0600 and the key goes from
+ * that file into the SDK session and nowhere else.
+ ********************************************************************/
+
+const {
+ getValidatorSettings, readWallets, publicWalletInfo, promptSecret, loadSdk,
+ COIN_NETWORKS, WALLETS_FILE
+} = require('./ValidatorService')
+
+const STAKE_TICK = 'XCHAIN'
+// One stake that clears every capability floor at once (llm attestation
+// provider is the highest at 25000); see the indexer's STAKING.CAPABILITIES.
+const DEFAULT_STAKE_AMOUNT = 25000
+// Blocks between an action landing and the stake changing state, in either
+// direction: a STAKE is not counted for this long, and an UNSTAKE keeps
+// counting for this long (xchain-indexer/src/coins/BTC.js ACTIVATION_DELAY_BLOCKS,
+// applied to the deactivation block in actions/unstake.js).
+const ACTIVATION_DELAY_BLOCKS = 6
+const PUBLIC_EXPLORER = 'https://explorer.xchain.io'
+
+function fail(msg) {
+ const e = new Error(msg)
+ e.validatorStake = true
+ return e
+}
+
+// The stake WIF: wallets.env first, then the env var, then a hidden prompt.
+function resolveStakeWif(wallets) {
+ if (wallets && wallets.STAKE_WIF_SECRET) return wallets.STAKE_WIF_SECRET
+ if (process.env.XCHAIN_NODE_STAKE_WIF) return process.env.XCHAIN_NODE_STAKE_WIF
+ const typed = promptSecret('WIF for the stake wallet (input hidden): ')
+ if (!typed) throw fail('no stake wallet. Run `xchain-node validator init`, or set XCHAIN_NODE_STAKE_WIF.')
+ return typed
+}
+
+function num(x) {
+ const n = Number(x)
+ return Number.isFinite(n) ? n : 0
+}
+
+// Read everything the plan needs from the explorer. Split out so the
+// broadcast path and the tests share one shape.
+async function readChainState(sdk, address, pubkey) {
+ const addr = await sdk.explorer.getAddress(address)
+ const coinBal = num(addr && addr.balances && addr.balances.confirmed)
+ const coinPending = num(addr && addr.balances && addr.balances.pending)
+
+ const bals = await sdk.getBalances(address)
+ const row = ((bals && bals.data) || []).find(b => b && b.tick === STAKE_TICK)
+ const tokenBal = num(row && row.amount)
+
+ const token = await sdk.explorer.getToken(STAKE_TICK)
+ const mints = (token && token.mints) || {}
+
+ // 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.
+ //
+ // Read the SET and filter, rather than a per-pubkey lookup: the explorer
+ // serves /validator/, but the SDK client exposes no method for it,
+ // and calling one that does not exist throws a TypeError that a catch here
+ // would turn into "no existing stake" - a reassuring null that green-lights
+ // a duplicate STAKE. A failed read is reported, never silently treated as
+ // an answer.
+ let existing = null
+ let existingUnknown = null
+ try {
+ const v = await sdk.explorer.getValidators()
+ const rows = (v && v.data) || []
+ existing = rows.find(r => r && r.status === 'valid' &&
+ String(r.signing_pubkey || '').toLowerCase() === pubkey) || null
+ } catch (e) {
+ existingUnknown = e.message
+ }
+
+ return { coinBal, coinPending, tokenBal, mintMax: num(mints.max), mintAddressMax: num(mints.address_max), existing, existingUnknown }
+}
+
+// Plan the mints: how many transactions, at what amount each, to lift the
+// balance to `amount`. Pure, so it is testable without a network.
+function planMints(network, tokenBal, amount, mintMax, mintAddressMax) {
+ const short = Math.max(0, amount - tokenBal)
+ if (short === 0) return { short, mints: [], reason: null }
+ if (network === 'mainnet') {
+ return { short, mints: [], reason: 'XCHAIN is not mintable on mainnet: acquire ' + short + ' more and re-run.' }
+ }
+ if (!(mintMax > 0)) {
+ return { short, mints: [], reason: 'the ' + STAKE_TICK + ' token reports no open mint (MAX_MINT is 0); cannot mint the shortfall.' }
+ }
+ const mints = []
+ let left = short
+ while (left > 0) { const a = Math.min(mintMax, left); mints.push(a); left -= a }
+ 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.'
+ }
+ return { short, mints, reason }
+}
+
+function explorerUrl(coins, pathPart) {
+ return (process.env.EXPLORER_URL || PUBLIC_EXPLORER).replace(/\/$/, '') + '/' + coins.stakeCoin + '/' + pathPart
+}
+
+const sleep = (ms) => new Promise(r => setTimeout(r, ms))
+
+/**
+ * The outputs of `prevTxid` that belong to this address, once the encoder can
+ * see them. Seconds (mempool visibility), not a block.
+ *
+ * Handing these to the encoder as the ONLY candidate inputs is what lets the
+ * whole run go out at once safely. The indexer resolves a STAKE's balance from
+ * every ledger entry with a LOWER ACTION INDEX (`m.action_index < ?` in db.js
+ * getAddressCreditDebit), and that index is global, so the mints count whether
+ * they share the STAKE's block or sit in an earlier one. All that matters is
+ * that they come FIRST, which the funding chain guarantees by construction:
+ * consensus requires a parent transaction to precede its child, so a chain
+ * cannot be reordered within a block or split out of order across two. Left to
+ * choose freely, the encoder could fund the STAKE from an unrelated output at
+ * the same address, and a miner would then be free to place it ahead of the
+ * mints that pay for it.
+ *
+ * Measured 2026-08-29 on testnet4: five chained actions spanned two blocks
+ * (three mints at action_index 40-42 in 150306, the last mint and the STAKE at
+ * 43-44 in 150307) and the STAKE indexed valid, because relative order held
+ * across the block boundary exactly as the chain guarantees.
+ *
+ * Returns null if nothing spendable appears in time, which is the caller's
+ * signal to stop chaining and fall back to waiting.
+ */
+async function chainedInputs(sdk, address, prevTxid, timeoutMs) {
+ const deadline = Date.now() + (timeoutMs || 90000)
+ for (;;) {
+ let utxos = null
+ try { utxos = await sdk._requireEncoder().getUTXOs(address) } catch { /* transient; retry */ }
+ const outs = ((utxos && utxos.utxos) || []).filter(o => (o.fullTxid || o.txid) === prevTxid)
+ if (outs.length) return outs
+ const left = deadline - Date.now()
+ if (left <= 0) return null
+ // Never sleep past our own deadline: a fixed poll interval turns a
+ // short timeout into a wait far longer than the caller asked for.
+ await sleep(Math.min(2000, left))
+ }
+}
+
+// Poll until the address's confirmed XCHAIN balance covers `amount`. The
+// fallback when a chain cannot be formed: once the mints are indexed, the
+// STAKE no longer depends on in-block ordering at all.
+async function waitForBalance(sdk, address, amount, timeoutMs, log, pollMs) {
+ const deadline = Date.now() + (timeoutMs || 7200000)
+ for (;;) {
+ let held = 0
+ try {
+ const b = await sdk.getBalances(address)
+ const row = ((b && b.data) || []).find(t => t && t.tick === STAKE_TICK)
+ held = num(row && row.amount)
+ } catch { /* transient; retry */ }
+ if (held >= amount) return true
+ const left = deadline - Date.now()
+ if (left <= 0) return false
+ log(' waiting for the mints to index (' + held + '/' + amount + ' ' + STAKE_TICK + ')...')
+ await sleep(Math.min(pollMs || 30000, left))
+ }
+}
+
+/**
+ * Everything both the stake and unstake commands need: this validator's
+ * identity, its network, and a wallet session proven to control the recorded
+ * stake address. Shared so the two commands cannot drift on the guards that
+ * matter (network resolution and the WIF/address match).
+ */
+function openValidatorSession(opts, deps) {
+ const settings = deps.settings || getValidatorSettings()
+ if (!settings) throw fail('no validator configured. Run: xchain-node validator init')
+ const network = settings.network || (settings.P2P_PORT === 10002 ? 'testnet' : (settings.P2P_PORT === 10001 ? 'mainnet' : null))
+ if (!network || !COIN_NETWORKS[network]) throw fail('validator network unknown; re-run `validator init --network testnet|mainnet`.')
+ const coins = COIN_NETWORKS[network]
+ const pubkey = String(settings.pubkey || '').toLowerCase()
+
+ const wallets = deps.wallets !== undefined ? deps.wallets : readWallets()
+ const wif = deps.wif || resolveStakeWif(wallets)
+ const info = publicWalletInfo(wallets)
+
+ const { XChainSDK } = deps.sdk || loadSdk()
+ const sdk = deps.makeSdk ? deps.makeSdk(coins.stake) : new XChainSDK({ network: coins.stake })
+ const session = sdk.session(wif, { waitForIndexer: true })
+ if (info && info.stakeAddress && session.address !== info.stakeAddress) {
+ throw fail('the stake WIF controls ' + session.address + ', not the ' + info.stakeAddress +
+ ' recorded in ' + WALLETS_FILE + '. Nothing was sent.')
+ }
+ return { settings, network, coins, pubkey, sdk, session, address: session.address }
+}
+
+/**
+ * Run the unstake command: withdraw this validator's stake and leave the set.
+ *
+ * The counterpart to staking, and it matters more than it looks. Membership is
+ * derived from chain stake alone, so a validator that has staked but is not
+ * running COUNTS toward every capability's N while contributing nothing, which
+ * raises the federation's quorum threshold (CapabilitySnapshot.getQuorum) and
+ * puts a hub that cannot answer into publisher elections. Standing down is how
+ * an operator stops being that.
+ */
+async function unstakeValidator(opts = {}, deps = {}) {
+ const log = deps.log || console.log
+ const { coins, pubkey, sdk, session, address } = openValidatorSession(opts, deps)
+
+ // Read the set rather than a per-pubkey lookup (see readChainState): the
+ // SDK exposes no getValidator, and a lookup failure must not read as
+ // "nothing staked" when that is the very thing being acted on.
+ let active = null
+ try {
+ const v = await sdk.explorer.getValidators()
+ active = ((v && v.data) || []).find(r => r && r.status === 'valid' &&
+ String(r.signing_pubkey || '').toLowerCase() === pubkey) || null
+ } catch (e) {
+ throw fail('could not read the validator set (' + e.message + '), so this run cannot tell ' +
+ 'whether there is a stake to withdraw. Nothing was sent.')
+ }
+
+ log('')
+ log('Validator unstake plan')
+ log(' signing pubkey : ' + pubkey)
+ log(' stake address : ' + address)
+
+ if (!active) {
+ log('')
+ log(' This pubkey carries no valid stake. Nothing to withdraw.')
+ log('')
+ return { unstaked: false, nothingStaked: true }
+ }
+
+ log(' active stake : ' + active.amount + ' ' + STAKE_TICK +
+ ' (action ' + active.action_index + ', activated at block ' + active.activation_block + ')')
+ log('')
+ log(' Steps:')
+ log(' UNSTAKE v0: withdraw the full stake for this pubkey')
+ log('')
+ log(' The stake keeps counting toward every capability for ' + ACTIVATION_DELAY_BLOCKS +
+ ' more blocks after this is')
+ log(' indexed, then drops out of the active set and your XCHAIN is spendable again.')
+
+ if (!opts.broadcast) {
+ log('')
+ log(' Dry run: nothing sent. Re-run with --broadcast to withdraw.')
+ log('')
+ return { unstaked: false, dryRun: true, active }
+ }
+
+ const timeoutMin = Number(opts.timeout)
+ const timeoutMs = (Number.isFinite(timeoutMin) && timeoutMin > 0 ? timeoutMin : 120) * 60 * 1000
+ const enc = {}
+ if (opts.feePerKb) enc.feePerKb = Number(opts.feePerKb)
+
+ log('')
+ log(' Sending UNSTAKE v0...')
+ const r = await session.submit({ action: 'UNSTAKE', params: { VERSION: 0, SIGNING_PUBKEY: pubkey } }, enc,
+ { waitForIndexer: opts.wait !== false, timeout: timeoutMs, pollInterval: 15000 })
+ log(' txid ' + r.txid + (opts.wait !== false ? ' (indexed)' : ' (broadcast)'))
+ log('')
+ log(' Unstaked. You leave the active set ' + ACTIVATION_DELAY_BLOCKS +
+ ' blocks after the block this landed in;')
+ log(' until then the federation still counts you, which is why standing down is not instant.')
+ log(' Watch it at ' + explorerUrl(coins, 'validator/' + pubkey))
+ log('')
+ return { unstaked: true, txid: r.txid }
+}
+
+/**
+ * Run the stake command. `deps` lets tests inject an SDK factory and a
+ * logger; production uses the real SDK and console.
+ */
+async function stakeValidator(opts = {}, deps = {}) {
+ const log = deps.log || console.log
+ const { network, coins, pubkey, sdk, session, address } = openValidatorSession(opts, deps)
+ const amount = parseInt(opts.amount) || DEFAULT_STAKE_AMOUNT
+
+ const state = await readChainState(sdk, address, pubkey)
+ const plan = planMints(network, state.tokenBal, amount, state.mintMax, state.mintAddressMax)
+
+ log('')
+ log('Validator stake plan (' + network + ')')
+ log(' signing pubkey : ' + pubkey)
+ log(' stake address : ' + address)
+ log(' ' + coins.stakeCoin.padEnd(15) + ': ' + state.coinBal + ' confirmed' +
+ (state.coinPending ? ' (+' + state.coinPending + ' pending)' : '') + ' (pays the transaction fees)')
+ log(' ' + STAKE_TICK.padEnd(15) + ': ' + state.tokenBal + ' held, ' + amount + ' to stake' +
+ (plan.short ? ', short ' + plan.short : ''))
+ if (state.mintMax) log(' faucet caps : ' + state.mintMax + ' per MINT, ' + (state.mintAddressMax || 'no') + ' per address')
+
+ if (state.existing) {
+ log('')
+ log(' This pubkey already carries a valid STAKE of ' + state.existing.amount +
+ ' (action ' + state.existing.action_index + ', activates at block ' + state.existing.activation_block + ').')
+ log(' Nothing to do. Check it at ' + explorerUrl(coins, 'validator/' + pubkey))
+ log('')
+ return { staked: false, existing: state.existing }
+ }
+
+ if (state.existingUnknown) {
+ log('')
+ log(' WARNING: could not read the validator set (' + state.existingUnknown + '),')
+ log(' so this run cannot tell whether the pubkey is already staked. Check')
+ log(' ' + explorerUrl(coins, 'validator/' + pubkey) + ' before broadcasting.')
+ }
+
+ const steps = plan.mints.map((a, i) => 'MINT ' + (i + 1) + '/' + plan.mints.length + ': ' + a + ' ' + STAKE_TICK)
+ steps.push('STAKE v1: ' + amount + ' ' + STAKE_TICK + ' to ' + pubkey)
+ log('')
+ log(' Steps:')
+ for (const s of steps) log(' ' + s)
+
+ const blockers = []
+ if (plan.reason) blockers.push(plan.reason)
+ if (state.coinBal <= 0) blockers.push('no confirmed ' + coins.stakeCoin + ' at ' + address + ' to pay fees; fund it first.')
+ if (blockers.length) {
+ log('')
+ for (const b of blockers) log(' BLOCKED: ' + b)
+ log('')
+ return { staked: false, plan, blockers }
+ }
+
+ if (!opts.broadcast) {
+ log('')
+ log(' Dry run: nothing sent. Re-run with --broadcast to send the ' + steps.length + ' transaction(s) above.')
+ log(' They go out back to back, each funded by the one before it, so the run confirms in the')
+ log(' next block or two rather than costing a block per step. (--serialize sends them a block')
+ log(' apart instead.)')
+ log('')
+ return { staked: false, plan, dryRun: true }
+ }
+
+ // Long waits, deliberately: the indexer sees an action only once its block
+ // is mined, and a testnet block can take twenty minutes. Parsed as a real
+ // number rather than an integer, because parseInt('0.5') is 0, which would
+ // silently fall through to the 120-minute default for every sub-minute
+ // value instead of honouring it.
+ const timeoutMin = Number(opts.timeout)
+ const timeoutMs = (Number.isFinite(timeoutMin) && timeoutMin > 0 ? timeoutMin : 120) * 60 * 1000
+ const baseEncoder = {}
+ if (opts.feePerKb) baseEncoder.feePerKb = Number(opts.feePerKb)
+
+ // Every action is sent back to back and funded from the one before it, so
+ // 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.
+ const chain = !opts.serialize
+ const sent = []
+ let prevTxid = null
+ let chainBroken = false
+
+ async function send(kind, params, isLast) {
+ const enc = Object.assign({}, baseEncoder)
+ if (chain && prevTxid) {
+ const inputs = await chainedInputs(sdk, address, prevTxid, opts.chainTimeoutMs)
+ if (inputs) enc.utxos = inputs
+ else {
+ // Nothing spendable came back from the previous transaction, so
+ // the ordering guarantee is gone. Say so rather than broadcast a
+ // STAKE that a miner may place ahead of its own funding.
+ chainBroken = true
+ log(' (no spendable output from ' + prevTxid.slice(0, 16) + '..., cannot chain)')
+ }
+ }
+ // Only the final action waits for the indexer: the ones before it are
+ // ordered by the funding chain, so waiting on them buys nothing but a
+ // block of latency.
+ const wait = isLast && opts.wait !== false
+ const r = await session.submit({ action: kind, params }, enc,
+ { waitForIndexer: wait, timeout: timeoutMs, pollInterval: 15000 })
+ if (chain && prevTxid && !(r.spentInputs || []).some(i => i.txid === prevTxid)) chainBroken = true
+ prevTxid = r.txid
+ sent.push({ step: kind, txid: r.txid })
+ log(' txid ' + r.txid + (wait ? ' (indexed)' : ' (broadcast)'))
+ return r
+ }
+
+ for (let i = 0; i < plan.mints.length; i++) {
+ log('')
+ log(' Sending MINT ' + (i + 1) + '/' + plan.mints.length + ' (' + plan.mints[i] + ' ' + STAKE_TICK + ')...')
+ await send('MINT', { VERSION: 0, TICK: STAKE_TICK, AMOUNT: String(plan.mints[i]) }, opts.serialize === true)
+ }
+
+ // A broken chain means in-block order is the miner's choice, and a STAKE
+ // evaluated before its own mints is rejected for insufficient funds. Wait
+ // the mints out instead: once they are indexed, ordering stops mattering.
+ if (plan.mints.length && (chainBroken || opts.serialize)) {
+ log('')
+ log(' Waiting for the mints to be indexed before staking' +
+ (chainBroken ? ' (the funding chain broke, so in-block order is not guaranteed)' : '') + '...')
+ const ok = await waitForBalance(sdk, address, amount, timeoutMs, log, opts.balancePollMs)
+ if (!ok) {
+ log('')
+ log(' The mints have not indexed within the timeout. They are broadcast and will confirm;')
+ log(' re-run this command to send the STAKE once they do.')
+ log('')
+ return { staked: false, sent, pendingMints: true }
+ }
+ prevTxid = null // fund the STAKE freely; ordering no longer matters
+ }
+
+ log('')
+ log(' Sending STAKE v1 (' + amount + ' ' + STAKE_TICK + ' to ' + pubkey + ')...')
+ const r = await send('STAKE', { VERSION: 1, AMOUNT: String(amount), SIGNING_PUBKEY: pubkey }, true)
+ log('')
+ if (opts.wait === false) {
+ log(' Broadcast. Watch it land at ' + explorerUrl(coins, 'validator/' + pubkey))
+ } else {
+ log(' Staked. The stake activates 6 blocks after it is indexed; peers admit you on their next')
+ log(' signer-set refresh after that. Watch it at ' + explorerUrl(coins, 'validator/' + pubkey))
+ }
+ log(' Next: xchain-node install master xchain-hub')
+ log('')
+ return { staked: true, sent, txid: r.txid, chained: chain && !chainBroken }
+}
+
+module.exports = {
+ stakeValidator, unstakeValidator, planMints, readChainState,
+ DEFAULT_STAKE_AMOUNT, ACTIVATION_DELAY_BLOCKS, STAKE_TICK
+}
diff --git a/test/unit/ConfigService.test.js b/test/unit/ConfigService.test.js
index d120a6b..7169077 100644
--- a/test/unit/ConfigService.test.js
+++ b/test/unit/ConfigService.test.js
@@ -1297,5 +1297,69 @@ describe('ConfigService', function () {
expect(result['bitcoin']).to.have.property('testnet')
expect(result['bitcoin']).to.have.property('regtest')
})
+
+ // recreate/start/stop/logs pass the operator's raw token straight here
+ // without going through resolveArgs, so the alias has to apply in both.
+ it('accepts the short shared-service names operators actually type', function () {
+ expect(filterCommandParameters(null, 'hub', null, null)['']).to.deep.equal({ '': ['xchain-hub'] })
+ expect(filterCommandParameters(null, 'sync', null, null)['']).to.deep.equal({ '': ['xchain-sync'] })
+ expect(filterCommandParameters(null, 'db', null, null)['']).to.deep.equal({ '': ['database'] })
+ })
+ })
+
+ describe('resolveArgs() service resolution', function () {
+ const { resolveArgs } = require('../../src/services/ConfigService')
+
+ it('resolves the canonical shared-service names', function () {
+ expect(resolveArgs(['master', 'xchain-hub'], { expectBranch: true }))
+ .to.include({ service: 'xchain-hub', branch: 'master' })
+ })
+
+ it('resolves the short shared-service aliases to their canonical names', function () {
+ expect(resolveArgs(['master', 'hub'], { expectBranch: true })).to.include({ service: 'xchain-hub' })
+ expect(resolveArgs(['master', 'sync'], { expectBranch: true })).to.include({ service: 'xchain-sync' })
+ expect(resolveArgs(['master', 'db'], { expectBranch: true })).to.include({ service: 'database' })
+ })
+
+ // The bug: an unrecognized token was silently dropped, leaving
+ // service='all'. `install master hub` did not refuse the unknown name, it
+ // installed EVERY service on every coin and network. The 'xchain-node'
+ // guard already documented this trap but covered only that one name.
+ it('refuses an unrecognized service name instead of expanding to every service', function () {
+ expect(() => resolveArgs(['master', 'hubb'], { expectBranch: true }))
+ .to.throw(/Unrecognized argument 'hubb'/)
+ expect(() => resolveArgs(['master', 'xchain-indexr'], { expectBranch: true }))
+ .to.throw(/Unrecognized argument/)
+ })
+
+ it('names the valid services in the refusal, so the operator can correct it', function () {
+ try {
+ resolveArgs(['master', 'hubb'], { expectBranch: true })
+ expect.fail('should have thrown')
+ } catch (err) {
+ expect(err.message).to.include('xchain-hub')
+ expect(err.message).to.include('xchain-indexer')
+ expect(err.message).to.include('bitcoin')
+ expect(err.message).to.include('testnet')
+ }
+ })
+
+ it('still accepts every legitimate argument shape', function () {
+ expect(resolveArgs(['master', 'xchain-indexer', 'bitcoin', 'mainnet'], { expectBranch: true }))
+ .to.include({ service: 'xchain-indexer', chain: 'bitcoin', network: 'mainnet', branch: 'master' })
+ expect(resolveArgs(['master', 'all'], { expectBranch: true }))
+ .to.include({ service: 'all', branch: 'master' })
+ expect(resolveArgs(['master', 'node', 'litecoin', 'testnet'], { expectBranch: true }))
+ .to.include({ service: 'node', chain: 'litecoin', network: 'testnet' })
+ expect(resolveArgs(['xchain-decoder', 'bitcoin', 'regtest'], { expectBranch: false }))
+ .to.include({ service: 'xchain-decoder', chain: 'bitcoin', network: 'regtest' })
+ })
+
+ // A branch name is an arbitrary string, so the free slot must still take
+ // one; the refusal only fires once that slot is spoken for.
+ it('leaves the branch slot free to take an arbitrary name', function () {
+ expect(resolveArgs(['feature/some-branch', 'xchain-indexer'], { expectBranch: true }))
+ .to.include({ service: 'xchain-indexer', branch: 'feature/some-branch' })
+ })
})
})
diff --git a/test/unit/ModuleService.test.js b/test/unit/ModuleService.test.js
index 745f777..7258d2b 100644
--- a/test/unit/ModuleService.test.js
+++ b/test/unit/ModuleService.test.js
@@ -1526,7 +1526,9 @@ describe('ModuleService', function () {
'./DatabaseService': { setDatabaseParameters: sinon.stub().resolves(), setHubDatabaseParameters: sinon.stub().resolves() },
'./ValidatorService': {
getCapabilityConfigMountDir: () => capsHostDir,
- CAPS_CONTAINER_DIR: '/validator'
+ getSignerMountDir: () => null,
+ CAPS_CONTAINER_DIR: '/validator',
+ SIGNER_CONTAINER_DIR: '/XChainHub/operator-signer'
},
'./VersionService': { getLocalNodeVersion: sinon.stub().resolves(null), getLocalModuleVersion: sinon.stub().resolves(null), checkRemoteNodeVersion: sinon.stub().resolves() },
'./NodeService': { buildCryptoNode: sinon.stub().resolves(true), getCryptoNode: sinon.stub().resolves() },
diff --git a/test/unit/NodeService.test.js b/test/unit/NodeService.test.js
index 389b568..fea89b4 100644
--- a/test/unit/NodeService.test.js
+++ b/test/unit/NodeService.test.js
@@ -26,9 +26,15 @@ function makeNodeServiceStubs(overrides = {}) {
writeFileSync: sinon.stub(),
readFileSync: sinon.stub().returns('rpcuser=old\nrpcpassword=old\n'),
mkdirSync: sinon.stub(),
+ copyFileSync: sinon.stub(),
// ensureHostDir lstats before mkdir; default: path absent.
lstatSync: sinon.stub().throws(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })),
}
+ // The per-coin Dockerfile and conf templates ship in git, so they are present
+ // for every build. Model that: a default-false existsSync makes the build
+ // scaffold look absent and every install path fail closed on a precondition
+ // that never fails in a real checkout.
+ fsStub.existsSync.withArgs(sinon.match(/crypto_nodes[\\/][a-z]+[\\/](Dockerfile|[a-z]+-[a-z]+\.conf)$/)).returns(true)
const dbStub = {
insertModuleContainer: sinon.stub().resolves(true),
isReady: sinon.stub().returns(true)
@@ -819,10 +825,13 @@ describe('NodeService: buildCryptoNode()', function () {
// EACCES "failed to create"; ensureHostDir lstats first and skips.
const stubs = makeNodeServiceStubs()
stubs.fs.lstatSync = sinon.stub().returns({ isSymbolicLink: () => true })
- stubs.fs.mkdirSync.throws(new Error('EACCES: permission denied'))
+ // Scoped to the blocks path: staging the build scaffold also creates
+ // its own directory, and a blanket throw would fail there instead,
+ // never reaching the case under test.
+ stubs.fs.mkdirSync.withArgs(sinon.match(/bigdisk/)).throws(new Error('EACCES: permission denied'))
const args = await build(stubs, { envBlocksDir: '/bigdisk' })
expect(args).to.not.be.null
- expect(stubs.fs.mkdirSync.called).to.be.false
+ expect(stubs.fs.mkdirSync.calledWith(sinon.match(/bigdisk/))).to.be.false
})
})
@@ -910,7 +919,9 @@ describe('NodeService: buildCryptoNode()', function () {
// does); a synchronous stub would let the throw be captured by the Promise
// executor and mask the bug.
const stubs = makeNodeServiceStubs()
- stubs.fs.mkdirSync.throws(new Error('EACCES: permission denied'))
+ // Scoped to the blocks path (see the symlink case above): a blanket throw
+ // now trips staging the build scaffold first and never reaches this guard.
+ stubs.fs.mkdirSync.withArgs(sinon.match(/^\/blocks/)).throws(new Error('EACCES: permission denied'))
stubs.execFile.callsFake((cmd, args, opts, cb) => {
if (args[0] === 'build') return setImmediate(() => cb(null))
if (args[0] === 'run') return setImmediate(() => cb(null, 'a'.repeat(64) + '\n'))
diff --git a/test/unit/ServiceRegistry.test.js b/test/unit/ServiceRegistry.test.js
index 1729c4d..0cdbb15 100644
--- a/test/unit/ServiceRegistry.test.js
+++ b/test/unit/ServiceRegistry.test.js
@@ -190,7 +190,9 @@ describe('SERVICE_REGISTRY', function () {
},
'./ValidatorService': {
getCapabilityConfigMountDir: () => '/host/validator/hub-caps',
- CAPS_CONTAINER_DIR: '/validator'
+ getSignerMountDir: () => null,
+ CAPS_CONTAINER_DIR: '/validator',
+ SIGNER_CONTAINER_DIR: '/XChainHub/operator-signer'
},
'../state': { db: {}, getRemoteModuleVersions: () => ({}), getLastStatus: () => null },
'./StatusService': { statusChanged: async () => {}, getStatus: async () => ({}) },
@@ -203,6 +205,37 @@ describe('SERVICE_REGISTRY', function () {
expect(r.volumeArgs.some(a => /capabilities\.json:/.test(a))).to.be.false
})
+ it('hub: mounts the generated DOGE signer (ro) with this package\'s node_modules beside it', function () {
+ const path = require('path')
+ const ms2 = proxyquire('../../src/services/ModuleService', {
+ './ConfigService': {
+ getUtxoTrackerVolumeName: () => 'v', getModuleDir: (m) => '/m/' + m,
+ checkIfModuleExists: () => true, moduleDirExists: () => false,
+ getDockerContainerImageName: () => 'x', getDockerNetwork: () => 'n',
+ getDefaultConfig: async () => ({}), validatePort: () => true
+ },
+ './ValidatorService': {
+ getCapabilityConfigMountDir: () => '/host/validator/hub-caps',
+ getSignerMountDir: () => '/host/validator/signer',
+ CAPS_CONTAINER_DIR: '/validator',
+ SIGNER_CONTAINER_DIR: '/XChainHub/operator-signer'
+ },
+ '../state': { db: {}, getRemoteModuleVersions: () => ({}), getLastStatus: () => null },
+ './StatusService': { statusChanged: async () => {}, getStatus: async () => ({}) },
+ './DockerService': { getPublishedHostPorts: async () => new Map() },
+ './DatabaseService': { setDatabaseParameters: async () => {}, setHubDatabaseParameters: async () => {} }
+ })
+ const r = ms2.buildModuleDockerArgs(HUB_MODULE_NAME, { HUB_PORT: 10000, HUB_CAPABILITY_CONFIG: '/validator/capabilities.json' }, '', '')
+ const nodeModules = path.resolve(__dirname, '..', '..', 'node_modules')
+ expect(r.volumeArgs).to.deep.equal([
+ '-v', '/host/validator/hub-caps:/validator:ro',
+ '-v', '/host/validator/signer:/XChainHub/operator-signer:ro',
+ '-v', nodeModules + ':/XChainHub/operator-signer/node_modules:ro'
+ ])
+ // Read-only, both of them: the hub must not be able to alter the signer or its key file.
+ expect(r.volumeArgs.filter(a => a.includes('operator-signer')).every(a => a.endsWith(':ro'))).to.be.true
+ })
+
it('hub: a mount refusal from ValidatorService fails the build instead of silently dropping the config', function () {
const ms2 = proxyquire('../../src/services/ModuleService', {
'./ConfigService': {
@@ -215,7 +248,9 @@ describe('SERVICE_REGISTRY', function () {
getCapabilityConfigMountDir: () => {
throw new Error('refusing to mount /host/validator/hub-caps into the hub container')
},
- CAPS_CONTAINER_DIR: '/validator'
+ getSignerMountDir: () => null,
+ CAPS_CONTAINER_DIR: '/validator',
+ SIGNER_CONTAINER_DIR: '/XChainHub/operator-signer'
},
'../state': { db: {}, getRemoteModuleVersions: () => ({}), getLastStatus: () => null },
'./StatusService': { statusChanged: async () => {}, getStatus: async () => ({}) },
diff --git a/test/unit/ValidatorService.test.js b/test/unit/ValidatorService.test.js
index 3371e2f..0042427 100644
--- a/test/unit/ValidatorService.test.js
+++ b/test/unit/ValidatorService.test.js
@@ -27,6 +27,26 @@ const FAKE_SETTINGS_FILE = path.join(FAKE_VALIDATOR_DIR, 'validator.json')
const FAKE_CAPS_DIR = path.join(FAKE_VALIDATOR_DIR, 'hub-caps')
const FAKE_CAPS_FILE = path.join(FAKE_CAPS_DIR, 'capabilities.json')
const FAKE_LEGACY_CAPS = path.join(FAKE_VALIDATOR_DIR, 'capabilities.json')
+// The coin wallets and the DOGE signer the hub mounts. The stake WIF lives in
+// wallets.env, OUTSIDE the mounted signer directory, so the hub never sees it.
+const FAKE_WALLETS_FILE = path.join(FAKE_VALIDATOR_DIR, 'wallets.env')
+const FAKE_SIGNER_DIR = path.join(FAKE_VALIDATOR_DIR, 'signer')
+const FAKE_SIGNER_FILE = path.join(FAKE_SIGNER_DIR, 'signer.js')
+const FAKE_SIGNER_ENV = path.join(FAKE_SIGNER_DIR, '.env')
+
+// What a written wallets.env looks like: the argument of the writeFileSync
+// call that targeted it, parsed back into KEY=VALUE.
+function writtenWallets(fs) {
+ const call = fs.writeFileSync.getCalls().find(c => c.args[0] === FAKE_WALLETS_FILE)
+ if (!call) return null
+ const out = {}
+ for (const line of String(call.args[1]).split('\n')) {
+ if (!line || line.startsWith('#')) continue
+ const eq = line.indexOf('=')
+ out[line.substring(0, eq)] = line.substring(eq + 1)
+ }
+ return out
+}
// Generate a real 64-hex Ed25519 seed for tests that need valid crypto
function makeSeedHex() {
@@ -172,13 +192,16 @@ describe('ValidatorService', function () {
expect(result.capabilities).to.deep.equal(['price', 'cross_chain', 'oracle_publish', 'attestation'])
})
- it('returns existing settings without re-writing when already initialized', async function () {
- const existingSettings = makeSettings()
+ it('returns existing settings and never rotates the signing key when already initialized', async function () {
+ // network already recorded and wallets already present: nothing to repair.
+ const existingSettings = makeSettings(undefined, { network: 'testnet', P2P_PORT: 10002 })
const fs = makeFs({
existsSync: sinon.stub().callsFake(p =>
p === FAKE_SETTINGS_FILE || p === FAKE_KEY_FILE),
+ lstatSync: fileLstat([FAKE_WALLETS_FILE]),
readFileSync: sinon.stub().callsFake(p => {
if (p === FAKE_SETTINGS_FILE) return JSON.stringify(existingSettings)
+ if (p === FAKE_WALLETS_FILE) return 'NETWORK=testnet\nSTAKE_ADDRESS=mKept\nDOGE_ADDRESS=nKept\n'
return ''
})
})
@@ -188,6 +211,48 @@ describe('ValidatorService', function () {
expect(fs.writeFileSync.called).to.be.false
})
+ // A validator initialized before wallets existed must be able to get
+ // them by re-running init. Making it pass --force (a NEW signing key,
+ // a re-stake and another activation wait) would be a punishing upgrade.
+ it('repairs a pre-wallets validator on a re-run, without touching the signing key', async function () {
+ const existingSettings = makeSettings(undefined, { network: 'testnet', P2P_PORT: 10002 })
+ const fs = makeFs({
+ existsSync: sinon.stub().callsFake(p =>
+ p === FAKE_SETTINGS_FILE || p === FAKE_KEY_FILE),
+ readFileSync: sinon.stub().callsFake(p =>
+ p === FAKE_SETTINGS_FILE ? JSON.stringify(existingSettings) : '{}')
+ })
+ const vs = loadValidatorService(fs)
+ await vs.initValidator()
+ const w = writtenWallets(fs)
+ expect(w, 'wallets were created').to.exist
+ expect(w.NETWORK).to.equal('testnet')
+ const wrote = fs.writeFileSync.getCalls().map(c => c.args[0])
+ expect(wrote, 'signing key untouched').to.not.include(FAKE_KEY_FILE)
+ expect(wrote, 'settings untouched (network already recorded)').to.not.include(FAKE_SETTINGS_FILE)
+ expect(wrote).to.include(FAKE_SIGNER_FILE)
+ })
+
+ it('records the network on a validator.json that predates the field', async function () {
+ const old = makeSettings() // no `network`, P2P_PORT 10001
+ const fs = makeFs({
+ existsSync: sinon.stub().callsFake(p =>
+ p === FAKE_SETTINGS_FILE || p === FAKE_KEY_FILE),
+ lstatSync: fileLstat([FAKE_WALLETS_FILE]), // wallets present, so only the network is repaired
+ readFileSync: sinon.stub().callsFake(p => {
+ if (p === FAKE_SETTINGS_FILE) return JSON.stringify(old)
+ if (p === FAKE_WALLETS_FILE) return 'NETWORK=mainnet\nSTAKE_ADDRESS=1Kept\nDOGE_ADDRESS=DKept\n'
+ return ''
+ })
+ })
+ const vs = loadValidatorService(fs)
+ const result = await vs.initValidator()
+ expect(result.network).to.equal('mainnet') // derived from port 10001
+ const settingsWrite = fs.writeFileSync.getCalls().find(c => c.args[0] === FAKE_SETTINGS_FILE)
+ expect(settingsWrite, 'the derived network is persisted').to.exist
+ expect(JSON.parse(settingsWrite.args[1]).network).to.equal('mainnet')
+ })
+
it('re-generates key with force=true even when already initialized', async function () {
const existingSettings = makeSettings()
const fs = makeFs({
@@ -259,11 +324,14 @@ describe('ValidatorService', function () {
expect(keyWriteCall.args[2]).to.deep.equal({ mode: 0o600 })
})
- it('skips capabilities file write when it already exists and force is false', async function () {
- const existingSettings = makeSettings()
+ it('leaves an existing, operator-tuned capabilities file alone when force is false', async function () {
+ // An operator already set their own publisher address: init fills only
+ // placeholders, so nothing in this file is a placeholder and it stays.
+ const tuned = JSON.stringify({ oracle_publish: { doge_address: 'DOperatorOwnAddress', doge_wallet: '/their/path' } })
const fs = makeFs({
- existsSync: sinon.stub().callsFake(p => p === FAKE_CAPS_FILE),
- lstatSync: fileLstat([FAKE_CAPS_FILE])
+ existsSync: sinon.stub().callsFake(p => p === FAKE_CAPS_FILE),
+ lstatSync: fileLstat([FAKE_CAPS_FILE]),
+ readFileSync: sinon.stub().callsFake(p => p === FAKE_CAPS_FILE ? tuned : '{}')
// SETTINGS_FILE and KEY_FILE do NOT exist → triggers a fresh init
})
const vs = loadValidatorService(fs)
@@ -272,6 +340,26 @@ describe('ValidatorService', function () {
expect(capsWriteCalls.length).to.equal(0)
})
+ it('fills only the publisher placeholders in an existing capabilities file', async function () {
+ const stale = JSON.stringify({
+ DISABLED_CAPABILITIES: ['cross_chain'],
+ oracle_publish: { doge_address: 'REPLACE_WITH_DOGE_ADDRESS', doge_wallet: 'REPLACE_WITH_DOGE_WALLET_PATH' }
+ })
+ const fs = makeFs({
+ existsSync: sinon.stub().callsFake(p => p === FAKE_CAPS_FILE),
+ lstatSync: fileLstat([FAKE_CAPS_FILE]),
+ readFileSync: sinon.stub().callsFake(p => p === FAKE_CAPS_FILE ? stale : '{}')
+ })
+ const vs = loadValidatorService(fs)
+ await vs.initValidator()
+ const capsWriteCalls = fs.writeFileSync.getCalls().filter(c => c.args[0] === FAKE_CAPS_FILE)
+ expect(capsWriteCalls.length).to.equal(1)
+ const written = JSON.parse(capsWriteCalls[0].args[1])
+ expect(written.DISABLED_CAPABILITIES).to.deep.equal(['cross_chain'])
+ expect(written.oracle_publish.doge_address).to.match(/^D/)
+ expect(written.oracle_publish.doge_wallet).to.equal('/XChainHub/operator-signer/.env')
+ })
+
it('creates validator dir if it does not exist', async function () {
const fs = makeFs({
existsSync: sinon.stub().callsFake(p => false)
@@ -283,7 +371,9 @@ describe('ValidatorService', function () {
it('skips validator dir creation if it already exists', async function () {
const fs = makeFs({
- existsSync: sinon.stub().callsFake(p => p === FAKE_VALIDATOR_DIR || p === FAKE_CAPS_DIR)
+ existsSync: sinon.stub().callsFake(p =>
+ p === FAKE_VALIDATOR_DIR || p === FAKE_CAPS_DIR || p === FAKE_SIGNER_DIR ||
+ p === path.join(FAKE_SIGNER_DIR, 'node_modules'))
})
const vs = loadValidatorService(fs)
await vs.initValidator()
@@ -666,4 +756,563 @@ describe('ValidatorService', function () {
expect(vs.VALIDATOR_DIR).to.equal(FAKE_VALIDATOR_DIR)
})
})
+
+ // The two coin wallets init writes: the BTC stake wallet (fees, mints, the
+ // STAKE itself) and the DOGE wallet the hub publishes price rounds and
+ // anchors from. Real key generation through the SDK, offline; only the
+ // filesystem is faked.
+ describe('coin wallets', function () {
+
+ afterEach(function () {
+ delete process.env.XCHAIN_NODE_STAKE_WIF
+ delete process.env.XCHAIN_NODE_DOGE_WIF
+ delete process.env.HUB_NETWORK
+ delete process.env.DOGE_ENCODER_URL
+ delete process.env.XCHAIN_NODE_HUB_SIGNER_DIR
+ })
+
+ it('generates a testnet stake wallet and a testnet DOGE wallet for port 10002', async function () {
+ const fs = makeFs()
+ const vs = loadValidatorService(fs)
+ const result = await vs.initValidator({ p2pPort: '10002' })
+ expect(result.network).to.equal('testnet')
+ const w = writtenWallets(fs)
+ expect(w).to.exist
+ expect(w.NETWORK).to.equal('testnet')
+ expect(w.STAKE_ADDRESS).to.match(/^[mn][a-km-zA-HJ-NP-Z1-9]{25,34}$/) // BTC testnet P2PKH
+ expect(w.DOGE_ADDRESS).to.match(/^n[a-km-zA-HJ-NP-Z1-9]{33}$/) // DOGE testnet P2PKH
+ expect(w.STAKE_WIF_SECRET).to.be.a('string').with.length.above(50)
+ expect(w.DOGE_WIF_SECRET).to.be.a('string').with.length.above(50)
+ expect(w.STAKE_PUBKEY_HEX).to.match(/^0[23][0-9a-f]{64}$/)
+ expect(w.DOGE_PUBKEY_HEX).to.match(/^0[23][0-9a-f]{64}$/)
+ })
+
+ it('writes wallets.env and the signer .env with mode 0600, and the signer module beside it', async function () {
+ const fs = makeFs()
+ const vs = loadValidatorService(fs)
+ await vs.initValidator({ network: 'testnet' })
+ const byPath = p => fs.writeFileSync.getCalls().find(c => c.args[0] === p)
+ expect(byPath(FAKE_WALLETS_FILE).args[2]).to.deep.equal({ mode: 0o600 })
+ expect(byPath(FAKE_SIGNER_ENV).args[2]).to.deep.equal({ mode: 0o600 })
+ expect(fs.chmodSync.calledWith(FAKE_WALLETS_FILE, 0o600)).to.be.true
+ expect(fs.chmodSync.calledWith(FAKE_SIGNER_ENV, 0o600)).to.be.true
+ const signer = byPath(FAKE_SIGNER_FILE)
+ expect(signer).to.exist
+ expect(signer.args[1]).to.include("require('@dankest-llc/xchain-sdk')")
+ expect(signer.args[1]).to.include('async walletSign(psbtHex)')
+ expect(signer.args[1]).to.include('async broadcast(payload)')
+ })
+
+ it('points the signer at the DOGE wallet and the public testnet encoder', async function () {
+ const fs = makeFs()
+ const vs = loadValidatorService(fs)
+ await vs.initValidator({ network: 'testnet' })
+ const w = writtenWallets(fs)
+ const env = fs.writeFileSync.getCalls().find(c => c.args[0] === FAKE_SIGNER_ENV).args[1]
+ expect(env).to.include('DOGE_NETWORK=dogecoin-testnet')
+ expect(env).to.include('DOGE_ADDRESS=' + w.DOGE_ADDRESS)
+ expect(env).to.include('DOGE_WIF=' + w.DOGE_WIF_SECRET)
+ expect(env).to.include('DOGE_ENCODER_URL=https://encoder.xchain.io/TDOGE')
+ // The stake key is NOT in the mounted directory.
+ expect(env).to.not.include(w.STAKE_WIF_SECRET)
+ })
+
+ it('fills oracle_publish in the fresh capabilities file from the DOGE wallet', async function () {
+ const fs = makeFs()
+ const vs = loadValidatorService(fs)
+ await vs.initValidator({ network: 'testnet' })
+ const w = writtenWallets(fs)
+ const caps = JSON.parse(fs.writeFileSync.getCalls().find(c => c.args[0] === FAKE_CAPS_FILE).args[1])
+ expect(caps.oracle_publish.doge_address).to.equal(w.DOGE_ADDRESS)
+ expect(caps.oracle_publish.doge_wallet).to.equal('/XChainHub/operator-signer/.env')
+ })
+
+ it('--network names the federation and picks the matching port', async function () {
+ const fs = makeFs()
+ const vs = loadValidatorService(fs)
+ const result = await vs.initValidator({ network: 'testnet' })
+ expect(result.P2P_PORT).to.equal(10002)
+ expect(result.network).to.equal('testnet')
+ expect(result.SEED_NODES).to.deep.equal(['01','02','03','04','05'].map(n => 'ws://validator' + n + '.xchain.io:10002'))
+ })
+
+ it('rejects an unknown --network', async function () {
+ const vs = loadValidatorService(makeFs())
+ let err = null
+ try { await vs.initValidator({ network: 'devnet' }) } catch (e) { err = e }
+ expect(err).to.exist
+ expect(err.message).to.match(/--network must be one of/)
+ })
+
+ it('defaults ORACLE_EPOCH_START to the testnet federation value', async function () {
+ const vs = loadValidatorService(makeFs())
+ const result = await vs.initValidator({ p2pPort: '10002' })
+ expect(result.ORACLE_EPOCH_START).to.equal(1787875200000)
+ })
+
+ it('--oracle-epoch-start still overrides the federation default', async function () {
+ const vs = loadValidatorService(makeFs())
+ const result = await vs.initValidator({ p2pPort: '10002', oracleEpochStart: '1717200000000' })
+ expect(result.ORACLE_EPOCH_START).to.equal(1717200000000)
+ })
+
+ it('leaves ORACLE_EPOCH_START null on mainnet, where no federation value is known yet', async function () {
+ const vs = loadValidatorService(makeFs())
+ const result = await vs.initValidator({ p2pPort: '10001' })
+ expect(result.network).to.equal('mainnet')
+ expect(result.ORACLE_EPOCH_START).to.be.null
+ })
+
+ it('skips wallets on a non-standard port and says so, without failing init', async function () {
+ const fs = makeFs()
+ const vs = loadValidatorService(fs)
+ const logged = []
+ const stub = sinon.stub(console, 'log').callsFake(m => logged.push(String(m)))
+ try { await vs.initValidator({ p2pPort: '10099' }) } finally { stub.restore() }
+ expect(writtenWallets(fs)).to.be.null
+ expect(logged.some(l => /Wallets skipped: the network is unknown/.test(l))).to.be.true
+ })
+
+ it('refuses to import a key when the network is unknown', async function () {
+ const vs = loadValidatorService(makeFs())
+ process.env.XCHAIN_NODE_STAKE_WIF = 'cV' + 'x'.repeat(50)
+ let err = null
+ try { await vs.initValidator({ p2pPort: '10099', importStakeKey: true }) } catch (e) { err = e }
+ expect(err).to.exist
+ expect(err.message).to.match(/without knowing the network/)
+ })
+
+ it('--no-wallets skips wallet generation entirely', async function () {
+ const fs = makeFs()
+ const vs = loadValidatorService(fs)
+ await vs.initValidator({ network: 'testnet', wallets: false })
+ expect(writtenWallets(fs)).to.be.null
+ const caps = JSON.parse(fs.writeFileSync.getCalls().find(c => c.args[0] === FAKE_CAPS_FILE).args[1])
+ expect(caps.oracle_publish.doge_address).to.equal('REPLACE_WITH_DOGE_ADDRESS')
+ })
+
+ it('imports operator-supplied WIFs from the environment and derives their addresses', async function () {
+ // Generate two known keys through the SDK, then hand them to init the
+ // way a vanity-address operator would: as WIFs, never as argv.
+ const { XChainSDK } = require('@dankest-llc/xchain-sdk')
+ const btc = new XChainSDK({ network: 'bitcoin-testnet' })
+ const doge = new XChainSDK({ network: 'dogecoin-testnet' })
+ const stakeKey = btc.wallet.generateKeyPair()
+ const dogeKey = doge.wallet.generateKeyPair()
+ process.env.XCHAIN_NODE_STAKE_WIF = stakeKey.wif
+ process.env.XCHAIN_NODE_DOGE_WIF = dogeKey.wif
+
+ const fs = makeFs()
+ const vs = loadValidatorService(fs)
+ await vs.initValidator({ network: 'testnet' })
+ const w = writtenWallets(fs)
+ expect(w.STAKE_ADDRESS).to.equal(btc.wallet.deriveAddress(stakeKey.publicKey))
+ expect(w.STAKE_WIF_SECRET).to.equal(stakeKey.wif)
+ expect(w.DOGE_ADDRESS).to.equal(doge.wallet.deriveAddress(dogeKey.publicKey))
+ expect(w.DOGE_WIF_SECRET).to.equal(dogeKey.wif)
+ })
+
+ it('rejects a WIF from the wrong network', async function () {
+ const { XChainSDK } = require('@dankest-llc/xchain-sdk')
+ const mainnetKey = new XChainSDK({ network: 'bitcoin-mainnet' }).wallet.generateKeyPair()
+ process.env.XCHAIN_NODE_STAKE_WIF = mainnetKey.wif
+ const vs = loadValidatorService(makeFs())
+ let err = null
+ try { await vs.initValidator({ network: 'testnet' }) } catch (e) { err = e }
+ expect(err).to.exist
+ })
+
+ it('keeps existing wallets across --force (a funded address must not be abandoned silently)', async function () {
+ const existing = [
+ 'NETWORK=testnet', 'STAKE_ADDRESS=mExistingStake', 'STAKE_PUBKEY_HEX=02aa', 'STAKE_WIF_SECRET=cExisting',
+ 'DOGE_ADDRESS=nExistingDoge', 'DOGE_PUBKEY_HEX=02bb', 'DOGE_WIF_SECRET=cExistingDoge', ''
+ ].join('\n')
+ const fs = makeFs({
+ existsSync: sinon.stub().callsFake(p => p === FAKE_SETTINGS_FILE || p === FAKE_KEY_FILE),
+ lstatSync: fileLstat([FAKE_WALLETS_FILE]),
+ readFileSync: sinon.stub().callsFake(p => {
+ if (p === FAKE_WALLETS_FILE) return existing
+ if (p === FAKE_SETTINGS_FILE) return JSON.stringify(makeSettings())
+ return '{}'
+ })
+ })
+ const vs = loadValidatorService(fs)
+ await vs.initValidator({ force: true, p2pPort: '10002' })
+ expect(writtenWallets(fs)).to.be.null
+ // The fresh capabilities file (force rewrites it) still names the kept DOGE wallet.
+ const caps = JSON.parse(fs.writeFileSync.getCalls().find(c => c.args[0] === FAKE_CAPS_FILE).args[1])
+ expect(caps.oracle_publish.doge_address).to.equal('nExistingDoge')
+ })
+
+ it('--force-wallets replaces them', async function () {
+ const existing = 'NETWORK=testnet\nSTAKE_ADDRESS=mExistingStake\nDOGE_ADDRESS=nExistingDoge\n'
+ const fs = makeFs({
+ lstatSync: fileLstat([FAKE_WALLETS_FILE]),
+ readFileSync: sinon.stub().callsFake(p => p === FAKE_WALLETS_FILE ? existing : '{}')
+ })
+ const vs = loadValidatorService(fs)
+ await vs.initValidator({ p2pPort: '10002', forceWallets: true })
+ const w = writtenWallets(fs)
+ expect(w).to.exist
+ expect(w.STAKE_ADDRESS).to.not.equal('mExistingStake')
+ })
+
+ // Docker mounts the parent bind first, then the SDK on top of
+ // signer/node_modules. A missing mountpoint under a read-only mount is
+ // not a degraded signer, it aborts container creation, so init has to
+ // leave the empty directory behind.
+ it('creates the node_modules mountpoint the SDK mount lands on', async function () {
+ const fs = makeFs()
+ const vs = loadValidatorService(fs)
+ await vs.initValidator({ network: 'testnet' })
+ expect(fs.mkdirSync.calledWith(path.join(FAKE_SIGNER_DIR, 'node_modules'), { recursive: true })).to.be.true
+ })
+
+ it('creates that mountpoint at mount time too, for a config written before it existed', function () {
+ const fs = makeFs({
+ existsSync: sinon.stub().callsFake(p => p !== path.join(FAKE_SIGNER_DIR, 'node_modules')),
+ lstatSync: fileLstat([FAKE_SIGNER_FILE, FAKE_SIGNER_ENV])
+ })
+ const vs = loadValidatorService(fs)
+ expect(vs.getSignerMountDir()).to.equal(FAKE_SIGNER_DIR)
+ expect(fs.mkdirSync.calledWith(path.join(FAKE_SIGNER_DIR, 'node_modules'), { recursive: true })).to.be.true
+ })
+
+ it('does not claim a signer mount when the signer files are absent', function () {
+ const fs = makeFs()
+ const vs = loadValidatorService(fs)
+ expect(vs.getSignerMountDir()).to.be.null
+ expect(fs.mkdirSync.called).to.be.false
+ })
+
+ it('publicWalletInfo never carries a WIF', function () {
+ const vs = loadValidatorService(makeFs())
+ const info = vs.publicWalletInfo({
+ NETWORK: 'testnet', STAKE_ADDRESS: 'mA', STAKE_PUBKEY_HEX: '02', STAKE_WIF_SECRET: 'cSECRET',
+ DOGE_ADDRESS: 'nB', DOGE_PUBKEY_HEX: '03', DOGE_WIF_SECRET: 'cSECRET2'
+ })
+ expect(JSON.stringify(info)).to.not.include('SECRET')
+ expect(info).to.deep.equal({ network: 'testnet', stakeAddress: 'mA', stakePubkeyHex: '02', dogeAddress: 'nB', dogePubkeyHex: '03' })
+ })
+
+ describe('hub env wiring', function () {
+
+ function walletedFs(seed, settings, extra = {}) {
+ const wallets = [
+ 'NETWORK=testnet', 'STAKE_ADDRESS=mStake', 'STAKE_PUBKEY_HEX=02aa', 'STAKE_WIF_SECRET=cStake',
+ 'DOGE_ADDRESS=nDoge', 'DOGE_PUBKEY_HEX=02bb', 'DOGE_WIF_SECRET=cDoge', ''
+ ].join('\n')
+ return makeFs({
+ existsSync: sinon.stub().callsFake(p => p === FAKE_SETTINGS_FILE || p === FAKE_KEY_FILE),
+ lstatSync: fileLstat([FAKE_WALLETS_FILE, FAKE_SIGNER_FILE, FAKE_SIGNER_ENV]),
+ readFileSync: sinon.stub().callsFake(p => {
+ if (p === FAKE_KEY_FILE) return seed
+ if (p === FAKE_WALLETS_FILE) return wallets
+ return JSON.stringify(settings)
+ }),
+ ...extra
+ })
+ }
+
+ it('hands the hub the DOGE publisher address, pubkey, encoder and signer module, never the WIF', function () {
+ const seed = makeSeedHex()
+ const vs = loadValidatorService(walletedFs(seed, makeSettings(undefined, { network: 'testnet', P2P_PORT: 10002 })))
+ const env = vs.getValidatorEnv()
+ expect(env.HUB_NETWORK).to.equal('testnet')
+ expect(env.DOGE_ADDRESS).to.equal('nDoge')
+ expect(env.DOGE_PUBKEY_HEX).to.equal('02bb')
+ expect(env.DOGE_ENCODER_URL).to.equal('https://encoder.xchain.io/TDOGE')
+ expect(env.HUB_SIGNER_MODULE).to.equal('/XChainHub/operator-signer/signer.js')
+ expect(JSON.stringify(env)).to.not.include('cDoge')
+ expect(JSON.stringify(env)).to.not.include('cStake')
+ })
+
+ it('host env wins over the recorded values', function () {
+ process.env.HUB_NETWORK = 'regtest'
+ process.env.DOGE_ENCODER_URL = 'http://my-encoder:3113'
+ const vs = loadValidatorService(walletedFs(makeSeedHex(), makeSettings(undefined, { network: 'testnet' })))
+ const env = vs.getValidatorEnv()
+ expect(env).to.not.have.property('HUB_NETWORK') // passthrough already carries the host value
+ expect(env.DOGE_ENCODER_URL).to.equal('http://my-encoder:3113')
+ })
+
+ it('an operator-supplied signer directory takes precedence over the generated one', function () {
+ process.env.XCHAIN_NODE_HUB_SIGNER_DIR = '/home/op/hub-signer'
+ const vs = loadValidatorService(walletedFs(makeSeedHex(), makeSettings(undefined, { network: 'testnet' })))
+ expect(vs.getSignerMountDir()).to.be.null
+ expect(vs.getValidatorEnv()).to.not.have.property('HUB_SIGNER_MODULE')
+ })
+
+ it('a validator without wallets gets no DOGE wiring (pre-wallets install)', function () {
+ const seed = makeSeedHex()
+ const fs = makeFs({
+ existsSync: sinon.stub().callsFake(p => p === FAKE_SETTINGS_FILE || p === FAKE_KEY_FILE),
+ readFileSync: sinon.stub().callsFake(p => p === FAKE_KEY_FILE ? seed : JSON.stringify(makeSettings()))
+ })
+ const env = loadValidatorService(fs).getValidatorEnv()
+ expect(env).to.not.have.property('DOGE_ADDRESS')
+ expect(env).to.not.have.property('HUB_SIGNER_MODULE')
+ })
+ })
+ })
+
+ // ROLLCALL: `validator status` surfaces the DOGE publisher runway in roll
+ // calls, whether the configured signer can PUBLISH one (not merely sign
+ // it), and this key's BTC-side absence streak from the indexer's
+ // getrollcallabsences({source, limit}) read.
+ describe('ROLLCALL status reporting', function () {
+
+ describe('rollcallEpochBlocks()', function () {
+
+ it('is 30 on regtest and 1008 on testnet, mainnet, and anything unrecognized', function () {
+ const vs = loadValidatorService(makeFs())
+ expect(vs.rollcallEpochBlocks('regtest')).to.equal(30)
+ expect(vs.rollcallEpochBlocks('testnet')).to.equal(1008)
+ expect(vs.rollcallEpochBlocks('mainnet')).to.equal(1008)
+ expect(vs.rollcallEpochBlocks('devnet')).to.equal(1008)
+ })
+ })
+
+ describe('rollcallAbsenceStreak()', function () {
+
+ it('returns 0 for an empty list (healthy: no absence on record, not "unknown")', function () {
+ const vs = loadValidatorService(makeFs())
+ expect(vs.rollcallAbsenceStreak([], 30)).to.equal(0)
+ })
+
+ it('counts a single absence as a streak of one', function () {
+ const vs = loadValidatorService(makeFs())
+ expect(vs.rollcallAbsenceStreak([{ epoch_height: 100, evicted: 0 }], 30)).to.equal(1)
+ })
+
+ it('counts two absences one epoch apart as a streak of two', function () {
+ const vs = loadValidatorService(makeFs())
+ const rows = [{ epoch_height: 130, evicted: 1 }, { epoch_height: 100, evicted: 0 }]
+ expect(vs.rollcallAbsenceStreak(rows, 30)).to.equal(2)
+ })
+
+ it('stops the streak at a rolled (present) epoch between two absences, even though only absences are in the array', function () {
+ const vs = loadValidatorService(makeFs())
+ // 160 -> 130 is one epoch (consecutive); 130 -> 70 is two epochs, so
+ // whatever epoch happened at 100 was NOT an absence and breaks the run.
+ const rows = [{ epoch_height: 160, evicted: 0 }, { epoch_height: 130, evicted: 0 }, { epoch_height: 70, evicted: 0 }]
+ expect(vs.rollcallAbsenceStreak(rows, 30)).to.equal(2)
+ })
+
+ it('only counts the run starting at the head of the array, never a streak buried deeper in the history', function () {
+ const vs = loadValidatorService(makeFs())
+ // A lone, older absence with no adjacent row: even though it is "2
+ // consecutive" if paired with something further down, nothing here
+ // is adjacent to it, so head-counting gives 1, not a longer run
+ // found by scanning the rest of the array.
+ const rows = [{ epoch_height: 500, evicted: 0 }, { epoch_height: 100, evicted: 0 }, { epoch_height: 70, evicted: 1 }]
+ expect(vs.rollcallAbsenceStreak(rows, 30)).to.equal(1)
+ })
+ })
+
+ describe('getActiveSignerFile()', function () {
+
+ afterEach(function () { delete process.env.XCHAIN_NODE_HUB_SIGNER_DIR })
+
+ it('prefers an operator-supplied signer directory', function () {
+ process.env.XCHAIN_NODE_HUB_SIGNER_DIR = '/home/op/hub-signer'
+ const vs = loadValidatorService(makeFs())
+ expect(vs.getActiveSignerFile()).to.equal(path.join('/home/op/hub-signer', 'signer.js'))
+ })
+
+ it('falls back to the generated signer.js once init has written one', function () {
+ const fs = makeFs({ lstatSync: fileLstat([FAKE_SIGNER_FILE, FAKE_SIGNER_ENV]) })
+ const vs = loadValidatorService(fs)
+ expect(vs.getActiveSignerFile()).to.equal(FAKE_SIGNER_FILE)
+ })
+
+ it('is null when no signer is configured at all', function () {
+ const vs = loadValidatorService(makeFs())
+ expect(vs.getActiveSignerFile()).to.be.null
+ })
+ })
+
+ describe('signerModuleExportsBroadcast()', function () {
+
+ it('recognizes the CLI-generated template shape (object-literal broadcast beside walletSign)', function () {
+ const fs = makeFs({
+ lstatSync: fileLstat([FAKE_SIGNER_FILE]),
+ readFileSync: sinon.stub().returns(
+ '/* broadcast(payload) -> Promise<{txid}> optional, replaces the default pipeline */\n' +
+ 'module.exports = {\n async broadcast(payload) { return { txid: "x" } },\n async walletSign(p) { return "s" }\n};'
+ )
+ })
+ const vs = loadValidatorService(fs)
+ expect(vs.signerModuleExportsBroadcast(FAKE_SIGNER_FILE)).to.be.true
+ })
+
+ it('recognizes the separate exports.broadcast = assignment form', function () {
+ const fs = makeFs({
+ lstatSync: fileLstat([FAKE_SIGNER_FILE]),
+ readFileSync: sinon.stub().returns(
+ 'exports.walletSign = async function (p) { return "s" };\n' +
+ 'exports.broadcast = async function (payload) { return { txid: "y" } };'
+ )
+ })
+ const vs = loadValidatorService(fs)
+ expect(vs.signerModuleExportsBroadcast(FAKE_SIGNER_FILE)).to.be.true
+ })
+
+ it('returns false for a hand-built module that only signs', function () {
+ const fs = makeFs({
+ lstatSync: fileLstat([FAKE_SIGNER_FILE]),
+ readFileSync: sinon.stub().returns('module.exports = {\n async walletSign(p) { return "s" }\n};')
+ })
+ const vs = loadValidatorService(fs)
+ expect(vs.signerModuleExportsBroadcast(FAKE_SIGNER_FILE)).to.be.false
+ })
+
+ it('is not fooled by a contract comment BEFORE module.exports that only mentions broadcast', function () {
+ const fs = makeFs({
+ lstatSync: fileLstat([FAKE_SIGNER_FILE]),
+ readFileSync: sinon.stub().returns(
+ '/*\n * walletSign(psbtHex) -> Promise REQUIRED\n' +
+ ' * broadcast(payload) -> Promise<{txid}> optional, replaces the default pipeline\n */\n' +
+ 'module.exports = {\n async walletSign(p) { return "s" }\n};'
+ )
+ })
+ const vs = loadValidatorService(fs)
+ expect(vs.signerModuleExportsBroadcast(FAKE_SIGNER_FILE)).to.be.false
+ })
+
+ it('is not fooled by a TODO comment INSIDE the exports block that mentions broadcast(', function () {
+ // Here the misleading text sits AFTER the `module.exports` token, so
+ // only stripping comments before the scan (not the tail-scoping alone)
+ // keeps this from reading as a real export.
+ const fs = makeFs({
+ lstatSync: fileLstat([FAKE_SIGNER_FILE]),
+ readFileSync: sinon.stub().returns(
+ 'module.exports = {\n' +
+ ' // TODO: implement broadcast(payload) once the HSM supports it\n' +
+ ' async walletSign(p) { return "s" }\n' +
+ '};'
+ )
+ })
+ const vs = loadValidatorService(fs)
+ expect(vs.signerModuleExportsBroadcast(FAKE_SIGNER_FILE)).to.be.false
+ })
+
+ it('returns null when there is no signer file to check', function () {
+ const vs = loadValidatorService(makeFs())
+ expect(vs.signerModuleExportsBroadcast(FAKE_SIGNER_FILE)).to.be.null
+ })
+ })
+
+ describe('getRollcallStatus()', function () {
+
+ // A fake SDK shaped like the parts the status command touches: the DOGE
+ // network's address balance and the BTC network's absence read. Mirrors
+ // ValidatorStakeService's test fakes (same sdk.explorer.* shape).
+ function makeRollcallSdk({ dogeBalance, dogeThrows, absences, absencesThrows, noAbsencesMethod } = {}) {
+ const explorerDoge = {
+ getAddress: dogeThrows
+ ? sinon.stub().rejects(new Error(dogeThrows))
+ : sinon.stub().resolves({ balances: { confirmed: dogeBalance !== undefined ? dogeBalance : '0' } })
+ }
+ const explorerBtc = {}
+ if (!noAbsencesMethod) {
+ explorerBtc.getRollcallAbsences = absencesThrows
+ ? sinon.stub().rejects(new Error(absencesThrows))
+ : sinon.stub().resolves({ absences: absences || [] })
+ }
+ return { dogeSdk: { explorer: explorerDoge }, btcSdk: { explorer: explorerBtc } }
+ }
+
+ const WALLET = { network: 'testnet', stakeAddress: 'mStake', dogeAddress: 'nDoge' }
+
+ function routedMakeSdk(dogeSdk, btcSdk) {
+ return sdkNetwork => sdkNetwork === 'dogecoin-testnet' ? dogeSdk : btcSdk
+ }
+
+ it('healthy: a DOGE runway, no absences, and a publish-capable signer', async function () {
+ const vs = loadValidatorService(makeFs())
+ const { dogeSdk, btcSdk } = makeRollcallSdk({ dogeBalance: '0.03', absences: [] })
+ const result = await vs.getRollcallStatus(WALLET, 'testnet', {
+ makeSdk: routedMakeSdk(dogeSdk, btcSdk),
+ getActiveSignerFile: () => null
+ })
+ expect(result.doge).to.deep.equal({ unavailable: false, balance: 0.03, rollcalls: 5 })
+ expect(result.absences).to.deep.equal({ unavailable: false, streak: 0, evictedNow: false })
+ expect(result.broadcast).to.be.null
+ })
+
+ it('a streak of one reads as a warning shot, not an eviction', async function () {
+ const vs = loadValidatorService(makeFs())
+ const rows = [{ epoch_height: 100, source: 'mStake', close_block: 100, evicted: 0 }]
+ const { dogeSdk, btcSdk } = makeRollcallSdk({ absences: rows })
+ const result = await vs.getRollcallStatus(WALLET, 'testnet', {
+ makeSdk: routedMakeSdk(dogeSdk, btcSdk), getActiveSignerFile: () => null
+ })
+ expect(result.absences).to.deep.equal({ unavailable: false, streak: 1, evictedNow: false })
+ })
+
+ it('a streak of two with evicted:1 on the head row reads as evicted', async function () {
+ const vs = loadValidatorService(makeFs())
+ // 1008 BTC blocks apart: one ROLLCALL epoch on testnet, so these two
+ // absences are genuinely back-to-back.
+ const rows = [
+ { epoch_height: 2016, source: 'mStake', close_block: 2016, evicted: 1 },
+ { epoch_height: 1008, source: 'mStake', close_block: 1008, evicted: 0 }
+ ]
+ const { dogeSdk, btcSdk } = makeRollcallSdk({ absences: rows })
+ const result = await vs.getRollcallStatus(WALLET, 'testnet', {
+ makeSdk: routedMakeSdk(dogeSdk, btcSdk), getActiveSignerFile: () => null
+ })
+ expect(result.absences.streak).to.equal(2)
+ expect(result.absences.evictedNow).to.be.true
+ })
+
+ it('degrades to unavailable, never a reassuring zero, when the absence read throws', async function () {
+ const vs = loadValidatorService(makeFs())
+ const { dogeSdk, btcSdk } = makeRollcallSdk({ absencesThrows: 'ECONNREFUSED' })
+ const result = await vs.getRollcallStatus(WALLET, 'testnet', {
+ makeSdk: routedMakeSdk(dogeSdk, btcSdk), getActiveSignerFile: () => null
+ })
+ expect(result.absences.unavailable).to.be.true
+ expect(result.absences).to.not.have.property('streak')
+ })
+
+ it('degrades to unavailable, not "no absences", when the indexer predates this read', async function () {
+ const vs = loadValidatorService(makeFs())
+ const { dogeSdk, btcSdk } = makeRollcallSdk({ noAbsencesMethod: true })
+ const result = await vs.getRollcallStatus(WALLET, 'testnet', {
+ makeSdk: routedMakeSdk(dogeSdk, btcSdk), getActiveSignerFile: () => null
+ })
+ expect(result.absences.unavailable).to.be.true
+ expect(result.absences.reason).to.match(/does not expose/)
+ })
+
+ it('degrades the DOGE runway to unavailable rather than reporting 0 roll calls', async function () {
+ const vs = loadValidatorService(makeFs())
+ const { dogeSdk, btcSdk } = makeRollcallSdk({ dogeThrows: 'timeout', absences: [] })
+ const result = await vs.getRollcallStatus(WALLET, 'testnet', {
+ makeSdk: routedMakeSdk(dogeSdk, btcSdk), getActiveSignerFile: () => null
+ })
+ expect(result.doge.unavailable).to.be.true
+ expect(result.doge).to.not.have.property('rollcalls')
+ })
+
+ it('reports a signer with no broadcast export, naming the file, independent of wallet state', async function () {
+ const OPERATOR_SIGNER = '/home/op/hub-signer/signer.js'
+ const fs = makeFs({
+ lstatSync: fileLstat([OPERATOR_SIGNER]),
+ readFileSync: sinon.stub().returns('module.exports = {\n async walletSign(p) { return "s" }\n};')
+ })
+ const vs = loadValidatorService(fs)
+ const result = await vs.getRollcallStatus(null, 'testnet', {
+ getActiveSignerFile: () => OPERATOR_SIGNER
+ })
+ // With no walletInfo, doge/absences stay null but broadcast is still computed:
+ // the check has nothing to do with whether wallets exist.
+ expect(result.doge).to.be.null
+ expect(result.absences).to.be.null
+ expect(result.broadcast).to.deep.equal({ file: OPERATOR_SIGNER, exportsBroadcast: false })
+ })
+ })
+ })
})
diff --git a/test/unit/ValidatorStakeService.test.js b/test/unit/ValidatorStakeService.test.js
new file mode 100644
index 0000000..294b386
--- /dev/null
+++ b/test/unit/ValidatorStakeService.test.js
@@ -0,0 +1,379 @@
+'use strict'
+
+// Copyright © 2025–2026 Dankest, LLC
+// Based on XChain Platform by Dankest, LLC – https://dankest.llc
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of XChain Platform. Licensed under the GNU Affero
+// General Public License v3.0 or later; see LICENSE.md. A commercial
+// license (without AGPL source-disclosure terms) is available -
+// contact legal@dankest.llc.
+
+const sinon = require('sinon')
+const { expect } = require('chai')
+
+const { stakeValidator, unstakeValidator, planMints } = require('../../src/services/ValidatorStakeService')
+
+const PUBKEY = 'ab'.repeat(32)
+const ADDRESS = 'mStakeAddress'
+
+// A fake SDK shaped like the parts the command touches: explorer reads,
+// balances, and a session whose mint/stake record what they were asked.
+function makeSdk(chain = {}) {
+ const calls = { mint: [], stake: [] }
+ const sdk = {
+ explorer: {
+ getAddress: sinon.stub().resolves({ balances: { confirmed: chain.coin ?? '0.001', pending: '0' } }),
+ getToken: sinon.stub().resolves({ mints: { max: chain.mintMax ?? 10000, address_max: chain.addressMax ?? 50000 } }),
+ // 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".
+ getValidators: chain.validatorsThrow
+ ? sinon.stub().rejects(new Error(chain.validatorsThrow))
+ : sinon.stub().resolves({ data: [
+ { status: 'valid', signing_pubkey: 'ff'.repeat(32), amount: '25000', action_index: '1', activation_block: '1' },
+ ...(chain.existing ? [chain.existing] : [])
+ ] })
+ },
+ // The mints credit once they index. Modelled by reporting the post-mint
+ // balance after they have been sent, so the fallback path can finish.
+ getBalances: async () => {
+ const base = chain.xchain !== undefined ? chain.xchain : 0
+ const credited = calls.mint.length && chain.mintsNeverIndex !== true
+ ? base + calls.mint.reduce((s, c) => s + Number(c.params.AMOUNT), 0)
+ : base
+ return { data: [{ tick: 'XCHAIN', amount: String(credited) }] }
+ },
+ // Every action goes through session.submit; the convenience wrappers
+ // (mint/stake) are sugar over it, and the service calls submit directly
+ // so one code path handles the funding chain.
+ session: () => ({
+ address: ADDRESS,
+ submit: async (actionData, enc, opts) => {
+ const rec = { params: actionData.params, enc, opts }
+ if (actionData.action === 'MINT') calls.mint.push(rec)
+ if (actionData.action === 'STAKE') calls.stake.push(rec)
+ const txid = actionData.action === 'STAKE' ? 'staketx' : 'mint' + calls.mint.length
+ // Report the inputs the encoder was told to use, so the chain
+ // assertion in the service sees a real answer.
+ const spentInputs = (enc.utxos || []).map(u => ({ txid: u.txid, vout: u.vout }))
+ return { txid, spentInputs }
+ }
+ }),
+ // The chain link: each broadcast leaves a change output the next action
+ // is funded from. Keyed by txid so the service's filter is exercised.
+ _requireEncoder: () => ({
+ getUTXOs: async () => ({ utxos: chain.noChange ? [] : [
+ { txid: prevTxidRef.value || 'seed', fullTxid: prevTxidRef.value || 'seed', vout: 1, value: '150000', confirmations: 0 }
+ ] })
+ })
+ }
+ // The fake encoder answers with an output of whatever was broadcast last.
+ const prevTxidRef = { value: null }
+ const origSubmit = sdk.session
+ sdk.session = () => {
+ const s = origSubmit()
+ const inner = s.submit
+ s.submit = async (a, e, o) => { const r = await inner(a, e, o); prevTxidRef.value = r.txid; return r }
+ return s
+ }
+ return { sdk, calls }
+}
+
+function run(opts, chain, settingsExtra = {}) {
+ const { sdk, calls } = makeSdk(chain)
+ const logged = []
+ const deps = {
+ settings: { enabled: true, pubkey: PUBKEY, network: 'testnet', P2P_PORT: 10002, ...settingsExtra },
+ wallets: { NETWORK: 'testnet', STAKE_ADDRESS: ADDRESS, STAKE_WIF_SECRET: 'cFakeWif' },
+ makeSdk: () => sdk,
+ sdk: { XChainSDK: function () { throw new Error('makeSdk should be used') } },
+ log: m => logged.push(String(m))
+ }
+ return stakeValidator(opts, deps).then(result => ({ result, calls, logged }))
+}
+
+describe('ValidatorStakeService', function () {
+
+ describe('planMints()', function () {
+
+ it('needs no mint when the balance already covers the stake', function () {
+ expect(planMints('testnet', 30000, 25000, 10000, 50000)).to.deep.equal({ short: 0, mints: [], reason: null })
+ })
+
+ it('splits the shortfall into per-transaction cap-sized mints', function () {
+ const p = planMints('testnet', 0, 25000, 10000, 50000)
+ expect(p.short).to.equal(25000)
+ expect(p.mints).to.deep.equal([10000, 10000, 5000])
+ expect(p.reason).to.be.null
+ })
+
+ it('mints only the difference when partly funded', function () {
+ expect(planMints('testnet', 12000, 25000, 10000, 50000).mints).to.deep.equal([10000, 3000])
+ })
+
+ it('never mints on mainnet', function () {
+ const p = planMints('mainnet', 0, 25000, 10000, 50000)
+ expect(p.mints).to.deep.equal([])
+ expect(p.reason).to.match(/not mintable on mainnet/)
+ })
+
+ it('flags a shortfall above the per-address cap', function () {
+ const p = planMints('testnet', 0, 60000, 10000, 50000)
+ expect(p.reason).to.match(/per-address mint cap/)
+ })
+
+ it('flags a token with no open mint', function () {
+ expect(planMints('testnet', 0, 25000, 0, 0).reason).to.match(/no open mint/)
+ })
+ })
+
+ describe('stakeValidator()', function () {
+
+ it('dry run prints the plan and sends nothing', async function () {
+ const { result, calls, logged } = await run({}, { xchain: 0, coin: '0.001' })
+ expect(result.dryRun).to.be.true
+ expect(calls.mint).to.have.length(0)
+ expect(calls.stake).to.have.length(0)
+ expect(logged.join('\n')).to.include('MINT 1/3: 10000 XCHAIN')
+ expect(logged.join('\n')).to.include('STAKE v1: 25000 XCHAIN to ' + PUBKEY)
+ expect(logged.join('\n')).to.include('Dry run')
+ })
+
+ it('with --broadcast mints the shortfall in order, then stakes', async function () {
+ const { result, calls } = await run({ broadcast: true }, { xchain: 5000, coin: '0.001' })
+ expect(calls.mint.map(c => c.params)).to.deep.equal([
+ { VERSION: 0, TICK: 'XCHAIN', AMOUNT: '10000' },
+ { VERSION: 0, TICK: 'XCHAIN', AMOUNT: '10000' }
+ ])
+ expect(calls.stake).to.have.length(1)
+ expect(calls.stake[0].params).to.deep.equal({ VERSION: 1, AMOUNT: '25000', SIGNING_PUBKEY: PUBKEY })
+ expect(result.staked).to.be.true
+ expect(result.txid).to.equal('staketx')
+ })
+
+ // The whole point of chaining: the indexer resolves a STAKE against every
+ // ledger entry with a lower action index, so the mints only need to be
+ // EARLIER IN THE SAME BLOCK. Waiting a block per step buys nothing.
+ it('sends everything back to back and waits only on the STAKE', async function () {
+ const { calls, result } = await run({ broadcast: true }, { xchain: 0, coin: '0.001' })
+ expect(calls.mint).to.have.length(3)
+ for (const m of calls.mint) expect(m.opts.waitForIndexer, 'a mint must not wait for a block').to.be.false
+ expect(calls.stake[0].opts.waitForIndexer, 'the stake waits, so the operator sees it land').to.be.true
+ expect(calls.stake[0].opts.timeout).to.be.above(60 * 60 * 1000)
+ expect(result.chained).to.be.true
+ })
+
+ // Ordering inside the block is guaranteed by construction, not hoped for:
+ // each action is funded from the previous one's outputs, and consensus
+ // forbids a child from preceding its parent in a block.
+ it('funds each action from the previous one, forcing the in-block order', async function () {
+ const { calls } = await run({ broadcast: true }, { xchain: 0, coin: '0.001' })
+ expect(calls.mint[0].enc.utxos, 'the first action funds itself freely').to.be.undefined
+ expect(calls.mint[1].enc.utxos.map(u => u.txid)).to.deep.equal(['mint1'])
+ expect(calls.mint[2].enc.utxos.map(u => u.txid)).to.deep.equal(['mint2'])
+ expect(calls.stake[0].enc.utxos.map(u => u.txid)).to.deep.equal(['mint3'])
+ })
+
+ it('waits for the mints to index when no chain can be formed, rather than racing the STAKE', async function () {
+ const { calls, logged, result } = await run(
+ { broadcast: true, chainTimeoutMs: 5, balancePollMs: 5 },
+ { xchain: 0, coin: '0.001', noChange: true })
+ expect(logged.join('\n')).to.include('cannot chain')
+ expect(logged.join('\n')).to.include('the funding chain broke')
+ expect(calls.stake, 'the stake still goes out, after the wait').to.have.length(1)
+ expect(calls.stake[0].enc.utxos, 'funded freely once ordering stops mattering').to.be.undefined
+ expect(result.chained).to.be.false
+ })
+
+ it('reports pending mints instead of staking when they never index', async function () {
+ const { calls, result, logged } = await run(
+ { broadcast: true, chainTimeoutMs: 5, balancePollMs: 5, timeout: 0.0001 },
+ { xchain: 0, coin: '0.001', noChange: true, mintsNeverIndex: true })
+ expect(calls.mint).to.have.length(3)
+ expect(calls.stake, 'never broadcast a STAKE that would be rejected').to.have.length(0)
+ expect(result.pendingMints).to.be.true
+ expect(logged.join('\n')).to.include('re-run this command to send the STAKE')
+ })
+
+ it('--serialize keeps the old block-per-action behaviour', async function () {
+ const { calls } = await run({ broadcast: true, serialize: true, balancePollMs: 5 }, { xchain: 0, coin: '0.001' })
+ for (const m of calls.mint) expect(m.opts.waitForIndexer).to.be.true
+ for (const m of calls.mint) expect(m.enc.utxos, 'no chaining when serialized').to.be.undefined
+ expect(calls.stake).to.have.length(1)
+ })
+
+ it('skips minting when already funded', async function () {
+ const { calls } = await run({ broadcast: true }, { xchain: 25000, coin: '0.001' })
+ expect(calls.mint).to.have.length(0)
+ expect(calls.stake).to.have.length(1)
+ })
+
+ it('--no-wait returns as soon as the STAKE is broadcast', async function () {
+ const { result, calls, logged } = await run({ broadcast: true, wait: false }, { xchain: 0, coin: '0.001' })
+ expect(calls.mint).to.have.length(3)
+ expect(calls.stake).to.have.length(1)
+ expect(calls.stake[0].opts.waitForIndexer).to.be.false
+ expect(result.staked).to.be.true
+ expect(logged.join('\n')).to.include('Broadcast. Watch it land at')
+ })
+
+ it('passes --fee-per-kb through to the encoder', async function () {
+ const { calls } = await run({ broadcast: true, feePerKb: '0.0001' }, { xchain: 25000, coin: '0.001' })
+ expect(calls.stake[0].enc).to.deep.equal({ feePerKb: 0.0001 })
+ })
+
+ it('refuses when the address has no coin for fees', async function () {
+ const { result, calls, logged } = await run({ broadcast: true }, { xchain: 0, coin: '0' })
+ expect(result.blockers.join(' ')).to.match(/no confirmed TBTC/)
+ expect(calls.mint).to.have.length(0)
+ expect(logged.join('\n')).to.include('BLOCKED')
+ })
+
+ it('does nothing when the pubkey already carries a valid stake', async function () {
+ const existing = { status: 'valid', signing_pubkey: PUBKEY, amount: '25000', action_index: '33', activation_block: '150061' }
+ const { result, calls, logged } = await run({ broadcast: true }, { xchain: 25000, coin: '0.001', existing })
+ expect(result.staked).to.be.false
+ expect(result.existing).to.equal(existing)
+ expect(calls.stake).to.have.length(0)
+ expect(logged.join('\n')).to.include('already carries a valid STAKE')
+ })
+
+ it('on mainnet never mints and blocks on a shortfall', async function () {
+ const { result, calls } = await run({ broadcast: true }, { xchain: 1000, coin: '0.01' }, { network: 'mainnet', P2P_PORT: 10001 })
+ expect(calls.mint).to.have.length(0)
+ expect(calls.stake).to.have.length(0)
+ expect(result.blockers.join(' ')).to.match(/not mintable on mainnet/)
+ })
+
+ it('refuses a WIF that does not control the recorded stake address', async function () {
+ const { sdk } = makeSdk({ xchain: 25000 })
+ sdk.session = () => ({ address: 'mSomeOtherAddress' })
+ let err = null
+ try {
+ await stakeValidator({}, {
+ settings: { enabled: true, pubkey: PUBKEY, network: 'testnet' },
+ wallets: { STAKE_ADDRESS: ADDRESS, STAKE_WIF_SECRET: 'cFakeWif' },
+ makeSdk: () => sdk, sdk: {}, log: () => {}
+ })
+ } catch (e) { err = e }
+ expect(err).to.exist
+ expect(err.message).to.match(/controls mSomeOtherAddress, not the mStakeAddress/)
+ })
+
+ it('warns and keeps going when the validator set cannot be read, instead of reading the failure as "not staked"', async function () {
+ const { result, logged } = await run({}, { xchain: 25000, coin: '0.001', validatorsThrow: 'explorer 503' })
+ expect(logged.join('\n')).to.include('could not read the validator set (explorer 503)')
+ expect(result.dryRun).to.be.true
+ })
+
+ // The guard above is only as good as the method name it calls: a stub
+ // for a method the real SDK does not have passes every test here while
+ // the live path throws into a catch and reports "not staked".
+ 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'])
+ 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')
+ expect(sdk.explorer.getValidator, 'getValidator does NOT exist; do not call it').to.be.undefined
+ })
+
+ it('refuses without a validator', async function () {
+ let err = null
+ try { await stakeValidator({}, { settings: null }) } catch (e) { err = e }
+ expect(err.message).to.match(/no validator configured/)
+ })
+ })
+
+ // Standing down matters as much as joining: membership is derived from
+ // chain stake alone, so a staked validator that is not running still counts
+ // toward every capability's N and raises the federation's quorum threshold
+ // while contributing nothing.
+ describe('unstakeValidator()', function () {
+
+ function runUnstake(opts, chain, settingsExtra = {}) {
+ const { sdk, calls } = makeSdk(chain)
+ const logged = []
+ const deps = {
+ settings: { enabled: true, pubkey: PUBKEY, network: 'testnet', P2P_PORT: 10002, ...settingsExtra },
+ wallets: { NETWORK: 'testnet', STAKE_ADDRESS: ADDRESS, STAKE_WIF_SECRET: 'cFakeWif' },
+ makeSdk: () => sdk,
+ sdk: {},
+ log: m => logged.push(String(m))
+ }
+ const unstakeCalls = []
+ const origSession = sdk.session
+ sdk.session = () => {
+ const s = origSession()
+ const inner = s.submit
+ s.submit = async (a, e, o) => { if (a.action === 'UNSTAKE') unstakeCalls.push({ params: a.params, enc: e, opts: o }); return inner(a, e, o) }
+ return s
+ }
+ return unstakeValidator(opts, deps).then(result => ({ result, calls, unstakeCalls, logged }))
+ }
+
+ const STAKED = { status: 'valid', signing_pubkey: PUBKEY, amount: '25000', action_index: '44', activation_block: '150313' }
+
+ it('dry run reports the active stake and sends nothing', async function () {
+ const { result, unstakeCalls, logged } = await runUnstake({}, { existing: STAKED })
+ expect(result.dryRun).to.be.true
+ expect(unstakeCalls).to.have.length(0)
+ expect(logged.join('\n')).to.include('active stake : 25000 XCHAIN (action 44, activated at block 150313)')
+ expect(logged.join('\n')).to.include('Dry run')
+ })
+
+ it('with --broadcast sends UNSTAKE v0 for this pubkey', async function () {
+ const { result, unstakeCalls } = await runUnstake({ broadcast: true }, { existing: STAKED })
+ expect(unstakeCalls).to.have.length(1)
+ expect(unstakeCalls[0].params).to.deep.equal({ VERSION: 0, SIGNING_PUBKEY: PUBKEY })
+ expect(result.unstaked).to.be.true
+ })
+
+ it('says the delay out loud: the stake keeps counting for 6 more blocks', async function () {
+ const { logged } = await runUnstake({ broadcast: true }, { existing: STAKED })
+ expect(logged.join('\n')).to.include('for 6 more blocks')
+ expect(logged.join('\n')).to.include('leave the active set 6 blocks after')
+ })
+
+ it('does nothing when the pubkey carries no valid stake', async function () {
+ const { result, unstakeCalls, logged } = await runUnstake({ broadcast: true }, {})
+ expect(result.nothingStaked).to.be.true
+ expect(unstakeCalls).to.have.length(0)
+ expect(logged.join('\n')).to.include('carries no valid stake')
+ })
+
+ // Refusing beats guessing here: an unreadable validator set could mean
+ // "nothing staked", and acting on that reading is how a real stake gets
+ // left in place while the operator is told they have stood down.
+ it('refuses when the validator set cannot be read', async function () {
+ let err = null
+ try { await runUnstake({ broadcast: true }, { validatorsThrow: 'explorer 503' }) } catch (e) { err = e }
+ expect(err).to.exist
+ expect(err.message).to.match(/could not read the validator set/)
+ })
+
+ it('refuses a WIF that does not control the recorded stake address', async function () {
+ const { sdk } = makeSdk({ existing: STAKED })
+ sdk.session = () => ({ address: 'mSomeOtherAddress', submit: async () => ({ txid: 'x' }) })
+ let err = null
+ try {
+ await unstakeValidator({ broadcast: true }, {
+ settings: { enabled: true, pubkey: PUBKEY, network: 'testnet' },
+ wallets: { STAKE_ADDRESS: ADDRESS, STAKE_WIF_SECRET: 'cFakeWif' },
+ makeSdk: () => sdk, sdk: {}, log: () => {}
+ })
+ } catch (e) { err = e }
+ expect(err).to.exist
+ expect(err.message).to.match(/controls mSomeOtherAddress/)
+ })
+
+ it('refuses without a validator', async function () {
+ let err = null
+ try { await unstakeValidator({}, { settings: null }) } catch (e) { err = e }
+ expect(err.message).to.match(/no validator configured/)
+ })
+ })
+})
diff --git a/test/unit/cryptoNodeBuildScaffold.test.js b/test/unit/cryptoNodeBuildScaffold.test.js
new file mode 100644
index 0000000..5d4fa06
--- /dev/null
+++ b/test/unit/cryptoNodeBuildScaffold.test.js
@@ -0,0 +1,174 @@
+'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 against the REAL filesystem on purpose. The bug these tests cover was a
+// mismatch between the build directory and the source tree, and a mocked fs is
+// exactly what hid it: every unit test stubbed the conf file as present, so the
+// one case that mattered (a build directory holding nothing but the downloaded
+// tarball) was never expressed.
+
+const fs = require('fs')
+const os = require('os')
+const path = require('path')
+const { expect } = require('chai')
+
+const { stageBuildScaffold } = require('../../src/services/NodeService')
+
+const REPO_ROOT = path.join(__dirname, '..', '..')
+const CREDS = { NODE_USER: 'u_test', NODE_PASSWORD: 'p_test' }
+
+describe('crypto-node build scaffold (XCHAIN_NODE_CRYPTO_NODES_DIR)', function () {
+ let tmpRoot
+
+ beforeEach(function () {
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xcn-scaffold-'))
+ })
+ afterEach(function () {
+ fs.rmSync(tmpRoot, { recursive: true, force: true })
+ })
+
+ // The reported failure: with the env var pointed at a separate volume the
+ // installer created // and downloaded the daemon tarball into it,
+ // then ran `docker build .` there. The context held no Dockerfile, so docker
+ // reported "transferring dockerfile: 2B" and failed after the whole download.
+ it('stages a Dockerfile into a build dir that holds only the downloaded tarball', function () {
+ const nodeDir = path.join(tmpRoot, 'litecoin')
+ fs.mkdirSync(nodeDir, { recursive: true })
+ fs.writeFileSync(path.join(nodeDir, 'litecoin-0.21.tar.gz'), 'tarball')
+ expect(fs.readdirSync(nodeDir)).to.not.include('Dockerfile')
+
+ const confName = stageBuildScaffold('litecoin', 'testnet', nodeDir, CREDS)
+
+ expect(fs.existsSync(path.join(nodeDir, 'Dockerfile'))).to.be.true
+ expect(fs.existsSync(path.join(nodeDir, confName))).to.be.true
+ })
+
+ it('creates the build dir when it does not exist yet', function () {
+ const nodeDir = path.join(tmpRoot, 'dogecoin')
+ stageBuildScaffold('dogecoin', 'mainnet', nodeDir, CREDS)
+ expect(fs.existsSync(path.join(nodeDir, 'Dockerfile'))).to.be.true
+ })
+
+ it('injects the provisioned credentials, leaving no placeholder in the built conf', function () {
+ const nodeDir = path.join(tmpRoot, 'bitcoin')
+ const confName = stageBuildScaffold('bitcoin', 'testnet', nodeDir, CREDS)
+ const conf = fs.readFileSync(path.join(nodeDir, confName), 'utf8')
+
+ expect(conf).to.match(/^rpcuser=u_test$/m)
+ expect(conf).to.match(/^rpcpassword=p_test$/m)
+ expect(conf).to.not.include('__XCHAIN_NODE_RPC_')
+ })
+
+ // The template is tracked in git. Injecting in place wrote live RPC
+ // credentials into a tracked file and left every host that ever built a node
+ // with a dirty worktree holding a secret.
+ it('never writes credentials back into the tracked template', function () {
+ const nodeDir = path.join(tmpRoot, 'bitcoin')
+ const template = path.join(REPO_ROOT, 'crypto_nodes', 'bitcoin', 'bitcoin-testnet.conf')
+ const before = fs.readFileSync(template, 'utf8')
+
+ const confName = stageBuildScaffold('bitcoin', 'testnet', nodeDir, CREDS)
+
+ expect(fs.readFileSync(template, 'utf8')).to.equal(before)
+ expect(before).to.include('__XCHAIN_NODE_RPC_PASSWORD__')
+ // ...and the generated file is a different file, kept out of git.
+ expect(confName).to.not.equal('bitcoin-testnet.conf')
+ expect(confName).to.include('.generated.')
+ })
+
+ it('writes the credential-bearing conf 0600', function () {
+ const nodeDir = path.join(tmpRoot, 'bitcoin')
+ const confName = stageBuildScaffold('bitcoin', 'regtest', nodeDir, CREDS)
+ const mode = fs.statSync(path.join(nodeDir, confName)).mode & 0o777
+ expect(mode).to.equal(0o600)
+ })
+
+ // Missing credentials must stop the build, not silently skip the injection:
+ // the absent-conf case is precisely the custom-directory one, and skipping
+ // ships an image carrying the placeholder tokens as its RPC credentials.
+ it('fails closed when the provisioned credentials are missing', function () {
+ const nodeDir = path.join(tmpRoot, 'bitcoin')
+ expect(() => stageBuildScaffold('bitcoin', 'mainnet', nodeDir, {}))
+ .to.throw(/Missing NODE_USER\/NODE_PASSWORD/)
+ expect(() => stageBuildScaffold('bitcoin', 'mainnet', nodeDir, { NODE_USER: 'u' }))
+ .to.throw(/Missing NODE_USER\/NODE_PASSWORD/)
+ })
+
+ it('names the missing path when the scaffold is not in the source tree', function () {
+ const nodeDir = path.join(tmpRoot, 'bitcoin')
+ expect(() => stageBuildScaffold('bitcoin', 'nosuchnetwork', nodeDir, CREDS))
+ .to.throw(/Missing build scaffold .*bitcoin-nosuchnetwork\.conf/)
+ })
+
+ // The default layout points the build dir at the source tree itself. The copy
+ // must not turn into a self-copy, and must not disturb the tracked files.
+ it('is a no-op copy when the build dir IS the source tree', function () {
+ const bundled = path.join(REPO_ROOT, 'crypto_nodes', 'bitcoin')
+ const dockerfileBefore = fs.readFileSync(path.join(bundled, 'Dockerfile'), 'utf8')
+
+ const confName = stageBuildScaffold('bitcoin', 'regtest', bundled, CREDS)
+ try {
+ expect(fs.readFileSync(path.join(bundled, 'Dockerfile'), 'utf8')).to.equal(dockerfileBefore)
+ expect(fs.existsSync(path.join(bundled, confName))).to.be.true
+ } finally {
+ fs.rmSync(path.join(bundled, confName), { force: true })
+ }
+ })
+
+ // Not about the build scaffold, but the same class of problem: a secret the
+ // repo writes into the working tree that no ignore rule covered.
+ describe('secrets the CLI writes are ignored by git', function () {
+ const { execFileSync } = require('child_process')
+
+ function isIgnored(relPath) {
+ try {
+ execFileSync('git', ['check-ignore', '-q', relPath], { cwd: REPO_ROOT })
+ return true
+ } catch (err) {
+ // exit 1 means "not ignored"; anything else is a broken probe.
+ if (err.status === 1) return false
+ throw err
+ }
+ }
+
+ // `validator init` writes an Ed25519 consensus key here and
+ // `validator stake` writes a spendable Bitcoin key beside it. This is a
+ // public repo that operators clone, so an unignored key is one
+ // `git add -A` away from being published.
+ it('ignores the validator identity directory', function () {
+ expect(isIgnored('config/validator/signing.key'), 'signing.key').to.be.true
+ expect(isIgnored('config/validator/stake.wif'), 'stake.wif').to.be.true
+ })
+
+ it('ignores the hub credential sidecar', function () {
+ expect(isIgnored('config/hub.local')).to.be.true
+ })
+
+ // The build writes RPC credentials into this file; the template beside
+ // it stays tracked and must keep its placeholders.
+ it('ignores the generated crypto-node conf but not the template', function () {
+ expect(isIgnored('crypto_nodes/bitcoin/bitcoin-testnet.generated.conf')).to.be.true
+ expect(isIgnored('crypto_nodes/bitcoin/bitcoin-testnet.conf')).to.be.false
+ })
+ })
+
+ it('covers every coin and network the installer supports', function () {
+ for (const coin of ['bitcoin', 'litecoin', 'dogecoin']) {
+ for (const network of ['mainnet', 'testnet', 'regtest']) {
+ const nodeDir = path.join(tmpRoot, `${coin}-${network}`)
+ const confName = stageBuildScaffold(coin, network, nodeDir, CREDS)
+ expect(fs.existsSync(path.join(nodeDir, 'Dockerfile')), `${coin} ${network} Dockerfile`).to.be.true
+ expect(fs.readFileSync(path.join(nodeDir, confName), 'utf8')).to.match(/^rpcpassword=p_test$/m)
+ }
+ }
+ })
+})
diff --git a/test/unit/mutatingLockWait.test.js b/test/unit/mutatingLockWait.test.js
new file mode 100644
index 0000000..3a92110
--- /dev/null
+++ b/test/unit/mutatingLockWait.test.js
@@ -0,0 +1,87 @@
+/*********************************************************************
+ * Unit tests for the mutating-command lock wait
+ * (XCHAIN_NODE_MUTATING_LOCK_WAIT_MS). The default must stay "refuse a held
+ * lock at once", so both halves are asserted. These drive the real CLI in a
+ * child process: the behaviour lives in the preAction hook, not an export.
+ ********************************************************************/
+
+const assert = require('assert')
+const fs = require('fs')
+const os = require('os')
+const path = require('path')
+const { spawnSync } = require('child_process')
+
+const CLI = path.join(__dirname, '..', '..', 'src', 'index.js')
+
+describe('mutating command lock wait', () => {
+ let tmpDir, holder
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xchain-node-mutwait-'))
+ // A live pid, so the lock reads as held rather than stale-and-reapable.
+ holder = require('child_process').spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)'])
+ fs.writeFileSync(
+ path.join(tmpDir, 'command.lock'),
+ JSON.stringify({ pid: holder.pid, command: 'update', startedAt: new Date().toISOString() })
+ )
+ })
+
+ afterEach(() => {
+ if (holder) holder.kill()
+ fs.rmSync(tmpDir, { recursive: true, force: true })
+ })
+
+ function runAutoheal(extraEnv) {
+ const started = Date.now()
+ const res = spawnSync(process.execPath, [CLI, 'autoheal'], {
+ env: {
+ ...process.env,
+ XCHAIN_NODE_LOCK_DIR: tmpDir,
+ // The third case proceeds past the lock into a MUTATING action,
+ // so pin Docker out of reach: no machine may act on real
+ // containers from a unit test.
+ DOCKER_HOST: 'unix:///nonexistent/xchain-node-test-docker.sock',
+ ...extraEnv
+ },
+ encoding: 'utf8',
+ timeout: 60000
+ })
+ return { elapsed: Date.now() - started, output: `${res.stdout || ''}${res.stderr || ''}` }
+ }
+
+ it('refuses a held lock immediately when the env var is unset (interactive default)', function () {
+ this.timeout(40000)
+ const { elapsed, output } = runAutoheal({ XCHAIN_NODE_MUTATING_LOCK_WAIT_MS: '' })
+ assert.match(output, /holds the command lock/)
+ // Generous bound: the point is that it does not sit through a wait, not
+ // that process startup hits any particular millisecond.
+ assert.ok(elapsed < 10000, `expected an immediate refusal, took ${elapsed}ms`)
+ })
+
+ it('waits for the holder when XCHAIN_NODE_MUTATING_LOCK_WAIT_MS is set', function () {
+ this.timeout(40000)
+ const { elapsed, output } = runAutoheal({ XCHAIN_NODE_MUTATING_LOCK_WAIT_MS: '3000' })
+ assert.match(output, /holds the command lock/)
+ // Lower bound only. A loaded runner can be slower but never faster, so
+ // this cannot flake the way an upper bound would.
+ assert.ok(elapsed >= 2500, `expected to block for the wait, took only ${elapsed}ms`)
+ })
+
+ it('takes the lock once the holder releases mid-wait', function () {
+ this.timeout(40000)
+ // The release MUST come from a separate process: spawnSync below blocks
+ // this one's event loop, so an in-process timer would not fire until the
+ // child had already given up.
+ const lockPath = path.join(tmpDir, 'command.lock')
+ require('child_process').spawn(
+ process.execPath,
+ ['-e', `setTimeout(()=>require('fs').rmSync(${JSON.stringify(lockPath)},{force:true}),2000)`],
+ { detached: true, stdio: 'ignore' }
+ ).unref()
+ const { output } = runAutoheal({ XCHAIN_NODE_MUTATING_LOCK_WAIT_MS: '30000' })
+ // It proceeds past the lock and on into preCheck (which fails here with
+ // no Docker). Absence of the lock error is the assertion: it acquired
+ // rather than timing out.
+ assert.doesNotMatch(output, /holds the command lock/)
+ })
+})