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 @@

Version - Tests + Tests Node License

@@ -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/) + }) +})