diff --git a/.github/attest-mirror/hub-llm-probe.js b/.github/attest-mirror/hub-llm-probe.js new file mode 100644 index 00000000..7890e40c --- /dev/null +++ b/.github/attest-mirror/hub-llm-probe.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +'use strict' + +// Copyright (c) 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. + +// One `--print` turn with the argv and credential env a venue hub's spawn +// transport uses, printing the CLI's verdict so a provider_error on the leg +// has a named cause. The hub logs only stderr, while the CLI reports auth and +// vendor failures as JSON on stdout. Never prints the credential. + +const { spawnSync } = require('child_process') +const os = require('os') + +const PROBE_MODEL = 'claude-sonnet-4-6' +const CREDENTIAL_KEYS = ['CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'] + +// The hub's buildCliArgs (xchain-hub src/providers/llm/claude_spawn.js) for a turn with no system prompt. +function probeArgs (model) { + return ['--print', '--output-format', 'json', '--model', model, '--tools', '', + '--no-session-persistence', '--setting-sources', ''] +} + +// The hub's childEnv over the hub_token resolution: ambient credentials out, the hub's pair in. +function probeEnv (env) { + const out = Object.assign({}, env) + for (const key of CREDENTIAL_KEYS) delete out[key] + out.CLAUDE_CODE_OAUTH_TOKEN = env.HUB_CLAUDE_CODE_OAUTH_TOKEN + out.CLAUDE_CONFIG_DIR = env.HUB_CLAUDE_CONFIG_DIR + return out +} + +function redact (text, secret) { + const s = String(text || '') + return secret ? s.split(secret).join('***') : s +} + +// The verdict line: exit code, the envelope's error fields and the first 300 characters of its text. +function describe (result, secret) { + let json = null + try { json = JSON.parse(result.stdout) } catch (_) { json = null } + const fields = json ? { + is_error: json.is_error, subtype: json.subtype, api_error_status: json.api_error_status, + result: redact(json.result, secret).slice(0, 300), + } : { stdout: redact(result.stdout, secret).slice(0, 300) } + return 'hub llm probe: exit ' + result.status + ' ' + JSON.stringify(fields) + + (result.stderr ? ' stderr: ' + redact(result.stderr, secret).trim().slice(0, 300) : '') +} + +function main (env) { + const secret = env.HUB_CLAUDE_CODE_OAUTH_TOKEN + if (!secret || !env.HUB_CLAUDE_CONFIG_DIR) { + console.log('hub llm probe: skipped, no hub credential on this leg') + return 0 + } + console.log('hub llm probe: credential length ' + secret.length + + ', oauth-token form ' + /^sk-ant-oat/.test(secret)) + const result = spawnSync(env.CLAUDE_BIN || 'claude', probeArgs(PROBE_MODEL), { + input: 'Reply with the single word: ok', env: probeEnv(env), cwd: os.tmpdir(), + encoding: 'utf8', timeout: 90000, + }) + if (result.error) console.log('hub llm probe: spawn failed: ' + result.error.message) + else console.log(describe(result, secret)) + return result.status === 0 ? 0 : 1 +} + +if (require.main === module) process.exitCode = main(process.env) + +module.exports = { probeArgs, probeEnv, describe, main } diff --git a/.github/workflows/attest-mirror-leg.yml b/.github/workflows/attest-mirror-leg.yml index d3fd72fa..e056520e 100644 --- a/.github/workflows/attest-mirror-leg.yml +++ b/.github/workflows/attest-mirror-leg.yml @@ -1,7 +1,7 @@ name: Attest-mirror leg (regtest) run-name: >- - Attest-mirror legs @ e2e ${{ inputs.e2e_ref || 'develop' }} ${{ inputs.legs || '(all)' }} + Attest-mirror legs @ e2e ${{ inputs.e2e_ref || 'develop' }} ${{ inputs.legs || '(all)' }}${{ inputs.hub_llm && ' +llm' || '' }} # Runs L004-ACC's attest-mirror legs (`BF_STAMP_AHEAD_S=900 npm run test:attest-mirror` # in xchain-e2e-test) on GitHub-hosted runners, ONE leg per job, all in parallel, @@ -36,6 +36,10 @@ on: description: 'Ref the service images and source roots are built at' type: string default: develop + hub_llm: + description: 'Give the venue hubs the HUB_CLAUDE_CODE_OAUTH_TOKEN secret so llm attestation cases run (regtest rail only)' + type: boolean + default: false permissions: contents: read @@ -199,11 +203,40 @@ jobs: echo "L004_LOG_DIR=$RUNNER_TEMP/am-logs" >> "$GITHUB_ENV" mkdir -p "$RUNNER_TEMP/am-logs" + # The llm attestation provider on the venue hubs, ONLY when the dispatch asks + # for it (hub_llm, off by default and off on the schedule). A validator on + # testnet or mainnet answers ATTEST with its own LLM setup, which the hub + # resolves through xchain-hub src/lib/hub_credentials.js: a HUB_CLAUDE_CONFIG_DIR + # the hub owns, and on this rail the session's HUB_CLAUDE_CODE_OAUTH_TOKEN + # paired with it. The venue refuses an llm case unless all three of its + # needs are on the box (attestMirrorVenue assertLlmAvailable): the directory, + # a credential the hub's resolver accepts, and `claude` on the PATH the hub + # children inherit, pinned to the hub image's CLI version (xchain-hub Dockerfile). + # The directory sits outside am-logs, so the uploaded artifact never holds + # what the CLI writes there. + - name: Install the hub llm CLI (hub_llm only) + if: inputs.hub_llm == true + run: | + npm install -g --no-audit --no-fund @anthropic-ai/claude-code@2.1.266 + command -v claude + mkdir -m 700 -p "$RUNNER_TEMP/hub-claude" + + # The token rides this step's env and nothing else: the image builds and the + # standing stacks above never see it, and the leg forwards it only to its + # venue hub children (at5 forwardedHubCredentialEnv). It is masked before any + # leg output and never printed. With the credential on, one probe turn with + # the hub's own argv names the CLI's verdict first, because the hub logs only + # stderr and the CLI reports an auth or vendor failure on stdout. - name: Run ${{ matrix.leg }} working-directory: ${{ env.RAIL_ROOT }}/xchain-e2e-test env: LEG: ${{ matrix.leg }} + HUB_CLAUDE_CONFIG_DIR: ${{ inputs.hub_llm == true && format('{0}/hub-claude', runner.temp) || '' }} + HUB_CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.hub_llm == true && secrets.HUB_CLAUDE_CODE_OAUTH_TOKEN || '' }} run: | + if [ -n "$HUB_CLAUDE_CODE_OAUTH_TOKEN" ]; then echo "::add-mask::$HUB_CLAUDE_CODE_OAUTH_TOKEN"; fi + node "$GITHUB_WORKSPACE/.github/attest-mirror/hub-llm-probe.js" | tee "$L004_LOG_DIR/hub-llm-probe.log" || + echo "::warning::hub llm probe failed; llm cases on this leg will answer provider_error" set +e env L004_VENUE_ROOT="$RAIL_ROOT" ATTEST_MIRROR_CONCURRENCY=1 ATTEST_MIRROR_READY_TIMEOUT_MS=1200000 \ ATTEST_MIRROR_STACK_ROOT="$AM_ROOT" ATTEST_MIRROR_STACK_PREFIX=xca7 \ diff --git a/.github/workflows/rail-leg.yml b/.github/workflows/rail-leg.yml index 100fff9d..1c9dd5c1 100644 --- a/.github/workflows/rail-leg.yml +++ b/.github/workflows/rail-leg.yml @@ -41,6 +41,30 @@ on: description: 'Per-leg bound passed to rail_leg_drive.js --limit-minutes' type: string default: '200' + XC_ANCHOR_FOLD_REGTEST_ACTIVATION: + description: 'Optional common regtest height for the anchor fold rehearsal' + type: string + required: false + XC_ANCHOR_STAKE_REGTEST_ACTIVATION: + description: 'Optional common regtest height for the anchor stake rehearsal' + type: string + required: false + XC_ANCHOR_SLASH_REGTEST_ACTIVATION: + description: 'Optional common regtest height for the anchor slash rehearsal' + type: string + required: false + XC_AMOUNTS_PRICE_REGTEST_ACTIVATION: + description: 'Optional regtest height for the R2 amounts price arm' + type: string + required: false + XC_AMOUNTS_PRICE_REGTEST_TIME: + description: 'Optional Unix instant for the R2 amounts price arm' + type: string + required: false + XC_CONTRACTS_REGTEST_ACTIVATION: + description: 'Optional regtest height for the R2 contracts arm' + type: string + required: false permissions: contents: read @@ -104,6 +128,36 @@ jobs: with: node-version: '22.22.3' + - name: Resolve the anchor arm height + id: anchor-arms + env: + XC_ANCHOR_FOLD_REGTEST_ACTIVATION: ${{ inputs.XC_ANCHOR_FOLD_REGTEST_ACTIVATION }} + XC_ANCHOR_STAKE_REGTEST_ACTIVATION: ${{ inputs.XC_ANCHOR_STAKE_REGTEST_ACTIVATION }} + XC_ANCHOR_SLASH_REGTEST_ACTIVATION: ${{ inputs.XC_ANCHOR_SLASH_REGTEST_ACTIVATION }} + run: | + node scripts/rail_leg_env.js --resolve-anchor-arms "$RUNNER_TEMP/anchor-arms.env" + cat "$RUNNER_TEMP/anchor-arms.env" >> "$GITHUB_ENV" + if [ -s "$RUNNER_TEMP/anchor-arms.env" ]; then + . "$RUNNER_TEMP/anchor-arms.env" + echo "height=$XC_ANCHOR_FOLD_REGTEST_ACTIVATION" >> "$GITHUB_OUTPUT" + fi + + - name: Resolve the R2 arm values + env: + XC_AMOUNTS_PRICE_REGTEST_ACTIVATION: ${{ inputs.XC_AMOUNTS_PRICE_REGTEST_ACTIVATION }} + XC_AMOUNTS_PRICE_REGTEST_TIME: ${{ inputs.XC_AMOUNTS_PRICE_REGTEST_TIME }} + XC_CONTRACTS_REGTEST_ACTIVATION: ${{ inputs.XC_CONTRACTS_REGTEST_ACTIVATION }} + run: | + node scripts/rail_leg_env.js --resolve-r2-arms "$RUNNER_TEMP/r2-arms.env" + cat "$RUNNER_TEMP/r2-arms.env" >> "$GITHUB_ENV" + + - name: Resolve the leg expect values + env: + LEG_EXPECT_JSON: ${{ toJSON(matrix.expect) }} + run: | + node scripts/rail_leg_env.js --resolve-leg-expect "$RUNNER_TEMP/leg-expect.env" + cat "$RUNNER_TEMP/leg-expect.env" >> "$GITHUB_ENV" + - name: Start headless MariaDB run: | XCHAIN_NODE_EXTERNAL_DB_ROOT_PASSWORD="$(openssl rand -hex 24)" @@ -136,6 +190,10 @@ jobs: # BTC_INDEXER_API_URL left every venue hub unable to read its capability set. - name: Initialize the validator identity run: | + anchor_arm_values="${XC_ANCHOR_FOLD_REGTEST_ACTIVATION:-}${XC_ANCHOR_STAKE_REGTEST_ACTIVATION:-}${XC_ANCHOR_SLASH_REGTEST_ACTIVATION:-}" + if [ -n "$anchor_arm_values" ]; then + export DOGE_ENCODER_URL=http://xchain-node-dogecoin-regtest-xchain-encoder:3003 + fi node src/index.js validator init \ --network regtest \ --oracle-epoch-start "$(date -u +%s)000" \ @@ -157,6 +215,14 @@ jobs: echo "XCHAIN_CONFIRMATIONS_BTC=1" echo "XCHAIN_CONFIRMATIONS_LTC=1" echo "XCHAIN_CONFIRMATIONS_DOGE=1" + if [ -n "$anchor_arm_values" ]; then + echo "DOGE_ENCODER_URL=$DOGE_ENCODER_URL" + echo "HUB_SIGNER_MODULE=/XChainHub/operator-signer/signer.js" + fi + for name in XC_ANCHOR_FOLD_REGTEST_ACTIVATION XC_ANCHOR_STAKE_REGTEST_ACTIVATION XC_ANCHOR_SLASH_REGTEST_ACTIVATION XC_AMOUNTS_PRICE_REGTEST_ACTIVATION XC_AMOUNTS_PRICE_REGTEST_TIME XC_CONTRACTS_REGTEST_ACTIVATION; do + value="${!name:-}" + if [ -n "$value" ]; then echo "$name=$value"; fi + done # Mirror admission publishes decoder-tip observations only after a round window. # Short regtest windows keep the armed rail leg paced at its block cadence. if [ "$XC_MIRROR_ADMISSION_ACTIVATION" = armed ]; then @@ -179,6 +245,10 @@ jobs: echo "INDEXER_PORT=$((base + 4))" echo "REGTEST_MINER_PORT=$((base + 5))" for line in "$@"; do echo "$line"; done + for name in XC_ANCHOR_FOLD_REGTEST_ACTIVATION XC_ANCHOR_STAKE_REGTEST_ACTIVATION XC_ANCHOR_SLASH_REGTEST_ACTIVATION XC_AMOUNTS_PRICE_REGTEST_ACTIVATION XC_AMOUNTS_PRICE_REGTEST_TIME XC_CONTRACTS_REGTEST_ACTIVATION; do + value="${!name:-}" + if [ -n "$value" ]; then echo "$name=$value"; fi + done } > "$file" } mkdir -p config @@ -203,6 +273,10 @@ jobs: mkdir -p "$XCHAIN_NODE_DATA_DIR" set -a; . "$RUNNER_TEMP/stack.env"; set +a node src/index.js install "$STACK_REF" all dogecoin regtest + # The standing hub pays for its anchors from the generated DOGE publisher, which + # nothing else funds; fund and confirm it before BTC checkpoints can call for an + # anchor. The script does nothing when no anchor arm input is set. + node scripts/rail_fund_publisher.js node src/index.js install "$STACK_REF" all bitcoin regtest case ",${STACKS:-btc,doge}," in *,ltc,*) node src/index.js install "$STACK_REF" all litecoin regtest ;; @@ -244,11 +318,106 @@ jobs: node -e 'require(process.argv[1] + "/xchain-indexer/node_modules/xchain-vm"); console.log("xchain-indexer loads xchain-vm")' "$ROOT" - name: Write the host .env for the drive + env: + DRIVE: ${{ matrix.drive }} + LEG: ${{ matrix.leg }} run: | - node scripts/rail_leg_env.js "$RAIL_ROOT/xchain-e2e-test/.env" bitcoin + coin=bitcoin + coin_helper="$RAIL_ROOT/xchain-e2e-test/test/helpers/rail_leg_coins.js" + if [ -f "$coin_helper" ]; then + coin="$(node "$coin_helper" "$DRIVE" "$LEG")" + fi + node scripts/rail_leg_env.js "$RAIL_ROOT/xchain-e2e-test/.env" "$coin" + if [ "$coin" != bitcoin ]; then + # Steps that still run as COIN=bitcoin on this leg (the federation seed) load + # .env.btc instead of the leg's coin .env (xchain-e2e-test test/helpers/rail/coin_env.js). + node scripts/rail_leg_env.js "$RAIL_ROOT/xchain-e2e-test/.env.btc" bitcoin + fi + for name in BTC_INDEXER_API_URL DOGE_INDEXER_API_URL DOGE_ENCODER_URL; do + grep -Eq "^${name}=http://127\\.0\\.0\\.1:[0-9]+$" "$RAIL_ROOT/xchain-e2e-test/.env" + done mkdir -p "$RAIL_ROOT/xchain-e2e-test/drill-keys" chmod 700 "$RAIL_ROOT/xchain-e2e-test/drill-keys" + - name: Clone forward DOGE follower + if: ${{ steps.anchor-arms.outputs.height != '' }} + env: + DOGE_SOURCE_CONTAINER: xchain-node-dogecoin-regtest-xchain-indexer + DOGE_FOLLOWER_CONTAINER: xchain-rail-doge-follower + DOGE_FOLLOWER_DB: XChain_DOGE_DrillB_Indexer + DOGE_FOLLOWER_NETWORK: xchain-node-dogecoin-regtest + run: | + set -euo pipefail + umask 077 + WORK="$RUNNER_TEMP/doge-follower" + ENV_FILE="$WORK/follower.env" + PLAN_FILE="$WORK/plan.json" + mkdir -p "$WORK" + export DOGE_FOLLOWER_ENV_FILE="$ENV_FILE" DOGE_FOLLOWER_PLAN_FILE="$PLAN_FILE" + docker inspect "$DOGE_SOURCE_CONTAINER" --format '{{json .Config.Env}}' > "$WORK/source-env.json" + docker inspect "$DOGE_SOURCE_CONTAINER" --format '{{.Config.Image}}' > "$WORK/source-image.txt" + node - <<'JS' + const fs = require('fs') + const { followerEnvLines, followerCreateArgs } = require('./scripts/rail_follower_clone') + const sourceEnv = JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP + '/doge-follower/source-env.json', 'utf8')) + const sourceDb = sourceEnv.find((line) => line.startsWith('INDEXER_DB_NAME='))?.slice('INDEXER_DB_NAME='.length) + const image = fs.readFileSync(process.env.RUNNER_TEMP + '/doge-follower/source-image.txt', 'utf8').trim() + const envLines = followerEnvLines(sourceEnv, process.env.DOGE_FOLLOWER_DB) + fs.writeFileSync(process.env.DOGE_FOLLOWER_ENV_FILE, envLines.join('\n') + '\n', { mode: 0o600 }) + fs.writeFileSync(process.env.DOGE_FOLLOWER_PLAN_FILE, JSON.stringify({ + sourceDb, + createArgs: followerCreateArgs({ + container: process.env.DOGE_FOLLOWER_CONTAINER, + network: process.env.DOGE_FOLLOWER_NETWORK, + envFile: process.env.DOGE_FOLLOWER_ENV_FILE, + image, + }), + }), { mode: 0o600 }) + JS + docker rm -f "$DOGE_FOLLOWER_CONTAINER" >/dev/null 2>&1 || true + docker stop "$DOGE_SOURCE_CONTAINER" >/dev/null + trap 'docker start "$DOGE_SOURCE_CONTAINER" >/dev/null || true' EXIT + node - <<'JS' + const fs = require('fs') + const mariadb = require('mariadb') + const { cloneStatements } = require('./scripts/rail_follower_clone') + ;(async () => { + const plan = JSON.parse(fs.readFileSync(process.env.DOGE_FOLLOWER_PLAN_FILE, 'utf8')) + const connectionOptions = { + host: process.env.XCHAIN_NODE_EXTERNAL_DB_HOST, + port: Number(process.env.XCHAIN_NODE_EXTERNAL_DB_PORT), + user: process.env.XCHAIN_NODE_EXTERNAL_DB_ROOT_USER, + } + connectionOptions['password'] = process.env.XCHAIN_NODE_EXTERNAL_DB_ROOT_PASSWORD + const connection = await mariadb.createConnection(connectionOptions) + try { + const rows = await connection.query( + 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = ? ORDER BY TABLE_NAME', + [plan.sourceDb, 'BASE TABLE'] + ) + const statements = cloneStatements(plan.sourceDb, process.env.DOGE_FOLLOWER_DB, + rows.map((row) => row.TABLE_NAME)) + for (const statement of statements) await connection.query(statement) + } finally { + await connection.end() + } + })().catch((error) => { console.error('DOGE follower clone failed: ' + error.message); process.exitCode = 1 }) + JS + docker start "$DOGE_SOURCE_CONTAINER" >/dev/null + trap - EXIT + node - <<'JS' + const fs = require('fs') + const { spawnSync } = require('child_process') + const plan = JSON.parse(fs.readFileSync(process.env.DOGE_FOLLOWER_PLAN_FILE, 'utf8')) + const created = spawnSync('docker', plan.createArgs, { stdio: 'inherit' }) + if (created.status !== 0) process.exit(created.status || 1) + JS + docker start "$DOGE_FOLLOWER_CONTAINER" >/dev/null + { + node -e 'const p=require(process.env.DOGE_FOLLOWER_PLAN_FILE); console.log("DOGE_FOLLOWER_SOURCE_DB="+p.sourceDb)' + echo "DOGE_FOLLOWER_DB=$DOGE_FOLLOWER_DB" + } >> "$GITHUB_ENV" + # The self-hosted rails were seeded once with the four-source ROLLCALL # federation (test/tools/rollcallSeedFederation.test.js), whose keys the # harness can sign for; the bridge drives reuse that seated set. A fresh @@ -337,6 +506,44 @@ jobs: test -s "$J/case-journal.jsonl" && node scripts/rail_journal_triage.js "$J/case-journal.jsonl" 2>/dev/null | tail -5 || true exit "$rc" + - name: Compare DOGE follower state hashes + if: ${{ success() && steps.anchor-arms.outputs.height != '' }} + env: + DOGE_FOLLOWER_ARM_HEIGHT: ${{ steps.anchor-arms.outputs.height }} + run: | + set -euo pipefail + H="$DOGE_FOLLOWER_ARM_HEIGHT" + OUT="$JOURNAL_DIR/follower" + A="$OUT/standing.json" + B="$OUT/forward.json" + mkdir -p "$OUT" + ready=0 + for attempt in $(seq 1 60); do + node scripts/rail_follower_rows.js \ + --host "$XCHAIN_NODE_EXTERNAL_DB_HOST" \ + --port "$XCHAIN_NODE_EXTERNAL_DB_PORT" \ + --user "$XCHAIN_NODE_EXTERNAL_DB_ROOT_USER" \ + --password-env XCHAIN_NODE_EXTERNAL_DB_ROOT_PASSWORD \ + --database "$DOGE_FOLLOWER_SOURCE_DB" --arm-height "$H" --out "$A" + node scripts/rail_follower_rows.js \ + --host "$XCHAIN_NODE_EXTERNAL_DB_HOST" \ + --port "$XCHAIN_NODE_EXTERNAL_DB_PORT" \ + --user "$XCHAIN_NODE_EXTERNAL_DB_ROOT_USER" \ + --password-env XCHAIN_NODE_EXTERNAL_DB_ROOT_PASSWORD \ + --database "$DOGE_FOLLOWER_DB" --arm-height "$H" --out "$B" + if node -e 'const fs=require("fs"); const h=Number(process.argv[1]); for (const f of process.argv.slice(2)) { const rows=JSON.parse(fs.readFileSync(f)); if (![h-1,h,h+5].every((n)=>rows.some((r)=>Number(r.block_index)===n))) process.exit(1) }' "$H" "$A" "$B"; then + ready=1 + break + fi + sleep 5 + done + if [ "$ready" -ne 1 ]; then + echo "DOGE followers did not both reach comparison height $((H + 5))" >&2 + exit 1 + fi + node scripts/rail_follower_compare.js --a "$A" --b "$B" --arm-height "$H" | tee "$OUT/compare.log" + node scripts/rail_follower_compare.js --a "$A" --b "$B" --arm-height "$H" --control | tee "$OUT/control.log" + - name: Capture stack diagnostics if: always() run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index a8108006..706e49b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.21.3] - 2026-10-03 + +### Added +- Published the hub peer port in validator mode. +- Added an optional litecoin rail stack and admission-armed list share rail legs. + +### Changed +- Validated the running indexer's migration status before service use. + +### Fixed +- Used regtest namespaces and the correct encoder URL in validator links. +- Started module containers under an init process and bounded health-check requests. + ## [0.21.2] - 2026-10-02 ### Fixed diff --git a/package-lock.json b/package-lock.json index daa011a2..2b2c396b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-node", - "version": "0.21.2", + "version": "0.21.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-node", - "version": "0.21.2", + "version": "0.21.3", "license": "AGPL-3.0-or-later", "dependencies": { "@dankest-llc/xchain-sdk": "^0.18.0", diff --git a/package.json b/package.json index 7ef64cb6..5819aa10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xchain-node", - "version": "0.21.2", + "version": "0.21.3", "description": "xchain-node allows users to install, configure and run XChain platform nodes.", "license": "AGPL-3.0-or-later", "repository": { diff --git a/scripts/rail_follower_clone.js b/scripts/rail_follower_clone.js new file mode 100644 index 00000000..540b7eb9 --- /dev/null +++ b/scripts/rail_follower_clone.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +'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 IDENTIFIER = /^[A-Za-z0-9_]+$/ +const OMITTED_ENV_KEYS = new Set(['HOME', 'HOSTNAME', 'PATH', 'INDEXER_DB_NAME']) + +function envKey (line) { + const separator = line.indexOf('=') + return separator === -1 ? line : line.slice(0, separator) +} + +function followerEnvLines (sourceEnv, followerDb) { + if (!Array.isArray(sourceEnv)) throw new Error('source environment must be an array') + const sourceDbLine = sourceEnv.find((line) => envKey(line) === 'INDEXER_DB_NAME') + if (sourceDbLine === undefined) throw new Error('source environment has no INDEXER_DB_NAME') + const sourceDb = sourceDbLine.slice(sourceDbLine.indexOf('=') + 1) + if (!followerDb) throw new Error('follower database is required') + if (!IDENTIFIER.test(followerDb)) throw new Error('invalid follower database identifier') + if (followerDb === sourceDb) throw new Error('follower database must differ from source database') + + return sourceEnv + .filter((line) => !OMITTED_ENV_KEYS.has(envKey(line))) + .concat('INDEXER_DB_NAME=' + followerDb) +} + +function followerCreateArgs (options = {}) { + const fields = ['container', 'network', 'envFile', 'image'] + for (const field of fields) { + if (typeof options[field] !== 'string' || options[field].length === 0) { + throw new Error(field + ' is required') + } + } + return [ + 'create', + '--name', options.container, + '--network', options.network, + '--env-file', options.envFile, + options.image, + ] +} + +function checkedIdentifier (value, label) { + if (typeof value !== 'string' || !IDENTIFIER.test(value)) { + throw new Error('invalid ' + label + ' identifier') + } + return '`' + value + '`' +} + +function cloneStatements (sourceDb, followerDb, tables) { + const source = checkedIdentifier(sourceDb, 'source database') + const follower = checkedIdentifier(followerDb, 'follower database') + if (sourceDb === followerDb) throw new Error('follower database must differ from source database') + if (!Array.isArray(tables)) throw new Error('tables must be an array') + + const statements = [ + 'DROP DATABASE IF EXISTS ' + follower + ';', + 'CREATE DATABASE ' + follower + ';', + ] + for (const table of tables) { + const name = checkedIdentifier(table, 'table') + statements.push('CREATE TABLE ' + follower + '.' + name + ' LIKE ' + source + '.' + name + ';') + statements.push('INSERT INTO ' + follower + '.' + name + ' SELECT * FROM ' + source + '.' + name + ';') + } + return statements +} + +module.exports = { + followerEnvLines, + followerCreateArgs, + cloneStatements, +} diff --git a/scripts/rail_follower_compare.js b/scripts/rail_follower_compare.js new file mode 100644 index 00000000..cade4948 --- /dev/null +++ b/scripts/rail_follower_compare.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const fs = require('fs') + +const HASH_FIELDS = ['ledger_hash', 'actions_hash', 'contract_hash', 'state_hash'] + +function followerHashSql () { + return 'SELECT b.block_index AS block_index, ' + + 'lh.hash AS ledger_hash, ah.hash AS actions_hash, ' + + 'ch.hash AS contract_hash, sh.hash AS state_hash ' + + 'FROM blocks b ' + + 'LEFT JOIN index_transactions lh ON (lh.id = b.ledger_hash_id) ' + + 'LEFT JOIN index_transactions ah ON (ah.id = b.actions_hash_id) ' + + 'LEFT JOIN index_transactions ch ON (ch.id = b.contract_hash_id) ' + + 'LEFT JOIN index_transactions sh ON (sh.id = b.state_hash_id) ' + + 'WHERE b.block_index IN (?, ?, ?) ' + + 'ORDER BY b.block_index ASC' +} + +function armCompareHeights (height) { + if (!Number.isInteger(height) || height < 1) { + throw new Error('arm height must be an integer at least 1') + } + return [height - 1, height, height + 5] +} + +function rowsByHeight (rows) { + const result = new Map() + for (const row of rows) { + const height = Number(row.block_index) + if (Number.isInteger(height)) result.set(height, row) + } + return result +} + +function lacksHash (row) { + return HASH_FIELDS.some((field) => row[field] === null || row[field] === undefined) +} + +function compareFollowerHashes (a, b, heights) { + const aByHeight = rowsByHeight(a) + const bByHeight = rowsByHeight(b) + const comparisons = heights.map((height) => { + const aRow = aByHeight.get(Number(height)) + const bRow = bByHeight.get(Number(height)) + if (!aRow || !bRow || lacksHash(aRow) || lacksHash(bRow)) { + return { height, verdict: 'MISSING', fields: [] } + } + const fields = HASH_FIELDS.filter((field) => aRow[field] !== bRow[field]) + return { + height, + verdict: fields.length === 0 ? 'MATCH' : 'MISMATCH', + fields, + } + }) + return { + ok: comparisons.every(({ verdict }) => verdict === 'MATCH'), + heights: comparisons, + } +} + +function injectDifference (rows, height) { + return rows.map((row) => { + const copy = { ...row } + if (Number(row.block_index) === Number(height)) { + copy.state_hash = String(row.state_hash) + '-control-difference' + } + return copy + }) +} + +function parseArgs (argv) { + const args = {} + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === '--control') { + if (args.control) throw new Error('duplicate --control flag') + args.control = true + } else if (arg === '--a' || arg === '--b' || arg === '--arm-height') { + if (args[arg]) throw new Error('duplicate ' + arg + ' flag') + const value = argv[++i] + if (value === undefined || value.startsWith('--')) throw new Error('missing value for ' + arg) + args[arg] = value + } else { + throw new Error('unknown argument: ' + arg) + } + } + if (!args['--a'] || !args['--b'] || !args['--arm-height']) { + throw new Error('usage: rail_follower_compare.js --a --b --arm-height [--control]') + } + return args +} + +function readRows (file) { + const rows = JSON.parse(fs.readFileSync(file, 'utf8')) + if (!Array.isArray(rows)) throw new Error(file + ' must contain a JSON array') + return rows +} + +function main (argv = process.argv.slice(2)) { + try { + const args = parseArgs(argv) + const armHeight = Number(args['--arm-height']) + const heights = armCompareHeights(armHeight) + const a = readRows(args['--a']) + let b = readRows(args['--b']) + if (args.control) b = injectDifference(b, armHeight) + const result = compareFollowerHashes(a, b, heights) + for (const comparison of result.heights) { + console.log('FOLLOWER ' + comparison.height + ' ' + comparison.verdict) + } + if (args.control) { + return result.heights.find(({ height }) => height === armHeight)?.verdict === 'MISMATCH' ? 0 : 1 + } + return result.ok ? 0 : 1 + } catch (error) { + console.error('rail_follower_compare: ' + error.message) + return 2 + } +} + +if (require.main === module) process.exitCode = main() + +module.exports = { + followerHashSql, + armCompareHeights, + compareFollowerHashes, + injectDifference, + main, +} diff --git a/scripts/rail_follower_rows.js b/scripts/rail_follower_rows.js new file mode 100644 index 00000000..69ccb47e --- /dev/null +++ b/scripts/rail_follower_rows.js @@ -0,0 +1,125 @@ +#!/usr/bin/env node +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const fs = require('fs') +const { followerHashSql, armCompareHeights } = require('./rail_follower_compare') + +const FLAGS = ['--host', '--port', '--user', '--password-env', '--database', '--arm-height', '--out'] +const HASH_FIELDS = ['ledger_hash', 'actions_hash', 'contract_hash', 'state_hash'] + +class UsageError extends Error {} + +async function readFollowerRows (query, armHeight) { + const heights = armCompareHeights(armHeight) + const rows = await query(followerHashSql(), heights) + return rows.map((row) => { + const result = { block_index: Number(row.block_index) } + for (const field of HASH_FIELDS) { + result[field] = row[field] === null || row[field] === undefined ? null : String(row[field]) + } + return result + }) +} + +function usage () { + return 'usage: rail_follower_rows.js --host --port --user ' + + '--password-env --database --arm-height --out ' +} + +function parseArgs (argv) { + const args = {} + for (let i = 0; i < argv.length; i += 1) { + const flag = argv[i] + if (!FLAGS.includes(flag)) throw new UsageError('unknown argument: ' + flag) + if (args[flag] !== undefined) throw new UsageError('duplicate ' + flag + ' flag') + const value = argv[++i] + if (value === undefined || value.startsWith('--')) throw new UsageError('missing value for ' + flag) + args[flag] = value + } + if (FLAGS.some((flag) => args[flag] === undefined)) throw new UsageError(usage()) + const port = Number(args['--port']) + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new UsageError('port must be an integer from 1 to 65535') + const armHeight = Number(args['--arm-height']) + try { + armCompareHeights(armHeight) + } catch (error) { + throw new UsageError(error.message) + } + return { ...args, port, armHeight } +} + +async function run (argv, env) { + let args + try { + args = parseArgs(argv) + } catch (error) { + if (!(error instanceof UsageError)) throw error + console.error('rail_follower_rows: ' + error.message) + return 2 + } + + const password = env[args['--password-env']] + if (password === undefined) { + console.error('rail_follower_rows: password environment variable is unset') + return 2 + } + + let connection + let rows + let failure + try { + const mariadb = require('mariadb') + connection = await mariadb.createConnection({ + host: args['--host'], + port: args.port, + user: args['--user'], + password, + database: args['--database'], + }) + rows = await readFollowerRows(connection.query.bind(connection), args.armHeight) + fs.writeFileSync(args['--out'], JSON.stringify(rows) + '\n', { mode: 0o600 }) + fs.chmodSync(args['--out'], 0o600) + } catch (error) { + failure = error + } finally { + if (connection) { + try { + await connection.end() + } catch (error) { + failure = failure || error + } + } + } + + if (failure) { + console.error('rail_follower_rows: database ' + args['--database'] + ' read failed') + return 1 + } + console.log('ROWS ' + args['--database'] + ' ' + rows.length) + return 0 +} + +async function main (argv = process.argv.slice(2), env = process.env) { + try { + return await run(argv, env) + } catch (error) { + console.error('rail_follower_rows: unexpected failure') + return 1 + } +} + +if (require.main === module) { + main().then((code) => { process.exitCode = code }) +} + +module.exports = { readFollowerRows, main } diff --git a/scripts/rail_fund_publisher.js b/scripts/rail_fund_publisher.js new file mode 100644 index 00000000..e48730f9 --- /dev/null +++ b/scripts/rail_fund_publisher.js @@ -0,0 +1,115 @@ +#!/usr/bin/env node +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const fs = require('fs') +const path = require('path') +const axios = require('axios') +const wallets = require('../src/services/validator_service/wallets') + +const ARM_ENV_NAMES = [ + 'XC_ANCHOR_FOLD_REGTEST_ACTIVATION', + 'XC_ANCHOR_STAKE_REGTEST_ACTIVATION', + 'XC_ANCHOR_SLASH_REGTEST_ACTIVATION', +] +const DEFAULT_AMOUNT = 100 +const DOGE_REGTEST_CONFIG = path.join(__dirname, '../config/dogecoin-regtest') + +function hasAnchorArm (env) { + return ARM_ENV_NAMES.some((name) => env[name] !== undefined && env[name] !== null && env[name] !== '') +} + +function publisherFundingPlan ({ env, wallet, minerPort, amount }) { + if (!hasAnchorArm(env)) return null + if (!wallet || wallet.network !== 'regtest') { + const network = wallet && wallet.network + throw new Error('wallet.network must be regtest, received ' + String(network)) + } + if (!wallet.dogeAddress) throw new Error('wallet.dogeAddress is required') + + return { + address: wallet.dogeAddress, + amount, + minerUrl: 'http://127.0.0.1:' + minerPort, + } +} + +function rpcError (response) { + const data = response && response.data + if (data && data.error) return data.error.message || String(data.error) + if (data && data.result && data.result.error) return String(data.result.error) + return null +} + +async function callMiner (plan, post, method, params, id) { + try { + const response = await post(plan.minerUrl, { + jsonrpc: '2.0', + id, + method, + params, + }) + const error = rpcError(response) + if (error) throw new Error(error) + return response + } catch (error) { + throw new Error(method + ' failed: ' + error.message) + } +} + +async function fundPublisher (plan, { post }) { + await callMiner(plan, post, 'send_funds', { + address: plan.address, + amount: plan.amount, + }, 1) + await callMiner(plan, post, 'generate_blocks', { count: 1 }, 2) +} + +function readMinerPort (readFileSync, configPath) { + const text = readFileSync(configPath, 'utf8') + const match = /^REGTEST_MINER_PORT=(.+)$/m.exec(text) + if (!match || !/^\d+$/.test(match[1]) || Number(match[1]) < 1) { + throw new Error('REGTEST_MINER_PORT is missing or invalid in ' + configPath) + } + return Number(match[1]) +} + +async function main (dependencies = {}) { + const env = dependencies.env || process.env + if (!hasAnchorArm(env)) return null + + const readWallets = dependencies.readWallets || wallets.readWallets + const publicWalletInfo = dependencies.publicWalletInfo || wallets.publicWalletInfo + const readFileSync = dependencies.readFileSync || fs.readFileSync + const post = dependencies.post || axios.post + const configPath = dependencies.configPath || DOGE_REGTEST_CONFIG + const amount = dependencies.amount === undefined ? DEFAULT_AMOUNT : dependencies.amount + const wallet = publicWalletInfo(readWallets()) + const minerPort = readMinerPort(readFileSync, configPath) + const plan = publisherFundingPlan({ env, wallet, minerPort, amount }) + await fundPublisher(plan, { post }) + return plan +} + +module.exports = { + ARM_ENV_NAMES, + publisherFundingPlan, + fundPublisher, + main, +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write(error.message + '\n') + process.exitCode = 1 + }) +} diff --git a/scripts/rail_leg_env.js b/scripts/rail_leg_env.js index adb919fb..2987d04d 100644 --- a/scripts/rail_leg_env.js +++ b/scripts/rail_leg_env.js @@ -14,6 +14,9 @@ // the loopback and every port to its published host port. // // Usage: node scripts/rail_leg_env.js [coin] +// node scripts/rail_leg_env.js --resolve-anchor-arms +// node scripts/rail_leg_env.js --resolve-r2-arms +// node scripts/rail_leg_env.js --resolve-leg-expect // Prints key names only; values never reach the log. const fs = require('fs') @@ -21,6 +24,86 @@ const path = require('path') const { execFileSync } = require('child_process') const CONFIG_DIR = path.resolve(__dirname, '..', 'config') +const ANCHOR_ARM_ENVS = Object.freeze([ + 'XC_ANCHOR_FOLD_REGTEST_ACTIVATION', + 'XC_ANCHOR_STAKE_REGTEST_ACTIVATION', + 'XC_ANCHOR_SLASH_REGTEST_ACTIVATION', +]) +const R2_ARM_ENVS = Object.freeze([ + 'XC_AMOUNTS_PRICE_REGTEST_ACTIVATION', + 'XC_AMOUNTS_PRICE_REGTEST_TIME', + 'XC_CONTRACTS_REGTEST_ACTIVATION', +]) +const LEG_EXPECT_ENVS = Object.freeze([ + 'XC_E2E_PRICE_FEE_BATCH_LANDED', + 'XC_VOTE_CALLBACK_BINDING_EXPECT', + 'XC_JSON_STRINGIFY_HOOK_EXPECT', +]) + +function anchorArmHeight (env = process.env) { + const configured = ANCHOR_ARM_ENVS + .map((name) => [name, env[name]]) + .filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '') + .map(([name, value]) => [name, String(value).trim()]) + if (!configured.length) return null + for (const [name, value] of configured) { + if (!/^\d+$/.test(value)) throw new Error(name + ' must be a non-negative integer') + } + for (const [name, value] of configured) { + if (BigInt(value) < 1n) throw new Error(name + ' must be an integer at least 1') + } + const heights = new Set(configured.map(([, value]) => BigInt(value).toString())) + if (heights.size !== 1) throw new Error('anchor arm activations must resolve to one height') + return heights.values().next().value +} + +function writeAnchorArmEnv (out) { + if (!out) throw new Error('usage: rail_leg_env.js --resolve-anchor-arms ') + const height = anchorArmHeight() + const lines = height === null ? [] : ANCHOR_ARM_ENVS.map((name) => name + '=' + height) + fs.writeFileSync(out, lines.join('\n') + (lines.length ? '\n' : ''), { mode: 0o600 }) + console.log('rail_leg_env: resolved anchor arm keys: ' + (height === null ? 'none' : ANCHOR_ARM_ENVS.join(' '))) +} + +function writeR2ArmEnv (out, env = process.env) { + if (!out) throw new Error('usage: rail_leg_env.js --resolve-r2-arms ') + const configured = R2_ARM_ENVS + .map((name) => [name, env[name]]) + .filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '') + .map(([name, value]) => [name, String(value).trim()]) + for (const [name, value] of configured) { + if (!/^\d+$/.test(value)) throw new Error(name + ' must be a non-negative integer') + } + const lines = configured.map(([name, value]) => name + '=' + value) + fs.writeFileSync(out, lines.join('\n') + (lines.length ? '\n' : ''), { mode: 0o600 }) + console.log('rail_leg_env: resolved R2 arm keys: ' + (lines.length ? configured.map(([name]) => name).join(' ') : 'none')) +} + +function writeLegExpectEnv (out, input = process.env.LEG_EXPECT_JSON) { + if (!out) throw new Error('usage: rail_leg_env.js --resolve-leg-expect ') + let values + try { + values = JSON.parse(input) + } catch (e) { + throw new Error('LEG_EXPECT_JSON must be a JSON object') + } + if (values === null) values = {} + if (typeof values !== 'object' || Array.isArray(values)) { + throw new Error('LEG_EXPECT_JSON must be a JSON object') + } + for (const name of Object.keys(values)) { + if (!LEG_EXPECT_ENVS.includes(name)) throw new Error('unknown leg expect key: ' + name) + const allowed = name === 'XC_E2E_PRICE_FEE_BATCH_LANDED' + ? ['armed', 'off'] + : ['armed', 'inert'] + if (!allowed.includes(values[name])) throw new Error(name + ' must be one of: ' + allowed.join(', ')) + } + const lines = LEG_EXPECT_ENVS + .filter((name) => Object.prototype.hasOwnProperty.call(values, name)) + .map((name) => name + '=' + values[name]) + fs.writeFileSync(out, lines.join('\n') + (lines.length ? '\n' : ''), { mode: 0o600 }) + console.log('rail_leg_env: resolved leg expect keys: ' + (lines.length ? lines.map((line) => line.split('=')[0]).join(' ') : 'none')) +} function containerEnv (name) { try { @@ -61,7 +144,24 @@ function first (...values) { return values.find((v) => v !== undefined && v !== null && v !== '') } +function hostServiceUrl (name, containerPort) { + const port = hostPort(name, containerPort) + return port ? 'http://127.0.0.1:' + port : null +} + function main () { + if (process.argv[2] === '--resolve-anchor-arms') { + writeAnchorArmEnv(process.argv[3]) + return + } + if (process.argv[2] === '--resolve-r2-arms') { + writeR2ArmEnv(process.argv[3]) + return + } + if (process.argv[2] === '--resolve-leg-expect') { + writeLegExpectEnv(process.argv[3]) + return + } const out = process.argv[2] const coin = process.argv[3] || 'bitcoin' if (!out) throw new Error('usage: rail_leg_env.js [coin]') @@ -96,6 +196,9 @@ function main () { EXPLORER_API_PORT: hostPort('xchain-node-xchain-explorer', 8080) || '18080', INDEXER_URL: 'localhost', INDEXER_API_PORT: hostPort(stack + 'xchain-indexer', 3004), + BTC_INDEXER_API_URL: hostServiceUrl('xchain-node-bitcoin-regtest-xchain-indexer', 3004), + DOGE_INDEXER_API_URL: hostServiceUrl('xchain-node-dogecoin-regtest-xchain-indexer', 3004), + DOGE_ENCODER_URL: hostServiceUrl('xchain-node-dogecoin-regtest-xchain-encoder', 3003), INDEXER_DB_NAME: indexer.INDEXER_DB_NAME, INDEXER_DB_USER: indexer.INDEXER_DB_USER, INDEXER_DB_PASS: indexer.INDEXER_DB_PASS, @@ -117,6 +220,9 @@ function main () { DECODER_DB_PASS: decoder.DECODER_DB_PASS, XCHAIN_NODE_CONFIG_DIR: CONFIG_DIR, } + for (const name of [...ANCHOR_ARM_ENVS, ...R2_ARM_ENVS]) { + if (process.env[name]) env[name] = process.env[name] + } const lines = [] const missing = [] for (const [k, v] of Object.entries(env)) { @@ -132,4 +238,6 @@ function main () { } } -main() +if (require.main === module) main() + +module.exports = { writeLegExpectEnv } diff --git a/src/cli.js b/src/cli.js index 812786ef..0900632a 100644 --- a/src/cli.js +++ b/src/cli.js @@ -53,6 +53,7 @@ const { restoreBootstrapInterface, startInterface } = require('./ui/menu') const { acquireCommandLock } = require('./utils/command_lock') const { noticeNewerRelease } = require('./services/self_update_service') const { runParseCommand } = require('./cli/parse_command') +const { dispatchSettings } = require('./cli/dispatch') const { installUnhandledRejectionHandler, installUncaughtExceptionHandler } = require('./cli/errors') const loadModule = require @@ -164,11 +165,10 @@ async function maybeSelfUpdateBeforeUpdate(args, deps = {}) { } // Serialized like every mutator, so two concurrent updates cannot both - // move the checkout. The lock is handed back right before the re-exec so - // the child, which takes its own lock in this same hook, is not refused - // by its parent. + // move the checkout. The lock is handed back right before the re-exec, and the + // child waits a bounded time for a mutator that takes it in between (lockSettingsFor). const lock = deps.acquireCommandLock || acquireCommandLock - const release = lock({ command: 'update (self-update)', waitMs: 0 }) + const release = lock({ command: 'update (self-update)', waitMs: dispatchSettings(config).MUTATING_LOCK_WAIT_MS }) let outcome try { outcome = await selfUpdate.selfUpdateAndReexec({ diff --git a/src/cli/dispatch.js b/src/cli/dispatch.js index 46571486..bff001ca 100644 --- a/src/cli/dispatch.js +++ b/src/cli/dispatch.js @@ -15,6 +15,10 @@ * Commander setup and command definitions ********************************************************************/ +// How long a release update re-taking the lock its self-update step handed back waits +// at least, so a mutator that slipped into that gap delays the update instead of refusing it. +const SELF_UPDATE_HANDOFF_WAIT_MS = 30000 + function dispatchSettings(config) { const commandsNeedingVersions = ['install', 'update', 'reinstall'] // Read-only commands only display state and never change which services @@ -104,6 +108,12 @@ function scopedCommandLock(command, deps) { // Hold the lock only while a validator stake/unstake sends, never through the indexer wait. const validatorSendLock = scopedCommandLock +// Floor a release update's wait at the handoff wait, since its self-update step just released the lock. +function lockSettingsFor(commandName, settings, config) { + if (commandName !== 'update' || !(config && config.XCHAIN_NODE_UPDATE_TARGET)) return settings + return { ...settings, MUTATING_LOCK_WAIT_MS: Math.max(settings.MUTATING_LOCK_WAIT_MS, SELF_UPDATE_HANDOFF_WAIT_MS) } +} + // preCheck provisions shared containers/DB/hub (buildDatabaseModule, // ensureXchainNodeAccess, scanAndRegisterModules, installHubModule) for // EVERY non-validator command, not just the mutating ones. Running that @@ -189,7 +199,7 @@ async function beforeAction(thisCommand, actionCommand, settings, deps) { } } - const lock = commandLock(commandName, settings, acquireCommandLock) + const lock = commandLock(commandName, lockSettingsFor(commandName, settings, deps.config), acquireCommandLock) if (!lock) return try { await preCheck( @@ -218,4 +228,4 @@ function installDispatch(program, deps) { beforeAction(thisCommand, actionCommand, settings, deps)) } -module.exports = { installDispatch, validatorSendLock, scopedCommandLock } +module.exports = { installDispatch, dispatchSettings, validatorSendLock, scopedCommandLock, SELF_UPDATE_HANDOFF_WAIT_MS } diff --git a/src/cli/output.js b/src/cli/output.js index 489724e9..74721dd0 100644 --- a/src/cli/output.js +++ b/src/cli/output.js @@ -123,7 +123,7 @@ function registerValidatorStake(validator, deps) { 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 under default governance settings)') + .option('--amount ', 'whole XCHAIN to stake (default 25000, clears every capability floor under default governance settings)') .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)') diff --git a/src/config/env_views.js b/src/config/env_views.js index 5099372e..26bbfdde 100644 --- a/src/config/env_views.js +++ b/src/config/env_views.js @@ -60,6 +60,7 @@ const LIST_VIEWS = { "XCHAIN_PRICE_MIN_BTC_VOLUME", "HUB_ALLOW_UNAUTHENTICATED", "XC_ROLLCALL_REGTEST_ACTIVATION", "XC_ROLLCALL_GATES_REGTEST_ACTIVATION", "XC_MIRROR_ADMISSION_ACTIVATION", "XC_ANCHOR_FOLD_REGTEST_ACTIVATION", + "XC_ANCHOR_STAKE_REGTEST_ACTIVATION", "XC_ANCHOR_SLASH_REGTEST_ACTIVATION", "XCHAIN_PRICE_INDEXER_DB_SECRET" ]], // The indexer's regtest genesis bootstrap (ConfigService genesisPassthroughVars). @@ -75,7 +76,8 @@ const LIST_VIEWS = { "DOGE_INDEXER_API_URL", "DOGE_INDEXER_API_KEY", "XC_ROLLCALL_REGTEST_ACTIVATION", "XC_ROLLCALL_GATES_REGTEST_ACTIVATION", "HUB_SYNC_ANCHOR_ATTEST_GRACE_S", "HUB_PRICE_SYNC_TIMEOUT_MS", "XCHAIN_COINPAY_EXPIRATION_S", - "XC_MIRROR_ADMISSION_ACTIVATION", "XC_ANCHOR_FOLD_REGTEST_ACTIVATION" + "XC_MIRROR_ADMISSION_ACTIVATION", "XC_ANCHOR_FOLD_REGTEST_ACTIVATION", + "XC_ANCHOR_STAKE_REGTEST_ACTIVATION", "XC_ANCHOR_SLASH_REGTEST_ACTIVATION" ]], // The hub-sync watermark graces a regtest indexer is handed (hubSyncRegtestGraceVars). HUB_SYNC_GRACE_ENV: ['hub sync grace', [ diff --git a/src/operations/module_operations/reset_modules.js b/src/operations/module_operations/reset_modules.js index bb0d3dc3..ef7d48b2 100644 --- a/src/operations/module_operations/reset_modules.js +++ b/src/operations/module_operations/reset_modules.js @@ -1,10 +1,10 @@ 'use strict' -let askMariadbRootPassword, confirmDestructiveReset, failureReason, isNoSuchContainerError, isNoSuchVolumeError, resolveNodeDataPath, restartResetModules, restartStoppedModules, Coin, CoinTickerSymbol, EXTERNAL_DB, HUB_MODULE_NAME, Network, NODE_MODULE_NAME, XChainService, clearHubPriceIngestWatermark, config, dataDir, db, execFileAsync, fs, getContainerBindMounts, getDatabaseContainerId, getDockerContainerImageName, getUtxoTrackerVolumeName, manualHubCrossChainPurgeStatements, nodeService, path, pingExternalDatabase, purgeHubCrossChainRows, readline, recordReindex, reindexAffectedModules, resetDatabases, restartContainer, sleep, startContainer, statusChanged, stopContainer +let askMariadbRootPassword, confirmDestructiveReset, failureReason, isNoSuchContainerError, isNoSuchVolumeError, resolveNodeDataPath, restartResetModules, restartStoppedModules, Coin, CoinTickerSymbol, EXTERNAL_DB, HUB_MODULE_NAME, Network, NODE_MODULE_NAME, XChainService, clearHubPriceIngestWatermark, config, dataDir, db, execFileAsync, fs, getContainerBindMounts, getDatabaseContainerId, getDockerContainerImageName, getUtxoTrackerVolumeName, manualHubCrossChainPurgeStatements, nodeService, path, pingExternalDatabase, purgeHubCrossChainRows, readline, recordReindex, reindexAffectedModules, resetDatabases, restartContainer, sleep, startContainer, statusChanged, stopContainer, stopModuleContainer, getContainerStopSettings let RESETTABLE_SERVICES function configure(dependencies) { - ({ askMariadbRootPassword, confirmDestructiveReset, failureReason, isNoSuchContainerError, isNoSuchVolumeError, resolveNodeDataPath, restartResetModules, restartStoppedModules, Coin, CoinTickerSymbol, EXTERNAL_DB, HUB_MODULE_NAME, Network, NODE_MODULE_NAME, XChainService, clearHubPriceIngestWatermark, config, dataDir, db, execFileAsync, fs, getContainerBindMounts, getDatabaseContainerId, getDockerContainerImageName, getUtxoTrackerVolumeName, manualHubCrossChainPurgeStatements, nodeService, path, pingExternalDatabase, purgeHubCrossChainRows, readline, recordReindex, reindexAffectedModules, resetDatabases, restartContainer, sleep, startContainer, statusChanged, stopContainer } = dependencies) + ({ askMariadbRootPassword, confirmDestructiveReset, failureReason, isNoSuchContainerError, isNoSuchVolumeError, resolveNodeDataPath, restartResetModules, restartStoppedModules, Coin, CoinTickerSymbol, EXTERNAL_DB, HUB_MODULE_NAME, Network, NODE_MODULE_NAME, XChainService, clearHubPriceIngestWatermark, config, dataDir, db, execFileAsync, fs, getContainerBindMounts, getDatabaseContainerId, getDockerContainerImageName, getUtxoTrackerVolumeName, manualHubCrossChainPurgeStatements, nodeService, path, pingExternalDatabase, purgeHubCrossChainRows, readline, recordReindex, reindexAffectedModules, resetDatabases, restartContainer, sleep, startContainer, statusChanged, stopContainer, stopModuleContainer, getContainerStopSettings } = dependencies) RESETTABLE_SERVICES = [ 'all', NODE_MODULE_NAME, @@ -86,11 +86,12 @@ async function resolveResetPaths(context) { // "Clearing node data" line was the only tell. "Not installed" stays a legitimate skip, and is stated out loud. let nodeDataPath = null if (resetNode) { - let nodeInstalled = null - let registryReadable = true + let nodeInstalled = null + let registryError = null try { - nodeInstalled = await db.getModuleContainer(NODE_MODULE_NAME, coin, network) - } catch { registryReadable = false } + // Strict, as in validateDecoderPair: the lenient read answers null on a registry failure, which reads as "not installed" below. + nodeInstalled = await db.getModuleContainerStrict(NODE_MODULE_NAME, coin, network) + } catch (err) { registryError = err } const resolved = await resolveNodeDataPath(coin, network) if (resolved.path) { @@ -99,13 +100,14 @@ async function resolveResetPaths(context) { console.log(`Node datadir resolved from ${resolved.resolvedFrom}: ${nodeDataPath}`) console.log(` (XCHAIN_NODE_DATA_DIR in this shell would have pointed at ${resolved.configuredPath})`) } - } else if (registryReadable && !nodeInstalled) { + } else if (!registryError && !nodeInstalled) { console.log(`No ${NODE_MODULE_NAME} container is installed for ${coin} ${network}; there is no node data to clear.`) } else { const envState = config.XCHAIN_NODE_DATA_DIR && config.XCHAIN_NODE_DATA_DIR.trim() !== '' ? `set to ${config.XCHAIN_NODE_DATA_DIR}` : 'UNSET in this shell (non-interactive shells do not source the profile)' console.log(`Aborted: cannot resolve the ${coin} ${network} node datadir. No data was touched.`) + if (registryError) console.log(` It could not read the ${NODE_MODULE_NAME} registry row (${failureReason(registryError)}), so it cannot tell whether a node is installed.`) console.log(` Container ${resolved.containerName} reported no /root/.${coin} bind mount ` + '(it is absent, or docker is unreachable from here).') console.log(` The configured path ${resolved.configuredPath} does not exist either.`) @@ -228,7 +230,8 @@ async function stopResetModules(context) { // A SUCCESSFUL read with no row is still a legitimate "not installed" skip; without this check stopContainer(null) fails and ABORTS the reset (uuid:fd7cc224 sibling site). if (!containerId) continue try { - await stopContainer(containerId) + // With each service's budget, so a decoder or tracker drain finishes; a rejection still reaches the catch below. + await stopModuleContainer(stopContainer, module, coin, network, containerId, undefined, getContainerStopSettings) stoppedModules.push(module) } catch (err) { if (isNoSuchContainerError(err)) continue diff --git a/src/release-manifest.json b/src/release-manifest.json index 5d3fc954..8ae75a6a 100644 --- a/src/release-manifest.json +++ b/src/release-manifest.json @@ -1,9 +1,9 @@ { "_comment": [ - "Pinned component set for XChain Platform v0.21.2.", + "Pinned component set for XChain Platform v0.21.3.", "Generated by bin/write-release-manifest.js from the ACTUAL tagged master merge commits.", "xchain-node is the carrier and is not listed: checking out its tag IS this manifest.", - "A train tags only the repos it touches. This one moves xchain-indexer, xchain-hub, xchain-sync, xchain-explorer and xchain-sdk.", + "A train tags only the repos it touches. This one moves xchain-vm, xchain-indexer, xchain-hub, xchain-sync, xchain-explorer and xchain-sdk.", "Every other component is unchanged 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.", @@ -12,28 +12,28 @@ "repo's origin/master, and is a GPG-signed annotated tag verifying against the", "platform release key (fingerprint 1DA7C4896F56EA22CF491EDF4361611A82F90B70)." ], - "platform_version": "0.21.2", - "released": "2026-10-02", + "platform_version": "0.21.3", + "released": "2026-10-03", "components": { "xchain-vm": { - "tag": "v0.21.0", - "commit": "d97442aaa61bf3709bf2288bf3cab1ac4c5d8d31" + "tag": "v0.21.3", + "commit": "be163a2215fb9885a70de149bfc7c973f2a728c6" }, "xchain-decoder": { "tag": "v0.21.0", "commit": "cc2c0802f1674e5aee397b0b9c4c4fce5dc2e551" }, "xchain-indexer": { - "tag": "v0.21.2", - "commit": "0f53ca1eecfd838d8bdf5fd7531ddd160fd05f18" + "tag": "v0.21.3", + "commit": "6e28b030ffa09601c8851d2b5dc0938230077083" }, "xchain-hub": { - "tag": "v0.21.1", - "commit": "d10d022baa6460020340a9eb3cbdcc305fb78e35" + "tag": "v0.21.3", + "commit": "06212b5923af4a18c793d5be217f489c4e739c9f" }, "xchain-sync": { - "tag": "v0.21.1", - "commit": "73329945d695aba7f3be51db8e61cf26ffbb83a4" + "tag": "v0.21.3", + "commit": "6015d390a473171035b0a49b5dff8bce78a96a7b" }, "xchain-encoder": { "tag": "v0.21.0", @@ -44,12 +44,12 @@ "commit": "8b2092686b6d5eb26117df5625a6899839d3caf4" }, "xchain-explorer": { - "tag": "v0.21.1", - "commit": "6c50f32388730e5328a75dd1b850553c4363659b" + "tag": "v0.21.3", + "commit": "db698605ed8420f8e61239abc422af825001a500" }, "xchain-sdk": { - "tag": "v0.21.1", - "commit": "32a0593e3945dcca495dada9b12756e0341a2a29" + "tag": "v0.21.3", + "commit": "da0e03ddf0546f029809657ca412c953878a14b3" }, "xchain-e2e-test": { "tag": "v0.21.0", diff --git a/src/services/bootstrap_service.js b/src/services/bootstrap_service.js index 791188d5..07048b70 100644 --- a/src/services/bootstrap_service.js +++ b/src/services/bootstrap_service.js @@ -66,6 +66,7 @@ const { } = require('./bootstrap_service/mariadb_archive') const { configureDependencies: configureRestoreArchive, + assertArchiveIdentity, restoreBootstrap } = require('./bootstrap_service/restore_archive') const { @@ -98,7 +99,7 @@ const workspace = { assertBootstrapCapacity, buildDateTimeString, ensureDir, ensureDirWritable, getWorkDir, startProgress } -const restoreArchive = { restoreBootstrap } +const restoreArchive = { restoreBootstrap, assertArchiveIdentity } const download = { downloadBootstrap } configureArchiveSigning({ fs, childProcess, config, logger }) diff --git a/src/services/bootstrap_service/ensure_restored.js b/src/services/bootstrap_service/ensure_restored.js index 139e5841..db6dc065 100644 --- a/src/services/bootstrap_service/ensure_restored.js +++ b/src/services/bootstrap_service/ensure_restored.js @@ -26,7 +26,7 @@ let { assessNodeTipForRestore } = require('../bootstrap_node_tip_guard') const { redactSecrets } = require('../../utils/helpers') const { BOOTSTRAP_SIG_SUFFIX } = require('./archive_signing') let { checkBootstrapSignature } = require('./archive_signing') -let { restoreBootstrap } = require('./restore_archive') +let { restoreBootstrap, assertArchiveIdentity } = require('./restore_archive') let { downloadBootstrap } = require('./download') const { getLogger } = require('../../observability/logger') let logger = getLogger() @@ -38,7 +38,7 @@ function configureDependencies(dependencies) { ;({ getDefaultConfig } = dependencies.configService) ;({ assessNodeTipForRestore } = dependencies.nodeTipGuard) ;({ checkBootstrapSignature } = dependencies.archiveSigning) - ;({ restoreBootstrap } = dependencies.restoreArchive) + ;({ restoreBootstrap, assertArchiveIdentity } = dependencies.restoreArchive) ;({ downloadBootstrap } = dependencies.download) logger = dependencies.logger } @@ -116,11 +116,12 @@ async function assessNodeTipBeforeRestore(coin, network, module, archivePath) { return assessNodeTipForRestore({ coin, network, module, archivePath }) } -// Verify the signature before a refusal retires the archive: the refusal rests on the unverified bootstrap.json height. -// A tampered archive then throws into the caller's catch, which records the integrity failure and KEEPS the archive, -// instead of being reported as node lag and deleted. Costs one hash pass, on the refuse branch only. -async function verifyRefusedArchive(archivePath) { +// Verify the signature, then the identity it authenticates, before a refusal retires the archive: the refusal rests on +// bootstrap.json. A tampered or mis-identified archive throws into the caller's catch, which records the failure and +// KEEPS the archive, instead of being reported as node lag and deleted. Costs one hash pass, on the refuse branch only. +async function verifyRefusedArchive(archivePath, target) { await checkBootstrapSignature(archivePath) + await assertArchiveIdentity(archivePath, target) } // A restore that went ahead with the node still below the archive is reported @@ -224,7 +225,7 @@ async function ensureBootstrapUtxoTracker(coin, network) { // Refused before any restore was attempted, and the archive is // signature-verified first, so retiring it destroys no evidence; // see retireBootstrapArchive for why it goes anyway. - await verifyRefusedArchive(archivePath) + await verifyRefusedArchive(archivePath, { module: XChainService.XCHAIN_UTXO_TRACKER, coin, network }) const bytes = await statBootstrapArchiveBytes(archivePath) const archive = retireBootstrapArchive(archivePath, bytes, 'restore refused, node behind; a re-run downloads a fresh copy anyway') recordBootstrapOutcome(XChainService.XCHAIN_UTXO_TRACKER, 'node-behind', tip.detail, archive) @@ -289,7 +290,7 @@ async function ensureBootstrapMariaDb(coin, network, module) { // Refused before any restore was attempted, and the archive is // signature-verified first, so retiring it destroys no evidence; // see retireBootstrapArchive for why it goes anyway. - await verifyRefusedArchive(archivePath) + await verifyRefusedArchive(archivePath, { module, coin, network }) const bytes = await statBootstrapArchiveBytes(archivePath) const archive = retireBootstrapArchive(archivePath, bytes, 'restore refused, node behind; a re-run downloads a fresh copy anyway') recordBootstrapOutcome(module, 'node-behind', tip.detail, archive) diff --git a/src/services/bootstrap_service/restore_archive.js b/src/services/bootstrap_service/restore_archive.js index 40ecacf8..3633928e 100644 --- a/src/services/bootstrap_service/restore_archive.js +++ b/src/services/bootstrap_service/restore_archive.js @@ -27,7 +27,8 @@ let execFileAsync = promisify(execFile) let { XChainService, EXTERNAL_DB } = require('../../config') let { db } = require('../../state') let { getDefaultConfig, getModuleDatabaseName, getUtxoTrackerVolumeName } = require('../config_service') -let { stopContainer, startContainer } = require('../docker_service') +let { stopContainer, startContainer, getContainerStopSettings } = require('../docker_service') +const { stopModuleContainer } = require('../stop_budget_service') let { getDatabaseContainerId, ensureDatabasePool, getExternalDbConfig, executeNativeMariaDbCommand } = require('../database_service') let databaseService = require('../database_service') const { dockerMariadbArgs, mariadbEnv } = require('../../utils/docker_mariadb') @@ -46,7 +47,7 @@ function configureDependencies(dependencies) { ;({ XChainService, EXTERNAL_DB } = dependencies.config) ;({ db } = dependencies.state) ;({ getDefaultConfig, getModuleDatabaseName, getUtxoTrackerVolumeName } = dependencies.configService) - ;({ stopContainer, startContainer } = dependencies.dockerService) + ;({ stopContainer, startContainer, getContainerStopSettings } = dependencies.dockerService) databaseService = dependencies.databaseService ;({ getDatabaseContainerId, ensureDatabasePool, getExternalDbConfig, executeNativeMariaDbCommand } = databaseService) ;({ checkBootstrapSignature, ensureVerifiedInnerArchive } = dependencies.archiveSigning) @@ -90,7 +91,8 @@ async function restoreBootstrapUtxoTracker(coin, network, fileName) { const context = await prepareTrackerRestore(coin, network, fileName) logger.info(`Stopping ${XChainService.XCHAIN_UTXO_TRACKER} container...`) - await stopContainer(context.containerId) + // Stop with the service's budget, not docker's ten-second default; the volume is replaced next, so an unclean stop is reported, not refused. + await stopModuleContainer(stopContainer, XChainService.XCHAIN_UTXO_TRACKER, coin, network, context.containerId, undefined, getContainerStopSettings) await restoreTrackerVolume(context) @@ -250,7 +252,7 @@ async function prepareMariaRestore(coin, network, module, fileName) { } catch { /* service not installed yet, proceed without stopping */ } if (serviceContainerId) { logger.info(`Stopping ${module} container...`) - await stopContainer(serviceContainerId) + await stopModuleContainer(stopContainer, module, coin, network, serviceContainerId, undefined, getContainerStopSettings) } return { dbContainerId, dbName, externalCfg, innerArchive, module, rootPassword, serviceContainerId, workDir } @@ -312,6 +314,7 @@ async function restoreMariaDatabase({ dbContainerId, dbName, externalCfg, innerA } module.exports = { + assertArchiveIdentity, configureDependencies, restoreBootstrap } diff --git a/src/services/bootstrap_service/tracker_archive.js b/src/services/bootstrap_service/tracker_archive.js index 92e68295..ab5939cc 100644 --- a/src/services/bootstrap_service/tracker_archive.js +++ b/src/services/bootstrap_service/tracker_archive.js @@ -28,7 +28,8 @@ let execFileAsync = promisify(execFile) let { XChainService, SEP } = require('../../config') let { db } = require('../../state') let { getDefaultConfig, getUtxoTrackerVolumeName } = require('../config_service') -let { stopContainer, startContainer } = require('../docker_service') +let { stopContainer, startContainer, getContainerStopSettings } = require('../docker_service') +const { stopModuleContainer, stoppedUnclean } = require('../stop_budget_service') let { assertBootstrapSourceHealthy } = require('../bootstrap_health_gate') let bootstrapHealthGate = require('../bootstrap_health_gate') let { buildBootstrapMeta, writeBootstrapMeta } = require('../bootstrap_archive_meta') @@ -49,7 +50,7 @@ function configureDependencies(dependencies) { ;({ XChainService, SEP } = dependencies.config) ;({ db } = dependencies.state) ;({ getDefaultConfig, getUtxoTrackerVolumeName } = dependencies.configService) - ;({ stopContainer, startContainer } = dependencies.dockerService) + ;({ stopContainer, startContainer, getContainerStopSettings } = dependencies.dockerService) bootstrapHealthGate = dependencies.bootstrapHealthGate ;({ assertBootstrapSourceHealthy } = bootstrapHealthGate) ;({ buildBootstrapMeta, writeBootstrapMeta } = dependencies.archiveMeta) @@ -180,10 +181,13 @@ async function makeBootstrapUtxoTracker(coin, network) { const endMaintenanceWindow = await beginTrackerMaintenance(coin, network) logger.info(`Stopping ${XChainService.XCHAIN_UTXO_TRACKER} container...`) - await stopContainer(context.containerId) + const stop = await stopModuleContainer(stopContainer, XChainService.XCHAIN_UTXO_TRACKER, coin, network, context.containerId, undefined, getContainerStopSettings) context.containerRestored = false try { + // A killed or overrun drain never closed the store in order, so it is never archived or signed; the finally restarts the tracker. + if (stop.killed || stoppedUnclean(stop)) throw new Error(`utxo-tracker ${coin}/${network} did not shut down cleanly ` + + `(exit code ${stop.exitCode}, ${stop.budget} s budget); no archive was built or signed`) const snapshotTaken = await takeTrackerSnapshot(context, endMaintenanceWindow) await compressTrackerArchive(context, snapshotTaken) // Fallback path: the store is archived, so bring the tracker back before the wrap and sign, and read its height there. diff --git a/src/services/config_service/networks.js b/src/services/config_service/networks.js index 34d562db..5899e8c7 100644 --- a/src/services/config_service/networks.js +++ b/src/services/config_service/networks.js @@ -244,7 +244,8 @@ async function hasBitcoinIndexer(network) { // (D84 precedent): it arms the admission-map mirror and its consumer and // barrier gates together, so a venue arms as a unit; the hub's own // mirror-admission gate module gates it for real. -// XC_ANCHOR_FOLD_REGTEST_ACTIVATION rides beside it: the hub and the regtest indexer arm the fold together. +// XC_ANCHOR_FOLD_REGTEST_ACTIVATION rides beside it: the hub and the regtest indexer arm the fold together, +// and the stake and slash arms ride the same way. // Secret-bearing names in this list (XCHAIN_PRICE_INDEXER_DB_PASS) are also // accepted from the host env under their redaction-safe `*_SECRET` spelling; // everything else resolves to a plain process.env read. @@ -291,7 +292,9 @@ function configureHubBeforeKey(defaultValues, module) { "XC_ROLLCALL_REGTEST_ACTIVATION", "XC_ROLLCALL_GATES_REGTEST_ACTIVATION", "XC_MIRROR_ADMISSION_ACTIVATION", - "XC_ANCHOR_FOLD_REGTEST_ACTIVATION" + "XC_ANCHOR_FOLD_REGTEST_ACTIVATION", + "XC_ANCHOR_STAKE_REGTEST_ACTIVATION", + "XC_ANCHOR_SLASH_REGTEST_ACTIVATION" ] const hubAdmissionTimingVars = new Set([ "ATTESTATION_ROUND_TIMEOUT_MS", "ADMISSION_WATERMARK_SAMPLE_MS" diff --git a/src/services/config_service/services.js b/src/services/config_service/services.js index 5a9558b4..13331821 100644 --- a/src/services/config_service/services.js +++ b/src/services/config_service/services.js @@ -201,6 +201,16 @@ function configureIndexerBeforeHubKey(defaultValues, module, network) { defaultValues[varName] = config.INDEXER_GENESIS_ENV[varName] } } + const originIndexerPassthroughVars = [ + "BTC_INDEXER_URL", "LTC_INDEXER_URL", "DOGE_INDEXER_URL", + "BTC_INDEXER_API_KEY", "LTC_INDEXER_API_KEY", "DOGE_INDEXER_API_KEY" + ] + for (const varName of originIndexerPassthroughVars) { + const value = readSecretHostEnv(varName) + if (value !== undefined && value !== "") { + defaultValues[varName] = value + } + } const rollcallPassthroughVars = ["DOGE_INDEXER_API_URL", "DOGE_INDEXER_API_KEY"] if (network === Network.REGTEST) rollcallPassthroughVars.push("XC_ROLLCALL_REGTEST_ACTIVATION", "XC_ROLLCALL_GATES_REGTEST_ACTIVATION", @@ -208,7 +218,9 @@ function configureIndexerBeforeHubKey(defaultValues, module, network) { "HUB_PRICE_SYNC_TIMEOUT_MS", "XCHAIN_COINPAY_EXPIRATION_S", "XC_MIRROR_ADMISSION_ACTIVATION", - "XC_ANCHOR_FOLD_REGTEST_ACTIVATION") + "XC_ANCHOR_FOLD_REGTEST_ACTIVATION", + "XC_ANCHOR_STAKE_REGTEST_ACTIVATION", + "XC_ANCHOR_SLASH_REGTEST_ACTIVATION") for (const varName of rollcallPassthroughVars) { if (config.INDEXER_ROLLCALL_ENV[varName] !== undefined && config.INDEXER_ROLLCALL_ENV[varName] !== "") { defaultValues[varName] = config.INDEXER_ROLLCALL_ENV[varName] diff --git a/src/services/docker_service.js b/src/services/docker_service.js index 12f7bd02..8ad76b9a 100644 --- a/src/services/docker_service.js +++ b/src/services/docker_service.js @@ -80,9 +80,15 @@ async function removeContainer(containerId) { }) } -async function stopContainer(containerId) { - return new Promise((resolve, reject) => { - execFile('docker', ['stop', containerId], (error, stdout) => { +// Stop by id, rejecting with docker's own error so callers can tell a missing +// container from a failed stop. Without a budget it resolves true. With one it +// passes -t and resolves { stopped, seconds, killed, exitCode }, the outcome +// stopModuleContainer reports and a caller about to read the data can refuse on. +async function stopContainer(containerId, timeoutSeconds) { + const startedAt = Date.now() + const args = timeoutSeconds === undefined ? ['stop', containerId] : ['stop', '-t', String(timeoutSeconds), containerId] + await new Promise((resolve, reject) => { + execFile('docker', args, (error, stdout) => { if (error) { reject(error) } else if (stdout.trim() === containerId) { @@ -92,6 +98,21 @@ async function stopContainer(containerId) { } }) }) + if (timeoutSeconds === undefined) return true + return readStopOutcome(containerId, startedAt, timeoutSeconds) +} + +// How a stop that docker reported ended, read from the container's exit code. +async function readStopOutcome(ref, startedAt, timeoutSeconds) { + const seconds = Math.round((Date.now() - startedAt) / 1000) + const exitCode = await new Promise((resolve) => { + execFile('docker', ['inspect', '--format', '{{.State.ExitCode}}', ref], (error, stdout) => { + const code = parseInt(String(stdout || '').trim(), 10) + resolve(error || !Number.isFinite(code) ? null : code) + }) + }) + const killed = exitCode === 137 || (exitCode === null && seconds >= timeoutSeconds) + return { stopped: true, seconds, killed, exitCode } } // Graceful stop by NAME with an explicit shutdown budget, for stateful @@ -116,16 +137,8 @@ async function stopContainerByName(name, timeoutSeconds) { resolve(!error && stdout.trim() === name) }) }) - const seconds = Math.round((Date.now() - startedAt) / 1000) - if (!stopped) return { stopped: false, seconds, killed: false, exitCode: null } - const exitCode = await new Promise((resolve) => { - execFile('docker', ['inspect', '--format', '{{.State.ExitCode}}', name], (error, stdout) => { - const code = parseInt(String(stdout || '').trim(), 10) - resolve(error || !Number.isFinite(code) ? null : code) - }) - }) - const killed = exitCode === 137 || (exitCode === null && seconds >= timeoutSeconds) - return { stopped: true, seconds, killed, exitCode } + if (!stopped) return { stopped: false, seconds: Math.round((Date.now() - startedAt) / 1000), killed: false, exitCode: null } + return readStopOutcome(name, startedAt, timeoutSeconds) } async function startContainer(containerId) { diff --git a/src/services/validator_stake_service/stake_operations.js b/src/services/validator_stake_service/stake_operations.js index 2a789f51..2e402d54 100644 --- a/src/services/validator_stake_service/stake_operations.js +++ b/src/services/validator_stake_service/stake_operations.js @@ -241,6 +241,19 @@ async function finishStake(context, progress, stakeInputs) { return { staked: true, sent: progress.sent, txid: r.txid, chained: progress.chain && !progress.chainBroken } } +// Resolve --amount: the default only when it is absent, else whole XCHAIN above 0, refused rather than coerced. +// Whole units only: the mint split is float arithmetic, so a fractional shortfall would send malformed MINT amounts. +function resolveStakeAmount(raw, DEFAULT_STAKE_AMOUNT, fail, STAKE_TICK) { + if (raw === undefined || raw === null) return DEFAULT_STAKE_AMOUNT + const text = String(raw).trim() + const amount = Number(text) + if (!/^[0-9]+$/.test(text) || !Number.isSafeInteger(amount) || amount <= 0) { + throw fail('--amount must be a whole number of ' + STAKE_TICK + ' above 0, such as 25000 (got "' + raw + + '"). Nothing was sent.') + } + return amount +} + // Say that a dry run sent nothing and how --broadcast would send the steps. function logDryRun(log, stepCount) { log('') @@ -253,7 +266,7 @@ function logDryRun(log, stepCount) { function createStakeValidator(helpers) { const { openValidatorSession, stakeTiming, readChainState, planMints, explorerUrl, - chainedInputs, waitForBalance, paren, STAKE_TICK, DEFAULT_STAKE_AMOUNT } = helpers + chainedInputs, waitForBalance, fail, paren, STAKE_TICK, DEFAULT_STAKE_AMOUNT } = helpers /** * Run the stake command. `deps` lets tests inject an SDK factory and a @@ -271,8 +284,9 @@ function createStakeValidator(helpers) { async function planAndStake(opts, deps, sendLock) { const log = deps.log || defaultLog() + // Before the session opens, so a bad amount refuses ahead of any WIF prompt or chain read. + const amount = resolveStakeAmount(opts.amount, DEFAULT_STAKE_AMOUNT, fail, STAKE_TICK) const { network, coins, pubkey, sdk, session, address } = openValidatorSession(opts, deps) - const amount = parseInt(opts.amount) || DEFAULT_STAKE_AMOUNT const timing = stakeTiming(coins, network) // Take the lock before reading the plan, so no other broadcast sends between this read and these sends. sendLock.hold() diff --git a/src/services/validator_stake_service/unstake_operations.js b/src/services/validator_stake_service/unstake_operations.js index c5b4e758..c42bf4b2 100644 --- a/src/services/validator_stake_service/unstake_operations.js +++ b/src/services/validator_stake_service/unstake_operations.js @@ -126,10 +126,21 @@ function assertOwnsStake(live, address, fail) { } } +// Refuse a partial exit: an UNSTAKE now withdraws only the active rows, and the indexer leaves these pending ones staked. +function pendingTopUpRefusal(pending, tip, STAKE_TICK) { + const rows = pending.map(r => 'action ' + r.action_index + ' (' + r.amount + ' ' + STAKE_TICK + + ', activates at block ' + r.activation_block + ')').join(', ') + const from = Math.max(...pending.map(r => Number(r.activation_block))) + return 'part of this stake is not active yet: ' + rows + '. An UNSTAKE now would withdraw only the active ' + + 'part, and the indexer leaves those rows staked, so this pubkey would stay in the active set once they ' + + 'activate. An UNSTAKE covering the whole stake can land from block ' + from + ' (the explorer is at block ' + + tip + '). Re-run then. Nothing was sent.' +} + /** * Decide what an UNSTAKE would withdraw, before anything is sent. Returns * { active } when the indexer would admit one, or { done } with the result to - * return when there is nothing to withdraw; throws when it would reject. + * return when there is nothing to withdraw; throws when it would reject or not be a full exit. */ async function resolveUnstakeTarget({ sdk, coins, pubkey, address, timing, log, fail, paren, STAKE_TICK }) { // Read the set rather than a per-pubkey lookup (see readChainState): the @@ -177,6 +188,8 @@ async function resolveUnstakeTarget({ sdk, coins, pubkey, address, timing, log, (Number.isFinite(from) ? from : '(unknown)') + ' (the explorer is at block ' + tip + '). Re-run then. Nothing was sent.') } + // The indexer's UNSTAKE leaves a pending top-up undeactivated, so it would activate later and keep this key in N. + if (pending.length) throw fail(pendingTopUpRefusal(pending, tip, STAKE_TICK)) return { active: withdrawnStake(admissible) } } diff --git a/test/unit/bootstrap_service.test/helpers/support.js b/test/unit/bootstrap_service.test/helpers/support.js index 2ab23b61..74ba3348 100644 --- a/test/unit/bootstrap_service.test/helpers/support.js +++ b/test/unit/bootstrap_service.test/helpers/support.js @@ -138,7 +138,9 @@ function makeServiceStubs() { const dockerServiceStub = { stopContainer: sinon.stub().resolves(), - startContainer: sinon.stub().resolves() + startContainer: sinon.stub().resolves(), + // No stamp read: the stop budget falls to the service default (120 s for the tracker). + getContainerStopSettings: sinon.stub().resolves(null) } const databaseServiceStub = { @@ -322,7 +324,8 @@ function makeBootstrapOverrides(stubs, execFileCb) { }, './docker_service': { stopContainer: stubs.dockerService.stopContainer, - startContainer: stubs.dockerService.startContainer + startContainer: stubs.dockerService.startContainer, + getContainerStopSettings: stubs.dockerService.getContainerStopSettings }, './database_service': { getDatabaseContainerId: stubs.databaseService.getDatabaseContainerId, diff --git a/test/unit/bootstrap_service.test/restore_bootstrap_maria_db_happy_path.test.js b/test/unit/bootstrap_service.test/restore_bootstrap_maria_db_happy_path.test.js index 321cce93..a84defe1 100644 --- a/test/unit/bootstrap_service.test/restore_bootstrap_maria_db_happy_path.test.js +++ b/test/unit/bootstrap_service.test/restore_bootstrap_maria_db_happy_path.test.js @@ -58,6 +58,8 @@ describe('BootstrapService', function () { const result = await bs.restoreBootstrap(COIN, NETWORK, XChainService.XCHAIN_DECODER, 'dump.sql.gz') expect(result).to.be.true expect(stubs.dockerService.stopContainer.calledWith('svc-container-id')).to.be.true + // The decoder drain gets its 120 s service budget, not docker's ten-second default. + expect(stubs.dockerService.stopContainer.firstCall.args).to.deep.equal(['svc-container-id', 120]) expect(stubs.dockerService.startContainer.calledWith('svc-container-id')).to.be.true // The restore client must receive the root password via MYSQL_PWD env, never argv diff --git a/test/unit/bootstrap_service.test/restore_bootstrap_utxo_tracker_fresh_extract.test.js b/test/unit/bootstrap_service.test/restore_bootstrap_utxo_tracker_fresh_extract.test.js index 08bcebd7..e2545546 100644 --- a/test/unit/bootstrap_service.test/restore_bootstrap_utxo_tracker_fresh_extract.test.js +++ b/test/unit/bootstrap_service.test/restore_bootstrap_utxo_tracker_fresh_extract.test.js @@ -58,6 +58,7 @@ describe('BootstrapService', function () { const result = await bs.restoreBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER, 'data.tar.gz') expect(result).to.be.true expect(stubs.dockerService.stopContainer.called).to.be.true + expect(stubs.dockerService.stopContainer.firstCall.args[1], 'the tracker stops with its 120 s budget').to.equal(120) expect(stubs.dockerService.startContainer.called).to.be.true }) }) diff --git a/test/unit/bootstrap_service.test/restore_checks_archive_identity_and_refusal_signature.test.js b/test/unit/bootstrap_service.test/restore_checks_archive_identity_and_refusal_signature.test.js index 6fd2d355..317002d5 100644 --- a/test/unit/bootstrap_service.test/restore_checks_archive_identity_and_refusal_signature.test.js +++ b/test/unit/bootstrap_service.test/restore_checks_archive_identity_and_refusal_signature.test.js @@ -208,3 +208,53 @@ describe('BootstrapService', function () { }) }) }) + +describe('BootstrapService', function () { + beforeEach(allowUnsignedBootstrap) + afterEach(restoreRequireSignedBootstrapSetting) + describe('the node tip guard refusal checks the archive identity before the archive is retired', function () { + + // Refuse on the node tip, then run the ensure path and return its result and the end-of-install summary. + async function refusedRun(meta, ensure) { + const stubs = makeStubs() + const finishDownload = stubDownloadedArchive(stubs) + stubs.nodeTipGuard.assessNodeTipForRestore.resolves({ verdict: 'behind-refuse', refuse: true, detail: 'node behind' }) + stubs.archiveMeta.readBootstrapArchiveMeta.resolves(meta) + const bs = loadBootstrapService(stubs) + bs.resetBootstrapOutcomes() + const promise = ensure(bs) + setImmediate(finishDownload) + const result = await promise + const lines = [] + const log = sinon.stub(console, 'log').callsFake((...a) => lines.push(a.join(' '))) + try { bs.reportBootstrapOutcomes() } finally { log.restore() } + return { stubs, result, summary: lines.join('\n') } + } + + it('keeps a refused tracker archive declaring another coin and records the mismatch, not node lag', async function () { + const { stubs, result, summary } = await refusedRun(declared(XChainService.XCHAIN_UTXO_TRACKER, 'litecoin'), + bs => bs.ensureBootstrapUtxoTracker(COIN, NETWORK)) + expect(result).to.equal(false) + expect(archiveRemoved(stubs, TRACKER_ARCHIVE), 'a mis-published archive is evidence and must be kept').to.equal(false) + expect(summary).to.match(/xchain-utxo-tracker: NOT restored: Bootstrap archive identity mismatch .* declares coin litecoin/) + expect(summary).to.not.match(/the coin node is behind the archive/) + }) + + it('keeps a refused indexer archive offered to the decoder (module-only mismatch)', async function () { + const { stubs, result, summary } = await refusedRun(declared(XChainService.XCHAIN_INDEXER), + bs => bs.ensureBootstrapMariaDb(COIN, NETWORK, XChainService.XCHAIN_DECODER)) + expect(result).to.equal(false) + expect(archiveRemoved(stubs, DECODER_ARCHIVE)).to.equal(false) + expect(summary).to.match(/declares module xchain-indexer but the restore target is xchain-decoder/) + expect(summary).to.not.match(/the coin node is behind the archive/) + }) + + it('still retires a refused archive whose identity matches, recording node-behind', async function () { + const { stubs, result, summary } = await refusedRun(declared(XChainService.XCHAIN_DECODER), + bs => bs.ensureBootstrapMariaDb(COIN, NETWORK, XChainService.XCHAIN_DECODER)) + expect(result).to.equal(false) + expect(archiveRemoved(stubs, DECODER_ARCHIVE)).to.equal(true) + expect(summary).to.not.match(/identity mismatch/) + }) + }) +}) diff --git a/test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js b/test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js index 64710817..83ab6e47 100644 --- a/test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js +++ b/test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js @@ -111,6 +111,46 @@ describe('BootstrapService', function () { }) }) +describe('BootstrapService', function () { + beforeEach(saveRequireSignedBootstrapSetting) + afterEach(restoreRequireSignedBootstrapSetting) + describe('makeBootstrapUtxoTracker(): an unclean tracker stop is never archived', function () { + it('stops the tracker with its 120 s service budget when the container carries no stamp', async function () { + const { stubs } = trackerStubsWithProbe([100, 101]) + makeAutoSpawn(stubs) + + const bs = loadBootstrapService(stubs) + expect(await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER)).to.be.true + expect(stubs.dockerService.stopContainer.firstCall.args).to.deep.equal([FAKE_CONTAINER_ID, 120]) + }) + + for (const [label, outcome] of [ + ['was killed at the budget', { stopped: true, seconds: 120, killed: true, exitCode: 137 }], + ['exited non-zero inside the budget', { stopped: true, seconds: 100, killed: false, exitCode: 1 }] + ]) { + it(`refuses to snapshot, compress or sign when the tracker ${label}, and restarts it`, async function () { + const { stubs } = trackerStubsWithProbe([100, 101]) + const spawnCalls = makeAutoSpawn(stubs) + stubs.dockerService.stopContainer = sinon.stub().resolves(outcome) + + const bs = loadBootstrapService(stubs) + let err = null + try { await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER) } catch (e) { err = e } + + expect(err, 'an unclean stop must fail the create').to.not.equal(null) + expect(err.message).to.match(/did not shut down cleanly/) + expect(err.message).to.include(`exit code ${outcome.exitCode}`) + const snapshots = stubs.execFile.getCalls().filter(c => Array.isArray(c.args[1]) && c.args[1].includes('sh')) + expect(snapshots, 'no snapshot may be taken of an unclean store').to.have.length(0) + expect(spawnCalls, 'no compress or wrap may run').to.have.length(0) + expect(stubs.archiveMeta.writeBootstrapMeta.called).to.equal(false) + expect(stubs.dockerService.startContainer.calledOnceWithExactly(FAKE_CONTAINER_ID)).to.equal(true) + expect(stubs.encoderMaintenance.clearEncoderMaintenance.callCount).to.equal(1) + }) + } + }) +}) + describe('BootstrapService', function () { beforeEach(saveRequireSignedBootstrapSetting) afterEach(restoreRequireSignedBootstrapSetting) diff --git a/test/unit/cli/dispatch_lock.test.js b/test/unit/cli/dispatch_lock.test.js index a83eafc7..d4487e75 100644 --- a/test/unit/cli/dispatch_lock.test.js +++ b/test/unit/cli/dispatch_lock.test.js @@ -18,7 +18,7 @@ const sinon = require('sinon') const { expect } = require('chai') const { Command } = require('commander') -const { installDispatch } = require('../../../src/cli/dispatch') +const { installDispatch, SELF_UPDATE_HANDOFF_WAIT_MS } = require('../../../src/cli/dispatch') const SIGNALS = ['exit', 'SIGINT', 'SIGTERM'] @@ -78,8 +78,8 @@ function restoreListeners() { sinon.restore() } -async function run(argv) { - const deps = makeDeps() +async function run(argv, config = {}) { + const deps = Object.assign(makeDeps(), { config }) await buildProgram(deps).parseAsync(argv, { from: 'user' }) return deps } @@ -111,6 +111,29 @@ describe('CLI dispatch: commands that hold the lock through their action', funct }) }) +describe('CLI dispatch: the release update a self-update re-executes', function () { + beforeEach(snapshotListeners) + afterEach(restoreListeners) + + const TARGET = { XCHAIN_NODE_UPDATE_TARGET: 'v0.99.0' } + + it('waits out a mutator that took the handed-back lock instead of refusing at once', async function () { + const deps = await run(['update', 'all', 'v0.99.0'], TARGET) + expect(deps.acquireCommandLock.calledOnceWith({ command: 'update', waitMs: SELF_UPDATE_HANDOFF_WAIT_MS })).to.be.true + expect(deps.release.called).to.be.false + }) + + it('keeps an operator wait longer than the handoff floor', async function () { + const deps = await run(['update', 'all', 'v0.99.0'], { ...TARGET, XCHAIN_NODE_MUTATING_LOCK_WAIT_MS: '90000' }) + expect(deps.acquireCommandLock.calledOnceWith({ command: 'update', waitMs: 90000 })).to.be.true + }) + + it('gives no other mutator the floor, even with the target marker set', async function () { + const deps = await run(['install', 'xchain-hub'], TARGET) + expect(deps.acquireCommandLock.calledOnceWith({ command: 'install', waitMs: 0 })).to.be.true + }) +}) + describe('CLI dispatch: commands that hand the lock back after preCheck', function () { beforeEach(snapshotListeners) afterEach(restoreListeners) diff --git a/test/unit/cli_self_update.test.js b/test/unit/cli_self_update.test.js index 530efcb9..f5a765c6 100644 --- a/test/unit/cli_self_update.test.js +++ b/test/unit/cli_self_update.test.js @@ -45,6 +45,7 @@ function setupSelfUpdateTest() { function teardownSelfUpdateTest() { delete process.env[TARGET_ENV] delete process.env[NO_SELF_UPDATE_ENV] + delete process.env.XCHAIN_NODE_MUTATING_LOCK_WAIT_MS } describe('cli maybeSelfUpdateBeforeUpdate()', function () { @@ -119,6 +120,16 @@ describe('cli maybeSelfUpdateBeforeUpdate()', function () { expect(release.called, 'released after a no-move outcome too').to.equal(true) }) + it('takes the self-update lock with the same wait every mutator honours', async function () { + delete process.env.XCHAIN_NODE_MUTATING_LOCK_WAIT_MS + await maybeSelfUpdateBeforeUpdate(['all'], deps) + expect(deps.acquireCommandLock.firstCall.args[0].waitMs).to.equal(0) + setupSelfUpdateTest() + process.env.XCHAIN_NODE_MUTATING_LOCK_WAIT_MS = '4000' + await maybeSelfUpdateBeforeUpdate(['all'], deps) + expect(deps.acquireCommandLock.firstCall.args[0].waitMs).to.equal(4000) + }) + it('lets a lookup failure propagate as the update failure', async function () { deps.manifest.resolveLatestReleaseTag.rejects(new Error('rate limited')) let err = null diff --git a/test/unit/config/anchor_arm_passthrough.test.js b/test/unit/config/anchor_arm_passthrough.test.js new file mode 100644 index 00000000..8364431d --- /dev/null +++ b/test/unit/config/anchor_arm_passthrough.test.js @@ -0,0 +1,56 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// The hub and a regtest indexer must arm fold, stake and slash at the same +// heights; shared-ledger indexers must never see a regtest arm. + +const { expect, makeServiceWithConfig } = require('../config_service.test/helpers.test') + +const VARS = [ + 'XC_ANCHOR_FOLD_REGTEST_ACTIVATION', + 'XC_ANCHOR_STAKE_REGTEST_ACTIVATION', + 'XC_ANCHOR_SLASH_REGTEST_ACTIVATION' +] +const VALUES = { [VARS[0]]: '0', [VARS[1]]: '12', [VARS[2]]: '14' } + +describe('anchor arm passthrough', function () { + let saved + + beforeEach(function () { + saved = {} + for (const name of VARS) { saved[name] = process.env[name]; process.env[name] = VALUES[name] } + }) + + afterEach(function () { + for (const name of VARS) { + if (saved[name] === undefined) delete process.env[name]; else process.env[name] = saved[name] + } + }) + + it('hands the hub and a regtest indexer the same three values', async function () { + const cs = makeServiceWithConfig('') + const hub = await cs.getDefaultConfig('xchain-hub', null, null) + const indexer = await cs.getDefaultConfig('xchain-indexer', 'dogecoin', 'regtest') + for (const name of VARS) { + expect(hub[name], `hub ${name}`).to.equal(VALUES[name]) + expect(indexer[name], `indexer ${name}`).to.equal(VALUES[name]) + } + }) + + it('never hands a shared-ledger indexer any of them', async function () { + const cs = makeServiceWithConfig('') + for (const net of ['mainnet', 'testnet']) { + const config = await cs.getDefaultConfig('xchain-indexer', 'dogecoin', net) + for (const name of VARS) expect(config, `${net} ${name}`).to.not.have.property(name) + } + }) +}) diff --git a/test/unit/config_service.test/indexer_origin_urls.test.js b/test/unit/config_service.test/indexer_origin_urls.test.js new file mode 100644 index 00000000..e878984c --- /dev/null +++ b/test/unit/config_service.test/indexer_origin_urls.test.js @@ -0,0 +1,71 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect, makeServiceWithConfig } = require('./helpers.test') + +const ORIGIN_INDEXER_VARS = [ + 'BTC_INDEXER_URL', 'LTC_INDEXER_URL', 'DOGE_INDEXER_URL', + 'BTC_INDEXER_API_KEY', 'LTC_INDEXER_API_KEY', 'DOGE_INDEXER_API_KEY' +] + +describe('ConfigService', function () { + describe('getDefaultConfig()', function () { + describe('indexer origin endpoints', function () { + const saved = {} + + beforeEach(function () { + for (const name of ORIGIN_INDEXER_VARS) { + saved[name] = process.env[name] + delete process.env[name] + } + }) + + afterEach(function () { + for (const name of ORIGIN_INDEXER_VARS) { + if (saved[name] === undefined) delete process.env[name] + else process.env[name] = saved[name] + } + }) + + it('passes configured origin-chain URLs and API keys to every indexer', async function () { + const expected = { + BTC_INDEXER_URL: 'http://bitcoin-indexer:3014', + LTC_INDEXER_URL: 'http://litecoin-indexer:3214', + DOGE_INDEXER_URL: 'http://dogecoin-indexer:3114', + BTC_INDEXER_API_KEY: 'btc-origin-key', + LTC_INDEXER_API_KEY: 'ltc-origin-key', + DOGE_INDEXER_API_KEY: 'doge-origin-key' + } + Object.assign(process.env, expected) + + const cs = makeServiceWithConfig('') + for (const coin of ['bitcoin', 'litecoin', 'dogecoin']) { + const config = await cs.getDefaultConfig('xchain-indexer', coin, 'testnet') + for (const [name, value] of Object.entries(expected)) { + expect(config[name], coin + ' ' + name).to.equal(value) + } + } + }) + + it('does not inject an unset origin-chain value', async function () { + process.env.BTC_INDEXER_URL = 'http://bitcoin-indexer:3014' + + const cs = makeServiceWithConfig('') + const config = await cs.getDefaultConfig('xchain-indexer', 'dogecoin', 'testnet') + + expect(config.BTC_INDEXER_URL).to.equal('http://bitcoin-indexer:3014') + expect(config).to.not.have.property('BTC_INDEXER_API_KEY') + expect(config).to.not.have.property('LTC_INDEXER_URL') + }) + }) + }) +}) diff --git a/test/unit/docker_service.test/02_stop_container.test.js b/test/unit/docker_service.test/02_stop_container.test.js index 7e3ac527..4c5dfbee 100644 --- a/test/unit/docker_service.test/02_stop_container.test.js +++ b/test/unit/docker_service.test/02_stop_container.test.js @@ -154,3 +154,39 @@ describe('DockerService', function () { }) }) +describe('DockerService', function () { + describe('stopContainer() with a budget', function () { + const ID = 'xchain-node-bitcoin-mainnet-node' + + it('runs docker stop -t and resolves the outcome read after it', async function () { + const stubs = makeStubs() + const calls = stopThenInspect(stubs, { exitCode: '137' }) + const ds = loadDockerService(stubs) + const outcome = await ds.stopContainer(ID, 120) + expect(calls[0]).to.deep.equal(['stop', '-t', '120', ID]) + expect(calls[1].slice(0, 2)).to.deep.equal(['inspect', '--format']) + expect(outcome).to.include({ stopped: true, killed: true, exitCode: 137 }) + }) + + it('rejects with docker\'s own error object, so a missing container stays distinguishable', async function () { + const stubs = makeStubs() + const original = new Error('Error response from daemon: No such container: ' + ID) + const calls = stopThenInspect(stubs, { stopErr: original }) + const ds = loadDockerService(stubs) + let err = null + try { await ds.stopContainer(ID, 120) } catch (e) { err = e } + expect(err).to.equal(original) + expect(calls, 'a failed stop must not be inspected').to.have.length(1) + }) + + it('still rejects when docker exits 0 without echoing the id', async function () { + const stubs = makeStubs() + stopThenInspect(stubs, { stopOut: 'something-else\n' }) + const ds = loadDockerService(stubs) + let err = null + try { await ds.stopContainer(ID, 120) } catch (e) { err = e } + expect(err).to.be.a('string').and.include(ID) + }) + }) +}) + diff --git a/test/unit/module_operations.test/09_reset_modules.test.js b/test/unit/module_operations.test/09_reset_modules.test.js index 979d6971..f36f2200 100644 --- a/test/unit/module_operations.test/09_reset_modules.test.js +++ b/test/unit/module_operations.test/09_reset_modules.test.js @@ -57,6 +57,8 @@ describe('moduleOperations', function () { const result = await ops.resetModules('xchain-utxo-tracker', 'bitcoin', 'mainnet', true) expect(result).to.be.true expect(stubs.stopContainer.called).to.be.true + // The tracker's drain gets its 120 s service budget, not docker's ten-second default. + expect(stubs.stopContainer.firstCall.args).to.deep.equal(['container-id-123', 120]) }) }) }) diff --git a/test/unit/module_operations.test/10_reset_node_datadir_resolution.test.js b/test/unit/module_operations.test/10_reset_node_datadir_resolution.test.js index 324627bc..ba4cd12d 100644 --- a/test/unit/module_operations.test/10_reset_node_datadir_resolution.test.js +++ b/test/unit/module_operations.test/10_reset_node_datadir_resolution.test.js @@ -138,6 +138,7 @@ describe('moduleOperations', function () { stubs.getContainerBindMounts.resolves([]) stubs.fs.existsSync.returns(false) stubs.db.getModuleContainer.withArgs('node', 'bitcoin', 'mainnet').resolves(null) + stubs.db.getModuleContainerStrict.withArgs('node', 'bitcoin', 'mainnet').resolves(null) stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) const ops = loadOperations(stubs) const lines = [] @@ -155,3 +156,68 @@ describe('moduleOperations', function () { }) }) }) + +// Fail only the FIRST node registry read, whichever lookup makes it: the non-strict read answers +// null and the strict read throws, as db/modules.js does on a blip; later reads recover. +function registryBlip(stubs) { + let blipped = false + const first = () => { const hit = !blipped; blipped = true; return hit } + stubs.db.getModuleContainer.withArgs('node', 'bitcoin', 'mainnet') + .callsFake(async () => (first() ? null : 'container-id-123')) + stubs.db.getModuleContainerStrict.withArgs('node', 'bitcoin', 'mainnet') + .callsFake(async () => { if (first()) throw new Error('ECONNRESET'); return 'container-id-123' }) +} + +// Run a forced node reset and capture what it printed. +async function runReset(stubs) { + const ops = loadOperations(stubs) + const lines = [] + const logStub = sinon.stub(console, 'log').callsFake((...args) => lines.push(args.join(' '))) + let result + try { + result = await ops.resetModules('node', 'bitcoin', 'mainnet', true) + } finally { + logStub.restore() + } + return { result, output: lines.join('\n') } +} + +describe('moduleOperations', function () { + let resolveInstallTargetStub, recordInstallTargetStub, resolveUpdateTargetStub + registerLifecycleHooks(stubs => { + ({ resolveInstallTargetStub, recordInstallTargetStub, resolveUpdateTargetStub } = stubs) + }) + + describe('resetModules()', function () { + describe('node datadir resolution with an unreadable registry', function () { + + it('refuses, rather than skips, when the node registry read fails and the datadir is unresolvable', async function () { + const stubs = makeStubs() + stubs.getContainerBindMounts.resolves([]) + stubs.fs.existsSync.returns(false) + registryBlip(stubs) + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + const { result, output } = await runReset(stubs) + expect(result).to.be.false + expect(output).to.include('Aborted: cannot resolve the bitcoin mainnet node datadir') + expect(output).to.include('could not read the node registry row') + expect(output).to.include('ECONNRESET') + expect(output).to.not.include('no node data to clear') + expect(stubs.stopContainer.called).to.be.false + expect(stubs.resetDatabases.called).to.be.false + expect(wipedHostPaths(stubs.execFile)).to.be.empty + }) + + it('still wipes a datadir it resolved when only the registry read fails', async function () { + const stubs = makeStubs() + stubs.fs.existsSync.returns(false) + registryBlip(stubs) + stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) + const { result } = await runReset(stubs) + expect(result).to.be.true + expect(wipedHostPaths(stubs.execFile)) + .to.include('/srv/xchain/data/node/bitcoin/mainnet:/data') + }) + }) + }) +}) diff --git a/test/unit/module_operations.test/13_reset_registry_read.test.js b/test/unit/module_operations.test/13_reset_registry_read.test.js index 1bbb1528..dc26c879 100644 --- a/test/unit/module_operations.test/13_reset_registry_read.test.js +++ b/test/unit/module_operations.test/13_reset_registry_read.test.js @@ -77,8 +77,9 @@ describe('moduleOperations', function () { const stubs = makeStubs() stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) // node resolves and stops, the tracker read fails, and the - // rollback's own read fails the same way. + // rollback's own read fails the same way. Call 0 is the node datadir guard's read. stubs.db.getModuleContainerStrict.onCall(0).resolves('container-id-123') + stubs.db.getModuleContainerStrict.onCall(1).resolves('container-id-123') stubs.db.getModuleContainerStrict.rejects(new Error('registry unreachable')) const ops = loadOperations(stubs) const lines = [] diff --git a/test/unit/mutating_lock_wait.test.js b/test/unit/mutating_lock_wait.test.js index d407bef2..4111320e 100644 --- a/test/unit/mutating_lock_wait.test.js +++ b/test/unit/mutating_lock_wait.test.js @@ -31,12 +31,26 @@ function tearDownLock() { } function runAutoheal(extraEnv) { + return runCli(['autoheal'], extraEnv) +} + +// Delete the lock from a separate process after ms, standing in for a holder that finishes. +function releaseLockAfter(ms) { + const lockPath = path.join(tmpDir, 'command.lock') + require('child_process').spawn( + process.execPath, + ['-e', `setTimeout(()=>require('fs').rmSync(${JSON.stringify(lockPath)},{force:true}),${ms})`], + { detached: true, stdio: 'ignore' } + ).unref() +} + +function runCli(argv, extraEnv) { const started = Date.now() - const res = spawnSync(process.execPath, [CLI, 'autoheal'], { + const res = spawnSync(process.execPath, [CLI, ...argv], { env: { ...process.env, XCHAIN_NODE_LOCK_DIR: tmpDir, - // The third case proceeds past the lock into a MUTATING action, + // Some cases proceed 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', @@ -85,12 +99,7 @@ describe('mutating command lock wait', () => { // 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() + releaseLockAfter(2000) 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 @@ -98,3 +107,22 @@ describe('mutating command lock wait', () => { assert.doesNotMatch(output, /holds the command lock/) }) }) + +describe('mutating command lock wait', () => { + beforeEach(setUpLock) + afterEach(tearDownLock) + + it('lets the re-executed release update outwait a mutator that took the lock in the self-update handoff', function () { + this.timeout(60000) + // The child the self-update step spawns: explicit release form, target marker set, + // no operator wait configured, and another mutator holding the lock it was handed. + releaseLockAfter(2000) + const { elapsed, output } = runCli(['update', 'all', 'all', 'all', 'v0.99.0'], { + XCHAIN_NODE_MUTATING_LOCK_WAIT_MS: '', + XCHAIN_NODE_UPDATE_TARGET: 'v0.99.0' + }) + assert.doesNotMatch(output, /holds the command lock/) + // Lower bound only: it blocked on the held lock rather than failing before reaching it. + assert.ok(elapsed >= 1500, `expected to wait for the holder, took only ${elapsed}ms`) + }) +}) diff --git a/test/unit/rail_leg_workflow.test/anchor_arm_heights.test.js b/test/unit/rail_leg_workflow.test/anchor_arm_heights.test.js new file mode 100644 index 00000000..43005feb --- /dev/null +++ b/test/unit/rail_leg_workflow.test/anchor_arm_heights.test.js @@ -0,0 +1,198 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { execFileSync, spawnSync } = require('child_process') +const yaml = require('js-yaml') + +const ROOT = path.join(__dirname, '../../..') +const WORKFLOW = path.join(ROOT, '.github/workflows/rail-leg.yml') +const ENV_WRITER = path.join(ROOT, 'scripts/rail_leg_env.js') +const ARM_ENVS = [ + 'XC_ANCHOR_FOLD_REGTEST_ACTIVATION', + 'XC_ANCHOR_STAKE_REGTEST_ACTIVATION', + 'XC_ANCHOR_SLASH_REGTEST_ACTIVATION', +] + +function parseAssignments (file) { + const values = {} + for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { + const match = /^([^=]+)=(.*)$/.exec(line) + if (match) values[match[1]] = match[2] + } + return values +} + +// Output may carry generated masked credentials, so assert structure, not a digit substring. +function expectNoActivationValues (output) { + for (const line of output.split(/\r?\n/)) { + for (const name of ARM_ENVS) expect(line, line).to.not.match(new RegExp(name + '\\s*[=:]')) + expect(line, line).to.not.match(/::add-mask::\s*\d+\s*$/) + } +} + +function loadWorkflow () { + return yaml.load(fs.readFileSync(WORKFLOW, 'utf8')) +} + +function findStep (doc, name) { + const step = doc.jobs.leg.steps.find((candidate) => candidate.name === name) + if (!step) throw new Error('workflow step not found: ' + name) + return step +} + +function resolveArms (values) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rail-leg-anchor-arms-')) + const out = path.join(dir, 'arms.env') + const env = Object.assign({}, process.env) + for (const name of ARM_ENVS) delete env[name] + Object.assign(env, values) + const result = spawnSync(process.execPath, [ENV_WRITER, '--resolve-anchor-arms', out], { + env, + encoding: 'utf8', + }) + return { out, result } +} + +function writeDriveEnv (values) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rail-leg-anchor-drive-')) + const bin = path.join(dir, 'stub-bin') + const out = path.join(dir, '.env') + fs.mkdirSync(bin) + fs.writeFileSync(path.join(bin, 'docker'), '#!/usr/bin/env bash\nif [ "$1" = port ]; then echo "127.0.0.1:3020"; else echo "[]"; fi\n', { mode: 0o755 }) + const env = Object.assign({}, process.env, { PATH: bin + ':' + process.env.PATH }) + for (const name of ARM_ENVS) delete env[name] + Object.assign(env, values) + const output = execFileSync(process.execPath, [ENV_WRITER, out, 'bitcoin'], { env, encoding: 'utf8' }) + return { out, output } +} + +function driveSetupSteps (height) { + const doc = loadWorkflow() + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rail-leg-anchor-setup-')) + const runnerTemp = path.join(dir, 'runner-temp') + const bin = path.join(dir, 'stub-bin') + fs.mkdirSync(runnerTemp) + fs.mkdirSync(bin) + fs.writeFileSync(path.join(bin, 'node'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 }) + const env = { + PATH: bin + ':' + process.env.PATH, + RUNNER_TEMP: runnerTemp, + STACKS: 'btc,doge', + XCHAIN_NODE_EXTERNAL_DB_HOST: '172.17.0.1', + } + for (const name of ARM_ENVS) env[name] = height + execFileSync('bash', ['-e', '-c', findStep(doc, 'Initialize the validator identity').run], { + cwd: dir, + env, + }) + execFileSync('bash', ['-e', '-c', findStep(doc, 'Publish the two-stack host ports').run], { + cwd: dir, + env, + }) + return { dir, runnerTemp } +} + +function defineWorkflowInputTests () { + it('declares three optional inputs and resolves them before any stack install', function () { + const doc = loadWorkflow() + const inputs = doc.on.workflow_dispatch.inputs + for (const name of ARM_ENVS) { + expect(inputs[name].required, name).to.equal(false) + expect(findStep(doc, 'Resolve the anchor arm height').env[name], name) + .to.equal('${{ inputs.' + name + ' }}') + } + const steps = doc.jobs.leg.steps + expect(steps.indexOf(findStep(doc, 'Resolve the anchor arm height'))) + .to.be.lessThan(steps.indexOf(findStep(doc, 'Boot the two regtest stacks at ${{ inputs.stack_ref || \'develop\' }}'))) + }) +} + +function defineResolutionTests () { + it('canonicalizes any populated input into one height for all three gates', function () { + const { out, result } = resolveArms({ XC_ANCHOR_STAKE_REGTEST_ACTIVATION: '0042' }) + expect(result.status, result.stderr).to.equal(0) + expect(parseAssignments(out)).to.deep.equal(Object.fromEntries(ARM_ENVS.map((name) => [name, '42']))) + expectNoActivationValues(result.stdout) + expect(ARM_ENVS.filter((name) => result.stdout.includes(name))).to.deep.equal(ARM_ENVS) + }) + + it('exports nothing when unset and rejects conflicting or invalid heights', function () { + const unset = resolveArms({}) + expect(unset.result.status, unset.result.stderr).to.equal(0) + expect(fs.readFileSync(unset.out, 'utf8')).to.equal('') + + const conflicting = resolveArms({ + XC_ANCHOR_FOLD_REGTEST_ACTIVATION: '40', + XC_ANCHOR_SLASH_REGTEST_ACTIVATION: '41', + }) + expect(conflicting.result.status).to.not.equal(0) + expect(conflicting.result.stderr).to.include('must resolve to one height') + + const invalid = resolveArms({ XC_ANCHOR_FOLD_REGTEST_ACTIVATION: 'later' }) + expect(invalid.result.status).to.not.equal(0) + expect(invalid.result.stderr).to.include('must be a non-negative integer') + + for (const zero of ['0', '000']) { + const refused = resolveArms({ XC_ANCHOR_STAKE_REGTEST_ACTIVATION: zero }) + expect(refused.result.status, zero).to.not.equal(0) + expect(refused.result.stderr).to.include('must be an integer at least 1') + } + }) +} + +function defineStackPropagationTests () { + it('puts the same height in the standing stack and both indexer configs', function () { + const setup = driveSetupSteps('42') + const expected = Object.fromEntries(ARM_ENVS.map((name) => [name, '42'])) + expect(parseAssignments(path.join(setup.runnerTemp, 'stack.env'))).to.include(expected) + expect(parseAssignments(path.join(setup.dir, 'config/bitcoin-regtest'))).to.include(expected) + expect(parseAssignments(path.join(setup.dir, 'config/dogecoin-regtest'))).to.include(expected) + }) + + it('leaves the standing stack and both indexer configs unchanged when unset', function () { + const setup = driveSetupSteps(undefined) + for (const file of [ + path.join(setup.runnerTemp, 'stack.env'), + path.join(setup.dir, 'config/bitcoin-regtest'), + path.join(setup.dir, 'config/dogecoin-regtest'), + ]) { + expect(parseAssignments(file)).to.not.have.any.keys(ARM_ENVS) + } + }) +} + +function defineDrivePropagationTests () { + it('writes the resolved heights into the drive environment without logging values', function () { + const values = Object.fromEntries(ARM_ENVS.map((name) => [name, '42'])) + const { out, output } = writeDriveEnv(values) + expect(parseAssignments(out)).to.include(Object.fromEntries(ARM_ENVS.map((name) => [name, '42']))) + expectNoActivationValues(output) + expect(ARM_ENVS.filter((name) => output.includes(name))).to.deep.equal(ARM_ENVS) + }) + + it('leaves the drive environment and missing-value diagnostics unchanged when unset', function () { + const { out, output } = writeDriveEnv({}) + expect(parseAssignments(out)).to.not.have.any.keys(ARM_ENVS) + for (const name of ARM_ENVS) expect(output).to.not.include(name) + }) +} + +describe('rail-leg.yml anchor arm heights', function () { + defineWorkflowInputTests() + defineResolutionTests() + defineStackPropagationTests() + defineDrivePropagationTests() +}) diff --git a/test/unit/rail_leg_workflow.test/doge_follower_compare.test.js b/test/unit/rail_leg_workflow.test/doge_follower_compare.test.js new file mode 100644 index 00000000..51b57468 --- /dev/null +++ b/test/unit/rail_leg_workflow.test/doge_follower_compare.test.js @@ -0,0 +1,169 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { execFileSync, spawnSync } = require('child_process') +const yaml = require('js-yaml') + +const ROOT = path.join(__dirname, '../../..') +const WORKFLOW = path.join(ROOT, '.github/workflows/rail-leg.yml') +const FOLLOWER_COMPARE = path.join(ROOT, 'scripts/rail_follower_compare.js') +const ARM_INPUTS = [ + 'XC_ANCHOR_FOLD_REGTEST_ACTIVATION', + 'XC_ANCHOR_STAKE_REGTEST_ACTIVATION', + 'XC_ANCHOR_SLASH_REGTEST_ACTIVATION', +] + +function loadWorkflow () { + return yaml.load(fs.readFileSync(WORKFLOW, 'utf8')) +} + +function findStep (doc, name) { + const step = doc.jobs.leg.steps.find((candidate) => candidate.name === name) + if (!step) throw new Error('workflow step not found: ' + name) + return step +} + +function assignments (file) { + return Object.fromEntries(fs.readFileSync(file, 'utf8').trim().split('\n').map((line) => line.split('='))) +} + +function followerRows (height) { + return [height - 1, height, height + 5].map((blockIndex) => ({ + block_index: blockIndex, + ledger_hash: 'ledger-' + blockIndex, + actions_hash: 'actions-' + blockIndex, + contract_hash: 'contract-' + blockIndex, + state_hash: 'state-' + blockIndex, + })) +} + +function runFollowerCompare (args) { + return spawnSync(process.execPath, [FOLLOWER_COMPARE, ...args], { + cwd: ROOT, + encoding: 'utf8', + }) +} + +describe('rail-leg.yml DOGE follower comparison: arm wiring', function () { + it('clones before the drive and compares after it only for an armed run', function () { + const doc = loadWorkflow() + const steps = doc.jobs.leg.steps + const resolve = findStep(doc, 'Resolve the anchor arm height') + const clone = findStep(doc, 'Clone forward DOGE follower') + const drive = findStep(doc, 'Drive ${{ matrix.drive }} ${{ matrix.leg }}') + const compare = findStep(doc, 'Compare DOGE follower state hashes') + + expect(resolve.id).to.equal('anchor-arms') + expect(resolve.run).to.include('echo "height=$XC_ANCHOR_FOLD_REGTEST_ACTIVATION" >> "$GITHUB_OUTPUT"') + expect(clone.if).to.equal("${{ steps.anchor-arms.outputs.height != '' }}") + expect(compare.if).to.equal("${{ success() && steps.anchor-arms.outputs.height != '' }}") + expect(compare.env.DOGE_FOLLOWER_ARM_HEIGHT).to.equal('${{ steps.anchor-arms.outputs.height }}') + expect(compare.run).to.include('H="$DOGE_FOLLOWER_ARM_HEIGHT"') + expect(compare.run).to.not.match(/H="\$XC_ANCHOR_(?:FOLD|STAKE|SLASH)_REGTEST_ACTIVATION"/) + expect(steps.indexOf(clone)).to.be.lessThan(steps.indexOf(drive)) + expect(steps.indexOf(compare)).to.be.greaterThan(steps.indexOf(drive)) + }) + + it('uses the canonical height for fold-only, stake-only and slash-only inputs', function () { + const resolve = findStep(loadWorkflow(), 'Resolve the anchor arm height') + + for (const input of ARM_INPUTS) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'doge-follower-arm-')) + const githubEnv = path.join(dir, 'github.env') + const githubOutput = path.join(dir, 'github.output') + const env = { ...process.env, RUNNER_TEMP: dir, GITHUB_ENV: githubEnv, GITHUB_OUTPUT: githubOutput } + for (const name of ARM_INPUTS) delete env[name] + env[input] = '0042' + + execFileSync('bash', ['-e', '-u', '-o', 'pipefail', '-c', resolve.run], { cwd: ROOT, env }) + + expect(assignments(githubOutput), input).to.deep.equal({ height: '42' }) + expect(assignments(githubEnv), input) + .to.deep.equal(Object.fromEntries(ARM_INPUTS.map((name) => [name, '42']))) + } + }) + +}) + +describe('rail-leg.yml DOGE follower comparison: clone and reads', function () { + it('clones the stopped standing database and launches an unexposed follower', function () { + const clone = findStep(loadWorkflow(), 'Clone forward DOGE follower') + + expect(clone.env.DOGE_SOURCE_CONTAINER).to.equal('xchain-node-dogecoin-regtest-xchain-indexer') + expect(clone.env.DOGE_FOLLOWER_DB).to.equal('XChain_DOGE_DrillB_Indexer') + expect(clone.run).to.include('followerEnvLines') + expect(clone.run).to.include('followerCreateArgs') + expect(clone.run).to.include('cloneStatements') + expect(clone.run).to.match(/^set -euo pipefail\numask 077\n/) + expect(clone.run.indexOf('docker stop "$DOGE_SOURCE_CONTAINER"')) + .to.be.lessThan(clone.run.indexOf('const statements = cloneStatements')) + expect(clone.run.indexOf('const statements = cloneStatements')) + .to.be.lessThan(clone.run.lastIndexOf('docker start "$DOGE_SOURCE_CONTAINER"')) + expect(clone.run).to.include("trap 'docker start \"$DOGE_SOURCE_CONTAINER\" >/dev/null || true' EXIT") + expect(clone.run).to.not.match(/\bmariadb(?:-dump)?\b.*(?:-p|--password)/) + expect(clone.run).to.not.include('--publish') + }) + + it('reads H-1, H and H+5 from both followers before proving equality', function () { + const compare = findStep(loadWorkflow(), 'Compare DOGE follower state hashes') + const reads = compare.run.match(/node scripts\/rail_follower_rows\.js/g) || [] + + expect(reads).to.have.length(2) + expect(compare.run).to.include('--database "$DOGE_FOLLOWER_SOURCE_DB"') + expect(compare.run).to.include('--database "$DOGE_FOLLOWER_DB"') + expect(compare.run).to.include('[h-1,h,h+5]') + expect(compare.run).to.include('rail_follower_compare.js --a "$A" --b "$B" --arm-height "$H"') + expect(compare.run).to.include('$JOURNAL_DIR/follower') + }) + +}) + +describe('rail-leg.yml DOGE follower comparison: mismatch control', function () { + it('requires the injected-difference control to report a mismatch', function () { + const compare = findStep(loadWorkflow(), 'Compare DOGE follower state hashes') + const commands = compare.run.split('\n').filter((line) => line.includes('rail_follower_compare.js')) + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'doge-follower-compare-')) + const standing = path.join(dir, 'standing.json') + const forward = path.join(dir, 'forward.json') + const height = 42 + const rows = followerRows(height) + + expect(commands).to.have.length(2) + expect(commands[0]).to.not.include('--control') + expect(commands[1]).to.include('--control') + expect(commands[1]).to.include('control.log') + + fs.writeFileSync(standing, JSON.stringify(rows)) + fs.writeFileSync(forward, JSON.stringify(rows)) + + const baseArgs = ['--a', standing, '--b', forward, '--arm-height', String(height)] + const match = runFollowerCompare(baseArgs) + expect(match.status, match.stderr).to.equal(0) + expect(match.stdout.trim().split('\n')).to.deep.equal([ + 'FOLLOWER 41 MATCH', + 'FOLLOWER 42 MATCH', + 'FOLLOWER 47 MATCH', + ]) + + const control = runFollowerCompare([...baseArgs, '--control']) + expect(control.status, control.stderr).to.equal(0) + expect(control.stdout.trim().split('\n')).to.deep.equal([ + 'FOLLOWER 41 MATCH', + 'FOLLOWER 42 MISMATCH', + 'FOLLOWER 47 MATCH', + ]) + }) +}) diff --git a/test/unit/rail_leg_workflow.test/drive_env_coin.test.js b/test/unit/rail_leg_workflow.test/drive_env_coin.test.js new file mode 100644 index 00000000..934b03d8 --- /dev/null +++ b/test/unit/rail_leg_workflow.test/drive_env_coin.test.js @@ -0,0 +1,122 @@ +'use strict' + +// Copyright (c) 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { execFileSync } = require('child_process') +const yaml = require('js-yaml') + +const ROOT = path.join(__dirname, '../../..') +const WORKFLOW = path.join(ROOT, '.github/workflows/rail-leg.yml') + +function driveEnvStep () { + const workflow = yaml.load(fs.readFileSync(WORKFLOW, 'utf8')) + return workflow.jobs.leg.steps.find((step) => step.name === 'Write the host .env for the drive') +} + +function parseAssignments (file) { + return Object.fromEntries(fs.readFileSync(file, 'utf8').trim().split(/\r?\n/).map((line) => { + const separator = line.indexOf('=') + return [line.slice(0, separator), line.slice(separator + 1)] + })) +} + +function writeDockerStub (bin) { + fs.writeFileSync(path.join(bin, 'docker'), `#!/usr/bin/env bash +if [ "$1" = inspect ]; then + echo '[]' + exit 0 +fi +if [ "$1" != port ]; then exit 1; fi +case "$2:$3" in + xchain-node-bitcoin-regtest-node:18444/tcp) echo '0.0.0.0:3020' ;; + xchain-node-bitcoin-regtest-xchain-utxo-tracker:3001/tcp) echo '0.0.0.0:3021' ;; + xchain-node-bitcoin-regtest-xchain-decoder:3002/tcp) echo '0.0.0.0:3022' ;; + xchain-node-bitcoin-regtest-xchain-encoder:3003/tcp) echo '0.0.0.0:3023' ;; + xchain-node-bitcoin-regtest-xchain-indexer:3004/tcp) echo '0.0.0.0:3024' ;; + xchain-node-bitcoin-regtest-xchain-regtest-miner:3005/tcp) echo '0.0.0.0:3025' ;; + xchain-node-dogecoin-regtest-node:18444/tcp) echo '0.0.0.0:3120' ;; + xchain-node-dogecoin-regtest-xchain-utxo-tracker:3001/tcp) echo '0.0.0.0:3121' ;; + xchain-node-dogecoin-regtest-xchain-decoder:3002/tcp) echo '0.0.0.0:3122' ;; + xchain-node-dogecoin-regtest-xchain-encoder:3003/tcp) echo '0.0.0.0:3123' ;; + xchain-node-dogecoin-regtest-xchain-indexer:3004/tcp) echo '0.0.0.0:3124' ;; + xchain-node-dogecoin-regtest-xchain-regtest-miner:3005/tcp) echo '0.0.0.0:3125' ;; + xchain-node-xchain-explorer:8080/tcp) echo '0.0.0.0:18080' ;; + xchain-node-xchain-hub:10000/tcp) echo '0.0.0.0:10000' ;; + *) exit 1 ;; +esac +`, { mode: 0o755 }) +} + +function runDriveEnvStep (helperCoin) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rail-leg-drive-coin-')) + const bin = path.join(dir, 'bin') + const e2e = path.join(dir, 'xchain-e2e-test') + fs.mkdirSync(bin) + fs.mkdirSync(e2e) + writeDockerStub(bin) + if (helperCoin) { + const helpers = path.join(e2e, 'test/helpers') + fs.mkdirSync(helpers, { recursive: true }) + fs.writeFileSync(path.join(helpers, 'rail_leg_coins.js'), ` +if (process.argv[2] !== 'anchor_stake' || process.argv[3] !== 'gap-4') process.exit(2) +process.stdout.write(${JSON.stringify(helperCoin)}) +`) + } + const env = Object.assign({}, process.env, { + DRIVE: 'anchor_stake', + LEG: 'gap-4', + PATH: bin + ':' + process.env.PATH, + RAIL_ROOT: dir, + }) + try { + execFileSync('bash', ['-e', '-c', driveEnvStep().run], { cwd: ROOT, env, encoding: 'utf8' }) + const btc = path.join(e2e, '.env.btc') + return Object.assign(parseAssignments(path.join(e2e, '.env')), { + btcEnv: fs.existsSync(btc) ? parseAssignments(btc) : null, + }) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +} + +describe('rail-leg.yml drive environment coin', function () { + it('passes the matrix drive and leg into the setup step', function () { + expect(driveEnvStep().env).to.deep.equal({ + DRIVE: '${{ matrix.drive }}', + LEG: '${{ matrix.leg }}', + }) + }) + + it('uses the drive coin helper when it exists', function () { + expect(runDriveEnvStep('dogecoin').COIN).to.equal('dogecoin') + }) + + it('defaults to bitcoin when the drive coin helper is absent', function () { + expect(runDriveEnvStep().COIN).to.equal('bitcoin') + }) + + // The federation seed runs as COIN=bitcoin on every leg; on a dogecoin leg the e2e + // loader (test/helpers/rail/coin_env.js) takes .env.btc for it (R-3 attempt 5). + it('writes a bitcoin .env.btc beside a dogecoin leg env', function () { + const written = runDriveEnvStep('dogecoin') + expect(written.INDEXER_API_PORT).to.equal('3124') + expect(written.btcEnv).to.include({ COIN: 'bitcoin', INDEXER_API_PORT: '3024' }) + }) + + it('writes no .env.btc on a bitcoin leg', function () { + expect(runDriveEnvStep().btcEnv).to.equal(null) + expect(runDriveEnvStep('bitcoin').btcEnv).to.equal(null) + }) +}) diff --git a/test/unit/rail_leg_workflow.test/drive_env_urls.test.js b/test/unit/rail_leg_workflow.test/drive_env_urls.test.js new file mode 100644 index 00000000..cf800e53 --- /dev/null +++ b/test/unit/rail_leg_workflow.test/drive_env_urls.test.js @@ -0,0 +1,91 @@ +'use strict' + +// Copyright (c) 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { execFileSync } = require('child_process') +const yaml = require('js-yaml') + +const ROOT = path.join(__dirname, '../../..') +const WORKFLOW = path.join(ROOT, '.github/workflows/rail-leg.yml') +const ENV_WRITER = path.join(ROOT, 'scripts/rail_leg_env.js') + +function parseAssignments (file) { + return Object.fromEntries(fs.readFileSync(file, 'utf8').trim().split(/\r?\n/).map((line) => { + const separator = line.indexOf('=') + return [line.slice(0, separator), line.slice(separator + 1)] + })) +} + +function writeDriveEnv () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rail-leg-drive-urls-')) + const bin = path.join(dir, 'bin') + const out = path.join(dir, '.env') + fs.mkdirSync(bin) + fs.writeFileSync(path.join(bin, 'docker'), `#!/usr/bin/env bash +if [ "$1" = inspect ]; then + echo '[]' + exit 0 +fi +if [ "$1" != port ]; then exit 1; fi +case "$2:$3" in + xchain-node-bitcoin-regtest-node:18444/tcp) echo '0.0.0.0:3020' ;; + xchain-node-bitcoin-regtest-xchain-utxo-tracker:3001/tcp) echo '0.0.0.0:3021' ;; + xchain-node-bitcoin-regtest-xchain-decoder:3002/tcp) echo '0.0.0.0:3022' ;; + xchain-node-bitcoin-regtest-xchain-encoder:3003/tcp) echo '0.0.0.0:3023' ;; + xchain-node-bitcoin-regtest-xchain-indexer:3004/tcp) echo '0.0.0.0:3024' ;; + xchain-node-bitcoin-regtest-xchain-regtest-miner:3005/tcp) echo '0.0.0.0:3025' ;; + xchain-node-dogecoin-regtest-xchain-encoder:3003/tcp) echo '0.0.0.0:3123' ;; + xchain-node-dogecoin-regtest-xchain-indexer:3004/tcp) echo '0.0.0.0:3124' ;; + xchain-node-xchain-explorer:8080/tcp) echo '0.0.0.0:18080' ;; + xchain-node-xchain-hub:10000/tcp) echo '0.0.0.0:10000' ;; + *) exit 1 ;; +esac +`, { mode: 0o755 }) + const env = Object.assign({}, process.env, { PATH: bin + ':' + process.env.PATH }) + const output = execFileSync(process.execPath, [ENV_WRITER, out, 'bitcoin'], { env, encoding: 'utf8' }) + return { values: parseAssignments(out), output } +} + +describe('rail-leg.yml drive environment service URLs', function () { + it('writes host-reachable BTC and DOGE service URLs without changing generic bitcoin endpoints', function () { + const result = writeDriveEnv() + + expect(result.values).to.include({ + BTC_INDEXER_API_URL: 'http://127.0.0.1:3024', + DOGE_INDEXER_API_URL: 'http://127.0.0.1:3124', + DOGE_ENCODER_URL: 'http://127.0.0.1:3123', + ENCODER_URL: 'localhost', + ENCODER_API_PORT: '3023', + INDEXER_URL: 'localhost', + INDEXER_API_PORT: '3024', + }) + expect(result.output).to.include('BTC_INDEXER_API_URL DOGE_INDEXER_API_URL DOGE_ENCODER_URL') + expect(result.output).to.not.include('http://127.0.0.1') + }) + + it('requires the three URLs in the unconditional setup step before every drive', function () { + const workflow = yaml.load(fs.readFileSync(WORKFLOW, 'utf8')) + const steps = workflow.jobs.leg.steps + const setup = steps.find((step) => step.name === 'Write the host .env for the drive') + const drive = steps.find((step) => typeof step.name === 'string' && step.name.startsWith('Drive ')) + + expect(setup).to.not.equal(undefined) + expect(setup).to.not.have.property('if') + expect(steps.indexOf(setup)).to.be.lessThan(steps.indexOf(drive)) + for (const name of ['BTC_INDEXER_API_URL', 'DOGE_INDEXER_API_URL', 'DOGE_ENCODER_URL']) { + expect(setup.run).to.include(name) + } + }) +}) diff --git a/test/unit/rail_leg_workflow.test/follower_clone_plan.test.js b/test/unit/rail_leg_workflow.test/follower_clone_plan.test.js new file mode 100644 index 00000000..d8dda72d --- /dev/null +++ b/test/unit/rail_leg_workflow.test/follower_clone_plan.test.js @@ -0,0 +1,118 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') + +const { + followerEnvLines, + followerCreateArgs, + cloneStatements, +} = require('../../../scripts/rail_follower_clone') + +describe('rail follower clone environment plan', function () { + it('keeps source configuration order while dropping container defaults and redirecting the database', function () { + const source = [ + 'HOME=/root', + 'INDEXER_DB_USER=indexer', + 'HUB_API_URL=http://hub:10000', + 'HOSTNAME=source-indexer', + 'INDEXER_DB_PASS=secret=value', + 'INDEXER_DB_NAME=source_db', + 'COIN=bitcoin', + 'PATH=/usr/local/bin:/usr/bin', + ] + + expect(followerEnvLines(source, 'follower_db')).to.deep.equal([ + 'INDEXER_DB_USER=indexer', + 'HUB_API_URL=http://hub:10000', + 'INDEXER_DB_PASS=secret=value', + 'COIN=bitcoin', + 'INDEXER_DB_NAME=follower_db', + ]) + expect(source).to.include('INDEXER_DB_NAME=source_db') + }) + + it('rejects source environments without a database', function () { + expect(() => followerEnvLines(['COIN=bitcoin'], 'follower_db')) + .to.throw('no INDEXER_DB_NAME') + }) + + it('rejects empty follower databases', function () { + expect(() => followerEnvLines(['INDEXER_DB_NAME=source_db'], '')) + .to.throw('follower database is required') + }) + + it('rejects invalid follower database identifiers', function () { + expect(() => followerEnvLines(['INDEXER_DB_NAME=source_db'], 'follower-db')) + .to.throw('invalid follower database identifier') + }) + + it('rejects a follower database equal to the source', function () { + expect(() => followerEnvLines(['INDEXER_DB_NAME=source_db'], 'source_db')) + .to.throw('must differ') + }) +}) + +describe('rail follower clone container plan', function () { + it('builds docker create arguments without publishing a port', function () { + const args = followerCreateArgs({ + container: 'follower-indexer', + network: 'node-network', + envFile: '/tmp/follower.env', + image: 'xchain-indexer:test', + }) + expect(args).to.deep.equal([ + 'create', + '--name', 'follower-indexer', + '--network', 'node-network', + '--env-file', '/tmp/follower.env', + 'xchain-indexer:test', + ]) + expect(args).to.not.include('-p') + expect(args).to.not.include('--publish') + }) + + it('rejects every missing docker create field', function () { + const complete = { + container: 'follower-indexer', + network: 'node-network', + envFile: '/tmp/follower.env', + image: 'xchain-indexer:test', + } + for (const field of Object.keys(complete)) { + expect(() => followerCreateArgs({ ...complete, [field]: '' })).to.throw(field + ' is required') + } + }) +}) + +describe('rail follower clone database plan', function () { + it('builds the database and two-table clone statements', function () { + expect(cloneStatements('source_db', 'follower_db', ['blocks', 'transactions'])).to.deep.equal([ + 'DROP DATABASE IF EXISTS `follower_db`;', + 'CREATE DATABASE `follower_db`;', + 'CREATE TABLE `follower_db`.`blocks` LIKE `source_db`.`blocks`;', + 'INSERT INTO `follower_db`.`blocks` SELECT * FROM `source_db`.`blocks`;', + 'CREATE TABLE `follower_db`.`transactions` LIKE `source_db`.`transactions`;', + 'INSERT INTO `follower_db`.`transactions` SELECT * FROM `source_db`.`transactions`;', + ]) + }) + + it('rejects bad SQL identifiers', function () { + expect(() => cloneStatements('source-db', 'follower_db', ['blocks'])).to.throw('source database') + expect(() => cloneStatements('source_db', 'follower-db', ['blocks'])).to.throw('follower database') + expect(() => cloneStatements('source_db', 'follower_db', ['bad-table'])).to.throw('table') + }) + + it('rejects equal clone databases', function () { + expect(() => cloneStatements('source_db', 'source_db', ['blocks'])).to.throw('must differ') + }) +}) diff --git a/test/unit/rail_leg_workflow.test/follower_hash_compare.test.js b/test/unit/rail_leg_workflow.test/follower_hash_compare.test.js new file mode 100644 index 00000000..ef1396ff --- /dev/null +++ b/test/unit/rail_leg_workflow.test/follower_hash_compare.test.js @@ -0,0 +1,158 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { spawnSync } = require('child_process') + +const { + followerHashSql, + armCompareHeights, + compareFollowerHashes, + injectDifference, +} = require('../../../scripts/rail_follower_compare') + +const CLI = path.join(__dirname, '../../../scripts/rail_follower_compare.js') +const FIELDS = ['ledger_hash', 'actions_hash', 'contract_hash', 'state_hash'] + +function row (height, prefix = 'hash') { + return { + block_index: height, + ledger_hash: prefix + '-ledger-' + height, + actions_hash: prefix + '-actions-' + height, + contract_hash: prefix + '-contract-' + height, + state_hash: prefix + '-state-' + height, + } +} + +function runCli (a, b, extra = []) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'follower-hash-compare-')) + const aFile = path.join(dir, 'a.json') + const bFile = path.join(dir, 'b.json') + fs.writeFileSync(aFile, JSON.stringify(a)) + fs.writeFileSync(bFile, JSON.stringify(b)) + return spawnSync(process.execPath, [CLI, '--a', aFile, '--b', bFile, '--arm-height', '10', ...extra], { + encoding: 'utf8', + }) +} + +describe('rail follower hash comparison', function () { + it('builds the four-join hash query', function () { + const sql = followerHashSql() + expect(sql).to.include('b.block_index AS block_index') + for (const field of FIELDS) { + expect(sql).to.include('b.' + field + '_id') + expect(sql).to.include(' AS ' + field) + } + expect(sql).to.include('WHERE b.block_index IN (?, ?, ?)') + expect((sql.match(/JOIN index_transactions/g) || [])).to.have.length(4) + }) + + it('matches hashes at all three armed heights', function () { + const heights = armCompareHeights(10) + const rows = heights.map((height) => row(height)) + expect(heights).to.deep.equal([9, 10, 15]) + expect(compareFollowerHashes(rows, rows, heights)).to.deep.equal({ + ok: true, + heights: heights.map((height) => ({ height, verdict: 'MATCH', fields: [] })), + }) + }) + + it('names one differing hash field', function () { + const a = [row(10)] + const b = [{ ...row(10), actions_hash: 'different' }] + expect(compareFollowerHashes(a, b, [10])).to.deep.equal({ + ok: false, + heights: [{ height: 10, verdict: 'MISMATCH', fields: ['actions_hash'] }], + }) + }) +}) + +describe('rail follower hash comparison edge cases', function () { + it('marks an absent height missing', function () { + expect(compareFollowerHashes([row(10)], [], [10]).heights[0]).to.deep.equal({ + height: 10, + verdict: 'MISSING', + fields: [], + }) + }) + + it('marks an all-null side missing', function () { + const empty = { block_index: 10, ledger_hash: null, actions_hash: null, contract_hash: null, state_hash: null } + expect(compareFollowerHashes([row(10)], [empty], [10]).heights[0].verdict).to.equal('MISSING') + }) + + for (const field of FIELDS) { + it('marks identical rows with a null ' + field + ' missing', function () { + const partial = { ...row(10), [field]: null } + const result = compareFollowerHashes([partial], [partial], [10]) + expect(result.ok).to.equal(false) + expect(result.heights[0].verdict).to.equal('MISSING') + }) + + it('marks a one-sided undefined ' + field + ' missing', function () { + const partial = { ...row(10) } + delete partial[field] + const result = compareFollowerHashes([row(10)], [partial], [10]) + expect(result.ok).to.equal(false) + expect(result.heights[0].verdict).to.equal('MISSING') + }) + } + + it('compares string block indexes to numeric heights', function () { + expect(compareFollowerHashes([{ ...row(10), block_index: '10' }], [row(10)], [10]).ok).to.equal(true) + }) +}) + +describe('rail follower hash comparison CLI', function () { + it('injects a control difference without mutating its input', function () { + const input = [row(10)] + const copy = injectDifference(input, 10) + expect(copy[0].state_hash).to.not.equal(input[0].state_hash) + expect(input).to.deep.equal([row(10)]) + const result = runCli(input, input, ['--control']) + expect(result.status).to.equal(0) + expect(result.stdout).to.include('FOLLOWER 10 MISMATCH') + }) + + it('exits one when the control height is missing', function () { + const rows = [row(9), row(15)] + const result = runCli(rows, rows, ['--control']) + expect(result.status).to.equal(1) + expect(result.stdout).to.include('FOLLOWER 10 MISSING') + }) + + it('rejects a bad arm height', function () { + expect(() => armCompareHeights(0)).to.throw('integer at least 1') + expect(() => armCompareHeights(1.5)).to.throw('integer at least 1') + }) + + it('exits two for a missing flag', function () { + const result = spawnSync(process.execPath, [CLI, '--arm-height', '10'], { encoding: 'utf8' }) + expect(result.status).to.equal(2) + expect(result.stderr).to.include('usage:') + }) + + it('exits two for a malformed file', function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'follower-hash-malformed-')) + const bad = path.join(dir, 'bad.json') + const good = path.join(dir, 'good.json') + fs.writeFileSync(bad, '{') + fs.writeFileSync(good, '[]') + const result = spawnSync(process.execPath, + [CLI, '--a', bad, '--b', good, '--arm-height', '10'], { encoding: 'utf8' }) + expect(result.status).to.equal(2) + expect(result.stderr).to.include('rail_follower_compare:') + }) +}) diff --git a/test/unit/rail_leg_workflow.test/follower_rows_read.test.js b/test/unit/rail_leg_workflow.test/follower_rows_read.test.js new file mode 100644 index 00000000..2ec4d00c --- /dev/null +++ b/test/unit/rail_leg_workflow.test/follower_rows_read.test.js @@ -0,0 +1,100 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const path = require('path') +const { spawnSync } = require('child_process') + +const { followerHashSql } = require('../../../scripts/rail_follower_compare') +const { readFollowerRows } = require('../../../scripts/rail_follower_rows') + +const CLI = path.join(__dirname, '../../../scripts/rail_follower_rows.js') +const REQUIRED = [ + '--host', '127.0.0.1', + '--port', '3306', + '--user', 'reader', + '--password-env', 'FOLLOWER_ROWS_TEST_PASSWORD', + '--database', 'follower', + '--arm-height', '10', + '--out', 'rows.json', +] + +function runCli (args, env = process.env) { + return spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', env }) +} + +describe('rail follower row reader', function () { + it('queries once with the comparison SQL and three armed heights', async function () { + const calls = [] + const query = async (...args) => { + calls.push(args) + return [{ + block_index: 10n, + ledger_hash: 'ledger', + actions_hash: null, + contract_hash: 123, + state_hash: undefined, + ignored: 'value', + }] + } + + expect(await readFollowerRows(query, 10)).to.deep.equal([{ + block_index: 10, + ledger_hash: 'ledger', + actions_hash: null, + contract_hash: '123', + state_hash: null, + }]) + expect(calls).to.deep.equal([[followerHashSql(), [9, 10, 15]]]) + }) + + it('returns an empty list for an empty answer', async function () { + expect(await readFollowerRows(async () => [], 10)).to.deep.equal([]) + }) + + it('rejects a bad arm height before querying', async function () { + let calls = 0 + const query = async () => { calls += 1 } + let failure + try { + await readFollowerRows(query, 0) + } catch (error) { + failure = error + } + expect(failure).to.be.an('error').with.property('message').that.includes('integer at least 1') + expect(calls).to.equal(0) + }) +}) + +describe('rail follower row reader CLI usage', function () { + it('exits two for a missing flag', function () { + const result = runCli(['--arm-height', '10']) + expect(result.status).to.equal(2) + expect(result.stderr).to.include('usage:') + }) + + it('exits two for a bad arm height', function () { + const args = [...REQUIRED] + args[args.indexOf('--arm-height') + 1] = 'not-a-height' + const result = runCli(args) + expect(result.status).to.equal(2) + expect(result.stderr).to.include('arm height') + }) + + it('exits two for an unset password variable', function () { + const env = { ...process.env } + delete env.FOLLOWER_ROWS_TEST_PASSWORD + const result = runCli(REQUIRED, env) + expect(result.status).to.equal(2) + expect(result.stderr).to.include('environment variable is unset') + }) +}) diff --git a/test/unit/rail_leg_workflow.test/hub_anchor_pipeline.test.js b/test/unit/rail_leg_workflow.test/hub_anchor_pipeline.test.js new file mode 100644 index 00000000..28ec8719 --- /dev/null +++ b/test/unit/rail_leg_workflow.test/hub_anchor_pipeline.test.js @@ -0,0 +1,92 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { execFileSync } = require('child_process') +const yaml = require('js-yaml') + +const ROOT = path.join(__dirname, '../../..') +const WORKFLOW = path.join(ROOT, '.github/workflows/rail-leg.yml') +const ENCODER_URL = 'http://xchain-node-dogecoin-regtest-xchain-encoder:3003' +const SIGNER_MODULE = '/XChainHub/operator-signer/signer.js' + +function parseAssignments (file) { + const values = {} + for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { + const match = /^([^=]+)=(.*)$/.exec(line) + if (match) values[match[1]] = match[2] + } + return values +} + +function initializeStep () { + const doc = yaml.load(fs.readFileSync(WORKFLOW, 'utf8')) + const step = doc.jobs.leg.steps.find((candidate) => candidate.name === 'Initialize the validator identity') + if (!step) throw new Error('validator initialization step not found') + return step.run +} + +function runInitialize (values) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rail-hub-anchor-pipeline-')) + const runnerTemp = path.join(dir, 'runner-temp') + const bin = path.join(dir, 'bin') + fs.mkdirSync(runnerTemp) + fs.mkdirSync(bin) + fs.writeFileSync(path.join(bin, 'node'), [ + '#!/usr/bin/env bash', + 'env > "$RUNNER_TEMP/validator-init.env"', + 'printf \'%s\\n\' "$@" > "$RUNNER_TEMP/validator-init.args"', + '', + ].join('\n'), { mode: 0o755 }) + + const env = { + PATH: bin + ':' + process.env.PATH, + RUNNER_TEMP: runnerTemp, + STACKS: 'btc,doge', + } + Object.assign(env, values) + execFileSync('bash', ['-e', '-c', initializeStep()], { cwd: dir, env }) + return { + initEnv: parseAssignments(path.join(runnerTemp, 'validator-init.env')), + initArgs: fs.readFileSync(path.join(runnerTemp, 'validator-init.args'), 'utf8'), + stackEnv: parseAssignments(path.join(runnerTemp, 'stack.env')), + } +} + +describe('rail leg standing hub anchor pipeline', function () { + it('gives every anchor-armed regtest validator the container encoder before identity initialization', function () { + for (const arm of [ + 'XC_ANCHOR_FOLD_REGTEST_ACTIVATION', + 'XC_ANCHOR_STAKE_REGTEST_ACTIVATION', + 'XC_ANCHOR_SLASH_REGTEST_ACTIVATION', + ]) { + const result = runInitialize({ [arm]: '42' }) + expect(result.initArgs, arm).to.include('validator\ninit\n') + expect(result.initEnv.DOGE_ENCODER_URL, arm).to.equal(ENCODER_URL) + expect(result.stackEnv, arm).to.include({ + HUB_NETWORK: 'regtest', + DOGE_ENCODER_URL: ENCODER_URL, + HUB_SIGNER_MODULE: SIGNER_MODULE, + }) + } + }) + + it('does not enable the standing hub anchor pipeline for a non-anchor arm', function () { + const result = runInitialize({ XC_CONTRACTS_REGTEST_ACTIVATION: '42' }) + expect(result.initEnv).to.not.have.property('DOGE_ENCODER_URL') + expect(result.stackEnv).to.not.have.property('DOGE_ENCODER_URL') + expect(result.stackEnv).to.not.have.property('HUB_SIGNER_MODULE') + }) +}) diff --git a/test/unit/rail_leg_workflow.test/hub_llm_credential.test.js b/test/unit/rail_leg_workflow.test/hub_llm_credential.test.js new file mode 100644 index 00000000..fff38d27 --- /dev/null +++ b/test/unit/rail_leg_workflow.test/hub_llm_credential.test.js @@ -0,0 +1,116 @@ +'use strict' + +// Copyright (c) 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. + +// Pin that the regtest rail's LLM credential reaches only the step running the leg, +// only when the dispatch asks for it, and is masked and never printed. + +const { expect } = require('chai') +const fs = require('fs') +const path = require('path') +const yaml = require('js-yaml') + +const ROOT = path.join(__dirname, '../../..') +const WORKFLOW = path.join(ROOT, '.github/workflows/attest-mirror-leg.yml') +const SECRET = 'HUB_CLAUDE_CODE_OAUTH_TOKEN' +const SECRET_REF = 'secrets.' + SECRET +const CLI_PACKAGE = '@anthropic-ai/claude-code' + +function load () { + const text = fs.readFileSync(WORKFLOW, 'utf8') + return { text, workflow: yaml.load(text) } +} + +function legSteps (workflow) { + return workflow.jobs.leg.steps +} + +function runStep (workflow) { + const step = legSteps(workflow).find((s) => /^Run /.test(s.name || '')) + expect(step, 'the leg job has a Run step').to.exist + return step +} + +describe('attest-mirror-leg.yml hub llm credential gating', function () { + it('declares hub_llm as a boolean dispatch input that is off by default', function () { + const { workflow } = load() + const input = workflow.on.workflow_dispatch.inputs.hub_llm + expect(input).to.include({ type: 'boolean', default: false }) + }) + + it('references the secret exactly once, in the Run step env, gated on hub_llm', function () { + const { text, workflow } = load() + expect(text.split(SECRET_REF).length - 1).to.equal(1) + const env = runStep(workflow).env + expect(env[SECRET]).to.equal("${{ inputs.hub_llm == true && secrets.HUB_CLAUDE_CODE_OAUTH_TOKEN || '' }}") + expect(env.HUB_CLAUDE_CONFIG_DIR).to.match(/^\$\{\{ inputs\.hub_llm == true && .*\|\| '' \}\}$/) + }) + + it('never puts the secret in workflow-level, job-level or any other step env', function () { + const { workflow } = load() + expect(JSON.stringify(workflow.env || {})).to.not.include(SECRET) + for (const [name, job] of Object.entries(workflow.jobs)) { + expect(JSON.stringify(job.env || {}), 'job ' + name).to.not.include(SECRET) + for (const step of job.steps || []) { + if (name === 'leg' && step === runStep(workflow)) continue + expect(JSON.stringify(step.env || {}), 'step ' + (step.name || step.uses)).to.not.include(SECRET) + expect(JSON.stringify(step.with || {}), 'step ' + (step.name || step.uses)).to.not.include(SECRET) + } + } + }) + +}) + +describe('attest-mirror-leg.yml hub llm credential masking and placement', function () { + it('masks the token before any leg output and never echoes or writes it anywhere else', function () { + const { workflow } = load() + const script = String(runStep(workflow).run) + const firstLine = script.split('\n')[0] + expect(firstLine).to.equal('if [ -n "$HUB_CLAUDE_CODE_OAUTH_TOKEN" ]; then echo "::add-mask::$HUB_CLAUDE_CODE_OAUTH_TOKEN"; fi') + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps || []) { + const lines = String(step.run || '').split('\n').filter((l) => l.includes(SECRET)) + for (const line of lines) { + expect(line, 'only the mask line may name the token in a script').to.equal(firstLine) + } + } + } + expect(script).to.not.match(/HUB_CLAUDE_CODE_OAUTH_TOKEN.*(GITHUB_ENV|GITHUB_OUTPUT)/) + }) + + it('installs the hub CLI and makes the hub config dir only when hub_llm is on', function () { + const { workflow } = load() + const steps = legSteps(workflow) + const install = steps.find((s) => /hub llm CLI/.test(s.name || '')) + expect(install, 'an install step for the hub llm CLI').to.exist + expect(install.if).to.equal('inputs.hub_llm == true') + expect(install.run).to.include(CLI_PACKAGE + '@2.1.266') + expect(install.run).to.include('mkdir -m 700 -p "$RUNNER_TEMP/hub-claude"') + expect(install.run).to.not.include(SECRET) + expect(steps.indexOf(install)).to.be.below(steps.indexOf(runStep(workflow))) + }) + + it('keeps the hub config dir out of the uploaded leg logs', function () { + const { workflow } = load() + const upload = legSteps(workflow).find((s) => /upload-artifact/.test(s.uses || '')) + expect(upload.with.path).to.equal('${{ runner.temp }}/am-logs/') + expect(runStep(workflow).env.HUB_CLAUDE_CONFIG_DIR).to.include("format('{0}/hub-claude', runner.temp)") + }) + + it('pins the CLI to the version the hub image installs', function () { + const dockerfile = path.join(ROOT, '..', 'xchain-hub', 'Dockerfile') + if (!fs.existsSync(dockerfile)) this.skip() + const pinned = fs.readFileSync(dockerfile, 'utf8').match(new RegExp(CLI_PACKAGE + '@([0-9.]+)')) + expect(pinned, 'the hub Dockerfile pins the CLI').to.exist + const install = legSteps(load().workflow).find((s) => /hub llm CLI/.test(s.name || '')) + expect(install.run).to.include(CLI_PACKAGE + '@' + pinned[1]) + }) +}) diff --git a/test/unit/rail_leg_workflow.test/hub_llm_probe.test.js b/test/unit/rail_leg_workflow.test/hub_llm_probe.test.js new file mode 100644 index 00000000..060ffd9a --- /dev/null +++ b/test/unit/rail_leg_workflow.test/hub_llm_probe.test.js @@ -0,0 +1,79 @@ +'use strict' + +// Copyright (c) 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const fs = require('fs') +const os = require('os') +const path = require('path') + +const PROBE = path.join(__dirname, '../../../.github/attest-mirror/hub-llm-probe.js') +const { probeArgs, probeEnv, describe: describeResult, main } = require(PROBE) + +const TOKEN = 'sk-ant-oat01-unit-test-value' + +// A stand-in CLI that answers with the token it was given inside its error text. +function fakeCli (exitCode) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hub-llm-probe-')) + const bin = path.join(dir, 'claude') + fs.writeFileSync(bin, '#!/bin/sh\ncat > /dev/null\n' + + 'printf \'{"is_error":true,"subtype":"success","result":"bad token %s for %s"}\' ' + + '"$CLAUDE_CODE_OAUTH_TOKEN" "$CLAUDE_CONFIG_DIR"\nexit ' + exitCode + '\n', { mode: 0o755 }) + return bin +} + +function capture (fn) { + const lines = [] + const original = console.log + console.log = (line) => { lines.push(String(line)) } + try { return { code: fn(), lines } } finally { console.log = original } +} + +describe('attest-mirror hub-llm-probe.js', function () { + // A freshly written executable can take seconds to first exec on a Mac. + this.timeout(60000) + + it('uses the hub spawn argv with no tools and no settings', function () { + expect(probeArgs('m')).to.deep.equal(['--print', '--output-format', 'json', '--model', 'm', + '--tools', '', '--no-session-persistence', '--setting-sources', '']) + }) + + it('hands the CLI the hub token and config dir and drops ambient credentials', function () { + const env = probeEnv({ HUB_CLAUDE_CODE_OAUTH_TOKEN: TOKEN, HUB_CLAUDE_CONFIG_DIR: '/d', + ANTHROPIC_API_KEY: 'ambient', CLAUDE_CONFIG_DIR: '/human', PATH: '/bin' }) + expect(env).to.include({ CLAUDE_CODE_OAUTH_TOKEN: TOKEN, CLAUDE_CONFIG_DIR: '/d', PATH: '/bin' }) + expect(env).to.not.have.property('ANTHROPIC_API_KEY') + }) + + it('skips with exit 0 when the leg carries no hub credential', function () { + const out = capture(() => main({ PATH: process.env.PATH })) + expect(out.code).to.equal(0) + expect(out.lines.join('\n')).to.include('skipped') + }) + + it('prints the CLI verdict with the token redacted and exits 1 on a failed turn', function () { + const out = capture(() => main({ PATH: process.env.PATH, CLAUDE_BIN: fakeCli(1), + HUB_CLAUDE_CODE_OAUTH_TOKEN: TOKEN, HUB_CLAUDE_CONFIG_DIR: '/hub-dir' })) + const text = out.lines.join('\n') + expect(out.code).to.equal(1) + expect(text).to.include('exit 1').and.include('"is_error":true').and.include('bad token *** for /hub-dir') + expect(text).to.include('credential length ' + TOKEN.length).and.include('oauth-token form true') + expect(text).to.not.include(TOKEN) + }) + + it('exits 0 on a sound turn and redacts non-JSON output too', function () { + expect(capture(() => main({ PATH: process.env.PATH, CLAUDE_BIN: fakeCli(0), + HUB_CLAUDE_CODE_OAUTH_TOKEN: TOKEN, HUB_CLAUDE_CONFIG_DIR: '/d' })).code).to.equal(0) + const line = describeResult({ status: 1, stdout: 'raw ' + TOKEN, stderr: 'err ' + TOKEN }, TOKEN) + expect(line).to.not.include(TOKEN) + expect(line).to.include('raw ***').and.include('err ***') + }) +}) diff --git a/test/unit/rail_leg_workflow.test/leg_expect_values.test.js b/test/unit/rail_leg_workflow.test/leg_expect_values.test.js new file mode 100644 index 00000000..a66a6186 --- /dev/null +++ b/test/unit/rail_leg_workflow.test/leg_expect_values.test.js @@ -0,0 +1,140 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const fs = require('fs') +const path = require('path') +const yaml = require('js-yaml') +const { writeLegExpectEnv } = require('../../../scripts/rail_leg_env') + +const ROOT = path.join(__dirname, '../../..') +const WORKFLOW = path.join(ROOT, '.github/workflows/rail-leg.yml') +const EXPECT_ENVS = [ + 'XC_E2E_PRICE_FEE_BATCH_LANDED', + 'XC_VOTE_CALLBACK_BINDING_EXPECT', + 'XC_JSON_STRINGIFY_HOOK_EXPECT', +] + +function resolveExpect (input) { + const out = '/tmp/rail-leg-expect.env' + const writeFileSync = fs.writeFileSync + const log = console.log + let written + let error + fs.writeFileSync = (file, contents, options) => { written = { file, contents, options } } + console.log = () => {} + try { + writeLegExpectEnv(out, JSON.stringify(input)) + } catch (e) { + error = e + } finally { + fs.writeFileSync = writeFileSync + console.log = log + } + return { error, written } +} + +function parseAssignments (contents) { + const values = {} + for (const line of contents.split(/\r?\n/)) { + const match = /^([^=]+)=(.*)$/.exec(line) + if (match) values[match[1]] = match[2] + } + return values +} + +function loadWorkflow () { + return yaml.load(fs.readFileSync(WORKFLOW, 'utf8')) +} + +function findStep (doc, name) { + const step = doc.jobs.leg.steps.find((candidate) => candidate.name === name) + if (!step) throw new Error('workflow step not found: ' + name) + return step +} + +describe('rail leg expect resolver', function () { + for (const input of [null, {}]) { + it('writes an empty file for ' + JSON.stringify(input), function () { + const { error, written } = resolveExpect(input) + expect(error).to.equal(undefined) + expect(written).to.deep.equal({ + file: '/tmp/rail-leg-expect.env', + contents: '', + options: { mode: 0o600 }, + }) + }) + } + + for (const [name, value] of [ + [EXPECT_ENVS[0], 'off'], + [EXPECT_ENVS[1], 'inert'], + [EXPECT_ENVS[2], 'armed'], + ]) { + it('writes ' + name + ' independently', function () { + const { error, written } = resolveExpect({ [name]: value }) + expect(error).to.equal(undefined) + expect(parseAssignments(written.contents)).to.deep.equal({ [name]: value }) + }) + } + + it('writes all three values in allowlist order', function () { + const values = { + XC_JSON_STRINGIFY_HOOK_EXPECT: 'inert', + XC_E2E_PRICE_FEE_BATCH_LANDED: 'armed', + XC_VOTE_CALLBACK_BINDING_EXPECT: 'armed', + } + const { error, written } = resolveExpect(values) + expect(error).to.equal(undefined) + expect(written.contents).to.equal([ + 'XC_E2E_PRICE_FEE_BATCH_LANDED=armed', + 'XC_VOTE_CALLBACK_BINDING_EXPECT=armed', + 'XC_JSON_STRINGIFY_HOOK_EXPECT=inert', + '', + ].join('\n')) + }) + + it('rejects an unknown name and names it', function () { + const { error } = resolveExpect({ XC_UNKNOWN_EXPECT: 'armed' }) + expect(error).to.be.an('error').with.property('message').that.includes('XC_UNKNOWN_EXPECT') + }) + + it('rejects a bad value and names its key', function () { + const name = 'XC_E2E_PRICE_FEE_BATCH_LANDED' + const { error } = resolveExpect({ [name]: 'inert' }) + expect(error).to.be.an('error').with.property('message').that.includes(name) + }) + + it('rejects a JSON array', function () { + const { error } = resolveExpect([]) + expect(error).to.be.an('error').with.property('message').that.includes('must be a JSON object') + }) +}) + +describe('rail-leg.yml leg expect values', function () { + it('keeps expect optional in the plan check', function () { + const doc = loadWorkflow() + const plan = doc.jobs.plan.steps.find((step) => step.id === 'legs') + expect(plan.run).to.include('["row","drive","leg","e2e_ref"]') + expect(plan.run).to.not.include('["row","drive","leg","e2e_ref","expect"]') + }) + + it('resolves matrix.expect into the job environment after the R2 arms', function () { + const doc = loadWorkflow() + const resolveStep = findStep(doc, 'Resolve the leg expect values') + expect(resolveStep.env.LEG_EXPECT_JSON).to.equal('${{ toJSON(matrix.expect) }}') + expect(resolveStep.run).to.include('rail_leg_env.js --resolve-leg-expect') + expect(resolveStep.run).to.include('>> "$GITHUB_ENV"') + const steps = doc.jobs.leg.steps + expect(steps.indexOf(resolveStep)).to.equal(steps.indexOf(findStep(doc, 'Resolve the R2 arm values')) + 1) + }) +}) diff --git a/test/unit/rail_leg_workflow.test/mirror_admission.test.js b/test/unit/rail_leg_workflow.test/mirror_admission.test.js index 01f58b6a..fb81eb59 100644 --- a/test/unit/rail_leg_workflow.test/mirror_admission.test.js +++ b/test/unit/rail_leg_workflow.test/mirror_admission.test.js @@ -79,7 +79,8 @@ function driveBootStep (activation) { }, encoding: 'utf8', }) - return fs.readFileSync(log, 'utf8').trim().split('\n') + // Only the stack installs carry the arm; helper scripts between them are not graded here. + return fs.readFileSync(log, 'utf8').trim().split('\n').filter((call) => / install /.test(call)) } describe('rail-leg.yml mirror admission', function () { diff --git a/test/unit/rail_leg_workflow.test/publisher_funding.test.js b/test/unit/rail_leg_workflow.test/publisher_funding.test.js new file mode 100644 index 00000000..aea7bbcf --- /dev/null +++ b/test/unit/rail_leg_workflow.test/publisher_funding.test.js @@ -0,0 +1,156 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') + +const { + ARM_ENV_NAMES, + publisherFundingPlan, + fundPublisher, + main, +} = require('../../../scripts/rail_fund_publisher') + +const WALLET = { + network: 'regtest', + dogeAddress: 'nZpublisherAddress', +} + +function planFor (env, wallet = WALLET) { + return publisherFundingPlan({ + env, + wallet, + minerPort: 3125, + amount: 100, + }) +} + +function recordingPost () { + const calls = [] + return { + calls, + post: async (...args) => { + calls.push(args) + return { data: { result: 'ok' } } + }, + } +} + +describe('rail publisher funding plan', function () { + it('does nothing when no anchor arm is set', async function () { + expect(planFor({})).to.equal(null) + let reads = 0 + const result = await main({ + env: {}, + readWallets: () => { reads += 1 }, + readFileSync: () => { reads += 1 }, + post: () => { reads += 1 }, + }) + expect(result).to.equal(null) + expect(reads).to.equal(0) + }) + + for (const arm of ARM_ENV_NAMES) { + it('funds and mines when only ' + arm + ' is set', async function () { + const plan = planFor({ [arm]: '42' }) + expect(plan).to.deep.equal({ + address: WALLET.dogeAddress, + amount: 100, + minerUrl: 'http://127.0.0.1:3125', + }) + + const request = recordingPost() + await fundPublisher(plan, { post: request.post }) + expect(request.calls).to.have.length(2) + expect(request.calls[0]).to.deep.equal([ + plan.minerUrl, + { + jsonrpc: '2.0', + id: 1, + method: 'send_funds', + params: { address: WALLET.dogeAddress, amount: 100 }, + }, + ]) + expect(request.calls[1]).to.deep.equal([ + plan.minerUrl, + { + jsonrpc: '2.0', + id: 2, + method: 'generate_blocks', + params: { count: 1 }, + }, + ]) + }) + } + + it('rejects a non-regtest wallet and names its network field', function () { + expect(() => planFor({ [ARM_ENV_NAMES[0]]: '42' }, { + network: 'mainnet', + dogeAddress: WALLET.dogeAddress, + })).to.throw('wallet.network') + }) + + it('rejects a missing publisher address and names its field', function () { + expect(() => planFor({ [ARM_ENV_NAMES[0]]: '42' }, { + network: 'regtest', + })).to.throw('wallet.dogeAddress') + }) +}) + +describe('rail publisher funding execution', function () { + it('rejects a failing send_funds call by method and does not mine', async function () { + const methods = [] + let message + try { + await fundPublisher(planFor({ [ARM_ENV_NAMES[0]]: '42' }), { + post: async (url, body) => { + methods.push(body.method) + throw new Error('miner unavailable') + }, + }) + } catch (error) { + message = error.message + } + expect(message).to.include('send_funds') + expect(methods).to.deep.equal(['send_funds']) + }) + + it('treats JSON-RPC errors as a method failure', async function () { + let message + try { + await fundPublisher(planFor({ [ARM_ENV_NAMES[0]]: '42' }), { + post: async () => ({ data: { error: { message: 'rejected' } } }), + }) + } catch (error) { + message = error.message + } + expect(message).to.include('send_funds') + expect(message).to.include('rejected') + }) + + it('uses only public wallet information and never exposes a WIF', async function () { + const secret = 'cVprivateWifMustNotAppear' + const request = recordingPost() + const result = await main({ + env: { [ARM_ENV_NAMES[0]]: '42' }, + readWallets: () => ({ + NETWORK: 'regtest', + DOGE_ADDRESS: WALLET.dogeAddress, + DOGE_WIF_SECRET: secret, + }), + readFileSync: () => 'REGTEST_MINER_PORT=3125\n', + post: request.post, + }) + const observable = JSON.stringify({ result, calls: request.calls }) + expect(observable).to.not.include(secret) + expect(request.calls[0][1].params.address).to.equal(WALLET.dogeAddress) + }) +}) diff --git a/test/unit/rail_leg_workflow.test/publisher_funding_wiring.test.js b/test/unit/rail_leg_workflow.test/publisher_funding_wiring.test.js new file mode 100644 index 00000000..7cad7305 --- /dev/null +++ b/test/unit/rail_leg_workflow.test/publisher_funding_wiring.test.js @@ -0,0 +1,78 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// The boot step must fund the standing hub's DOGE publisher after the DOGE stack +// (and its regtest miner) is up and before the BTC stack can produce checkpoints +// that call for an anchor. A stub `node` records each invocation in order. + +const { expect } = require('chai') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { execFileSync } = require('child_process') +const yaml = require('js-yaml') + +const ROOT = path.join(__dirname, '../../..') +const WORKFLOW = path.join(ROOT, '.github/workflows/rail-leg.yml') + +function bootStep () { + const doc = yaml.load(fs.readFileSync(WORKFLOW, 'utf8')) + const step = doc.jobs.leg.steps.find((candidate) => /^Boot the two regtest stacks/.test(candidate.name)) + if (!step) throw new Error('boot step not found') + return step.run +} + +function runBoot (stacks) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rail-publisher-wiring-')) + const runnerTemp = path.join(dir, 'runner-temp') + const bin = path.join(dir, 'bin') + fs.mkdirSync(runnerTemp) + fs.mkdirSync(bin) + fs.writeFileSync(path.join(runnerTemp, 'stack.env'), 'HUB_NETWORK=regtest\n') + const stub = ['#!/usr/bin/env bash', 'echo "$*" >> "$RUNNER_TEMP/calls.log"', ''] + fs.writeFileSync(path.join(bin, 'node'), stub.join('\n'), { mode: 0o755 }) + fs.writeFileSync(path.join(bin, 'docker'), '#!/usr/bin/env bash\n', { mode: 0o755 }) + const env = { + PATH: bin + ':' + process.env.PATH, + RUNNER_TEMP: runnerTemp, + STACK_REF: 'develop', + STACKS: stacks, + XCHAIN_NODE_DATA_DIR: path.join(dir, 'data'), + } + execFileSync('bash', ['-e', '-c', bootStep()], { cwd: dir, env }) + return fs.readFileSync(path.join(runnerTemp, 'calls.log'), 'utf8').trim().split('\n') +} + +describe('rail leg publisher funding wiring', function () { + it('funds the publisher after the DOGE stack and before the BTC stack', function () { + const calls = runBoot('btc,doge') + const doge = calls.indexOf('src/index.js install develop all dogecoin regtest') + const fund = calls.indexOf('scripts/rail_fund_publisher.js') + const btc = calls.indexOf('src/index.js install develop all bitcoin regtest') + expect(doge, calls.join(' | ')).to.be.at.least(0) + expect(fund).to.equal(doge + 1) + expect(btc).to.equal(fund + 1) + }) + + it('calls the funding script exactly once with an LTC stack beside them', function () { + const calls = runBoot('btc,doge,ltc') + expect(calls.filter((call) => call === 'scripts/rail_fund_publisher.js')).to.have.length(1) + expect(calls[calls.length - 1]).to.equal('src/index.js install develop all litecoin regtest') + }) + + it('names a script that exists and exits quietly with no anchor arm set', function () { + const script = path.join(ROOT, 'scripts/rail_fund_publisher.js') + expect(fs.existsSync(script)).to.equal(true) + const env = { PATH: process.env.PATH } + expect(execFileSync(process.execPath, [script], { env, encoding: 'utf8' })).to.equal('') + }) +}) diff --git a/test/unit/rail_leg_workflow.test/r2_arm_values.test.js b/test/unit/rail_leg_workflow.test/r2_arm_values.test.js new file mode 100644 index 00000000..b9520121 --- /dev/null +++ b/test/unit/rail_leg_workflow.test/r2_arm_values.test.js @@ -0,0 +1,154 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { execFileSync, spawnSync } = require('child_process') +const yaml = require('js-yaml') + +const ROOT = path.join(__dirname, '../../..') +const WORKFLOW = path.join(ROOT, '.github/workflows/rail-leg.yml') +const ENV_WRITER = path.join(ROOT, 'scripts/rail_leg_env.js') +const R2_ARM_ENVS = [ + 'XC_AMOUNTS_PRICE_REGTEST_ACTIVATION', + 'XC_AMOUNTS_PRICE_REGTEST_TIME', + 'XC_CONTRACTS_REGTEST_ACTIVATION', +] + +function parseAssignments (file) { + const values = {} + for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { + const match = /^([^=]+)=(.*)$/.exec(line) + if (match) values[match[1]] = match[2] + } + return values +} + +function resolveArms (values) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rail-leg-r2-arms-')) + const out = path.join(dir, 'arms.env') + const env = Object.assign({}, process.env) + for (const name of R2_ARM_ENVS) delete env[name] + Object.assign(env, values) + const result = spawnSync(process.execPath, [ENV_WRITER, '--resolve-r2-arms', out], { + env, + encoding: 'utf8', + }) + return { out, result } +} + +function writeDriveEnv (values) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rail-leg-r2-drive-')) + const bin = path.join(dir, 'stub-bin') + const out = path.join(dir, '.env') + fs.mkdirSync(bin) + fs.writeFileSync(path.join(bin, 'docker'), '#!/usr/bin/env bash\nif [ "$1" = port ]; then echo "127.0.0.1:3020"; else echo "[]"; fi\n', { mode: 0o755 }) + const env = Object.assign({}, process.env, { PATH: bin + ':' + process.env.PATH }) + for (const name of R2_ARM_ENVS) delete env[name] + Object.assign(env, values) + const output = execFileSync(process.execPath, [ENV_WRITER, out, 'bitcoin'], { env, encoding: 'utf8' }) + return { out, output } +} + +function loadWorkflow () { + return yaml.load(fs.readFileSync(WORKFLOW, 'utf8')) +} + +function findStep (doc, name) { + const step = doc.jobs.leg.steps.find((candidate) => candidate.name === name) + if (!step) throw new Error('workflow step not found: ' + name) + return step +} + +describe('rail leg R2 arm resolver', function () { + it('writes an empty file when no value is set', function () { + const { out, result } = resolveArms({}) + expect(result.status, result.stderr).to.equal(0) + expect(fs.readFileSync(out, 'utf8')).to.equal('') + }) + + for (const [name, value] of [ + [R2_ARM_ENVS[0], '21'], + [R2_ARM_ENVS[1], '1700000000'], + [R2_ARM_ENVS[2], '34'], + ]) { + it('writes ' + name + ' independently', function () { + const { out, result } = resolveArms({ [name]: value }) + expect(result.status, result.stderr).to.equal(0) + expect(parseAssignments(out)).to.deep.equal({ [name]: value }) + expect(result.stdout).to.not.include(value) + }) + } + + it('keeps all configured values independent', function () { + const values = { + XC_AMOUNTS_PRICE_REGTEST_ACTIVATION: '55112233445511223344', + XC_AMOUNTS_PRICE_REGTEST_TIME: '66223344556622334455', + XC_CONTRACTS_REGTEST_ACTIVATION: '77334455667733445566', + } + const { out, result } = resolveArms(values) + expect(result.status, result.stderr).to.equal(0) + expect(parseAssignments(out)).to.deep.equal(values) + for (const value of Object.values(values)) expect(result.stdout).to.not.include(value) + }) + + it('writes the same values into the drive environment without logging them', function () { + const values = { + XC_AMOUNTS_PRICE_REGTEST_ACTIVATION: '88445566778844556677', + XC_AMOUNTS_PRICE_REGTEST_TIME: '99556677889955667788', + XC_CONTRACTS_REGTEST_ACTIVATION: '11667788991166778899', + } + const { out, output } = writeDriveEnv(values) + expect(parseAssignments(out)).to.include(values) + for (const value of Object.values(values)) expect(output).to.not.include(value) + }) + + it('rejects a bad value by name without printing its value', function () { + const badValue = 'invalid-r2-value' + const name = R2_ARM_ENVS[1] + const { result } = resolveArms({ [name]: badValue }) + expect(result.status).to.not.equal(0) + expect(result.stderr).to.include(name) + expect(result.stderr).to.include('must be a non-negative integer') + expect(result.stdout + result.stderr).to.not.include(badValue) + }) +}) + +describe('rail-leg.yml R2 arm values', function () { + it('declares ten inputs and resolves the three optional R2 values beside the anchor step', function () { + const doc = loadWorkflow() + const inputs = doc.on.workflow_dispatch.inputs + expect(Object.keys(inputs)).to.have.length(10) + const resolveStep = findStep(doc, 'Resolve the R2 arm values') + for (const name of R2_ARM_ENVS) { + expect(inputs[name].required, name).to.equal(false) + expect(resolveStep.env[name], name).to.equal('${{ inputs.' + name + ' }}') + } + expect(resolveStep.run).to.include('rail_leg_env.js --resolve-r2-arms') + expect(resolveStep.run).to.include('>> "$GITHUB_ENV"') + const steps = doc.jobs.leg.steps + expect(steps.indexOf(resolveStep)).to.equal(steps.indexOf(findStep(doc, 'Resolve the anchor arm height')) + 1) + }) + + it('names every R2 value in both standing stack configuration loops', function () { + const doc = loadWorkflow() + const runs = [ + findStep(doc, 'Initialize the validator identity').run, + findStep(doc, 'Publish the two-stack host ports').run, + ] + for (const run of runs) { + for (const name of R2_ARM_ENVS) expect(run, name).to.include(name) + } + }) +}) diff --git a/test/unit/validator_stake_service.test/stake_amount_option.test.js b/test/unit/validator_stake_service.test/stake_amount_option.test.js new file mode 100644 index 00000000..96921ac4 --- /dev/null +++ b/test/unit/validator_stake_service.test/stake_amount_option.test.js @@ -0,0 +1,59 @@ +'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. + +// --amount is a free string from the command line: the default applies only when it is absent, and +// anything that is not whole XCHAIN above 0 refuses instead of being truncated or replaced. + +const { expect } = require('chai') + +const { run } = require('../../helpers/stake_harness') + +// Run a stake and hand back the refusal, or null with the run when it went through. +async function attempt(opts, chain) { + try { + return { err: null, ...(await run(opts, chain)) } + } catch (err) { + return { err, result: null, calls: null, logged: [] } + } +} + +describe('ValidatorStakeService', function () { + + describe('stakeValidator() --amount', function () { + + it('stakes the 25000 default when --amount is absent', async function () { + const { err, calls } = await attempt({ broadcast: true }, { xchain: 30000 }) + expect(err).to.equal(null) + expect(calls.stake).to.have.length(1) + expect(calls.stake[0].params.AMOUNT).to.equal('25000') + }) + + it('stakes exactly the whole amount asked for', async function () { + const { err, calls, logged } = await attempt({ amount: '1500', broadcast: true }, { xchain: 30000 }) + expect(err).to.equal(null) + expect(calls.stake[0].params.AMOUNT).to.equal('1500') + expect(logged.join('\n')).to.include('STAKE v1: 1500 XCHAIN') + }) + + it('refuses a malformed amount, dry run or --broadcast, and sends nothing', async function () { + for (const amount of ['1500.5', '25,000', '0', 'abc', '', '1e3', '-5', '0x10', '99999999999999999999']) { + for (const opts of [{ amount }, { amount, broadcast: true }]) { + const { err } = await attempt(opts, { xchain: 0 }) + expect(err, JSON.stringify(opts)).to.exist + expect(err.message).to.include('--amount must be a whole number of XCHAIN above 0') + expect(err.message).to.include('(got "' + amount + '")') + expect(err.message).to.include('Nothing was sent') + } + } + }) + }) +}) diff --git a/test/unit/validator_stake_service.test/unstake_pending_top_up.test.js b/test/unit/validator_stake_service.test/unstake_pending_top_up.test.js new file mode 100644 index 00000000..b482c270 --- /dev/null +++ b/test/unit/validator_stake_service.test/unstake_pending_top_up.test.js @@ -0,0 +1,90 @@ +'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. + +// An owned top-up still inside its activation delay, beside an active stake. The indexer's UNSTAKE +// deactivates only rows already active, so the top-up activates afterwards and keeps the key in N. + +const { expect } = require('chai') + +const { unstakeValidator } = require('../../../src/services/validator_stake_service') +const { PUBKEY, ADDRESS, makeSdk } = require('../../helpers/stake_harness') + +const TIP = 200000 +const ACTIVE = { status: 'valid', signing_pubkey: PUBKEY, source: ADDRESS, amount: '25000', action_index: '44', + activation_block: '199000' } +const topUp = (action_index, amount, activation_block) => + ({ ...ACTIVE, action_index, amount, activation_block: String(activation_block) }) + +// Run unstakeValidator against the shared fake SDK; resolves to the refusal, or to the run when it did not refuse. +async function runUnstake(opts, stakeRows) { + const { sdk } = makeSdk({ existing: ACTIVE, stakeRows, tip: TIP }) + 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(a.params); return inner(a, e, o) } + return s + } + const logged = [] + const deps = { + settings: { enabled: true, pubkey: PUBKEY, network: 'testnet', P2P_PORT: 10002 }, + wallets: { NETWORK: 'testnet', STAKE_ADDRESS: ADDRESS, STAKE_WIF_SECRET: 'cFakeWif' }, + makeSdk: () => sdk, + sdk: {}, + log: m => logged.push(String(m)) + } + try { + const result = await unstakeValidator(opts, deps) + return { err: null, result, unstakeCalls, logged } + } catch (err) { + return { err, result: null, unstakeCalls, logged } + } +} + +describe('ValidatorStakeService', function () { + + describe('unstakeValidator() with an owned top-up still pending', function () { + + it('refuses a --broadcast run and names the pending row', async function () { + const { err, unstakeCalls } = await runUnstake({ broadcast: true }, [topUp('61', '5000', TIP + 4)]) + expect(err, 'a partial exit must refuse').to.exist + expect(unstakeCalls).to.have.length(0) + expect(err.message).to.match(/not active yet/) + expect(err.message).to.include('action 61') + expect(err.message).to.include('5000 XCHAIN') + expect(err.message).to.include('block ' + (TIP + 4)) + expect(err.message).to.include('Nothing was sent') + }) + + it('refuses the dry run the same way, before printing a plan', async function () { + const { err, logged } = await runUnstake({}, [topUp('61', '5000', TIP + 4)]) + expect(err).to.exist + expect(err.message).to.match(/not active yet/) + expect(logged.join('\n')).to.not.include('Dry run') + }) + + it('names the LAST pending activation as the block a re-run covers everything from', async function () { + const { err } = await runUnstake({ broadcast: true }, [topUp('61', '5000', TIP + 2), topUp('62', '1000', TIP + 5)]) + expect(err).to.exist + expect(err.message).to.include('can land from block ' + (TIP + 5)) + expect(err.message).to.include('action 62') + }) + + it('withdraws the whole stake once the top-up has activated', async function () { + const { err, unstakeCalls, logged } = await runUnstake({ broadcast: true }, [topUp('61', '5000', TIP + 1)]) + expect(err).to.equal(null) + expect(unstakeCalls).to.have.length(1) + expect(logged.join('\n')).to.include('active stake : 30000 XCHAIN (action 44, 61') + }) + }) +})