From d2edbc14439c0226fc551e7835421ec21c65f371 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 30 Aug 2026 21:10:51 -0700 Subject: [PATCH 1/8] validator: init builds the coin wallets, stake mints and broadcasts in one block, unstake withdraws --- .gitignore | 4 + README.md | 33 +- package-lock.json | 1803 ++++++++++++++++++++++- package.json | 4 +- src/cli.js | 112 +- src/services/ModuleService.js | 16 + src/services/ValidatorService.js | 717 ++++++++- src/services/ValidatorStakeService.js | 446 ++++++ test/unit/ModuleService.test.js | 4 +- test/unit/ServiceRegistry.test.js | 39 +- test/unit/ValidatorService.test.js | 663 ++++++++- test/unit/ValidatorStakeService.test.js | 379 +++++ 12 files changed, 4150 insertions(+), 70 deletions(-) create mode 100644 src/services/ValidatorStakeService.js create mode 100644 test/unit/ValidatorStakeService.test.js diff --git a/.gitignore b/.gitignore index c7e7457..deea375 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,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/README.md b/README.md index 276ee20..2644b08 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 1d0aed3..c49d1f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.11.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", @@ -2786,6 +3856,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", @@ -2821,9 +3938,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", @@ -2947,6 +4069,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", @@ -2987,11 +4144,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" @@ -3000,6 +4165,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", @@ -3052,6 +4238,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", @@ -3066,7 +4261,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" @@ -3103,6 +4297,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", @@ -3120,6 +4324,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", @@ -3136,6 +4358,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", @@ -3146,6 +4374,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", @@ -3171,7 +4412,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", @@ -3184,6 +4424,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", @@ -3212,7 +4510,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" @@ -3234,10 +4531,39 @@ "resolve": "bin/resolve" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "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" + }, + "engines": { + "node": ">= 18" } }, "node_modules/rxjs": { @@ -3250,12 +4576,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", @@ -3268,6 +4640,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", @@ -3278,11 +4701,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" @@ -3295,7 +4779,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" @@ -3305,7 +4788,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", @@ -3325,7 +4807,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", @@ -3342,7 +4823,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", @@ -3361,7 +4841,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", @@ -3427,6 +4906,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", @@ -3641,6 +5144,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", @@ -3678,6 +5216,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", @@ -3705,6 +5322,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", @@ -3725,6 +5348,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", @@ -3756,6 +5388,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", @@ -3770,6 +5408,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", @@ -3781,7 +5437,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" @@ -3793,6 +5448,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", @@ -3841,6 +5555,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", @@ -3965,11 +5706,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 44ffabe..fa1caa2 100644 --- a/package.json +++ b/package.json @@ -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/src/cli.js b/src/cli.js index bdb284c..2aec96e 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') @@ -595,18 +598,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 +665,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 +674,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/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/ValidatorService.js b/src/services/ValidatorService.js index de3f2f3..8b0469c 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, @@ -75,6 +89,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 +463,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 +480,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 +495,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 +570,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 +637,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 +688,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 master 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 master 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 +835,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 +876,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 +990,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/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/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/) + }) + }) +}) From 845bf882c5133d8a165d950a51daf4da8628b8df Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 31 Aug 2026 19:45:03 -0700 Subject: [PATCH 2/8] node: wait out a busy command lock instead of losing a publish run `bootstrap create` is a mutating subcommand, so it takes the same command lock as `update` and `reset`. A bootstrap publish that started while a deploy was underway therefore failed every combo in the same second and reported it as a broken publisher, so the run exited and the archives it should have cut simply never appeared. That is how a scheduled tracker publish was lost with nothing to show for it but stale archives found by hand hours later. Mutating commands now accept a wait, defaulting to zero so an interactive update or reset still refuses a held lock immediately rather than appearing to hang. The lock helper already polls and takes the lock the moment it frees, so no retry logic was added; unattended callers just ask for the wait. The publisher asks for thirty minutes and reports a combo it could not start as locked, which tells the operator the box was busy rather than sending them to look for a fault in the machinery or the source. --- scripts/publish-bootstraps.sh | 14 ++++- src/cli.js | 8 ++- test/unit/mutatingLockWait.test.js | 87 ++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 test/unit/mutatingLockWait.test.js diff --git a/scripts/publish-bootstraps.sh b/scripts/publish-bootstraps.sh index 9810283..ed9c486 100755 --- a/scripts/publish-bootstraps.sh +++ b/scripts/publish-bootstraps.sh @@ -32,6 +32,10 @@ # 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 waits $LOCK_WAIT_MIN minutes for +# the holder, then reports the combo LOCKED (busy box, not a broken source). # - 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 +92,7 @@ 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) TRACKER_SVC="xchain-utxo-tracker" ALL_SERVICES=(xchain-decoder xchain-indexer xchain-utxo-tracker) @@ -246,10 +251,17 @@ for c in "${SELECTED[@]}"; do 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="$((LOCK_WAIT_MIN * 60000))" \ "$XCHAIN_NODE_BIN" bootstrap create "$svc" "$coin" "$net" >"$create_log" 2>&1 || create_rc=$? 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 2aec96e..5596787 100644 --- a/src/cli.js +++ b/src/cli.js @@ -120,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") @@ -139,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. @@ -148,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) 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/) + }) +}) From ea9a5344d3d4e131393499a43d682019c12eba1d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 31 Aug 2026 20:02:57 -0700 Subject: [PATCH 3/8] node: retry a locked bootstrap create in the publisher, not the CLI The wait for a busy command lock lived in the CLI, so a fleet running a pinned release does not get it. The publisher is a standalone script that can be updated on its own; the CLI cannot be moved off a release pin one file at a time, because a single file carries requires that the pinned tree has no modules for. Move the retry into the publisher, where it works against whatever CLI the box carries, and pin the CLI's own wait to zero so the two cannot compound. Budget and poll interval are both settable, and a create still locked when the budget runs out is reported exactly as before. --- scripts/publish-bootstraps.sh | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/scripts/publish-bootstraps.sh b/scripts/publish-bootstraps.sh index ed9c486..245d281 100755 --- a/scripts/publish-bootstraps.sh +++ b/scripts/publish-bootstraps.sh @@ -34,8 +34,9 @@ # 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 waits $LOCK_WAIT_MIN minutes for -# the holder, then reports the combo LOCKED (busy box, not a broken source). +# 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. @@ -93,6 +94,7 @@ 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) @@ -246,13 +248,26 @@ 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_MUTATING_LOCK_WAIT_MS="$((LOCK_WAIT_MIN * 60000))" \ - "$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 # Contention is neither a broken publisher nor a broken source: the box was From efb3b12463186fe3251efc13a842f42542d0212c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 31 Aug 2026 21:33:16 -0700 Subject: [PATCH 4/8] docs: the README badges and test counts describe what the repo ships --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2644b08..a7ed4c1 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ # XChain Platform Node

- Version - Tests + Version + Tests Node License

@@ -156,19 +156,19 @@ Turn it off with any of: `--no-telemetry` on any command (sticks for future runs | Command | Description | |---|---| -| `npm test` | Unit tests (1,084 tests) | -| `npm run test:integration` | Integration tests (87 tests) | -| `npm run test:smoke` | Smoke tests (50 tests) | +| `npm test` | Unit tests (1,759 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 (27 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 (95 tests) | +| `npm run test:fuzz` | Fuzz tests (264 tests) | | `npm run test:chaos` | Chaos engineering tests (121 tests) | | `npm run test:regression` | Regression tests (58 tests) | | `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 (~1,552 tests; excludes security/boundary) | +| `npm run test:all` | All tests (~2,521 tests; excludes security/boundary) | | `npm run benchmark` | Performance benchmarks (5 scenarios) | | `npm run benchmark:quick` | Quick benchmarks | From 6981a728e3191aea4fd9dd21117b027eae17a43b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 1 Sep 2026 09:06:29 -0700 Subject: [PATCH 5/8] validator: point the printed next step at the release, not a branch `validator init` ends by telling a new operator to run `install master xchain-hub`. That is an unpinned tracking install, it contradicts every other instruction we give, and it is printed at the exact moment someone is deciding what to run. The ref is derived from this CLI's own version, which is the platform version, so the hint stays on the release the operator is holding instead of rotting the way a hardcoded one does. --- src/services/ValidatorService.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/services/ValidatorService.js b/src/services/ValidatorService.js index 8b0469c..9e7be6b 100644 --- a/src/services/ValidatorService.js +++ b/src/services/ValidatorService.js @@ -69,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') @@ -766,14 +773,14 @@ async function initValidator(opts = {}) { : 'already present, kept') console.log('') console.log(' Then: xchain-node validator stake (mints XCHAIN on testnet, then stakes)') - console.log(' xchain-node install master xchain-hub (starts the validator)') + 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 master xchain-hub') + console.log(' Then: xchain-node install ' + RELEASE_REF + ' xchain-hub') } console.log('') if (!settings.ORACLE_EPOCH_START) From 252e3b51d50801ddb3d7613ba5178a5670967a2b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 1 Sep 2026 09:29:43 -0700 Subject: [PATCH 6/8] node: build a coin image from a context that actually holds the Dockerfile 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 and conf template the repo ships in git. Only the daemon tree is downloaded there, so on the default layout the scaffold was present by accident: the build directory already was the repo's crypto_nodes. Point the crypto-nodes directory at a separate volume, which the README recommends for a small root partition, and the build context held nothing but the tarball. Docker reported "failed to read dockerfile" after the entire download had already run. Stage the scaffold before every build, and fail closed naming the missing path rather than letting docker report a two-byte build context. Two defects in the same path go with it. The credential injection was skipped whenever the conf was absent, which is exactly the custom-directory case, so a build there would have shipped an image carrying the placeholder tokens as its RPC credentials; it now fails closed. And the substitution wrote back into the tracked conf template, leaving every host that ever built a node with a dirty worktree holding live credentials. It now writes a gitignored 0600 generated sibling and never touches the template. --- .gitignore | 2 + src/services/NodeService.js | 80 +++++++--- test/unit/NodeService.test.js | 17 ++- test/unit/cryptoNodeBuildScaffold.test.js | 174 ++++++++++++++++++++++ 4 files changed, 253 insertions(+), 20 deletions(-) create mode 100644 test/unit/cryptoNodeBuildScaffold.test.js diff --git a/.gitignore b/.gitignore index deea375..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 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/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/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) + } + } + }) +}) From 5a2960233725834fa424197f49907abce21d9876 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 1 Sep 2026 09:29:43 -0700 Subject: [PATCH 7/8] cli: refuse an unknown service name instead of installing everything An argument that matched no service, coin or network was silently dropped, and the service slot kept its default of "all". So `install master hub` did not refuse the unrecognized name, it expanded to every service on every coin and network. An operator reaching for one service got the whole platform. The trap was already known for one token: `xchain-node` is guarded by name, with a comment explaining that a dropped argument leaves service='all'. That reasoning was never generalized, so every other typo and every short name still fell through. Refuse anything unrecognized and name the valid services, coins and networks in the refusal. The branch slot still accepts an arbitrary string, so the refusal only fires once that slot is spoken for. Shared services are also aliased now. The canonical names carry an `xchain-` prefix that is easy to omit, `explorer` already had that treatment, and the others did not, so `recreate hub` matched no container and read as though the hub simply could not be recreated. hub, sync and db now resolve to their canonical names in both the argument resolver and the command filter, because the commands that bypass the resolver hand the operator's raw token straight through. --- src/services/ConfigService.js | 28 +++++++++++++++ test/unit/ConfigService.test.js | 64 +++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) 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/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' }) + }) }) }) From b3f20d4fe28926d6d06d49016e85c9b75fbdd8d0 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 1 Sep 2026 09:33:53 -0700 Subject: [PATCH 8/8] release: 0.12.2 A patch train that moves the carrier alone. Every component keeps the tag it already carries, which is what it means for a version to be the platform version at which a component last changed: a gap is unchanged, not skipped. It exists because v0.12.1 shipped without the validator commands its own documentation describes. Someone following the published quickstart installed the release, ran `validator stake`, and was told there was no such command. The documentation was right; the release was missing the feature. Also carries an unrecognised service name being refused with the list of valid ones rather than silently expanding to every service on every coin and network, a coin image building from a context that holds its Dockerfile, and a mutating command waiting out a busy lock instead of losing the run. --- CHANGELOG.md | 12 ++++ README.md | 6 +- package.json | 2 +- src/release-manifest.json | 125 +++++++++++++++++++------------------- 4 files changed, 79 insertions(+), 66 deletions(-) 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 a7ed4c1..5aa3cc5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Version - Tests + Tests Node License

@@ -156,7 +156,7 @@ Turn it off with any of: `--no-telemetry` on any command (sticks for future runs | Command | Description | |---|---| -| `npm test` | Unit tests (1,759 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) | @@ -168,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,521 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.json b/package.json index b86c197..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": { 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" + } } - } }