From e08d36b09c33666b5e3a62f476f8d6d9ebdf3047 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 09:08:05 -0700 Subject: [PATCH 01/50] Refuse cross-major release downgrades Guard carrier self-update and manifest target resolution before mutation. Add a loud environment override and focused boundary coverage. --- src/services/release_manifest_service.js | 13 ++- src/services/self_update_service.js | 35 +++++- test/unit/downgrade_floor.test.js | 135 +++++++++++++++++++++++ 3 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 test/unit/downgrade_floor.test.js diff --git a/src/services/release_manifest_service.js b/src/services/release_manifest_service.js index 01fba236..10a40e15 100644 --- a/src/services/release_manifest_service.js +++ b/src/services/release_manifest_service.js @@ -29,9 +29,12 @@ const fs = require('fs') const path = require('path') const axios = require('axios') +const { version: installedNodeVersion } = require('../../package.json') const { githubApiHeaders, githubRateLimitError, GITHUB_API_TIMEOUT_MS, GITHUB_DOWNLOAD_TIMEOUT_MS } = require('../utils/github_api') const { verifyManifestForTag } = require('./release_signature_service') +const { allowMajorDowngradeForRun, assertMajorDowngradeAllowed } = require('./self_update_service') +const { hostEnv } = require('../config') const { getLogger } = require('../observability/logger'); const logger = getLogger(); @@ -254,9 +257,15 @@ async function resolveLatestReleaseTag() { * @returns {Promise<{kind:'release'|'branch', ref:string, tag:string|null, * manifest:object|null, resolvedFrom:string}>} */ -async function resolveInstallTarget(ref, { defaultBranch = 'master', fallbackToBranch = true } = {}) { +async function resolveInstallTarget(ref, { + defaultBranch = 'master', fallbackToBranch = true, env = hostEnv(), + logger: resolutionLogger = logger, installedVersion = installedNodeVersion +} = {}) { + const allowMajorDowngrade = allowMajorDowngradeForRun({ env, logger: resolutionLogger }) + if (isReleaseRef(ref)) { const tag = ref.trim() + assertMajorDowngradeAllowed(installedVersion, tag, allowMajorDowngrade) return { kind: 'release', ref: tag, @@ -290,6 +299,8 @@ async function resolveInstallTarget(ref, { defaultBranch = 'master', fallbackToB return { kind: 'branch', ref: defaultBranch, tag: null, manifest: null, resolvedFrom: 'no published release' } } + assertMajorDowngradeAllowed(installedVersion, tag, allowMajorDowngrade) + return { kind: 'release', ref: tag, diff --git a/src/services/self_update_service.js b/src/services/self_update_service.js index ae3f3635..ae6b8c21 100644 --- a/src/services/self_update_service.js +++ b/src/services/self_update_service.js @@ -46,13 +46,14 @@ const { execFile, spawn } = require('child_process') const { promisify } = require('util') const execFileAsync = promisify(execFile) -const { dataDir, SELF_UPDATE_ENV, childProcessEnv } = require('../config') +const { dataDir, SELF_UPDATE_ENV, childProcessEnv, hostEnv } = require('../config') const { getLogger } = require('../observability/logger') const { adoptCommandLock } = require('../utils/command_lock') const CARRIER_ROOT = path.join(__dirname, '../..') const TARGET_ENV = 'XCHAIN_NODE_UPDATE_TARGET' const NO_SELF_UPDATE_ENV = 'XCHAIN_NODE_NO_SELF_UPDATE' +const ALLOW_MAJOR_DOWNGRADE_ENV = 'XCHAIN_NODE_ALLOW_MAJOR_DOWNGRADE' const CHECK_CACHE_FILE = 'release-check.json' const CHECK_TTL_MS = 60 * 60 * 1000 const CHECK_TIMEOUT_MS = 5000 @@ -70,6 +71,31 @@ function currentVersion() { return require('../../package.json').version } +function versionMajor(version) { + const match = /^v?(\d+)(?:\.|$)/.exec(String(version || '').trim()) + return match ? Number(match[1]) : null +} + +function allowMajorDowngradeForRun({ env = hostEnv(), logger = defaultLogger() } = {}) { + const allowed = /^(1|true|yes)$/i.test(env[ALLOW_MAJOR_DOWNGRADE_ENV] || '') + if (allowed) { + logger.warn(`WARNING: ${ALLOW_MAJOR_DOWNGRADE_ENV}=1 is set; major-version downgrade protection is disabled for this run.`) + } + return allowed +} + +function assertMajorDowngradeAllowed(installedVersion, targetVersion, allowed = false) { + const installedMajor = versionMajor(installedVersion) + const targetMajor = versionMajor(targetVersion) + if (allowed || installedMajor == null || targetMajor == null || targetMajor >= installedMajor) return + + throw new Error( + `Refusing release ${targetVersion} because its major version ${targetMajor} is below the installed` + + ` xchain-node major version ${installedMajor} (v${versionOfTag(installedVersion)}). Set` + + ` ${ALLOW_MAJOR_DOWNGRADE_ENV}=1 to override this protection. Nothing was changed.` + ) +} + function versionOfTag(tag) { return String(tag || '').trim().replace(/^v/, '') } @@ -182,6 +208,9 @@ async function selfUpdateAndReexec({ tag, childArgs, deps = {} }) { const env = deps.env || SELF_UPDATE_ENV const logger = deps.logger || defaultLogger() const version = deps.currentVersion ? deps.currentVersion() : currentVersion() + const allowMajorDowngrade = allowMajorDowngradeForRun({ env: deps.env || hostEnv(), logger }) + + assertMajorDowngradeAllowed(version, tag, allowMajorDowngrade) if (env[TARGET_ENV]) return { moved: false, reason: 'already-reexecuted' } if (selfUpdateDisabled(env)) { @@ -302,11 +331,15 @@ module.exports = { CARRIER_ROOT, TARGET_ENV, NO_SELF_UPDATE_ENV, + ALLOW_MAJOR_DOWNGRADE_ENV, CHECK_CACHE_FILE, CHECK_TTL_MS, currentVersion, + versionMajor, versionOfTag, compareVersions, + allowMajorDowngradeForRun, + assertMajorDowngradeAllowed, selfUpdateDisabled, describeCarrier, selfUpdateAndReexec, diff --git a/test/unit/downgrade_floor.test.js b/test/unit/downgrade_floor.test.js new file mode 100644 index 00000000..3da828ae --- /dev/null +++ b/test/unit/downgrade_floor.test.js @@ -0,0 +1,135 @@ +'use strict' + +const sinon = require('sinon') +const { expect } = require('chai') +const proxyquire = require('proxyquire').noCallThru() + +const selfUpdate = require('../../src/services/self_update_service') + +function testLogger() { + return { log: sinon.stub(), warn: sinon.stub(), error: sinon.stub() } +} + +function loadManifestService(axiosGet = sinon.stub()) { + return proxyquire('../../src/services/release_manifest_service', { + axios: { get: axiosGet }, + './release_signature_service': { verifyManifestForTag: sinon.stub().resolves({ verified: true }) } + }) +} + +describe('major-version downgrade floor', function () { + afterEach(function () { + sinon.restore() + }) + + it('refuses to move a 1.0.0 CLI to release v0.23.0 before touching git', async function () { + const describeCarrier = sinon.stub().resolves({ isRepo: true, commit: 'a'.repeat(40), dirty: [] }) + + await selfUpdate.selfUpdateAndReexec({ + tag: 'v0.23.0', + childArgs: ['update'], + deps: { + env: {}, + logger: testLogger(), + currentVersion: () => '1.0.0', + describeCarrier + } + }).then( + () => { throw new Error('should have rejected') }, + err => { + expect(err.message).to.match(/major version 0 is below.*major version 1/) + expect(err.message).to.include('XCHAIN_NODE_ALLOW_MAJOR_DOWNGRADE=1') + expect(err.message).to.include('Nothing was changed') + } + ) + + expect(describeCarrier.called).to.equal(false) + }) + + it('does not turn a same-major version decrease into a refusal', function () { + expect(() => selfUpdate.assertMajorDowngradeAllowed('1.9.0', 'v1.0.0')).not.to.throw() + }) + + it('refuses an explicit lower-major install before fetching its manifest', async function () { + const axiosGet = sinon.stub() + const service = loadManifestService(axiosGet) + + await service.resolveInstallTarget('v0.23.0', { + env: {}, + logger: testLogger(), + installedVersion: '1.0.0' + }).then( + () => { throw new Error('should have rejected') }, + err => expect(err.message).to.match(/Refusing release v0\.23\.0/) + ) + + expect(axiosGet.called).to.equal(false) + }) + + it('refuses a latest lower-major update before fetching its manifest', async function () { + const axiosGet = sinon.stub().resolves({ data: { tag_name: 'v0.23.0' } }) + const service = loadManifestService(axiosGet) + + await service.resolveInstallTarget(null, { + fallbackToBranch: false, + env: {}, + logger: testLogger(), + installedVersion: '1.0.0' + }).then( + () => { throw new Error('should have rejected') }, + err => expect(err.message).to.match(/Refusing release v0\.23\.0/) + ) + + expect(axiosGet.calledOnce).to.equal(true) + }) + + it('allows the self-update override and announces it even when self-update is disabled', async function () { + const logger = testLogger() + const out = await selfUpdate.selfUpdateAndReexec({ + tag: 'v0.23.0', + childArgs: ['update'], + deps: { + env: { + XCHAIN_NODE_ALLOW_MAJOR_DOWNGRADE: '1', + XCHAIN_NODE_NO_SELF_UPDATE: '1' + }, + logger, + currentVersion: () => '1.0.0' + } + }) + + expect(out.reason).to.equal('disabled') + expect(logger.warn.calledOnce).to.equal(true) + expect(logger.warn.firstCall.args[0]).to.include('major-version downgrade protection is disabled') + }) + + it('announces the manifest override on every target-resolution run', async function () { + const service = loadManifestService() + const logger = testLogger() + const options = { + env: { XCHAIN_NODE_ALLOW_MAJOR_DOWNGRADE: 'yes' }, + logger, + installedVersion: '1.0.0' + } + + await service.resolveInstallTarget('develop', options) + await service.resolveInstallTarget('master', options) + + expect(logger.warn.callCount).to.equal(2) + expect(logger.warn.alwaysCalledWithMatch(/XCHAIN_NODE_ALLOW_MAJOR_DOWNGRADE=1/)).to.equal(true) + }) + + it('allows a lower-major release target when the manifest override is set', async function () { + const service = loadManifestService() + const logger = testLogger() + const target = await service.resolveInstallTarget('v0.23.0', { + env: { XCHAIN_NODE_ALLOW_MAJOR_DOWNGRADE: 'true' }, + logger, + installedVersion: '1.0.0' + }) + + expect(target.kind).to.equal('release') + expect(target.tag).to.equal('v0.23.0') + expect(logger.warn.calledOnce).to.equal(true) + }) +}) From 02e8eb0702e7ee9fd547887e6b053f19e7c39e39 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 16:12:17 +0000 Subject: [PATCH 02/50] Split downgrade floor tests into short describe blocks The single describe spanned 116 lines, above the 60 line function limit. Group the cases into self-update, manifest refusal and override blocks and hoist the shared sinon restore. --- test/unit/downgrade_floor.test.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test/unit/downgrade_floor.test.js b/test/unit/downgrade_floor.test.js index 3da828ae..7fa79e39 100644 --- a/test/unit/downgrade_floor.test.js +++ b/test/unit/downgrade_floor.test.js @@ -17,11 +17,11 @@ function loadManifestService(axiosGet = sinon.stub()) { }) } -describe('major-version downgrade floor', function () { - afterEach(function () { - sinon.restore() - }) +afterEach(function () { + sinon.restore() +}) +describe('major-version downgrade floor: self-update', function () { it('refuses to move a 1.0.0 CLI to release v0.23.0 before touching git', async function () { const describeCarrier = sinon.stub().resolves({ isRepo: true, commit: 'a'.repeat(40), dirty: [] }) @@ -50,6 +50,9 @@ describe('major-version downgrade floor', function () { expect(() => selfUpdate.assertMajorDowngradeAllowed('1.9.0', 'v1.0.0')).not.to.throw() }) +}) + +describe('major-version downgrade floor: manifest refusals', function () { it('refuses an explicit lower-major install before fetching its manifest', async function () { const axiosGet = sinon.stub() const service = loadManifestService(axiosGet) @@ -83,6 +86,9 @@ describe('major-version downgrade floor', function () { expect(axiosGet.calledOnce).to.equal(true) }) +}) + +describe('major-version downgrade floor: overrides', function () { it('allows the self-update override and announces it even when self-update is disabled', async function () { const logger = testLogger() const out = await selfUpdate.selfUpdateAndReexec({ From df1dd28946d2705904ea4914689767cb8eedeec9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 11:43:27 -0700 Subject: [PATCH 03/50] Fix 14 review findings in xchain-node - Stake mint plan ignores mints.start_block/stop_block, which the explorer serves and the indexer enforces - Per-address mint cap is checked against this run's shortfall, not cap minus what the address has already minted - planMints treats MAX_MINT 0 as 'no open mint', but the indexer reads 0 as 'no per-transaction cap' - MariaDB version drift between repositories - Recreating the DB container over a persisted XCHAIN_NODE_DB_DATA_DIR saves an unapplied root password over the working one, locking the operator out - Discovery lets a running container matched only by image take the registry row from the container matched by name - CLI stop/start/restart report success when the docker operation failed; stop fails with no output at all - Tracker/gate health contract is pinned only by hand-copied literals, with no live parity check across the seam - xchain-node and xchain-utxo-tracker compare bootstrap.json identity with different case rules - Hub install in preCheck ignores the tag a no-ref `update` already resolved and calls releases/latest a second time - Refusal blast radius drops schema-satisfied manual migrations that an unscoped indexer migrate run still executes - Bootstrap health gate header says any REORG_HALT row refuses a source, but the query only refuses a halt that no newer REORG_HALT_CLEARED supersedes - Fractional XCHAIN balance gives a float-noise MINT AMOUNT the indexer rejects as over-precise - Unstake keeps its own copies of readTip and isUndeactivated instead of using free_key's --- package-lock.json | 8 +- package.json | 2 +- scripts/publish-bootstraps.sh | 5 +- src/cli.js | 6 +- src/cli/dispatch.js | 3 +- src/cli/options.js | 30 ++- src/db/migration_ledger.js | 28 +++ .../module_operations/module_controls.js | 96 ++++---- src/services/bootstrap_archive_meta.js | 16 +- src/services/bootstrap_health_gate.js | 5 +- .../bootstrap_health_gate/halt_markers.js | 5 +- .../status_evaluation.js | 5 +- src/services/database_service.js | 2 + .../database_service/database_module.js | 14 +- src/services/database_service/mariadb_exec.js | 6 +- .../database_service/persisted_datadir.js | 120 ++++++++++ src/services/discovery_service.js | 33 +-- .../migration_precondition_service.js | 8 +- .../migration_scan.js | 12 +- .../recorded_ledger.js | 46 ++++ src/services/validator_stake_service.js | 57 +++-- .../validator_stake_service/free_key.js | 19 +- .../validator_stake_service/mint_rules.js | 148 ++++++++++++ .../stake_operations.js | 32 ++- .../unstake_operations.js | 21 +- test/helpers/stake_harness.js | 9 +- test/integration/module_lifecycle.test.js | 4 +- test/unit/bootstrap_archive_meta.test.js | 33 +++ test/unit/cli_error_handling.test.js | 32 +++ test/unit/cli_ref_for_pre_check.test.js | 43 ++++ .../root_password_persisted_datadir.test.js | 218 ++++++++++++++++++ test/unit/discovery_service.test.js | 48 ++++ .../10_refusal_lists_recorded_ledger.test.js | 111 +++++++++ .../03_module_controls.test.js | 10 +- .../17_module_error_paths.test.js | 71 +++++- .../unit/validator_sdk_encoder_compat.test.js | 5 +- test/unit/validator_stake_service.test.js | 54 ++++- .../mint_rules.test.js | 160 +++++++++++++ .../send_lock.test.js | 5 +- .../stake_validator.test.js | 19 +- .../unstake_validator.test.js | 5 + .../upstream_reads.test.js | 4 +- 42 files changed, 1401 insertions(+), 157 deletions(-) create mode 100644 src/db/migration_ledger.js create mode 100644 src/services/database_service/persisted_datadir.js create mode 100644 src/services/migration_precondition_service/recorded_ledger.js create mode 100644 src/services/validator_stake_service/mint_rules.js create mode 100644 test/unit/database_service.test/mariadb_exec/root_password_persisted_datadir.test.js create mode 100644 test/unit/migration_precondition_service.test/10_refusal_lists_recorded_ledger.test.js create mode 100644 test/unit/validator_stake_service.test/mint_rules.test.js diff --git a/package-lock.json b/package-lock.json index 738b327f..62ef1775 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "dotenv": "^16.4.5", "enquirer": "^2.4.1", "follow-redirects": "^1.16.0", - "mariadb": "3.5.3", + "mariadb": "3.5.4", "semver": "^7.6.3" }, "bin": { @@ -4471,9 +4471,9 @@ } }, "node_modules/mariadb": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.5.3.tgz", - "integrity": "sha512-i053Kc0MgdUv/hu9mCyq67TYfPXFj3/MV8I7ZW5wvJNixIyXC0VztMPUjIVj/449nQo+BsxFD4Fdk/sA/uqKPQ==", + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.5.4.tgz", + "integrity": "sha512-3m0vdfgRkjle5/9hHl4R7rlVvnDA7QfuFSNkdIAQNROWKOS0J3b9IQK9HaZSCGEVKiDUbFXFiINfC+cmR5P/aA==", "license": "LGPL-2.1-or-later", "dependencies": { "@types/geojson": "^7946.0.16", diff --git a/package.json b/package.json index f71ad457..dcf2e0dc 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "dotenv": "^16.4.5", "enquirer": "^2.4.1", "follow-redirects": "^1.16.0", - "mariadb": "3.5.3", + "mariadb": "3.5.4", "semver": "^7.6.3" }, "scripts": { diff --git a/scripts/publish-bootstraps.sh b/scripts/publish-bootstraps.sh index ac87919a..9932ddf3 100755 --- a/scripts/publish-bootstraps.sh +++ b/scripts/publish-bootstraps.sh @@ -26,8 +26,9 @@ # can verify provenance against the pinned public key. # - Refuses an unhealthy source. `bootstrap create` runs a source health gate: # a service that is stopped, crash-looping, reporting unhealthy, -# materially behind its tip, or carrying a durable halt marker (a decoder -# REORG_HALT row, an uncleared sync_halt) is NOT snapshotted. Such a combo is +# materially behind its tip, or carrying a durable halt marker (a live +# decoder REORG_HALT row that no newer REORG_HALT_CLEARED supersedes, an +# uncleared sync_halt) is NOT snapshotted. Such a combo is # summarised as SOURCE-UNHEALTHY and the run exits non-zero, leaving the last # good archive in place as the newest. This exists because the weekly cron # once published a halted litecoin/mainnet decoder as the newest "good" diff --git a/src/cli.js b/src/cli.js index 7740cb51..5236eca0 100644 --- a/src/cli.js +++ b/src/cli.js @@ -67,11 +67,15 @@ const loadModule = require // problem with better context. function refForPreCheck(commandName, actionCommand) { if (commandName !== 'install' && commandName !== 'update') return null + let branch try { - return resolveArgs(actionCommand.args || [], { expectBranch: true, defaultBranch: null }).branch + branch = resolveArgs(actionCommand.args || [], { expectBranch: true, defaultBranch: null }).branch } catch { return null } + // Stage a no-ref update's hub at the tag self-update handed on, the one updateModules installs at. + if (!branch && commandName === 'update') return config.XCHAIN_NODE_UPDATE_TARGET || null + return branch } // The verbs that can put the hub container back: they rebuild it, recreate it diff --git a/src/cli/dispatch.js b/src/cli/dispatch.js index bff001ca..a895bf7c 100644 --- a/src/cli/dispatch.js +++ b/src/cli/dispatch.js @@ -160,7 +160,8 @@ function commandLock(commandName, settings, acquireCommandLock, // the action uses rather than "the first positional", because the // args are order-independent and only resolveArgs knows which one // is a ref (`install regtest` names a network, not a branch). A -// command that names no ref, or an arg shape resolveArgs refuses, +// no-ref update takes the tag its self-update step handed on; any +// other command naming no ref, or an arg shape resolveArgs refuses, // yields null and the previous default-branch behaviour. // // Whether this command could bring a crash-looping hub back, which diff --git a/src/cli/options.js b/src/cli/options.js index ccce9d22..e86078a1 100644 --- a/src/cli/options.js +++ b/src/cli/options.js @@ -47,7 +47,7 @@ function registerSync(program, deps) { } function registerStart(program, deps) { - const { filterCommandParameters, startModules } = deps + const { filterCommandParameters, startModules, redactSecrets } = deps program .command('start') .description('Start XChain service') @@ -56,14 +56,20 @@ function registerStart(program, deps) { .argument('[network]', '(mainnet, testnet, regtest, all)') .action(async (service, chain, network) => { const serviceList = filterCommandParameters(null, service, chain, network) - await startModules(serviceList) + // Every selected module is still attempted; a failed one only changes the exit status. + try { + await startModules(serviceList) + } catch (err) { + console.error('start failed: ' + redactSecrets(err && err.message ? err.message : err)) + return process.exit(1) + } return process.exit(0) }) } function registerStop(program, deps) { - const { filterCommandParameters, stopModules } = deps + const { filterCommandParameters, stopModules, redactSecrets } = deps program .command('stop') .description('Stop XChain service') @@ -72,14 +78,20 @@ function registerStop(program, deps) { .argument('[network]', '(mainnet, testnet, regtest, all)') .action(async (service, chain, network) => { const serviceList = filterCommandParameters(null, service, chain, network) - await stopModules(serviceList) + // Every selected module is still attempted; a failed one only changes the exit status. + try { + await stopModules(serviceList) + } catch (err) { + console.error('stop failed: ' + redactSecrets(err && err.message ? err.message : err)) + return process.exit(1) + } return process.exit(0) }) } function registerRestart(program, deps) { - const { filterCommandParameters, restartModules } = deps + const { filterCommandParameters, restartModules, redactSecrets } = deps program .command('restart') .description('Restart XChain service') @@ -88,7 +100,13 @@ function registerRestart(program, deps) { .argument('[network]', '(mainnet, testnet, regtest, all)') .action(async (service, chain, network) => { const serviceList = filterCommandParameters(null, service, chain, network) - await restartModules(serviceList) + // Every selected module is still attempted; a failed one only changes the exit status. + try { + await restartModules(serviceList) + } catch (err) { + console.error('restart failed: ' + redactSecrets(err && err.message ? err.message : err)) + return process.exit(1) + } return process.exit(0) }) diff --git a/src/db/migration_ledger.js b/src/db/migration_ledger.js new file mode 100644 index 00000000..dd210fb9 --- /dev/null +++ b/src/db/migration_ledger.js @@ -0,0 +1,28 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * XChain Node - migration ledger reads + * + * The statement that lists the names a migration ledger records on its own, + * with no schema-satisfied names folded in. The migrate runner reads only + * the ledger, so this is the set an unscoped run treats as already applied. + * + ********************************************************************/ + +// Every name the ledger table itself records. +function recordedMigrationsSql(database, ledgerTable) { + return 'SELECT name FROM `' + database + '`.' + ledgerTable +} + +module.exports = { recordedMigrationsSql } diff --git a/src/operations/module_operations/module_controls.js b/src/operations/module_operations/module_controls.js index 35731f9f..edcd2b58 100644 --- a/src/operations/module_operations/module_controls.js +++ b/src/operations/module_operations/module_controls.js @@ -1,9 +1,9 @@ 'use strict' -let XChainService, failureReason, sleep, db, execContainer, getDockerContainerImageName, logContainer, restartContainer, shellContainer, startContainer, startDockerMonitor, statusChanged, stopContainerByName, stopModuleContainer, getContainerStopSettings +let XChainService, failureReason, sleep, db, execContainer, getDockerContainerImageName, logContainer, restartContainer, shellContainer, startContainer, startDockerMonitor, statusChanged, stopContainer, stopModuleContainer, getContainerStopSettings, isNoSuchContainerError function configure(dependencies) { - ({ XChainService, failureReason, sleep, db, execContainer, getDockerContainerImageName, logContainer, restartContainer, shellContainer, startContainer, startDockerMonitor, statusChanged, stopContainerByName, stopModuleContainer, getContainerStopSettings } = dependencies) + ({ XChainService, failureReason, sleep, db, execContainer, getDockerContainerImageName, logContainer, restartContainer, shellContainer, startContainer, startDockerMonitor, statusChanged, stopContainer, stopModuleContainer, getContainerStopSettings, isNoSuchContainerError } = dependencies) } async function logModules(servicesList, follow = true) { @@ -71,60 +71,74 @@ async function monitorModules(servicesList, follow = true) { return true } -async function restartModules(servicesList) { - for (const nextCoin in servicesList) { - for (const nextNetwork in servicesList[nextCoin]) { - for (const nextModule of servicesList[nextCoin][nextNetwork]) { - const containerId = await db.getModuleContainer(nextModule, nextCoin, nextNetwork) +// Read a registry row strictly: a failed read is recorded as a failure, never taken for "not installed". +async function readRegisteredContainer(target, failures) { + try { + return await db.getModuleContainerStrict(target.module, target.coin, target.network) + } catch (err) { + const reason = `registry lookup failed (${failureReason(err)})` + console.error(`${target.module} (${target.coin} ${target.network}): ${reason}`) + failures.push({ ...target, reason }) + return null + } +} + +// Run one docker action on every selected module that is installed, attempting all of them, and +// throw one error naming each module that failed so the command can exit non-zero. +async function controlModules(verb, servicesList, act) { + const failures = [] + for (const coin in servicesList) { + for (const network in servicesList[coin]) { + for (const module of servicesList[coin][network]) { + const target = { module, coin, network } + const containerId = await readRegisteredContainer(target, failures) if (!containerId) continue try { - await restartContainer(containerId) - await statusChanged() + await act(target, containerId) } catch (err) { - console.log(err) + console.error(`${verb}: ${module} (${coin} ${network}) FAILED: ${failureReason(err)}`) + failures.push({ ...target, reason: failureReason(err) }) } } } } + if (failures.length > 0) { + const detail = failures.map(f => `${f.module} (${f.coin} ${f.network}): ${f.reason}`).join('; ') + const err = new Error(`${verb} failed for ${failures.length} module${failures.length === 1 ? '' : 's'}: ${detail}`) + err.failures = failures + throw err + } return true } +async function restartModules(servicesList) { + return controlModules('restart', servicesList, async (target, containerId) => { + await restartContainer(containerId) + try { + await statusChanged() + } catch (err) { + throw new Error(`restarted, but the hub and explorer config refresh failed (${failureReason(err)})`) + } + }) +} + async function stopModules(servicesList) { - for (const nextCoin in servicesList) { - for (const nextNetwork in servicesList[nextCoin]) { - for (const nextModule of servicesList[nextCoin][nextNetwork]) { - const containerId = await db.getModuleContainer(nextModule, nextCoin, nextNetwork) - if (!containerId) continue - try { - // With the service's budget, not docker's ten seconds: a bare - // `docker stop` on a container created before the budget was - // stamped on it is a coin flip for a service mid-block. - await stopModuleContainer(stopContainerByName, nextModule, nextCoin, nextNetwork, containerId, - undefined, getContainerStopSettings) - } catch (err) { - console.log(err) - } - } + return controlModules('stop', servicesList, async ({ module, coin, network }, containerId) => { + try { + // With the service's budget, not docker's ten seconds: a bare `docker stop` on a container + // created before the budget was stamped on it is a coin flip for a service mid-block. + await stopModuleContainer(stopContainer, module, coin, network, containerId, + undefined, getContainerStopSettings) + } catch (err) { + // Skip a container docker says is gone: it is not running, which is what stop promises. + if (!isNoSuchContainerError(err)) throw err + console.log(`${module} (${coin} ${network}): the registered container no longer exists; nothing to stop`) } - } - return true + }) } async function startModules(servicesList) { - for (const nextCoin in servicesList) { - for (const nextNetwork in servicesList[nextCoin]) { - for (const nextModule of servicesList[nextCoin][nextNetwork]) { - const containerId = await db.getModuleContainer(nextModule, nextCoin, nextNetwork) - if (!containerId) continue - try { - await startContainer(containerId) - } catch (err) { - console.log(err) - } - } - } - } - return true + return controlModules('start', servicesList, (target, containerId) => startContainer(containerId)) } // The decoder's clear tool, relative to the decoder container's working directory. diff --git a/src/services/bootstrap_archive_meta.js b/src/services/bootstrap_archive_meta.js index d651450c..cd2a82fc 100644 --- a/src/services/bootstrap_archive_meta.js +++ b/src/services/bootstrap_archive_meta.js @@ -171,16 +171,24 @@ async function readBootstrapArchiveMeta(archivePath) { const ARCHIVE_IDENTITY_FIELDS = ['module', 'coin', 'network'] +// An identity value trimmed and lowercased, or null when it is absent or blank: the rule the tracker's +// restore_validation.js applies to the same bootstrap.json, so both readers judge one archive alike. +function identityValue(value) { + if (typeof value !== 'string') return null + const v = value.trim().toLowerCase() + return v === '' ? null : v +} + // Compare an archive's embedded identity with the restore target: { status: 'match' | 'unchecked' | 'mismatch', mismatches, unchecked }. -// Only a present field that differs is a mismatch. A null meta or field is unchecked, not a mismatch: legacy archives carry none, and -// the tracker's convert-bootstrap-to-classiclevel.sh writes coin/network null when BOOTSTRAP_COIN/BOOTSTRAP_NETWORK are unset. +// Only a present field that differs, ignoring case and padding, is a mismatch; a target lacking a declared field is one too. A null +// or blank meta field is unchecked: legacy archives carry none, and the converter writes coin/network null when unset. function compareArchiveIdentity(meta, target) { const mismatches = [] const unchecked = [] for (const field of ARCHIVE_IDENTITY_FIELDS) { - const value = meta && typeof meta[field] === 'string' ? meta[field] : null + const value = identityValue(meta && meta[field]) if (value === null) unchecked.push(field) - else if (value !== target[field]) mismatches.push({ field, archive: value, target: target[field] }) + else if (value !== identityValue(target[field])) mismatches.push({ field, archive: meta[field], target: target[field] }) } const status = mismatches.length > 0 ? 'mismatch' : unchecked.length > 0 ? 'unchecked' : 'match' return { status, mismatches, unchecked } diff --git a/src/services/bootstrap_health_gate.js b/src/services/bootstrap_health_gate.js index f1df3bf7..28bb9239 100644 --- a/src/services/bootstrap_health_gate.js +++ b/src/services/bootstrap_health_gate.js @@ -30,8 +30,9 @@ * The gate refuses to create a bootstrap from a source that is: * - missing, not running, restarting, or crash-looping * - reporting an unhealthy/halted status on its own health surface - * - carrying a durable halt marker in its database: a decoder REORG_HALT row - * (events.code = 'REORG_HALT') or an uncleared xchain-sync divergence halt + * - carrying a durable halt marker in its database: a live decoder REORG_HALT + * row (events.code = 'REORG_HALT' with no newer REORG_HALT_CLEARED row + * superseding it) or an uncleared xchain-sync divergence halt * (sync_halt with cleared_at IS NULL). For an indexer source both its own * database and the PAIRED DECODER's are read: REORG_HALT is written only in the * decoder's (an indexer's own events table carries REORG and TRAIN_ACTIVATION_HALT diff --git a/src/services/bootstrap_health_gate/halt_markers.js b/src/services/bootstrap_health_gate/halt_markers.js index c74112c7..1faed914 100644 --- a/src/services/bootstrap_health_gate/halt_markers.js +++ b/src/services/bootstrap_health_gate/halt_markers.js @@ -14,8 +14,9 @@ * * XChain Node - Bootstrap source health gate halt marker probe * - * Reads the durable halt markers (a decoder REORG_HALT row, an uncleared - * sync_halt row) and the dump-window watermarks straight out of the database + * Reads the durable halt markers (a live decoder REORG_HALT row, one no newer + * REORG_HALT_CLEARED row supersedes, and an uncleared sync_halt row) and the + * dump-window watermarks straight out of the database * about to be dumped, and for an indexer source out of its paired decoder * database as well. Every unreadable answer throws, and the gate turns a throw * into a refusal. diff --git a/src/services/bootstrap_health_gate/status_evaluation.js b/src/services/bootstrap_health_gate/status_evaluation.js index e55573f6..148fe8a5 100644 --- a/src/services/bootstrap_health_gate/status_evaluation.js +++ b/src/services/bootstrap_health_gate/status_evaluation.js @@ -68,8 +68,9 @@ function evaluateContainerState(raw, { now = Date.now() } = {}) { // decoder health: status, lag_blocks/blockLag, reorg_halted, reorg_halt_checked_at, // reorg_halt_parked // indexer health: status, lag, decoderReorgHalted, stallClass, train_activation.status -// tracker health: lag, synced, halted -// any /status: status ('ok'|'healthy'|'halted'|'degraded'|'unhealthy') +// tracker health: status, lag, halted, halt_reason, block_fetch_desync, +// node_height_stale (synced is sent but not read here) +// any /status: status ('ok'|'healthy'|'halted'|'degraded'|'stalled'|'unhealthy') function evaluateStatusPayload(payload, { maxLag = DEFAULT_MAX_LAG_BLOCKS } = {}) { const reasons = [] if (!payload || typeof payload !== 'object') diff --git a/src/services/database_service.js b/src/services/database_service.js index 5da89d2d..49531636 100644 --- a/src/services/database_service.js +++ b/src/services/database_service.js @@ -62,6 +62,7 @@ const { const { XCHAIN_NODE_DB_HOST, XCHAIN_NODE_DB_DEFAULT_PORT, getDatabaseContainerId, getDatabaseContainerPresence, getDatabaseHostPort, checkIfDatabaseModuleExists, checkIfDatabaseIsReady, configureDependencies: configureContainerAccess } = require('./database_service/container_access') const { getExternalDbConfig, pingExternalDatabase, configureDependencies: configureExternalDb } = require('./database_service/external_db') const { askMariadbRootPassword, executeDockerMariaDbCommand, executeNativeMariaDbCommand, configureDependencies: configureMariadbExec } = require('./database_service/mariadb_exec') +const { configureDependencies: configurePersistedDatadir } = require('./database_service/persisted_datadir') const { addUserPasswordToDatabase, configureDependencies: configureUserProvisioning } = require('./database_service/user_provisioning') const { setDatabaseParameters, setHubDatabaseParameters, configureDependencies: configureDbParameters } = require('./database_service/db_parameters') const { resetDatabases, clearHubPriceIngestWatermark, configureDependencies: configureResetDatabases } = require('./database_service/reset_databases') @@ -72,6 +73,7 @@ const databaseServiceDependencies = { execFile, spawn, promisify, execFileAsync, configureContainerAccess(databaseServiceDependencies) configureExternalDb(databaseServiceDependencies) configureMariadbExec(databaseServiceDependencies) +configurePersistedDatadir(databaseServiceDependencies) configureUserProvisioning(databaseServiceDependencies) configureDbParameters(databaseServiceDependencies) configureResetDatabases(databaseServiceDependencies) diff --git a/src/services/database_service/database_module.js b/src/services/database_service/database_module.js index 74b80eec..e60ea3cd 100644 --- a/src/services/database_service/database_module.js +++ b/src/services/database_service/database_module.js @@ -42,6 +42,7 @@ let { let { XCHAIN_NODE_DB_HOST, XCHAIN_NODE_DB_DEFAULT_PORT, getDatabaseContainerId, checkIfDatabaseModuleExists, checkIfDatabaseIsReady } = require('./container_access') let { getExternalDbConfig, pingMariaDb } = require('./external_db') let { askMariadbRootPassword, executeNativeMariaDbCommand, executeDockerMariaDbCommand } = require('./mariadb_exec') +let { dbDatadirState, confirmPersistedDatadirRootPassword } = require('./persisted_datadir') const nativeExecFile = execFile function configureDependencies(dependencies) { @@ -77,7 +78,8 @@ async function verifyExternalDatabase() { // (moduleOperations.js RECREATE/update paths) and from `recreate` // (RECREATE_UNSUPPORTED_MODULES), so no automated fleet path re-applies // it. An already-installed DB keeps 10m x 3 until an operator tears the -// container down and reinstalls it inside a maintenance window. +// container down and reinstalls it inside a maintenance window; a datadir +// pinned with XCHAIN_NODE_DB_DATA_DIR keeps its data and root password then. function createDatabaseRunArgs(containerPrefix, environmentVariables, coin, network) { const runArgs = createDatabaseContainerRunArgs(containerPrefix, logger) // A visibility-only probe makes a stalled-but-alive mariadbd observable @@ -191,7 +193,8 @@ async function prepareDatabaseContainer(containerPrefix, dbHostPort) { containerPrefix + "', which is not a confirmed absence. This install path force-removes that container " + "and would orphan the database volume holding every module's data. Run `docker inspect " + containerPrefix + "` and `docker ps -a` to see what is actually there, then re-run once docker answers cleanly. " + - "If the container is genuinely dead and you want it rebuilt from empty, remove it yourself first." + "If the container is genuinely dead and you want it rebuilt, remove it yourself first; a datadir pinned with " + + "XCHAIN_NODE_DB_DATA_DIR survives that removal, its data and root password included." ) } try { @@ -236,7 +239,12 @@ async function createDatabaseModule(coin, network) { const { runArgs, dbHostPort } = createDatabaseRunArgs(containerPrefix, environmentVariables, coin, network) addDatabaseTuningArgs(runArgs) await prepareDatabaseContainer(containerPrefix, dbHostPort) - return startDatabaseContainer(runArgs, mariadbRootPassword) + // Read before the start: a new container initialises an empty datadir, which then holds mysql/. + const persisted = dbDatadirState() === 'initialised' + const containerId = await startDatabaseContainer(runArgs, mariadbRootPassword) + // The image ignored MYSQL_ROOT_PASSWORD over a datadir that already held a database, so store one only once it authenticates. + if (persisted) await confirmPersistedDatadirRootPassword(containerId, mariadbRootPassword) + return containerId } // Existing-container branch. checkIfDatabaseModuleExists returns the id on diff --git a/src/services/database_service/mariadb_exec.js b/src/services/database_service/mariadb_exec.js index d99e0657..39368cf4 100644 --- a/src/services/database_service/mariadb_exec.js +++ b/src/services/database_service/mariadb_exec.js @@ -30,6 +30,7 @@ let logger = getLogger() let { loadDbRootPassword, saveDbRootPassword } = require('../credentials_service') let { checkIfDatabaseModuleExists, getDatabaseContainerPresence } = require('./container_access') let { getExternalDbConfig } = require('./external_db') +let { dbDatadirState, rootPasswordForPersistedDatadir } = require('./persisted_datadir') const nativeExecFile = execFile function configureDependencies(dependencies) { @@ -275,7 +276,10 @@ async function askMariadbRootPassword(coin, network) { } const dbContainerId = await checkIfDatabaseModuleExists(coin, network) - if (!dbContainerId) await requireConfirmedDbAbsence() + if (!dbContainerId) { + await requireConfirmedDbAbsence() + if (dbDatadirState() === 'initialised') return rootPasswordForPersistedDatadir() + } const configured = await rootPasswordFromConfig(dbContainerId) if (configured) return configured const fromContainer = await rootPasswordFromContainer(dbContainerId) diff --git a/src/services/database_service/persisted_datadir.js b/src/services/database_service/persisted_datadir.js new file mode 100644 index 00000000..c69e1ba8 --- /dev/null +++ b/src/services/database_service/persisted_datadir.js @@ -0,0 +1,120 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * XChain Node - Database Service + * Root password for a DB container recreated over a persisted datadir + ********************************************************************/ + +const fs = require('fs') +const path = require('path') +let { execFile } = require('child_process') +const { promisify } = require('util') +let execFileAsync = promisify(execFile) +let { Password } = require('enquirer') +let { setDbRootPassword } = require('../../state') +let { dockerMariadbArgs, mariadbEnv } = require('../../utils/docker_mariadb') +let { sleep } = require('../../utils/helpers') +let config = require('../../config') +let { getLogger } = require('../../observability/logger') +let logger = getLogger() +let { loadDbRootPassword, saveDbRootPassword } = require('../credentials_service') + +const nativeExecFile = execFile +function configureDependencies(dependencies) { + if (dependencies.execFile === nativeExecFile) return + ;({ execFile, execFileAsync, Password, setDbRootPassword, dockerMariadbArgs, mariadbEnv, sleep, config, logger, loadDbRootPassword, saveDbRootPassword } = dependencies) +} + +// Say whether the datadir a new DB container mounts already holds a database ('initialised') or +// will be initialised from MYSQL_ROOT_PASSWORD ('fresh'). The mariadb image ignores that variable +// when /mysql exists, so the old root password stays in force there. +function dbDatadirState() { + const dir = config.XCHAIN_NODE_DB_DATA_DIR + if (!dir) return 'fresh' + // Count a docker named volume as initialised: it persists and cannot be inspected from here. + if (!path.isAbsolute(dir)) return 'initialised' + try { + fs.statSync(path.join(dir, 'mysql')) + return 'initialised' + } catch (err) { + // Only a confirmed absence is fresh; EACCES and the like may hide an initialised datadir. + return err && err.code === 'ENOENT' ? 'fresh' : 'initialised' + } +} + +// Pick the root password for a container recreated over an initialised datadir, saving nothing: +// it is checked against the running container (confirmPersistedDatadirRootPassword) before it is stored. +async function rootPasswordForPersistedDatadir() { + const dir = config.XCHAIN_NODE_DB_DATA_DIR + const stored = loadDbRootPassword() + const envPassword = config.XCHAIN_NODE_DB_ROOT_PASSWORD + if (stored && envPassword && stored !== envPassword) { + logger.warn('WARNING: XCHAIN_NODE_DB_ROOT_PASSWORD differs from the stored root password; the stored copy is ' + + 'tried first and both are checked once the MariaDB container is up.') + } + logger.info(`The MariaDB datadir at ${dir} already holds a database, so its existing root password stays in ` + + 'force; the image ignores MYSQL_ROOT_PASSWORD on an initialised datadir.') + let candidate = stored || envPassword + if (!candidate) { + if (!process.stdin.isTTY) { + throw new Error(`The MariaDB datadir at ${dir} (XCHAIN_NODE_DB_DATA_DIR) already holds a database, so its ` + + 'existing root password is needed, and neither credentials.json nor XCHAIN_NODE_DB_ROOT_PASSWORD ' + + 'supplies one and there is no TTY to prompt on. Set XCHAIN_NODE_DB_ROOT_PASSWORD to that ' + + 'datadir\'s root password and re-run. No root password was set or saved.') + } + candidate = await new Password({ name: 'password', + message: `The MariaDB datadir at ${dir} already holds a database. Type its existing root password` }).run() + } + setDbRootPassword(candidate) + return candidate +} + +// Ask the DB container whether `password` is root's: 'yes', 'no' (the server refused it) or 'down'. +async function probeRootPassword(containerId, password) { + try { + const { stdout, stderr } = await execFileAsync('docker', + dockerMariadbArgs(containerId, ['mariadb-admin', '-u', 'root', 'ping']), { env: mariadbEnv(password) }) + if (String(stdout).includes('mysqld is alive')) return 'yes' + return /access denied/i.test(String(stderr) + String(stdout)) ? 'no' : 'down' + } catch (err) { + return /access denied/i.test(String(err && (err.stderr || err.message))) ? 'no' : 'down' + } +} + +// Store a root password for a container created over an initialised datadir only once it +// authenticates, trying the chosen one, then the stored copy, then XCHAIN_NODE_DB_ROOT_PASSWORD. +async function confirmPersistedDatadirRootPassword(containerId, chosen, { tries = 30, retryDelay = 2000 } = {}) { + const candidates = [chosen, loadDbRootPassword(), config.XCHAIN_NODE_DB_ROOT_PASSWORD] + .filter((p, i, all) => p && all.indexOf(p) === i) + for (const candidate of candidates) { + let answer = 'down' + for (let left = tries; left > 0 && answer === 'down'; left--) { + answer = await probeRootPassword(containerId, candidate) + if (answer === 'down' && left > 1) await sleep(retryDelay) + } + if (answer === 'down') { + throw new Error('The new MariaDB container did not answer a root ping, so its root password could not be ' + + 'checked; credentials.json was left untouched. Check `docker logs` for the database container.') + } + if (answer === 'yes') { + setDbRootPassword(candidate) + saveDbRootPassword(candidate) + return candidate + } + } + throw new Error(`The MariaDB datadir at ${config.XCHAIN_NODE_DB_DATA_DIR} already holds a database whose root ` + + 'password is neither the stored copy nor XCHAIN_NODE_DB_ROOT_PASSWORD; credentials.json was left ' + + 'untouched. Set XCHAIN_NODE_DB_ROOT_PASSWORD to that datadir\'s root password and re-run.') +} + +module.exports = { dbDatadirState, rootPasswordForPersistedDatadir, confirmPersistedDatadirRootPassword, configureDependencies } diff --git a/src/services/discovery_service.js b/src/services/discovery_service.js index 49d071b3..674b1a9c 100644 --- a/src/services/discovery_service.js +++ b/src/services/discovery_service.js @@ -47,6 +47,16 @@ function logIfNotSilent(silent, message) { if (!silent) logger.info(message) } +// Read a container's name the way docker reports it: first entry of an array, leading slash dropped. +function containerName(container) { + return String((Array.isArray(container.Names) ? container.Names[0] : container.Names) || '').replace(/^\//, '') +} + +// True when the container carries a CLI-assigned name rather than matching only by its image tag. +function matchesByName(container) { + return containerName(container).startsWith(NODE_PREFIX + SEP) +} + // Resolve a docker container to its registry identity { module, coin, network } // (coin/network are '' for shared modules), or null for non-xchain containers. // @@ -60,7 +70,7 @@ function logIfNotSilent(silent, message) { // xchain-indexer incident, 2026-06-11). The image name remains a fallback for // containers that were renamed externally. function classifyContainer(container) { - const name = String((Array.isArray(container.Names) ? container.Names[0] : container.Names) || '').replace(/^\//, '') + const name = containerName(container) const image = String(container.Image || '') const identity = name.startsWith(NODE_PREFIX + SEP) ? name : image if (!identity.startsWith(NODE_PREFIX + SEP)) return null @@ -115,14 +125,11 @@ function moduleKey(module, coin, network) { async function registerContainers(containers, silent, seen) { let added = 0 let reconciled = 0 - // Sort containers so RUNNING ones come first. When multiple containers - // share the same expected key (e.g. an old exited container and a fresh - // running one), the running ID wins on the upsert. - containers.sort((a, b) => { - const ar = (a.State || '').toLowerCase() === 'running' ? 0 : 1 - const br = (b.State || '').toLowerCase() === 'running' ? 0 : 1 - return ar - br - }) + // Rank name matches first, then RUNNING (stable, so docker's newest-first breaks ties): + // a container started by hand from a module image under another name matches only by + // image and must never take the key from the container holding the CLI-assigned name. + const rank = (c) => (matchesByName(c) ? 0 : 2) + ((c.State || '').toLowerCase() === 'running' ? 0 : 1) + containers.sort((a, b) => rank(a) - rank(b)) for (const nextContainer of containers) { const identity = classifyContainer(nextContainer) @@ -130,11 +137,9 @@ async function registerContainers(containers, silent, seen) { const { module, coin, network } = identity const label = (coin || network) ? (coin + SEP + network + SEP + module) : module - // First-seen wins the upsert. Because the list is sorted RUNNING-first, - // the running container reconciles the row first; any later duplicate - // (e.g. a stopped container that classifies to the same key via the - // image fallback) is skipped so it cannot overwrite the live ID with a - // dead one. The key still records into `seen` for the orphan purge. + // First-seen wins the upsert, so the ranking above decides which ID holds the row; + // a later duplicate is skipped so it cannot overwrite it. The key still records + // into `seen` for the orphan purge. const key = moduleKey(module, coin, network) if (seen.has(key)) continue seen.add(key) diff --git a/src/services/migration_precondition_service.js b/src/services/migration_precondition_service.js index d2dcaf4f..5e792dfb 100644 --- a/src/services/migration_precondition_service.js +++ b/src/services/migration_precondition_service.js @@ -202,13 +202,17 @@ async function assertRequiredMigrationsApplied(module, coin, network, branch = n // See the ledger the way the target's runner will after its boot re-key, so a // renamed file recorded under its old name is not refused as missing. - const applied = rekeyApplied(result.applied, readTargetLedgerRenames(module, deps)) + const renames = readTargetLedgerRenames(module, deps) + const applied = rekeyApplied(result.applied, renames) const missing = required.filter(f => !applied.has(f)) if (missing.length) { + // The blast radius subtracts only recorded names: the runner still executes a file the schema matches. + const recorded = result.ledger ? rekeyApplied(result.ledger, renames) : applied // Only reached on the refusal path, so the probe costs a healthy deploy // nothing and cannot introduce a new way for one to fail: both the probe // and the pending-scan degrade to the cautious branch of the message. - const remedy = await readRefusalRemedy(module, coin, network, applied, deps) + const remedy = await readRefusalRemedy(module, coin, network, recorded, deps) + remedy.schemaMatched = new Set([...applied].filter(f => !recorded.has(f))) throw new Error(refusalMessage(module, coin, network, dbName, missing, remedy)) } diff --git a/src/services/migration_precondition_service/migration_scan.js b/src/services/migration_precondition_service/migration_scan.js index 68c0fe52..9fbf0f5f 100644 --- a/src/services/migration_precondition_service/migration_scan.js +++ b/src/services/migration_precondition_service/migration_scan.js @@ -18,6 +18,7 @@ const path = require('path') const { EXTERNAL_DB, XChainService } = require('../../config') const { tableCountSql, tableExistsSql } = require('../../db/information_schema') const { appliedMigrationsSql } = require('../../db/migrations') +const { readRecordedNames, wouldApplyNote } = require('./recorded_ledger') const { migrationFiles } = require('../../utils/migration_files') const { getDockerContainerImageName } = require('../config_service') const { readMigrateCli, migrateCliPathFor, migrateCliPathsFor, decoderStatusRows } = require('../../utils/indexer_migrate_cli') @@ -209,7 +210,8 @@ async function runningBuildSupportsPerFileMigrations(container, deps = {}, modul /** * The gated files an unscoped migrate run inside the running build would apply: * its own pending rows (a row with no mode is the runners' manual default), - * minus any whose end state the database already embodies. + * minus any the ledger records (re-keyed for renames). A file whose end state + * the schema already matches still runs when no ledger row records it. */ function runningBuildPendingManual(rows, applied) { return rows.filter(row => !row.applied && (row.mode == null || row.mode === 'manual') && @@ -241,7 +243,8 @@ function databaseLiteral(database) { /** * Read the applied-migration ledger of one module database. * - * Returns { state: 'ledger', applied: Set } when the ledger was read, + * Returns { state: 'ledger', applied: Set, ledger: Set|null } when + * the ledger was read (`applied` adds an indexer's schema-satisfied names, `ledger` does not), * { state: 'empty-database' } when the schema holds no tables at all (or does * not exist yet), and { state: 'unreadable', reason } for everything else. The * three are deliberately distinct: only the middle one is safe to proceed on. @@ -313,7 +316,8 @@ async function readAppliedMigrations({ database, coin, network }, deps = {}) { const out = String(await runner(appliedMigrationsSql(database, LEDGER_TABLE))) const applied = new Set(out.split('\n').map(s => s.trim()).filter(Boolean)) - return { state: 'ledger', applied } + // Keep the recorded names apart: the runner executes every pending file the ledger lacks, schema match or not. + return { state: 'ledger', applied, ledger: await readRecordedNames(runner, database, LEDGER_TABLE, applied) } } catch (err) { return { state: 'unreadable', reason: (err && err.message) ? err.message : String(err) } } @@ -363,7 +367,7 @@ function refusalMessage(module, coin, network, dbName, missing, remedy = {}) { : 'Whether that container\'s build honours --file could not be read, and an unverified capability is not one: it may ignore --file') + ' and apply EVERY pending manual migration on ' + dbName + ', which is ' + wouldApply.length + ' file(s)' + source + ':\n' + - wouldApply.map(f => ' ' + f + (carried.includes(f) ? ' (the one you need)' : '')).join('\n') + '\n' + + wouldApply.map(f => ' ' + f + wouldApplyNote(f, carried, remedy)).join('\n') + '\n' + ' Apply ' + (plural ? 'the needed files' : 'the needed file') + ' with a build that supports ' + '--file, or apply the statement by hand with the writer quiesced, then re-run the update.' + notCarriedNote diff --git a/src/services/migration_precondition_service/recorded_ledger.js b/src/services/migration_precondition_service/recorded_ledger.js new file mode 100644 index 00000000..8de2d9be --- /dev/null +++ b/src/services/migration_precondition_service/recorded_ledger.js @@ -0,0 +1,46 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * XChain Node - Migration precondition guard: the recorded ledger + * + * An indexer database can embody a manual migration's end state with no + * ledger row for it. The guard counts that as applied when deciding what is + * missing, but the indexer runner reads only the ledger, so an unscoped + * migrate run still executes such a file. The refusal's list of what that + * run would apply is therefore built from the names the ledger records. + ********************************************************************/ + +const { recordedMigrationsSql } = require('../../db/migration_ledger') + +// The names the ledger alone records, or null when that read fails (callers fall back to `applied`). +// A non-indexer database has no schema-satisfied names, so its applied set already is the ledger. +async function readRecordedNames(runner, database, ledgerTable, applied) { + if (!/_Indexer$/.test(database)) return applied + try { + const out = String(await runner(recordedMigrationsSql(database, ledgerTable))) + return new Set(out.split('\n').map(s => s.trim()).filter(Boolean)) + } catch { + return null + } +} + +// The note beside one file in the "an unscoped run would apply" list. +function wouldApplyNote(file, carried, remedy) { + if (carried.includes(file)) return ' (the one you need)' + if (remedy.schemaMatched && remedy.schemaMatched.has(file)) { + return ' (the live schema already matches it; no ledger row records it)' + } + return '' +} + +module.exports = { readRecordedNames, wouldApplyNote } diff --git a/src/services/validator_stake_service.js b/src/services/validator_stake_service.js index d337e54b..3e0f4e53 100644 --- a/src/services/validator_stake_service.js +++ b/src/services/validator_stake_service.js @@ -35,9 +35,10 @@ const { } = require('./validator_service') const { getCoinConfigByFullName } = require('../coins') const config = require('../config'); -const { readKeyHolders } = require('./validator_stake_service/free_key') +const { readKeyHolders, amountUnits, fromUnits } = require('./validator_stake_service/free_key') const { readSleepState } = require('./validator_stake_service/address_sleep') const { readTokenRow } = require('./validator_stake_service/token_balance_read') +const { readMintState, overAddressCap } = require('./validator_stake_service/mint_rules') const STAKE_TICK = 'XCHAIN' // One stake that clears every capability floor at once. The highest is the hub's llm attestation @@ -174,12 +175,10 @@ async function readChainState(sdk, address, pubkey, keyCtx) { if (!e || !e.incompleteRead) throw e } - // Read the faucet caps from the token's mints group; a token with no such - // group is an unreadable answer, not a MAX_MINT of 0. + // Read the token's mint rules the indexer judges a MINT by; an unreported + // value is an unreadable answer, not an unset 0. const token = entity(await getStakeToken(sdk)) - const mints = (token && token.mints && typeof token.mints === 'object') ? token.mints : null - const mintUnreadable = mints ? null : 'the ' + STAKE_TICK + ' token read returned an unexpected shape (no mints group), ' + - 'so its faucet caps are unknown' + const mintState = await readMintState(sdk, address, token, keyCtx.coins, STAKE_TICK) // An existing stake on this pubkey means v1 would be rejected (the indexer // refuses a pubkey that already carries one); say so instead of spending. @@ -196,37 +195,57 @@ async function readChainState(sdk, address, pubkey, keyCtx) { // Read whether the stake address put itself to sleep: the indexer rejects its MINTs and STAKE then. const sleepState = await readSleepState(sdk, address, keyCtx.coins) - return { coinBal, coinPending, tokenBal, mintMax: num(mints && mints.max), - mintAddressMax: num(mints && mints.address_max), mintUnreadable, ...holders, ...sleepState } + return { coinBal, coinPending, tokenBal, ...mintState, ...holders, ...sleepState } } // Plan the mints: how many transactions, at what amount each, to lift the // balance to `amount`. Pure, so it is testable without a network. // `mintUnreadable` names why the caps could not be read, so it is not reported as MAX_MINT 0. -function planMints(network, tokenBal, amount, mintMax, mintAddressMax, mintUnreadable) { +// `mintRules` carries the read lock/window verdict (mintClosed), the address's +// earlier self-mints (mintedSoFar) and a failed read of them (mintedUnknown). +function planMints(network, tokenBal, amount, mintMax, mintAddressMax, mintUnreadable, mintRules = {}) { if (tokenBal === null) { return { short: null, mints: [], reason: 'the ' + STAKE_TICK + ' balance is unavailable (the explorer\'s ' + 'balance list could not be read to the end), so the shortfall is unknown. Retry, or check the ' + 'explorer; this does not mean the address holds none.' } } - const short = Math.max(0, amount - tokenBal) - if (short === 0) return { short, mints: [], reason: null } + if (tokenBal >= amount) return { short: 0, mints: [], reason: null } + // Subtract and split in exact 1e-8 units: float arithmetic on a fractional balance yields + // MINT amounts past 8 places, which the indexer rejects as malformed after the fee is spent. + const balUnits = amountUnits(tokenBal) + const amountU = amountUnits(amount) + if (balUnits === null || amountU === null) { + return { short: null, mints: [], reason: 'the ' + STAKE_TICK + ' balance (' + tokenBal + ') or the stake amount (' + + amount + ') is not an amount of at most 8 decimal places, so the shortfall cannot be planned exactly.' } + } + if (balUnits >= amountU) return { short: 0, mints: [], reason: null } + const shortUnits = amountU - balUnits + const short = Number(fromUnits(shortUnits)) if (network === 'mainnet') { - return { short, mints: [], reason: 'XCHAIN is not mintable on mainnet: acquire ' + short + ' more and re-run.' } + return { short, mints: [], reason: 'XCHAIN is not mintable on mainnet: acquire ' + fromUnits(shortUnits) + ' more and re-run.' } } if (mintUnreadable) { return { short, mints: [], reason: mintUnreadable + '; cannot plan the mints for the shortfall. Retry, or check the explorer.' } } - if (!(mintMax > 0)) { - return { short, mints: [], reason: 'the ' + STAKE_TICK + ' token reports no open mint (MAX_MINT is 0); cannot mint the shortfall.' } + if (!Number.isFinite(mintMax) || mintMax < 0) { + return { short, mints: [], reason: 'the ' + STAKE_TICK + ' token\'s MAX_MINT is unknown; cannot plan the mints for the shortfall. Retry, or check the explorer.' } } + if (mintRules.mintClosed) return { short, mints: [], reason: mintRules.mintClosed } + if (mintRules.mintedUnknown) return { short, mints: [], reason: mintRules.mintedUnknown } + // MAX_MINT 0 is the indexer's "no per-transaction cap", so the whole shortfall goes in one MINT. + const capUnits = mintMax > 0 ? amountUnits(mintMax) : null + const perMint = capUnits !== null && capUnits > 0n ? capUnits : shortUnits const mints = [] - let left = short - while (left > 0) { const a = Math.min(mintMax, left); mints.push(a); left -= a } + for (let left = shortUnits; left > 0n; ) { + const a = left < perMint ? left : perMint + mints.push(Number(fromUnits(a))) + left -= a + } + const minted = mintRules.mintedSoFar || 0 let reason = null - if (mintAddressMax > 0 && short > mintAddressMax) { - reason = 'the shortfall (' + short + ') exceeds the per-address mint cap (' + mintAddressMax + '); ' + - 'this address cannot mint enough on its own.' + if (overAddressCap(minted, short, mintAddressMax)) { + reason = 'the shortfall (' + short + ')' + (minted ? ' plus the ' + minted + ' this address already minted' : '') + + ' exceeds the per-address mint cap (' + mintAddressMax + '); this address cannot mint enough on its own.' } return { short, mints, reason } } diff --git a/src/services/validator_stake_service/free_key.js b/src/services/validator_stake_service/free_key.js index 4e69b6df..19141323 100644 --- a/src/services/validator_stake_service/free_key.js +++ b/src/services/validator_stake_service/free_key.js @@ -128,9 +128,21 @@ function sumAmounts(rows) { if (u === null) return null total += u } + return fromUnits(total) +} + +// A non-negative amount, a number or a plain decimal string, as 1e-8 units; null when it is neither. +// A number is read at 8 places, so a float that stands for an 8-place decimal comes back as that decimal. +function amountUnits(amount) { + if (typeof amount === 'number') return Number.isFinite(amount) && amount >= 0 ? toUnits(amount.toFixed(UNIT_DIGITS)) : null + return toUnits(amount) +} + +// Non-negative 1e-8 units as a plain decimal string, trailing zeros trimmed. +function fromUnits(units) { const scale = 10n ** BigInt(UNIT_DIGITS) - const frac = (total % scale).toString().padStart(UNIT_DIGITS, '0').replace(/0+$/, '') - return (total / scale).toString() + (frac ? '.' + frac : '') + const frac = (units % scale).toString().padStart(UNIT_DIGITS, '0').replace(/0+$/, '') + return (units / scale).toString() + (frac ? '.' + frac : '') } // The explorer's indexed tip for the stake chain; throws when it is unreadable. @@ -245,6 +257,9 @@ module.exports = { stakeKeyReuseHeight, stakeRowsHoldingKey, delegationRowsHoldingKey, + isUndeactivated, + amountUnits, + fromUnits, sumAmounts, readDelegationsByPubkey, readKeyHolders, diff --git a/src/services/validator_stake_service/mint_rules.js b/src/services/validator_stake_service/mint_rules.js new file mode 100644 index 00000000..21e785e5 --- /dev/null +++ b/src/services/validator_stake_service/mint_rules.js @@ -0,0 +1,148 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * XChain Node - Validator Stake Mint Rules + * + * The token rules the indexer judges a MINT by (xchain-indexer/src/actions/ + * mint/validate.js), read before the stake run spends any MINT fee: LOCK_MINT, + * the MINT_START_BLOCK/MINT_STOP_BLOCK window at the block a MINT sent now can + * land in, MAX_MINT, and MINT_ADDRESS_MAX against what this address has + * already minted itself. A cap or block of 0 is unset, as in the indexer, and + * a value the explorer did not report is unknown, never 0. The CLI only reads + * these rules; consensus stays in the indexer. + ********************************************************************/ + +const { readTip } = require('./free_key') + +// The explorer's per-method row cap, and the OFFSET ceiling that bounds a sweep. +const PAGE_LIMIT = 100 +const MAX_PAGES = 1001 +// XCHAIN carries 8 decimal places; amounts are summed and compared in these integer units. +const UNITS = 1e8 + +// A mints-group value as a number, or null when the explorer did not report one. +function reported(x) { + if (x === null || x === undefined || x === '') return null + const n = Number(x) + return Number.isFinite(n) && n >= 0 ? n : null +} + +/** + * The token's mint caps, lock and window. `unreadable` names every value the + * explorer did not report, so a missing cap never reads as the unset 0. + */ +function readTokenMintRules(token, tick) { + const mints = (token && token.mints && typeof token.mints === 'object') ? token.mints : null + if (!mints) { + return { max: null, addressMax: null, unreadable: 'the ' + tick + ' token read returned an unexpected ' + + 'shape (no mints group), so its faucet caps are unknown' } + } + const locked = (token.locks && typeof token.locks === 'object') ? token.locks.mint : undefined + const rules = { max: reported(mints.max), addressMax: reported(mints.address_max), + startBlock: reported(mints.start_block), stopBlock: reported(mints.stop_block), locked } + const missing = [['max', 'mints.max'], ['addressMax', 'mints.address_max'], ['startBlock', 'mints.start_block'], + ['stopBlock', 'mints.stop_block']].filter(([key]) => rules[key] === null).map(([, name]) => name) + if (typeof locked !== 'boolean') missing.push('locks.mint') + rules.unreadable = missing.length + ? 'the ' + tick + ' token read did not report ' + missing.join(', ') + ', so its mint rules are unknown' + : null + return rules +} + +/** + * Why the indexer would reject a MINT landing at landBlock for the token's + * lock or window, or null when the mint is open there. + */ +function mintClosedReason(rules, landBlock, tick) { + const none = 'the ' + tick + ' token has no open mint: ' + if (rules.locked) return none + 'its mint is locked (LOCK_MINT), and the indexer rejects every MINT of it.' + if (rules.startBlock > 0 && landBlock < rules.startBlock) { + return none + 'minting opens at block ' + rules.startBlock + ' (MINT_START_BLOCK), and a MINT sent now ' + + 'lands at block ' + landBlock + ' at the earliest. Re-run from block ' + rules.startBlock + '.' + } + if (rules.stopBlock > 0 && landBlock > rules.stopBlock) { + return none + 'minting closed after block ' + rules.stopBlock + ' (MINT_STOP_BLOCK), and a MINT sent now ' + + 'lands at block ' + landBlock + ' at the earliest.' + } + return null +} + +// One row's amount in units when it is a valid MINT of `tick` authored by `address`, else 0. +function selfMintUnits(row, address, tick) { + if (!row || row.status !== 'valid' || String(row.source) !== String(address) || String(row.tick) !== tick) return 0 + const n = Number(row.amount) + if (row.amount === null || row.amount === '' || !Number.isFinite(n) || n < 0) { + throw new Error('MINT action ' + row.action_index + ' has no readable amount') + } + return Math.round(n * UNITS) +} + +/** + * What this address has already minted itself: its valid MINT rows of `tick` + * on the explorer's source lane, paged to the end, which is the total the + * indexer holds MINT_ADDRESS_MAX against. Throws on anything short of a + * complete answer, so a failed read never counts as nothing minted. + */ +async function readSelfMinted(sdk, address, tick) { + if (!sdk.explorer || typeof sdk.explorer.getMints !== 'function') throw new Error('this SDK has no getMints') + let units = 0 + let seen = 0 + for (let page = 1; ; page++) { + if (page > MAX_PAGES) throw new Error('mint read incomplete: more than ' + (MAX_PAGES * PAGE_LIMIT) + ' rows') + const v = await sdk.explorer.getMints(address, 'source', { page, limit: PAGE_LIMIT, sortorder: 'ASC' }) + if (!v || !Array.isArray(v.data)) throw new Error('the mint lookup by source returned no row list') + for (const row of v.data) units += selfMintUnits(row, address, tick) + seen += v.data.length + const total = Number(v.total) + const known = v.total !== undefined && v.total !== null && Number.isInteger(total) && total >= 0 + if (known ? seen >= total : v.data.length < PAGE_LIMIT) break + if (!v.data.length) throw new Error('mint read incomplete: saw ' + seen + ' of ' + total + ' rows') + } + return units / UNITS +} + +/** + * The mint rules as plan state: { mintMax, mintAddressMax, mintUnreadable, + * mintClosed, mintedSoFar, mintedUnknown }. A failed tip or mint-history read + * comes back as a reason string, never as an open mint or zero minted. + */ +async function readMintState(sdk, address, token, coins, tick) { + const rules = readTokenMintRules(token, tick) + const state = { mintMax: rules.max, mintAddressMax: rules.addressMax, mintUnreadable: rules.unreadable, + mintClosed: null, mintedSoFar: null, mintedUnknown: null } + if (rules.unreadable) return state + try { + state.mintClosed = rules.locked ? mintClosedReason(rules, null, tick) + : mintClosedReason(rules, (await readTip(sdk, coins)) + 1, tick) + } catch (e) { + state.mintClosed = 'could not confirm the ' + tick + ' mint window is open (' + e.message + '). The ' + + 'indexer rejects a MINT outside it after the fee is spent, so nothing is minted until this can be ' + + 'read. Retry, or check the explorer.' + } + if (!(rules.addressMax > 0)) return state + try { + state.mintedSoFar = await readSelfMinted(sdk, address, tick) + } catch (e) { + state.mintedUnknown = 'could not read what ' + address + ' has already minted (' + e.message + '), so ' + + 'the per-address mint cap cannot be checked. The indexer counts every earlier MINT from this address ' + + 'against it and rejects an over-cap MINT after the fee is spent. Retry, or check the explorer.' + } + return state +} + +/** Whether `minted` plus `short` passes the per-address cap `cap` (0 is no cap), compared in units. */ +function overAddressCap(minted, short, cap) { + return cap > 0 && Math.round((minted + short) * UNITS) > Math.round(cap * UNITS) +} + +module.exports = { readTokenMintRules, mintClosedReason, readSelfMinted, readMintState, overAddressCap } diff --git a/src/services/validator_stake_service/stake_operations.js b/src/services/validator_stake_service/stake_operations.js index 43c5d98f..33d3898b 100644 --- a/src/services/validator_stake_service/stake_operations.js +++ b/src/services/validator_stake_service/stake_operations.js @@ -17,6 +17,13 @@ const { getLogger } = require('../../observability/logger') const { sendLockFor, releaseOnIndexWait, holdForSend } = require('./send_lock') const { sleepRefusal, sleepUnknownRefusal } = require('./address_sleep') +const { amountUnits, fromUnits } = require('./free_key') + +// A planned MINT amount as the plain decimal the wire carries (at most 8 places, above 0), or null. +function mintAmountText(amount) { + const units = amountUnits(amount) + return units === null || units === 0n ? null : fromUnits(units) +} function defaultLog() { const logger = getLogger() @@ -36,11 +43,16 @@ function logStakeBalances(log, network, coins, pubkey, address, amount, state, p const tokenHeld = state.tokenBal === null ? 'unavailable (balance list read incomplete)' : state.tokenBal + ' held' log(' ' + STAKE_TICK.padEnd(15) + ': ' + tokenHeld + ', ' + amount + ' to stake' + (plan.short ? ', short ' + plan.short : '')) - if (state.mintMax) log(' faucet caps : ' + state.mintMax + ' per MINT, ' + (state.mintAddressMax || 'no') + ' per address') + // A MAX_MINT of 0 is a token with no per-transaction cap, so it prints as such rather than vanishing. + if (Number.isFinite(state.mintMax)) { + log(' faucet caps : ' + (state.mintMax || 'no cap') + ' per MINT, ' + (state.mintAddressMax || 'no') + ' per address' + + (state.mintedSoFar ? ' (' + state.mintedSoFar + ' already minted)' : '')) + } } function logStakeSteps(log, amount, pubkey, plan, timing, STAKE_TICK, paren) { - const steps = plan.mints.map((a, i) => 'MINT ' + (i + 1) + '/' + plan.mints.length + ': ' + a + ' ' + STAKE_TICK) + const steps = plan.mints.map((a, i) => 'MINT ' + (i + 1) + '/' + plan.mints.length + ': ' + + (mintAmountText(a) || a) + ' ' + STAKE_TICK) steps.push('STAKE v1: ' + amount + ' ' + STAKE_TICK + ' to ' + pubkey) log('') log(' Steps:') @@ -196,13 +208,20 @@ async function broadcastStake(context) { // the whole run lands in a single block (see chainedInputs). --serialize // restores the old one-action-per-block behaviour, which costs a block per // step and is only worth it if chaining ever misbehaves on a venue. + // Check every MINT amount before the first send: one past 8 places is rejected after its fee is spent. + const mintTexts = plan.mints.map(mintAmountText) + const malformed = plan.mints.filter((a, i) => mintTexts[i] === null) + if (malformed.length) { + throw context.fail('the planned MINT amount ' + malformed.join(', ') + ' is not a ' + STAKE_TICK + ' amount above 0 ' + + 'with at most 8 decimal places, which the indexer rejects. Nothing was sent.') + } const progress = { chain: !opts.serialize, sent: [], prevTxid: null, chainBroken: false } for (let i = 0; i < plan.mints.length; i++) { log('') - log(' Sending MINT ' + (i + 1) + '/' + plan.mints.length + ' (' + plan.mints[i] + ' ' + STAKE_TICK + ')...') + log(' Sending MINT ' + (i + 1) + '/' + plan.mints.length + ' (' + mintTexts[i] + ' ' + STAKE_TICK + ')...') const inputs = await linkInputs(context, progress) await sendStakeAction(context, progress, 'MINT', - { VERSION: 0, TICK: STAKE_TICK, AMOUNT: String(plan.mints[i]) }, opts.serialize === true, inputs) + { VERSION: 0, TICK: STAKE_TICK, AMOUNT: mintTexts[i] }, opts.serialize === true, inputs) } // Form the STAKE's own link to the last mint BEFORE deciding whether to @@ -255,7 +274,6 @@ async function finishStake(context, progress, stakeInputs) { } // Resolve --amount: the default only when it is absent, else whole XCHAIN above 0, refused rather than coerced. -// Whole units only: the mint split is float arithmetic, so a fractional shortfall would send malformed MINT amounts. function resolveStakeAmount(raw, DEFAULT_STAKE_AMOUNT, fail, STAKE_TICK) { if (raw === undefined || raw === null) return DEFAULT_STAKE_AMOUNT const text = String(raw).trim() @@ -304,7 +322,7 @@ function createStakeValidator(helpers) { // Take the lock before reading the plan, so no other broadcast sends between this read and these sends. sendLock.hold() const state = await readChainState(sdk, address, pubkey, { network, coins, cooldownBlocks: timing.cooldownBlocks }) - const plan = planMints(network, state.tokenBal, amount, state.mintMax, state.mintAddressMax, state.mintUnreadable) + const plan = planMints(network, state.tokenBal, amount, state.mintMax, state.mintAddressMax, state.mintUnreadable, state) logStakeBalances(log, network, coins, pubkey, address, amount, state, plan, STAKE_TICK) if (state.existing || state.delegated) { @@ -327,7 +345,7 @@ function createStakeValidator(helpers) { } return broadcastStake({ opts, sdk, session, address, coins, pubkey, amount, plan, log, timing, - explorerUrl, chainedInputs, waitForBalance, paren, STAKE_TICK, sendLock }) + explorerUrl, chainedInputs, waitForBalance, paren, STAKE_TICK, sendLock, fail }) } } diff --git a/src/services/validator_stake_service/unstake_operations.js b/src/services/validator_stake_service/unstake_operations.js index 78c5a2cc..0d48e5eb 100644 --- a/src/services/validator_stake_service/unstake_operations.js +++ b/src/services/validator_stake_service/unstake_operations.js @@ -17,7 +17,8 @@ const { getLogger } = require('../../observability/logger') // Reads /validators to the end: one unpaged read drops every stake past the newest 100 rows. const { readValidatorSet } = require('./validator_set_read') -const { sumAmounts } = require('./free_key') +// The stake command's own tip read and withdrawn-row test, so both commands judge one key alike. +const { sumAmounts, readTip, isUndeactivated } = require('./free_key') const { sendLockFor, releaseOnIndexWait } = require('./send_lock') const { readAddressSleep, sleepRefusal, sleepUnknownRefusal } = require('./address_sleep') @@ -66,11 +67,6 @@ function logUnstakeSuccess(log, active, timing, coins, pubkey, STAKE_TICK, paren log('') } -// An empty deactivation_block is a stake no UNSTAKE or ROLLCALL eviction has touched. -function isUndeactivated(row) { - return row.deactivation_block === null || row.deactivation_block === undefined || row.deactivation_block === '' -} - // Sort rows as the indexer admits an UNSTAKE landing at landBlock: undeactivated AND activation_block <= landBlock. // The explorer keeps status='valid' on deactivated rows, so status alone never means "active". function classifyUnstakeRows(rows, landBlock) { @@ -96,20 +92,13 @@ function withdrawnStake(admissible) { } // The explorer's indexed tip for the stake coin; unreadable means refuse, never guess. -async function readTip(sdk, coins, fail) { - let tip +async function readTipOrRefuse(sdk, coins, fail) { try { - const status = await sdk.explorer.getStatus() - tip = Number(status && status.last_block && status.last_block[coins.stakeCoin]) + return await readTip(sdk, coins) } catch (e) { throw fail('could not read the chain tip (' + e.message + '), so this run cannot tell ' + 'whether the stake is active yet. Nothing was sent.') } - if (!Number.isInteger(tip) || tip < 0) { - throw fail('the explorer reported no last block for ' + coins.stakeCoin + ', so this run cannot tell ' + - 'whether the stake is active yet. Nothing was sent.') - } - return tip } // Refuse unless `address` owns every undeactivated row: the indexer rejects an UNSTAKE v0 @@ -181,7 +170,7 @@ async function resolveUnstakeTarget({ sdk, coins, pubkey, address, timing, log, assertOwnsStake(rows.filter(isUndeactivated), address, fail) // The UNSTAKE lands no earlier than the next block, so that is the block admission is judged at. - const tip = await readTip(sdk, coins, fail) + const tip = await readTipOrRefuse(sdk, coins, fail) const { admissible, pending } = classifyUnstakeRows(rows, tip + 1) if (!admissible.length) { const from = Math.min(...pending.map(r => Number(r.activation_block))) diff --git a/test/helpers/stake_harness.js b/test/helpers/stake_harness.js index 922eebf8..d2c3ca0c 100644 --- a/test/helpers/stake_harness.js +++ b/test/helpers/stake_harness.js @@ -26,7 +26,14 @@ function makeExplorer(chain) { ? sinon.stub().rejects(Object.assign( new Error('Explorer returned HTTP 404 for /RBTC/api/token/XCHAIN'), { code: 'EXPLORER_HTTP_404', details: { status: 404 } })) - : sinon.stub().resolves({ mints: { max: chain.mintMax ?? 10000, address_max: chain.addressMax ?? 50000 } }), + : sinon.stub().resolves({ + mints: { max: chain.mintMax ?? 10000, address_max: chain.addressMax ?? 50000, + start_block: chain.mintStart ?? 0, stop_block: chain.mintStop ?? 0 }, + locks: { mint: chain.mintLocked ?? false } }), + // The MINT rows the explorer's source lane holds for the stake address; none by default. + getMints: chain.mintsThrow + ? sinon.stub().rejects(new Error(chain.mintsThrow)) + : sinon.stub().resolves({ total: (chain.priorMints || []).length, data: chain.priorMints || [] }), // The whole validator set, which is the method the SDK actually has. // Carries an unrelated validator too, so the pubkey filter is exercised // rather than "the only row wins". diff --git a/test/integration/module_lifecycle.test.js b/test/integration/module_lifecycle.test.js index 50b5eaa2..a715d144 100644 --- a/test/integration/module_lifecycle.test.js +++ b/test/integration/module_lifecycle.test.js @@ -219,7 +219,7 @@ describe('Integration: Module Lifecycle (LevelDB state)', function () { const moduleOps = proxyquire('../../src/operations/module_operations', { '../services/docker_service': { - stopContainerByName: async (id, seconds) => { + stopContainer: async (id, seconds) => { capture._history.push({ command: 'docker stop -t ' + seconds + ' ' + id, type: 'exec', options: {} }) return { stopped: true, seconds: 1, killed: false, exitCode: 0 } }, @@ -263,7 +263,7 @@ describe('Integration: Module Lifecycle (LevelDB state)', function () { const moduleOps = proxyquire('../../src/operations/module_operations', { '../services/docker_service': { - stopContainerByName: async (id, seconds) => { + stopContainer: async (id, seconds) => { capture._history.push({ command: 'docker stop -t ' + seconds + ' ' + id, type: 'exec', options: {} }) return { stopped: true, seconds: 1, killed: false, exitCode: 0 } }, diff --git a/test/unit/bootstrap_archive_meta.test.js b/test/unit/bootstrap_archive_meta.test.js index 703de388..61349513 100644 --- a/test/unit/bootstrap_archive_meta.test.js +++ b/test/unit/bootstrap_archive_meta.test.js @@ -266,3 +266,36 @@ describe('BootstrapArchiveMeta', function () { }) }) }) + +// The tracker's restorebootstrap reads the same bootstrap.json trimmed and lowercased; node must +// reach the same verdict on it, or one archive restores on one path and is refused on the other. +describe('BootstrapArchiveMeta identity normalization', function () { + describe('compareArchiveIdentity() normalization shared with the tracker', function () { + const target = { module: 'xchain-utxo-tracker', coin: 'bitcoin', network: 'mainnet' } + const full = { format: 1, module: 'xchain-utxo-tracker', coin: 'bitcoin', network: 'mainnet', height: 1, created: null } + + it('matches an identity that differs only in case or padding', function () { + const result = meta.compareArchiveIdentity({ ...full, coin: 'Bitcoin', network: ' MAINNET ' }, target) + expect(result).to.deep.equal({ status: 'match', mismatches: [], unchecked: [] }) + }) + + it('reads a blank identity field as undeclared, not as a mismatch', function () { + for (const blank of ['', ' ']) { + const result = meta.compareArchiveIdentity({ ...full, coin: blank }, target) + expect(result).to.deep.equal({ status: 'unchecked', mismatches: [], unchecked: ['coin'] }) + } + }) + + it('still refuses a different coin whatever its case', function () { + const result = meta.compareArchiveIdentity({ ...full, coin: 'DogeCoin' }, target) + expect(result.status).to.equal('mismatch') + expect(result.mismatches).to.deep.equal([{ field: 'coin', archive: 'DogeCoin', target: 'bitcoin' }]) + }) + + it('keeps a target with no value for a declared field a mismatch', function () { + const result = meta.compareArchiveIdentity(full, { ...target, coin: undefined }) + expect(result.status).to.equal('mismatch') + expect(result.mismatches.map(m => m.field)).to.deep.equal(['coin']) + }) + }) +}) diff --git a/test/unit/cli_error_handling.test.js b/test/unit/cli_error_handling.test.js index 66200af1..832ed91b 100644 --- a/test/unit/cli_error_handling.test.js +++ b/test/unit/cli_error_handling.test.js @@ -254,3 +254,35 @@ describe('CLI unhandled-rejection backstop', function () { expect(exitStub.calledWith(1)).to.be.true }) }) + +// A runbook that stops a service before a backup reads the exit status, so a +// start, stop or restart that failed must not leave the shell with success. +describe('CLI `start`/`stop`/`restart` exit code', function () { + + let exitStub, errorStub + + beforeEach(function () { + exitStub = sinon.stub(process, 'exit') + errorStub = sinon.stub(console, 'error') + }) + + afterEach(function () { sinon.restore() }) + + for (const [verb, op] of [['start', 'startModules'], ['stop', 'stopModules'], ['restart', 'restartModules']]) { + it(`exits NON-ZERO and names the module when ${verb} fails`, async function () { + const failure = new Error(`${verb} failed for 1 module: xchain-indexer (bitcoin regtest): docker said no`) + const program = loadCli({ [op]: sinon.stub().rejects(failure) }) + await program.parseAsync([verb, 'xchain-indexer', 'bitcoin', 'regtest'], { from: 'user' }) + expect(errorStub.calledWithMatch(new RegExp(`${verb} failed: ${verb} failed for 1 module: xchain-indexer`))).to.be.true + expect(exitStub.calledWith(1)).to.be.true + expect(exitStub.calledWith(0)).to.be.false + }) + + it(`exits ZERO when ${verb} succeeds`, async function () { + const program = loadCli({ [op]: sinon.stub().resolves(true) }) + await program.parseAsync([verb, 'xchain-indexer', 'bitcoin', 'regtest'], { from: 'user' }) + expect(exitStub.calledWith(0)).to.be.true + expect(exitStub.calledWith(1)).to.be.false + }) + } +}) diff --git a/test/unit/cli_ref_for_pre_check.test.js b/test/unit/cli_ref_for_pre_check.test.js index 836f977b..21ad9095 100644 --- a/test/unit/cli_ref_for_pre_check.test.js +++ b/test/unit/cli_ref_for_pre_check.test.js @@ -75,6 +75,49 @@ describe('cli refForPreCheck()', function () { }) }) +// A no-ref `update` that the CLI did not move hands its resolved tag on through +// XCHAIN_NODE_UPDATE_TARGET; the hub preCheck stages must come from that tag too, +// not from a second releases/latest lookup that can answer differently. +describe('cli refForPreCheck() with a self-update tag handed on', function () { + let saved + + beforeEach(function () { + saved = process.env.XCHAIN_NODE_UPDATE_TARGET + process.env.XCHAIN_NODE_UPDATE_TARGET = 'v0.11.0' + }) + + afterEach(function () { + if (saved === undefined) delete process.env.XCHAIN_NODE_UPDATE_TARGET + else process.env.XCHAIN_NODE_UPDATE_TARGET = saved + }) + + it('stages a no-ref update from the handed-on tag', function () { + expect(refForPreCheck('update', cmd([]))).to.equal('v0.11.0') + expect(refForPreCheck('update', cmd(undefined))).to.equal('v0.11.0') + expect(refForPreCheck('update', cmd(['xchain-indexer']))).to.equal('v0.11.0') + }) + + it('keeps a ref named on the update command over the handed-on tag', function () { + expect(refForPreCheck('update', cmd(['develop', 'xchain-indexer']))).to.equal('develop') + expect(refForPreCheck('update', cmd(['v0.9.0']))).to.equal('v0.9.0') + }) + + it('never lets the handed-on tag pin an install or another command', function () { + expect(refForPreCheck('install', cmd([]))).to.equal(null) + expect(refForPreCheck('install', cmd(['all', 'bitcoin', 'regtest']))).to.equal(null) + expect(refForPreCheck('ps', cmd([]))).to.equal(null) + }) + + it('still yields null for a refused argument shape', function () { + expect(refForPreCheck('update', cmd(['xchain-node']))).to.equal(null) + }) + + it('yields null for a no-ref update when no tag was handed on', function () { + delete process.env.XCHAIN_NODE_UPDATE_TARGET + expect(refForPreCheck('update', cmd([]))).to.equal(null) + }) +}) + // commandRepairsHub decides which commands survive a hub that is not answering. // // preCheck pushes local config to the hub before every state-changing command. diff --git a/test/unit/database_service.test/mariadb_exec/root_password_persisted_datadir.test.js b/test/unit/database_service.test/mariadb_exec/root_password_persisted_datadir.test.js new file mode 100644 index 00000000..7d7072a4 --- /dev/null +++ b/test/unit/database_service.test/mariadb_exec/root_password_persisted_datadir.test.js @@ -0,0 +1,218 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const fs = require('fs') +const os = require('os') +const path = require('path') +const { sinon, expect, VALID_CONTAINER_ID, makeStubs, loadDatabaseService } = require('../helpers/harness') + +// A datadir pinned with XCHAIN_NODE_DB_DATA_DIR survives `docker rm`, and the mariadb image +// ignores MYSQL_ROOT_PASSWORD once /mysql exists, so a recreated container keeps the +// old root password. No password may be stored for it until it authenticates. + +const DENIED = "mariadb-admin: connect to server at 'localhost' failed\nerror: 'Access denied for user 'root'@'localhost''" + +const madeDirs = [] +after(() => { for (const dir of madeDirs) fs.rmSync(dir, { recursive: true, force: true }) }) + +// Make a temporary datadir, already initialised (holding mysql/) unless told otherwise. +function makeDatadir({ initialised = true } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xn-datadir-')) + madeDirs.push(dir) + if (initialised) fs.mkdirSync(path.join(dir, 'mysql')) + return dir +} + +// Answer docker the way a host with no DB container, a working run, and a server whose root +// password is `realRoot` would; `down` makes every ping fail as a server that never came up. +function fakeDocker(stubs, { realRoot, down = false }) { + stubs.execFileAsync.callsFake(async (cmd, args, opts) => { + if (args[0] === 'inspect') throw new Error('Error: No such container: xchain-node-database') + if (args[0] === 'run') return { stdout: VALID_CONTAINER_ID + '\n', stderr: '' } + if (args[0] === 'exec' && args.includes('mariadb-admin')) { + if (down) throw Object.assign(new Error('ping failed'), { stderr: "Can't connect to local server through socket" }) + if (opts && opts.env && opts.env.MYSQL_PWD === realRoot) return { stdout: 'mysqld is alive\n', stderr: '' } + return { stdout: '', stderr: DENIED } + } + return { stdout: '', stderr: '' } + }) +} + +// Run `fn` with the datadir, env override and stdin TTY set as asked, restoring all three after. +async function withEnv({ datadir, envPassword = null, tty = false }, fn) { + const saved = { dir: process.env.XCHAIN_NODE_DB_DATA_DIR, pw: process.env.XCHAIN_NODE_DB_ROOT_PASSWORD } + const savedIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') + if (datadir === undefined) delete process.env.XCHAIN_NODE_DB_DATA_DIR + else process.env.XCHAIN_NODE_DB_DATA_DIR = datadir + if (envPassword === null) delete process.env.XCHAIN_NODE_DB_ROOT_PASSWORD + else process.env.XCHAIN_NODE_DB_ROOT_PASSWORD = envPassword + Object.defineProperty(process.stdin, 'isTTY', { value: tty, configurable: true }) + try { + return await fn() + } finally { + if (savedIsTTY) Object.defineProperty(process.stdin, 'isTTY', savedIsTTY) + else delete process.stdin.isTTY + if (saved.dir === undefined) delete process.env.XCHAIN_NODE_DB_DATA_DIR + else process.env.XCHAIN_NODE_DB_DATA_DIR = saved.dir + if (saved.pw === undefined) delete process.env.XCHAIN_NODE_DB_ROOT_PASSWORD + else process.env.XCHAIN_NODE_DB_ROOT_PASSWORD = saved.pw + } +} + +// Resolve the root password with no DB container, docker confirming it gone. +async function resolveRoot({ datadir, stored = null, envPassword = null, tty = false, realRoot = 'old-root' }) { + const stubs = makeStubs() + stubs.getDbRootPassword.returns(null) + stubs.loadDbRootPassword.returns(stored) + fakeDocker(stubs, { realRoot }) + return withEnv({ datadir, envPassword, tty }, async () => { + const ds = loadDatabaseService(stubs) + let err = null + let result = null + try { result = await ds.askMariadbRootPassword('', '') } catch (e) { err = e } + return { stubs, err, result } + }) +} + +describe('DatabaseService', function () { + afterEach(() => sinon.restore()) + + describe('askMariadbRootPassword() over a datadir that already holds a database', function () { + + it('uses the stored copy over a different env override and saves nothing, naming no value', async function () { + const warn = sinon.stub(console, 'warn') + const { stubs, err, result } = await resolveRoot({ datadir: makeDatadir(), stored: 'old-root', envPassword: 'new-root' }) + expect(err).to.equal(null) + expect(result).to.equal('old-root') + expect(stubs.saveDbRootPassword.called).to.equal(false) + const text = warn.args.map(a => a.join(' ')).join('\n') + expect(text).to.include('XCHAIN_NODE_DB_ROOT_PASSWORD differs') + expect(text).to.not.include('new-root') + expect(text).to.not.include('old-root') + }) + + it('uses the stored copy with no env override, without prompting or saving', async function () { + const { stubs, result } = await resolveRoot({ datadir: makeDatadir(), stored: 'old-root' }) + expect(result).to.equal('old-root') + expect(stubs.saveDbRootPassword.called).to.equal(false) + }) + + it('uses the env override when nothing is stored, without saving it', async function () { + const { stubs, result } = await resolveRoot({ datadir: makeDatadir(), envPassword: 'env-root' }) + expect(result).to.equal('env-root') + expect(stubs.saveDbRootPassword.called).to.equal(false) + }) + + it('refuses with no candidate and no TTY, naming the datadir and the variable', async function () { + const datadir = makeDatadir() + const { stubs, err } = await resolveRoot({ datadir }) + expect(err).to.be.an('error') + expect(err.message).to.include(datadir) + expect(err.message).to.include('XCHAIN_NODE_DB_ROOT_PASSWORD') + expect(stubs.setDbRootPassword.called).to.equal(false) + expect(stubs.saveDbRootPassword.called).to.equal(false) + }) + + }) +}) + +describe('DatabaseService', function () { + afterEach(() => sinon.restore()) + + describe('askMariadbRootPassword() over a datadir that already holds a database', function () { + + it('treats a named volume as initialised, since it persists', async function () { + const { stubs, result } = await resolveRoot({ datadir: 'xn-db-volume', envPassword: 'env-root' }) + expect(result).to.equal('env-root') + expect(stubs.saveDbRootPassword.called).to.equal(false) + }) + + it('treats a datadir it cannot read as initialised', async function () { + if (typeof process.getuid === 'function' && process.getuid() === 0) this.skip() + const datadir = makeDatadir() + fs.chmodSync(datadir, 0o000) + try { + const { stubs, result } = await resolveRoot({ datadir, envPassword: 'env-root' }) + expect(result).to.equal('env-root') + expect(stubs.saveDbRootPassword.called).to.equal(false) + } finally { + fs.chmodSync(datadir, 0o700) + } + }) + + it('keeps the fresh-install path for a datadir with no mysql/ in it', async function () { + const { stubs, result } = await resolveRoot({ datadir: makeDatadir({ initialised: false }), envPassword: 'env-root' }) + expect(result).to.equal('env-root') + expect(stubs.saveDbRootPassword.calledOnceWith('env-root')).to.equal(true) + }) + }) +}) + +// Install the DB container with no container present and docker confirming it gone. +async function install({ datadir, stored = null, envPassword = null, realRoot, down = false }) { + const stubs = makeStubs() + stubs.getDbRootPassword.returns(null) + stubs.loadDbRootPassword.returns(stored) + fakeDocker(stubs, { realRoot, down }) + return withEnv({ datadir, envPassword }, async () => { + const ds = loadDatabaseService(stubs) + let err = null + let result = null + try { result = await ds.buildDatabaseModule('', '') } catch (e) { err = e } + return { stubs, err, result } + }) +} + +describe('DatabaseService', function () { + afterEach(() => sinon.restore()) + + describe('buildDatabaseModule() over a datadir that already holds a database', function () { + + it('stores the chosen root password once it authenticates against the new container', async function () { + const { stubs, err, result } = await install({ datadir: makeDatadir(), stored: 'old-root', envPassword: 'new-root', realRoot: 'old-root' }) + expect(err).to.equal(null) + expect(result).to.equal(VALID_CONTAINER_ID) + expect(stubs.saveDbRootPassword.calledOnceWithExactly('old-root')).to.equal(true) + }) + + it('falls back to the env override when the stored copy is refused, and stores that one', async function () { + const { stubs, err } = await install({ datadir: makeDatadir(), stored: 'stale-root', envPassword: 'real-root', realRoot: 'real-root' }) + expect(err).to.equal(null) + expect(stubs.setDbRootPassword.calledWith('real-root')).to.equal(true) + expect(stubs.saveDbRootPassword.calledOnceWithExactly('real-root')).to.equal(true) + }) + + it('refuses and stores nothing when no candidate authenticates', async function () { + const datadir = makeDatadir() + const { stubs, err } = await install({ datadir, stored: 'stale-root', envPassword: 'other-root', realRoot: 'real-root' }) + expect(err).to.be.an('error') + expect(err.message).to.include(datadir) + expect(err.message).to.include('credentials.json was left untouched') + expect(stubs.saveDbRootPassword.called).to.equal(false) + }) + + it('refuses and stores nothing when the new container never answers a ping', async function () { + const { stubs, err } = await install({ datadir: makeDatadir(), stored: 'old-root', realRoot: 'old-root', down: true }) + expect(err).to.be.an('error') + expect(err.message).to.match(/did not answer a root ping/) + expect(stubs.saveDbRootPassword.called).to.equal(false) + }) + + it('checks nothing after the start on a fresh datadir, which takes the password it was given', async function () { + const { stubs, err } = await install({ datadir: makeDatadir({ initialised: false }), envPassword: 'env-root', realRoot: 'env-root' }) + expect(err).to.equal(null) + expect(stubs.saveDbRootPassword.calledOnceWithExactly('env-root')).to.equal(true) + const pings = stubs.execFileAsync.getCalls().filter(c => c.args[1].includes('mariadb-admin')) + expect(pings).to.have.lengthOf(0) + }) + }) +}) diff --git a/test/unit/discovery_service.test.js b/test/unit/discovery_service.test.js index 3638d9f3..4017040c 100644 --- a/test/unit/discovery_service.test.js +++ b/test/unit/discovery_service.test.js @@ -268,3 +268,51 @@ describe('DiscoveryService.scanAndRegisterModules fail-closed registry', functio expect(err.message).to.match(/Couldn't reconcile database/) }) }) + +// A container started by hand from a module image under another name matches +// its key only through the image fallback, so it must never take the row from +// the container that holds the CLI-assigned name. +describe('DiscoveryService.scanAndRegisterModules name-over-image ranking', function () { + + afterEach(() => sinon.restore()) + + const IMAGE = 'xchain-node-bitcoin-mainnet-xchain-indexer' + const debug = { Names: 'some-debug-name', Image: IMAGE, ID: 'debugdebugdebugdebug', State: 'running' } + const real = { Names: IMAGE, Image: IMAGE, ID: 'realrealrealrealreal', State: 'running' } + + it('gives the row to the named container when a newer image-only container also runs', async function () { + const { svc, db } = loadService({ containers: [debug, real] }) + await svc.scanAndRegisterModules({ silent: true }) + sinon.assert.calledWith(db.setModuleContainer, 'xchain-indexer', 'bitcoin', 'mainnet', 'realrealrealrealreal') + sinon.assert.neverCalledWith(db.setModuleContainer, 'xchain-indexer', 'bitcoin', 'mainnet', 'debugdebugdebugdebug') + }) + + it('leaves a row already holding the named container alone', async function () { + const { svc, db } = loadService({ + containers: [debug, real], + db: { + getModuleContainer: sinon.stub().resolves('realrealrealrealreal'), + getAllModuleContainers: sinon.stub().resolves([ + { module: 'xchain-indexer', coin: 'bitcoin', network: 'mainnet', container_id: 'realrealrealrealreal' }, + ]), + }, + }) + const changed = await svc.scanAndRegisterModules({ silent: true }) + expect(changed).to.equal(0) + sinon.assert.notCalled(db.setModuleContainer) + }) + + it('keeps the row on a stopped named container over a running image-only one', async function () { + const { svc, db } = loadService({ containers: [debug, { ...real, State: 'exited' }] }) + await svc.scanAndRegisterModules({ silent: true }) + sinon.assert.calledOnceWithExactly(db.setModuleContainer, 'xchain-indexer', 'bitcoin', 'mainnet', 'realrealrealrealreal') + }) + + it('still prefers a running image-only container over a stopped one when neither is named', async function () { + const stale = { Names: 'renamed-old', Image: IMAGE, ID: 'staleSTALEstaleSTALE', State: 'exited' } + const live = { Names: 'renamed-new', Image: IMAGE, ID: 'liveLIVEliveLIVElive', State: 'running' } + const { svc, db } = loadService({ containers: [stale, live] }) + await svc.scanAndRegisterModules({ silent: true }) + sinon.assert.calledOnceWithExactly(db.setModuleContainer, 'xchain-indexer', 'bitcoin', 'mainnet', 'liveLIVEliveLIVElive') + }) +}) diff --git a/test/unit/migration_precondition_service.test/10_refusal_lists_recorded_ledger.test.js b/test/unit/migration_precondition_service.test/10_refusal_lists_recorded_ledger.test.js new file mode 100644 index 00000000..015c9233 --- /dev/null +++ b/test/unit/migration_precondition_service.test/10_refusal_lists_recorded_ledger.test.js @@ -0,0 +1,111 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// An indexer database can embody a manual migration's end state with no ledger +// row for it. That counts as applied when deciding what is missing, but the +// indexer runner reads only the ledger, so an unscoped migrate run still executes +// the file: the refusal's "would apply" list has to name it. + +const sinon = require('sinon') +const { expect } = require('chai') + +const { XChainService } = require('../../../src/config') +const { assertRequiredMigrationsApplied } = require('../../../src/services/migration_precondition_service') +const { readAppliedMigrations } = require('../../../src/services/migration_precondition_service/migration_scan') + +const REQUIRED = '2026-09-13-gated.sql' +const RECORDED = '2026-05-01-recorded.sql' +const SATISFIED = '2026-07-15-markets-dedup-unique-pair.sql' + +// Batch-mode answers by query: the counts, the ledger-plus-schema read, and the ledger-only read. +function runnerFor({ union, ledger, ledgerThrows = false }) { + return async (sql) => { + if (/TABLE_NAME = 'schema_migrations'/.test(sql)) return '1' + if (/COUNT\(\*\)/.test(sql)) return '40' + if (/ UNION /.test(sql)) return union.join('\n') + if (ledgerThrows) throw new Error('Lost connection') + return ledger.join('\n') + } +} + +function guardDeps({ applied, ledger, pendingManual }) { + return { + cloneGit: sinon.stub().resolves(), + listDeployPreconditionMigrations: sinon.stub().returns([REQUIRED]), + readAppliedMigrations: sinon.stub().resolves({ state: 'ledger', applied: new Set(applied), + ...(ledger ? { ledger: new Set(ledger) } : {}) }), + runningBuildSupportsPerFileMigrations: sinon.stub().resolves(false), + pendingManualMigrations: sinon.stub().returns(pendingManual), + readLedgerRenames: sinon.stub().returns({ state: 'absent', renames: {} }) + } +} + +async function refusalOf(deps) { + try { + await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', 'master', deps) + } catch (err) { return err } + return null +} + +describe('migration precondition guard: refusal lists against the recorded ledger', () => { + let warn + beforeEach(() => { warn = sinon.stub(console, 'warn') }) + afterEach(() => { warn.restore() }) + + describe('readAppliedMigrations', () => { + const indexer = { database: 'XChain_BTC_Mainnet_Indexer', coin: 'bitcoin', network: 'mainnet' } + + it('keeps the recorded names apart from the schema-satisfied ones on an indexer database', async () => { + const res = await readAppliedMigrations(indexer, { runner: runnerFor({ union: [RECORDED, SATISFIED], ledger: [RECORDED] }) }) + expect(res.state).to.equal('ledger') + expect([...res.applied]).to.have.members([RECORDED, SATISFIED]) + expect([...res.ledger]).to.have.members([RECORDED]) + }) + + it('reads a decoder ledger once and reports it as both sets', async () => { + const decoder = { database: 'XChain_BTC_Mainnet_Decoder', coin: 'bitcoin', network: 'mainnet' } + const res = await readAppliedMigrations(decoder, { runner: async (sql) => + /TABLE_NAME = 'schema_migrations'/.test(sql) ? '1' : /COUNT\(\*\)/.test(sql) ? '40' : RECORDED }) + expect([...res.applied]).to.have.members([RECORDED]) + expect(res.ledger).to.equal(res.applied) + }) + + it('reports a failed ledger-only read as null without failing the applied read', async () => { + const res = await readAppliedMigrations(indexer, + { runner: runnerFor({ union: [RECORDED, SATISFIED], ledger: [], ledgerThrows: true }) }) + expect(res.state).to.equal('ledger') + expect([...res.applied]).to.have.members([RECORDED, SATISFIED]) + expect(res.ledger).to.equal(null) + }) + }) + + describe('assertRequiredMigrationsApplied', () => { + it('lists a schema-satisfied file with no ledger row among what an unscoped run applies', async () => { + const deps = guardDeps({ applied: [RECORDED, SATISFIED], ledger: [RECORDED], pendingManual: [REQUIRED, SATISFIED] }) + const err = await refusalOf(deps) + expect(err).to.be.an('error') + const subtracted = deps.pendingManualMigrations.firstCall.args[1] + expect(subtracted.has(RECORDED)).to.equal(true) + expect(subtracted.has(SATISFIED)).to.equal(false) + expect(err.message).to.contain('which is 2 file(s)') + expect(err.message).to.contain(SATISFIED + ' (the live schema already matches it; no ledger row records it)') + expect(err.message).to.contain(REQUIRED + ' (the one you need)') + }) + + it('still passes a required file the live schema already satisfies', async () => { + const deps = guardDeps({ applied: [RECORDED, REQUIRED], ledger: [RECORDED], pendingManual: [] }) + expect(await refusalOf(deps)).to.equal(null) + }) + + it('falls back to the applied set when no recorded ledger was read', async () => { + const deps = guardDeps({ applied: [RECORDED, SATISFIED], pendingManual: [REQUIRED] }) + const err = await refusalOf(deps) + expect(err).to.be.an('error') + expect(deps.pendingManualMigrations.firstCall.args[1].has(SATISFIED)).to.equal(true) + }) + }) +}) diff --git a/test/unit/module_operations.test/03_module_controls.test.js b/test/unit/module_operations.test/03_module_controls.test.js index 9fb2a82a..6c41b4ef 100644 --- a/test/unit/module_operations.test/03_module_controls.test.js +++ b/test/unit/module_operations.test/03_module_controls.test.js @@ -103,13 +103,13 @@ describe('moduleOperations', function () { const stubs = makeStubs() const ops = loadOperations(stubs) await ops.startModules({ bitcoin: { mainnet: ['xchain-encoder'] } }) - expect(stubs.db.getModuleContainer.calledOnce).to.be.true + expect(stubs.db.getModuleContainerStrict.calledOnce).to.be.true expect(stubs.startContainer.calledWith('container-id-123')).to.be.true }) it('skips module when container ID is not found', async function () { const stubs = makeStubs() - stubs.db.getModuleContainer.resolves(null) + stubs.db.getModuleContainerStrict.resolves(null) const ops = loadOperations(stubs) const result = await ops.startModules({ bitcoin: { mainnet: ['xchain-encoder'] } }) expect(result).to.be.true @@ -135,8 +135,8 @@ describe('moduleOperations', function () { const stubs = makeStubs() const ops = loadOperations(stubs) await ops.stopModules({ bitcoin: { mainnet: ['xchain-encoder'] } }) - expect(stubs.stopContainerByName.calledWith('container-id-123', 30)).to.be.true - expect(stubs.stopContainer.called).to.be.false + expect(stubs.stopContainer.calledOnceWithExactly('container-id-123', 30)).to.be.true + expect(stubs.stopContainerByName.called).to.be.false }) it('reads the container before the stop so a drain that predates the budget is named', async function () { @@ -146,7 +146,7 @@ describe('moduleOperations', function () { try { await loadOperations(stubs).stopModules({ bitcoin: { mainnet: ['xchain-decoder'] } }) expect(stubs.getContainerStopSettings.calledOnceWith('container-id-123')).to.be.true - expect(stubs.getContainerStopSettings.calledBefore(stubs.stopContainerByName)).to.be.true + expect(stubs.getContainerStopSettings.calledBefore(stubs.stopContainer)).to.be.true expect(warn.args.some(a => /xchain-decoder \(bitcoin mainnet\) was created under a 300 s stop budget/.test(String(a[0])))).to.be.true } finally { warn.restore() diff --git a/test/unit/module_operations.test/17_module_error_paths.test.js b/test/unit/module_operations.test/17_module_error_paths.test.js index ff57812a..c07cc53b 100644 --- a/test/unit/module_operations.test/17_module_error_paths.test.js +++ b/test/unit/module_operations.test/17_module_error_paths.test.js @@ -105,10 +105,12 @@ describe('moduleOperations', function () { it('continues after restartContainer error', async function () { const stubs = makeStubs() - stubs.restartContainer.rejects(new Error('restart failed')) + stubs.restartContainer.onFirstCall().rejects(new Error('restart failed')) const ops = loadOperations(stubs) - const result = await ops.restartModules({ bitcoin: { mainnet: ['xchain-encoder', 'xchain-decoder'] } }) - expect(result).to.be.true + const err = await ops.restartModules({ bitcoin: { mainnet: ['xchain-encoder', 'xchain-decoder'] } }).then(() => null, e => e) + expect(stubs.restartContainer.callCount, 'the second module is still attempted').to.equal(2) + expect(err, 'a failed restart must not resolve').to.be.an('error') + expect(err.message).to.match(/restart failed for 1 module: xchain-encoder \(bitcoin mainnet\): restart failed/) }) }) }) @@ -128,7 +130,7 @@ describe('moduleOperations', function () { it('skips when container ID is not found', async function () { const stubs = makeStubs() - stubs.db.getModuleContainer.resolves(null) + stubs.db.getModuleContainerStrict.resolves(null) const ops = loadOperations(stubs) const result = await ops.stopModules({ bitcoin: { mainnet: ['xchain-encoder'] } }) expect(result).to.be.true @@ -137,10 +139,12 @@ describe('moduleOperations', function () { it('continues after stopContainer error', async function () { const stubs = makeStubs() - stubs.stopContainer.rejects(new Error('stop failed')) + stubs.stopContainer.onFirstCall().rejects(new Error('stop failed')) const ops = loadOperations(stubs) - const result = await ops.stopModules({ bitcoin: { mainnet: ['xchain-encoder'] } }) - expect(result).to.be.true + const err = await ops.stopModules({ bitcoin: { mainnet: ['xchain-encoder', 'xchain-decoder'] } }).then(() => null, e => e) + expect(stubs.stopContainer.callCount, 'the second module is still attempted').to.equal(2) + expect(err, 'a failed stop must not resolve').to.be.an('error') + expect(err.message).to.match(/stop failed for 1 module: xchain-encoder \(bitcoin mainnet\): stop failed/) }) }) }) @@ -156,10 +160,12 @@ describe('moduleOperations', function () { it('continues after startContainer error', async function () { const stubs = makeStubs() - stubs.startContainer.rejects(new Error('start failed')) + stubs.startContainer.onFirstCall().rejects(new Error('start failed')) const ops = loadOperations(stubs) - const result = await ops.startModules({ bitcoin: { mainnet: ['xchain-encoder'] } }) - expect(result).to.be.true + const err = await ops.startModules({ bitcoin: { mainnet: ['xchain-encoder', 'xchain-decoder'] } }).then(() => null, e => e) + expect(stubs.startContainer.callCount, 'the second module is still attempted').to.equal(2) + expect(err, 'a failed start must not resolve').to.be.an('error') + expect(err.message).to.match(/start failed for 1 module: xchain-encoder \(bitcoin mainnet\): start failed/) }) }) }) @@ -259,3 +265,48 @@ describe('moduleOperations', function () { }) }) }) + +// start, stop and restart attempt every selected module and then fail together, +// so a runbook can trust the exit status the CLI derives from the rejection. +describe('moduleOperations', function () { + registerLifecycleHooks(() => {}) + + describe('start/stop/restart: failures are reported, not swallowed', function () { + + it('stop treats a container docker says is gone as stopped, not as a failure', async function () { + const stubs = makeStubs() + const gone = new Error('Error response from daemon: No such container: container-id-123') + stubs.stopContainer.rejects(gone) + const result = await loadOperations(stubs).stopModules({ bitcoin: { mainnet: ['xchain-encoder'] } }) + expect(result).to.be.true + }) + + it('stop fails on a bare-string rejection, which is a failed stop rather than a miss', async function () { + const stubs = makeStubs() + stubs.stopContainer.callsFake(() => Promise.reject('There was an error trying to stop the docker container (container-id-123)')) + const err = await loadOperations(stubs).stopModules({ bitcoin: { mainnet: ['xchain-encoder'] } }).then(() => null, e => e) + expect(err).to.be.an('error') + expect(err.failures).to.have.lengthOf(1) + expect(err.failures[0].reason).to.match(/error trying to stop the docker container/) + }) + + it('fails on a registry read that errored instead of skipping the module as not installed', async function () { + for (const op of ['startModules', 'stopModules', 'restartModules']) { + const stubs = makeStubs() + stubs.db.getModuleContainerStrict.rejects(new Error('pool closed')) + const err = await loadOperations(stubs)[op]({ bitcoin: { mainnet: ['xchain-encoder'] } }).then(() => null, e => e) + expect(err, op).to.be.an('error') + expect(err.message, op).to.match(/registry lookup failed \(pool closed\)/) + expect(stubs.db.getModuleContainer.called, op).to.be.false + } + }) + + it('restart fails when the hub and explorer config refresh after it fails', async function () { + const stubs = makeStubs() + stubs.statusChanged.rejects(new Error('hub unreachable')) + const err = await loadOperations(stubs).restartModules({ bitcoin: { mainnet: ['xchain-encoder'] } }).then(() => null, e => e) + expect(stubs.restartContainer.calledOnce).to.be.true + expect(err.message).to.match(/restarted, but the hub and explorer config refresh failed \(hub unreachable\)/) + }) + }) +}) diff --git a/test/unit/validator_sdk_encoder_compat.test.js b/test/unit/validator_sdk_encoder_compat.test.js index 6d5be3b3..3e7fa0d7 100644 --- a/test/unit/validator_sdk_encoder_compat.test.js +++ b/test/unit/validator_sdk_encoder_compat.test.js @@ -106,7 +106,10 @@ function stubEncoder() { function makeExplorer(chain) { return { getAddress: sinon.stub().resolves({ balances: { confirmed: chain.coin, pending: '0' } }), - getToken: sinon.stub().resolves({ mints: { max: 10000, address_max: 50000 } }), + // An open mint: caps set, no window, no lock, and no earlier MINT from the stake address. + getToken: sinon.stub().resolves({ mints: { max: 10000, address_max: 50000, start_block: 0, stop_block: 0 }, + locks: { mint: false } }), + getMints: sinon.stub().resolves({ total: 0, data: [] }), getValidators: sinon.stub().resolves({ data: [] }), // No delegation holds the key; without an answer the stake command refuses to send. getDelegations: sinon.stub().resolves({ total: 0, data: [] }), diff --git a/test/unit/validator_stake_service.test.js b/test/unit/validator_stake_service.test.js index 0f5edb6a..89fa486d 100644 --- a/test/unit/validator_stake_service.test.js +++ b/test/unit/validator_stake_service.test.js @@ -13,6 +13,7 @@ const { expect } = require('chai') const { planMints, stakeTiming } = require('../../src/services/validator_stake_service') +const { mintClosedReason } = require('../../src/services/validator_stake_service/mint_rules') // The authoritative source for both stake clocks. Read here too, so a test that // asserts the CLI's numbers cannot itself become the place they are frozen. const { getCoinConfig } = require('../../src/coins') @@ -48,7 +49,58 @@ describe('ValidatorStakeService', function () { }) it('flags a token with no open mint', function () { - expect(planMints('testnet', 0, 25000, 0, 0).reason).to.match(/no open mint/) + const mintClosed = mintClosedReason({ locked: true }, 200001, 'XCHAIN') + const p = planMints('testnet', 0, 25000, 10000, 50000, null, { mintClosed }) + expect(p.mints).to.deep.equal([]) + expect(p.reason).to.match(/no open mint/) + }) + + it('reads MAX_MINT 0 as no per-transaction cap and plans one MINT of the shortfall', function () { + expect(planMints('testnet', 0, 25000, 0, 0)).to.deep.equal({ short: 25000, mints: [25000], reason: null }) + expect(planMints('testnet', 0, 25000, 0, 50000).mints).to.deep.equal([25000]) + }) + + it('counts what the address already minted against the per-address cap', function () { + expect(planMints('testnet', 0, 25000, 10000, 50000, null, { mintedSoFar: 30000 }).reason) + .to.match(/plus the 30000 this address already minted exceeds the per-address mint cap/) + expect(planMints('testnet', 0, 25000, 10000, 50000, null, { mintedSoFar: 25000 }).reason).to.equal(null) + }) + + it('refuses when what the address already minted could not be read', function () { + const p = planMints('testnet', 0, 25000, 10000, 50000, null, { mintedUnknown: 'could not read what X has already minted' }) + expect(p.mints).to.deep.equal([]) + expect(p.reason).to.match(/could not read what X has already minted/) + }) + }) +}) + +// A fractional XCHAIN balance is the normal state after an earlier run spent from the address, +// and the indexer refuses a MINT AMOUNT past 8 decimal places as malformed. +describe('ValidatorStakeService exact mint amounts', function () { + describe('planMints() from a fractional balance', function () { + const EIGHT_PLACES = /^\d+(\.\d{1,8})?$/ + + it('plans the exact 8-place shortfall, not float noise', function () { + const p = planMints('testnet', 24999.99877, 25000, 10000, 50000) + expect(p).to.deep.equal({ short: 0.00123, mints: [0.00123], reason: null }) + for (const m of p.mints) expect(m.toFixed(8).replace(/\.?0+$/, '')).to.match(EIGHT_PLACES) + expect(String(p.mints[0])).to.equal('0.00123') + }) + + it('splits a fractional shortfall into cap-sized mints that sum to it exactly', function () { + const p = planMints('testnet', 12000.5, 25000, 10000, 50000) + expect(p.mints).to.deep.equal([10000, 2999.5]) + expect(p.short).to.equal(12999.5) + const p2 = planMints('testnet', 0.1 + 0.2, 25000, 10000, 50000) + expect(p2.mints).to.deep.equal([10000, 10000, 4999.7]) + }) + + it('names the exact shortfall on mainnet', function () { + expect(planMints('mainnet', 24999.99877, 25000, 10000, 50000).reason).to.match(/acquire 0\.00123 more/) + }) + + it('needs no mint when the balance rounds to the stake at 8 places', function () { + expect(planMints('testnet', 24999.999999999, 25000, 10000, 50000)).to.deep.equal({ short: 0, mints: [], reason: null }) }) }) }) diff --git a/test/unit/validator_stake_service.test/mint_rules.test.js b/test/unit/validator_stake_service.test/mint_rules.test.js new file mode 100644 index 00000000..7c01afbf --- /dev/null +++ b/test/unit/validator_stake_service.test/mint_rules.test.js @@ -0,0 +1,160 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// The token rules the indexer judges a MINT by, read before the stake run spends a MINT fee: +// MAX_MINT 0 is no per-transaction cap, LOCK_MINT and the start/stop window refuse, and +// MINT_ADDRESS_MAX counts what the address already minted itself. + +const { expect } = require('chai') + +const { readChainState, planMints } = require('../../../src/services/validator_stake_service') +const { readTokenMintRules, mintClosedReason, readSelfMinted } = + require('../../../src/services/validator_stake_service/mint_rules') +const { ADDRESS, run } = require('../../helpers/stake_harness') + +const TIP = 200000 +const COINS = { stake: 'bitcoin-testnet', stakeCoin: 'TBTC' } +const KEY_CTX = { network: 'testnet', coins: COINS, cooldownBlocks: 1000 } +const OPEN = { max: '10000.00000000', address_max: '50000.00000000', start_block: 0, stop_block: 0 } +const MINTED = (amount, extra = {}) => + ({ source: ADDRESS, tick: 'XCHAIN', amount, status: 'valid', action_index: 7, ...extra }) + +// An SDK whose token, tip and source-lane MINT reads answer with the given bodies. +function fakeSdk({ mints = OPEN, locks = { mint: false }, tip = TIP, getMints } = {}) { + return { + explorer: { + getAddress: async () => ({ balances: { confirmed: '0.002', pending: '0' } }), + getToken: async () => ({ info: { tick: 'XCHAIN' }, mints, locks }), + getValidators: async () => ({ data: [] }), + getDelegations: async () => ({ total: 0, data: [] }), + getStatus: async () => (tip === null ? {} : { last_block: { TBTC: tip } }), + getSleeps: async () => ({ total: 0, data: [] }), + getMints: getMints || (async () => ({ total: 0, data: [] })) + }, + getBalances: async () => ({ data: [{ tick: 'XCHAIN', amount: '0' }] }) + } +} + +// Plan a 25000 stake from a zero balance against what readChainState read. +async function planFor(sdk) { + const s = await readChainState(sdk, ADDRESS, 'ab'.repeat(32), KEY_CTX) + return { state: s, plan: planMints('testnet', s.tokenBal, 25000, s.mintMax, s.mintAddressMax, s.mintUnreadable, s) } +} + +describe('validator stake mint rules', function () { + + it('reads the explorer\'s MAX_MINT of "0.00000000" as no per-transaction cap', async function () { + const { state, plan } = await planFor(fakeSdk({ mints: { ...OPEN, max: '0.00000000', address_max: '0.00000000' } })) + expect(state.mintMax).to.equal(0) + expect(state.mintUnreadable).to.equal(null) + expect(plan).to.deep.equal({ short: 25000, mints: [25000], reason: null }) + }) + + it('reads an unreported cap, window or lock as unknown, never as an unset 0', function () { + const nullMax = readTokenMintRules({ mints: { ...OPEN, max: null }, locks: { mint: false } }, 'XCHAIN') + expect(nullMax.unreadable).to.include('mints.max') + const noWindow = readTokenMintRules({ mints: { max: '1', address_max: '1' }, locks: { mint: false } }, 'XCHAIN') + expect(noWindow.unreadable).to.include('mints.start_block').and.to.include('mints.stop_block') + expect(readTokenMintRules({ mints: OPEN }, 'XCHAIN').unreadable).to.include('locks.mint') + expect(readTokenMintRules({ mints: OPEN, locks: { mint: false } }, 'XCHAIN').unreadable).to.equal(null) + }) + + it('refuses a locked mint before any MINT is planned', async function () { + const { plan } = await planFor(fakeSdk({ locks: { mint: true } })) + expect(plan.mints).to.deep.equal([]) + expect(plan.reason).to.match(/no open mint: its mint is locked \(LOCK_MINT\)/) + }) + + it('refuses a window that opens after the block a MINT sent now lands in', async function () { + const { plan } = await planFor(fakeSdk({ mints: { ...OPEN, start_block: 999999999 } })) + expect(plan.mints).to.deep.equal([]) + expect(plan.reason).to.match(/minting opens at block 999999999 \(MINT_START_BLOCK\)/) + }) + + it('refuses a window that closed before the block a MINT sent now lands in', async function () { + const { plan } = await planFor(fakeSdk({ mints: { ...OPEN, stop_block: TIP } })) + expect(plan.reason).to.match(/minting closed after block 200000 \(MINT_STOP_BLOCK\)/) + }) + + it('judges the window at tip + 1, the indexer\'s inclusive bounds', function () { + expect(mintClosedReason({ startBlock: TIP + 1, stopBlock: 0 }, TIP + 1, 'XCHAIN')).to.equal(null) + expect(mintClosedReason({ startBlock: TIP + 2, stopBlock: 0 }, TIP + 1, 'XCHAIN')).to.match(/opens at block/) + expect(mintClosedReason({ startBlock: 0, stopBlock: TIP + 1 }, TIP + 1, 'XCHAIN')).to.equal(null) + }) + +}) + +describe('validator stake mint rules', function () { + + it('refuses when the tip cannot be read, instead of reading the window as open', async function () { + const { plan } = await planFor(fakeSdk({ tip: null, mints: { ...OPEN, stop_block: TIP + 5 } })) + expect(plan.mints).to.deep.equal([]) + expect(plan.reason).to.match(/could not confirm the XCHAIN mint window is open/) + }) + + it('sums only valid XCHAIN mints this address authored', async function () { + const rows = [MINTED('20000.00000000'), MINTED('10000'), MINTED('5000', { status: 'invalid: MINT_STOP_BLOCK' }), + MINTED('5000', { tick: 'OTHER' }), MINTED('5000', { source: 'mSomeoneElse' })] + const sdk = fakeSdk({ getMints: async () => ({ total: rows.length, data: rows }) }) + expect(await readSelfMinted(sdk, ADDRESS, 'XCHAIN')).to.equal(30000) + const { plan } = await planFor(sdk) + expect(plan.reason).to.match(/plus the 30000 this address already minted exceeds the per-address mint cap \(50000\)/) + }) + + it('pages the source lane to the end and refuses a short read', async function () { + const pages = [] + const full = Array.from({ length: 100 }, () => MINTED('100')) + const sdk = fakeSdk({ getMints: async (q, type, opts) => { + pages.push([type, opts.page]) + return opts.page === 1 ? { total: 101, data: full } : { total: 101, data: opts.page === 2 ? [MINTED('1')] : [] } + } }) + expect(await readSelfMinted(sdk, ADDRESS, 'XCHAIN')).to.equal(10001) + expect(pages).to.deep.equal([['source', 1], ['source', 2]]) + const short = fakeSdk({ getMints: async (q, t, opts) => (opts.page === 1 ? { total: 101, data: full } : { total: 101, data: [] }) }) + const { plan } = await planFor(short) + expect(plan.mints).to.deep.equal([]) + expect(plan.reason).to.match(/could not read what mStakeAddress has already minted \(mint read incomplete/) + }) + + it('skips the mint-history read when the token sets no per-address cap', async function () { + let called = 0 + const sdk = fakeSdk({ mints: { ...OPEN, address_max: '0' }, getMints: async () => { called++; throw new Error('no') } }) + const { plan } = await planFor(sdk) + expect(called).to.equal(0) + expect(plan.reason).to.equal(null) + }) +}) + +describe('validator stake --broadcast against the mint rules', function () { + + it('sends no MINT and no STAKE when earlier mints leave too little of the per-address cap', async function () { + const { result, calls } = await run({ broadcast: true }, { xchain: 0, priorMints: [MINTED('40000')] }) + expect(result.staked).to.be.false + expect(result.blockers.join(' ')).to.match(/already minted exceeds the per-address mint cap/) + expect(calls.mint).to.have.length(0) + expect(calls.stake).to.have.length(0) + }) + + it('sends nothing when the mint is locked or its window is closed', async function () { + for (const chain of [{ mintLocked: true }, { mintStart: 999999999 }, { mintStop: 100 }]) { + const { result, calls } = await run({ broadcast: true }, { xchain: 0, ...chain }) + expect(result.blockers.join(' '), JSON.stringify(chain)).to.match(/no open mint/) + expect(calls.mint.length + calls.stake.length, JSON.stringify(chain)).to.equal(0) + } + }) + + it('mints the whole shortfall in one MINT when the token has no per-transaction cap', async function () { + const { result, calls } = await run({ broadcast: true }, { xchain: 0, mintMax: '0.00000000', addressMax: '0' }) + expect(result.staked).to.equal(true) + expect(calls.mint.map(c => Number(c.params.AMOUNT))).to.deep.equal([25000]) + }) +}) diff --git a/test/unit/validator_stake_service.test/send_lock.test.js b/test/unit/validator_stake_service.test/send_lock.test.js index 82206c93..7329501e 100644 --- a/test/unit/validator_stake_service.test/send_lock.test.js +++ b/test/unit/validator_stake_service.test/send_lock.test.js @@ -52,7 +52,10 @@ function otherCommandGetsLock(command = 'update') { function makeExplorer(chain) { return { getAddress: sinon.stub().resolves({ balances: { confirmed: '0.001', pending: '0' } }), - getToken: sinon.stub().resolves({ mints: { max: 10000, address_max: 50000 } }), + // An open mint: caps set, no window, no lock, and no earlier MINT from the stake address. + getToken: sinon.stub().resolves({ mints: { max: 10000, address_max: 50000, start_block: 0, stop_block: 0 }, + locks: { mint: false } }), + getMints: sinon.stub().resolves({ total: 0, data: [] }), getValidators: sinon.stub().resolves({ data: chain.existing ? [chain.existing] : [] }), getDelegations: sinon.stub().resolves({ total: 0, data: [] }), getStatus: sinon.stub().resolves({ last_block: Object.fromEntries( diff --git a/test/unit/validator_stake_service.test/stake_validator.test.js b/test/unit/validator_stake_service.test/stake_validator.test.js index 54f69122..58d051c8 100644 --- a/test/unit/validator_stake_service.test/stake_validator.test.js +++ b/test/unit/validator_stake_service.test/stake_validator.test.js @@ -273,7 +273,7 @@ describe('ValidatorStakeService', function () { it('calls explorer methods that the published SDK actually exposes', function () { const { XChainSDK } = require('@dankest-llc/xchain-sdk') const sdk = new XChainSDK({ network: 'bitcoin-testnet' }) // offline: no network I/O in the constructor - for (const m of ['getValidators', 'getAddress', 'getToken', 'getDelegations', 'getStatus', 'getSleeps']) + for (const m of ['getValidators', 'getAddress', 'getToken', 'getDelegations', 'getStatus', 'getSleeps', 'getMints']) expect(sdk.explorer[m], 'sdk.explorer.' + m).to.be.a('function') for (const m of ['getBalances', 'session']) expect(sdk[m], 'sdk.' + m).to.be.a('function') @@ -312,4 +312,21 @@ describe('ValidatorStakeService', function () { expect(result.blockers).to.not.be.empty }) }) + + // An address an earlier run spent from holds a fractional XCHAIN balance; the MINT that + // tops it up must carry the exact 8-place shortfall, never float noise the indexer refuses. + describe('stakeValidator() from a fractional XCHAIN balance', function () { + + it('broadcasts the exact 8-place shortfall as the MINT amount', async function () { + const { calls, logged } = await run({ broadcast: true }, { xchain: 24999.99877, coin: '0.001' }) + expect(calls.mint.map(c => c.params)).to.deep.equal([{ VERSION: 0, TICK: 'XCHAIN', AMOUNT: '0.00123' }]) + expect(logged.join('\n')).to.include('Sending MINT 1/1 (0.00123 XCHAIN)') + expect(calls.stake).to.have.length(1) + }) + + it('prints a sub-micro shortfall as a plain decimal in the dry run', async function () { + const { logged } = await run({}, { xchain: 24999.9999999, coin: '0.001' }) + expect(logged.join('\n')).to.include('MINT 1/1: 0.0000001 XCHAIN') + }) + }) }) diff --git a/test/unit/validator_stake_service.test/unstake_validator.test.js b/test/unit/validator_stake_service.test/unstake_validator.test.js index 8e32ac67..6dc7fc33 100644 --- a/test/unit/validator_stake_service.test/unstake_validator.test.js +++ b/test/unit/validator_stake_service.test/unstake_validator.test.js @@ -323,6 +323,11 @@ describe('ValidatorStakeService', function () { expect(err.message).to.match(/could not read the chain tip/) }) + it('refuses before sending when the explorer reports no usable last block', async function () { + const err = await runUnstake({ broadcast: true }, { existing: STAKED, tip: -1 }).then(() => null, e => e) + expect(err && err.message).to.match(/could not read the chain tip \(.*no last block.*Nothing was sent/) + }) + it('withdraws only the admissible rows and reports their sum', async function () { const second = { ...STAKED, action_index: '61', amount: '5000', activation_block: '150320' } const { result, unstakeCalls, logged } = await runUnstake({ broadcast: true }, diff --git a/test/unit/validator_stake_service.test/upstream_reads.test.js b/test/unit/validator_stake_service.test/upstream_reads.test.js index 31fcfc9f..64144c1d 100644 --- a/test/unit/validator_stake_service.test/upstream_reads.test.js +++ b/test/unit/validator_stake_service.test/upstream_reads.test.js @@ -38,7 +38,9 @@ function fakeSdk(addressBody, tokenBody) { } } -const TOKEN = { info: { tick: 'XCHAIN' }, mints: { max: '10000', address_max: '50000' } } +// The full mints and locks groups the explorer serves for a token with an open mint. +const TOKEN = { info: { tick: 'XCHAIN' }, mints: { max: '10000', address_max: '50000', start_block: 0, stop_block: 0 }, + locks: { mint: false } } describe('validator stake upstream reads', function () { From 15617b9ab18d046ade0350cc049dbc5f9303b8dc Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 18:54:18 +0000 Subject: [PATCH 04/50] Pass host INDEX_MAP_PARITY_CHECK through to the sync server container Declare a SYNC_PASSTHROUGH_ENV view and copy its value into the shared service defaults, so a recreate of xchain-sync keeps the operator's setting. --- src/config/env_views.js | 3 ++ src/services/config_service/defaults.js | 7 +++ ...ig_service_sync_parity_passthrough.test.js | 51 +++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 test/unit/config_service_sync_parity_passthrough.test.js diff --git a/src/config/env_views.js b/src/config/env_views.js index 26bbfdde..c9276197 100644 --- a/src/config/env_views.js +++ b/src/config/env_views.js @@ -63,6 +63,9 @@ const LIST_VIEWS = { "XC_ANCHOR_STAKE_REGTEST_ACTIVATION", "XC_ANCHOR_SLASH_REGTEST_ACTIVATION", "XCHAIN_PRICE_INDEXER_DB_SECRET" ]], + // The host names the xchain-sync server container is handed (defaults.js + // configureSharedBeforeHubKey), so a recreate keeps the operator's value. + SYNC_PASSTHROUGH_ENV: ['sync passthrough', ["INDEX_MAP_PARITY_CHECK"]], // The indexer's regtest genesis bootstrap (ConfigService genesisPassthroughVars). INDEXER_GENESIS_ENV: ['indexer genesis', [ "XCHAIN_GENESIS_BLOCK", "XCHAIN_GENESIS_LEDGER_HASH", "XCHAIN_GENESIS_DUMP_HASH", diff --git a/src/services/config_service/defaults.js b/src/services/config_service/defaults.js index b51b4361..870db134 100644 --- a/src/services/config_service/defaults.js +++ b/src/services/config_service/defaults.js @@ -188,6 +188,13 @@ function configureSharedBeforeHubKey(defaultValues) { if (config.ALLOW_NO_COLOCATED_HUB_DB !== undefined && config.ALLOW_NO_COLOCATED_HUB_DB !== "") { defaultValues.ALLOW_NO_COLOCATED_HUB_DB = config.ALLOW_NO_COLOCATED_HUB_DB } + // Host INDEX_MAP_PARITY_CHECK reaches the xchain-sync server container, so a + // recreate keeps it instead of reverting to the image default. + for (const k of ["INDEX_MAP_PARITY_CHECK"]) { + if (config.SYNC_PASSTHROUGH_ENV[k] !== undefined && config.SYNC_PASSTHROUGH_ENV[k] !== "") { + defaultValues[k] = config.SYNC_PASSTHROUGH_ENV[k] + } + } if (config.HUB_API_KEY !== undefined && config.HUB_API_KEY !== "") { defaultValues.HUB_API_KEY = config.HUB_API_KEY } diff --git a/test/unit/config_service_sync_parity_passthrough.test.js b/test/unit/config_service_sync_parity_passthrough.test.js new file mode 100644 index 00000000..a657e3c9 --- /dev/null +++ b/test/unit/config_service_sync_parity_passthrough.test.js @@ -0,0 +1,51 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const { bindEnvViews } = require('../../src/config/env_views') +const defaults = require('../../src/services/config_service/defaults') + +function viewsFor(env) { + return bindEnvViews({ read: (n) => env[n], copy: () => ({ ...env }) }) +} + +function sharedDefaultsWith(env) { + const views = viewsFor(env) + defaults.configure({ + config: { ...views }, Network: {}, Coin: {}, CoinTickerSymbol: {}, XChainService: {}, DB_SEP: "_", + bootstrapDir: "/tmp", NODE_MODULE_NAME: "node", HUB_MODULE_NAME: "hub", + EXPLORER_MODULE_NAME: "explorer", SYNC_MODULE_NAME: "sync", + getDockerContainerImageName: (m) => m, getModuleDatabaseName: (m) => m + }) + const values = defaults.createSharedDefaults() + defaults.configureSharedBeforeHubKey(values) + return values +} + +describe('sync INDEX_MAP_PARITY_CHECK passthrough', () => { + it('the view answers the host value', () => { + expect(viewsFor({ INDEX_MAP_PARITY_CHECK: 'true' }).SYNC_PASSTHROUGH_ENV.INDEX_MAP_PARITY_CHECK).to.equal('true') + }) + + it('the view rejects an undeclared name', () => { + expect(() => viewsFor({}).SYNC_PASSTHROUGH_ENV.NOT_DECLARED).to.throw(/not declared/) + }) + + it('shared defaults carry the host value', () => { + expect(sharedDefaultsWith({ INDEX_MAP_PARITY_CHECK: 'true' }).INDEX_MAP_PARITY_CHECK).to.equal('true') + }) + + it('shared defaults omit it when unset or empty', () => { + expect(sharedDefaultsWith({}).INDEX_MAP_PARITY_CHECK).to.equal(undefined) + expect(sharedDefaultsWith({ INDEX_MAP_PARITY_CHECK: '' }).INDEX_MAP_PARITY_CHECK).to.equal(undefined) + }) +}) From 8b8d255d56a2a5bc36c7c931a7a93cc13338404c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 19:00:58 +0000 Subject: [PATCH 05/50] Scope INDEX_MAP_PARITY_CHECK passthrough to the sync module Move the host value copy into configureSharedAfterHubKey, guarded by the sync module name, so only the xchain-sync server container receives it. Tests now cover the sync module and assert hub, explorer and db do not get it. --- src/services/config_service/defaults.js | 16 +++++++++------- ...onfig_service_sync_parity_passthrough.test.js | 9 ++++++++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/services/config_service/defaults.js b/src/services/config_service/defaults.js index 870db134..5bd09de3 100644 --- a/src/services/config_service/defaults.js +++ b/src/services/config_service/defaults.js @@ -188,13 +188,6 @@ function configureSharedBeforeHubKey(defaultValues) { if (config.ALLOW_NO_COLOCATED_HUB_DB !== undefined && config.ALLOW_NO_COLOCATED_HUB_DB !== "") { defaultValues.ALLOW_NO_COLOCATED_HUB_DB = config.ALLOW_NO_COLOCATED_HUB_DB } - // Host INDEX_MAP_PARITY_CHECK reaches the xchain-sync server container, so a - // recreate keeps it instead of reverting to the image default. - for (const k of ["INDEX_MAP_PARITY_CHECK"]) { - if (config.SYNC_PASSTHROUGH_ENV[k] !== undefined && config.SYNC_PASSTHROUGH_ENV[k] !== "") { - defaultValues[k] = config.SYNC_PASSTHROUGH_ENV[k] - } - } if (config.HUB_API_KEY !== undefined && config.HUB_API_KEY !== "") { defaultValues.HUB_API_KEY = config.HUB_API_KEY } @@ -307,6 +300,15 @@ function configureSharedAfterHubKey(defaultValues, module) { defaultValues.CORS_ORIGIN = config.CORS_ORIGIN } configureExplorerHubFeed(defaultValues, module) + // Host INDEX_MAP_PARITY_CHECK reaches the xchain-sync server container only, + // so a recreate keeps it instead of reverting to the image default. + if (module === SYNC_MODULE_NAME) { + for (const k of ["INDEX_MAP_PARITY_CHECK"]) { + if (config.SYNC_PASSTHROUGH_ENV[k] !== undefined && config.SYNC_PASSTHROUGH_ENV[k] !== "") { + defaultValues[k] = config.SYNC_PASSTHROUGH_ENV[k] + } + } + } if (module === EXPLORER_MODULE_NAME) { if (config.EXPLORER_VM_QUERY_ENABLED !== undefined && config.EXPLORER_VM_QUERY_ENABLED !== "") { defaultValues.EXPLORER_VM_QUERY_ENABLED = config.EXPLORER_VM_QUERY_ENABLED diff --git a/test/unit/config_service_sync_parity_passthrough.test.js b/test/unit/config_service_sync_parity_passthrough.test.js index a657e3c9..e979d21d 100644 --- a/test/unit/config_service_sync_parity_passthrough.test.js +++ b/test/unit/config_service_sync_parity_passthrough.test.js @@ -18,7 +18,7 @@ function viewsFor(env) { return bindEnvViews({ read: (n) => env[n], copy: () => ({ ...env }) }) } -function sharedDefaultsWith(env) { +function sharedDefaultsWith(env, module = 'sync') { const views = viewsFor(env) defaults.configure({ config: { ...views }, Network: {}, Coin: {}, CoinTickerSymbol: {}, XChainService: {}, DB_SEP: "_", @@ -28,6 +28,7 @@ function sharedDefaultsWith(env) { }) const values = defaults.createSharedDefaults() defaults.configureSharedBeforeHubKey(values) + defaults.configureSharedAfterHubKey(values, module) return values } @@ -44,6 +45,12 @@ describe('sync INDEX_MAP_PARITY_CHECK passthrough', () => { expect(sharedDefaultsWith({ INDEX_MAP_PARITY_CHECK: 'true' }).INDEX_MAP_PARITY_CHECK).to.equal('true') }) + it('other shared modules do not receive it', () => { + for (const m of ['hub', 'explorer', 'db']) { + expect(sharedDefaultsWith({ INDEX_MAP_PARITY_CHECK: 'true' }, m).INDEX_MAP_PARITY_CHECK).to.equal(undefined) + } + }) + it('shared defaults omit it when unset or empty', () => { expect(sharedDefaultsWith({}).INDEX_MAP_PARITY_CHECK).to.equal(undefined) expect(sharedDefaultsWith({ INDEX_MAP_PARITY_CHECK: '' }).INDEX_MAP_PARITY_CHECK).to.equal(undefined) From cd8c31373c9c161d965958e367423e8db8337dba Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 19:06:12 +0000 Subject: [PATCH 06/50] Split sync passthrough out of configureSharedAfterHubKey The function spanned 63 lines, over the 60 line limit. The INDEX_MAP_PARITY_CHECK sync-only passthrough now lives in configureSyncPassthrough, leaving the function at 55 lines with unchanged behaviour. --- src/services/config_service/defaults.js | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/services/config_service/defaults.js b/src/services/config_service/defaults.js index 5bd09de3..09b5b8df 100644 --- a/src/services/config_service/defaults.js +++ b/src/services/config_service/defaults.js @@ -295,20 +295,23 @@ function configureManagedHubDiscovery(defaultValues, module) { } } +// Host INDEX_MAP_PARITY_CHECK reaches the xchain-sync server container only, +// so a recreate keeps it instead of reverting to the image default. +function configureSyncPassthrough(defaultValues, module) { + if (module !== SYNC_MODULE_NAME) return + for (const k of ["INDEX_MAP_PARITY_CHECK"]) { + if (config.SYNC_PASSTHROUGH_ENV[k] !== undefined && config.SYNC_PASSTHROUGH_ENV[k] !== "") { + defaultValues[k] = config.SYNC_PASSTHROUGH_ENV[k] + } + } +} + function configureSharedAfterHubKey(defaultValues, module) { if (config.CORS_ORIGIN !== undefined && config.CORS_ORIGIN !== "") { defaultValues.CORS_ORIGIN = config.CORS_ORIGIN } configureExplorerHubFeed(defaultValues, module) - // Host INDEX_MAP_PARITY_CHECK reaches the xchain-sync server container only, - // so a recreate keeps it instead of reverting to the image default. - if (module === SYNC_MODULE_NAME) { - for (const k of ["INDEX_MAP_PARITY_CHECK"]) { - if (config.SYNC_PASSTHROUGH_ENV[k] !== undefined && config.SYNC_PASSTHROUGH_ENV[k] !== "") { - defaultValues[k] = config.SYNC_PASSTHROUGH_ENV[k] - } - } - } + configureSyncPassthrough(defaultValues, module) if (module === EXPLORER_MODULE_NAME) { if (config.EXPLORER_VM_QUERY_ENABLED !== undefined && config.EXPLORER_VM_QUERY_ENABLED !== "") { defaultValues.EXPLORER_VM_QUERY_ENABLED = config.EXPLORER_VM_QUERY_ENABLED From 1183b8f1a6292d3fea03d890e4aa98b6eda4b7a0 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 12:23:31 -0700 Subject: [PATCH 07/50] Exercise sync passthrough through container config Drive the parity passthrough test through ConfigService.getDefaultConfig so removing the sync-specific wiring fails the focused acceptance. Keep the environment view documentation aligned with the current configuration stage. --- src/config/env_views.js | 2 +- ...ig_service_sync_parity_passthrough.test.js | 62 ++++++++++++------- 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/src/config/env_views.js b/src/config/env_views.js index c9276197..915de28b 100644 --- a/src/config/env_views.js +++ b/src/config/env_views.js @@ -64,7 +64,7 @@ const LIST_VIEWS = { "XCHAIN_PRICE_INDEXER_DB_SECRET" ]], // The host names the xchain-sync server container is handed (defaults.js - // configureSharedBeforeHubKey), so a recreate keeps the operator's value. + // configureSharedAfterHubKey), so a recreate keeps the operator's value. SYNC_PASSTHROUGH_ENV: ['sync passthrough', ["INDEX_MAP_PARITY_CHECK"]], // The indexer's regtest genesis bootstrap (ConfigService genesisPassthroughVars). INDEXER_GENESIS_ENV: ['indexer genesis', [ diff --git a/test/unit/config_service_sync_parity_passthrough.test.js b/test/unit/config_service_sync_parity_passthrough.test.js index e979d21d..be515685 100644 --- a/test/unit/config_service_sync_parity_passthrough.test.js +++ b/test/unit/config_service_sync_parity_passthrough.test.js @@ -12,24 +12,32 @@ const { expect } = require('chai') const { bindEnvViews } = require('../../src/config/env_views') -const defaults = require('../../src/services/config_service/defaults') +const { + makeServiceWithConfig, + DB_MODULE_NAME, + HUB_MODULE_NAME, + EXPLORER_MODULE_NAME, + SYNC_MODULE_NAME +} = require('./config_service.test/helpers.test') function viewsFor(env) { return bindEnvViews({ read: (n) => env[n], copy: () => ({ ...env }) }) } -function sharedDefaultsWith(env, module = 'sync') { - const views = viewsFor(env) - defaults.configure({ - config: { ...views }, Network: {}, Coin: {}, CoinTickerSymbol: {}, XChainService: {}, DB_SEP: "_", - bootstrapDir: "/tmp", NODE_MODULE_NAME: "node", HUB_MODULE_NAME: "hub", - EXPLORER_MODULE_NAME: "explorer", SYNC_MODULE_NAME: "sync", - getDockerContainerImageName: (m) => m, getModuleDatabaseName: (m) => m - }) - const values = defaults.createSharedDefaults() - defaults.configureSharedBeforeHubKey(values) - defaults.configureSharedAfterHubKey(values, module) - return values +async function withParityValue(value, fn) { + const previous = process.env.INDEX_MAP_PARITY_CHECK + if (value === undefined) delete process.env.INDEX_MAP_PARITY_CHECK + else process.env.INDEX_MAP_PARITY_CHECK = value + try { + await fn() + } finally { + if (previous === undefined) delete process.env.INDEX_MAP_PARITY_CHECK + else process.env.INDEX_MAP_PARITY_CHECK = previous + } +} + +async function containerConfig(module) { + return makeServiceWithConfig('').getDefaultConfig(module, null, null) } describe('sync INDEX_MAP_PARITY_CHECK passthrough', () => { @@ -41,18 +49,28 @@ describe('sync INDEX_MAP_PARITY_CHECK passthrough', () => { expect(() => viewsFor({}).SYNC_PASSTHROUGH_ENV.NOT_DECLARED).to.throw(/not declared/) }) - it('shared defaults carry the host value', () => { - expect(sharedDefaultsWith({ INDEX_MAP_PARITY_CHECK: 'true' }).INDEX_MAP_PARITY_CHECK).to.equal('true') + it('puts the host value in the sync container config', async () => { + await withParityValue('true', async () => { + const config = await containerConfig(SYNC_MODULE_NAME) + expect(config).to.have.property('INDEX_MAP_PARITY_CHECK', 'true') + }) }) - it('other shared modules do not receive it', () => { - for (const m of ['hub', 'explorer', 'db']) { - expect(sharedDefaultsWith({ INDEX_MAP_PARITY_CHECK: 'true' }, m).INDEX_MAP_PARITY_CHECK).to.equal(undefined) - } + it('does not put the host value in other shared container configs', async () => { + await withParityValue('true', async () => { + for (const module of [HUB_MODULE_NAME, EXPLORER_MODULE_NAME, DB_MODULE_NAME]) { + const config = await containerConfig(module) + expect(config).to.not.have.property('INDEX_MAP_PARITY_CHECK') + } + }) }) - it('shared defaults omit it when unset or empty', () => { - expect(sharedDefaultsWith({}).INDEX_MAP_PARITY_CHECK).to.equal(undefined) - expect(sharedDefaultsWith({ INDEX_MAP_PARITY_CHECK: '' }).INDEX_MAP_PARITY_CHECK).to.equal(undefined) + it('omits the value from the sync container config when unset or empty', async () => { + for (const value of [undefined, '']) { + await withParityValue(value, async () => { + const config = await containerConfig(SYNC_MODULE_NAME) + expect(config).to.not.have.property('INDEX_MAP_PARITY_CHECK') + }) + } }) }) From 18db87cffcf6d103d1d17fae0e46eac02b29cd36 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 15:20:35 -0700 Subject: [PATCH 08/50] Refresh audited production dependencies Update compatible lockfile resolutions for fast-uri, hono, and ip-address to patched releases. --- package-lock.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 62ef1775..75c78cd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3332,9 +3332,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -3859,9 +3859,9 @@ } }, "node_modules/hono": { - "version": "4.13.5", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", - "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -3976,9 +3976,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", "license": "MIT", "engines": { "node": ">= 12" From fb1a5ff39ddadb174f05340d16671d57c58acf37 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 15:27:56 -0700 Subject: [PATCH 09/50] Replace vulnerable Bitcoin signing dependencies Resolve message signing through maintained compatible forks backed by noble curve primitives. Remove the legacy elliptic dependency chain while preserving SDK signing behavior. --- package-lock.json | 89 ++++++++++++++++++++++++----------------------- 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/package-lock.json b/package-lock.json index 75c78cd9..d62f0695 100644 --- a/package-lock.json +++ b/package-lock.json @@ -617,7 +617,7 @@ "axios": "^1.18.1", "bip174": "2.1.1", "bitcoinjs-lib": "6.1.7", - "bitcoinjs-message": "^2.2.0", + "bitcoinjs-message": "npm:@oipwg/bitcoinjs-message@2.2.0-beta.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "ecpair": "2.1.0", @@ -632,6 +632,16 @@ "node": ">=22.0.0" } }, + "node_modules/@exodus/bitcoinerlab-secp256k1": { + "version": "1.0.5-exodus.1", + "resolved": "https://registry.npmjs.org/@exodus/bitcoinerlab-secp256k1/-/bitcoinerlab-secp256k1-1.0.5-exodus.1.tgz", + "integrity": "sha512-vk4WBauCD9YmH4nO7loye6gpoFUAFlx9D+s3/1M5ayp9AD9yBRNTujb2lgWeZuJbyeJpiAJBEKwLS1QrVJdwrg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5", + "@noble/secp256k1": "^1.7.1" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -1426,6 +1436,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/secp256k1": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.2.tgz", + "integrity": "sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==", + "license": "MIT", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1931,20 +1953,20 @@ } }, "node_modules/bitcoinjs-message": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/bitcoinjs-message/-/bitcoinjs-message-2.2.0.tgz", - "integrity": "sha512-103Wy3xg8Y9o+pdhGP4M3/mtQQuUWs6sPuOp1mYphSUoSMHjHTlkj32K4zxU8qMH0Ckv23emfkGlFWtoWZ7YFA==", + "name": "@oipwg/bitcoinjs-message", + "version": "2.2.0-beta.0", + "resolved": "https://registry.npmjs.org/@oipwg/bitcoinjs-message/-/bitcoinjs-message-2.2.0-beta.0.tgz", + "integrity": "sha512-wh+9TYx8hGVcjjydM2XkwBuqqkE6EAs0yGb3xDzr1cl16vjo/X6JrmRl5Ti/SH46PnFDDD3MScNAKjzeNZdvpA==", "license": "MIT", "dependencies": { "bech32": "^1.1.3", "bs58check": "^2.1.2", - "buffer-equals": "^1.0.3", - "create-hash": "^1.1.2", - "secp256k1": "^3.0.1", - "varuint-bitcoin": "^1.0.1" + "create-hash": "^1.2.0", + "secp256k1": "^4.0.0", + "varuint-bitcoin": "^1.1.2" }, "engines": { - "node": ">=0.10" + "node": ">=0.12" } }, "node_modules/bitcoinjs-message/node_modules/base-x": { @@ -1982,6 +2004,20 @@ "safe-buffer": "^5.1.2" } }, + "node_modules/bitcoinjs-message/node_modules/secp256k1": { + "name": "@exodus/secp256k1", + "version": "5.0.0-exodus.4.patch.2", + "resolved": "https://registry.npmjs.org/@exodus/secp256k1/-/secp256k1-5.0.0-exodus.4.patch.2.tgz", + "integrity": "sha512-GMHXCyLdwttG99gsDPL8qhPeGSXvzOTLgcCIg1CcsZHaarJQx8Le5q1w99dcUnEizS+uH6gQfumP1qD1BdvIRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@exodus/bitcoinerlab-secp256k1": "^1.0.5-exodus.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/blessed": { "version": "0.1.81", "resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz", @@ -2786,21 +2822,6 @@ "dev": true, "license": "ISC" }, - "node_modules/elliptic": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", - "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" - } - }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -5341,26 +5362,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/secp256k1": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-3.8.1.tgz", - "integrity": "sha512-tArjQw2P0RTdY7QmkNehgp6TVvQXq6ulIhxv8gaH6YubKG/wxxAoNKcbuXjDhybbc+b2Ihc7e0xxiGN744UIiQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "bip66": "^1.1.5", - "bn.js": "^4.11.8", - "create-hash": "^1.2.0", - "drbg.js": "^1.0.1", - "elliptic": "^6.5.7", - "nan": "^2.14.0", - "safe-buffer": "^5.1.2" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/seedrandom": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", From fd309ba91457ae7389b2854d314f963f947169fc Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 01:43:35 +0000 Subject: [PATCH 10/50] Honour configured indexer DB name in bootstrap restore rehearsal The rehearsal read the derived default database name directly. Route it through configuredDatabaseName so an operator-set INDEXER_DB_NAME is the database compared against. Adds a unit test for the resolution. --- src/cli/commands.js | 13 +++++- .../commands_configured_database_name.test.js | 44 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 test/unit/cli/commands_configured_database_name.test.js diff --git a/src/cli/commands.js b/src/cli/commands.js index 6565491b..2cd8b2cb 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -26,6 +26,7 @@ const { assertArchiveIdentity } = require('../services/bootstrap_service/restore const { getWorkDir } = require('../services/bootstrap_service/workspace') const database = require('../services/database_service') const { getDefaultConfig, getModuleDatabaseName } = require('../services/config_service') +const { configuredDatabaseName } = require('../utils/module_database_name') const { dockerMariadbArgs, mariadbEnv } = require('../utils/docker_mariadb') const { getLogger } = require('../observability/logger') @@ -119,9 +120,17 @@ function countsMatch(source, restored) { source.issuances === restored.issuances } +// The rehearsal compares against the database the indexer actually reads, which an +// operator may have renamed through INDEXER_DB_NAME. +async function rehearsalSourceDatabaseName(coin, network) { + const module = config.XChainService.XCHAIN_INDEXER + const moduleConfig = await getDefaultConfig(module, coin, network) + return configuredDatabaseName(module, moduleConfig, getModuleDatabaseName(module, coin, network)) +} + async function rehearseBootstrapRestore(coin, network, options) { if (network !== 'mainnet') throw new Error('Bootstrap restore rehearsal requires mainnet') - const sourceName = getModuleDatabaseName(config.XChainService.XCHAIN_INDEXER, coin, network) + const sourceName = await rehearsalSourceDatabaseName(coin, network) const archive = await rehearsalArchive(coin, network, options.file) const context = await rehearsalContext(coin, network) let created = false @@ -396,4 +405,4 @@ function registerToolCommands(program, deps) { registerBootstrapRestoreRehearsal(program, deps) } -module.exports = { registerPrimaryCommands, registerToolCommands, showRowCount, countsMatch } +module.exports = { registerPrimaryCommands, registerToolCommands, showRowCount, countsMatch, rehearsalSourceDatabaseName } diff --git a/test/unit/cli/commands_configured_database_name.test.js b/test/unit/cli/commands_configured_database_name.test.js new file mode 100644 index 00000000..859a24f2 --- /dev/null +++ b/test/unit/cli/commands_configured_database_name.test.js @@ -0,0 +1,44 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// Pins that the CLI command layer resolves the indexer database through +// configuredDatabaseName, so an operator-configured INDEXER_DB_NAME is honoured. + +const sinon = require('sinon') +const { expect } = require('chai') +const proxyquire = require('proxyquire').noCallThru().noPreserveCache() + +const realConfigService = require('../../../src/services/config_service') + +function load(moduleConfig) { + const getDefaultConfig = sinon.stub().resolves(moduleConfig) + const getModuleDatabaseName = sinon.stub().returns('derived_default_db') + const commands = proxyquire('../../../src/cli/commands', { + '../services/config_service': Object.assign({}, realConfigService, { getDefaultConfig, getModuleDatabaseName }) + }) + return { commands, getDefaultConfig, getModuleDatabaseName } +} + +describe('cli commands configured database name', () => { + it('uses the operator-configured INDEXER_DB_NAME', async () => { + const { commands, getDefaultConfig } = load({ INDEXER_DB_NAME: 'custom_indexer_db' }) + expect(await commands.rehearsalSourceDatabaseName('bitcoin', 'mainnet')).to.equal('custom_indexer_db') + expect(getDefaultConfig.calledOnce).to.equal(true) + }) + + it('falls back to the derived name when none is configured', async () => { + const { commands } = load({ INDEXER_DB_NAME: ' ' }) + expect(await commands.rehearsalSourceDatabaseName('bitcoin', 'mainnet')).to.equal('derived_default_db') + }) + + it('rejects an unsafe configured name', async () => { + const { commands } = load({ INDEXER_DB_NAME: 'bad name; drop' }) + let error = null + try { await commands.rehearsalSourceDatabaseName('bitcoin', 'mainnet') } catch (err) { error = err } + expect(error).to.be.an('error') + }) +}) From 2d7a06ba5b43b6af87b24082e47f4a26c049a2a0 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 02:01:27 +0000 Subject: [PATCH 11/50] Record the tracker archive height from the frozen store Read the height of the archived directory right after the compress (and before the fallback-path restart), and prefer it over the live status readings when present. Falls back to the existing behavior when the store reports nothing usable. --- .../bootstrap_service/tracker_archive.js | 8 +++-- .../bootstrap_service/tracker_height.js | 32 +++++++++++++++++-- ...acker_archive_height_after_restart.test.js | 21 ++++++++++++ .../bootstrap_service/tracker_height.test.js | 25 +++++++++++++++ 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/services/bootstrap_service/tracker_archive.js b/src/services/bootstrap_service/tracker_archive.js index 9f2617b3..69460300 100644 --- a/src/services/bootstrap_service/tracker_archive.js +++ b/src/services/bootstrap_service/tracker_archive.js @@ -38,7 +38,7 @@ const { redactSecrets } = require('../../utils/helpers') let { maybeSignBootstrap } = require('./archive_signing') let { startProgress, buildDateTimeString, getWorkDir, assertBootstrapCapacity, ensureDir, ensureDirWritable } = require('./workspace') const trackerHeight = require('./tracker_height') -const { readTrackerCommittedHeight, readTrackerHeightAfterRestart, chooseArchiveHeight, refusePreStopOnlyHeight } = trackerHeight +const { readTrackerCommittedHeight, readTrackerHeightAfterRestart, readFrozenStoreHeight, chooseArchiveHeight, refusePreStopOnlyHeight } = trackerHeight const { getLogger } = require('../../observability/logger') let logger = getLogger() @@ -190,9 +190,11 @@ async function makeBootstrapUtxoTracker(coin, network) { `(exit code ${stop.exitCode}, ${stop.budget} s budget); no archive was built or signed`) const snapshotTaken = await takeTrackerSnapshot(context, endMaintenanceWindow) await compressTrackerArchive(context, snapshotTaken) + // The archived store's own height, read before the fallback restart (snapshot path: the snapshot is still pinned until the finally). + context.frozenHeight = await readFrozenStoreHeight(context.volumeName, snapshotTaken ? `/data/${TRACKER_SNAPSHOT_DIR}` : '/data') // Fallback path: the store is archived, so bring the tracker back before the wrap and sign, and read its height there. if (!snapshotTaken) await restartTrackerAndReadHeight(context, endMaintenanceWindow) - context.archiveHeight = chooseArchiveHeight(context.preStopHeight, context.postRestartHeight) + context.archiveHeight = chooseArchiveHeight(context.preStopHeight, context.postRestartHeight, context.frozenHeight) await finalizeTrackerArchive(coin, network, context) } finally { await cleanupTrackerArchive(context.volumeName, context.containerId, context.containerRestored, endMaintenanceWindow) @@ -221,6 +223,7 @@ async function prepareTrackerArchive(coin, network) { // refuses the publish (chooseArchiveHeight). With neither reading the archive carries no height (BootstrapNodeTipGuard: unknown). context.preStopHeight = await readTrackerCommittedHeight(coin, network, context.containerId) context.postRestartHeight = null + context.frozenHeight = null // Staging and output dirs are prepared before the stop for the same reason as the capacity check: a read-only mount or a missing parent should not be // discovered with the tracker already dark. @@ -396,5 +399,6 @@ module.exports = { readTrackerCommittedHeight, // Exported for tests chooseArchiveHeight, + readFrozenStoreHeight, readTrackerHeightAfterRestart } diff --git a/src/services/bootstrap_service/tracker_height.js b/src/services/bootstrap_service/tracker_height.js index b82b2517..9eec54e0 100644 --- a/src/services/bootstrap_service/tracker_height.js +++ b/src/services/bootstrap_service/tracker_height.js @@ -79,10 +79,36 @@ async function readTrackerHeightAfterRestart(coin, network, containerId, { attem return null } +// File the tracker leaves in its store directory recording the height its last clean close committed. Plain text, one integer. +const FROZEN_HEIGHT_MARKER = 'committed_height' + +// The height of the frozen store itself, read from the directory that was just archived (the hardlink snapshot, or /data on the +// stopped-tracker fallback), or null. Unlike the live status surface this cannot run ahead of the archived data. +async function readFrozenStoreHeight(volumeName, sourceDir) { + try { + const { stdout } = await execFileAsync('docker', [ + 'run', '--rm', '-v', `${volumeName}:/data:ro`, 'alpine', 'cat', `${sourceDir}/${FROZEN_HEIGHT_MARKER}` + ], { timeout: 60000 }) + const text = String(stdout).trim() + const value = Number(text) + if (/^\d+$/.test(text) && Number.isSafeInteger(value)) return value + logger.info(`The frozen tracker store reported an unusable height (${redactSecrets(text.slice(0, 40))}); falling back to the status surface.`) + return null + } catch (err) { + logger.info(`Could not read the frozen tracker store height (${redactSecrets(err.message)}); falling back to the status surface.`) + return null + } +} + // Pick the height the archive records: never below the data it holds, since the restore guard treats nodeHeight >= archiveHeight as safe. // max() keeps a rollback during the restart from lowering it. A pre-stop reading alone can miss blocks committed while the tracker // stopped, so that case throws rather than sign an understated height; with no reading at all the archive honestly carries none. -function chooseArchiveHeight(preStopHeight, postRestartHeight) { +// A frozen-store reading is exact for the archived data, so it wins over both live readings. +function chooseArchiveHeight(preStopHeight, postRestartHeight, frozenHeight = null) { + if (Number.isInteger(frozenHeight)) { + logger.info(`Archive height: ${frozenHeight} (read from the frozen store).`) + return frozenHeight + } const pre = Number.isInteger(preStopHeight) ? preStopHeight : null const post = Number.isInteger(postRestartHeight) ? postRestartHeight : null if (post === null && pre !== null) { @@ -95,7 +121,8 @@ function chooseArchiveHeight(preStopHeight, postRestartHeight) { } // Throw when only the pre-stop reading exists, naming the run, so the publish stops before any compress or signing. -function refusePreStopOnlyHeight({ coin, network, preStopHeight, postRestartHeight }) { +function refusePreStopOnlyHeight({ coin, network, preStopHeight, postRestartHeight, frozenHeight }) { + if (Number.isInteger(frozenHeight)) return if (!Number.isInteger(preStopHeight) || Number.isInteger(postRestartHeight)) return throw new Error(`utxo-tracker ${coin}/${network} did not report its committed height after the restart; ` + `refusing to sign an archive whose recorded height could understate its data (pre-stop reading ${preStopHeight}). ` + @@ -106,6 +133,7 @@ module.exports = { configureDependencies, readTrackerCommittedHeight, readTrackerHeightAfterRestart, + readFrozenStoreHeight, chooseArchiveHeight, refusePreStopOnlyHeight } diff --git a/test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js b/test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js index bf1a3c31..50b3c917 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 @@ -100,6 +100,27 @@ describe('BootstrapService', function () { expect(recordedHeight(stubs)).to.equal(104) }) + it('records the frozen store height, read after the compress and before the fallback restart', async function () { + const { stubs, probeLog } = trackerStubsWithProbe([100, 104], { snapshotFails: true }) + const base = stubs.execFile + let startsAtFrozenRead = null + stubs.execFile = sinon.stub().callsFake((cmd, args) => { + if (Array.isArray(args) && args[args.length - 1] === '/data/committed_height') { + startsAtFrozenRead = stubs.dockerService.startContainer.callCount + return Promise.resolve({ stdout: '103\n' }) + } + return base(cmd, args) + }) + makeAutoSpawn(stubs) + + const bs = loadBootstrapService(stubs) + expect(await bs.makeBootstrap(COIN, NETWORK, XChainService.XCHAIN_UTXO_TRACKER)).to.be.true + + expect(startsAtFrozenRead).to.equal(0) + expect(probeLog.length).to.be.greaterThan(0) + expect(recordedHeight(stubs)).to.equal(103) + }) + it('keeps the pre-stop height when a rollback leaves the post-restart reading lower', async function () { const { stubs } = trackerStubsWithProbe([100, 98]) makeAutoSpawn(stubs) diff --git a/test/unit/services/bootstrap_service/tracker_height.test.js b/test/unit/services/bootstrap_service/tracker_height.test.js index 0eb6fb18..0eeedf1a 100644 --- a/test/unit/services/bootstrap_service/tracker_height.test.js +++ b/test/unit/services/bootstrap_service/tracker_height.test.js @@ -63,6 +63,31 @@ describe('tracker_height', function () { expect(trackerHeight.chooseArchiveHeight(null, null)).to.equal(null) }) + it('readFrozenStoreHeight reads the height of the archived directory', async function () { + fakes.execFile.callsFake((command, args, options, callback) => callback(null, { stdout: '4242\n', stderr: '' })) + + const height = await trackerHeight.readFrozenStoreHeight('vol', '/data/.snap') + + expect(height).to.equal(4242) + const args = fakes.execFile.firstCall.args[1] + expect(args).to.include('vol:/data:ro') + expect(args[args.length - 1]).to.equal('/data/.snap/committed_height') + }) + + it('readFrozenStoreHeight returns null on junk output or a failed read', async function () { + fakes.execFile.callsFake((command, args, options, callback) => callback(null, { stdout: '12abc', stderr: '' })) + expect(await trackerHeight.readFrozenStoreHeight('vol', '/data')).to.equal(null) + fakes.execFile.callsFake((command, args, options, callback) => callback(new Error('no such file'))) + expect(await trackerHeight.readFrozenStoreHeight('vol', '/data')).to.equal(null) + }) + + it('a frozen-store height wins over the live readings and lifts the pre-stop-only refusal', function () { + expect(trackerHeight.chooseArchiveHeight(100, null, 99)).to.equal(99) + expect(trackerHeight.chooseArchiveHeight(100, 105, 99)).to.equal(99) + expect(() => trackerHeight.refusePreStopOnlyHeight({ coin: 'bitcoin', network: 'mainnet', preStopHeight: 100, postRestartHeight: null, frozenHeight: 99 })).to.not.throw() + expect(() => trackerHeight.refusePreStopOnlyHeight({ coin: 'bitcoin', network: 'mainnet', preStopHeight: 100, postRestartHeight: null, frozenHeight: null })).to.throw() + }) + it('reads the committed height through configured fakes', async function () { probeServiceStatus.resolves({ committed_height: 321 }) From b993117a5a6fa371903bd8e3c1c9b6a52831863c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 02:03:55 +0000 Subject: [PATCH 12/50] Bring CLI commands module back under the 400 line limit Compact the rehearsal source database helper and drop blank lines between adjacent top-level functions so src/cli/commands.js holds exactly 400 lines. --- src/cli/commands.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/cli/commands.js b/src/cli/commands.js index 2cd8b2cb..3b53b1e2 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -86,7 +86,6 @@ function showRowCount(stdout, table) { } return count } - async function databaseCounts(context, databaseName) { const count = async table => { const { stdout } = await runMariaTool(context, 'mariadb-show', ['--count', databaseName, table]) @@ -98,7 +97,6 @@ async function databaseCounts(context, databaseName) { issuances: await count('issues') } } - async function importRehearsal(context, innerArchive) { const command = mariaTool(context, 'mariadb', [rehearsalDatabase], true) const maria = spawn(command.file, command.args, @@ -114,20 +112,15 @@ async function importRehearsal(context, innerArchive) { closed ]) } - function countsMatch(source, restored) { return source.rows === restored.rows && source.tokens === restored.tokens && source.issuances === restored.issuances } - -// The rehearsal compares against the database the indexer actually reads, which an -// operator may have renamed through INDEXER_DB_NAME. async function rehearsalSourceDatabaseName(coin, network) { const module = config.XChainService.XCHAIN_INDEXER const moduleConfig = await getDefaultConfig(module, coin, network) return configuredDatabaseName(module, moduleConfig, getModuleDatabaseName(module, coin, network)) } - async function rehearseBootstrapRestore(coin, network, options) { if (network !== 'mainnet') throw new Error('Bootstrap restore rehearsal requires mainnet') const sourceName = await rehearsalSourceDatabaseName(coin, network) @@ -152,7 +145,6 @@ async function rehearseBootstrapRestore(coin, network, options) { } } } - function registerBootstrapRestoreRehearsal(program, deps) { program.command('bootstrap-restore-rehearsal') .description('Restore a mainnet indexer bootstrap into a disposable database and compare counts') From 6007234390c6ca608d29800a9617377c0b10accf Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 02:07:37 +0000 Subject: [PATCH 13/50] Bring tracker archive height files under the readability limits Condense two comments so tracker_archive.js is at most 400 lines, and split the oversized describe blocks in the two height test files so no function spans more than 60 lines. --- .../bootstrap_service/tracker_archive.js | 13 ++++-------- ...acker_archive_height_after_restart.test.js | 6 ++++++ .../bootstrap_service/tracker_height.test.js | 20 +++++++++++++++++++ 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/services/bootstrap_service/tracker_archive.js b/src/services/bootstrap_service/tracker_archive.js index 69460300..7796e1f7 100644 --- a/src/services/bootstrap_service/tracker_archive.js +++ b/src/services/bootstrap_service/tracker_archive.js @@ -293,18 +293,13 @@ async function takeTrackerSnapshot(context, endMaintenanceWindow) { snapshotTaken = await snapshotTrackerVolume(context.volumeName) } catch (err) { logger.info(`Volume snapshot unavailable (${err.message}); compressing with the tracker stopped.`) - // A half-written snapshot must not be swept into the fallback - // archive, and a volume we cannot clean is not one we can publish - // from: let this throw into the outer finally, which restarts the - // container. + // A half-written snapshot must not be swept into the fallback archive, and a volume we cannot clean is not one we can publish + // from: let this throw into the outer finally, which restarts the container. await removeTrackerSnapshot(context.volumeName) } - // The whole point of the snapshot: the outage ends HERE, seconds after - // the stop, instead of after the multi-hour compress below. Without it - // the monthly cron took each mainnet encoder's tracker dark for the - // full run (2026-08-01: 3h36m BTC, 1h04m LTC, 42m DOGE), which the - // encoder correctly published as tracker_reachable:false. + // The whole point of the snapshot: the outage ends HERE, seconds after the stop, instead of after the multi-hour compress below. + // Without it the monthly cron took each mainnet encoder's tracker dark for the full run (2026-08-01: 3h36m BTC, 1h04m LTC, 42m DOGE). if (snapshotTaken) { logger.info(`Starting ${XChainService.XCHAIN_UTXO_TRACKER} container (compressing from the snapshot)...`) await restartTrackerAndReadHeight(context, endMaintenanceWindow) 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 50b3c917..e1144763 100644 --- a/test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js +++ b/test/unit/bootstrap_service.test/tracker_archive_height_after_restart.test.js @@ -99,7 +99,13 @@ describe('BootstrapService', function () { expect(probeLog.map(p => p.restarted)).to.deep.equal([false, true]) expect(recordedHeight(stubs)).to.equal(104) }) + }) +}) +describe('BootstrapService', function () { + beforeEach(saveRequireSignedBootstrapSetting) + afterEach(restoreRequireSignedBootstrapSetting) + describe('makeBootstrapUtxoTracker(): archive height comes from the archived store', function () { it('records the frozen store height, read after the compress and before the fallback restart', async function () { const { stubs, probeLog } = trackerStubsWithProbe([100, 104], { snapshotFails: true }) const base = stubs.execFile diff --git a/test/unit/services/bootstrap_service/tracker_height.test.js b/test/unit/services/bootstrap_service/tracker_height.test.js index 0eeedf1a..35506006 100644 --- a/test/unit/services/bootstrap_service/tracker_height.test.js +++ b/test/unit/services/bootstrap_service/tracker_height.test.js @@ -63,6 +63,15 @@ describe('tracker_height', function () { expect(trackerHeight.chooseArchiveHeight(null, null)).to.equal(null) }) +}) + +describe('tracker_height frozen store', function () { + let fakes + + beforeEach(function () { + fakes = configureTrackerHeight(sinon.stub()) + }) + it('readFrozenStoreHeight reads the height of the archived directory', async function () { fakes.execFile.callsFake((command, args, options, callback) => callback(null, { stdout: '4242\n', stderr: '' })) @@ -88,6 +97,17 @@ describe('tracker_height', function () { expect(() => trackerHeight.refusePreStopOnlyHeight({ coin: 'bitcoin', network: 'mainnet', preStopHeight: 100, postRestartHeight: null, frozenHeight: null })).to.throw() }) +}) + +describe('tracker_height live readings', function () { + let probeServiceStatus + let fakes + + beforeEach(function () { + probeServiceStatus = sinon.stub() + fakes = configureTrackerHeight(probeServiceStatus) + }) + it('reads the committed height through configured fakes', async function () { probeServiceStatus.resolves({ committed_height: 321 }) From c3559b163fca3a166bb6330a089858f86e9b1ef5 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 03:14:01 +0000 Subject: [PATCH 14/50] Refuse decoder/indexer buildAndUp that would share the default external DB name Under a non-default NODE_PREFIX with an external database, a decoder or indexer whose DB name is still the unprefixed default would read and write the default stack's data. Refuse before anything is torn down. --- src/services/module_service/build_and_up.js | 28 ++++++ ...uildandup_legacy_db_name_collision.test.js | 89 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 test/unit/module_service.test/19_buildandup_legacy_db_name_collision.test.js diff --git a/src/services/module_service/build_and_up.js b/src/services/module_service/build_and_up.js index 0ad24457..4bbd2d97 100644 --- a/src/services/module_service/build_and_up.js +++ b/src/services/module_service/build_and_up.js @@ -75,7 +75,35 @@ function configureDependencies(dependencies) { * @param {{reuseImage?: boolean}} [options] * @returns {Promise} the new container id */ +// Decoder and indexer database names carry no NODE_PREFIX. On the shared +// compose MariaDB each stack owns its own server, so that is harmless; on an +// external (host-native) MariaDB every stack lands in ONE server, and a +// non-default-prefix stack still carrying the unprefixed default name would +// read and write the default stack's data. Refuse before anything is torn down. +const DB_NAME_KEY_BY_MODULE = { 'xchain-decoder': 'DECODER_DB_NAME', 'xchain-indexer': 'INDEXER_DB_NAME' } + +function legacyDefaultDbName(module, coin, network) { + const { DB_SEP, CoinTickerSymbol } = config + const moduleName = module.slice('xchain-'.length) + return 'XChain' + DB_SEP + CoinTickerSymbol[coin] + DB_SEP + + network.charAt(0).toUpperCase() + network.slice(1) + DB_SEP + + moduleName.charAt(0).toUpperCase() + moduleName.slice(1) +} + +function assertNoLegacyDbNameCollision(module, coin, network, environmentVariables) { + const key = DB_NAME_KEY_BY_MODULE[module] + if (!key || !config.EXTERNAL_DB) return + if (config.NODE_PREFIX === config.DEFAULT_NODE_PREFIX) return + if (!coin || !network || config.CoinTickerSymbol[coin] === undefined) return + const dbName = environmentVariables[key] + if (dbName !== legacyDefaultDbName(module, coin, network)) return + throw new Error(`${key}=${dbName} is the unprefixed default database name, but NODE_PREFIX is ` + + `"${config.NODE_PREFIX}" and the database is external, so this stack would share ${dbName} ` + + `with the default "${config.DEFAULT_NODE_PREFIX}" stack. Set ${key} to a name unique to this stack.`) +} + async function assertDeploymentReady(module, coin, network, environmentVariables, dir, onlyExecution) { + assertNoLegacyDbNameCollision(module, coin, network, environmentVariables) // Go-live pre-flight: warns pre-launch, refuses a mainnet write-surface // deploy with un-armed settings once XCHAIN_NODE_GO_LIVE=1. const { assertGoLiveReady } = goLiveGate diff --git a/test/unit/module_service.test/19_buildandup_legacy_db_name_collision.test.js b/test/unit/module_service.test/19_buildandup_legacy_db_name_collision.test.js new file mode 100644 index 00000000..30d2f6e5 --- /dev/null +++ b/test/unit/module_service.test/19_buildandup_legacy_db_name_collision.test.js @@ -0,0 +1,89 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A decoder or indexer buildAndUp under a non-default NODE_PREFIX on an +// external database refuses while the DB name is still the unprefixed default, +// before any docker call is made. + +const { + sinon, expect, makeStubs, makeConfigServiceStub, loadModuleService, moduleSuite, stubDockerCreate +} = require('./support/helpers') + +const LEGACY = { + 'xchain-decoder': ['DECODER_DB_NAME', 'XChain_BTC_Mainnet_Decoder'], + 'xchain-indexer': ['INDEXER_DB_NAME', 'XChain_BTC_Mainnet_Indexer'] +} +const REFUSAL = 'unprefixed default database name' + +function load(stubs, module, dbName, overrides) { + const base = makeConfigServiceStub().getDefaultConfig + const constants = { NODE_PREFIX: 'stack2', DEFAULT_NODE_PREFIX: 'xchain-node', EXTERNAL_DB: true, ...overrides } + return loadModuleService(stubs, constants, { + './config_service': { + ...makeConfigServiceStub(constants), + getDefaultConfig: sinon.stub().callsFake(async (...a) => ({ ...(await base(...a)), [LEGACY[module][0]]: dbName })) + } + }) +} + +// The docker fake answers the whole create path, so a run that clears the +// guard completes instead of hanging on an execFile that never calls back. +function fakeDocker(stubs) { + stubs.fs.cpSync = sinon.stub() + return stubDockerCreate(stubs) +} + +async function attempt(ms, module) { + try { await ms.buildAndUp(module, 'bitcoin', 'mainnet') } catch (e) { return e } + return null +} + +function messageOf(err) { + return String(err && err.message ? err.message : err) +} + +moduleSuite('buildAndUp() legacy DB name collision', function () { + for (const module of Object.keys(LEGACY)) { + const [key, legacyName] = LEGACY[module] + + it(`REFUSES ${module} under a non-default prefix on an external DB with the unprefixed ${key}`, async function () { + const stubs = makeStubs() + const seen = fakeDocker(stubs) + const err = await attempt(load(stubs, module, legacyName), module) + expect(err).to.be.an('error') + expect(err.message).to.include(key) + expect(err.message).to.include(legacyName) + expect(seen).to.have.length(0) + }) + + it(`lets ${module} past the guard when ${key} is a stack-unique name`, async function () { + const stubs = makeStubs() + const seen = fakeDocker(stubs) + const err = await attempt(load(stubs, module, 'stack2_' + legacyName), module) + expect(messageOf(err)).to.not.include(REFUSAL) + }) + + it(`does not refuse ${module} under the default prefix`, async function () { + const stubs = makeStubs() + const seen = fakeDocker(stubs) + const err = await attempt(load(stubs, module, legacyName, { NODE_PREFIX: 'xchain-node' }), module) + expect(messageOf(err)).to.not.include(REFUSAL) + }) + + it(`does not refuse ${module} when the database is not external`, async function () { + const stubs = makeStubs() + const seen = fakeDocker(stubs) + const err = await attempt(load(stubs, module, legacyName, { EXTERNAL_DB: false }), module) + expect(messageOf(err)).to.not.include(REFUSAL) + }) + } +}) From 92636acb04956a536c62917cf0bcb0ba0a8fa7e9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 03:19:09 +0000 Subject: [PATCH 15/50] Bring build_and_up.js back under the 400 line limit Compact the legacy DB name collision check into one function and tighten a few multi-line constructs so the file holds 400 lines. Behaviour is unchanged. --- src/services/module_service/build_and_up.js | 107 ++++++++------------ 1 file changed, 40 insertions(+), 67 deletions(-) diff --git a/src/services/module_service/build_and_up.js b/src/services/module_service/build_and_up.js index 4bbd2d97..7ac75be3 100644 --- a/src/services/module_service/build_and_up.js +++ b/src/services/module_service/build_and_up.js @@ -44,62 +44,28 @@ let logger = getLogger(); function configureDependencies(dependencies) { ;({ - execFile, execFileAsync, fs, path, HUB_MODULE_NAME, LIBRARY_BUNDLES, db, - getModuleDir, checkIfModuleExists, getDockerContainerImageName, - getDockerNetwork, getDefaultConfig, validatePort, stopContainerByName, - removeContainer, forceRemoveContainerByName, checkBuildKitAvailable, - stopModuleContainer, stopTimeoutArgs, statusChanged, setHubDatabaseParameters, - redactSecrets, config, goLiveGate, memoryLimitService, - hubConsensusEnvGuard, rollcallWiring, readCheckoutIdentityFromDisk, - cloneGit, resolveBundledLibRef, assertNoHostPortConflicts, - resolveObservabilityEnv, buildHealthcheckArgs, buildModuleDockerArgs, - attachCrossChainNetworks, verifyContainerMemoryLimit, + execFile, execFileAsync, fs, path, HUB_MODULE_NAME, LIBRARY_BUNDLES, db, getModuleDir, + checkIfModuleExists, getDockerContainerImageName, getDockerNetwork, getDefaultConfig, validatePort, + stopContainerByName, removeContainer, forceRemoveContainerByName, checkBuildKitAvailable, + stopModuleContainer, stopTimeoutArgs, statusChanged, setHubDatabaseParameters, redactSecrets, config, + goLiveGate, memoryLimitService, hubConsensusEnvGuard, rollcallWiring, readCheckoutIdentityFromDisk, + cloneGit, resolveBundledLibRef, assertNoHostPortConflicts, resolveObservabilityEnv, + buildHealthcheckArgs, buildModuleDockerArgs, attachCrossChainNetworks, verifyContainerMemoryLimit, logDockerCreateWarnings, logger } = dependencies) } -/** - * Build the module image and (re)create its container from the current config. - * - * `options.reuseImage` keeps the image that is already tagged for this container and - * skips both the bundled-library re-clone and `docker build`. A container freezes its - * env at `docker run`, so the ONLY way to correct a credential it carries is to - * recreate it; without this flag that correction also drags in whatever GitHub HEAD - * holds today, turning a credential repair into an unreviewed version bump. - * - * @param {string} module - * @param {string|null} coin - * @param {string|null} network - * @param {string|null} [overwriteContainerId] - * @param {boolean} [onlyExecution] - * @param {string[]|null} [dockerCmdArgs] - * @param {{reuseImage?: boolean}} [options] - * @returns {Promise} the new container id - */ -// Decoder and indexer database names carry no NODE_PREFIX. On the shared -// compose MariaDB each stack owns its own server, so that is harmless; on an -// external (host-native) MariaDB every stack lands in ONE server, and a -// non-default-prefix stack still carrying the unprefixed default name would -// read and write the default stack's data. Refuse before anything is torn down. -const DB_NAME_KEY_BY_MODULE = { 'xchain-decoder': 'DECODER_DB_NAME', 'xchain-indexer': 'INDEXER_DB_NAME' } - -function legacyDefaultDbName(module, coin, network) { - const { DB_SEP, CoinTickerSymbol } = config - const moduleName = module.slice('xchain-'.length) - return 'XChain' + DB_SEP + CoinTickerSymbol[coin] + DB_SEP - + network.charAt(0).toUpperCase() + network.slice(1) + DB_SEP - + moduleName.charAt(0).toUpperCase() + moduleName.slice(1) -} - +// Decoder/indexer DB names carry no NODE_PREFIX. On an external MariaDB a non-default-prefix +// stack still on the unprefixed default name would share the default stack's data; refuse early. function assertNoLegacyDbNameCollision(module, coin, network, environmentVariables) { - const key = DB_NAME_KEY_BY_MODULE[module] - if (!key || !config.EXTERNAL_DB) return - if (config.NODE_PREFIX === config.DEFAULT_NODE_PREFIX) return + const key = { 'xchain-decoder': 'DECODER_DB_NAME', 'xchain-indexer': 'INDEXER_DB_NAME' }[module] + if (!key || !config.EXTERNAL_DB || config.NODE_PREFIX === config.DEFAULT_NODE_PREFIX) return if (!coin || !network || config.CoinTickerSymbol[coin] === undefined) return - const dbName = environmentVariables[key] - if (dbName !== legacyDefaultDbName(module, coin, network)) return - throw new Error(`${key}=${dbName} is the unprefixed default database name, but NODE_PREFIX is ` + - `"${config.NODE_PREFIX}" and the database is external, so this stack would share ${dbName} ` + - `with the default "${config.DEFAULT_NODE_PREFIX}" stack. Set ${key} to a name unique to this stack.`) + const cap = (str) => str.charAt(0).toUpperCase() + str.slice(1) + const legacy = ['XChain', config.CoinTickerSymbol[coin], cap(network), cap(module.slice(7))].join(config.DB_SEP) + if (environmentVariables[key] !== legacy) return + throw new Error(`${key}=${legacy} is the unprefixed default database name, but NODE_PREFIX is "${config.NODE_PREFIX}" ` + + `and the database is external, so this stack would share it with the default "${config.DEFAULT_NODE_PREFIX}" stack. ` + + `Set ${key} to a name unique to this stack.`) } async function assertDeploymentReady(module, coin, network, environmentVariables, dir, onlyExecution) { @@ -168,11 +134,7 @@ async function stageBundledLibraries(module, dir, reuseImage) { fs.cpSync(libSrc, libDest, { recursive: true, force: true, - filter: (src) => { - const base = path.basename(src) - return base !== "node_modules" && base !== ".git" && - base !== "test" && base !== "bench" && base !== "reports" - } + filter: (src) => !["node_modules", ".git", "test", "bench", "reports"].includes(path.basename(src)) }) } } @@ -200,10 +162,7 @@ async function resolveMemoryOptions(module, coin, network, onlyExecution) { const trackerCount = await countInstalledTrackers(db, { coin, network }) const memory = memoryArgsFor(module, { trackerCount, coin, network }) if (memory.note) logger.info(memory.note) - return { - memoryArgs: memory.args, - memoryLimitMb: memory.args.length > 0 ? memory.mb : null - } + return { memoryArgs: memory.args, memoryLimitMb: memory.args.length > 0 ? memory.mb : null } } // Validate all port values, parsed with the conflict check's grammar; refuse a // host-interface (IP-scoped) spec, which no configured module port may carry. @@ -226,10 +185,8 @@ async function assertReusableImage(reuseImage, containerPrefix, module, coin, ne try { await execFileAsync('docker', ['image', 'inspect', '--format', '{{.Id}}', containerPrefix]) } catch { - throw new Error( - "No local image tagged " + containerPrefix + " to reuse; run `update " + module + - (coin && network ? " " + coin + " " + network : "") + "` to build one." - ) + throw new Error("No local image tagged " + containerPrefix + " to reuse; run `update " + module + + (coin && network ? " " + coin + " " + network : "") + "` to build one.") } } @@ -395,13 +352,29 @@ function buildOrCreateContainer(context, resolve, reject) { function launchContainer(context) { return new Promise((resolve, reject) => buildOrCreateContainer(context, resolve, reject)) } +/** + * Build the module image and (re)create its container from the current config. + * + * `options.reuseImage` keeps the image that is already tagged for this container and + * skips both the bundled-library re-clone and `docker build`. A container freezes its + * env at `docker run`, so the ONLY way to correct a credential it carries is to + * recreate it; without this flag that correction also drags in whatever GitHub HEAD + * holds today, turning a credential repair into an unreviewed version bump. + * + * @param {string} module + * @param {string|null} coin + * @param {string|null} network + * @param {string|null} [overwriteContainerId] + * @param {boolean} [onlyExecution] + * @param {string[]|null} [dockerCmdArgs] + * @param {{reuseImage?: boolean}} [options] + * @returns {Promise} the new container id + */ async function buildAndUp(module, coin, network, overwriteContainerId = null, onlyExecution = false, dockerCmdArgs = null, options = {}) { if (!checkIfModuleExists(module)) throw "module not found" const reuseImage = options.reuseImage === true if (!reuseImage && typeof checkBuildKitAvailable === 'function') { - try { - await checkBuildKitAvailable() - } catch (err) { throw "Error creating Docker image: " + err } + await checkBuildKitAvailable().catch((err) => { throw "Error creating Docker image: " + err }) } let environmentVariables = await getDefaultConfig(module, coin, network) const dir = getModuleDir(module) From 05a39bc903e8af1656279265520b30b0c97597e7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 22:38:52 -0700 Subject: [PATCH 16/50] Declare the message-signing overrides and restore elliptic in the lock --- package-lock.json | 167 ++++++++++++---------------------------------- package.json | 6 +- 2 files changed, 48 insertions(+), 125 deletions(-) diff --git a/package-lock.json b/package-lock.json index d62f0695..9be50daa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -617,7 +617,7 @@ "axios": "^1.18.1", "bip174": "2.1.1", "bitcoinjs-lib": "6.1.7", - "bitcoinjs-message": "npm:@oipwg/bitcoinjs-message@2.2.0-beta.0", + "bitcoinjs-message": "^2.2.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "ecpair": "2.1.0", @@ -632,16 +632,6 @@ "node": ">=22.0.0" } }, - "node_modules/@exodus/bitcoinerlab-secp256k1": { - "version": "1.0.5-exodus.1", - "resolved": "https://registry.npmjs.org/@exodus/bitcoinerlab-secp256k1/-/bitcoinerlab-secp256k1-1.0.5-exodus.1.tgz", - "integrity": "sha512-vk4WBauCD9YmH4nO7loye6gpoFUAFlx9D+s3/1M5ayp9AD9yBRNTujb2lgWeZuJbyeJpiAJBEKwLS1QrVJdwrg==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "^1.1.5", - "@noble/secp256k1": "^1.7.1" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -810,6 +800,16 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bitcoinerlab-secp256k1": { + "version": "1.0.5-exodus.1", + "resolved": "https://registry.npmjs.org/@exodus/bitcoinerlab-secp256k1/-/bitcoinerlab-secp256k1-1.0.5-exodus.1.tgz", + "integrity": "sha512-vk4WBauCD9YmH4nO7loye6gpoFUAFlx9D+s3/1M5ayp9AD9yBRNTujb2lgWeZuJbyeJpiAJBEKwLS1QrVJdwrg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5", + "@noble/secp256k1": "^1.7.1" + } + }, "node_modules/@hono/node-server": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", @@ -1440,13 +1440,13 @@ "version": "1.7.2", "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.2.tgz", "integrity": "sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==", - "license": "MIT", "funding": [ { "type": "individual", "url": "https://paulmillr.com/funding/" } - ] + ], + "license": "MIT" }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -1908,15 +1908,6 @@ "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", "license": "MIT" }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, "node_modules/bip174": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.1.1.tgz", @@ -1926,15 +1917,6 @@ "node": ">=8.0.0" } }, - "node_modules/bip66": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/bip66/-/bip66-1.1.5.tgz", - "integrity": "sha512-nemMHz95EmS38a26XbbdxIYj5csHd3RMP3H5bwQknX0WYHF01qhpufP42mLOwVICuH2JmhIhXiWs89MfUGL7Xw==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/bitcoinjs-lib": { "version": "6.1.7", "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.7.tgz", @@ -2004,20 +1986,6 @@ "safe-buffer": "^5.1.2" } }, - "node_modules/bitcoinjs-message/node_modules/secp256k1": { - "name": "@exodus/secp256k1", - "version": "5.0.0-exodus.4.patch.2", - "resolved": "https://registry.npmjs.org/@exodus/secp256k1/-/secp256k1-5.0.0-exodus.4.patch.2.tgz", - "integrity": "sha512-GMHXCyLdwttG99gsDPL8qhPeGSXvzOTLgcCIg1CcsZHaarJQx8Le5q1w99dcUnEizS+uH6gQfumP1qD1BdvIRg==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@exodus/bitcoinerlab-secp256k1": "^1.0.5-exodus.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/blessed": { "version": "0.1.81", "resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz", @@ -2099,20 +2067,6 @@ "dev": true, "license": "ISC" }, - "node_modules/browserify-aes": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", - "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", - "license": "MIT", - "dependencies": { - "buffer-xor": "^1.0.3", - "cipher-base": "^1.0.0", - "create-hash": "^1.1.0", - "evp_bytestokey": "^1.0.3", - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -2166,21 +2120,6 @@ "bs58": "^5.0.0" } }, - "node_modules/buffer-equals": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/buffer-equals/-/buffer-equals-1.0.4.tgz", - "integrity": "sha512-99MsCq0j5+RhubVEtKQgKaD6EM+UP3xJgIvQqwJ3SOLDUekzxMX1ylXBng+Wa2sh7mGT0W6RUly8ojjr1Tt6nA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/buffer-xor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", - "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", - "license": "MIT" - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2598,20 +2537,6 @@ "sha.js": "^2.4.0" } }, - "node_modules/create-hmac": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", - "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", - "license": "MIT", - "dependencies": { - "cipher-base": "^1.0.3", - "create-hash": "^1.1.0", - "inherits": "^2.0.1", - "ripemd160": "^2.0.0", - "safe-buffer": "^5.0.1", - "sha.js": "^2.4.8" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2760,20 +2685,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/drbg.js": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/drbg.js/-/drbg.js-1.0.1.tgz", - "integrity": "sha512-F4wZ06PvqxYLFEZKkFxTDcns9oFNk34hvmJSEwdzsxVQ8YI5YaxtACgQatkYgv2VI2CFkUd2Y+xosPQnHv809g==", - "license": "MIT", - "dependencies": { - "browserify-aes": "^1.0.6", - "create-hash": "^1.1.2", - "create-hmac": "^1.1.4" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2822,6 +2733,21 @@ "dev": true, "license": "ISC" }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -3170,16 +3096,6 @@ "node": ">=18.0.0" } }, - "node_modules/evp_bytestokey": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", - "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", - "license": "MIT", - "dependencies": { - "md5.js": "^1.3.4", - "safe-buffer": "^5.1.1" - } - }, "node_modules/execa": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", @@ -3407,12 +3323,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/fill-keys": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", @@ -4757,12 +4667,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/nan": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", - "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", - "license": "MIT" - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -5362,6 +5266,21 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/secp256k1": { + "name": "@exodus/secp256k1", + "version": "5.0.0-exodus.4.patch.2", + "resolved": "https://registry.npmjs.org/@exodus/secp256k1/-/secp256k1-5.0.0-exodus.4.patch.2.tgz", + "integrity": "sha512-GMHXCyLdwttG99gsDPL8qhPeGSXvzOTLgcCIg1CcsZHaarJQx8Le5q1w99dcUnEizS+uH6gQfumP1qD1BdvIRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@exodus/bitcoinerlab-secp256k1": "^1.0.5-exodus.1", + "elliptic": "^6.5.2" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/seedrandom": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", diff --git a/package.json b/package.json index dcf2e0dc..4e35f989 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,11 @@ "form-data": "^4.0.6", "ip-address": "^10.3.1", "decimal.js": "10.4.3", - "@modelcontextprotocol/sdk": "^1.31.0" + "@modelcontextprotocol/sdk": "^1.31.0", + "bitcoinjs-message": { + ".": "npm:@oipwg/bitcoinjs-message@2.2.0-beta.0", + "secp256k1": "npm:@exodus/secp256k1@5.0.0-exodus.4.patch.2" + } }, "bin": { "xchain-node": "./src/index.js" From a5fffc63351f2e6d2993351fa9d650a277430c97 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 22:55:04 -0700 Subject: [PATCH 17/50] Stub the published host-port probe in the chaos module harnesses The host-port conflict check fell back to a real docker ps when the docker_service stub omitted it, so the suites failed on any host with running containers. --- .../docker_build_failure_chaos.test.js | 5 ++++- .../docker_run_failure_chaos.test.js | 5 ++++- .../invalid_port_in_config_during_build_and_up_chaos.test.js | 2 +- .../state_storage_failure_chaos.test.js | 5 ++++- test/chaos/process_resilience_chaos.test/helpers/index.js | 5 ++++- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/test/chaos/docker_resilience_chaos.test/docker_build_failure_chaos.test.js b/test/chaos/docker_resilience_chaos.test/docker_build_failure_chaos.test.js index e6927b54..eb187308 100644 --- a/test/chaos/docker_resilience_chaos.test/docker_build_failure_chaos.test.js +++ b/test/chaos/docker_resilience_chaos.test/docker_build_failure_chaos.test.js @@ -73,7 +73,10 @@ function loadModuleService(stubs, opts = {}) { './docker_service': { killContainer: opts.killContainer || sinon.stub().resolves(true), removeContainer: opts.removeContainer || sinon.stub().resolves(true), - getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } }) + getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } }), + // An empty port map keeps the host-port conflict check off the real `docker ps`, + // which on a host running containers reports their ports as conflicts. + getPublishedHostPorts: sinon.stub().resolves(new Map()) }, './database_service': { setDatabaseParameters: sinon.stub().resolves() } }) diff --git a/test/chaos/docker_resilience_chaos.test/docker_run_failure_chaos.test.js b/test/chaos/docker_resilience_chaos.test/docker_run_failure_chaos.test.js index 4edc2b66..9ff462f1 100644 --- a/test/chaos/docker_resilience_chaos.test/docker_run_failure_chaos.test.js +++ b/test/chaos/docker_resilience_chaos.test/docker_run_failure_chaos.test.js @@ -73,7 +73,10 @@ function loadModuleService(stubs, opts = {}) { './docker_service': { killContainer: opts.killContainer || sinon.stub().resolves(true), removeContainer: opts.removeContainer || sinon.stub().resolves(true), - getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } }) + getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } }), + // An empty port map keeps the host-port conflict check off the real `docker ps`, + // which on a host running containers reports their ports as conflicts. + getPublishedHostPorts: sinon.stub().resolves(new Map()) }, './database_service': { setDatabaseParameters: sinon.stub().resolves() } }) diff --git a/test/chaos/docker_resilience_chaos.test/invalid_port_in_config_during_build_and_up_chaos.test.js b/test/chaos/docker_resilience_chaos.test/invalid_port_in_config_during_build_and_up_chaos.test.js index f09e0eb8..19289714 100644 --- a/test/chaos/docker_resilience_chaos.test/invalid_port_in_config_during_build_and_up_chaos.test.js +++ b/test/chaos/docker_resilience_chaos.test/invalid_port_in_config_during_build_and_up_chaos.test.js @@ -52,7 +52,7 @@ function loadModuleServiceWithInvalidPort(stubs) { }) }, './status_service': { statusChanged: sinon.stub().resolves(), getStatus: sinon.stub().resolves({}) }, - './docker_service': { killContainer: sinon.stub().resolves(), removeContainer: sinon.stub().resolves() }, + './docker_service': { killContainer: sinon.stub().resolves(), removeContainer: sinon.stub().resolves(), getPublishedHostPorts: sinon.stub().resolves(new Map()) }, './database_service': { setDatabaseParameters: sinon.stub().resolves() } }) } diff --git a/test/chaos/docker_resilience_chaos.test/state_storage_failure_chaos.test.js b/test/chaos/docker_resilience_chaos.test/state_storage_failure_chaos.test.js index 16f16a15..993ab1c3 100644 --- a/test/chaos/docker_resilience_chaos.test/state_storage_failure_chaos.test.js +++ b/test/chaos/docker_resilience_chaos.test/state_storage_failure_chaos.test.js @@ -73,7 +73,10 @@ function loadModuleService(stubs, opts = {}) { './docker_service': { killContainer: opts.killContainer || sinon.stub().resolves(true), removeContainer: opts.removeContainer || sinon.stub().resolves(true), - getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } }) + getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } }), + // An empty port map keeps the host-port conflict check off the real `docker ps`, + // which on a host running containers reports their ports as conflicts. + getPublishedHostPorts: sinon.stub().resolves(new Map()) }, './database_service': { setDatabaseParameters: sinon.stub().resolves() } }) diff --git a/test/chaos/process_resilience_chaos.test/helpers/index.js b/test/chaos/process_resilience_chaos.test/helpers/index.js index 4e32226e..de7281d5 100644 --- a/test/chaos/process_resilience_chaos.test/helpers/index.js +++ b/test/chaos/process_resilience_chaos.test/helpers/index.js @@ -75,7 +75,10 @@ function loadModuleService(stubs, opts = {}) { stopped: true, seconds: 1, killed: false }), removeContainer: opts.removeContainer || sinon.stub().resolves(true), - getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } }) + getStatusFromContainer: sinon.stub().resolves({ State: { Status: 'running' } }), + // An empty port map keeps the host-port conflict check off the real `docker ps`, + // which on a host running containers reports their ports as conflicts. + getPublishedHostPorts: sinon.stub().resolves(new Map()) }, './database_service': { setDatabaseParameters: sinon.stub().resolves() } }) From 5a114f555413383c562c8771a1a21eb29747b34f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 06:35:51 +0000 Subject: [PATCH 18/50] Scope module database and DB user names to a non-default NODE_PREFIX getModuleDatabaseName and the decoder/indexer DB user names now carry the sanitized NODE_PREFIX when it is not the default; default names are unchanged. The prefix scope test snapshots and restores the src require cache and NODE_PREFIX. --- src/services/config_service.js | 15 +++- src/utils/module_database_name.js | 11 ++- test/unit/module_database_name.test.js | 12 +++- .../module_database_name_prefix_scope.test.js | 72 +++++++++++++++++++ 4 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 test/unit/module_database_name_prefix_scope.test.js diff --git a/src/services/config_service.js b/src/services/config_service.js index a86d0138..538fc297 100644 --- a/src/services/config_service.js +++ b/src/services/config_service.js @@ -27,6 +27,7 @@ const { moduleDir, tmpDir, cryptoNodesDir, bootstrapDir, configDir, EXTERNAL_DB } = require('../config') const { stringToCoin } = require('../utils/helpers') +const { nodePrefixSegment } = require('../utils/module_database_name') const { preferredSecretEnvName, foldSecretEnvAliases, readSecretHostEnv, deprecatedSecretEnvNames } = require('../config/secret_env') const { getCoinConfigByFullName } = require('../coins') const config = require('../config') @@ -148,11 +149,22 @@ function getModuleDatabaseName(module, coin, network) { } const moduleName = module.slice("xchain-".length) return "XChain" + DB_SEP + + nodePrefixSegment(NODE_PREFIX, DEFAULT_NODE_PREFIX, DB_SEP) + CoinTickerSymbol[coin] + DB_SEP + network.charAt(0).toUpperCase() + network.slice(1) + DB_SEP + moduleName.charAt(0).toUpperCase() + moduleName.slice(1) } +// Module DB account names carry the NODE_PREFIX like the database names, so two +// stacks sharing one MariaDB never contend for one account. Default prefix: unchanged. +function applyPrefixedModuleDbUsers(defaultValues, coin, network) { + const segment = nodePrefixSegment(NODE_PREFIX, DEFAULT_NODE_PREFIX, DB_SEP) + if (segment === '') return + for (const [key, name] of [["DECODER_DB_USER", "decoder"], ["INDEXER_DB_USER", "indexer"]]) { + defaultValues[key] = "xchain" + DB_SEP + segment + name + DB_SEP + coin + DB_SEP + network + } +} + sidecars.configure({ crypto, fs, path, readline, configDir, preferredSecretEnvName, foldSecretEnvAliases, deprecatedSecretEnvNames, logger @@ -191,7 +203,8 @@ const { resolveArgs } = argumentsService async function getDefaultConfig(module, coin, network) { let defaultValues; if (coin && network) { - defaultValues = defaults.createCoinDefaults(module, coin, network); coins.applyCoinDefaults(defaultValues, module, coin, network) + defaultValues = defaults.createCoinDefaults(module, coin, network) + applyPrefixedModuleDbUsers(defaultValues, coin, network); coins.applyCoinDefaults(defaultValues, module, coin, network) services.configureE2e(defaultValues, module, coin) if (module === XChainService.XCHAIN_E2E_TEST) await applyHubApiKeyFromSidecar(defaultValues) services.configureIndexerBeforeHubKey(defaultValues, module, network) diff --git a/src/utils/module_database_name.js b/src/utils/module_database_name.js index ad141b19..55e8bfe6 100644 --- a/src/utils/module_database_name.js +++ b/src/utils/module_database_name.js @@ -35,4 +35,13 @@ function configuredDatabaseName(module, cfg, defaultName) { return assertSafeDbIdentifier(name, 'database name') } -module.exports = { DATABASE_NAME_KEY, configuredDatabaseName } +// Identifier segment that scopes a derived database or DB user name to its NODE_PREFIX +// stack. The default prefix yields '' so existing deployments keep their names; any other +// prefix yields '_' with every character outside [A-Za-z0-9] folded to '_' (the +// prefix may carry hyphens and dots, which a SQL identifier may not). +function nodePrefixSegment(nodePrefix, defaultPrefix, sep = '_') { + if (nodePrefix === defaultPrefix) return '' + return String(nodePrefix).replace(/[^A-Za-z0-9]/g, '_') + sep +} + +module.exports = { DATABASE_NAME_KEY, configuredDatabaseName, nodePrefixSegment } diff --git a/test/unit/module_database_name.test.js b/test/unit/module_database_name.test.js index 6ca6657c..53a29ac4 100644 --- a/test/unit/module_database_name.test.js +++ b/test/unit/module_database_name.test.js @@ -12,7 +12,7 @@ const { expect } = require('chai') const { XChainService } = require('../../src/config') -const { configuredDatabaseName } = require('../../src/utils/module_database_name') +const { configuredDatabaseName, nodePrefixSegment } = require('../../src/utils/module_database_name') const DEFAULT_DECODER = 'XChain_BTC_Mainnet_Decoder' const DEFAULT_INDEXER = 'XChain_BTC_Mainnet_Indexer' @@ -45,3 +45,13 @@ describe('configuredDatabaseName()', function () { .to.throw(/Unsafe MariaDB database name/) }) }) + +describe('nodePrefixSegment()', function () { + it('is empty for the default prefix', function () { + expect(nodePrefixSegment('xchain-node', 'xchain-node')).to.equal('') + }) + + it('folds non-alphanumerics to underscores and appends the separator', function () { + expect(nodePrefixSegment('my-stack.1', 'xchain-node')).to.equal('my_stack_1_') + }) +}) diff --git a/test/unit/module_database_name_prefix_scope.test.js b/test/unit/module_database_name_prefix_scope.test.js new file mode 100644 index 00000000..9bc14652 --- /dev/null +++ b/test/unit/module_database_name_prefix_scope.test.js @@ -0,0 +1,72 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const path = require('path') + +const SRC = path.resolve(__dirname, '../../src') + path.sep + +// NODE_PREFIX is read once when src/config loads, so a prefixed stack needs a fresh load +// of every src module. Snapshot the require cache and the env var, then put both back so +// later suites keep their default-prefix singletons. +function isSrc(id) { return id.startsWith(SRC) } + +describe('module database names under a non-default NODE_PREFIX', function () { + let savedCache, savedPrefix, prefixed + + before(function () { + savedPrefix = process.env.NODE_PREFIX + savedCache = {} + for (const id of Object.keys(require.cache)) { + if (isSrc(id)) { savedCache[id] = require.cache[id]; delete require.cache[id] } + } + process.env.NODE_PREFIX = 'stack-b.test' + prefixed = require('../../src/services/config_service') + }) + + after(function () { + if (savedPrefix === undefined) delete process.env.NODE_PREFIX + else process.env.NODE_PREFIX = savedPrefix + for (const id of Object.keys(require.cache)) { + if (isSrc(id)) delete require.cache[id] + } + Object.assign(require.cache, savedCache) + }) + + it('carries the sanitized prefix in the derived database name', function () { + expect(prefixed.getModuleDatabaseName('xchain-decoder', 'bitcoin', 'mainnet')) + .to.equal('XChain_stack_b_test_BTC_Mainnet_Decoder') + }) + + it('carries the prefix in the module DB user names', async function () { + const decoder = await prefixed.getDefaultConfig('xchain-decoder', 'bitcoin', 'mainnet') + const indexer = await prefixed.getDefaultConfig('xchain-indexer', 'bitcoin', 'mainnet') + expect(decoder.DECODER_DB_USER).to.equal('xchain_stack_b_test_decoder_bitcoin_mainnet') + expect(indexer.INDEXER_DB_USER).to.equal('xchain_stack_b_test_indexer_bitcoin_mainnet') + expect(decoder.DECODER_DB_NAME).to.equal('XChain_stack_b_test_BTC_Mainnet_Decoder') + }) + + it('restores the default-prefix modules after the suite', function () { + // Runs while the prefixed load is still cached; the real check is that after() + // reinstates the snapshot, asserted by the sibling describe below. + expect(process.env.NODE_PREFIX).to.equal('stack-b.test') + }) +}) + +describe('module database names after the prefix suite', function () { + it('leave the default-prefix names unchanged and the env restored', async function () { + const cs = require('../../src/services/config_service') + expect(cs.getModuleDatabaseName('xchain-decoder', 'bitcoin', 'mainnet')).to.equal('XChain_BTC_Mainnet_Decoder') + const cfg = await cs.getDefaultConfig('xchain-decoder', 'bitcoin', 'mainnet') + expect(cfg.DECODER_DB_USER).to.equal('xchain_decoder_bitcoin_mainnet') + }) +}) From 564f4a4f3a5bfb67aaa44275c841c059fe76c355 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 06:39:33 +0000 Subject: [PATCH 19/50] Keep getDefaultConfig within the function length limit Fold the prefixed module DB user call onto the existing defaults line and shorten the nodePrefixSegment comment to three lines. --- src/services/config_service.js | 3 +-- src/utils/module_database_name.js | 7 +++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/services/config_service.js b/src/services/config_service.js index 538fc297..9e6e1501 100644 --- a/src/services/config_service.js +++ b/src/services/config_service.js @@ -203,8 +203,7 @@ const { resolveArgs } = argumentsService async function getDefaultConfig(module, coin, network) { let defaultValues; if (coin && network) { - defaultValues = defaults.createCoinDefaults(module, coin, network) - applyPrefixedModuleDbUsers(defaultValues, coin, network); coins.applyCoinDefaults(defaultValues, module, coin, network) + defaultValues = defaults.createCoinDefaults(module, coin, network); applyPrefixedModuleDbUsers(defaultValues, coin, network); coins.applyCoinDefaults(defaultValues, module, coin, network) services.configureE2e(defaultValues, module, coin) if (module === XChainService.XCHAIN_E2E_TEST) await applyHubApiKeyFromSidecar(defaultValues) services.configureIndexerBeforeHubKey(defaultValues, module, network) diff --git a/src/utils/module_database_name.js b/src/utils/module_database_name.js index 55e8bfe6..50ec8d34 100644 --- a/src/utils/module_database_name.js +++ b/src/utils/module_database_name.js @@ -35,10 +35,9 @@ function configuredDatabaseName(module, cfg, defaultName) { return assertSafeDbIdentifier(name, 'database name') } -// Identifier segment that scopes a derived database or DB user name to its NODE_PREFIX -// stack. The default prefix yields '' so existing deployments keep their names; any other -// prefix yields '_' with every character outside [A-Za-z0-9] folded to '_' (the -// prefix may carry hyphens and dots, which a SQL identifier may not). +// Scopes a derived database or DB user name to its NODE_PREFIX stack: '' for the default +// prefix, else '' plus sep with characters outside [A-Za-z0-9] folded to '_' +// (a SQL identifier cannot carry hyphens or dots). function nodePrefixSegment(nodePrefix, defaultPrefix, sep = '_') { if (nodePrefix === defaultPrefix) return '' return String(nodePrefix).replace(/[^A-Za-z0-9]/g, '_') + sep From 8fdb9b7c5514fc8b120a81b77565dc3710054b3e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 10:41:50 +0000 Subject: [PATCH 20/50] Read migration precondition ledger from the configured database name The guard checked the derived default database name, so an operator who set INDEXER_DB_NAME or DECODER_DB_NAME had the wrong database inspected. Resolve the name through configuredDatabaseName, falling back to the default with a warning when the config cannot be read. --- .../migration_precondition_service.js | 17 +++++- ...condition_configured_database_name.test.js | 57 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 test/unit/migration_precondition_configured_database_name.test.js diff --git a/src/services/migration_precondition_service.js b/src/services/migration_precondition_service.js index 5e792dfb..6a537b39 100644 --- a/src/services/migration_precondition_service.js +++ b/src/services/migration_precondition_service.js @@ -54,7 +54,8 @@ const { XChainService } = require('../config') const { migrationsDirOf } = require('../utils/migration_files') -const { getModuleTmpDir, getModuleDatabaseName, getDockerContainerImageName } = require('./config_service') +const { getModuleTmpDir, getModuleDatabaseName, getDockerContainerImageName, getDefaultConfig } = require('./config_service') +const { configuredDatabaseName } = require('../utils/module_database_name') const config = require('../config'); const { getLogger } = require('../observability/logger'); const logger = getLogger(); @@ -194,7 +195,19 @@ async function assertRequiredMigrationsApplied(module, coin, network, branch = n } if (!required.length) return { checked: false, reason: 'no-preconditions' } - const dbName = getModuleDatabaseName(module, coin, network) + // The database the service actually reads is the operator-configured name when + // set, not the derived default; checking the default would bless or refuse a + // database the service never touches. An unreadable config falls back to the + // default name with a warning, which is the guard's behaviour without the override. + const defaultName = getModuleDatabaseName(module, coin, network) + let dbName = defaultName + try { + const readConfig = deps.getDefaultConfig || getDefaultConfig + dbName = configuredDatabaseName(module, await readConfig(module, coin, network), defaultName) + } catch (err) { + logger.warn(`Migration precondition guard: could not read ${module}'s configured database name ` + + `(${err && err.message ? err.message : err}); using the default ${defaultName}.`) + } const result = await readApplied({ database: dbName, coin, network }) if (result.state === 'empty-database') return emptyDatabaseResult(module, dbName, required) diff --git a/test/unit/migration_precondition_configured_database_name.test.js b/test/unit/migration_precondition_configured_database_name.test.js new file mode 100644 index 00000000..535a0cb6 --- /dev/null +++ b/test/unit/migration_precondition_configured_database_name.test.js @@ -0,0 +1,57 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// The precondition guard must read the ledger of the operator-configured +// database, not the derived default name. + +const sinon = require('sinon') +const { expect } = require('chai') + +const { XChainService } = require('../../src/config') +const { assertRequiredMigrationsApplied } = require('../../src/services/migration_precondition_service') +const { getModuleDatabaseName } = require('../../src/services/config_service') + +const GATED = '2026-07-24-pubkeys-widen-uncompressed.sql' + +function deps(cfg) { + return { + cloneGit: sinon.stub().resolves(), + listDeployPreconditionMigrations: sinon.stub().returns([GATED]), + readAppliedMigrations: sinon.stub().resolves({ state: 'ledger', applied: new Set([GATED]) }), + getDefaultConfig: sinon.stub().resolves(cfg) + } +} + +describe('migration precondition configured database name', () => { + it('reads the ledger of INDEXER_DB_NAME when configured', async () => { + const d = deps({ INDEXER_DB_NAME: 'custom_idx' }) + await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', null, d) + expect(d.readAppliedMigrations.firstCall.args[0].database).to.equal('custom_idx') + }) + + it('reads the ledger of DECODER_DB_NAME when configured', async () => { + const d = deps({ DECODER_DB_NAME: 'custom_dec' }) + await assertRequiredMigrationsApplied(XChainService.XCHAIN_DECODER, 'bitcoin', 'mainnet', null, d) + expect(d.readAppliedMigrations.firstCall.args[0].database).to.equal('custom_dec') + }) + + it('falls back to the derived default name when none is configured', async () => { + const d = deps({}) + await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', null, d) + expect(d.readAppliedMigrations.firstCall.args[0].database) + .to.equal(getModuleDatabaseName(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet')) + }) + + it('names the configured database in the refusal', async () => { + const d = deps({ INDEXER_DB_NAME: 'custom_idx' }) + d.readAppliedMigrations.resolves({ state: 'unreadable', reason: 'down' }) + try { + await assertRequiredMigrationsApplied(XChainService.XCHAIN_INDEXER, 'bitcoin', 'mainnet', null, d) + expect.fail('should refuse') + } catch (err) { + expect(err.message).to.contain('custom_idx') + } + }) +}) From 20bf6294e9202a9505a39021d70feb353309e8b5 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 10:41:51 +0000 Subject: [PATCH 21/50] Pass regtest gate switches through the node config Hand XC_OFFER_LIST_REGTEST_ACTIVATION to the hub and EXACT_INTEGER_WIRE_FIELDS_REGTEST_TIME to a regtest indexer only, and declare both in the env views. Adds a passthrough test. --- src/config/env_views.js | 5 ++- src/services/config_service/networks.js | 4 +- src/services/config_service/services.js | 4 +- .../regtest_gate_switch_passthrough.test.js | 43 +++++++++++++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 test/unit/config/regtest_gate_switch_passthrough.test.js diff --git a/src/config/env_views.js b/src/config/env_views.js index 915de28b..69e06ba6 100644 --- a/src/config/env_views.js +++ b/src/config/env_views.js @@ -61,7 +61,7 @@ const LIST_VIEWS = { "XC_ROLLCALL_REGTEST_ACTIVATION", "XC_ROLLCALL_GATES_REGTEST_ACTIVATION", "XC_MIRROR_ADMISSION_ACTIVATION", "XC_ANCHOR_FOLD_REGTEST_ACTIVATION", "XC_ANCHOR_STAKE_REGTEST_ACTIVATION", "XC_ANCHOR_SLASH_REGTEST_ACTIVATION", - "XCHAIN_PRICE_INDEXER_DB_SECRET" + "XC_OFFER_LIST_REGTEST_ACTIVATION", "XCHAIN_PRICE_INDEXER_DB_SECRET" ]], // The host names the xchain-sync server container is handed (defaults.js // configureSharedAfterHubKey), so a recreate keeps the operator's value. @@ -80,7 +80,8 @@ const LIST_VIEWS = { "XC_ROLLCALL_GATES_REGTEST_ACTIVATION", "HUB_SYNC_ANCHOR_ATTEST_GRACE_S", "HUB_PRICE_SYNC_TIMEOUT_MS", "XCHAIN_COINPAY_EXPIRATION_S", "XC_MIRROR_ADMISSION_ACTIVATION", "XC_ANCHOR_FOLD_REGTEST_ACTIVATION", - "XC_ANCHOR_STAKE_REGTEST_ACTIVATION", "XC_ANCHOR_SLASH_REGTEST_ACTIVATION" + "XC_ANCHOR_STAKE_REGTEST_ACTIVATION", "XC_ANCHOR_SLASH_REGTEST_ACTIVATION", + "EXACT_INTEGER_WIRE_FIELDS_REGTEST_TIME" ]], // The hub-sync watermark graces a regtest indexer is handed (hubSyncRegtestGraceVars). HUB_SYNC_GRACE_ENV: ['hub sync grace', [ diff --git a/src/services/config_service/networks.js b/src/services/config_service/networks.js index d1596118..c0283e16 100644 --- a/src/services/config_service/networks.js +++ b/src/services/config_service/networks.js @@ -247,6 +247,7 @@ async function composeColocatedIndexerUrls(defaultValues, network, registeredSta // mirror-admission gate module gates it for real. // XC_ANCHOR_FOLD_REGTEST_ACTIVATION rides beside it: the hub and the regtest indexer arm the fold together, // and the stake and slash arms ride the same way. +// XC_OFFER_LIST_REGTEST_ACTIVATION arms the offer-list gate on the hub; the hub's own gate module gates it for real. // Secret-bearing names in this list (XCHAIN_PRICE_INDEXER_DB_PASS) are also // accepted from the host env under their redaction-safe `*_SECRET` spelling; // everything else resolves to a plain process.env read. @@ -295,7 +296,8 @@ function configureHubBeforeKey(defaultValues, module) { "XC_MIRROR_ADMISSION_ACTIVATION", "XC_ANCHOR_FOLD_REGTEST_ACTIVATION", "XC_ANCHOR_STAKE_REGTEST_ACTIVATION", - "XC_ANCHOR_SLASH_REGTEST_ACTIVATION" + "XC_ANCHOR_SLASH_REGTEST_ACTIVATION", + "XC_OFFER_LIST_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 1e70e340..7c62df0e 100644 --- a/src/services/config_service/services.js +++ b/src/services/config_service/services.js @@ -181,6 +181,7 @@ function configureE2e(defaultValues, module, coin) { // admission-era canonical refuses a legacy-map row and halts the block loop. // XC_ANCHOR_FOLD_REGTEST_ACTIVATION arms the ANCHOR v3 fold gate the same way; unarmed, // a deployed regtest indexer rejects every v3 bundle as an unknown version. +// EXACT_INTEGER_WIRE_FIELDS_REGTEST_TIME arms the exact-integer wire-field gate on a regtest indexer only. // The indexer pushes chain tips to HUB_API_URL; when that hub enforces // HUB_API_KEY, the indexer must present the same key or its writes 401. // Sourced from host env so it persists across `update`, then from the shared @@ -220,7 +221,8 @@ function configureIndexerBeforeHubKey(defaultValues, module, network) { "XC_MIRROR_ADMISSION_ACTIVATION", "XC_ANCHOR_FOLD_REGTEST_ACTIVATION", "XC_ANCHOR_STAKE_REGTEST_ACTIVATION", - "XC_ANCHOR_SLASH_REGTEST_ACTIVATION") + "XC_ANCHOR_SLASH_REGTEST_ACTIVATION", + "EXACT_INTEGER_WIRE_FIELDS_REGTEST_TIME") for (const varName of rollcallPassthroughVars) { if (config.INDEXER_ROLLCALL_ENV[varName] !== undefined && config.INDEXER_ROLLCALL_ENV[varName] !== "") { defaultValues[varName] = config.INDEXER_ROLLCALL_ENV[varName] diff --git a/test/unit/config/regtest_gate_switch_passthrough.test.js b/test/unit/config/regtest_gate_switch_passthrough.test.js new file mode 100644 index 00000000..68684042 --- /dev/null +++ b/test/unit/config/regtest_gate_switch_passthrough.test.js @@ -0,0 +1,43 @@ +'use strict' + +// The hub is handed the offer-list arm; a regtest indexer is handed the +// exact-integer wire-field arm; shared-ledger indexers never see either. + +const { expect, makeServiceWithConfig } = require('../config_service.test/helpers.test') + +const OFFER = 'XC_OFFER_LIST_REGTEST_ACTIVATION' +const WIRE = 'EXACT_INTEGER_WIRE_FIELDS_REGTEST_TIME' +const VALUES = { [OFFER]: '20', [WIRE]: '1700000000' } + +describe('regtest gate switch passthrough', function () { + let saved + + beforeEach(function () { + saved = {} + for (const name of Object.keys(VALUES)) { saved[name] = process.env[name]; process.env[name] = VALUES[name] } + }) + + afterEach(function () { + for (const name of Object.keys(VALUES)) { + if (saved[name] === undefined) delete process.env[name]; else process.env[name] = saved[name] + } + }) + + it('hands the hub the offer-list arm', async function () { + const hub = await makeServiceWithConfig('').getDefaultConfig('xchain-hub', null, null) + expect(hub[OFFER]).to.equal(VALUES[OFFER]) + }) + + it('hands a regtest indexer the wire-field arm', async function () { + const indexer = await makeServiceWithConfig('').getDefaultConfig('xchain-indexer', 'dogecoin', 'regtest') + expect(indexer[WIRE]).to.equal(VALUES[WIRE]) + }) + + it('never hands a shared-ledger indexer the wire-field arm', async function () { + const cs = makeServiceWithConfig('') + for (const net of ['mainnet', 'testnet']) { + const config = await cs.getDefaultConfig('xchain-indexer', 'dogecoin', net) + expect(config, net).to.not.have.property(WIRE) + } + }) +}) From 7fee74a63baa551d89ceee221ba73ecd62e11efc Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 10:59:28 +0000 Subject: [PATCH 22/50] Split the configured database name lookup out of the precondition guard assertRequiredMigrationsApplied exceeded the 60 line function limit. Move the name resolution into resolveCheckedDatabaseName; behaviour is unchanged. --- .../migration_precondition_service.js | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/services/migration_precondition_service.js b/src/services/migration_precondition_service.js index 6a537b39..a327c463 100644 --- a/src/services/migration_precondition_service.js +++ b/src/services/migration_precondition_service.js @@ -152,6 +152,22 @@ async function readRefusalRemedy(module, coin, network, applied, deps) { } } +/** + * The database the service reads: the operator-configured name when set, else the + * derived default. An unreadable config falls back to the default with a warning. + */ +async function resolveCheckedDatabaseName(module, coin, network, deps) { + const defaultName = getModuleDatabaseName(module, coin, network) + try { + const readConfig = deps.getDefaultConfig || getDefaultConfig + return configuredDatabaseName(module, await readConfig(module, coin, network), defaultName) + } catch (err) { + logger.warn(`Migration precondition guard: could not read ${module}'s configured database name ` + + `(${err && err.message ? err.message : err}); using the default ${defaultName}.`) + return defaultName + } +} + /** * Refuses (throws) when the source tree about to be deployed declares a migration * as a startup precondition that the target database has not applied. @@ -195,19 +211,7 @@ async function assertRequiredMigrationsApplied(module, coin, network, branch = n } if (!required.length) return { checked: false, reason: 'no-preconditions' } - // The database the service actually reads is the operator-configured name when - // set, not the derived default; checking the default would bless or refuse a - // database the service never touches. An unreadable config falls back to the - // default name with a warning, which is the guard's behaviour without the override. - const defaultName = getModuleDatabaseName(module, coin, network) - let dbName = defaultName - try { - const readConfig = deps.getDefaultConfig || getDefaultConfig - dbName = configuredDatabaseName(module, await readConfig(module, coin, network), defaultName) - } catch (err) { - logger.warn(`Migration precondition guard: could not read ${module}'s configured database name ` + - `(${err && err.message ? err.message : err}); using the default ${defaultName}.`) - } + const dbName = await resolveCheckedDatabaseName(module, coin, network, deps) const result = await readApplied({ database: dbName, coin, network }) if (result.state === 'empty-database') return emptyDatabaseResult(module, dbName, required) From 503c0ce3273dfa00fd03a812db92c1ba316a4f38 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 10:59:33 +0000 Subject: [PATCH 23/50] Hoist hub passthrough lists out of configureHubBeforeKey The function ran 61 lines against the 60 line limit. The passthrough name list and the admission timing set now live as module constants, leaving the function as a short loop. Behavior is unchanged. --- src/services/config_service/networks.js | 91 +++++++++++++------------ 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/src/services/config_service/networks.js b/src/services/config_service/networks.js index c0283e16..9c718b16 100644 --- a/src/services/config_service/networks.js +++ b/src/services/config_service/networks.js @@ -110,6 +110,50 @@ async function composeColocatedIndexerUrls(defaultValues, network, registeredSta } } +const HUB_PASSTHROUGH_VARS = [ + "HUB_API_KEY", + "BTC_INDEXER_URL", "LTC_INDEXER_URL", "DOGE_INDEXER_URL", + "BTC_INDEXER_API_KEY", "LTC_INDEXER_API_KEY", "DOGE_INDEXER_API_KEY", + "CHECKPOINT_ENABLED", "CHECKPOINT_INTERVAL_BLOCKS", "CHECKPOINT_CONFIRMATIONS", + "CHECKPOINT_POLL_MS", "CHECKPOINT_ROUND_TIMEOUT_MS", "CHECKPOINT_CHAINS", + "ANCHOR_ENABLED", "ANCHOR_INTERVAL_MS", "ANCHOR_MATCH_BATCH_SIZE", + "ANCHOR_MAX_BATCH", "ANCHOR_CHUNK_MAX_BYTES", "ANCHOR_ROUND_TIMEOUT_MS", + "ANCHOR_CHUNK_RETRY_MS", "ANCHOR_ELECTION_TOLERANCE_BLOCKS", "ANCHOR_REWARD_PER_PUBLISH", + "ANCHOR_CHECKPOINT_EVERY_N", + "DOGE_ENCODER_URL", "DOGE_ENCODER_API_KEY", "DOGE_ADDRESS", + "DOGE_PUBKEY_HEX", "DOGE_LOW_BALANCE_THRESHOLD", + "XDEX_SEED_LOCAL_VALIDATOR", "XDEX_SNAPSHOT_BLOCK", + "XCHAIN_CONFIRMATIONS_BTC", "XCHAIN_CONFIRMATIONS_LTC", "XCHAIN_CONFIRMATIONS_DOGE", + "HUB_TRUST_PROXY", + "HUB_NETWORK", + "ORACLE_MIN_SUBMISSIONS", + "ORACLE_ROUND_INTERVAL", "ORACLE_SUBMISSION_WINDOW", + "ATTESTATION_ROUND_TIMEOUT_MS", "ADMISSION_WATERMARK_SAMPLE_MS", + "ORACLE_BATCH_WINDOW_ROUNDS", "ORACLE_BATCH_GRACE_MS", + "ORACLE_BATCH_SIGN_TIMEOUT_MS", "ORACLE_BATCH_BUFFER_MAX_ROUNDS", + "ORACLE_BATCH_LANDING_RESERVE_MS", + "ATTEST_RESPONSE_FORWARD_S_OVERRIDE", + "ATTEST_BATCH_WINDOW_S_OVERRIDE", + "HUB_RATE_LIMIT_RPM", "HUB_RATE_LIMIT_EXEMPT_LOCAL", + "XCHAIN_PRICE_INDEXER_DB_HOST", "XCHAIN_PRICE_INDEXER_DB_PORT", + "XCHAIN_PRICE_INDEXER_DB_NAME", "XCHAIN_PRICE_INDEXER_DB_USER", + "XCHAIN_PRICE_INDEXER_DB_PASS", "XCHAIN_PRICE_INDEXER_DB_COIN", + "XCHAIN_PRICE_WINDOW_BLOCKS", "XCHAIN_PRICE_CONFIRMATION_BUFFER", + "XCHAIN_PRICE_BOOTSTRAP_SATS", + "XCHAIN_PRICE_MIN_BTC_VOLUME", + "HUB_API_KEY", "HUB_ALLOW_UNAUTHENTICATED", + "XC_ROLLCALL_REGTEST_ACTIVATION", + "XC_ROLLCALL_GATES_REGTEST_ACTIVATION", + "XC_MIRROR_ADMISSION_ACTIVATION", + "XC_ANCHOR_FOLD_REGTEST_ACTIVATION", + "XC_ANCHOR_STAKE_REGTEST_ACTIVATION", + "XC_ANCHOR_SLASH_REGTEST_ACTIVATION", + "XC_OFFER_LIST_REGTEST_ACTIVATION" +] +const HUB_ADMISSION_TIMING_VARS = new Set([ + "ATTESTATION_ROUND_TIMEOUT_MS", "ADMISSION_WATERMARK_SAMPLE_MS" +]) + // Hub telemetry secrets come from host env; unset, ip_hash stays null and admin is 401. // BTC indexer JSON-RPC URL comes from host env for a hub not co-located with one. Left // empty, it is composed from the co-located indexer, else the hub uses its configs table. @@ -259,51 +303,8 @@ function configureHubBeforeKey(defaultValues, module) { defaultValues["TELEMETRY_IP_SALT"] = config.TELEMETRY_IP_SALT defaultValues["TELEMETRY_ADMIN_KEY"] = config.TELEMETRY_ADMIN_KEY defaultValues["BTC_INDEXER_API_URL"] = config.BTC_INDEXER_API_URL - const hubPassthroughVars = [ - "HUB_API_KEY", - "BTC_INDEXER_URL", "LTC_INDEXER_URL", "DOGE_INDEXER_URL", - "BTC_INDEXER_API_KEY", "LTC_INDEXER_API_KEY", "DOGE_INDEXER_API_KEY", - "CHECKPOINT_ENABLED", "CHECKPOINT_INTERVAL_BLOCKS", "CHECKPOINT_CONFIRMATIONS", - "CHECKPOINT_POLL_MS", "CHECKPOINT_ROUND_TIMEOUT_MS", "CHECKPOINT_CHAINS", - "ANCHOR_ENABLED", "ANCHOR_INTERVAL_MS", "ANCHOR_MATCH_BATCH_SIZE", - "ANCHOR_MAX_BATCH", "ANCHOR_CHUNK_MAX_BYTES", "ANCHOR_ROUND_TIMEOUT_MS", - "ANCHOR_CHUNK_RETRY_MS", "ANCHOR_ELECTION_TOLERANCE_BLOCKS", "ANCHOR_REWARD_PER_PUBLISH", - "ANCHOR_CHECKPOINT_EVERY_N", - "DOGE_ENCODER_URL", "DOGE_ENCODER_API_KEY", "DOGE_ADDRESS", - "DOGE_PUBKEY_HEX", "DOGE_LOW_BALANCE_THRESHOLD", - "XDEX_SEED_LOCAL_VALIDATOR", "XDEX_SNAPSHOT_BLOCK", - "XCHAIN_CONFIRMATIONS_BTC", "XCHAIN_CONFIRMATIONS_LTC", "XCHAIN_CONFIRMATIONS_DOGE", - "HUB_TRUST_PROXY", - "HUB_NETWORK", - "ORACLE_MIN_SUBMISSIONS", - "ORACLE_ROUND_INTERVAL", "ORACLE_SUBMISSION_WINDOW", - "ATTESTATION_ROUND_TIMEOUT_MS", "ADMISSION_WATERMARK_SAMPLE_MS", - "ORACLE_BATCH_WINDOW_ROUNDS", "ORACLE_BATCH_GRACE_MS", - "ORACLE_BATCH_SIGN_TIMEOUT_MS", "ORACLE_BATCH_BUFFER_MAX_ROUNDS", - "ORACLE_BATCH_LANDING_RESERVE_MS", - "ATTEST_RESPONSE_FORWARD_S_OVERRIDE", - "ATTEST_BATCH_WINDOW_S_OVERRIDE", - "HUB_RATE_LIMIT_RPM", "HUB_RATE_LIMIT_EXEMPT_LOCAL", - "XCHAIN_PRICE_INDEXER_DB_HOST", "XCHAIN_PRICE_INDEXER_DB_PORT", - "XCHAIN_PRICE_INDEXER_DB_NAME", "XCHAIN_PRICE_INDEXER_DB_USER", - "XCHAIN_PRICE_INDEXER_DB_PASS", "XCHAIN_PRICE_INDEXER_DB_COIN", - "XCHAIN_PRICE_WINDOW_BLOCKS", "XCHAIN_PRICE_CONFIRMATION_BUFFER", - "XCHAIN_PRICE_BOOTSTRAP_SATS", - "XCHAIN_PRICE_MIN_BTC_VOLUME", - "HUB_API_KEY", "HUB_ALLOW_UNAUTHENTICATED", - "XC_ROLLCALL_REGTEST_ACTIVATION", - "XC_ROLLCALL_GATES_REGTEST_ACTIVATION", - "XC_MIRROR_ADMISSION_ACTIVATION", - "XC_ANCHOR_FOLD_REGTEST_ACTIVATION", - "XC_ANCHOR_STAKE_REGTEST_ACTIVATION", - "XC_ANCHOR_SLASH_REGTEST_ACTIVATION", - "XC_OFFER_LIST_REGTEST_ACTIVATION" - ] - const hubAdmissionTimingVars = new Set([ - "ATTESTATION_ROUND_TIMEOUT_MS", "ADMISSION_WATERMARK_SAMPLE_MS" - ]) - for (const varName of hubPassthroughVars) { - const value = hubAdmissionTimingVars.has(varName) + for (const varName of HUB_PASSTHROUGH_VARS) { + const value = HUB_ADMISSION_TIMING_VARS.has(varName) ? config.childProcessEnv()[varName] : readSecretHostEnv(varName) if (value !== undefined && value !== "") { From ca806e53e3251fcfadeb9f18ac231c65e2a2e6b8 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 11:12:03 +0000 Subject: [PATCH 24/50] Trim networks.js to the 400 line file limit Drop a blank comment line so the file stays within the limit. --- src/services/config_service/networks.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/services/config_service/networks.js b/src/services/config_service/networks.js index 9c718b16..52896af1 100644 --- a/src/services/config_service/networks.js +++ b/src/services/config_service/networks.js @@ -226,7 +226,6 @@ const HUB_ADMISSION_TIMING_VARS = new Set([ // collectively blows 100/min and gets 429'd, so the heartbeat gate then stays // closed and the chain stalls. Raise for prod fleets. Passed through so the // host env survives a hub container regenerate. -// // The hub exempts loopback and private-range callers from // that cap by default, which covers the case above: the indexers reach the hub // container over the bridge network this compose file creates, so a managed From 219ab75c03a766b82fde6ef47dccb1270e946c6f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 08:00:00 -0700 Subject: [PATCH 25/50] fix(discovery): classify containers by name only, never by image A container outside this install's naming scheme was still claimed as a module when it ran a module image, so a second stack on the same host (for example a compose project built from the same images) took over the registry rows and later commands would act on its containers. Identity now comes from the CLI-assigned container name alone. --- src/services/discovery_service.js | 34 ++++++++--------------------- test/unit/discovery_service.test.js | 34 +++++++++++++++++++---------- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/src/services/discovery_service.js b/src/services/discovery_service.js index 674b1a9c..79a39478 100644 --- a/src/services/discovery_service.js +++ b/src/services/discovery_service.js @@ -52,30 +52,16 @@ function containerName(container) { return String((Array.isArray(container.Names) ? container.Names[0] : container.Names) || '').replace(/^\//, '') } -// True when the container carries a CLI-assigned name rather than matching only by its image tag. -function matchesByName(container) { - return containerName(container).startsWith(NODE_PREFIX + SEP) -} - // Resolve a docker container to its registry identity { module, coin, network } -// (coin/network are '' for shared modules), or null for non-xchain containers. -// -// Identity comes from the CONTAINER NAME, the stable key the CLI itself -// assigns at creation (and what the file header above promises). The image -// tag is NOT reliable: rebuilding a module's image while the old container is -// still running steals the tag, docker then reports that container's image as -// a bare ID, and an image-keyed scan goes blind to a live, healthy container. -// Worse, the orphan purge below then DELETES its registry row, after which -// update/start/stop silently no-op for it (a litecoin-mainnet -// xchain-indexer incident, 2026-06-11). The image name remains a fallback for -// containers that were renamed externally. +// (coin/network are '' for shared modules), or null for any other container. +// Identity is the container NAME the CLI assigns; the image tag can be stolen by a rebuild. function classifyContainer(container) { - const name = containerName(container) - const image = String(container.Image || '') - const identity = name.startsWith(NODE_PREFIX + SEP) ? name : image - if (!identity.startsWith(NODE_PREFIX + SEP)) return null + const name = containerName(container) + // Refuse every name outside this install's scheme, whatever image it runs: + // another stack on the host may run the same module images under its own names. + if (!name.startsWith(NODE_PREFIX + SEP)) return null - const rest = identity.substr(NODE_PREFIX.length + SEP.length) + const rest = name.substr(NODE_PREFIX.length + SEP.length) if (SHARED_MODULES.includes(rest)) return { module: rest, coin: "", network: "" } const parts = rest.split(SEP) @@ -125,10 +111,8 @@ function moduleKey(module, coin, network) { async function registerContainers(containers, silent, seen) { let added = 0 let reconciled = 0 - // Rank name matches first, then RUNNING (stable, so docker's newest-first breaks ties): - // a container started by hand from a module image under another name matches only by - // image and must never take the key from the container holding the CLI-assigned name. - const rank = (c) => (matchesByName(c) ? 0 : 2) + ((c.State || '').toLowerCase() === 'running' ? 0 : 1) + // Rank RUNNING first (stable, so docker's newest-first breaks ties) before first-seen wins. + const rank = (c) => ((c.State || '').toLowerCase() === 'running' ? 0 : 1) containers.sort((a, b) => rank(a) - rank(b)) for (const nextContainer of containers) { diff --git a/test/unit/discovery_service.test.js b/test/unit/discovery_service.test.js index 4017040c..2d61ebfd 100644 --- a/test/unit/discovery_service.test.js +++ b/test/unit/discovery_service.test.js @@ -19,7 +19,7 @@ * image as a bare ID, so the scan went blind to a live container, and the * orphan purge then DELETED its registry row. From then on update/start/stop * silently no-op'd for that module while `sync` reported "already in sync". - * Identity now comes from the CONTAINER NAME (image as fallback). + * Identity now comes from the CONTAINER NAME only; the image never decides it. ********************************************************************/ const sinon = require('sinon') @@ -57,11 +57,24 @@ describe('DiscoveryService.classifyContainer', function () { })).to.deep.equal({ module: 'xchain-indexer', coin: 'litecoin', network: 'mainnet' }) }) - it('falls back to the image name when the container was renamed externally', function () { + it('never classifies a container outside the naming scheme by its module image', function () { + // Another stack on the same host (here a compose project) runs the same + // module images; claiming its containers would point every command at them. + for (const [name, image] of [ + ['other-stack-hub-1', 'xchain-node-xchain-hub'], + ['other-stack-db-1', 'xchain-node-database'], + ['other-stack-doge-miner-1', 'xchain-node-dogecoin-regtest-xchain-regtest-miner'], + ['my-custom-name', 'xchain-node-bitcoin-regtest-xchain-decoder'], + ]) { + expect(svc.classifyContainer({ Names: name, Image: image }), name).to.equal(null) + } + // This install's own names still classify whatever image they run. + expect(svc.classifyContainer({ Names: 'xchain-node-xchain-hub', Image: 'xchain-node-xchain-hub' })) + .to.deep.equal({ module: 'xchain-hub', coin: '', network: '' }) expect(svc.classifyContainer({ - Names: 'my-custom-name', - Image: 'xchain-node-bitcoin-regtest-xchain-decoder', - })).to.deep.equal({ module: 'xchain-decoder', coin: 'bitcoin', network: 'regtest' }) + Names: 'xchain-node-bitcoin-regtest-xchain-utxo-tracker', + Image: 'xchain-node-bitcoin-regtest-xchain-utxo-tracker', + })).to.deep.equal({ module: 'xchain-utxo-tracker', coin: 'bitcoin', network: 'regtest' }) }) it('classifies shared modules (no coin/network)', function () { @@ -269,9 +282,8 @@ describe('DiscoveryService.scanAndRegisterModules fail-closed registry', functio }) }) -// A container started by hand from a module image under another name matches -// its key only through the image fallback, so it must never take the row from -// the container that holds the CLI-assigned name. +// A container started by hand from a module image under another name is not a +// module, so it must never take the row from the container holding the CLI name. describe('DiscoveryService.scanAndRegisterModules name-over-image ranking', function () { afterEach(() => sinon.restore()) @@ -308,11 +320,11 @@ describe('DiscoveryService.scanAndRegisterModules name-over-image ranking', func sinon.assert.calledOnceWithExactly(db.setModuleContainer, 'xchain-indexer', 'bitcoin', 'mainnet', 'realrealrealrealreal') }) - it('still prefers a running image-only container over a stopped one when neither is named', async function () { + it('registers nothing when only containers outside the naming scheme run module images', async function () { const stale = { Names: 'renamed-old', Image: IMAGE, ID: 'staleSTALEstaleSTALE', State: 'exited' } - const live = { Names: 'renamed-new', Image: IMAGE, ID: 'liveLIVEliveLIVElive', State: 'running' } + const live = { Names: 'other-stack-indexer-1', Image: IMAGE, ID: 'liveLIVEliveLIVElive', State: 'running' } const { svc, db } = loadService({ containers: [stale, live] }) await svc.scanAndRegisterModules({ silent: true }) - sinon.assert.calledOnceWithExactly(db.setModuleContainer, 'xchain-indexer', 'bitcoin', 'mainnet', 'liveLIVEliveLIVElive') + sinon.assert.notCalled(db.setModuleContainer) }) }) From 2caf0fa4603d6b2a124dade0d2f91056682d821d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 08:12:06 -0700 Subject: [PATCH 26/50] Bump js-yaml to 4.3.2 and brace-expansion in the lockfile --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9be50daa..93e375a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2042,9 +2042,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4171,9 +4171,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { From 158dc10d1356a4f5bf8ee71db6d163ce412fa93c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 16:12:40 +0000 Subject: [PATCH 27/50] Pass publish switches through the hub host passthrough ORACLE_PUBLISH_ENABLED and ATTEST_BATCH_PUBLISH_ENABLED now reach the hub env from the host, declared in the env view and the hub passthrough list, with a test covering both. --- src/config/env_views.js | 1 + src/services/config_service/networks.js | 1 + ...ig_service_sync_parity_passthrough.test.js | 24 +++++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/src/config/env_views.js b/src/config/env_views.js index 69e06ba6..ff600664 100644 --- a/src/config/env_views.js +++ b/src/config/env_views.js @@ -52,6 +52,7 @@ const LIST_VIEWS = { "ORACLE_BATCH_GRACE_MS", "ORACLE_BATCH_SIGN_TIMEOUT_MS", "ORACLE_BATCH_BUFFER_MAX_ROUNDS", "ORACLE_BATCH_LANDING_RESERVE_MS", "ATTEST_RESPONSE_FORWARD_S_OVERRIDE", "ATTEST_BATCH_WINDOW_S_OVERRIDE", + "ORACLE_PUBLISH_ENABLED", "ATTEST_BATCH_PUBLISH_ENABLED", "HUB_RATE_LIMIT_RPM", "HUB_RATE_LIMIT_EXEMPT_LOCAL", "XCHAIN_PRICE_INDEXER_DB_HOST", "XCHAIN_PRICE_INDEXER_DB_PORT", "XCHAIN_PRICE_INDEXER_DB_NAME", "XCHAIN_PRICE_INDEXER_DB_USER", "XCHAIN_PRICE_INDEXER_DB_PASS", diff --git a/src/services/config_service/networks.js b/src/services/config_service/networks.js index 52896af1..694825a1 100644 --- a/src/services/config_service/networks.js +++ b/src/services/config_service/networks.js @@ -134,6 +134,7 @@ const HUB_PASSTHROUGH_VARS = [ "ORACLE_BATCH_LANDING_RESERVE_MS", "ATTEST_RESPONSE_FORWARD_S_OVERRIDE", "ATTEST_BATCH_WINDOW_S_OVERRIDE", + "ORACLE_PUBLISH_ENABLED", "ATTEST_BATCH_PUBLISH_ENABLED", "HUB_RATE_LIMIT_RPM", "HUB_RATE_LIMIT_EXEMPT_LOCAL", "XCHAIN_PRICE_INDEXER_DB_HOST", "XCHAIN_PRICE_INDEXER_DB_PORT", "XCHAIN_PRICE_INDEXER_DB_NAME", "XCHAIN_PRICE_INDEXER_DB_USER", diff --git a/test/unit/config_service_sync_parity_passthrough.test.js b/test/unit/config_service_sync_parity_passthrough.test.js index be515685..2291491e 100644 --- a/test/unit/config_service_sync_parity_passthrough.test.js +++ b/test/unit/config_service_sync_parity_passthrough.test.js @@ -74,3 +74,27 @@ describe('sync INDEX_MAP_PARITY_CHECK passthrough', () => { } }) }) + +describe('hub publish switches passthrough', () => { + const NAMES = ['ORACLE_PUBLISH_ENABLED', 'ATTEST_BATCH_PUBLISH_ENABLED'] + + it('the view answers the host values', () => { + const views = viewsFor({ ORACLE_PUBLISH_ENABLED: 'false', ATTEST_BATCH_PUBLISH_ENABLED: 'true' }) + expect(views.HUB_PASSTHROUGH_ENV.ORACLE_PUBLISH_ENABLED).to.equal('false') + expect(views.HUB_PASSTHROUGH_ENV.ATTEST_BATCH_PUBLISH_ENABLED).to.equal('true') + }) + + it('puts both host values in the hub env', async () => { + const previous = {} + for (const n of NAMES) { previous[n] = process.env[n]; process.env[n] = 'false' } + try { + const config = await containerConfig(HUB_MODULE_NAME) + for (const n of NAMES) expect(config).to.have.property(n, 'false') + } finally { + for (const n of NAMES) { + if (previous[n] === undefined) delete process.env[n] + else process.env[n] = previous[n] + } + } + }) +}) From 7ad46459dd5e275127a9c17d7c3c6a27de31f12e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 16:22:55 +0000 Subject: [PATCH 28/50] Keep networks.js at the 400 line file limit Fold the publish switch names onto the preceding passthrough line so the file is back to 400 lines. --- src/services/config_service/networks.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/services/config_service/networks.js b/src/services/config_service/networks.js index 694825a1..d91d24a0 100644 --- a/src/services/config_service/networks.js +++ b/src/services/config_service/networks.js @@ -133,8 +133,7 @@ const HUB_PASSTHROUGH_VARS = [ "ORACLE_BATCH_SIGN_TIMEOUT_MS", "ORACLE_BATCH_BUFFER_MAX_ROUNDS", "ORACLE_BATCH_LANDING_RESERVE_MS", "ATTEST_RESPONSE_FORWARD_S_OVERRIDE", - "ATTEST_BATCH_WINDOW_S_OVERRIDE", - "ORACLE_PUBLISH_ENABLED", "ATTEST_BATCH_PUBLISH_ENABLED", + "ATTEST_BATCH_WINDOW_S_OVERRIDE", "ORACLE_PUBLISH_ENABLED", "ATTEST_BATCH_PUBLISH_ENABLED", "HUB_RATE_LIMIT_RPM", "HUB_RATE_LIMIT_EXEMPT_LOCAL", "XCHAIN_PRICE_INDEXER_DB_HOST", "XCHAIN_PRICE_INDEXER_DB_PORT", "XCHAIN_PRICE_INDEXER_DB_NAME", "XCHAIN_PRICE_INDEXER_DB_USER", From df2bfac8e78a99a527afa082cff4eada1792bb24 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 13:40:48 -0700 Subject: [PATCH 29/50] Route contributions to develop and guard master against non-release pull requests Contributors now branch off develop and open pull requests against it, as the docs and PR template say. A new pull_request_target check fails any pull request into master that is not this repository's own develop, release/* or hotfix/* branch, and CI now runs on pull requests into develop. --- .github/pull_request_template.md | 4 +++ .github/workflows/base-branch-guard.yml | 40 +++++++++++++++++++++++++ .github/workflows/ci.yml | 2 +- CONTRIBUTING.md | 2 ++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/base-branch-guard.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6241305c..061bdbb5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,9 @@ +## Base branch + +- [ ] This pull request targets `develop`. `master` only takes release merges, and a pull request into it from a fork cannot be merged. + ## What this changes diff --git a/.github/workflows/base-branch-guard.yml b/.github/workflows/base-branch-guard.yml new file mode 100644 index 00000000..ad3e8dab --- /dev/null +++ b/.github/workflows/base-branch-guard.yml @@ -0,0 +1,40 @@ +# Fails any pull request into master that does not come from this repository's +# own develop, release/* or hotfix/* branch. master is what production runs, so +# outside contributions land on develop first and reach master only in a release +# merge (hotfix/* is the documented emergency path, see the release process). +# master's branch protection requires this check, so a fork pull request aimed +# at master cannot be merged by mistake. +# +# pull_request_target reads this file from the repository's own branch, never +# from the pull request, so a pull request cannot edit the guard away. The job +# reads only event metadata and never checks out pull request code. +name: Base branch guard + +on: + pull_request_target: + branches: [master] + # edited fires when an open pull request is retargeted onto master. + types: [opened, reopened, synchronize, edited] + +permissions: {} + +jobs: + base-branch-guard: + name: base-branch guard + runs-on: ubuntu-latest + steps: + - name: Require develop, release/* or hotfix/* from this repository + # Event fields go through env, never inline, so a branch name cannot inject shell. + env: + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + THIS_REPO: ${{ github.repository }} + run: | + if [ "$HEAD_REPO" != "$THIS_REPO" ]; then + echo "::error::Pull requests from forks go to develop, not master. Change the base branch to develop." + exit 1 + fi + case "$HEAD_REF" in + develop|release/*|hotfix/*) echo "ok: $HEAD_REF into master" ;; + *) echo "::error::master only accepts develop, release/* or hotfix/*; retarget $HEAD_REF to develop."; exit 1 ;; + esac diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39e7fe8c..ae312a09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ on: # so the merge commit a release tag points at has its own green run. branches: [master, develop] pull_request: - branches: [master] + branches: [master, develop] jobs: ci: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6dd58834..4d6b118c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,6 +112,8 @@ Match the existing log style: a concise subject line, then a short body explaini ## Pull requests +Fork the repository, branch off `develop`, and open your pull request against `develop`. Keep history linear (rebase, don't merge). `master` is what production runs and only ever receives release merges from `develop`; a pull request into `master` from a fork or any other branch fails the `base-branch guard` check and cannot be merged. + CI is the unit gate. Before opening a PR: 1. Run the no-external-services tiers (`npm run ci`, `npm run test:security`) and confirm they pass. From 9e586b31ed7d4baba2c6f8d8e05666e86e3d98d1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 14 Oct 2026 01:54:13 +0000 Subject: [PATCH 30/50] Kill the gpg agent when the gpg tests clean up their temp home Each throwaway keyring starts a gpg-agent bound to its homedir. Run gpgconf --kill gpg-agent against that same home before removing it so the agent and its socket do not outlive the run. --- test/unit/publish_bootstraps_forced_republish.test.js | 5 +++++ .../verify_detached_signature.test.js | 9 ++++++++- .../verify_git_tag_signature.test.js | 9 ++++++++- .../verify_manifest_for_tag.test.js | 9 ++++++++- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/test/unit/publish_bootstraps_forced_republish.test.js b/test/unit/publish_bootstraps_forced_republish.test.js index 4fa4ab9c..2c1d77e7 100644 --- a/test/unit/publish_bootstraps_forced_republish.test.js +++ b/test/unit/publish_bootstraps_forced_republish.test.js @@ -49,6 +49,11 @@ function createWorkDir() { } function removeWorkDir() { + // Any gpg the script under test runs under this work dir may have started an + // agent bound to it; stop it before the directory goes. + try { + execFileSync('gpgconf', ['--homedir', workDir, '--kill', 'gpg-agent'], { stdio: 'ignore' }) + } catch { /* gpgconf missing or no agent running */ } fs.rmSync(workDir, { recursive: true, force: true }) } diff --git a/test/unit/release_signature_service.test/verify_detached_signature.test.js b/test/unit/release_signature_service.test/verify_detached_signature.test.js index 23037461..4dbfb05f 100644 --- a/test/unit/release_signature_service.test/verify_detached_signature.test.js +++ b/test/unit/release_signature_service.test/verify_detached_signature.test.js @@ -58,7 +58,14 @@ function makeKeyring(name) { '--local-user', fingerprint, '--armor', '--detach-sign', '--output', '-', dataPath ]) }, - cleanup() { fs.rmSync(home, { recursive: true, force: true }) } + cleanup() { + // gpg spawns a gpg-agent bound to this homedir; kill it first or it + // outlives the run and leaks a socket and a process per keyring. + try { + execFileSync('gpgconf', ['--homedir', home, '--kill', 'gpg-agent'], { stdio: 'ignore' }) + } catch { /* gpgconf missing or no agent running */ } + fs.rmSync(home, { recursive: true, force: true }) + } } } diff --git a/test/unit/release_signature_service.test/verify_git_tag_signature.test.js b/test/unit/release_signature_service.test/verify_git_tag_signature.test.js index 89d52bea..d317072c 100644 --- a/test/unit/release_signature_service.test/verify_git_tag_signature.test.js +++ b/test/unit/release_signature_service.test/verify_git_tag_signature.test.js @@ -58,7 +58,14 @@ function makeKeyring(name) { '--local-user', fingerprint, '--armor', '--detach-sign', '--output', '-', dataPath ]) }, - cleanup() { fs.rmSync(home, { recursive: true, force: true }) } + cleanup() { + // gpg spawns a gpg-agent bound to this homedir; kill it first or it + // outlives the run and leaks a socket and a process per keyring. + try { + execFileSync('gpgconf', ['--homedir', home, '--kill', 'gpg-agent'], { stdio: 'ignore' }) + } catch { /* gpgconf missing or no agent running */ } + fs.rmSync(home, { recursive: true, force: true }) + } } } diff --git a/test/unit/release_signature_service.test/verify_manifest_for_tag.test.js b/test/unit/release_signature_service.test/verify_manifest_for_tag.test.js index da7fc10d..ec07db1a 100644 --- a/test/unit/release_signature_service.test/verify_manifest_for_tag.test.js +++ b/test/unit/release_signature_service.test/verify_manifest_for_tag.test.js @@ -60,7 +60,14 @@ function makeKeyring(name) { '--local-user', fingerprint, '--armor', '--detach-sign', '--output', '-', dataPath ]) }, - cleanup() { fs.rmSync(home, { recursive: true, force: true }) } + cleanup() { + // gpg spawns a gpg-agent bound to this homedir; kill it first or it + // outlives the run and leaks a socket and a process per keyring. + try { + execFileSync('gpgconf', ['--homedir', home, '--kill', 'gpg-agent'], { stdio: 'ignore' }) + } catch { /* gpgconf missing or no agent running */ } + fs.rmSync(home, { recursive: true, force: true }) + } } } From e6f14473e4a569116ad4b8070b81b80e6a135ac5 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 14 Oct 2026 08:03:52 +0000 Subject: [PATCH 31/50] Budget and disambiguate NODE_PREFIX database scope, route reset through configured names Derived decoder and indexer database and user names now carry a segment that is injective in the raw NODE_PREFIX (digest when folding is lossy or the prefix is long) and is capped so the indexer name plus _HubMirror stays within 64 characters. Database reset resolves targets through configuredDatabaseName, and the migration precondition guard warns when a non-default prefix stack is on the unprefixed default name. --- src/services/config_service.js | 6 +-- .../database_service/reset_databases.js | 15 ++------ .../migration_precondition_service.js | 17 ++++++++- src/utils/module_database_name.js | 37 ++++++++++++++++-- .../module_database_name_prefix_scope.test.js | 38 +++++++++++++++++-- 5 files changed, 88 insertions(+), 25 deletions(-) diff --git a/src/services/config_service.js b/src/services/config_service.js index 9e6e1501..9e74689c 100644 --- a/src/services/config_service.js +++ b/src/services/config_service.js @@ -27,7 +27,7 @@ const { moduleDir, tmpDir, cryptoNodesDir, bootstrapDir, configDir, EXTERNAL_DB } = require('../config') const { stringToCoin } = require('../utils/helpers') -const { nodePrefixSegment } = require('../utils/module_database_name') +const { scopedNodePrefixSegment } = require('../utils/module_database_name') const { preferredSecretEnvName, foldSecretEnvAliases, readSecretHostEnv, deprecatedSecretEnvNames } = require('../config/secret_env') const { getCoinConfigByFullName } = require('../coins') const config = require('../config') @@ -149,7 +149,7 @@ function getModuleDatabaseName(module, coin, network) { } const moduleName = module.slice("xchain-".length) return "XChain" + DB_SEP - + nodePrefixSegment(NODE_PREFIX, DEFAULT_NODE_PREFIX, DB_SEP) + + scopedNodePrefixSegment(NODE_PREFIX, DEFAULT_NODE_PREFIX, DB_SEP) + CoinTickerSymbol[coin] + DB_SEP + network.charAt(0).toUpperCase() + network.slice(1) + DB_SEP + moduleName.charAt(0).toUpperCase() + moduleName.slice(1) @@ -158,7 +158,7 @@ function getModuleDatabaseName(module, coin, network) { // Module DB account names carry the NODE_PREFIX like the database names, so two // stacks sharing one MariaDB never contend for one account. Default prefix: unchanged. function applyPrefixedModuleDbUsers(defaultValues, coin, network) { - const segment = nodePrefixSegment(NODE_PREFIX, DEFAULT_NODE_PREFIX, DB_SEP) + const segment = scopedNodePrefixSegment(NODE_PREFIX, DEFAULT_NODE_PREFIX, DB_SEP) if (segment === '') return for (const [key, name] of [["DECODER_DB_USER", "decoder"], ["INDEXER_DB_USER", "indexer"]]) { defaultValues[key] = "xchain" + DB_SEP + segment + name + DB_SEP + coin + DB_SEP + network diff --git a/src/services/database_service/reset_databases.js b/src/services/database_service/reset_databases.js index bd65dcdd..e3a52934 100644 --- a/src/services/database_service/reset_databases.js +++ b/src/services/database_service/reset_databases.js @@ -23,6 +23,7 @@ let { assertSafeDbIdentifier, escapeSqlStringLiteral } = require('../../utils/sq let { tableExistsSql } = require('../../db/information_schema') let { PRICE_FENCE_TABLE, FENCE_NETWORK_COLUMN, clearChainFenceSql, clearNetworkFenceSql, manualClearStatement } = require('../../db/price_fence') let { getDefaultConfig, getModuleDatabaseName } = require('../config_service') +const { DATABASE_NAME_KEY, configuredDatabaseName } = require('../../utils/module_database_name') let { getLogger } = require('../../observability/logger') let logger = getLogger() let { getDatabaseContainerId } = require('./container_access') @@ -54,20 +55,10 @@ function configureDependencies(dependencies) { // throw with the first module's database already dropped. This also covers // an operator-supplied name, which is the only untrusted one. async function resolveResetTargets(coin, network, modules) { - const configuredDbNameKey = { - [XChainService.XCHAIN_DECODER]: "DECODER_DB_NAME", - [XChainService.XCHAIN_INDEXER]: "INDEXER_DB_NAME" - } const resetTargets = [] for (const module of modules) { - let dbName = getModuleDatabaseName(module, coin, network) - const configKey = configuredDbNameKey[module] - if (configKey) { - const cfg = await getDefaultConfig(module, coin, network) - const configured = cfg ? cfg[configKey] : undefined - if (typeof configured === "string" && configured.trim() !== "") dbName = configured.trim() - } - resetTargets.push(assertSafeDbIdentifier(dbName, 'database name')) + const cfg = DATABASE_NAME_KEY[module] ? await getDefaultConfig(module, coin, network) : undefined + resetTargets.push(configuredDatabaseName(module, cfg, getModuleDatabaseName(module, coin, network))) } return resetTargets } diff --git a/src/services/migration_precondition_service.js b/src/services/migration_precondition_service.js index a327c463..8f96e29b 100644 --- a/src/services/migration_precondition_service.js +++ b/src/services/migration_precondition_service.js @@ -55,7 +55,8 @@ const { XChainService } = require('../config') const { migrationsDirOf } = require('../utils/migration_files') const { getModuleTmpDir, getModuleDatabaseName, getDockerContainerImageName, getDefaultConfig } = require('./config_service') -const { configuredDatabaseName } = require('../utils/module_database_name') +const { configuredDatabaseName, unscopedDefaultDatabaseName } = require('../utils/module_database_name') +const appConfig = require('../config') const config = require('../config'); const { getLogger } = require('../observability/logger'); const logger = getLogger(); @@ -152,6 +153,16 @@ async function readRefusalRemedy(module, coin, network, applied, deps) { } } +// A non-default NODE_PREFIX stack still on the unprefixed default name shares that +// database with the default stack; deploys refuse it, this surfaces it before then. +function warnUnscopedDefaultName(module, coin, network, name) { + const { NODE_PREFIX, DEFAULT_NODE_PREFIX, CoinTickerSymbol, DB_SEP } = appConfig + if (NODE_PREFIX === DEFAULT_NODE_PREFIX || !CoinTickerSymbol || CoinTickerSymbol[coin] === undefined) return + if (name !== unscopedDefaultDatabaseName(module, CoinTickerSymbol[coin], network, DB_SEP)) return + logger.warn(`Migration precondition guard: ${module} uses the unprefixed default database ${name} ` + + `under NODE_PREFIX "${NODE_PREFIX}"; it is shared with the default "${DEFAULT_NODE_PREFIX}" stack.`) +} + /** * The database the service reads: the operator-configured name when set, else the * derived default. An unreadable config falls back to the default with a warning. @@ -160,7 +171,9 @@ async function resolveCheckedDatabaseName(module, coin, network, deps) { const defaultName = getModuleDatabaseName(module, coin, network) try { const readConfig = deps.getDefaultConfig || getDefaultConfig - return configuredDatabaseName(module, await readConfig(module, coin, network), defaultName) + const name = configuredDatabaseName(module, await readConfig(module, coin, network), defaultName) + warnUnscopedDefaultName(module, coin, network, name) + return name } catch (err) { logger.warn(`Migration precondition guard: could not read ${module}'s configured database name ` + `(${err && err.message ? err.message : err}); using the default ${defaultName}.`) diff --git a/src/utils/module_database_name.js b/src/utils/module_database_name.js index 50ec8d34..34d71442 100644 --- a/src/utils/module_database_name.js +++ b/src/utils/module_database_name.js @@ -35,12 +35,41 @@ function configuredDatabaseName(module, cfg, defaultName) { return assertSafeDbIdentifier(name, 'database name') } -// Scopes a derived database or DB user name to its NODE_PREFIX stack: '' for the default -// prefix, else '' plus sep with characters outside [A-Za-z0-9] folded to '_' -// (a SQL identifier cannot carry hyphens or dots). +// MariaDB caps a database name at 64 characters and the indexer's mirror database adds +// '_HubMirror'. The longest unscoped name is 'XChain_DOGE_Testnet_Indexer' (27), so the +// scope segment (separator included) is capped to keep name plus mirror suffix inside 64. +const DB_NAME_MAX = 64 +const HUB_MIRROR_SUFFIX = '_HubMirror' +const SCOPE_SEGMENT_MAX = 24 + +// Folds a NODE_PREFIX to its readable SQL-identifier segment: '' for the default prefix, else +// the prefix with characters outside [A-Za-z0-9] folded to '_' plus sep (a SQL identifier +// cannot carry hyphens or dots). Lossy and unbounded; derived names use scopedNodePrefixSegment. function nodePrefixSegment(nodePrefix, defaultPrefix, sep = '_') { if (nodePrefix === defaultPrefix) return '' return String(nodePrefix).replace(/[^A-Za-z0-9]/g, '_') + sep } -module.exports = { DATABASE_NAME_KEY, configuredDatabaseName, nodePrefixSegment } +// The segment derived database and DB user names carry. Folding is lossy ('a-b' and 'a_b' +// collide) and long prefixes must be cut, so either case swaps the tail for a digest of the +// RAW prefix: distinct prefixes keep distinct names inside the length budget. +function scopedNodePrefixSegment(nodePrefix, defaultPrefix, sep = '_') { + if (nodePrefix === defaultPrefix) return '' + const raw = String(nodePrefix) + const readable = nodePrefixSegment(raw, defaultPrefix, sep) + if (readable.slice(0, -sep.length) === raw && readable.length <= SCOPE_SEGMENT_MAX) return readable + const digest = require('crypto').createHash('sha256').update(raw).digest('hex').substring(0, 8) + const head = readable.substring(0, SCOPE_SEGMENT_MAX - sep.length * 2 - digest.length) + return head + sep + digest + sep +} + +// The unprefixed default name a pre-scoping install (or the default stack) uses. +function unscopedDefaultDatabaseName(module, ticker, network, sep = '_') { + const cap = (str) => str.charAt(0).toUpperCase() + str.slice(1) + return ['XChain', ticker, cap(network), cap(module.slice('xchain-'.length))].join(sep) +} + +module.exports = { + DATABASE_NAME_KEY, DB_NAME_MAX, HUB_MIRROR_SUFFIX, configuredDatabaseName, nodePrefixSegment, + scopedNodePrefixSegment, unscopedDefaultDatabaseName +} diff --git a/test/unit/module_database_name_prefix_scope.test.js b/test/unit/module_database_name_prefix_scope.test.js index 9bc14652..b1620510 100644 --- a/test/unit/module_database_name_prefix_scope.test.js +++ b/test/unit/module_database_name_prefix_scope.test.js @@ -20,6 +20,28 @@ const SRC = path.resolve(__dirname, '../../src') + path.sep // later suites keep their default-prefix singletons. function isSrc(id) { return id.startsWith(SRC) } +const { scopedNodePrefixSegment, DB_NAME_MAX, HUB_MIRROR_SUFFIX, unscopedDefaultDatabaseName } = require('../../src/utils/module_database_name') +const seg = scopedNodePrefixSegment('stack-b.test', 'xchain-node', '_') +const scopedDecoder = 'XChain_' + seg + 'BTC_Mainnet_Decoder' +const scopedDecoderUser = 'xchain_' + seg + 'decoder_bitcoin_mainnet' +const scopedIndexerUser = 'xchain_' + seg + 'indexer_bitcoin_mainnet' + +describe('prefix segment budget', function () { + it('is empty for the default prefix', function () { + expect(scopedNodePrefixSegment('xchain-node', 'xchain-node')).to.equal('') + }) + it('keeps prefixes that fold to the same text distinct', function () { + expect(scopedNodePrefixSegment('a-b', 'x')).to.not.equal(scopedNodePrefixSegment('a_b', 'x')) + expect(scopedNodePrefixSegment('stack-b.test', 'x')).to.not.equal(scopedNodePrefixSegment('stack_b_test', 'x')) + }) + it('keeps the longest indexer name plus _HubMirror within 64 characters', function () { + const long = 'a-very-long-node-prefix-that-overflows-every-budget' + const name = unscopedDefaultDatabaseName('xchain-indexer', 'DOGE', 'testnet').replace('XChain_', 'XChain_' + scopedNodePrefixSegment(long, 'x')) + expect(name.length + HUB_MIRROR_SUFFIX.length).to.be.at.most(DB_NAME_MAX) + expect(scopedNodePrefixSegment(long, 'x')).to.not.equal(scopedNodePrefixSegment(long + '2', 'x')) + }) +}) + describe('module database names under a non-default NODE_PREFIX', function () { let savedCache, savedPrefix, prefixed @@ -44,15 +66,23 @@ describe('module database names under a non-default NODE_PREFIX', function () { it('carries the sanitized prefix in the derived database name', function () { expect(prefixed.getModuleDatabaseName('xchain-decoder', 'bitcoin', 'mainnet')) - .to.equal('XChain_stack_b_test_BTC_Mainnet_Decoder') + .to.equal(scopedDecoder) }) it('carries the prefix in the module DB user names', async function () { const decoder = await prefixed.getDefaultConfig('xchain-decoder', 'bitcoin', 'mainnet') const indexer = await prefixed.getDefaultConfig('xchain-indexer', 'bitcoin', 'mainnet') - expect(decoder.DECODER_DB_USER).to.equal('xchain_stack_b_test_decoder_bitcoin_mainnet') - expect(indexer.INDEXER_DB_USER).to.equal('xchain_stack_b_test_indexer_bitcoin_mainnet') - expect(decoder.DECODER_DB_NAME).to.equal('XChain_stack_b_test_BTC_Mainnet_Decoder') + expect(decoder.DECODER_DB_USER).to.equal(scopedDecoderUser) + expect(indexer.INDEXER_DB_USER).to.equal(scopedIndexerUser) + expect(decoder.DECODER_DB_NAME).to.equal(scopedDecoder) + }) + + it('lets a configured name win over the scoped default in reset targets', async function () { + const { configuredDatabaseName } = require('../../src/utils/module_database_name') + const cs = prefixed + const def = cs.getModuleDatabaseName('xchain-decoder', 'bitcoin', 'mainnet') + expect(configuredDatabaseName('xchain-decoder', { DECODER_DB_NAME: 'Custom_Dec' }, def)).to.equal('Custom_Dec') + expect(configuredDatabaseName('xchain-decoder', {}, def)).to.equal(def) }) it('restores the default-prefix modules after the suite', function () { From 6eef5c3c631d3b920b16a72c4e83da426063dc1d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 14 Oct 2026 08:12:37 +0000 Subject: [PATCH 32/50] Prove NODE_PREFIX database scope end to end in the unit suite Export resolveResetTargets and resolveCheckedDatabaseName so the suite can drive them directly. Add tests that the legacy-collision refusal in buildAndUp fires first for decoder and indexer, that reset targets and the precondition precheck use the configured name, and that every raw getModuleDatabaseName caller routes through configuredDatabaseName. --- .../database_service/reset_databases.js | 2 +- .../migration_precondition_service.js | 3 +- .../module_database_name_prefix_scope.test.js | 111 +++++++++++++++++- 3 files changed, 113 insertions(+), 3 deletions(-) diff --git a/src/services/database_service/reset_databases.js b/src/services/database_service/reset_databases.js index e3a52934..bab213f3 100644 --- a/src/services/database_service/reset_databases.js +++ b/src/services/database_service/reset_databases.js @@ -273,4 +273,4 @@ function warnPriceFenceNotCleared(ticker, fenceNetwork, reason) { + ticker + " in place.") } -module.exports = { resetDatabases, clearHubPriceIngestWatermark, configureDependencies } +module.exports = { resetDatabases, clearHubPriceIngestWatermark, configureDependencies, resolveResetTargets } diff --git a/src/services/migration_precondition_service.js b/src/services/migration_precondition_service.js index 8f96e29b..1bea413e 100644 --- a/src/services/migration_precondition_service.js +++ b/src/services/migration_precondition_service.js @@ -262,5 +262,6 @@ module.exports = { // database returning `unreadable` rather than throwing past the guard, and // that is a property of the real driver call, not of a stub. readAppliedMigrations, - assertRequiredMigrationsApplied + assertRequiredMigrationsApplied, + resolveCheckedDatabaseName } diff --git a/test/unit/module_database_name_prefix_scope.test.js b/test/unit/module_database_name_prefix_scope.test.js index b1620510..a97e308c 100644 --- a/test/unit/module_database_name_prefix_scope.test.js +++ b/test/unit/module_database_name_prefix_scope.test.js @@ -43,7 +43,10 @@ describe('prefix segment budget', function () { }) describe('module database names under a non-default NODE_PREFIX', function () { - let savedCache, savedPrefix, prefixed + const EXTERNAL_ENV = { XCHAIN_NODE_EXTERNAL_DB: '1', XCHAIN_NODE_EXTERNAL_DB_HOST: '127.0.0.1', + XCHAIN_NODE_EXTERNAL_DB_PORT: '3306', XCHAIN_NODE_EXTERNAL_DB_ROOT_USER: 'root', + XCHAIN_NODE_EXTERNAL_DB_ROOT_PASSWORD: 'unit-test-only' } + let savedCache, savedPrefix, savedExternal, prefixed before(function () { savedPrefix = process.env.NODE_PREFIX @@ -51,13 +54,20 @@ describe('module database names under a non-default NODE_PREFIX', function () { for (const id of Object.keys(require.cache)) { if (isSrc(id)) { savedCache[id] = require.cache[id]; delete require.cache[id] } } + savedExternal = {} + for (const k of Object.keys(EXTERNAL_ENV)) savedExternal[k] = process.env[k] process.env.NODE_PREFIX = 'stack-b.test' + Object.assign(process.env, EXTERNAL_ENV) prefixed = require('../../src/services/config_service') }) after(function () { if (savedPrefix === undefined) delete process.env.NODE_PREFIX else process.env.NODE_PREFIX = savedPrefix + for (const [k, v] of Object.entries(savedExternal)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } for (const id of Object.keys(require.cache)) { if (isSrc(id)) delete require.cache[id] } @@ -85,6 +95,80 @@ describe('module database names under a non-default NODE_PREFIX', function () { expect(configuredDatabaseName('xchain-decoder', {}, def)).to.equal(def) }) + it('resolves reset targets through the configured name, not the raw derived name', async function () { + const { resolveResetTargets } = require('../../src/services/database_service/reset_databases') + const targets = await resolveResetTargets('bitcoin', 'mainnet', ['xchain-decoder', 'xchain-indexer']) + expect(targets).to.deep.equal([scopedDecoder, 'XChain_' + seg + 'BTC_Mainnet_Indexer']) + expect(targets[0]).to.not.equal('XChain_BTC_Mainnet_Decoder') + }) + + describe('legacy-collision refusal in buildAndUp', function () { + const legacy = { 'xchain-decoder': ['DECODER_DB_NAME', 'XChain_BTC_Mainnet_Decoder'], + 'xchain-indexer': ['INDEXER_DB_NAME', 'XChain_BTC_Mainnet_Indexer'] } + const reached = new Error('past the refusal') + + function wire(envFor) { + const { configureDependencies, buildAndUp } = require('../../src/services/module_service/build_and_up') + configureDependencies({ + checkIfModuleExists: () => true, checkBuildKitAvailable: async () => {}, + getDefaultConfig: async (m) => envFor(m), getModuleDir: () => '/nonexistent', + getDockerContainerImageName: (m) => m, config: require('../../src/config'), + execFileAsync: async () => ({ stdout: '' }), + // First step after the refusal in assertDeploymentReady; proves ordering. + goLiveGate: { assertGoLiveReady: () => { throw reached } } + }) + return buildAndUp + } + + for (const module of Object.keys(legacy)) { + it('refuses the unprefixed default name for ' + module + ' before any other deploy step', async function () { + const [key, name] = legacy[module] + const buildAndUp = wire(() => ({ [key]: name })) + let err + try { await buildAndUp(module, 'bitcoin', 'mainnet') } catch (e) { err = e } + expect(err).to.be.instanceOf(Error) + expect(err).to.not.equal(reached) + expect(err.message).to.include(key + '=' + name) + }) + it('lets the scoped name for ' + module + ' through to the next deploy step', async function () { + const [key] = legacy[module] + const cfg = (await prefixed.getDefaultConfig(module, 'bitcoin', 'mainnet')) + const buildAndUp = wire(() => ({ [key]: cfg[key] })) + let err + try { await buildAndUp(module, 'bitcoin', 'mainnet') } catch (e) { err = e } + expect(err).to.equal(reached) + }) + } + + it('does not refuse other modules', async function () { + const buildAndUp = wire(() => ({})) + let err + try { await buildAndUp('xchain-encoder', 'bitcoin', 'mainnet') } catch (e) { err = e } + expect(err).to.equal(reached) + }) + }) + + describe('precheck for default names under a non-default prefix', function () { + it('warns when the configured name is the unprefixed default', async function () { + const { getLogger } = require('../../src/observability/logger') + const logger = getLogger() + const original = logger.warn + const warnings = [] + logger.warn = (m) => { warnings.push(String(m)) } + try { + const { resolveCheckedDatabaseName } = require('../../src/services/migration_precondition_service') + const name = await resolveCheckedDatabaseName('xchain-decoder', 'bitcoin', 'mainnet', + { getDefaultConfig: async () => ({ DECODER_DB_NAME: 'XChain_BTC_Mainnet_Decoder' }) }) + expect(name).to.equal('XChain_BTC_Mainnet_Decoder') + expect(warnings.some(w => w.includes('unprefixed default database'))).to.equal(true) + warnings.length = 0 + await resolveCheckedDatabaseName('xchain-decoder', 'bitcoin', 'mainnet', + { getDefaultConfig: async () => ({ DECODER_DB_NAME: scopedDecoder }) }) + expect(warnings.some(w => w.includes('unprefixed default database'))).to.equal(false) + } finally { logger.warn = original } + }) + }) + it('restores the default-prefix modules after the suite', function () { // Runs while the prefixed load is still cached; the real check is that after() // reinstates the snapshot, asserted by the sibling describe below. @@ -100,3 +184,28 @@ describe('module database names after the prefix suite', function () { expect(cfg.DECODER_DB_USER).to.equal('xchain_decoder_bitcoin_mainnet') }) }) + +describe('raw getModuleDatabaseName callers', function () { + const fs = require('fs') + const files = [] + ;(function walk(dir) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, e.name) + if (e.isDirectory()) walk(full) + else if (e.name.endsWith('.js')) files.push(full) + } + })(SRC) + const allowed = new Set(['services/config_service.js', 'services/config_service/defaults.js']) + + it('resolve the name through configuredDatabaseName wherever they call it', function () { + const offenders = [] + for (const file of files) { + const rel = path.relative(SRC, file) + if (allowed.has(rel)) continue + const text = fs.readFileSync(file, 'utf8') + const calls = text.split('\n').filter(l => /getModuleDatabaseName\(/.test(l) && !/^\s*(\/\/|\*)/.test(l)) + if (calls.length > 0 && !/configuredDatabaseName\(/.test(text)) offenders.push(rel) + } + expect(offenders).to.deep.equal([]) + }) +}) From 97cf388b177c2116d2e531b0a039667e464c2fe9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 14 Oct 2026 01:17:32 -0700 Subject: [PATCH 33/50] Hoist the crypto require and split the NODE_PREFIX scope suite into named steps Brings the prefix-scoped database name change inside the code-structure baseline: require() at the top of module_database_name.js, and the prefixed-load hooks and nested suites as named functions so no test function runs past 60 lines. --- src/utils/module_database_name.js | 3 +- .../module_database_name_prefix_scope.test.js | 207 +++++++++--------- 2 files changed, 111 insertions(+), 99 deletions(-) diff --git a/src/utils/module_database_name.js b/src/utils/module_database_name.js index 34d71442..8682aeb1 100644 --- a/src/utils/module_database_name.js +++ b/src/utils/module_database_name.js @@ -16,6 +16,7 @@ 'use strict' +const crypto = require('crypto') const { XChainService } = require('../config/module_names') const { assertSafeDbIdentifier } = require('./sql_safety') @@ -58,7 +59,7 @@ function scopedNodePrefixSegment(nodePrefix, defaultPrefix, sep = '_') { const raw = String(nodePrefix) const readable = nodePrefixSegment(raw, defaultPrefix, sep) if (readable.slice(0, -sep.length) === raw && readable.length <= SCOPE_SEGMENT_MAX) return readable - const digest = require('crypto').createHash('sha256').update(raw).digest('hex').substring(0, 8) + const digest = crypto.createHash('sha256').update(raw).digest('hex').substring(0, 8) const head = readable.substring(0, SCOPE_SEGMENT_MAX - sep.length * 2 - digest.length) return head + sep + digest + sep } diff --git a/test/unit/module_database_name_prefix_scope.test.js b/test/unit/module_database_name_prefix_scope.test.js index a97e308c..7a49cbe6 100644 --- a/test/unit/module_database_name_prefix_scope.test.js +++ b/test/unit/module_database_name_prefix_scope.test.js @@ -42,46 +42,120 @@ describe('prefix segment budget', function () { }) }) -describe('module database names under a non-default NODE_PREFIX', function () { - const EXTERNAL_ENV = { XCHAIN_NODE_EXTERNAL_DB: '1', XCHAIN_NODE_EXTERNAL_DB_HOST: '127.0.0.1', - XCHAIN_NODE_EXTERNAL_DB_PORT: '3306', XCHAIN_NODE_EXTERNAL_DB_ROOT_USER: 'root', - XCHAIN_NODE_EXTERNAL_DB_ROOT_PASSWORD: 'unit-test-only' } - let savedCache, savedPrefix, savedExternal, prefixed - - before(function () { - savedPrefix = process.env.NODE_PREFIX - savedCache = {} - for (const id of Object.keys(require.cache)) { - if (isSrc(id)) { savedCache[id] = require.cache[id]; delete require.cache[id] } - } - savedExternal = {} - for (const k of Object.keys(EXTERNAL_ENV)) savedExternal[k] = process.env[k] - process.env.NODE_PREFIX = 'stack-b.test' - Object.assign(process.env, EXTERNAL_ENV) - prefixed = require('../../src/services/config_service') - }) +const EXTERNAL_ENV = { XCHAIN_NODE_EXTERNAL_DB: '1', XCHAIN_NODE_EXTERNAL_DB_HOST: '127.0.0.1', + XCHAIN_NODE_EXTERNAL_DB_PORT: '3306', XCHAIN_NODE_EXTERNAL_DB_ROOT_USER: 'root', + XCHAIN_NODE_EXTERNAL_DB_ROOT_PASSWORD: 'unit-test-only' } +const prefixState = {} + +// Reload every src module under NODE_PREFIX=stack-b.test in external-DB mode, saving what it replaces. +function loadPrefixedModules() { + prefixState.savedPrefix = process.env.NODE_PREFIX + prefixState.savedCache = {} + for (const id of Object.keys(require.cache)) { + if (isSrc(id)) { prefixState.savedCache[id] = require.cache[id]; delete require.cache[id] } + } + prefixState.savedExternal = {} + for (const k of Object.keys(EXTERNAL_ENV)) prefixState.savedExternal[k] = process.env[k] + process.env.NODE_PREFIX = 'stack-b.test' + Object.assign(process.env, EXTERNAL_ENV) + prefixState.prefixed = require('../../src/services/config_service') +} + +// Put the env and the default-prefix module cache back. +function restoreDefaultModules() { + if (prefixState.savedPrefix === undefined) delete process.env.NODE_PREFIX + else process.env.NODE_PREFIX = prefixState.savedPrefix + for (const [k, v] of Object.entries(prefixState.savedExternal)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + for (const id of Object.keys(require.cache)) { + if (isSrc(id)) delete require.cache[id] + } + Object.assign(require.cache, prefixState.savedCache) +} + +// The deploy path refuses a scoped stack's unprefixed default names before any other step. +function legacyCollisionSuite(getPrefixed) { + const legacy = { 'xchain-decoder': ['DECODER_DB_NAME', 'XChain_BTC_Mainnet_Decoder'], + 'xchain-indexer': ['INDEXER_DB_NAME', 'XChain_BTC_Mainnet_Indexer'] } + const reached = new Error('past the refusal') + + function wire(envFor) { + const { configureDependencies, buildAndUp } = require('../../src/services/module_service/build_and_up') + configureDependencies({ + checkIfModuleExists: () => true, checkBuildKitAvailable: async () => {}, + getDefaultConfig: async (m) => envFor(m), getModuleDir: () => '/nonexistent', + getDockerContainerImageName: (m) => m, config: require('../../src/config'), + execFileAsync: async () => ({ stdout: '' }), + // First step after the refusal in assertDeploymentReady; proves ordering. + goLiveGate: { assertGoLiveReady: () => { throw reached } } + }) + return buildAndUp + } - after(function () { - if (savedPrefix === undefined) delete process.env.NODE_PREFIX - else process.env.NODE_PREFIX = savedPrefix - for (const [k, v] of Object.entries(savedExternal)) { - if (v === undefined) delete process.env[k] - else process.env[k] = v - } - for (const id of Object.keys(require.cache)) { - if (isSrc(id)) delete require.cache[id] - } - Object.assign(require.cache, savedCache) + for (const module of Object.keys(legacy)) { + it('refuses the unprefixed default name for ' + module + ' before any other deploy step', async function () { + const [key, name] = legacy[module] + const buildAndUp = wire(() => ({ [key]: name })) + let err + try { await buildAndUp(module, 'bitcoin', 'mainnet') } catch (e) { err = e } + expect(err).to.be.instanceOf(Error) + expect(err).to.not.equal(reached) + expect(err.message).to.include(key + '=' + name) + }) + it('lets the scoped name for ' + module + ' through to the next deploy step', async function () { + const [key] = legacy[module] + const cfg = (await getPrefixed().getDefaultConfig(module, 'bitcoin', 'mainnet')) + const buildAndUp = wire(() => ({ [key]: cfg[key] })) + let err + try { await buildAndUp(module, 'bitcoin', 'mainnet') } catch (e) { err = e } + expect(err).to.equal(reached) + }) + } + + it('does not refuse other modules', async function () { + const buildAndUp = wire(() => ({})) + let err + try { await buildAndUp('xchain-encoder', 'bitcoin', 'mainnet') } catch (e) { err = e } + expect(err).to.equal(reached) + }) +} + +// The migration precheck warns on an unprefixed default name under a non-default prefix. +function defaultNamePrecheckSuite() { + it('warns when the configured name is the unprefixed default', async function () { + const { getLogger } = require('../../src/observability/logger') + const logger = getLogger() + const original = logger.warn + const warnings = [] + logger.warn = (m) => { warnings.push(String(m)) } + try { + const { resolveCheckedDatabaseName } = require('../../src/services/migration_precondition_service') + const name = await resolveCheckedDatabaseName('xchain-decoder', 'bitcoin', 'mainnet', + { getDefaultConfig: async () => ({ DECODER_DB_NAME: 'XChain_BTC_Mainnet_Decoder' }) }) + expect(name).to.equal('XChain_BTC_Mainnet_Decoder') + expect(warnings.some(w => w.includes('unprefixed default database'))).to.equal(true) + warnings.length = 0 + await resolveCheckedDatabaseName('xchain-decoder', 'bitcoin', 'mainnet', + { getDefaultConfig: async () => ({ DECODER_DB_NAME: scopedDecoder }) }) + expect(warnings.some(w => w.includes('unprefixed default database'))).to.equal(false) + } finally { logger.warn = original } }) +} + +describe('module database names under a non-default NODE_PREFIX', function () { + before(loadPrefixedModules) + after(restoreDefaultModules) it('carries the sanitized prefix in the derived database name', function () { - expect(prefixed.getModuleDatabaseName('xchain-decoder', 'bitcoin', 'mainnet')) + expect(prefixState.prefixed.getModuleDatabaseName('xchain-decoder', 'bitcoin', 'mainnet')) .to.equal(scopedDecoder) }) it('carries the prefix in the module DB user names', async function () { - const decoder = await prefixed.getDefaultConfig('xchain-decoder', 'bitcoin', 'mainnet') - const indexer = await prefixed.getDefaultConfig('xchain-indexer', 'bitcoin', 'mainnet') + const decoder = await prefixState.prefixed.getDefaultConfig('xchain-decoder', 'bitcoin', 'mainnet') + const indexer = await prefixState.prefixed.getDefaultConfig('xchain-indexer', 'bitcoin', 'mainnet') expect(decoder.DECODER_DB_USER).to.equal(scopedDecoderUser) expect(indexer.INDEXER_DB_USER).to.equal(scopedIndexerUser) expect(decoder.DECODER_DB_NAME).to.equal(scopedDecoder) @@ -89,7 +163,7 @@ describe('module database names under a non-default NODE_PREFIX', function () { it('lets a configured name win over the scoped default in reset targets', async function () { const { configuredDatabaseName } = require('../../src/utils/module_database_name') - const cs = prefixed + const cs = prefixState.prefixed const def = cs.getModuleDatabaseName('xchain-decoder', 'bitcoin', 'mainnet') expect(configuredDatabaseName('xchain-decoder', { DECODER_DB_NAME: 'Custom_Dec' }, def)).to.equal('Custom_Dec') expect(configuredDatabaseName('xchain-decoder', {}, def)).to.equal(def) @@ -102,72 +176,9 @@ describe('module database names under a non-default NODE_PREFIX', function () { expect(targets[0]).to.not.equal('XChain_BTC_Mainnet_Decoder') }) - describe('legacy-collision refusal in buildAndUp', function () { - const legacy = { 'xchain-decoder': ['DECODER_DB_NAME', 'XChain_BTC_Mainnet_Decoder'], - 'xchain-indexer': ['INDEXER_DB_NAME', 'XChain_BTC_Mainnet_Indexer'] } - const reached = new Error('past the refusal') - - function wire(envFor) { - const { configureDependencies, buildAndUp } = require('../../src/services/module_service/build_and_up') - configureDependencies({ - checkIfModuleExists: () => true, checkBuildKitAvailable: async () => {}, - getDefaultConfig: async (m) => envFor(m), getModuleDir: () => '/nonexistent', - getDockerContainerImageName: (m) => m, config: require('../../src/config'), - execFileAsync: async () => ({ stdout: '' }), - // First step after the refusal in assertDeploymentReady; proves ordering. - goLiveGate: { assertGoLiveReady: () => { throw reached } } - }) - return buildAndUp - } - - for (const module of Object.keys(legacy)) { - it('refuses the unprefixed default name for ' + module + ' before any other deploy step', async function () { - const [key, name] = legacy[module] - const buildAndUp = wire(() => ({ [key]: name })) - let err - try { await buildAndUp(module, 'bitcoin', 'mainnet') } catch (e) { err = e } - expect(err).to.be.instanceOf(Error) - expect(err).to.not.equal(reached) - expect(err.message).to.include(key + '=' + name) - }) - it('lets the scoped name for ' + module + ' through to the next deploy step', async function () { - const [key] = legacy[module] - const cfg = (await prefixed.getDefaultConfig(module, 'bitcoin', 'mainnet')) - const buildAndUp = wire(() => ({ [key]: cfg[key] })) - let err - try { await buildAndUp(module, 'bitcoin', 'mainnet') } catch (e) { err = e } - expect(err).to.equal(reached) - }) - } - - it('does not refuse other modules', async function () { - const buildAndUp = wire(() => ({})) - let err - try { await buildAndUp('xchain-encoder', 'bitcoin', 'mainnet') } catch (e) { err = e } - expect(err).to.equal(reached) - }) - }) + describe('legacy-collision refusal in buildAndUp', () => legacyCollisionSuite(() => prefixState.prefixed)) - describe('precheck for default names under a non-default prefix', function () { - it('warns when the configured name is the unprefixed default', async function () { - const { getLogger } = require('../../src/observability/logger') - const logger = getLogger() - const original = logger.warn - const warnings = [] - logger.warn = (m) => { warnings.push(String(m)) } - try { - const { resolveCheckedDatabaseName } = require('../../src/services/migration_precondition_service') - const name = await resolveCheckedDatabaseName('xchain-decoder', 'bitcoin', 'mainnet', - { getDefaultConfig: async () => ({ DECODER_DB_NAME: 'XChain_BTC_Mainnet_Decoder' }) }) - expect(name).to.equal('XChain_BTC_Mainnet_Decoder') - expect(warnings.some(w => w.includes('unprefixed default database'))).to.equal(true) - warnings.length = 0 - await resolveCheckedDatabaseName('xchain-decoder', 'bitcoin', 'mainnet', - { getDefaultConfig: async () => ({ DECODER_DB_NAME: scopedDecoder }) }) - expect(warnings.some(w => w.includes('unprefixed default database'))).to.equal(false) - } finally { logger.warn = original } - }) - }) + describe('precheck for default names under a non-default prefix', defaultNamePrecheckSuite) it('restores the default-prefix modules after the suite', function () { // Runs while the prefixed load is still cached; the real check is that after() From 801b82b1e22b41a048feeda8ff3cd6f03ddf043e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 14 Oct 2026 01:18:19 -0700 Subject: [PATCH 34/50] Show NOT READY in ps from the indexer and decoder readiness verdicts A service can be alive and pass its healthcheck while not serving the chain: an indexer reporting ready false on /status, an indexer holding a hub barrier past its ceiling (which the stall reducer still calls a healthy defer), or a decoder reporting ready false on /live. ps now reads those fields, appends NOT READY to the row, records not_ready with its reasons, and prints a note naming them. An older image without the fields, or a route that cannot be read, keeps the plain running row. --- src/services/bootstrap_health_gate.js | 14 ++ src/services/status_service.js | 9 +- src/services/status_service/get_status.js | 21 ++- .../status_service/status_reducers.js | 44 ++++++ .../get_status_not_ready_column.test.js | 127 ++++++++++++++++++ 5 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 test/unit/status_service.test/get_status_not_ready_column.test.js diff --git a/src/services/bootstrap_health_gate.js b/src/services/bootstrap_health_gate.js index 28bb9239..e101d468 100644 --- a/src/services/bootstrap_health_gate.js +++ b/src/services/bootstrap_health_gate.js @@ -159,6 +159,19 @@ async function probeServiceStatus(containerId, port, runner) { throw lastErr || new Error('no status probe succeeded') } +// One GET on a service's own readiness surface (the indexer's /status, the decoder's /live), +// parsed, or null when the route cannot be read. A non-2xx answer (wget exits non-zero and +// prints no body) reads as null on purpose: the container healthcheck already shows that fault. +async function probeReadinessSurface(containerId, port, route, runner) { + try { + const { stdout } = await runner('docker', ['exec', containerId, 'wget', '-qO-', `http://localhost:${port}${route}`]) + const parsed = JSON.parse(String(stdout || '').trim()) + return (parsed && typeof parsed === 'object') ? parsed : null + } catch (_) { + return null + } +} + // Refuse unless `module` on coin/network is a known-good bootstrap source. // Resolves silently when the source passes; throws BootstrapSourceUnhealthyError // (with every reason, not just the first) when it does not. @@ -356,5 +369,6 @@ module.exports = { // StatusService reads a decoder's health surface through the same probe so // `xchain-node ps` can show a latent REORG_HALT. probeServiceStatus, + probeReadinessSurface, MODULE_API_PORT_KEY } diff --git a/src/services/status_service.js b/src/services/status_service.js index 90e86a67..bec716f5 100644 --- a/src/services/status_service.js +++ b/src/services/status_service.js @@ -43,7 +43,7 @@ const { configureDependencies: configureInstalledModules } = require('./status_service/installed_modules.js') const { - isContainerGoneError, probeServiceHealthPayload, + isContainerGoneError, probeServiceHealthPayload, probeReadinessPayload, reduceNotReady, describeNotReadyNote, reduceNodeCatchingUp, describeNodeCatchingUpNote, reduceNodeUnreachable, describeNodeUnreachableNote, describeDuration, reduceDecoderReorgHalt, describeReorgHaltNote, @@ -62,7 +62,8 @@ configureGetStatus({ getLocalNodeVersion, getContainerNodeVersion, getLocalModuleVersion, getContainerModuleVersion, peers, getLogger, logger, checkRemoteNodeVersionAdvisory, loadInstalledModules, isContainerGoneError, - probeServiceHealthPayload, reduceNodeCatchingUp, describeNodeCatchingUpNote, + probeServiceHealthPayload, probeReadinessPayload, reduceNotReady, describeNotReadyNote, + reduceNodeCatchingUp, describeNodeCatchingUpNote, reduceNodeUnreachable, describeNodeUnreachableNote, reduceDecoderReorgHalt, describeReorgHaltNote, reduceIndexerStall, describeIndexerStallNote, reduceTrackerHalt, describeTrackerHaltNote @@ -119,5 +120,7 @@ module.exports = { reduceIndexerStall, describeIndexerStallNote, reduceTrackerHalt, - describeTrackerHaltNote + describeTrackerHaltNote, + reduceNotReady, + describeNotReadyNote } diff --git a/src/services/status_service/get_status.js b/src/services/status_service/get_status.js index 2d24ca8f..d1980baa 100644 --- a/src/services/status_service/get_status.js +++ b/src/services/status_service/get_status.js @@ -30,7 +30,7 @@ let { getLogger } = require('../../observability/logger'); let logger = getLogger(); let { checkRemoteNodeVersionAdvisory, loadInstalledModules } = require('./installed_modules') let { - isContainerGoneError, probeServiceHealthPayload, + isContainerGoneError, probeServiceHealthPayload, probeReadinessPayload, reduceNotReady, describeNotReadyNote, reduceNodeCatchingUp, describeNodeCatchingUpNote, reduceNodeUnreachable, describeNodeUnreachableNote, reduceDecoderReorgHalt, describeReorgHaltNote, @@ -39,7 +39,7 @@ let { } = require('./status_reducers') function configureDependencies(dependencies) { - ;({ NODE_MODULE_NAME, SEP, XChainService, db, getInstalledModules, resetInstalledModules, getRemoteModuleVersions, isStatusUpdated, setStatusUpdated, getLastStatus, setLastStatus, getLastPrintedStatus, setLastPrintedStatus, getStatusFromContainer, getLocalNodeVersion, getContainerNodeVersion, getLocalModuleVersion, getContainerModuleVersion, peers, getLogger, logger, checkRemoteNodeVersionAdvisory, loadInstalledModules, isContainerGoneError, probeServiceHealthPayload, reduceNodeCatchingUp, describeNodeCatchingUpNote, reduceNodeUnreachable, describeNodeUnreachableNote, reduceDecoderReorgHalt, describeReorgHaltNote, reduceIndexerStall, describeIndexerStallNote, reduceTrackerHalt, describeTrackerHaltNote } = dependencies) + ;({ NODE_MODULE_NAME, SEP, XChainService, db, getInstalledModules, resetInstalledModules, getRemoteModuleVersions, isStatusUpdated, setStatusUpdated, getLastStatus, setLastStatus, getLastPrintedStatus, setLastPrintedStatus, getStatusFromContainer, getLocalNodeVersion, getContainerNodeVersion, getLocalModuleVersion, getContainerModuleVersion, peers, getLogger, logger, checkRemoteNodeVersionAdvisory, loadInstalledModules, isContainerGoneError, probeServiceHealthPayload, probeReadinessPayload, reduceNotReady, describeNotReadyNote, reduceNodeCatchingUp, describeNodeCatchingUpNote, reduceNodeUnreachable, describeNodeUnreachableNote, reduceDecoderReorgHalt, describeReorgHaltNote, reduceIndexerStall, describeIndexerStallNote, reduceTrackerHalt, describeTrackerHaltNote } = dependencies) } async function recordVersions(moduleStatus, nextModule, nextCoin, nextCoinNetwork, containerId, remoteModuleVersions) { @@ -178,6 +178,23 @@ async function annotatePublishedHealth(nextModule, containerId, nextCoin, nextCo notes.push(describe(nextCoin, nextCoinNetwork, finding)) } } + // The readiness verdict rides on the indexer's /status and the decoder's /live, which the + // JSON-RPC health read above does not carry. Advisory, like the rest: an unreadable route or + // an older image without the fields leaves the row as it was. Distinct word, same amber. + if (nextModule === XChainService.XCHAIN_INDEXER || nextModule === XChainService.XCHAIN_DECODER) { + let readiness = null + try { + readiness = typeof (payload && payload.ready) === 'boolean' + ? payload : await probeReadinessPayload(nextModule, containerId, nextCoin, nextCoinNetwork) + } catch { /* advisory: an unreadable route is not a finding */ } + const notReady = reduceNotReady(readiness) + if (notReady) { + display.state += " NOT READY" + display.isChurning = true + moduleStatus["not_ready"] = notReady + notes.push(describeNotReadyNote(nextCoin, nextCoinNetwork, nextModule, notReady)) + } + } const wait = reduceNodeCatchingUp(payload) if (wait) { display.state += " WAITING FOR NODE" diff --git a/src/services/status_service/status_reducers.js b/src/services/status_service/status_reducers.js index f32350a9..df739584 100644 --- a/src/services/status_service/status_reducers.js +++ b/src/services/status_service/status_reducers.js @@ -57,6 +57,23 @@ async function probeServiceHealthPayload(module, containerId, coin, network) { return probeServiceStatus(containerId, port, runner) } +// Which route carries each module's readiness verdict. The JSON-RPC health method the +// probe above reads first does not carry it. +const READINESS_ROUTE = { 'xchain-indexer': '/status', 'xchain-decoder': '/live' } + +// The readiness fields of an indexer's /status or a decoder's /live, or null when the +// route is unreadable or the image predates the verdict (an older image reads as ready). +async function probeReadinessPayload(module, containerId, coin, network) { + const route = READINESS_ROUTE[module] + const { probeReadinessSurface, MODULE_API_PORT_KEY } = peers.bootstrapHealthGate + if (!route || typeof probeReadinessSurface !== 'function') return null + const config = await configService.getDefaultConfig(module, coin, network) + const port = config && config[MODULE_API_PORT_KEY[module]] + if (!port) return null + const runner = (cmd, args) => nodeUtil.promisify(childProcess.execFile)(cmd, args, { timeout: 15000 }) + return probeReadinessSurface(containerId, port, route, runner) +} + async function probeDecoderReorgHalt(containerId, coin, network) { return reduceDecoderReorgHalt(await probeServiceHealthPayload(XChainService.XCHAIN_DECODER, containerId, coin, network)) } @@ -189,6 +206,30 @@ function reduceIndexerStall(payload) { } } +// The readiness verdict a service publishes: the indexer's `ready` / `notReadyReasons` +// (/status) and `barrierCeilingExceeded`, the decoder's `ready` / `not_ready_reasons` +// (/live). Strict on shape: only `ready === false` or `barrierCeilingExceeded === true` +// is a finding, so an older image that lacks the fields reads as ready, as today. A hub +// barrier held past its ceiling is NOT READY even though reduceIndexerStall calls a +// barrier_defer healthy: the block is not advancing and the hub is why. +function reduceNotReady(payload) { + if (!payload || typeof payload !== 'object') return null + const ceiling = payload.barrierCeilingExceeded === true + if (payload.ready !== false && !ceiling) return null + const listed = [payload.notReadyReasons, payload.not_ready_reasons].find(Array.isArray) || [] + const reasons = listed.filter(r => typeof r === 'string' && r.trim()).map(r => r.trim()) + if (ceiling && !reasons.includes('barrier_ceiling_exceeded')) reasons.push('barrier_ceiling_exceeded') + return { notReady: true, reasons } +} + +// The line `ps` prints under the table for a service that is up but not ready. +function describeNotReadyNote(coin, network, module, finding) { + return coin + "/" + network + " " + module + " is NOT READY" + + (finding.reasons.length ? " (" + finding.reasons.join(", ") + ")" : "") + + ": the process is alive and its healthcheck passes, but it is not serving the chain right now." + + " A restart does not clear this; `xchain-node logs " + module + " " + coin + " " + network + "` names the cause." +} + // The indexer stamps its last commit in epoch milliseconds; the note wants an // instant an operator can read. A string is passed through, anything else is null. function describeCommitInstant(value) { @@ -256,6 +297,9 @@ function describeTrackerHaltNote(coin, network, halt) { module.exports = { isContainerGoneError, probeServiceHealthPayload, + probeReadinessPayload, + reduceNotReady, + describeNotReadyNote, probeDecoderReorgHalt, reduceNodeCatchingUp, describeNodeCatchingUpNote, diff --git a/test/unit/status_service.test/get_status_not_ready_column.test.js b/test/unit/status_service.test/get_status_not_ready_column.test.js new file mode 100644 index 00000000..e749fd44 --- /dev/null +++ b/test/unit/status_service.test/get_status_not_ready_column.test.js @@ -0,0 +1,127 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// `ps` shows NOT READY for a service that is up and passing its healthcheck but reports +// ready false (indexer /status, decoder /live), or an indexer holding a hub barrier past +// its ceiling, which reduceIndexerStall still calls a healthy defer. An older image that +// carries none of the fields keeps the plain running row. + +const sinon = require('sinon') +const { configStub } = require('../../helpers/config_stub') +const { expect } = require('chai') +const proxyquire = require('proxyquire').noCallThru() + +function makeContainerStatus() { + return { State: { Status: 'running', Health: { Status: 'healthy' } }, NetworkSettings: { Ports: {} }, Config: { Labels: {} } } +} + +// `health` answers the JSON-RPC probe, `surfaces` the readiness route, both by container id. +function load(rows, health, surfaces) { + const installedModulesObj = {} + let printed = '' + const state = { + db: { isReady: sinon.stub().returns(true), getAllModuleContainers: sinon.stub().resolves(rows) }, + getInstalledModules: () => installedModulesObj, + setInstalledModules: sinon.stub(), + resetInstalledModules: () => { for (const k of Object.keys(installedModulesObj)) delete installedModulesObj[k] }, + getRemoteModuleVersions: () => ({}), + isStatusUpdated: () => false, + setStatusUpdated: sinon.stub(), + getLastStatus: () => null, + setLastStatus: sinon.stub(), + getLastPrintedStatus: () => printed, + setLastPrintedStatus: (s) => { printed = s }, + appendLastPrintedStatus: sinon.stub() + } + const routes = [] + const ss = proxyquire('../../../src/services/status_service', { + '../config': configStub({ NODE_MODULE_NAME: 'node', SEP: '-', + Coin: { BITCOIN: 'bitcoin', LITECOIN: 'litecoin' }, + Network: { MAINNET: 'mainnet', TESTNET: 'testnet', REGTEST: 'regtest' }, + XChainService: { XCHAIN_DECODER: 'xchain-decoder', XCHAIN_INDEXER: 'xchain-indexer', XCHAIN_UTXO_TRACKER: 'xchain-utxo-tracker' } }), + '../state': state, + './docker_service': { getStatusFromContainer: sinon.stub().resolves(makeContainerStatus()) }, + './version_service': { + checkRemoteNodeVersion: sinon.stub().resolves(), getLocalNodeVersion: sinon.stub().resolves('27.0'), + getContainerNodeVersion: sinon.stub().resolves('27.0'), getLocalModuleVersion: sinon.stub().resolves('1.0.0'), + getContainerModuleVersion: sinon.stub().resolves('1.0.0') + }, + './config_service': { getDefaultConfig: async () => ({ INDEXER_API_PORT: '3004', DECODER_API_PORT: '3002' }) }, + './peer_services': { bindPeerServices: () => ({ + bootstrapHealthGate: { + probeServiceStatus: async (id) => health[id], + probeReadinessSurface: async (id, port, route) => { routes.push(route); return surfaces[id] || null }, + MODULE_API_PORT_KEY: { 'xchain-decoder': 'DECODER_API_PORT', 'xchain-indexer': 'INDEXER_API_PORT' } }, + moduleService: { getModuleBranch: sinon.stub().resolves('master') } + }) } + }) + return { ss, routes, printed: () => printed } +} + +const IDX = { module: 'xchain-indexer', coin: 'bitcoin', network: 'mainnet', container_id: 'idx' } +const DEC = { module: 'xchain-decoder', coin: 'bitcoin', network: 'mainnet', container_id: 'dec' } + +describe('StatusService: getStatus() NOT READY column', function () { + + it('prints NOT READY and lists the reasons for an indexer whose /status says ready false', async function () { + const { ss, routes, printed } = load([IDX], { idx: { status: 'healthy', stallReason: null } }, + { idx: { ready: false, notReadyReasons: ['decoder_reorg_halted', 'behind_chain_tip'] } }) + const result = await ss.getStatus('bitcoin', 'mainnet', false) + expect(routes).to.deep.equal(['/status']) + expect(result.bitcoin.mainnet['xchain-indexer'].not_ready.reasons).to.deep.equal(['decoder_reorg_halted', 'behind_chain_tip']) + expect(printed()).to.match(/xchain-indexer.*running NOT READY/) + expect(printed()).to.match(/! bitcoin\/mainnet xchain-indexer is NOT READY \(decoder_reorg_halted, behind_chain_tip\)/) + }) + + it('prints NOT READY for an indexer past its barrier ceiling although the stall reducer calls it a healthy defer', async function () { + const { ss, printed } = load([IDX], + { idx: { status: 'healthy', stallReason: 'price_sync_barrier', stallClass: 'barrier_defer' } }, + { idx: { ready: true, barrierCeilingExceeded: true } }) + const result = await ss.getStatus('bitcoin', 'mainnet', false) + expect(result.bitcoin.mainnet['xchain-indexer'].stall).to.equal(undefined) + expect(result.bitcoin.mainnet['xchain-indexer'].not_ready.reasons).to.deep.equal(['barrier_ceiling_exceeded']) + expect(printed()).to.match(/xchain-indexer.*running NOT READY/) + expect(printed()).to.not.match(/STALL/) + }) + + it('prints NOT READY for a decoder whose /live body says ready false, reading /live', async function () { + const { ss, routes, printed } = load([DEC], { dec: { status: 'healthy', reorg_halted: true } }, + { dec: { status: 'healthy', ready: false, not_ready_reasons: ['reorg_halt'] } }) + const result = await ss.getStatus('bitcoin', 'mainnet', false) + expect(routes).to.deep.equal(['/live']) + expect(result.bitcoin.mainnet['xchain-decoder'].not_ready.reasons).to.deep.equal(['reorg_halt']) + expect(printed()).to.match(/xchain-decoder.*running REORG_HALT NOT READY/) + expect(printed()).to.match(/xchain-decoder is NOT READY \(reorg_halt\)/) + }) + + it('keeps today\'s plain running row when the fields are absent, ready is true or the route is unreadable', async function () { + const { ss, printed } = load([IDX, DEC, { ...IDX, coin: 'litecoin', container_id: 'ltc' }], + { idx: { status: 'healthy' }, dec: { status: 'healthy' }, ltc: { status: 'healthy' } }, + { idx: { indexerBlock: 5, stallClass: 'none' }, dec: { status: 'healthy', ready: true, not_ready_reasons: [] } }) + const result = await ss.getStatus(null, null, false) + expect(result.bitcoin.mainnet['xchain-indexer'].not_ready).to.equal(undefined) + expect(result.bitcoin.mainnet['xchain-decoder'].not_ready).to.equal(undefined) + expect(result.litecoin.mainnet['xchain-indexer'].not_ready).to.equal(undefined) + expect(printed()).to.not.match(/NOT READY/) + expect(printed()).to.not.match(/^!/m) + }) + + it('reduceNotReady reads only an explicit ready false or a true ceiling flag', function () { + const { ss } = load([], {}, {}) + expect(ss.reduceNotReady(null)).to.equal(null) + expect(ss.reduceNotReady({})).to.equal(null) + expect(ss.reduceNotReady({ ready: 'no' })).to.equal(null) + expect(ss.reduceNotReady({ ready: false })).to.deep.equal({ notReady: true, reasons: [] }) + expect(ss.reduceNotReady({ ready: false, barrierCeilingExceeded: true, notReadyReasons: ['x'] }).reasons) + .to.deep.equal(['x', 'barrier_ceiling_exceeded']) + }) +}) From cef003ae3fb3c1d9d0a8120093b582afbb50c6b7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 14 Oct 2026 10:17:50 -0700 Subject: [PATCH 35/50] Build and sign the DOGE signer's reveal before broadcasting its commit, and keep the signed reveal when only its broadcast fails --- bin/pins/at1-suite-titles.json | 22 +- src/services/validator_service/signer.js | 77 ++++--- ...itted_signer_post_funding_failures.test.js | 191 ++++++++++++++---- 3 files changed, 215 insertions(+), 75 deletions(-) diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index 6ca99b22..a76c0117 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -772,12 +772,6 @@ "validator stake free-key rules resolves the reuse gate COIN:network first, then the bare network", "validator stake free-key rules sums amounts exactly in 1e-8 units and refuses to guess on a malformed one" ], - "332b1a20f0f85580": [ - "ValidatorService coin wallets the emitted signer marks post-funding failures does not tag a successful two-phase publish", - "ValidatorService coin wallets the emitted signer marks post-funding failures is valid JavaScript once the template literal is expanded", - "ValidatorService coin wallets the emitted signer marks post-funding failures leaves a pre-funding failure untagged, so the round stays retryable", - "ValidatorService coin wallets the emitted signer marks post-funding failures tags a definitive phase-2 rejection with fundsCommitted and the phase-1 txid" - ], "332bea463a49f99c": [ "Regression Suite [regression:p0] Security R-SEC-001: branch name with shell metacharacters rejected", "Regression Suite [regression:p0] Security R-SEC-002: branch name with backticks and $() rejected", @@ -1490,6 +1484,18 @@ "makeBootstrap() consults the source health gate gates the utxo-tracker BEFORE stopping its container, so a refusal costs no downtime", "makeBootstrap() consults the source health gate still rejects an unsupported module before consulting the gate" ], + "55d86c81bb4903d3": [ + "ValidatorService coin wallets the emitted signer builds and signs the reveal before the commit broadcast broadcasts nothing when the encoder refuses the reveal", + "ValidatorService coin wallets the emitted signer builds and signs the reveal before the commit broadcast broadcasts nothing when the reveal cannot be signed", + "ValidatorService coin wallets the emitted signer builds and signs the reveal before the commit broadcast builds no reveal for a single-transaction encoding", + "ValidatorService coin wallets the emitted signer builds and signs the reveal before the commit broadcast builds the reveal from the signed commit, then broadcasts the commit and the reveal", + "ValidatorService coin wallets the emitted signer builds and signs the reveal before the commit broadcast hands back the signed reveal when its broadcast fails after the commit", + "ValidatorService coin wallets the emitted signer builds and signs the reveal before the commit broadcast leaves a commit broadcast failure untagged for the hub to classify", + "ValidatorService coin wallets the emitted signer marks post-funding failures does not tag a successful two-phase publish", + "ValidatorService coin wallets the emitted signer marks post-funding failures is valid JavaScript once the template literal is expanded", + "ValidatorService coin wallets the emitted signer marks post-funding failures marks a pre-funding failure neverSent and not fundsCommitted, so the round stays retryable", + "ValidatorService coin wallets the emitted signer marks post-funding failures tags a definitive phase-2 rejection with fundsCommitted, the phase-1 txid and the signed reveal" + ], "56897dc4174a8788": [ "Chaos: Download Resilience Experiment 8c: SHA-256 hash mismatch succeeds when hash matches exactly", "Chaos: Download Resilience Experiment 8c: SHA-256 hash mismatch throws with expected vs actual hash on mismatch" @@ -5112,7 +5118,7 @@ "test/unit/validator_service.test/11_validator_dir.test.js": "26f27dbd3b14ec37", "test/unit/validator_service.test/12_coin_wallets.test.js": "6d7114a33630b804", "test/unit/validator_service.test/13_coin_wallet_options_and_mounts.test.js": "3670f7eb60a0bd6e", - "test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js": "332b1a20f0f85580", + "test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js": "55d86c81bb4903d3", "test/unit/validator_service.test/15_hub_env_wiring.test.js": "1dc33a33679f1947", "test/unit/validator_service.test/16_rollcall_status_reporting.test.js": "cb99d238d302d2c2", "test/unit/validator_service.test/17_capability_drift_probe.test.js": "1ee619322512f5f5", @@ -5536,7 +5542,7 @@ "test/unit/validator_service.test/11_validator_dir.test.js": "26f27dbd3b14ec37", "test/unit/validator_service.test/12_coin_wallets.test.js": "6d7114a33630b804", "test/unit/validator_service.test/13_coin_wallet_options_and_mounts.test.js": "3670f7eb60a0bd6e", - "test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js": "332b1a20f0f85580", + "test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js": "55d86c81bb4903d3", "test/unit/validator_service.test/15_hub_env_wiring.test.js": "1dc33a33679f1947", "test/unit/validator_service.test/16_rollcall_status_reporting.test.js": "cb99d238d302d2c2", "test/unit/validator_service.test/17_capability_drift_probe.test.js": "1ee619322512f5f5", diff --git a/src/services/validator_service/signer.js b/src/services/validator_service/signer.js index 3855020d..5820f935 100644 --- a/src/services/validator_service/signer.js +++ b/src/services/validator_service/signer.js @@ -121,6 +121,15 @@ function requireEncoder() { return typeof sdk.requireEncoder === 'function' ? sdk.requireEncoder() : sdk['_requireEncoder'](); } +// Set the hub's classification fields on a failure and return it. The SAME object +// is mutated where there is one, because the hub classifier reads err.response and +// err.message off the original and a fresh wrapper would drop both. A thrown +// non-object gets a carrier instead. +function tagFailure(err, prefix, fields) { + const tagged = (err && typeof err === 'object') ? err : new Error(prefix + String(err)); + return Object.assign(tagged, fields); +} + module.exports = { // Full publish pipeline for a raw wire payload string -> { txid }. The // returned txid is the phase-2 (reveal) txid when two-phase encoding is @@ -131,44 +140,54 @@ module.exports = { const txParams = { data: payload, pubkey: signerEnv.address, change: signerEnv.address, encoding: 'P2SH' }; if (signerEnv.feePerKb !== undefined) txParams.feePerKb = signerEnv.feePerKb; - const encoded = await encoder.createTx(txParams); - const signed = sdk.wallet.signPsbt(encoded.psbt, signerEnv.wif); + // Both transactions are built and signed before either is broadcast. The + // signed phase 1 already has its final txid, so the reveal is built from it + // here, and a reveal the encoder refuses or this key cannot sign stops the + // publish while nothing is on chain. neverSent tells the hub this round + // spent nothing and may be rebuilt. + let signed, spendSigned = null, twoPhase; + try { + const encoded = await encoder.createTx(txParams); + signed = sdk.wallet.signPsbt(encoded.psbt, signerEnv.wif); + twoPhase = encoded.encoding === 'P2SH' || encoded.encoding === 'P2WSH'; + if (twoPhase) { + const spendParams = { + pubkey: signerEnv.address, + p2shHash: signed.txid, + p2shHex: signed.txHex, + data: payload, + encoding: encoded.encoding, + change: signerEnv.address + }; + if (signerEnv.feePerKb !== undefined) spendParams.feePerKb = signerEnv.feePerKb; + const spendResult = await encoder.spendP2sh(spendParams); + spendSigned = sdk.wallet.signRevealPsbt(spendResult.psbt, signerEnv.wif); + } + } catch (err) { + throw tagFailure(err, 'doge-signer: publish failed before any broadcast: ', { neverSent: true }); + } + await encoder.broadcastTx(signed.txHex); - if (encoded.encoding !== 'P2SH' && encoded.encoding !== 'P2WSH') - return { txid: signed.txid }; + if (!twoPhase) return { txid: signed.txid }; - // Phase 1 has funded the P2SH outputs on chain, so every failure below is a - // POST-SPEND failure and has to say so on the way out. The hub reads a + // Phase 1 has funded the P2SH outputs on chain, so a reveal broadcast failure + // is a POST-SPEND failure and has to say so on the way out. The hub reads a // definitive encoder rejection as safe to retry, and a retry re-enters this // function, runs createTx over fresh UTXOs and funds the same payload a second // time. fundsCommitted makes the hub fail closed instead; phase1Txid is what an - // operator reconciles the stranded funding transaction against. + // operator reconciles the funding transaction against, and revealTxHex is the + // signed reveal that spends it, ready to rebroadcast as it stands. try { - const spendParams = { - pubkey: signerEnv.address, - p2shHash: signed.txid, - p2shHex: signed.txHex, - data: payload, - encoding: encoded.encoding, - change: signerEnv.address - }; - if (signerEnv.feePerKb !== undefined) spendParams.feePerKb = signerEnv.feePerKb; - const spendResult = await encoder.spendP2sh(spendParams); - const spendSigned = sdk.wallet.signRevealPsbt(spendResult.psbt, signerEnv.wif); await encoder.broadcastTx(spendSigned.txHex); - - return { txid: spendSigned.txid, phase1_txid: signed.txid }; } catch (err) { - // Mutate and rethrow the SAME object where there is one: the classifier - // reads err.response and err.message off the original, and a fresh wrapper - // would drop both. A thrown non-object gets a carrier instead. - const tagged = (err && typeof err === 'object') - ? err - : new Error('doge-signer: phase 2 failed after funding: ' + String(err)); - tagged.fundsCommitted = true; - tagged.phase1Txid = signed.txid; - throw tagged; + throw tagFailure(err, 'doge-signer: reveal broadcast failed after funding: ', { + fundsCommitted: true, + phase1Txid: signed.txid, + revealTxid: spendSigned.txid, + revealTxHex: spendSigned.txHex + }); } + return { txid: spendSigned.txid, phase1_txid: signed.txid }; }, // Sign an encoder-built PSBT -> signed raw tx hex. The hub's built-in diff --git a/test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js b/test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js index 07c7a87b..ca05d0d3 100644 --- a/test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js +++ b/test/unit/validator_service.test/14_emitted_signer_post_funding_failures.test.js @@ -70,10 +70,12 @@ function makeFs(overrides = {}) { const vm = require('vm') const PHASE1 = 'f'.repeat(64) +const REVEAL = 'e'.repeat(64) // Compile the written template and hand it a stub SDK, so the two-phase -// pipeline can be exercised without a key, an encoder or a network. -function loadEmittedSigner(source, encoder) { +// pipeline can be exercised without a key, an encoder or a network. `wallet` +// replaces the stub signing methods. +function loadEmittedSigner(source, encoder, wallet) { const mod = { exports: {} } vm.runInNewContext(source, { require: (id) => { @@ -81,10 +83,10 @@ function loadEmittedSigner(source, encoder) { if (id === 'dotenv') return { config: () => ({}) } if (id === '@dankest-llc/xchain-sdk') return { XChainSDK: function () { this.requireEncoder = () => encoder - this.wallet = { + this.wallet = Object.assign({ signPsbt: () => ({ txHex: 'hex-1', txid: PHASE1 }), - signRevealPsbt: () => ({ txHex: 'hex-2', txid: 'e'.repeat(64) }) - } + signRevealPsbt: () => ({ txHex: 'hex-2', txid: REVEAL }) + }, wallet || {}) } } throw new Error('unexpected require in the emitted signer: ' + id) }, @@ -107,6 +109,40 @@ async function emitSigner() { return fs.writeFileSync.getCalls().find(c => c.args[0] === FAKE_SIGNER_FILE).args[1] } +// An encoder and wallet whose every call is recorded in order, with the +// broadcast that `failBroadcast` names (1 = the commit, 2 = the reveal) refused +// by the node. +function recordingSigner(source, { encoding = 'P2SH', failBroadcast = 0, encoder = {}, wallet = {} } = {}) { + const calls = [] + let broadcasts = 0 + const record = (name, fn) => async (...args) => { calls.push(name); return fn(...args) } + const signer = loadEmittedSigner(source, { + createTx: record('createTx', encoder.createTx || (async () => ({ psbt: 'psbt-1', encoding }))), + spendP2sh: record('spendP2sh', encoder.spendP2sh || (async () => ({ psbt: 'psbt-2' }))), + broadcastTx: async (hex) => { + calls.push('broadcast:' + hex) + broadcasts += 1 + if (broadcasts === failBroadcast) throw new Error('Encoder RPC error: min relay fee not met') + return { txid: broadcasts === 1 ? PHASE1 : REVEAL } + } + }, { + signPsbt: (...args) => { + calls.push('sign') + return (wallet.signPsbt || (() => ({ txHex: 'hex-1', txid: PHASE1 })))(...args) + }, + signRevealPsbt: (...args) => { + calls.push('sign-reveal') + return (wallet.signRevealPsbt || (() => ({ txHex: 'hex-2', txid: REVEAL })))(...args) + } + }) + return { signer, calls } +} + +async function rejection(promise) { + try { await promise } catch (e) { return e } + throw new Error('expected the publish to fail') +} + function stubEncoder(overrides) { return Object.assign({ createTx: async () => ({ psbt: 'psbt-1', encoding: 'P2SH' }), @@ -115,11 +151,111 @@ function stubEncoder(overrides) { }, overrides || {}) } +// Each case is a named step, so the suite below reads as the list of what the +// emitted signer guarantees. +const cases = { + async validJs() { + const source = await emitSigner() + expect(() => new vm.Script(source, { filename: 'signer.js' })).to.not.throw() + }, + + async revealRejected() { + let broadcasts = 0 + const signer = loadEmittedSigner(await emitSigner(), stubEncoder({ + broadcastTx: async () => { + broadcasts += 1 + if (broadcasts === 2) throw new Error('Encoder RPC error: bad-txns-inputs-missingorspent') + return { txid: PHASE1 } + } + })) + const caught = await rejection(signer.broadcast('wire')) + expect(caught).to.include({ fundsCommitted: true, phase1Txid: PHASE1, revealTxid: REVEAL, revealTxHex: 'hex-2' }) + expect(caught.neverSent).to.equal(undefined) + // The SAME object is rethrown: the hub classifies on message and response. + expect(caught.message).to.equal('Encoder RPC error: bad-txns-inputs-missingorspent') + }, + + async preFundingFailure() { + const signer = loadEmittedSigner(await emitSigner(), stubEncoder({ + createTx: async () => { throw new Error('Encoder RPC error: no UTXOs available') } + })) + const caught = await rejection(signer.broadcast('wire')) + expect(caught.fundsCommitted).to.equal(undefined) + expect(caught.neverSent).to.equal(true) + }, + + async twoPhaseSuccess() { + const signer = loadEmittedSigner(await emitSigner(), stubEncoder()) + const res = await signer.broadcast('wire') + expect(res.phase1_txid).to.equal(PHASE1) + }, + + async encoderRefusesReveal() { + const { signer, calls } = recordingSigner(await emitSigner(), { encoder: { + spendP2sh: async () => { throw new Error('Encoder RPC error: reveal leg below dust') } + } }) + const caught = await rejection(signer.broadcast('wire')) + expect(calls).to.deep.equal(['createTx', 'sign', 'spendP2sh']) + expect(caught.neverSent).to.equal(true) + expect(caught.fundsCommitted).to.equal(undefined) + expect(caught.message).to.equal('Encoder RPC error: reveal leg below dust') + }, + + async revealUnsignable() { + const { signer, calls } = recordingSigner(await emitSigner(), { wallet: { + signRevealPsbt: () => { throw new Error('Reveal PSBT finalization failed') } + } }) + const caught = await rejection(signer.broadcast('wire')) + expect(calls).to.deep.equal(['createTx', 'sign', 'spendP2sh', 'sign-reveal']) + expect(caught.neverSent).to.equal(true) + expect(caught.fundsCommitted).to.equal(undefined) + }, + + async revealBuiltFirst() { + let spendParams = null + const { signer, calls } = recordingSigner(await emitSigner(), { encoder: { + spendP2sh: async (p) => { spendParams = p; return { psbt: 'psbt-2' } } + } }) + const res = await signer.broadcast('wire') + expect(calls).to.deep.equal(['createTx', 'sign', 'spendP2sh', 'sign-reveal', 'broadcast:hex-1', 'broadcast:hex-2']) + expect(spendParams).to.include({ p2shHash: PHASE1, p2shHex: 'hex-1', data: 'wire', encoding: 'P2SH' }) + expect(res).to.deep.equal({ txid: REVEAL, phase1_txid: PHASE1 }) + }, + + async revealBroadcastFails() { + const { signer, calls } = recordingSigner(await emitSigner(), { failBroadcast: 2 }) + const caught = await rejection(signer.broadcast('wire')) + expect(calls).to.deep.equal(['createTx', 'sign', 'spendP2sh', 'sign-reveal', 'broadcast:hex-1', 'broadcast:hex-2']) + expect(caught).to.include({ fundsCommitted: true, phase1Txid: PHASE1, revealTxid: REVEAL, revealTxHex: 'hex-2' }) + expect(caught.neverSent).to.equal(undefined) + }, + + async commitBroadcastFails() { + const { signer, calls } = recordingSigner(await emitSigner(), { failBroadcast: 1 }) + const caught = await rejection(signer.broadcast('wire')) + expect(calls).to.deep.equal(['createTx', 'sign', 'spendP2sh', 'sign-reveal', 'broadcast:hex-1']) + expect(caught.neverSent).to.equal(undefined) + expect(caught.fundsCommitted).to.equal(undefined) + }, + + async singleTransaction() { + const { signer, calls } = recordingSigner(await emitSigner(), { encoding: 'OP_RETURN' }) + const res = await signer.broadcast('wire') + expect(calls).to.deep.equal(['createTx', 'sign', 'broadcast:hex-1']) + expect(res).to.deep.equal({ txid: PHASE1 }) + } +} + // The emitted signer runs both phases of the P2SH encoding, and phase 1 puts // real DOGE on chain. A failure after that point must not reach the hub looking // like a clean pre-send failure: the hub would requeue, re-enter broadcast(), // run createTx over fresh UTXOs and fund the same payload a second time. So the // template is driven for real here rather than grepped, with the SDK stubbed. +// +// A P2SH commit whose reveal is then refused leaves its legs locked to a script +// only that reveal spends, and the hub dead-letters such a round rather than +// fund it again. So the reveal is built from the signed commit and signed before +// the commit is broadcast, and a refusal there sends nothing. describe('ValidatorService', function () { describe('coin wallets', function () { @@ -133,40 +269,19 @@ describe('ValidatorService', function () { }) describe('the emitted signer marks post-funding failures', function () { + it('is valid JavaScript once the template literal is expanded', cases.validJs) + it('tags a definitive phase-2 rejection with fundsCommitted, the phase-1 txid and the signed reveal', cases.revealRejected) + it('marks a pre-funding failure neverSent and not fundsCommitted, so the round stays retryable', cases.preFundingFailure) + it('does not tag a successful two-phase publish', cases.twoPhaseSuccess) + }) - it('is valid JavaScript once the template literal is expanded', async function () { - const source = await emitSigner() - expect(() => new vm.Script(source, { filename: 'signer.js' })).to.not.throw() - }) - - it('tags a definitive phase-2 rejection with fundsCommitted and the phase-1 txid', async function () { - const signer = loadEmittedSigner(await emitSigner(), stubEncoder({ - spendP2sh: async () => { throw new Error('Encoder RPC error: bad-txns-inputs-missingorspent') } - })) - let caught = null - try { await signer.broadcast('wire') } catch (e) { caught = e } - expect(caught).to.exist - expect(caught.fundsCommitted).to.equal(true) - expect(caught.phase1Txid).to.equal(PHASE1) - // The SAME object is rethrown: the hub classifies on message and response. - expect(caught.message).to.equal('Encoder RPC error: bad-txns-inputs-missingorspent') - }) - - it('leaves a pre-funding failure untagged, so the round stays retryable', async function () { - const signer = loadEmittedSigner(await emitSigner(), stubEncoder({ - createTx: async () => { throw new Error('Encoder RPC error: no UTXOs available') } - })) - let caught = null - try { await signer.broadcast('wire') } catch (e) { caught = e } - expect(caught).to.exist - expect(caught.fundsCommitted).to.equal(undefined) - }) - - it('does not tag a successful two-phase publish', async function () { - const signer = loadEmittedSigner(await emitSigner(), stubEncoder()) - const res = await signer.broadcast('wire') - expect(res.phase1_txid).to.equal(PHASE1) - }) + describe('the emitted signer builds and signs the reveal before the commit broadcast', function () { + it('broadcasts nothing when the encoder refuses the reveal', cases.encoderRefusesReveal) + it('broadcasts nothing when the reveal cannot be signed', cases.revealUnsignable) + it('builds the reveal from the signed commit, then broadcasts the commit and the reveal', cases.revealBuiltFirst) + it('hands back the signed reveal when its broadcast fails after the commit', cases.revealBroadcastFails) + it('leaves a commit broadcast failure untagged for the hub to classify', cases.commitBroadcastFails) + it('builds no reveal for a single-transaction encoding', cases.singleTransaction) }) }) }) From 010513d7a0bbcba8255612ff85507d7c77abcae5 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 14 Oct 2026 09:57:13 -0700 Subject: [PATCH 36/50] Fix review round findings for xchain-node (2026-10-14) Fixes the defects, doc drift and test gaps this repo carried on the review board and the test-gap board, 37 files. --- README.md | 4 +- src/cli.js | 4 +- src/cli/options.js | 10 +- src/operations/module_operations.js | 4 +- .../module_operations/module_controls.js | 19 +- src/services/autoheal_service.js | 46 +++-- .../status_evaluation.js | 13 +- src/services/config_service/coins.js | 5 +- src/services/credentials_service.js | 65 +++++-- .../database_service/database_module.js | 3 +- .../database_service/not_running_hint.js | 26 +++ .../migration_precondition_service.js | 3 +- .../migration_scan.js | 6 +- .../recorded_ledger.js | 10 +- src/services/module_service/docker_args.js | 2 +- .../status_service/status_reducers.js | 37 +++- src/services/stop_budget_service.js | 18 +- src/utils/command_lock.js | 7 +- test/e2e/scenarios/exec_logs.test.js | 18 +- test/integration/module_lifecycle.test.js | 10 +- .../04_service_lifecycle.test.js | 10 +- test/unit/autoheal_service.test.js | 11 +- .../01_health_state_guards.test.js | 13 +- .../02_fresh_grace_after_stop.test.js | 9 +- .../11_dry_run_and_missing_timing.test.js | 11 +- .../12_restart_and_inspection_errors.test.js | 11 +- .../13_ring_buffer_restart.test.js | 9 +- .../14_recovery_clears_onset.test.js | 7 +- .../15_retained_probe_recovery.test.js | 9 +- .../16_registry_state.test.js | 7 +- .../17_grace_override.test.js | 7 +- .../18_health_log_helpers.test.js | 7 +- .../20_budgeted_restart.test.js | 120 ++++++++++++ .../backoff/03_restart_cooldown.test.js | 11 +- .../backoff/04_exponential_backoff.test.js | 13 +- .../05_recovery_resets_backoff.test.js | 11 +- .../07_stopped_container_backoff.test.js | 9 +- .../backoff/10_backoff_calculations.test.js | 7 +- .../backoff/20_escalation_signal.test.js | 181 ++++++++++++++++++ .../probation/06_starting_probation.test.js | 11 +- .../08_recovery_past_probation.test.js | 11 +- .../09_pass_inside_probation.test.js | 11 +- .../sidecar_and_bootstrap.test.js | 35 ++++ .../01_save_credentials.test.js | 10 +- ...oot_password_save_db_root_password.test.js | 6 +- .../05_save_external_db_config.test.js | 6 +- .../07_atomic_save.test.js | 112 +++++++++++ .../08_atomic_save_on_disk.test.js | 72 +++++++ .../support/helpers.js | 29 ++- .../build_database_module.test.js | 43 +++++ .../10_refusal_lists_recorded_ledger.test.js | 5 +- .../03_module_controls.test.js | 6 +- .../17_module_error_paths.test.js | 37 ++-- .../reset/09_reset_modules.test.js | 6 +- test/unit/status_reorg_halt.test.js | 51 +++++ 55 files changed, 1048 insertions(+), 176 deletions(-) create mode 100644 src/services/database_service/not_running_hint.js create mode 100644 test/unit/autoheal_service.test/20_budgeted_restart.test.js create mode 100644 test/unit/autoheal_service.test/backoff/20_escalation_signal.test.js create mode 100644 test/unit/credentials_service.test/07_atomic_save.test.js create mode 100644 test/unit/credentials_service.test/08_atomic_save_on_disk.test.js diff --git a/README.md b/README.md index 4277fbf6..7e2c2805 100644 --- a/README.md +++ b/README.md @@ -138,13 +138,13 @@ When xchain-node manages its own MariaDB container, these optional env vars over Every persistent service container carries a Docker healthcheck, but Docker itself takes no action on the `unhealthy` state (`--restart unless-stopped` only fires when the process exits). An alive-but-stalled service would otherwise stay wedged until an operator notices it in `docker ps`. `xchain-node autoheal` closes that loop: it restarts containers that have been continuously unhealthy past a grace window, for services opted in via `autoheal: true` in the healthcheck table (currently the decoder and indexer; the utxo-tracker is deliberately excluded because it halts on purpose rather than exiting, and a restart would just re-halt it; the encoder is deliberately excluded because its health probe reports utxo-tracker readiness (unreachable, halted, lagging or mempool not ready), which restarting the encoder cannot clear). -The command is one-shot and never prompts or daemonizes. Unattended remediation requires wiring it to a cron entry or systemd timer, e.g. `*/5 * * * * xchain_node autoheal`. Detection-to-restart latency is the timer interval plus the health retry budget plus the grace window. Use `--dry-run` to see restart candidates without acting. Exit code is non-zero only when a restart was attempted and failed. +The command is one-shot and never prompts or daemonizes. Unattended remediation requires wiring it to a cron entry or systemd timer, e.g. `*/5 * * * * xchain_node autoheal`. Detection-to-restart latency is the timer interval plus the health retry budget plus the grace window. Use `--dry-run` to see restart candidates without acting. Exit codes: `1` when a restart was attempted and failed, `2` when a container has been restarted twice this episode without recovering (a wedge restarts are not clearing; investigate), and `0` otherwise. The escalation also logs a warning on stderr, so a systemd `OnFailure=` hook trips on it and cron mails it even when stdout is discarded. It repeats on every pass until the container recovers. | Variable | Default | What it controls | |---|---|---| | `XCHAIN_NODE_AUTOHEAL_GRACE_MS` | `120000` | How long a container must be continuously unhealthy before a restart is considered | | `XCHAIN_NODE_AUTOHEAL_COOLDOWN_MS` | `600000` | Base wait before autoheal restarts the same container again (anti-flap). The first retry waits this long; each further restart of a container that has not recovered doubles the wait, up to `XCHAIN_NODE_AUTOHEAL_COOLDOWN_CEILING_MS` (with defaults: 10m, 20m, 40m and so on, capped at 6h). The doubling resets once the container recovers | -| `XCHAIN_NODE_AUTOHEAL_COOLDOWN_CEILING_MS` | `21600000` | Upper bound on the doubled cooldown. A container that never recovers settles at one restart per ceiling; there is no attempt cap, and every skipped pass logs an "investigate" line | +| `XCHAIN_NODE_AUTOHEAL_COOLDOWN_CEILING_MS` | `21600000` | Upper bound on the doubled cooldown. A container that never recovers settles at one restart per ceiling; there is no attempt cap, and from the second unrecovered restart on every pass logs an "investigate" warning and exits `2` | | `XCHAIN_NODE_AUTOHEAL_PROBATION_MS` | `150000` | How long after an autoheal restart a passing health probe counts as that restart's own artifact rather than recovery, so it does not reset the backoff. If you widen a service's start period with `XCHAIN_NODE_HEALTH_START_PERIOD_`, widen this to match (start period plus interval times retries, plus margin) | | `XCHAIN_NODE_AUTOHEAL_STATE_DIR` | `~/.xchain-node` | Where the state file (`autoheal-state.json`) lives: last restart times, unhealthy onsets and the per-container restart count that drives the backoff. Falls back to `XCHAIN_NODE_LOCK_DIR` when that is set, otherwise `~/.xchain-node` | diff --git a/src/cli.js b/src/cli.js index 5236eca0..d393aadd 100644 --- a/src/cli.js +++ b/src/cli.js @@ -169,8 +169,8 @@ async function maybeSelfUpdateBeforeUpdate(args, deps = {}) { } // Serialized like every mutator, so two concurrent updates cannot both - // move the checkout. The parent releases before spawning; the re-exec child - // takes the normal command lock and refuses if another mutator gets there first. + // move the checkout. The parent releases before spawning; the update run that re-takes + // the lock waits at least SELF_UPDATE_HANDOFF_WAIT_MS, refusing only if it is still held then. const lock = deps.acquireCommandLock || acquireCommandLock const release = lock({ command: 'update (self-update)', waitMs: dispatchSettings(config).MUTATING_LOCK_WAIT_MS }) let outcome diff --git a/src/cli/options.js b/src/cli/options.js index e86078a1..4d2db95e 100644 --- a/src/cli/options.js +++ b/src/cli/options.js @@ -119,12 +119,12 @@ function registerAutoheal(program, deps) { .description('Restart containers stuck in the Docker "unhealthy" state (opt-in per service); one-shot, cron/timer safe') .option('--dry-run', 'report restart candidates without acting') .action(async (options) => { - const { runAutoheal } = loadModule('./services/autoheal_service') + const { runAutoheal, autohealExitCode } = loadModule('./services/autoheal_service') const result = await runAutoheal({ dryRun: options.dryRun ?? false }) - // Exit non-zero ONLY when a restart was attempted and failed, so a - // timer unit can alert on real remediation failures without paging - // on "nothing to do" passes. - return process.exit(result.failed.length > 0 ? 1 : 0) + // Exit 1 when a restart was attempted and failed, 2 when a container + // outlived two restarts this episode (the escalation), and 0 otherwise, + // so a timer unit alerts on both without paging on "nothing to do" passes. + return process.exit(autohealExitCode(result)) }) } diff --git a/src/operations/module_operations.js b/src/operations/module_operations.js index 27f14de1..45187028 100644 --- a/src/operations/module_operations.js +++ b/src/operations/module_operations.js @@ -29,7 +29,7 @@ const { db } = require('../state') const { sleep } = require('../utils/helpers') const { getDockerContainerImageName, getUtxoTrackerVolumeName, getDockerNetwork } = require('../services/config_service') const { createDockerNetwork, probeContainerPresenceByName, stopContainer, stopContainerByName, startContainer, restartContainer, execContainer, shellContainer, logContainer, startDockerMonitor, waitContainer, saveContainerLogs, getContainerBindMounts, getContainerStopSettings, removeContainer } = require('../services/docker_service') -const { stopModuleContainer } = require('../services/stop_budget_service') +const { stopModuleContainer, restartModuleContainer } = require('../services/stop_budget_service') const { buildDatabaseModule, resetDatabases, clearHubPriceIngestWatermark, purgeHubCrossChainRows, manualHubCrossChainPurgeStatements, getDatabaseContainerId, pingExternalDatabase, askMariadbRootPassword } = require('../services/database_service') const { getModuleBranch, installModule, uninstallModule } = require('../services/module_service') const { assertHubNotBehind } = require('../services/skew_guard_service') @@ -161,7 +161,7 @@ const dependencies = { stopContainerByName, startContainer, restartContainer, execContainer, shellContainer, logContainer, startDockerMonitor, waitContainer, saveContainerLogs, getContainerBindMounts, getContainerStopSettings, removeContainer, - stopModuleContainer, buildDatabaseModule, resetDatabases, + stopModuleContainer, restartModuleContainer, buildDatabaseModule, resetDatabases, clearHubPriceIngestWatermark, purgeHubCrossChainRows, manualHubCrossChainPurgeStatements, getDatabaseContainerId, askMariadbRootPassword, diff --git a/src/operations/module_operations/module_controls.js b/src/operations/module_operations/module_controls.js index edcd2b58..5e62e1b1 100644 --- a/src/operations/module_operations/module_controls.js +++ b/src/operations/module_operations/module_controls.js @@ -1,9 +1,9 @@ 'use strict' -let XChainService, failureReason, sleep, db, execContainer, getDockerContainerImageName, logContainer, restartContainer, shellContainer, startContainer, startDockerMonitor, statusChanged, stopContainer, stopModuleContainer, getContainerStopSettings, isNoSuchContainerError +let XChainService, failureReason, sleep, db, execContainer, getDockerContainerImageName, logContainer, shellContainer, startContainer, startDockerMonitor, statusChanged, stopContainer, stopModuleContainer, restartModuleContainer, getContainerStopSettings, isNoSuchContainerError function configure(dependencies) { - ({ XChainService, failureReason, sleep, db, execContainer, getDockerContainerImageName, logContainer, restartContainer, shellContainer, startContainer, startDockerMonitor, statusChanged, stopContainer, stopModuleContainer, getContainerStopSettings, isNoSuchContainerError } = dependencies) + ({ XChainService, failureReason, sleep, db, execContainer, getDockerContainerImageName, logContainer, shellContainer, startContainer, startDockerMonitor, statusChanged, stopContainer, stopModuleContainer, restartModuleContainer, getContainerStopSettings, isNoSuchContainerError } = dependencies) } async function logModules(servicesList, follow = true) { @@ -112,8 +112,11 @@ async function controlModules(verb, servicesList, act) { } async function restartModules(servicesList) { - return controlModules('restart', servicesList, async (target, containerId) => { - await restartContainer(containerId) + return controlModules('restart', servicesList, async ({ module, coin, network }, containerId) => { + // With the same stop budget as `stop`, so a restart cannot SIGKILL a service mid-block either. + // A container docker says is gone stays a failure here: restart promises a running service. + await restartModuleContainer(stopContainer, startContainer, module, coin, network, containerId, + undefined, getContainerStopSettings) try { await statusChanged() } catch (err) { @@ -303,15 +306,17 @@ async function bounceResetModules(context) { continue } if (!containerId) continue /* not installed, skip */ - // restartContainer = docker stop + docker start; sufficient to re-enter Node's bootstrap with the freshly-created DB ready. + // A budgeted stop then a start, enough to re-enter Node's bootstrap with the freshly-created DB ready. // A failed restart can leave the wiped service stopped, so it gets the start pass's one retry and is then reported, never swallowed. + const bounce = () => restartModuleContainer(stopContainer, startContainer, module, coin, network, containerId, + undefined, getContainerStopSettings) try { - await restartContainer(containerId) + await bounce() } catch (firstErr) { console.warn(`Failed to restart ${module} (${firstErr && firstErr.message}); retrying in 3s...`) await sleep(3000) try { - await restartContainer(containerId) + await bounce() } catch (retryErr) { startFailures.push({ module, error: `post-reset restart failed (${(retryErr && retryErr.message) || String(retryErr)})` }) } diff --git a/src/services/autoheal_service.js b/src/services/autoheal_service.js index ee5a3cae..29ba924f 100644 --- a/src/services/autoheal_service.js +++ b/src/services/autoheal_service.js @@ -44,8 +44,11 @@ * watchdog on node containers, and a cap that stops retrying can leave a * RECOVERABLE service down through an outage that would have self-cleared. * Backing off ends the churn; giving up would trade it for an outage. - * Every skipped pass still logs an "investigate" line, which is the - * escalation path for a wedge that has stopped being transient. + * The escalation path instead: once a container has been restarted twice + * in one episode without recovering, every pass that sees it logs an + * "investigate" line at warn (stderr) and the command exits 2, so a + * systemd OnFailure= hook or cron mail can act on a wedge that has stopped + * being transient. A failed restart logs at error and exits 1. * * Detection-to-restart latency is the timer interval plus the health * retry budget plus the grace window; this is deliberate, restarts @@ -57,7 +60,8 @@ ********************************************************************/ const { db } = require('../state') -const { getStatusFromContainer, restartContainer } = require('./docker_service') +const { getStatusFromContainer, stopContainer, startContainer, getContainerStopSettings } = require('./docker_service') +const { restartModuleContainer } = require('./stop_budget_service') const { SERVICE_HEALTHCHECK } = require('./module_service') const { DEFAULT_GRACE_MS, @@ -112,8 +116,8 @@ async function inspectCandidate(candidate, result) { // at its last value the moment a container stops (the probe goroutine // runs only while the container is up), so a container that happened to // be unhealthy when an operator stopped it keeps reporting `unhealthy` -// while State.Status is `exited` - and `docker restart` on a stopped -// container STARTS it, silently undoing the stop. Frozen health from a +// while State.Status is `exited` - and a restart (stop, then start) of a +// stopped container STARTS it, silently undoing the stop. Frozen health from a // container that is no longer probing is not evidence of a wedge. This // costs no healing either: autoheal exists for the ALIVE-but-stalled // case (see the file header), because `--restart unless-stopped` already @@ -152,7 +156,7 @@ function skipNonUnhealthy(candidate, health, state) { // that DID recover starts its next episode at the base cooldown. Backing // off is a response to a wedge restarts are not clearing; a recovery is // the evidence they cleared it, and `!== 'unhealthy'` is not that - // evidence: `docker restart` puts the container into `starting` for the + // evidence: a restart puts the container into `starting` for the // descriptor's start period plus its retry budget (60s + 3x15s for the // decoder/indexer, and an operator can widen it to minutes via // XCHAIN_NODE_HEALTH_START_PERIOD_), so a pass landing in that @@ -240,12 +244,20 @@ async function restartEligibleCandidate(candidate, since, timing, state, result, // instead of one per cooldown forever. const attempts = state.restartCount[containerId] || 0 const effectiveCooldownMs = restartBackoffMs(attempts, timing.cooldownMs, timing.ceilingMs) + // Two restarts that did not clear the wedge is where the backoff starts doubling, and where it stops being transient. + const escalated = attempts >= 2 + // Escalate on the restart path too: a timer slower than the doubled cooldown never lands in the cooldown branch. + if (escalated) result.escalated.push({ module, coin, network, containerId, attempts }) const lastRestart = state.restarts[containerId] if (typeof lastRestart === 'number' && now - lastRestart < effectiveCooldownMs) { result.skipped.push({ module, coin, network, containerId, reason: 'inside restart cooldown' }) - logger.info(`autoheal: ${label} already restarted ${now - lastRestart}ms ago (${attempts} restart(s) this episode, backed-off cooldown ${effectiveCooldownMs}ms), a restart is not clearing this wedge; investigate`) + const log = escalated ? logger.warn : logger.info + log(`autoheal: ${label} already restarted ${now - lastRestart}ms ago (${attempts} restart(s) this episode, backed-off cooldown ${effectiveCooldownMs}ms), a restart is not clearing this wedge; investigate`) return } + if (escalated) { + logger.warn(`autoheal: ${label} is still unhealthy after ${attempts} restart(s) this episode, a restart is not clearing this wedge; investigate`) + } result.candidates.push({ module, coin, network, containerId }) if (dryRun) { @@ -254,14 +266,16 @@ async function restartEligibleCandidate(candidate, since, timing, state, result, } try { - await restartContainer(containerId) + // With the stop budget `stop` uses, so the restart gives a block-bound drain its time + await restartModuleContainer(stopContainer, startContainer, module, coin, network, containerId, + undefined, getContainerStopSettings) state.restarts[containerId] = now state.restartCount[containerId] = attempts + 1 result.restarted.push({ module, coin, network, containerId }) logger.info(`autoheal: restarted ${label} (unhealthy for ${now - since}ms, restart #${attempts + 1} this episode)`) } catch (err) { result.failed.push({ module, coin, network, containerId, reason: String(err) }) - logger.info(`autoheal: FAILED to restart ${label}: ${err}`) + logger.error(`autoheal: FAILED to restart ${label}: ${err}`) } } @@ -283,13 +297,13 @@ function persistState(stateFile, state, modules) { // One autoheal pass over the module registry. Never throws for a single bad // container (a vanished container id must not abort the whole sweep). -// Returns { candidates, restarted, failed, skipped } where each array holds -// { module, coin, network, containerId, reason? }. +// Returns { candidates, restarted, failed, skipped, escalated } where each array holds +// { module, coin, network, containerId, reason? }; escalated entries carry attempts instead. async function runAutoheal({ dryRun = false, now = Date.now() } = {}) { const timing = autohealTiming() const stateFile = getStateFilePath() const state = readState(stateFile) - const result = { candidates: [], restarted: [], failed: [], skipped: [] } + const result = { candidates: [], restarted: [], failed: [], skipped: [], escalated: [] } // Set whenever an episode-onset or attempt-count entry is recorded or cleared, // so the pass persists it even when nothing was restarted. Without this the // onset never survives to the next pass and the grace window can never be @@ -331,8 +345,16 @@ async function runAutoheal({ dryRun = false, now = Date.now() } = {}) { return result } +// The CLI exit code for one pass: 1 when a restart failed, 2 when a wedge outlived two restarts, else 0. +function autohealExitCode(result) { + if (result.failed.length > 0) return 1 + if (result.escalated && result.escalated.length > 0) return 2 + return 0 +} + module.exports = { runAutoheal, + autohealExitCode, getUnhealthySinceMs, getLastHealthyProbeMs, isRecoveryEstablished, diff --git a/src/services/bootstrap_health_gate/status_evaluation.js b/src/services/bootstrap_health_gate/status_evaluation.js index 148fe8a5..38586a1a 100644 --- a/src/services/bootstrap_health_gate/status_evaluation.js +++ b/src/services/bootstrap_health_gate/status_evaluation.js @@ -67,7 +67,8 @@ function evaluateContainerState(raw, { now = Date.now() } = {}) { // every known spelling is checked rather than one canonical key: // decoder health: status, lag_blocks/blockLag, reorg_halted, reorg_halt_checked_at, // reorg_halt_parked -// indexer health: status, lag, decoderReorgHalted, stallClass, train_activation.status +// indexer health: status, lag, decoderReorgHalted, stallClass (values: see stallReasons), +// train_activation.status // tracker health: status, lag, halted, halt_reason, block_fetch_desync, // node_height_stale (synced is sent but not read here) // any /status: status ('ok'|'healthy'|'halted'|'degraded'|'stalled'|'unhealthy') @@ -131,9 +132,13 @@ function statusFlagReasons(payload) { // Refusal reasons from the indexer's wedged-counter verdict and a train-activation halt. function stallReasons(payload) { const reasons = [] - // The indexer's own single-field verdict on its block counter: - // 'none' | 'future_block_wait' | 'barrier_defer' | 'wedged'. Only 'wedged' is a - // refusal, and it needs its own leg: the indexer reports status "healthy" + // The indexer's own single-field verdict on its block counter: 'none' | + // 'future_block_wait' | 'bridge_proof_wait' | 'price_landing_wait' | 'barrier_defer' | + // 'wedged', plus the published refinements 'upstream_halted' (only beside + // decoderReorgHalted: true, refused above) | 'rollcall_proof_wait' | 'rollcall_proof_stuck'. + // The set is defined in xchain-indexer src/XChainIndexer/stall_health.js (stallClassOf, + // publishedStallClass). Only 'wedged' is a refusal, and it needs its own + // leg: the indexer reports status "healthy" // whenever its process is up and its DB circuit is closed, so a freshly-wedged // indexer whose lag is still inside the ceiling passes every other check here. // NOT keyed on `degraded`, which stays true throughout the healthy diff --git a/src/services/config_service/coins.js b/src/services/config_service/coins.js index 69216f34..dbd0a8fc 100644 --- a/src/services/config_service/coins.js +++ b/src/services/config_service/coins.js @@ -54,7 +54,8 @@ function configure(dependencies) { // native-fee validation failing closed on every fee-bearing LTC/DOGE action (audit // F-11). getCoinConfigByFullName applies the per-coin host-env override itself // (ignored + warned on mainnet: fee acceptance is consensus and must not depend on -// operator env); the generic FEE_DESTINATION host var is honored on non-mainnet only. +// operator env); the generic FEE_DESTINATION host var is honored on regtest only, since +// testnet is a multi-operator federation whose consensus pin hashes only the static bundle. // Injected as a default, so a value in the - config file still wins. // Address-deriving modules (encoder/decoder/utxo-tracker) resolve their bitcoinjs // network via CryptoNetworks, which keys on the COIN-PREFIXED name (e.g. @@ -97,7 +98,7 @@ function applyCoinDefaults(defaultValues, module, coin, network) { } const feeDestEnvName = 'XCHAIN_FEE_DESTINATION_' + CoinTickerSymbol[coin] + '_' + network.toUpperCase() const registryFeeDestination = getCoinConfigByFullName(coin, network).addresses.FEE_DESTINATION - const feeDestination = (network !== Network.MAINNET && !config.FEE_DESTINATION_ENV[feeDestEnvName] && config.FEE_DESTINATION) + const feeDestination = (network === Network.REGTEST && !config.FEE_DESTINATION_ENV[feeDestEnvName] && config.FEE_DESTINATION) ? config.FEE_DESTINATION : registryFeeDestination if (feeDestination) { diff --git a/src/services/credentials_service.js b/src/services/credentials_service.js index 69785a79..9a7d7028 100644 --- a/src/services/credentials_service.js +++ b/src/services/credentials_service.js @@ -19,6 +19,8 @@ const fs = require('fs') const os = require('os') const path = require('path') const crypto = require('crypto') +const { getLogger } = require('../observability/logger') +const logger = getLogger() const XCHAIN_NODE_DB = "xchain_node" const CREDENTIALS_DIR_NAME = ".xchain-node" @@ -69,6 +71,50 @@ function loadCredentials() { } } +// Read the current document for a save. A missing file is an empty one; any other read +// error throws, because the rename in writeCredentialsAtomic() would replace a file this +// user cannot read, where an in-place write would have refused to open it. +function readCredentialsForUpdate(filePath) { + let content + try { + content = fs.readFileSync(filePath, 'utf8') + } catch (err) { + if (err && err.code === 'ENOENT') return {} + throw err + } + let parsed = null + try { parsed = JSON.parse(content) } catch { parsed = null } + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed + // Move an unparseable file aside instead of saving over it, so the sibling blocks it held stay recoverable + const aside = filePath + '.corrupt-' + new Date().toISOString().replace(/[:.]/g, '-') + fs.renameSync(filePath, aside) + logger.warn(`WARNING: ${filePath} could not be parsed, so it was moved to ${aside} and a new one started. ` + + 'Any stored user/password, dbRootPassword or externalDb entry it held may need recovering from that copy.') + return {} +} + +// Write the whole document to a 0600 sibling temp file, flush it, then rename it over the +// original, so a crash or a full disk mid-write never leaves credentials.json torn. +function writeCredentialsAtomic(filePath, doc) { + const tmpName = '.' + CREDENTIALS_FILE_NAME + '.' + process.pid + '.' + crypto.randomBytes(6).toString('hex') + '.tmp' + const tmpPath = path.join(path.dirname(filePath), tmpName) + try { + fs.writeFileSync(tmpPath, JSON.stringify(doc, null, 2), { mode: 0o600, flag: 'wx' }) + const fd = fs.openSync(tmpPath, 'r+') + try { fs.fsyncSync(fd) } finally { fs.closeSync(fd) } + fs.renameSync(tmpPath, filePath) + } catch (err) { + // Leave no temp file behind; the original is untouched until the rename + try { fs.unlinkSync(tmpPath) } catch { /* never created */ } + throw err + } + try { + fs.chmodSync(filePath, 0o600) + } catch { + // chmod may fail on Windows; file is in user's HOME so still protected by ACL + } +} + // Read-modify-write, mirroring saveExternalDbConfig() below: a whole-file // overwrite here would destroy the sibling `externalDb` block written moments // earlier in the same provisioning run (uuid:7ed329f7). @@ -78,14 +124,9 @@ function saveCredentials(creds) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) } const filePath = getCredentialsPath() - const existing = readCredentialsRaw() || {} + const existing = readCredentialsForUpdate(filePath) Object.assign(existing, creds) - fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), { mode: 0o600 }) - try { - fs.chmodSync(filePath, 0o600) - } catch { - // chmod may fail on Windows; file is in user's HOME so still protected by ACL - } + writeCredentialsAtomic(filePath, existing) } // --- External DB config (host-native MariaDB mode) --- @@ -124,10 +165,9 @@ function saveDbRootPassword(password) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) } const filePath = getCredentialsPath() - const existing = readCredentialsRaw() || {} + const existing = readCredentialsForUpdate(filePath) existing.dbRootPassword = String(password) - fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), { mode: 0o600 }) - try { fs.chmodSync(filePath, 0o600) } catch {} + writeCredentialsAtomic(filePath, existing) } function hasExternalDbConfig() { @@ -151,15 +191,14 @@ function saveExternalDbConfig(cfg) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) } const filePath = getCredentialsPath() - const existing = readCredentialsRaw() || {} + const existing = readCredentialsForUpdate(filePath) existing.externalDb = { host: String(cfg.host), port: Number(cfg.port), root_user: String(cfg.root_user), root_password: String(cfg.root_password) } - fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), { mode: 0o600 }) - try { fs.chmodSync(filePath, 0o600) } catch {} + writeCredentialsAtomic(filePath, existing) } module.exports = { diff --git a/src/services/database_service/database_module.js b/src/services/database_service/database_module.js index e60ea3cd..56e3df87 100644 --- a/src/services/database_service/database_module.js +++ b/src/services/database_service/database_module.js @@ -30,6 +30,7 @@ let { getDefaultConfig, getDockerContainerImageName, getDockerNetwork, validateP let { getStatusFromContainer, addContainerToNetwork, forceRemoveContainerByName, probeContainerPresenceByName } = require('../docker_service') let { statusChanged } = require('../status_service') const { createDatabaseContainerRunArgs } = require('./container_run_args') +const { notRunningHint } = require('./not_running_hint') let config = require('../../config'); let peers = require('../peer_services').bindPeerServices((file) => require(path.join('..', file))) let { getLogger } = require('../../observability/logger'); @@ -270,7 +271,7 @@ async function reuseDatabaseModule(existingId, coin, network) { } catch { /* inspect failed: fall through and let the existing path surface it */ } if (dbState && dbState !== 'running') { const name = getDockerContainerImageName(DB_MODULE_NAME, coin, network) - throw new Error("MariaDB container " + name + " exists but is " + dbState + "; run: docker start " + name) + throw new Error("MariaDB container " + name + " exists but is " + dbState + "; " + notRunningHint(dbState, name)) } try { if (coin && network) { diff --git a/src/services/database_service/not_running_hint.js b/src/services/database_service/not_running_hint.js new file mode 100644 index 00000000..4f26a898 --- /dev/null +++ b/src/services/database_service/not_running_hint.js @@ -0,0 +1,26 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Pick the recovery hint for a MariaDB container that exists but is not running, by its Docker state. +// Every hint is read-only or a plain resume: none removes, recreates or auto-starts the container. +function notRunningHint(dbState, name) { + // `docker start` acts only on a stopped (exited) or never-started (created) container. + if (dbState === 'exited' || dbState === 'created') return "run: docker start " + name + // A crash-loop under --restart unless-stopped: start does nothing, and the cause is in the logs. + if (dbState === 'restarting') return "it is crash-looping; find the cause with: docker logs " + name + // Docker refuses to start a paused container; it must be unpaused. + if (dbState === 'paused') return "run: docker unpause " + name + // Any other state (dead, removing) needs a look before anything is started or removed. + return "inspect it with: docker inspect " + name + " and docker logs " + name +} + +module.exports = { notRunningHint } diff --git a/src/services/migration_precondition_service.js b/src/services/migration_precondition_service.js index 1bea413e..f3b0af1a 100644 --- a/src/services/migration_precondition_service.js +++ b/src/services/migration_precondition_service.js @@ -236,7 +236,8 @@ async function assertRequiredMigrationsApplied(module, coin, network, branch = n const applied = rekeyApplied(result.applied, renames) const missing = required.filter(f => !applied.has(f)) if (missing.length) { - // The blast radius subtracts only recorded names: the runner still executes a file the schema matches. + // The blast radius subtracts only recorded names: the runner still executes a file the schema matches, + // unless its MIGRATION_PRECONDITIONS entry baselines it (see recorded_ledger.js), so the list can overcount. const recorded = result.ledger ? rekeyApplied(result.ledger, renames) : applied // Only reached on the refusal path, so the probe costs a healthy deploy // nothing and cannot introduce a new way for one to fail: both the probe diff --git a/src/services/migration_precondition_service/migration_scan.js b/src/services/migration_precondition_service/migration_scan.js index 9fbf0f5f..25beb408 100644 --- a/src/services/migration_precondition_service/migration_scan.js +++ b/src/services/migration_precondition_service/migration_scan.js @@ -210,8 +210,8 @@ async function runningBuildSupportsPerFileMigrations(container, deps = {}, modul /** * The gated files an unscoped migrate run inside the running build would apply: * its own pending rows (a row with no mode is the runners' manual default), - * minus any the ledger records (re-keyed for renames). A file whose end state - * the schema already matches still runs when no ledger row records it. + * minus any the ledger records (re-keyed for renames). A schema-matched file with no + * ledger row still runs, unless its MIGRATION_PRECONDITIONS entry baselines it (recorded_ledger.js). */ function runningBuildPendingManual(rows, applied) { return rows.filter(row => !row.applied && (row.mode == null || row.mode === 'manual') && @@ -316,7 +316,7 @@ async function readAppliedMigrations({ database, coin, network }, deps = {}) { const out = String(await runner(appliedMigrationsSql(database, LEDGER_TABLE))) const applied = new Set(out.split('\n').map(s => s.trim()).filter(Boolean)) - // Keep the recorded names apart: the runner executes every pending file the ledger lacks, schema match or not. + // Keep the recorded names apart: the runner executes every pending file the ledger lacks unless MIGRATION_PRECONDITIONS baselines it. return { state: 'ledger', applied, ledger: await readRecordedNames(runner, database, LEDGER_TABLE, applied) } } catch (err) { return { state: 'unreadable', reason: (err && err.message) ? err.message : String(err) } diff --git a/src/services/migration_precondition_service/recorded_ledger.js b/src/services/migration_precondition_service/recorded_ledger.js index 8de2d9be..4001572f 100644 --- a/src/services/migration_precondition_service/recorded_ledger.js +++ b/src/services/migration_precondition_service/recorded_ledger.js @@ -15,9 +15,13 @@ * * An indexer database can embody a manual migration's end state with no * ledger row for it. The guard counts that as applied when deciding what is - * missing, but the indexer runner reads only the ledger, so an unscoped - * migrate run still executes such a file. The refusal's list of what that - * run would apply is therefore built from the names the ledger records. + * missing, but the indexer runner decides from the ledger, so an unscoped + * migrate run still executes such a file. The exception is a file listed in + * that runner's MIGRATION_PRECONDITIONS: when its predicate finds the end + * state already live, the runner records it as applied without running a + * statement, while an older build lacking that entry still executes it. The + * refusal's list of what that run would apply is therefore built from the + * names the ledger records, and can overcount by such a baselined file. ********************************************************************/ const { recordedMigrationsSql } = require('../../db/migration_ledger') diff --git a/src/services/module_service/docker_args.js b/src/services/module_service/docker_args.js index ca08c8bb..5d98d40a 100644 --- a/src/services/module_service/docker_args.js +++ b/src/services/module_service/docker_args.js @@ -119,7 +119,7 @@ async function assertNoHostPortConflicts(portArgs, selfName) { // (--health-start-period); set long enough for npm start + DB connect // autoheal - opt-in flag consumed by AutohealService/`xchain-node autoheal`: // when true, a container that stays unhealthy past the grace window -// gets `docker restart`ed. Default OFF. Only set it where a restart +// is restarted (a budgeted stop, then a start). Default OFF. Only set it where a restart // plausibly clears the wedge (stalled event loop, dead DB pool). // NEVER set it on xchain-utxo-tracker: that service deliberately // enters a stable halted state (503 while halted) instead of diff --git a/src/services/status_service/status_reducers.js b/src/services/status_service/status_reducers.js index df739584..25137337 100644 --- a/src/services/status_service/status_reducers.js +++ b/src/services/status_service/status_reducers.js @@ -189,13 +189,15 @@ function describeReorgHaltNote(coin, network, reorgHalt) { // its grace window, which a BTC mainnet indexer's price mirror defers on // almost every poll while the counter keeps advancing. A host fault (a // missing DOGE read, a VM executor down) shows at once, and so does any stall -// the indexer graded wedged, or one an older image left ungraded. +// the indexer graded wedged, or one an older image left ungraded. A bridge +// proof wait shows too: it never wedges, so nothing else would surface it. function reduceIndexerStall(payload) { if (!payload || typeof payload !== 'object') return null const reason = typeof payload.stallReason === 'string' && payload.stallReason.trim() ? payload.stallReason.trim() : null const stallClass = typeof payload.stallClass === 'string' ? payload.stallClass : null + // The price-landing wait is that same in-grace barrier defer, judged on its own longer window. const healthyDefer = stallClass === 'future_block_wait' - || (stallClass === 'barrier_defer' && /_barrier$/.test(reason || '')) + || ((stallClass === 'barrier_defer' || stallClass === 'price_landing_wait') && /_barrier$/.test(reason || '')) return { stalled: reason !== null && !healthyDefer, reason, @@ -239,8 +241,9 @@ function describeCommitInstant(value) { } // What to do about each stall an indexer names, keyed by its reason word. A -// reason with no entry gets the barrier line, since every hub-mirror barrier -// is named `_barrier` and they all clear the same way. +// reason with no entry gets the hub-mirror line only when its word ends in +// `_barrier` (the indexer's name for a hub-mirror barrier), else the log line. +// The bridge proof wait ends in `_barrier` but no hub resync shortens it, so it has its own entry. const INDEXER_STALL_HINTS = { rollcall_proof_unavailable: (coin, network) => "it cannot prove on Dogecoin who signed the roll call, so it defers the epoch close rather than read silence as absence." @@ -255,15 +258,33 @@ const INDEXER_STALL_HINTS = { train_activation_halt: (coin, network) => "the signed release manifest names a rule set this build does not implement; `xchain-node update xchain-indexer " + coin + " " + network + "`.", reorg_rollback: () => - "a reorg rollback is in progress; it resumes on its own once the rolled-back range is re-parsed." + "a reorg rollback is in progress; it resumes on its own once the rolled-back range is re-parsed.", + bridge_proof_barrier: (coin, network) => + "it is waiting for a bridge escrow proof and defers the block rather than mint unproven. This is not a hub-mirror fault," + + " and neither a hub resync nor a restart shortens it: it clears once this node holds a quorum-established checkpoint" + + " at or after the transfer's snapshot block, or an origin-chain indexer endpoint is configured and serves the proof." + + " Look for BRIDGE ESCROW PROOF UNAVAILABLE in `xchain-node logs xchain-indexer " + coin + " " + network + "`.", + list_share_halt: (coin, network) => + "it refuses to commit the block because a list version is missing or altered, and committing would fork it from its peers;" + + " this is not a hub-mirror wait. `xchain-node logs xchain-indexer " + coin + " " + network + "` shows the LIST SHARE HALT line and its reason.", + parse_exception: (coin, network) => + "the same exception hit the same block twice, so the block is not committing;" + + " `xchain-node logs xchain-indexer " + coin + " " + network + "` shows the repeated parse error." +} + +// Hub-mirror line for an unlisted `_barrier` word, neutral log line for any other unlisted +// reason. Tests the word, not the reason, so exception prose ending in `_barrier` never matches. +function fallbackIndexerStallHint(stall) { + if (/_barrier$/.test(stall.word || '')) return (c, n) => + "a hub-mirror barrier (" + stall.reason + ") has held the head block past the grace window; check that the hub is answering" + + " and its mirror stream is advancing, then `xchain-node logs xchain-indexer " + c + " " + n + "`." + return (c, n) => "`ps` has no specific remedy for this stall; `xchain-node logs xchain-indexer " + c + " " + n + "` names the fault." } // The line `ps` prints under the table for a stalled indexer: the reason, how // long no block has committed, and the remedy for that reason. function describeIndexerStallNote(coin, network, stall) { - const hint = INDEXER_STALL_HINTS[stall.word] || ((c, n) => - "a hub-mirror barrier (" + stall.reason + ") has held the head block past the grace window; check that the hub is answering" - + " and its mirror stream is advancing, then `xchain-node logs xchain-indexer " + c + " " + n + "`.") + const hint = INDEXER_STALL_HINTS[stall.word] || fallbackIndexerStallHint(stall) return coin + "/" + network + " xchain-indexer is STALLED (" + stall.reason + ")" + (stall.since ? ", no block committed since " + stall.since : "") + ": " + hint(coin, network) diff --git a/src/services/stop_budget_service.js b/src/services/stop_budget_service.js index 21d06bd9..c4556d8b 100644 --- a/src/services/stop_budget_service.js +++ b/src/services/stop_budget_service.js @@ -261,6 +261,21 @@ async function stopModuleContainer(stopContainerByName, module, coin, network, c return { ...outcome, budget } } +// Restart a service container as the budgeted stop above, then a start. A bare `docker restart` +// honours only a stamped --stop-timeout (docker's ten seconds without one) and never reads the exit code. +async function restartModuleContainer(stopContainerById, startContainerById, module, coin, network, containerRef, + env = MODULE_STOP_TIMEOUT_ENV, readStopSettings = readContainerStopSettings) { + const outcome = await stopModuleContainer(stopContainerById, module, coin, network, containerRef, env, readStopSettings) + try { + await startContainerById(containerRef) + } catch (err) { + // Say the service is now down, since the stop half already happened + const reason = err && err.message ? err.message : String(err) + throw new Error(`${module} was stopped but failed to start again (${reason})`) + } + return outcome +} + module.exports = { DEFAULT_MODULE_STOP_TIMEOUT_SECONDS, MODULE_STOP_TIMEOUT_SECONDS, @@ -274,5 +289,6 @@ module.exports = { effectiveStopBudgetSeconds, stoppedUnclean, describeModuleStopOutcome, - stopModuleContainer + stopModuleContainer, + restartModuleContainer } diff --git a/src/utils/command_lock.js b/src/utils/command_lock.js index 3a018dad..e6e325db 100644 --- a/src/utils/command_lock.js +++ b/src/utils/command_lock.js @@ -39,8 +39,11 @@ * removes the file only when both the pid and the nonce still match, so * neither a stale-lock takeover nor a recycled pid is clobbered on exit. * A self-update releases its parent-owned lock before starting the re-exec - * child. The child then competes for the lock normally, so another mutator - * that acquires it first causes the child to refuse the update. + * child. The update run that re-takes it waits at least + * SELF_UPDATE_HANDOFF_WAIT_MS (cli/dispatch.js, 30s, even when + * XCHAIN_NODE_MUTATING_LOCK_WAIT_MS is lower or 0), so a mutator that + * slipped into the gap delays the update; it refuses only if that mutator + * still holds the lock when the wait runs out. * * Ops note: clearing a wedged lock by hand may mean deleting both * command.lock and command.lock.reclaim. diff --git a/test/e2e/scenarios/exec_logs.test.js b/test/e2e/scenarios/exec_logs.test.js index 6d1d5e8a..f837b75d 100644 --- a/test/e2e/scenarios/exec_logs.test.js +++ b/test/e2e/scenarios/exec_logs.test.js @@ -130,7 +130,7 @@ describe('E2E: Exec and Logs Commands (Scenarios 4.11, 4.12)', function () { this.timeout(30000) beforeEach(setupEnv) afterEach(teardownEnv) - // E2E-072: Restart calls docker restart for each module + // E2E-072: Restart stops each module within its stop budget, then starts it describe('E2E-072: Restart command', function () { it('calls docker restart for each installed module', async function () { @@ -147,11 +147,17 @@ describe('E2E: Exec and Logs Commands (Scenarios 4.11, 4.12)', function () { await cli.moduleOps.restartModules(serviceList) - const restartCmds = env.capture.findCommands(/docker restart/) - expect(restartCmds.length).to.be.greaterThanOrEqual(5) - - for (const cmd of restartCmds) { - const restartedId = cmd.command.split(/\s+/).pop() + // A budgeted stop then a start per module, never a bare `docker restart` and docker's ten seconds + expect(env.capture.findCommands(/docker restart/)).to.have.length(0) + const stopCmds = env.capture.findCommands(/docker stop/) + expect(stopCmds.length).to.be.greaterThanOrEqual(5) + for (const cmd of stopCmds) { + expect(cmd.command, 'every restart stop carries a budget').to.match(/docker stop -t \d+ \S+$/) + } + const stoppedIds = stopCmds.map(cmd => cmd.command.split(/\s+/).pop()) + const startedIds = env.capture.findCommands(/docker start/).map(cmd => cmd.command.split(/\s+/).pop()) + expect(startedIds, 'every stopped module is started again').to.have.members(stoppedIds) + for (const restartedId of startedIds) { expect(containerIds, `restarted ID ${restartedId} in LevelDB`).to.include(restartedId) } }) diff --git a/test/integration/module_lifecycle.test.js b/test/integration/module_lifecycle.test.js index a715d144..c0735b0a 100644 --- a/test/integration/module_lifecycle.test.js +++ b/test/integration/module_lifecycle.test.js @@ -330,15 +330,18 @@ describe('Integration: Module Lifecycle (LevelDB state)', function () { describe('moduleOperations uses LevelDB for container lookups', function () { it('restartModules reads IDs and calls docker restart', async function () { const restartedIds = [] + const stoppedWith = [] const containerId = TestEnv.fakeContainerId('r') await env.insertModule('xchain-indexer', 'bitcoin', 'mainnet', containerId) const moduleOps = proxyquire('../../src/operations/module_operations', { '../services/docker_service': { - restartContainer: async (id) => { restartedIds.push(id); return true }, + // A restart is a budgeted stop then a start, never a bare docker restart + restartContainer: async () => { throw new Error('restart must not use a bare docker restart') }, createDockerNetwork: async () => true, - startContainer: async () => true, - stopContainer: async () => true, + startContainer: async (id) => { restartedIds.push(id); return true }, + stopContainer: async (id, seconds) => { stoppedWith.push([id, seconds]); return true }, + getContainerStopSettings: async () => null, killContainer: async () => true, removeContainer: async () => true, execContainer: async () => '', @@ -356,6 +359,7 @@ describe('Integration: Module Lifecycle (LevelDB state)', function () { expect(restartedIds).to.have.length(1) expect(restartedIds[0]).to.equal(containerId) + expect(stoppedWith).to.deep.equal([[containerId, 30]]) }) }) }) diff --git a/test/regression/regression.test/04_service_lifecycle.test.js b/test/regression/regression.test/04_service_lifecycle.test.js index 21b85a21..2fc17ad9 100644 --- a/test/regression/regression.test/04_service_lifecycle.test.js +++ b/test/regression/regression.test/04_service_lifecycle.test.js @@ -145,12 +145,15 @@ describe('Regression Suite', function () { await env.insertModule('xchain-indexer', 'bitcoin', 'mainnet', containerId) const restartedIds = [] + const stoppedWith = [] const moduleOps = proxyquire('../../../src/operations/module_operations', { '../services/docker_service': { - restartContainer: async (id) => { restartedIds.push(id); return true }, + // A restart is a budgeted stop then a start, never a bare docker restart + restartContainer: async () => { throw new Error('restart must not use a bare docker restart') }, createDockerNetwork: async () => true, - startContainer: async () => true, - stopContainer: async () => true, + startContainer: async (id) => { restartedIds.push(id); return true }, + stopContainer: async (id, seconds) => { stoppedWith.push([id, seconds]); return true }, + getContainerStopSettings: async () => null, killContainer: async () => true, removeContainer: async () => true, execContainer: async () => '', @@ -165,6 +168,7 @@ describe('Regression Suite', function () { await moduleOps.restartModules(serviceList) expect(restartedIds).to.have.length(1) expect(restartedIds[0]).to.equal(containerId) + expect(stoppedWith).to.deep.equal([[containerId, 30]]) }) }) }) diff --git a/test/unit/autoheal_service.test.js b/test/unit/autoheal_service.test.js index e3aa3146..aa54e9eb 100644 --- a/test/unit/autoheal_service.test.js +++ b/test/unit/autoheal_service.test.js @@ -52,7 +52,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -61,7 +62,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../src/services/module_service').SERVICE_HEALTHCHECK } @@ -111,7 +114,7 @@ describe('AutohealService', () => { const result = await service.runAutoheal({ now: NOW }) - expect(stubs.restartContainer.calledOnceWith('aaa')).to.equal(true) + expect(stubs.startContainer.calledOnceWith('aaa')).to.equal(true) expect(result.restarted).to.have.length(1) expect(result.failed).to.have.length(0) }) @@ -122,7 +125,7 @@ describe('AutohealService', () => { const result = await service.runAutoheal({ now: NOW }) - expect(stubs.restartContainer.called).to.equal(false) + expect(stubs.stopContainer.called).to.equal(false) expect(result.candidates).to.have.length(0) }) }) diff --git a/test/unit/autoheal_service.test/01_health_state_guards.test.js b/test/unit/autoheal_service.test/01_health_state_guards.test.js index 09a27ba2..e21c7dc5 100644 --- a/test/unit/autoheal_service.test/01_health_state_guards.test.js +++ b/test/unit/autoheal_service.test/01_health_state_guards.test.js @@ -59,7 +59,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -68,7 +69,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } @@ -104,7 +107,7 @@ describe('AutohealService', () => { const result = await service.runAutoheal({ now: NOW }) - expect(stubs.restartContainer.called).to.equal(false) + expect(stubs.stopContainer.called).to.equal(false) expect(result.skipped[0].reason).to.equal('inside grace window') }) @@ -114,7 +117,7 @@ describe('AutohealService', () => { const result = await service.runAutoheal({ now: NOW }) - expect(stubs.restartContainer.called).to.equal(false) + expect(stubs.stopContainer.called).to.equal(false) expect(result.candidates).to.have.length(0) }) @@ -128,7 +131,7 @@ describe('AutohealService', () => { const result = await service.runAutoheal({ now: NOW }) - expect(stubs.restartContainer.called).to.equal(false) + expect(stubs.stopContainer.called).to.equal(false) expect(result.candidates).to.have.length(0) expect(result.skipped[0].reason).to.equal('not running (state: exited)') }) diff --git a/test/unit/autoheal_service.test/02_fresh_grace_after_stop.test.js b/test/unit/autoheal_service.test/02_fresh_grace_after_stop.test.js index 2b5c8232..73a82643 100644 --- a/test/unit/autoheal_service.test/02_fresh_grace_after_stop.test.js +++ b/test/unit/autoheal_service.test/02_fresh_grace_after_stop.test.js @@ -59,7 +59,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -68,7 +69,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } @@ -125,7 +128,7 @@ describe('AutohealService', () => { stubs.getStatusFromContainer.resolves(inspectStatus('unhealthy', [freshFailure])) const third = await service.runAutoheal({ now: at }) - expect(stubs.restartContainer.called).to.equal(false) + expect(stubs.stopContainer.called).to.equal(false) expect(third.skipped[0].reason).to.equal('inside grace window') }) }) diff --git a/test/unit/autoheal_service.test/11_dry_run_and_missing_timing.test.js b/test/unit/autoheal_service.test/11_dry_run_and_missing_timing.test.js index ecae510c..7fd95aa5 100644 --- a/test/unit/autoheal_service.test/11_dry_run_and_missing_timing.test.js +++ b/test/unit/autoheal_service.test/11_dry_run_and_missing_timing.test.js @@ -52,7 +52,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -61,7 +62,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } @@ -98,7 +101,7 @@ describe('AutohealService', () => { const dry = await service.runAutoheal({ dryRun: true, now: NOW }) expect(dry.candidates).to.have.length(1) expect(dry.restarted).to.have.length(0) - expect(stubs.restartContainer.called).to.equal(false) + expect(stubs.stopContainer.called).to.equal(false) // A real pass right after must still restart (dry run wrote no state). const real = await service.runAutoheal({ now: NOW }) @@ -111,7 +114,7 @@ describe('AutohealService', () => { const result = await service.runAutoheal({ now: NOW }) - expect(stubs.restartContainer.called).to.equal(false) + expect(stubs.stopContainer.called).to.equal(false) expect(result.skipped[0].reason).to.match(/no failing probe log/) }) }) diff --git a/test/unit/autoheal_service.test/12_restart_and_inspection_errors.test.js b/test/unit/autoheal_service.test/12_restart_and_inspection_errors.test.js index 176fb837..c808f9c6 100644 --- a/test/unit/autoheal_service.test/12_restart_and_inspection_errors.test.js +++ b/test/unit/autoheal_service.test/12_restart_and_inspection_errors.test.js @@ -52,7 +52,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -61,7 +62,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } @@ -97,8 +100,8 @@ describe('AutohealService', () => { { module: 'xchain-decoder', coin: 'litecoin', network: 'regtest', container_id: 'jjj' } ]) stubs.getStatusFromContainer.resolves(unhealthyPastGrace()) - stubs.restartContainer.withArgs('iii').rejects(new Error('docker daemon gone')) - stubs.restartContainer.withArgs('jjj').resolves(true) + stubs.stopContainer.withArgs('iii').rejects(new Error('docker daemon gone')) + stubs.stopContainer.withArgs('jjj').resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }) const result = await service.runAutoheal({ now: NOW }) diff --git a/test/unit/autoheal_service.test/13_ring_buffer_restart.test.js b/test/unit/autoheal_service.test/13_ring_buffer_restart.test.js index b30b6fc8..4f10c10f 100644 --- a/test/unit/autoheal_service.test/13_ring_buffer_restart.test.js +++ b/test/unit/autoheal_service.test/13_ring_buffer_restart.test.js @@ -53,7 +53,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -62,7 +63,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } @@ -111,6 +114,6 @@ describe('AutohealService', () => { stubs.getStatusFromContainer.resolves(unhealthyRingBuffer(NOW + 3 * 60000)) const second = await service.runAutoheal({ now: NOW + 3 * 60000 }) expect(second.restarted, 'a sustained wedge must eventually be restarted').to.have.length(1) - expect(stubs.restartContainer.calledOnceWith('ring')).to.equal(true) + expect(stubs.startContainer.calledOnceWith('ring')).to.equal(true) }) }) diff --git a/test/unit/autoheal_service.test/14_recovery_clears_onset.test.js b/test/unit/autoheal_service.test/14_recovery_clears_onset.test.js index f7d65aef..9d77f2cd 100644 --- a/test/unit/autoheal_service.test/14_recovery_clears_onset.test.js +++ b/test/unit/autoheal_service.test/14_recovery_clears_onset.test.js @@ -64,7 +64,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -73,7 +74,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } diff --git a/test/unit/autoheal_service.test/15_retained_probe_recovery.test.js b/test/unit/autoheal_service.test/15_retained_probe_recovery.test.js index 22b9408d..1ab80b10 100644 --- a/test/unit/autoheal_service.test/15_retained_probe_recovery.test.js +++ b/test/unit/autoheal_service.test/15_retained_probe_recovery.test.js @@ -65,7 +65,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -74,7 +75,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } @@ -127,7 +130,7 @@ describe('AutohealService', () => { ])) const second = await service.runAutoheal({ now: later }) - expect(stubs.restartContainer.called, 'a relapse must serve its own grace window').to.equal(false) + expect(stubs.stopContainer.called, 'a relapse must serve its own grace window').to.equal(false) expect(second.skipped[0].reason).to.equal('inside grace window') const state = JSON.parse(fs.readFileSync(path.join(stateDir, 'autoheal-state.json'), 'utf8')) diff --git a/test/unit/autoheal_service.test/16_registry_state.test.js b/test/unit/autoheal_service.test/16_registry_state.test.js index 2dda7629..8f8e656b 100644 --- a/test/unit/autoheal_service.test/16_registry_state.test.js +++ b/test/unit/autoheal_service.test/16_registry_state.test.js @@ -53,7 +53,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -62,7 +63,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } diff --git a/test/unit/autoheal_service.test/17_grace_override.test.js b/test/unit/autoheal_service.test/17_grace_override.test.js index b03b3372..45a925ef 100644 --- a/test/unit/autoheal_service.test/17_grace_override.test.js +++ b/test/unit/autoheal_service.test/17_grace_override.test.js @@ -52,7 +52,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -61,7 +62,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } diff --git a/test/unit/autoheal_service.test/18_health_log_helpers.test.js b/test/unit/autoheal_service.test/18_health_log_helpers.test.js index e4dae571..e775c49c 100644 --- a/test/unit/autoheal_service.test/18_health_log_helpers.test.js +++ b/test/unit/autoheal_service.test/18_health_log_helpers.test.js @@ -69,7 +69,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -78,7 +79,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } diff --git a/test/unit/autoheal_service.test/20_budgeted_restart.test.js b/test/unit/autoheal_service.test/20_budgeted_restart.test.js new file mode 100644 index 00000000..90c6f8dd --- /dev/null +++ b/test/unit/autoheal_service.test/20_budgeted_restart.test.js @@ -0,0 +1,120 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Pins the autoheal restart to the service's stop budget. A bare `docker restart` gave an +// unstamped decoder docker's ten seconds, which SIGKILLs a drain waiting for a block boundary. + +const fs = require('fs') +const os = require('os') +const path = require('path') +const sinon = require('sinon') +const { expect } = require('chai') +const proxyquire = require('proxyquire').noCallThru() + +const NOW = Date.parse('2026-07-21T12:00:00Z') + +// Health.Log entry helper: a probe that started `agoMs` before NOW. +function logEntry(agoMs, exitCode) { + const start = new Date(NOW - agoMs) + return { + Start: start.toISOString(), + End: new Date(start.getTime() + 1000).toISOString(), + ExitCode: exitCode, + Output: exitCode === 0 ? 'ok' : 'wget: server returned error' + } +} + +// Continuously unhealthy for ~10 minutes (well past the 2min default grace). +function unhealthyPastGrace() { + return { + State: { + Status: 'running', + Health: { + Status: 'unhealthy', + FailingStreak: 5, + Log: [logEntry(11 * 60000, 0), logEntry(10 * 60000, 1), logEntry(5 * 60000, 1), logEntry(60000, 1)] + } + } + } +} + +function makeStubs() { + return { + db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, + getStatusFromContainer: sinon.stub(), + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true), + // An unstamped container: created before the stop budgets existed + getContainerStopSettings: sinon.stub().resolves({ stopTimeout: null, shutdownTimeoutMs: null }) + } +} + +function loadService(stubs) { + return proxyquire('../../../src/services/autoheal_service', { + '../state': { db: stubs.db }, + './docker_service': { + getStatusFromContainer: stubs.getStatusFromContainer, + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: stubs.getContainerStopSettings + }, + './module_service': { SERVICE_HEALTHCHECK: require('../../../src/services/module_service').SERVICE_HEALTHCHECK } + }) +} + +describe('AutohealService', () => { + let stubs, service, stateDir, logStub, warnStub + + beforeEach(() => { + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'autoheal-test-')) + process.env.XCHAIN_NODE_AUTOHEAL_STATE_DIR = stateDir + stubs = makeStubs() + service = loadService(stubs) + logStub = sinon.stub(console, 'log') + warnStub = sinon.stub(console, 'warn') + }) + + afterEach(() => { + logStub.restore() + warnStub.restore() + delete process.env.XCHAIN_NODE_AUTOHEAL_STATE_DIR + fs.rmSync(stateDir, { recursive: true, force: true }) + }) + + it('restarts an unstamped decoder with its 120 s stop budget, then starts it', async () => { + stubs.db.getAllModuleContainers.resolves([ + { module: 'xchain-decoder', coin: 'bitcoin', network: 'mainnet', container_id: 'dec1' } + ]) + stubs.getStatusFromContainer.resolves(unhealthyPastGrace()) + + const result = await service.runAutoheal({ now: NOW }) + + expect(result.restarted).to.have.length(1) + expect(stubs.stopContainer.calledOnceWith('dec1', 120)).to.equal(true) + expect(stubs.startContainer.calledOnceWith('dec1')).to.equal(true) + expect(stubs.stopContainer.calledBefore(stubs.startContainer)).to.equal(true) + }) + + it('reports a restart whose start failed after the stop, naming the stopped service', async () => { + stubs.db.getAllModuleContainers.resolves([ + { module: 'xchain-indexer', coin: 'bitcoin', network: 'mainnet', container_id: 'idx1' } + ]) + stubs.getStatusFromContainer.resolves(unhealthyPastGrace()) + stubs.startContainer.rejects(new Error('port is already allocated')) + + const result = await service.runAutoheal({ now: NOW }) + + expect(result.restarted).to.have.length(0) + expect(result.failed).to.have.length(1) + expect(result.failed[0].reason).to.match(/xchain-indexer was stopped but failed to start again/) + }) +}) diff --git a/test/unit/autoheal_service.test/backoff/03_restart_cooldown.test.js b/test/unit/autoheal_service.test/backoff/03_restart_cooldown.test.js index ba5b0c52..3a077bb2 100644 --- a/test/unit/autoheal_service.test/backoff/03_restart_cooldown.test.js +++ b/test/unit/autoheal_service.test/backoff/03_restart_cooldown.test.js @@ -52,7 +52,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -61,7 +62,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } @@ -103,7 +106,7 @@ describe('AutohealService', () => { stubs.getStatusFromContainer.resolves(unhealthyPastGrace()) const second = await service.runAutoheal({ now: NOW + 3 * 60000 }) - expect(stubs.restartContainer.callCount).to.equal(1) + expect(stubs.startContainer.callCount).to.equal(1) expect(second.skipped[0].reason).to.equal('inside restart cooldown') }) @@ -114,7 +117,7 @@ describe('AutohealService', () => { await service.runAutoheal({ now: NOW }) const later = await service.runAutoheal({ now: NOW + 11 * 60000 }) - expect(stubs.restartContainer.callCount).to.equal(2) + expect(stubs.startContainer.callCount).to.equal(2) expect(later.restarted).to.have.length(1) }) }) diff --git a/test/unit/autoheal_service.test/backoff/04_exponential_backoff.test.js b/test/unit/autoheal_service.test/backoff/04_exponential_backoff.test.js index 07958652..3899fe6f 100644 --- a/test/unit/autoheal_service.test/backoff/04_exponential_backoff.test.js +++ b/test/unit/autoheal_service.test/backoff/04_exponential_backoff.test.js @@ -52,7 +52,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -61,7 +62,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } @@ -101,17 +104,17 @@ describe('AutohealService', () => { await service.runAutoheal({ now: NOW }) // restart #1 await service.runAutoheal({ now: NOW + 11 * 60000 }) // restart #2, base cooldown - expect(stubs.restartContainer.callCount).to.equal(2) + expect(stubs.startContainer.callCount).to.equal(2) // 11 minutes after restart #2 clears the BASE cooldown but not the doubled // one, so the old fixed-window behavior would have restarted here. const throttled = await service.runAutoheal({ now: NOW + 22 * 60000 }) - expect(stubs.restartContainer.callCount).to.equal(2) + expect(stubs.startContainer.callCount).to.equal(2) expect(throttled.skipped[0].reason).to.equal('inside restart cooldown') // 21 minutes after restart #2 clears the doubled window. const resumed = await service.runAutoheal({ now: NOW + 32 * 60000 }) - expect(stubs.restartContainer.callCount).to.equal(3) + expect(stubs.startContainer.callCount).to.equal(3) expect(resumed.restarted).to.have.length(1) const state = JSON.parse(fs.readFileSync(path.join(stateDir, 'autoheal-state.json'), 'utf8')) diff --git a/test/unit/autoheal_service.test/backoff/05_recovery_resets_backoff.test.js b/test/unit/autoheal_service.test/backoff/05_recovery_resets_backoff.test.js index c2ffcc04..70ab21b2 100644 --- a/test/unit/autoheal_service.test/backoff/05_recovery_resets_backoff.test.js +++ b/test/unit/autoheal_service.test/backoff/05_recovery_resets_backoff.test.js @@ -52,7 +52,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -61,7 +62,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } @@ -97,7 +100,7 @@ describe('AutohealService', () => { await service.runAutoheal({ now: NOW }) // restart #1 await service.runAutoheal({ now: NOW + 11 * 60000 }) // restart #2 -> next wait doubles - expect(stubs.restartContainer.callCount).to.equal(2) + expect(stubs.startContainer.callCount).to.equal(2) // Container comes back healthy: the earned backoff is not a debt it carries // into a later, unrelated wedge. @@ -111,7 +114,7 @@ describe('AutohealService', () => { // 20-minute window would still be blocking. stubs.getStatusFromContainer.resolves(unhealthyPastGrace()) const again = await service.runAutoheal({ now: NOW + 22 * 60000 }) - expect(stubs.restartContainer.callCount).to.equal(3) + expect(stubs.startContainer.callCount).to.equal(3) expect(again.restarted).to.have.length(1) }) }) diff --git a/test/unit/autoheal_service.test/backoff/07_stopped_container_backoff.test.js b/test/unit/autoheal_service.test/backoff/07_stopped_container_backoff.test.js index 9dd696fc..1b8a554b 100644 --- a/test/unit/autoheal_service.test/backoff/07_stopped_container_backoff.test.js +++ b/test/unit/autoheal_service.test/backoff/07_stopped_container_backoff.test.js @@ -59,7 +59,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -68,7 +69,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } @@ -106,7 +109,7 @@ describe('AutohealService', () => { await service.runAutoheal({ now: NOW }) // restart #1 await service.runAutoheal({ now: NOW + 11 * 60000 }) // restart #2 - expect(stubs.restartContainer.callCount).to.equal(2) + expect(stubs.startContainer.callCount).to.equal(2) stubs.getStatusFromContainer.resolves(stoppedWithFrozenUnhealthy()) const skipped = await service.runAutoheal({ now: NOW + 12 * 60000 }) diff --git a/test/unit/autoheal_service.test/backoff/10_backoff_calculations.test.js b/test/unit/autoheal_service.test/backoff/10_backoff_calculations.test.js index 1b8db64e..349aa6cc 100644 --- a/test/unit/autoheal_service.test/backoff/10_backoff_calculations.test.js +++ b/test/unit/autoheal_service.test/backoff/10_backoff_calculations.test.js @@ -23,7 +23,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -32,7 +33,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } diff --git a/test/unit/autoheal_service.test/backoff/20_escalation_signal.test.js b/test/unit/autoheal_service.test/backoff/20_escalation_signal.test.js new file mode 100644 index 00000000..a5e29695 --- /dev/null +++ b/test/unit/autoheal_service.test/backoff/20_escalation_signal.test.js @@ -0,0 +1,181 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A wedge two restarts did not clear is the "investigate" case the file header +// names as the escalation path, so it must reach something a timer can act on: +// a warn line on stderr and exit code 2, while a failed restart logs at error. + +const fs = require('fs') +const os = require('os') +const path = require('path') +const sinon = require('sinon') +const { expect } = require('chai') +const { Command } = require('commander') +const proxyquire = require('proxyquire').noCallThru() + +const NOW = Date.parse('2026-07-21T12:00:00Z') +const MIN = 60000 + +// Health.Log entry helper: a probe that started `agoMs` before NOW. +function logEntry(agoMs, exitCode) { + const start = new Date(NOW - agoMs) + return { + Start: start.toISOString(), + End: new Date(start.getTime() + 1000).toISOString(), + ExitCode: exitCode, + Output: exitCode === 0 ? 'ok' : 'wget: server returned error' + } +} + +// Continuously unhealthy for ~10 minutes (well past the 2min default grace). +function unhealthyPastGrace() { + const log = [logEntry(11 * MIN, 0), logEntry(10 * MIN, 1), logEntry(5 * MIN, 1), logEntry(MIN, 1)] + return { State: { Status: 'running', Health: { Status: 'unhealthy', FailingStreak: 5, Log: log } } } +} + +// Stubs both restart routes (a plain restart and a budgeted stop then start), so +// the escalation contract is pinned whichever one the service takes. +function makeStubs() { + return { + db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, + getStatusFromContainer: sinon.stub(), + restartContainer: sinon.stub().resolves(true), + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) + } +} + +function loadService(stubs) { + return proxyquire('../../../../src/services/autoheal_service', { + '../state': { db: stubs.db }, + './docker_service': { + getStatusFromContainer: stubs.getStatusFromContainer, + restartContainer: stubs.restartContainer, + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) + }, + './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } + }) +} + +// Each suite gets a fresh state dir, a wedged indexer row and silenced console sinks. +function escalationHarness() { + const h = {} + beforeEach(() => { + h.stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'autoheal-escalation-')) + process.env.XCHAIN_NODE_AUTOHEAL_STATE_DIR = h.stateDir + h.stubs = makeStubs() + h.stubs.db.getAllModuleContainers.resolves([{ module: 'xchain-indexer', coin: 'bitcoin', network: 'regtest', container_id: 'esc1' }]) + h.stubs.getStatusFromContainer.resolves(unhealthyPastGrace()) + h.service = loadService(h.stubs) + h.log = sinon.stub(console, 'log') + h.warn = sinon.stub(console, 'warn') + h.error = sinon.stub(console, 'error') + }) + afterEach(() => { + h.log.restore() + h.warn.restore() + h.error.restore() + delete process.env.XCHAIN_NODE_AUTOHEAL_STATE_DIR + fs.rmSync(h.stateDir, { recursive: true, force: true }) + }) + return h +} + +// Restart #1 at NOW and #2 after the base cooldown, leaving attempts at 2. +async function twoRestartsThatDidNotClear(h) { + const first = await h.service.runAutoheal({ now: NOW }) + const second = await h.service.runAutoheal({ now: NOW + 11 * MIN }) + expect(first.restarted.length + second.restarted.length).to.equal(2) + expect(first.escalated.length + second.escalated.length).to.equal(0) +} + +function warnedInvestigate(h) { + return h.warn.getCalls().some(c => /investigate/.test(String(c.args[0]))) +} + +describe('AutohealService escalation', () => { + const h = escalationHarness() + + it('escalates a wedge that outlived two restarts at warn and exit code 2', async () => { + await twoRestartsThatDidNotClear(h) + const throttled = await h.service.runAutoheal({ now: NOW + 22 * MIN }) + expect(throttled.skipped[0].reason).to.equal('inside restart cooldown') + expect(throttled.escalated).to.have.length(1) + expect(throttled.escalated[0]).to.include({ containerId: 'esc1', attempts: 2 }) + expect(warnedInvestigate(h)).to.be.true + expect(h.service.autohealExitCode(throttled)).to.equal(2) + }) + + it('keeps the first restart cooldown an info line that does not escalate', async () => { + await h.service.runAutoheal({ now: NOW }) + const first = await h.service.runAutoheal({ now: NOW + 5 * MIN }) + expect(first.skipped[0].reason).to.equal('inside restart cooldown') + expect(first.escalated).to.deep.equal([]) + expect(h.warn.called).to.be.false + expect(h.log.getCalls().some(c => /investigate/.test(String(c.args[0])))).to.be.true + expect(h.service.autohealExitCode(first)).to.equal(0) + }) + + it('escalates on a restart #3 pass too, not only on a cooldown skip', async () => { + await twoRestartsThatDidNotClear(h) + const resumed = await h.service.runAutoheal({ now: NOW + 32 * MIN }) + expect(resumed.restarted).to.have.length(1) + expect(resumed.escalated).to.have.length(1) + expect(warnedInvestigate(h)).to.be.true + expect(h.service.autohealExitCode(resumed)).to.equal(2) + }) + + it('reports the escalation on a dry run without restarting', async () => { + await twoRestartsThatDidNotClear(h) + const dry = await h.service.runAutoheal({ now: NOW + 32 * MIN, dryRun: true }) + expect(dry.restarted).to.deep.equal([]) + expect(dry.candidates).to.have.length(1) + expect(dry.escalated).to.have.length(1) + }) +}) + +describe('AutohealService escalation', () => { + const h = escalationHarness() + + it('logs a failed restart at error and exits 1', async () => { + h.stubs.restartContainer.rejects(new Error('daemon gone')) + h.stubs.startContainer.rejects(new Error('daemon gone')) + const result = await h.service.runAutoheal({ now: NOW }) + expect(result.failed).to.have.length(1) + expect(h.error.getCalls().some(c => /FAILED to restart/.test(String(c.args[0])))).to.be.true + expect(h.service.autohealExitCode(result)).to.equal(1) + }) + + it('ranks a failed restart over an escalation in the exit code', () => { + const { autohealExitCode } = h.service + expect(autohealExitCode({ failed: [{}], escalated: [{}] })).to.equal(1) + expect(autohealExitCode({ failed: [], escalated: [{}] })).to.equal(2) + expect(autohealExitCode({ failed: [], escalated: [] })).to.equal(0) + }) + + it('exits the autoheal command with the escalation code', async () => { + const { registerLifecycleCommands } = require('../../../../src/cli/options') + const program = new Command().exitOverride() + const result = { candidates: [], restarted: [], failed: [], skipped: [], escalated: [{ attempts: 2 }] } + const loadModule = () => ({ runAutoheal: async () => result, autohealExitCode: h.service.autohealExitCode }) + registerLifecycleCommands(program, { loadModule }) + const exit = sinon.stub(process, 'exit') + try { + await program.parseAsync(['node', 'xchain-node', 'autoheal']) + expect(exit.calledOnceWith(2)).to.be.true + } finally { + exit.restore() + } + }) +}) diff --git a/test/unit/autoheal_service.test/probation/06_starting_probation.test.js b/test/unit/autoheal_service.test/probation/06_starting_probation.test.js index 89f87205..2d1d8909 100644 --- a/test/unit/autoheal_service.test/probation/06_starting_probation.test.js +++ b/test/unit/autoheal_service.test/probation/06_starting_probation.test.js @@ -52,7 +52,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -61,7 +62,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } @@ -102,7 +105,7 @@ describe('AutohealService', () => { await service.runAutoheal({ now: NOW }) // restart #1 await service.runAutoheal({ now: NOW + 11 * 60000 }) // restart #2 -> next wait doubles - expect(stubs.restartContainer.callCount).to.equal(2) + expect(stubs.startContainer.callCount).to.equal(2) // A minute after restart #2: Docker still reports `starting`. stubs.getStatusFromContainer.resolves(inspectStatus('starting', [logEntry(15000, 1)])) @@ -116,7 +119,7 @@ describe('AutohealService', () => { // still open, so nothing restarts; with the counter wiped it would have. stubs.getStatusFromContainer.resolves(unhealthyPastGrace()) const throttled = await service.runAutoheal({ now: NOW + 22 * 60000 }) - expect(stubs.restartContainer.callCount).to.equal(2) + expect(stubs.startContainer.callCount).to.equal(2) expect(throttled.skipped[0].reason).to.equal('inside restart cooldown') }) }) diff --git a/test/unit/autoheal_service.test/probation/08_recovery_past_probation.test.js b/test/unit/autoheal_service.test/probation/08_recovery_past_probation.test.js index 32f8cc55..afa0fa9f 100644 --- a/test/unit/autoheal_service.test/probation/08_recovery_past_probation.test.js +++ b/test/unit/autoheal_service.test/probation/08_recovery_past_probation.test.js @@ -81,7 +81,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -90,7 +91,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } @@ -130,7 +133,7 @@ describe('AutohealService', () => { await service.runAutoheal({ now: NOW }) // restart #1 await service.runAutoheal({ now: NOW + 11 * 60000 }) // restart #2 -> next wait doubles - expect(stubs.restartContainer.callCount).to.equal(2) + expect(stubs.startContainer.callCount).to.equal(2) // A pass 7 minutes after restart #2, far outside the 150s probation, then a // fresh failing run: the container recovered and relapsed between passes. @@ -154,6 +157,6 @@ describe('AutohealService', () => { stubs.getStatusFromContainer.resolves(unhealthyRingBuffer(NOW + 25 * 60000)) const resumed = await service.runAutoheal({ now: NOW + 25 * 60000 }) expect(resumed.restarted, 'the new episode must start at the base cooldown').to.have.length(1) - expect(stubs.restartContainer.callCount).to.equal(3) + expect(stubs.startContainer.callCount).to.equal(3) }) }) diff --git a/test/unit/autoheal_service.test/probation/09_pass_inside_probation.test.js b/test/unit/autoheal_service.test/probation/09_pass_inside_probation.test.js index 9cc047c3..1a2f1878 100644 --- a/test/unit/autoheal_service.test/probation/09_pass_inside_probation.test.js +++ b/test/unit/autoheal_service.test/probation/09_pass_inside_probation.test.js @@ -81,7 +81,8 @@ function makeStubs() { return { db: { getAllModuleContainers: sinon.stub().resolves([]), assertReady: sinon.stub() }, getStatusFromContainer: sinon.stub(), - restartContainer: sinon.stub().resolves(true) + stopContainer: sinon.stub().resolves({ stopped: true, seconds: 1, killed: false, exitCode: 0 }), + startContainer: sinon.stub().resolves(true) } } @@ -90,7 +91,9 @@ function loadService(stubs) { '../state': { db: stubs.db }, './docker_service': { getStatusFromContainer: stubs.getStatusFromContainer, - restartContainer: stubs.restartContainer + stopContainer: stubs.stopContainer, + startContainer: stubs.startContainer, + getContainerStopSettings: sinon.stub().resolves(null) }, // Real descriptor table: asserts the actual opt-in flags too. './module_service': { SERVICE_HEALTHCHECK: require('../../../../src/services/module_service').SERVICE_HEALTHCHECK } @@ -130,7 +133,7 @@ describe('AutohealService', () => { await service.runAutoheal({ now: NOW }) // restart #1 await service.runAutoheal({ now: NOW + 11 * 60000 }) // restart #2 -> next wait doubles - expect(stubs.restartContainer.callCount).to.equal(2) + expect(stubs.startContainer.callCount).to.equal(2) // The pass landed 60s after restart #2, inside the 150s probation window. const t3 = NOW + 22 * 60000 @@ -149,7 +152,7 @@ describe('AutohealService', () => { stubs.getStatusFromContainer.resolves(unhealthyRingBuffer(NOW + 25 * 60000)) const throttled = await service.runAutoheal({ now: NOW + 25 * 60000 }) - expect(stubs.restartContainer.callCount).to.equal(2) + expect(stubs.startContainer.callCount).to.equal(2) expect(throttled.skipped[0].reason).to.equal('inside restart cooldown') }) }) diff --git a/test/unit/config_service.test/sidecar_and_bootstrap.test.js b/test/unit/config_service.test/sidecar_and_bootstrap.test.js index 0e44959f..551f9874 100644 --- a/test/unit/config_service.test/sidecar_and_bootstrap.test.js +++ b/test/unit/config_service.test/sidecar_and_bootstrap.test.js @@ -180,6 +180,33 @@ function feeDestinationInjection() { } +// Testnet is a multi-operator federation whose consensus pin hashes only the +// static bundle, so the generic host var must not reach either consumer there. +function feeDestinationTestnet() { + const TESTNET_ENV = 'XCHAIN_FEE_DESTINATION_BTC_TESTNET' + let saved + beforeEach(function () { + saved = { perCoin: process.env[TESTNET_ENV], generic: process.env.FEE_DESTINATION } + delete process.env[TESTNET_ENV] + process.env.FEE_DESTINATION = 'mGenericTestnetAddr555' + }) + afterEach(function () { + if (saved.perCoin === undefined) delete process.env[TESTNET_ENV]; else process.env[TESTNET_ENV] = saved.perCoin + if (saved.generic === undefined) delete process.env.FEE_DESTINATION; else process.env.FEE_DESTINATION = saved.generic + }) + + it('ignores the generic FEE_DESTINATION host env on testnet and injects the registry pin', async function () { + const { getCoinConfigByFullName } = require('../../../src/coins') + const pinned = getCoinConfigByFullName('bitcoin', 'testnet').addresses.FEE_DESTINATION + expect(pinned).to.be.a('string').and.not.equal('mGenericTestnetAddr555') + for (const module of ['xchain-decoder', 'xchain-indexer']) { + const config = await makeServiceWithConfig('').getDefaultConfig(module, 'bitcoin', 'testnet') + expect(config['FEE_DESTINATION'], module).to.equal(pinned) + expect(config[TESTNET_ENV], module).to.equal(pinned) + } + }) +} + function genesisBootstrapPassthrough() { const GENESIS_VARS = [ 'XCHAIN_GENESIS_BLOCK', 'XCHAIN_GENESIS_LEDGER_HASH', 'XCHAIN_GENESIS_DUMP_HASH', @@ -255,6 +282,14 @@ describe('ConfigService', function () { }) }) +describe('ConfigService', function () { + describe('getDefaultConfig()', function () { + describe('with coin and network (coin-specific config)', function () { + describe('native-coin fee destination injection', feeDestinationTestnet) + }) + }) +}) + describe('ConfigService', function () { describe('getDefaultConfig()', function () { describe('with coin and network (coin-specific config)', function () { diff --git a/test/unit/credentials_service.test/01_save_credentials.test.js b/test/unit/credentials_service.test/01_save_credentials.test.js index 12ba5886..90801e36 100644 --- a/test/unit/credentials_service.test/01_save_credentials.test.js +++ b/test/unit/credentials_service.test/01_save_credentials.test.js @@ -12,7 +12,7 @@ const { sinon, expect, CREDS_DIR, CREDS_FILE, - loadCredentialsService, makeFs + loadCredentialsService, makeFs, expectAtomicSave } = require('./support/helpers') // saveCredentials @@ -47,9 +47,7 @@ describe('CredentialsService', function () { const cs = loadCredentialsService(fs) const creds = { user: 'u', password: 'test-pass', database: 'xchain_node' } cs.saveCredentials(creds) - expect(fs.writeFileSync.calledOnce).to.be.true - const [filePath, content, opts] = fs.writeFileSync.firstCall.args - expect(filePath).to.equal(CREDS_FILE) + const { content, opts } = expectAtomicSave(fs) expect(JSON.parse(content)).to.deep.equal(creds) expect(opts.mode).to.equal(0o600) }) @@ -93,9 +91,7 @@ describe('CredentialsService', function () { const cs = loadCredentialsService(fs) cs.saveCredentials({ user: 'u', password: 'test-pass', database: 'xchain_node' }) - expect(fs.writeFileSync.calledOnce).to.be.true - const [filePath, content, opts] = fs.writeFileSync.firstCall.args - expect(filePath).to.equal(CREDS_FILE) + const { content, opts } = expectAtomicSave(fs) const written = JSON.parse(content) expect(written.externalDb).to.deep.equal({ host: 'db.example.com', port: 3306, root_user: 'root', root_password: 'r' }) expect(written.user).to.equal('u') diff --git a/test/unit/credentials_service.test/02_load_db_root_password_save_db_root_password.test.js b/test/unit/credentials_service.test/02_load_db_root_password_save_db_root_password.test.js index f8e7a753..772c9b37 100644 --- a/test/unit/credentials_service.test/02_load_db_root_password_save_db_root_password.test.js +++ b/test/unit/credentials_service.test/02_load_db_root_password_save_db_root_password.test.js @@ -12,7 +12,7 @@ const { sinon, expect, CREDS_FILE, - loadCredentialsService, makeFs + loadCredentialsService, makeFs, expectAtomicSave } = require('./support/helpers') // loadDbRootPassword / saveDbRootPassword @@ -67,9 +67,7 @@ describe('CredentialsService', function () { const cs = loadCredentialsService(fs) cs.saveDbRootPassword('new-root-pw') - expect(fs.writeFileSync.calledOnce).to.be.true - const [filePath, content, opts] = fs.writeFileSync.firstCall.args - expect(filePath).to.equal(CREDS_FILE) + const { content, opts } = expectAtomicSave(fs) const written = JSON.parse(content) expect(written.dbRootPassword).to.equal('new-root-pw') expect(written.user).to.equal('u') diff --git a/test/unit/credentials_service.test/05_save_external_db_config.test.js b/test/unit/credentials_service.test/05_save_external_db_config.test.js index b115c837..929bcf96 100644 --- a/test/unit/credentials_service.test/05_save_external_db_config.test.js +++ b/test/unit/credentials_service.test/05_save_external_db_config.test.js @@ -11,7 +11,7 @@ // contact legal@dankest.llc. const { - sinon, expect, loadCredentialsService, makeFs + sinon, expect, loadCredentialsService, makeFs, fsError } = require('./support/helpers') // saveExternalDbConfig @@ -22,7 +22,7 @@ describe('CredentialsService', function () { it('creates dir if it does not exist', function () { const fs = makeFs({ existsSync: sinon.stub().returns(false), - readFileSync: sinon.stub().throws(new Error('ENOENT')) + readFileSync: sinon.stub().throws(fsError('ENOENT')) }) const cs = loadCredentialsService(fs) const cfg = { host: '127.0.0.1', port: 3306, root_user: 'root', root_password: 'test-pass' } @@ -61,7 +61,7 @@ describe('CredentialsService', function () { it('coerces types: host/root_user/root_password to string, port to number', function () { const fs = makeFs({ existsSync: sinon.stub().returns(true), - readFileSync: sinon.stub().throws(new Error('ENOENT')) + readFileSync: sinon.stub().throws(fsError('ENOENT')) }) const cs = loadCredentialsService(fs) cs.saveExternalDbConfig({ host: 127, port: '3306', root_user: 42, root_password: true }) diff --git a/test/unit/credentials_service.test/07_atomic_save.test.js b/test/unit/credentials_service.test/07_atomic_save.test.js new file mode 100644 index 00000000..4fb2dc23 --- /dev/null +++ b/test/unit/credentials_service.test/07_atomic_save.test.js @@ -0,0 +1,112 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Pins the save path of credentials.json: never truncated in place, and an existing file that +// will not parse is moved aside, not silently rebuilt from {} with its sibling blocks dropped. + +const { + sinon, expect, CREDS_DIR, CREDS_FILE, + loadCredentialsService, makeFs, fsError, expectAtomicSave +} = require('./support/helpers') + +const TORN = '{"user":"u","password":"s3cret-torn' + +// The three save helpers, each with the one field it writes. +const SAVES = [ + { name: 'saveCredentials', run: cs => cs.saveCredentials({ user: 'n', password: 'np' }), field: { user: 'n', password: 'np' } }, + { name: 'saveDbRootPassword', run: cs => cs.saveDbRootPassword('rootpw'), field: { dbRootPassword: 'rootpw' } }, + { + name: 'saveExternalDbConfig', + run: cs => cs.saveExternalDbConfig({ host: 'h', port: 3306, root_user: 'r', root_password: 'rp' }), + field: { externalDb: { host: 'h', port: 3306, root_user: 'r', root_password: 'rp' } } + } +] + +let warnStub + +describe('CredentialsService', function () { + beforeEach(() => { warnStub = sinon.stub(console, 'warn') }) + afterEach(() => warnStub.restore()) + + describe('atomic save of an unparseable credentials.json', function () { + for (const save of SAVES) { + it(`${save.name} moves a torn file aside, warns without secrets, and saves only its own field`, function () { + const fs = makeFs({ existsSync: sinon.stub().returns(true), readFileSync: sinon.stub().returns(TORN) }) + save.run(loadCredentialsService(fs)) + const aside = fs.renameSync.firstCall.args + expect(aside[0]).to.equal(CREDS_FILE) + expect(aside[1].startsWith(CREDS_FILE + '.corrupt-')).to.be.true + expect(warnStub.calledOnce).to.be.true + expect(String(warnStub.firstCall.args[0])).to.include(aside[1]) + expect(String(warnStub.firstCall.args[0])).to.not.include('s3cret') + const { content } = expectAtomicSave(fs) + expect(JSON.parse(content)).to.deep.equal(save.field) + }) + } + + it('treats a document that parses to something other than an object as unparseable', function () { + for (const body of ['null', '[]', '"text"', '7']) { + const fs = makeFs({ existsSync: sinon.stub().returns(true), readFileSync: sinon.stub().returns(body) }) + loadCredentialsService(fs).saveDbRootPassword('pw') + expect(fs.renameSync.firstCall.args[1].startsWith(CREDS_FILE + '.corrupt-'), body).to.be.true + } + }) + + it('refuses to save when the torn file cannot be moved aside, leaving it in place', function () { + const fs = makeFs({ + existsSync: sinon.stub().returns(true), + readFileSync: sinon.stub().returns(TORN), + renameSync: sinon.stub().throws(fsError('EXDEV')) + }) + expect(() => loadCredentialsService(fs).saveDbRootPassword('pw')).to.throw('EXDEV') + expect(fs.writeFileSync.called).to.be.false + }) + }) +}) + +describe('CredentialsService', function () { + beforeEach(() => { warnStub = sinon.stub(console, 'warn') }) + afterEach(() => warnStub.restore()) + + describe('atomic save read and write failures', function () { + it('starts a fresh document, with nothing moved aside, when the file does not exist', function () { + const fs = makeFs({ readFileSync: sinon.stub().throws(fsError('ENOENT')) }) + loadCredentialsService(fs).saveDbRootPassword('pw') + const { content } = expectAtomicSave(fs) + expect(JSON.parse(content)).to.deep.equal({ dbRootPassword: 'pw' }) + expect(fs.renameSync.calledOnce).to.be.true + expect(warnStub.called).to.be.false + }) + + it('refuses to save over a file it cannot read, rather than replacing it by rename', function () { + const fs = makeFs({ existsSync: sinon.stub().returns(true), readFileSync: sinon.stub().throws(fsError('EACCES')) }) + expect(() => loadCredentialsService(fs).saveDbRootPassword('pw')).to.throw('EACCES') + expect(fs.writeFileSync.called).to.be.false + expect(fs.renameSync.called).to.be.false + }) + + it('leaves credentials.json untouched and removes the temp file when the write fails', function () { + const fs = makeFs({ existsSync: sinon.stub().returns(true), writeFileSync: sinon.stub().throws(fsError('ENOSPC')) }) + expect(() => loadCredentialsService(fs).saveDbRootPassword('pw')).to.throw('ENOSPC') + const tmpPath = fs.writeFileSync.firstCall.args[0] + expect(tmpPath.startsWith(CREDS_DIR)).to.be.true + expect(fs.renameSync.called).to.be.false + expect(fs.unlinkSync.calledOnceWith(tmpPath)).to.be.true + }) + + it('removes the temp file and reports the error when the rename over the original fails', function () { + const fs = makeFs({ existsSync: sinon.stub().returns(true), renameSync: sinon.stub().throws(fsError('EPERM')) }) + expect(() => loadCredentialsService(fs).saveCredentials({ user: 'u', password: 'p' })).to.throw('EPERM') + expect(fs.unlinkSync.calledOnceWith(fs.writeFileSync.firstCall.args[0])).to.be.true + }) + }) +}) diff --git a/test/unit/credentials_service.test/08_atomic_save_on_disk.test.js b/test/unit/credentials_service.test/08_atomic_save_on_disk.test.js new file mode 100644 index 00000000..48411beb --- /dev/null +++ b/test/unit/credentials_service.test/08_atomic_save_on_disk.test.js @@ -0,0 +1,72 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Runs the credentials.json save on a real filesystem, where rename, mode bits and the +// quarantine copy are the operating system's, not a stub's. + +const fs = require('fs') +const os = require('os') +const path = require('path') +const { sinon, expect, loadCredentialsService } = require('./support/helpers') + +let home, credsDir, credsFile, warnStub + +// Load the service with the real fs and a home directory inside a fresh temp dir. +function loadOnDisk() { + return loadCredentialsService(fs, { homedir: () => home, userInfo: () => ({ username: 'testuser' }) }) +} + +describe('CredentialsService', function () { + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'xcn-creds-')) + credsDir = path.join(home, '.xchain-node') + credsFile = path.join(credsDir, 'credentials.json') + fs.mkdirSync(credsDir, { mode: 0o700 }) + warnStub = sinon.stub(console, 'warn') + }) + + afterEach(() => { + warnStub.restore() + fs.rmSync(home, { recursive: true, force: true }) + }) + + describe('atomic save on a real filesystem', function () { + it('keeps a torn file byte for byte beside a fresh 0600 credentials.json, with no temp file left', function () { + const torn = '{"user":"u","password":"p","externalDb":{"host":"h"' + fs.writeFileSync(credsFile, torn, { mode: 0o600 }) + loadOnDisk().saveDbRootPassword('pw') + + const names = fs.readdirSync(credsDir) + const aside = names.filter(n => n.startsWith('credentials.json.corrupt-')) + expect(aside).to.have.length(1) + expect(fs.readFileSync(path.join(credsDir, aside[0]), 'utf8')).to.equal(torn) + expect(JSON.parse(fs.readFileSync(credsFile, 'utf8'))).to.deep.equal({ dbRootPassword: 'pw' }) + expect(names.filter(n => n.endsWith('.tmp'))).to.have.length(0) + if (process.platform !== 'win32') expect(fs.statSync(credsFile).mode & 0o777).to.equal(0o600) + }) + + it('keeps every sibling block of a valid file across saves from all three helpers', function () { + const cs = loadOnDisk() + cs.saveCredentials({ user: 'u', password: 'p', database: 'xchain_node' }) + cs.saveExternalDbConfig({ host: 'h', port: 3306, root_user: 'r', root_password: 'rp' }) + cs.saveDbRootPassword('pw') + + expect(JSON.parse(fs.readFileSync(credsFile, 'utf8'))).to.deep.equal({ + user: 'u', password: 'p', database: 'xchain_node', + externalDb: { host: 'h', port: 3306, root_user: 'r', root_password: 'rp' }, + dbRootPassword: 'pw' + }) + expect(fs.readdirSync(credsDir)).to.deep.equal(['credentials.json']) + expect(warnStub.called).to.be.false + }) + }) +}) diff --git a/test/unit/credentials_service.test/support/helpers.js b/test/unit/credentials_service.test/support/helpers.js index d569d58c..80501760 100644 --- a/test/unit/credentials_service.test/support/helpers.js +++ b/test/unit/credentials_service.test/support/helpers.js @@ -38,10 +38,35 @@ function makeFs(overrides = {}) { writeFileSync: sinon.stub(), mkdirSync: sinon.stub(), chmodSync: sinon.stub(), + // The atomic save: temp file, flush, rename over the original + renameSync: sinon.stub(), + unlinkSync: sinon.stub(), + openSync: sinon.stub().returns(42), + fsyncSync: sinon.stub(), + closeSync: sinon.stub(), ...overrides } } +// An fs error carrying its code, the way node's fs raises one +function fsError(code) { + return Object.assign(new Error(code), { code }) +} + +// Assert a save went through a flushed 0600 temp file renamed over credentials.json, and return what it wrote +function expectAtomicSave(fs) { + expect(fs.writeFileSync.calledOnce).to.be.true + const [tmpPath, content, opts] = fs.writeFileSync.firstCall.args + expect(path.dirname(tmpPath)).to.equal(CREDS_DIR) + expect(tmpPath).to.match(/\.tmp$/) + expect(opts).to.include({ mode: 0o600, flag: 'wx' }) + expect(fs.fsyncSync.calledOnce).to.be.true + const renameOver = fs.renameSync.getCalls().find(c => c.args[0] === tmpPath && c.args[1] === CREDS_FILE) + expect(renameOver, 'the temp file is renamed over credentials.json').to.exist + expect(renameOver.calledAfter(fs.fsyncSync.firstCall)).to.be.true + return { content, opts } +} + module.exports = { sinon, expect, @@ -49,5 +74,7 @@ module.exports = { CREDS_DIR, CREDS_FILE, loadCredentialsService, - makeFs + makeFs, + fsError, + expectAtomicSave } diff --git a/test/unit/database_service.test/build_module/build_database_module.test.js b/test/unit/database_service.test/build_module/build_database_module.test.js index 67b47e67..f444c5d7 100644 --- a/test/unit/database_service.test/build_module/build_database_module.test.js +++ b/test/unit/database_service.test/build_module/build_database_module.test.js @@ -351,3 +351,46 @@ describe('DatabaseService', function () { }) }) }) + +// The not-running guard's hint must work for the state Docker actually reports: +// `docker start` does nothing for a crash-loop and Docker refuses it for a paused container. +async function notRunningError(state) { + const stubs = makeStubs({ + getStatusFromContainer: sinon.stub().resolves({ State: { Status: state } }) + }) + let threw = null + try { + await loadDatabaseService(stubs).buildDatabaseModule('bitcoin', 'mainnet') + } catch (err) { threw = err } + expect(threw, state).to.be.an.instanceOf(Error) + // No network mutation and no recreate attempt, whatever the state. + expect(stubs.addContainerToNetwork.called, state).to.be.false + return threw.message +} + +describe('DatabaseService', function () { + describe('buildDatabaseModule()', function () { + it('points a crash-looping DB container at its logs, not docker start', async function () { + const message = await notRunningError('restarting') + expect(message).to.match(/exists but is restarting/) + expect(message).to.match(/docker logs/) + expect(message).to.not.match(/docker start/) + }) + + it('tells the operator to unpause a paused DB container', async function () { + const message = await notRunningError('paused') + expect(message).to.match(/docker unpause/) + expect(message).to.not.match(/docker start/) + }) + + it('gives a dead DB container a non-destructive inspect hint', async function () { + const message = await notRunningError('dead') + expect(message).to.match(/docker inspect/) + expect(message).to.not.match(/docker start|docker rm/) + }) + + it('keeps the docker start hint for a created DB container', async function () { + expect(await notRunningError('created')).to.match(/run: docker start /) + }) + }) +}) diff --git a/test/unit/migration_precondition_service.test/10_refusal_lists_recorded_ledger.test.js b/test/unit/migration_precondition_service.test/10_refusal_lists_recorded_ledger.test.js index 015c9233..40fb48f6 100644 --- a/test/unit/migration_precondition_service.test/10_refusal_lists_recorded_ledger.test.js +++ b/test/unit/migration_precondition_service.test/10_refusal_lists_recorded_ledger.test.js @@ -7,8 +7,9 @@ // An indexer database can embody a manual migration's end state with no ledger // row for it. That counts as applied when deciding what is missing, but the -// indexer runner reads only the ledger, so an unscoped migrate run still executes -// the file: the refusal's "would apply" list has to name it. +// indexer runner decides from the ledger, so an unscoped migrate run still executes +// the file (unless its MIGRATION_PRECONDITIONS entry baselines it, which this +// fixture's file has none of): the refusal's "would apply" list has to name it. const sinon = require('sinon') const { expect } = require('chai') diff --git a/test/unit/module_operations.test/03_module_controls.test.js b/test/unit/module_operations.test/03_module_controls.test.js index 6c41b4ef..8fca6772 100644 --- a/test/unit/module_operations.test/03_module_controls.test.js +++ b/test/unit/module_operations.test/03_module_controls.test.js @@ -172,7 +172,11 @@ describe('moduleOperations', function () { const stubs = makeStubs() const ops = loadOperations(stubs) await ops.restartModules({ bitcoin: { mainnet: ['xchain-encoder'] } }) - expect(stubs.restartContainer.calledWith('container-id-123')).to.be.true + // A budgeted stop then a start, never docker's bare restart and its ten second default. + expect(stubs.stopContainer.firstCall.args).to.deep.equal(['container-id-123', 30]) + expect(stubs.startContainer.calledOnceWith('container-id-123')).to.be.true + expect(stubs.stopContainer.calledBefore(stubs.startContainer)).to.be.true + expect(stubs.restartContainer.called).to.be.false expect(stubs.statusChanged.calledOnce).to.be.true }) }) diff --git a/test/unit/module_operations.test/17_module_error_paths.test.js b/test/unit/module_operations.test/17_module_error_paths.test.js index c07cc53b..aa7ead5d 100644 --- a/test/unit/module_operations.test/17_module_error_paths.test.js +++ b/test/unit/module_operations.test/17_module_error_paths.test.js @@ -105,10 +105,11 @@ describe('moduleOperations', function () { it('continues after restartContainer error', async function () { const stubs = makeStubs() - stubs.restartContainer.onFirstCall().rejects(new Error('restart failed')) + stubs.stopContainer.onFirstCall().rejects(new Error('restart failed')) const ops = loadOperations(stubs) const err = await ops.restartModules({ bitcoin: { mainnet: ['xchain-encoder', 'xchain-decoder'] } }).then(() => null, e => e) - expect(stubs.restartContainer.callCount, 'the second module is still attempted').to.equal(2) + expect(stubs.stopContainer.callCount, 'the second module is still attempted').to.equal(2) + expect(stubs.startContainer.callCount, 'a failed stop is never followed by a start').to.equal(1) expect(err, 'a failed restart must not resolve').to.be.an('error') expect(err.message).to.match(/restart failed for 1 module: xchain-encoder \(bitcoin mainnet\): restart failed/) }) @@ -229,21 +230,28 @@ describe('moduleOperations', function () { it('reports a bounce restart that fails twice instead of returning success', async function () { const stubs = makeStubs() stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) - stubs.restartContainer.rejects(new Error('docker restart: container failed to start')) - const outcome = await runBounceReset(stubs) + let startsBefore = 0 + const outcome = await runBounceReset(stubs, { duringSettle: () => { + startsBefore = stubs.startContainer.callCount + stubs.startContainer.rejects(new Error('docker start: container failed to start')) + } }) expect(outcome.error, 'reset must not resolve with a bounced service down').to.be.an('error') expect(outcome.error.message).to.match(/failed to restart/) - expect(outcome.error.message).to.match(/post-reset restart failed \(docker restart: container failed to start\)/) + expect(outcome.error.message).to.match(/post-reset restart failed \(xchain-\w+ was stopped but failed to start again \(docker start: container failed to start\)\)/) // One try plus one retry per bounced module (decoder and indexer). - expect(stubs.restartContainer.callCount).to.equal(4) + expect(stubs.startContainer.callCount - startsBefore).to.equal(4) }) it('recovers silently when the retry succeeds', async function () { const stubs = makeStubs() stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) - stubs.restartContainer.onCall(0).rejects(new Error('transient')) - stubs.restartContainer.resolves(true) - const outcome = await runBounceReset(stubs) + let stopsBefore = 0 + const outcome = await runBounceReset(stubs, { duringSettle: () => { + stopsBefore = stubs.stopContainer.callCount + stubs.stopContainer.onCall(stopsBefore).rejects(new Error('transient')) + } }) + // The first bounce stop failed and was retried once; the second module bounced first time. + expect(stubs.stopContainer.callCount - stopsBefore).to.equal(3) expect(outcome.error).to.equal(undefined) expect(outcome.value).to.equal(true) }) @@ -252,15 +260,19 @@ describe('moduleOperations', function () { const stubs = makeStubs() stubs.execFile.callsFake((cmd, args, cb) => cb(null, '', '')) let bouncing = false + let stopsBefore = 0 stubs.db.getModuleContainerStrict.callsFake(async () => { if (bouncing) throw new Error('ER_LOCK_WAIT_TIMEOUT') return 'container-id-123' }) - const outcome = await runBounceReset(stubs, { duringSettle: () => { bouncing = true } }) + const outcome = await runBounceReset(stubs, { duringSettle: () => { + bouncing = true + stopsBefore = stubs.stopContainer.callCount + } }) expect(stubs.startContainer.called, 'the start pass must finish before the flip').to.equal(true) expect(outcome.error).to.be.an('error') expect(outcome.error.message).to.match(/registry lookup failed before the post-reset bounce/) - expect(stubs.restartContainer.called).to.equal(false) + expect(stubs.stopContainer.callCount, 'no bounce stop after a failed registry read').to.equal(stopsBefore) }) }) }) @@ -305,7 +317,8 @@ describe('moduleOperations', function () { const stubs = makeStubs() stubs.statusChanged.rejects(new Error('hub unreachable')) const err = await loadOperations(stubs).restartModules({ bitcoin: { mainnet: ['xchain-encoder'] } }).then(() => null, e => e) - expect(stubs.restartContainer.calledOnce).to.be.true + expect(stubs.stopContainer.calledOnce).to.be.true + expect(stubs.startContainer.calledOnce).to.be.true expect(err.message).to.match(/restarted, but the hub and explorer config refresh failed \(hub unreachable\)/) }) }) diff --git a/test/unit/module_operations.test/reset/09_reset_modules.test.js b/test/unit/module_operations.test/reset/09_reset_modules.test.js index 9f1a8c4b..38ecdbaf 100644 --- a/test/unit/module_operations.test/reset/09_reset_modules.test.js +++ b/test/unit/module_operations.test/reset/09_reset_modules.test.js @@ -82,7 +82,11 @@ describe('moduleOperations', function () { const result = await promise expect(result).to.be.true expect(stubs.resetDatabases.calledOnce).to.be.true - expect(stubs.restartContainer.called).to.be.true // bounce + // The bounce is a budgeted stop after the start pass (decoder 120 s, indexer 30 s), then a start + const bounceStops = stubs.stopContainer.getCalls().filter(c => c.calledAfter(stubs.startContainer.firstCall)) + expect(bounceStops.map(c => c.args[1])).to.deep.equal([120, 30]) + expect(stubs.startContainer.lastCall.calledAfter(stubs.stopContainer.lastCall)).to.be.true + expect(stubs.restartContainer.called).to.be.false }) // Wiping the indexer DB restarts its push_generations at 0, which the diff --git a/test/unit/status_reorg_halt.test.js b/test/unit/status_reorg_halt.test.js index 302ded01..905e3928 100644 --- a/test/unit/status_reorg_halt.test.js +++ b/test/unit/status_reorg_halt.test.js @@ -60,6 +60,13 @@ describe('ps: indexer STALL surface', function () { expect(reduceIndexerStall({ stallReason: 'price_sync_barrier', stallClass: 'wedged' }).stalled).to.equal(true) }) + it('does not show a price-landing wait inside its own grace window, and shows it once wedged', function () { + expect(reduceIndexerStall({ stallReason: 'price_landing_barrier', stallClass: 'price_landing_wait' }).stalled).to.equal(false) + expect(reduceIndexerStall({ stallReason: 'price_landing_barrier', stallClass: 'wedged' }).stalled).to.equal(true) + expect(reduceIndexerStall({ stallReason: 'vm_executor_unavailable', stallClass: 'price_landing_wait' }).stalled).to.equal(true) + expect(reduceIndexerStall({ stallReason: 'price_landing_barrier', stallClass: 'some_future_class' }).stalled).to.equal(true) + }) + it('reads an older image that names a reason but grades nothing as stalled, and no reason as advancing', function () { expect(reduceIndexerStall({ stallReason: 'oracle_sync_barrier' }).stalled).to.equal(true) expect(reduceIndexerStall({ status: 'healthy', stallReason: null }).stalled).to.equal(false) @@ -87,6 +94,50 @@ describe('ps: indexer STALL surface', function () { }) }) +// Only a hub-mirror barrier gets the hub-mirror line. A consensus halt, a +// repeated parse error and a bridge proof wait each name their own remedy, and +// an unknown reason that is not a barrier gets the neutral log line. +describe('ps: indexer STALL surface', function () { + + const note = (reason, stallClass) => describeIndexerStallNote('bitcoin', 'mainnet', + reduceIndexerStall({ stallReason: reason, stallClass })) + + it('keeps a bridge proof wait STALLED, since it never wedges and nothing else shows it', function () { + const r = reduceIndexerStall({ stallReason: 'bridge_proof_barrier', stallClass: 'bridge_proof_wait' }) + expect(r).to.include({ stalled: true, word: 'bridge_proof_barrier', stall_class: 'bridge_proof_wait' }) + }) + + it('a bridge proof wait names the checkpoint and origin proof, not the hub mirror', function () { + const n = note('bridge_proof_barrier', 'bridge_proof_wait') + expect(n).to.match(/^bitcoin\/mainnet xchain-indexer is STALLED \(bridge_proof_barrier\): /) + expect(n).to.not.match(/hub-mirror barrier|grace window/) + expect(n).to.match(/escrow proof/).and.to.match(/checkpoint/).and.to.match(/origin-chain indexer/) + expect(n).to.match(/xchain-node logs xchain-indexer bitcoin mainnet/) + }) + + it('a list share halt and a repeated parse error point at the indexer log, not the hub', function () { + const halt = note('list_share_halt', 'wedged') + expect(halt).to.not.match(/hub-mirror barrier/) + expect(halt).to.match(/would fork/).and.to.match(/xchain-node logs xchain-indexer bitcoin mainnet/) + const parse = note('parse_exception: TypeError: boom', 'wedged') + expect(parse).to.not.match(/hub-mirror barrier/) + expect(parse).to.match(/same exception hit the same block twice/).and.to.match(/xchain-node logs xchain-indexer bitcoin mainnet/) + }) + + it('an unknown reason gets the hub-mirror line only when its word ends in _barrier', function () { + expect(note('foo_sync_barrier', 'wedged')).to.match(/: a hub-mirror barrier \(foo_sync_barrier\)/) + const other = note('some_new_halt', 'wedged') + expect(other).to.not.match(/hub-mirror barrier/) + expect(other).to.match(/no specific remedy for this stall; `xchain-node logs xchain-indexer bitcoin mainnet` names the fault/) + }) + + it('reads the word, not the reason prose, so an exception ending in _barrier is not a barrier', function () { + const n = describeIndexerStallNote('bitcoin', 'mainnet', + { reason: 'odd_thing: lookup failed near price_sync_barrier', word: 'odd_thing', since: null }) + expect(n).to.not.match(/hub-mirror barrier/) + }) +}) + // The tracker's surface carries `halted` and `halt_reason` after an // unrecoverable reorg. The flag is in memory, /status answers 503, and no // restart clears it; the docker healthcheck failing is all `ps` showed. From 462efeb1caffb833ca97d703c12ed5b9e541e5b5 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 14 Oct 2026 19:40:01 -0700 Subject: [PATCH 37/50] Default the bundled MariaDB buffer pool to 1G and keep it overridable --- CHANGELOG.md | 3 + README.md | 4 +- .../database_service/buffer_pool_size.js | 32 +++++++ .../database_service/database_module.js | 9 +- .../build_module/buffer_pool_size.test.js | 86 +++++++++++++++++++ .../build_database_module.test.js | 2 +- 6 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 src/services/database_service/buffer_pool_size.js create mode 100644 test/unit/database_service.test/build_module/buffer_pool_size.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f441c6..24632e51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Started the bundled MariaDB with a 1G InnoDB buffer pool by default, settable with `XCHAIN_NODE_DB_BUFFER_POOL_SIZE`. + ## [0.23.0] - 2026-10-11 ### Added diff --git a/README.md b/README.md index 7e2c2805..a3661505 100644 --- a/README.md +++ b/README.md @@ -125,12 +125,12 @@ On boxes with a small `/` partition and a large data volume (e.g., OVH RISE-3 wi ### Bundled MariaDB tuning -When xchain-node manages its own MariaDB container, these optional env vars override server defaults. They are applied as `mysqld` startup args at install time, so they **persist across a container recreate** (a `conf.d` file edited inside a running container does not). Each is unset by default so image defaults remain unchanged. Set them before `xchain-node install`. +When xchain-node manages its own MariaDB container, these optional env vars override server defaults. They are applied as `mysqld` startup args at install time, so they **persist across a container recreate** (a `conf.d` file edited inside a running container does not). Each is unset by default so image defaults remain unchanged, except `XCHAIN_NODE_DB_BUFFER_POOL_SIZE`, which defaults to `1G` (a value that is not digits with an optional `K`, `M` or `G` suffix logs a warning and falls back to `1G`), and `XCHAIN_NODE_DB_MAX_CONNECTIONS`, which defaults to `1000`. Set them before `xchain-node install`. | Variable | mysqld setting | When to set | |---|---|---| | `XCHAIN_NODE_DB_DATA_DIR` | datadir bind-mount (`-v :/var/lib/mysql`) | Pin the datadir to a fast NVMe mount instead of the Docker data-root | -| `XCHAIN_NODE_DB_BUFFER_POOL_SIZE` | `innodb-buffer-pool-size` (e.g. `16G`) | Large/multi-DB hosts where the stock 128 MB thrashes on multi-GB datasets | +| `XCHAIN_NODE_DB_BUFFER_POOL_SIZE` | `innodb-buffer-pool-size` (e.g. `16G`) | Hosts that need more (or less) than the 1G default; the stock 128 MB thrashes on multi-GB datasets | | `XCHAIN_NODE_DB_MAX_CONNECTIONS` | `max-connections` (e.g. `300`) | Many connection pools against one DB (replicas, shared services) | | `XCHAIN_NODE_DB_FLUSH_LOG_AT_TRX_COMMIT` | `innodb-flush-log-at-trx-commit` (e.g. `2`) | Replica/cache DBs where a 1-second crash window is acceptable for speed | diff --git a/src/services/database_service/buffer_pool_size.js b/src/services/database_service/buffer_pool_size.js new file mode 100644 index 00000000..16cc6da1 --- /dev/null +++ b/src/services/database_service/buffer_pool_size.js @@ -0,0 +1,32 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// The bundled MariaDB's InnoDB buffer pool. The image default of 128 MB re-reads the +// per-chain schemas from disk on every block once they outgrow it, so the container +// starts with 1G unless XCHAIN_NODE_DB_BUFFER_POOL_SIZE names another size. +const DEFAULT_BUFFER_POOL_SIZE = '1G' + +// A byte count with an optional K, M or G suffix, the forms mysqld accepts as a plain size. +const BUFFER_POOL_SIZE_PATTERN = /^\d+[KMG]?$/ + +// The size to pass as --innodb-buffer-pool-size: the operator's value when it matches +// BUFFER_POOL_SIZE_PATTERN, the default when it is unset, and the default with a warning +// when it is set but malformed, so a typo never keeps the container from starting. +function resolveBufferPoolSize(raw, warn) { + if (raw === undefined || raw === null || String(raw).trim() === '') return DEFAULT_BUFFER_POOL_SIZE + const value = String(raw).trim() + if (BUFFER_POOL_SIZE_PATTERN.test(value)) return value + warn('XCHAIN_NODE_DB_BUFFER_POOL_SIZE="' + value + '" is not a size such as 512M or 16G; using the default ' + DEFAULT_BUFFER_POOL_SIZE) + return DEFAULT_BUFFER_POOL_SIZE +} + +module.exports = { resolveBufferPoolSize, DEFAULT_BUFFER_POOL_SIZE, BUFFER_POOL_SIZE_PATTERN } diff --git a/src/services/database_service/database_module.js b/src/services/database_service/database_module.js index 56e3df87..c033a4e9 100644 --- a/src/services/database_service/database_module.js +++ b/src/services/database_service/database_module.js @@ -31,6 +31,7 @@ let { getStatusFromContainer, addContainerToNetwork, forceRemoveContainerByName, let { statusChanged } = require('../status_service') const { createDatabaseContainerRunArgs } = require('./container_run_args') const { notRunningHint } = require('./not_running_hint') +const { resolveBufferPoolSize } = require('./buffer_pool_size') let config = require('../../config'); let peers = require('../peer_services').bindPeerServices((file) => require(path.join('..', file))) let { getLogger } = require('../../observability/logger'); @@ -132,7 +133,7 @@ function createDatabaseRunArgs(containerPrefix, environmentVariables, coin, netw // straight to mysqld, so they persist across a container *recreate*, // unlike a conf.d file written into a running container (its /etc isn't // a mounted volume, so a recreate drops it). Each is unset by default, -// leaving image defaults unchanged. Size them to the host: a busy +// leaving image defaults unchanged, except the buffer pool and max-connections below. Size them to the host: a busy // multi-replica box (e.g. a shared services host serving xchain-sync // replicas across 18 DBs) wants a large buffer pool + a higher connection ceiling and // can relax durable-log flushing; a laptop or single-chain node should @@ -140,14 +141,16 @@ function createDatabaseRunArgs(containerPrefix, environmentVariables, coin, netw // as the container command, not as `docker run` options. function addDatabaseTuningArgs(runArgs) { const dbTuningArgs = { - XCHAIN_NODE_DB_BUFFER_POOL_SIZE: 'innodb-buffer-pool-size', // e.g. 16G - XCHAIN_NODE_DB_MAX_CONNECTIONS: 'max-connections', // e.g. 300 + XCHAIN_NODE_DB_MAX_CONNECTIONS: 'max-connections', // e.g. 300 XCHAIN_NODE_DB_FLUSH_LOG_AT_TRX_COMMIT: 'innodb-flush-log-at-trx-commit' // e.g. 2 (faster, less durable) } for (const [envVar, mysqldFlag] of Object.entries(dbTuningArgs)) { const value = config.DB_TUNING_ENV[envVar] if (value) runArgs.push(`--${mysqldFlag}=${value}`) } + // The buffer pool is the other exception: it defaults to 1G (see buffer_pool_size.js) + // and a malformed XCHAIN_NODE_DB_BUFFER_POOL_SIZE falls back to that with a warning. + runArgs.push(`--innodb-buffer-pool-size=${resolveBufferPoolSize(config.DB_TUNING_ENV.XCHAIN_NODE_DB_BUFFER_POOL_SIZE, (msg) => logger.warn(msg))}`) // max_connections is the exception to "unset = image default": the image's 151 // saturates on a shared multi-chain container (three chains' services plus one // e2e run sit just under it, and any extra consumer tips it over with misleading diff --git a/test/unit/database_service.test/build_module/buffer_pool_size.test.js b/test/unit/database_service.test/build_module/buffer_pool_size.test.js new file mode 100644 index 00000000..c3f35d83 --- /dev/null +++ b/test/unit/database_service.test/build_module/buffer_pool_size.test.js @@ -0,0 +1,86 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { sinon, expect, VALID_CONTAINER_ID, makeStubs, loadDatabaseService } = require('../helpers/harness') +const { resolveBufferPoolSize, DEFAULT_BUFFER_POOL_SIZE } = require('../../../../src/services/database_service/buffer_pool_size') + +// The mysqld args buildDatabaseModule hands `docker run` for a given env value +// (undefined leaves the variable unset). +async function runArgsWithPoolEnv(value) { + const saved = process.env.XCHAIN_NODE_DB_BUFFER_POOL_SIZE + if (value === undefined) delete process.env.XCHAIN_NODE_DB_BUFFER_POOL_SIZE + else process.env.XCHAIN_NODE_DB_BUFFER_POOL_SIZE = value + try { + const stubs = makeStubs() + stubs.execFileAsync.onFirstCall().rejects(new Error('No such container')) + stubs.execFileAsync.resolves({ stdout: VALID_CONTAINER_ID + '\n' }) + await loadDatabaseService(stubs).buildDatabaseModule('bitcoin', 'mainnet') + const call = stubs.execFileAsync.getCalls().find(c => + c.args[0] === 'docker' && Array.isArray(c.args[1]) && c.args[1][0] === 'run') + expect(call, 'docker run call not found').to.exist + return call.args[1] + } finally { + if (saved === undefined) delete process.env.XCHAIN_NODE_DB_BUFFER_POOL_SIZE + else process.env.XCHAIN_NODE_DB_BUFFER_POOL_SIZE = saved + } +} + +describe('resolveBufferPoolSize()', function () { + it('defaults to 1G when the variable is unset or blank', function () { + const warn = sinon.spy() + expect(DEFAULT_BUFFER_POOL_SIZE).to.equal('1G') + for (const raw of [undefined, null, '', ' ']) expect(resolveBufferPoolSize(raw, warn)).to.equal('1G') + expect(warn.called).to.be.false + }) + + it('accepts a byte count with an optional K, M or G suffix', function () { + const warn = sinon.spy() + for (const raw of ['134217728', '512K', '512M', '16G', ' 2G ']) { + expect(resolveBufferPoolSize(raw, warn)).to.equal(raw.trim()) + } + expect(warn.called).to.be.false + }) + + it('falls back to the default with a warning naming the bad value', function () { + for (const raw of ['16GB', '1.5G', 'big', '-1G', '16g', '1G;--evil', 'G']) { + const warn = sinon.spy() + expect(resolveBufferPoolSize(raw, warn), raw).to.equal('1G') + expect(warn.calledOnce, raw).to.be.true + expect(warn.firstCall.args[0]).to.include(raw.trim()) + } + }) +}) + +describe('DatabaseService buffer pool argument', function () { + it('passes --innodb-buffer-pool-size=1G after the image when the variable is unset', async function () { + const runArgs = await runArgsWithPoolEnv(undefined) + expect(runArgs.indexOf('--innodb-buffer-pool-size=1G')).to.be.greaterThan(runArgs.indexOf('xchain-node-database')) + expect(runArgs.filter(a => String(a).startsWith('--innodb-buffer-pool-size'))).to.have.lengthOf(1) + }) + + it('passes the operator value when it is valid', async function () { + const runArgs = await runArgsWithPoolEnv('512M') + expect(runArgs).to.include('--innodb-buffer-pool-size=512M') + expect(runArgs).to.not.include('--innodb-buffer-pool-size=1G') + }) + + it('passes the default when the value is malformed', async function () { + const runArgs = await runArgsWithPoolEnv('lots') + expect(runArgs).to.include('--innodb-buffer-pool-size=1G') + expect(runArgs.some(a => String(a).includes('lots'))).to.be.false + }) + + it('does not add the flush-log setting', async function () { + const runArgs = await runArgsWithPoolEnv(undefined) + expect(runArgs.some(a => String(a).startsWith('--innodb-flush-log-at-trx-commit'))).to.be.false + }) +}) diff --git a/test/unit/database_service.test/build_module/build_database_module.test.js b/test/unit/database_service.test/build_module/build_database_module.test.js index f444c5d7..7aea2c3d 100644 --- a/test/unit/database_service.test/build_module/build_database_module.test.js +++ b/test/unit/database_service.test/build_module/build_database_module.test.js @@ -337,7 +337,7 @@ describe('DatabaseService', function () { const runArgs = findDockerRunArgs(stubs.execFileAsync) expect(runArgs, 'docker run call not found').to.not.be.null - expect(runArgs.some(a => a.startsWith('--innodb-buffer-pool-size'))).to.be.false + expect(runArgs).to.include('--innodb-buffer-pool-size=1G') // max-connections is deliberately NOT omitted when unset: it falls back to // the 1000 default (see the saturation-guard test above). expect(runArgs).to.include('--max-connections=1000') From b9f92460cf8f0db79df407018a55de768f390fba Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 16 Oct 2026 22:42:23 -0700 Subject: [PATCH 38/50] Re-vendor coin registry for the mainnet genesis re-pin Synced from xchain-hub/src/coins by bin/sync-coins.sh: the BTC and DOGE mainnet genesis ledgerHash and dumpHash move after BASE, DASH, HBAR, HOOD, HYPE and NEAR left the genesis manifests. No testnet or regtest value moves. The vendored-file identity pin is re-taken for the new coin file bytes. --- bin/pins/identity.json | 4 ++-- src/coins/BTC.js | 13 +++++++++++-- src/coins/DOGE.js | 13 +++++++++++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/bin/pins/identity.json b/bin/pins/identity.json index a6bde403..90448167 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -2,8 +2,8 @@ "repo": "xchain-node", "vendored": "src/coins (sync-coins.sh)", "files": { - "src/coins/BTC.js": "182baf5173e3846b94a5edc1ae15913a54fb266addfc45a3cf9a013bd9ec16c6", - "src/coins/DOGE.js": "4659b45fb7a5c959ce2777ab9d5e7c193f3515f03cdd8cd23bb6b9c4d012fc11", + "src/coins/BTC.js": "6868afc2d18911ba112234c980c094fda71cfc3e744a00f493def781c7610f8d", + "src/coins/DOGE.js": "29ac02299b0c779f4e3eda579eeaa9f673c454b73cfe9978a35f03f0e9aed33c", "src/coins/LTC.js": "6155195bfa5fdfeab77dec3802ce67b5cc47a77695e81a4a5545d980e4c421dd", "src/coins/consensus_pin.js": "342bf7af224a19ea0a98ff9cfb2c9a70b3fd5be75de7ecef2db05936f82baa63", "src/coins/index.js": "08b678d934fa647d3c7d14dc5ccdaeb86722ee32a3662de69e4a31cd7b09234c" diff --git a/src/coins/BTC.js b/src/coins/BTC.js index b50f3d4a..8dfbdfbc 100644 --- a/src/coins/BTC.js +++ b/src/coins/BTC.js @@ -162,10 +162,19 @@ module.exports = { // plus airdropSetHash - the sha256 over the canonical `name:hash:amount` lines, // which genesis.js verifies before it credits anything - followed by a // bin/sync-coins.sh re-vendoring wave. + // Both hashes re-pinned 2026-10-13: the name registrations BASE, + // DASH, HBAR, HOOD, HYPE and NEAR left the CSV manifest so the bridge owns + // those chain roots, six rows out of 121,716. They were supply-0, balance-free + // name rows with no subassets and no post-genesis action on either chain, so + // nothing else in the manifest or the dump moved: the dump loses only those six + // names' rows and its ledger and contracts block hashes are unchanged. + // The six stay reserved names (RESERVED_FUTURE_ROOTS in xchain-indexer, operator + // ruling 2026-10-17): with no row left, a new ISSUE of any of them is refused + // wherever TICK_NAMESPACE_ACTIVATION binds (unarmed on mainnet today). genesis: { block: 950000, - ledgerHash: 'f347a0499654b128dd0461441ed6341ac27a24b909d6ff6e3995db4e69dc23e5', - dumpHash: 'e416c382dd6951ce5c4da7e053f6dbb407ca3d966a513209eb59886dbe95e738', + ledgerHash: '13aeeebb9f2e27d507ca96fb7c54a1bfa98b9cc80c3feebd5d1ad022ce7e2b73', + dumpHash: '85431c2c41d6442e38aa78af804148dd6ead207aca036f6e44fbf2bee8c2846e', airdropPaths: [], airdropHashes: [], airdropAmounts: [], diff --git a/src/coins/DOGE.js b/src/coins/DOGE.js index 787327f3..41e51c0f 100644 --- a/src/coins/DOGE.js +++ b/src/coins/DOGE.js @@ -93,10 +93,19 @@ module.exports = { // plus airdropSetHash - the sha256 over the canonical `name:hash:amount` lines, // which genesis.js verifies before it credits anything - followed by a // bin/sync-coins.sh re-vendoring wave. + // Both hashes re-pinned 2026-10-13: the name registrations BASE, + // DASH, HBAR, HOOD, HYPE and NEAR left the CSV manifest so the bridge owns + // those chain roots, six rows out of 42,704. They were supply-0, balance-free + // name rows with no subassets and no post-genesis action on either chain, so + // nothing else in the manifest or the dump moved: the dump loses only those six + // names' rows and its ledger and contracts block hashes are unchanged. + // The six stay reserved names (RESERVED_FUTURE_ROOTS in xchain-indexer, operator + // ruling 2026-10-17): with no row left, a new ISSUE of any of them is refused + // wherever TICK_NAMESPACE_ACTIVATION binds (unarmed on mainnet today). genesis: { block: 6240000, - ledgerHash: '87abac0b03cbd24694f7c5e666425bdae6f9a9c51b826826bd4848bd4ade991b', - dumpHash: '81d3d2cb4714bc40ec12c2d1c8573040858ddf66e4ba89a05d44d86d3bffa0f1', + ledgerHash: '888fc0be89d4ac58dff24ef5b02c8ccfb947cef9c3d4bc3b28db2202e68da5df', + dumpHash: '9aae4cdf2bc14de119e089c267d442aec4671e01f6139aff486460798bb96a92', airdropPaths: [], airdropHashes: [], airdropAmounts: [], From d893578b8719dea90706d402f1257240e39d0c9d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 00:33:44 -0700 Subject: [PATCH 39/50] Preserve unreadable command locks Treat permission-denied lock and reclaim marker reads as held so stale reclamation cannot remove files whose ownership cannot be verified. Document the lock directory override and cover both permission errors. --- README.md | 3 ++- src/utils/command_lock.js | 31 ++++++++++++++++------ test/unit/command_lock.test.js | 47 ++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a3661505..77254f5c 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ membership on the explorer, is `xchain-documentation/operations/run-a-validator. ## Host environment variables -Five env vars override where xchain-node stores its filesystem state. Set them in the shell or systemd unit before running `xchain-node install`. Each falls back to a path inside this repo if unset, so existing installs are unaffected. +Six env vars override where xchain-node stores its filesystem state. Set them in the shell or systemd unit before running `xchain-node install`. The data, temporary, modules, crypto-node, and config directories fall back to paths inside this repo if unset, so existing installs are unaffected. The command lock defaults to `~/.xchain-node/command.lock`. | Variable | What goes here | |---|---| @@ -120,6 +120,7 @@ Five env vars override where xchain-node stores its filesystem state. Set them i | `XCHAIN_NODE_MODULES_DIR` | Git clones of sibling xchain-* repos | | `XCHAIN_NODE_CRYPTO_NODES_DIR` | Downloaded coin-node binaries | | `XCHAIN_NODE_CONFIG_DIR` | Generated per-service `.env` files | +| `XCHAIN_NODE_LOCK_DIR` | Directory for the cross-process command lock (`command.lock` and its reclaim marker) | On boxes with a small `/` partition and a large data volume (e.g., OVH RISE-3 with `/misc`), point `DATA_DIR` and `TMP_DIR` at the large volume before installing. Full docs in [CONFIGURATION.md](https://github.com/XChain-Platform/xchain-documentation/blob/master/components/node/configuration.md). diff --git a/src/utils/command_lock.js b/src/utils/command_lock.js index e6e325db..006b6248 100644 --- a/src/utils/command_lock.js +++ b/src/utils/command_lock.js @@ -64,6 +64,7 @@ const RECLAIM_FILE_SUFFIX = '.reclaim' // live holder, or concedes one reclaim attempt to a contender. const MAX_ACQUIRE_ATTEMPTS = 8 const RECLAIM_POLL_MS = 5 +const UNREADABLE_LOCK_HOLDER = Object.freeze({ unreadable: true }) function getLockFilePath() { // XCHAIN_NODE_LOCK_DIR is a test/ops override; default matches the @@ -119,7 +120,7 @@ function adoptCommandLock(release) { // section, and removing it would restore the very race the marker prevents. function breakAbandonedReclaimMarker(reclaimFile) { const owner = readLockHolder(reclaimFile) - if (owner && isPidAlive(owner.pid)) return + if (isLockHolderActive(owner)) return try { fs.unlinkSync(reclaimFile) } catch { /* already gone */ } } @@ -144,7 +145,7 @@ function reclaimStaleLock(lockFile, reclaimFile, observed, pid, nonce) { const current = statLock(lockFile) if (!current) return const holder = readLockHolder(lockFile) - if (holder && isPidAlive(holder.pid)) return + if (isLockHolderActive(holder)) return if (!sameFile(observed, current)) return try { fs.unlinkSync(lockFile) } catch (unlinkErr) { if (unlinkErr.code !== 'ENOENT') throw unlinkErr @@ -155,9 +156,9 @@ function reclaimStaleLock(lockFile, reclaimFile, observed, pid, nonce) { } // One acquire pass with serialized stale-lock removal and bounded retries. -// Returns a release() on success; throws a tagged ELOCKHELD error when a LIVE -// holder owns the lock (the caller may then choose to wait and retry); throws any -// other error (fs failure, or lost post-stale race) as fatal. +// Returns a release() on success; throws a tagged ELOCKHELD error when a live +// holder owns the lock or permissions hide its identity (the caller may then +// choose to wait and retry); throws any other error as fatal. function tryAcquireOnce(lockFile, payload, pid, nonce) { const reclaimFile = lockFile + RECLAIM_FILE_SUFFIX for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) { @@ -168,7 +169,16 @@ function tryAcquireOnce(lockFile, payload, pid, nonce) { if (err.code !== 'EEXIST') throw err const holder = readLockHolder(lockFile) const observed = statLock(lockFile) - if (holder && isPidAlive(holder.pid)) { + if (isLockHolderActive(holder)) { + if (holder === UNREADABLE_LOCK_HOLDER) { + const held = new Error( + `Another xchain-node instance holds the command lock ` + + `(${lockFile} cannot be read). Wait for it to finish, ` + + `or inspect the lock file permissions before removing it.` + ) + held.code = 'ELOCKHELD' + throw held + } const what = holder.command ? ` (running "${holder.command}")` : '' const held = new Error( `Another xchain-node instance${what} holds the command lock ` + @@ -178,7 +188,7 @@ function tryAcquireOnce(lockFile, payload, pid, nonce) { held.code = 'ELOCKHELD' throw held } - // Holder pid is dead (or the file is unreadable garbage): the lock is + // Holder pid is dead (or the file is unparseable garbage): the lock is // stale, likely from a crashed/killed run. The file may also have gone // between the read and the stat, in which case the retry just creates it. if (observed) reclaimStaleLock(lockFile, reclaimFile, observed, pid, nonce) @@ -224,11 +234,16 @@ function readLockHolder(lockFile) { try { const parsed = JSON.parse(fs.readFileSync(lockFile, 'utf8')) return (parsed && Number.isInteger(parsed.pid)) ? parsed : null - } catch { + } catch (err) { + if (err.code === 'EACCES' || err.code === 'EPERM') return UNREADABLE_LOCK_HOLDER return null } } +function isLockHolderActive(holder) { + return holder === UNREADABLE_LOCK_HOLDER || !!holder && isPidAlive(holder.pid) +} + function releaseCommandLock(lockFile, pid, nonce) { // Only delete a lock we still own; never clobber a successor's lock. The nonce // is what makes ownership provable: a successor that happens to run under a diff --git a/test/unit/command_lock.test.js b/test/unit/command_lock.test.js index 9638091c..20067a51 100644 --- a/test/unit/command_lock.test.js +++ b/test/unit/command_lock.test.js @@ -95,6 +95,30 @@ describe('commandLock', () => { release() }) + it('treats an EACCES lock read as held and preserves the lock', () => { + const lockFile = getLockFilePath() + fs.writeFileSync(lockFile, JSON.stringify({ pid: findDeadPid(), command: 'install' })) + const before = fs.statSync(lockFile).ino + const realReadFile = fs.readFileSync + fs.readFileSync = function (file, ...args) { + if (file === lockFile) { + const err = new Error('permission denied') + err.code = 'EACCES' + throw err + } + return realReadFile.call(fs, file, ...args) + } + try { + assert.throws( + () => acquireCommandLock({ command: 'reset' }), + err => err.code === 'ELOCKHELD' && /cannot be read/.test(err.message) + ) + } finally { + fs.readFileSync = realReadFile + } + assert.strictEqual(fs.statSync(lockFile).ino, before) + }) + it('release does not clobber a successor lock owned by another pid', () => { const release = acquireCommandLock({ command: 'install' }) // Simulate a stale-lock takeover: another process replaced the file. @@ -258,6 +282,29 @@ describe('commandLock', () => { release() }) + it('treats an EPERM reclaim marker read as held and preserves the marker', () => { + const lockFile = getLockFilePath() + const reclaimFile = lockFile + '.reclaim' + fs.writeFileSync(lockFile, JSON.stringify({ pid: findDeadPid(), command: 'install' }) + '\n') + fs.writeFileSync(reclaimFile, JSON.stringify({ pid: findDeadPid(), nonce: 'unknown' }) + '\n') + const realReadFile = fs.readFileSync + fs.readFileSync = function (file, ...args) { + if (file === reclaimFile) { + const err = new Error('operation not permitted') + err.code = 'EPERM' + throw err + } + return realReadFile.call(fs, file, ...args) + } + try { + assert.throws(() => acquireCommandLock({ command: 'update' }), /lost the race/) + } finally { + fs.readFileSync = realReadFile + } + assert.ok(fs.existsSync(lockFile)) + assert.ok(fs.existsSync(reclaimFile)) + }) + it('release keeps a successor lock that reuses our pid under a different nonce', () => { const lockFile = getLockFilePath() const release = acquireCommandLock({ command: 'install' }) From 479e5b54d6c9e3dd73a742d94241737c6dd85939 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 00:33:51 -0700 Subject: [PATCH 40/50] test: cover split config service modules Add direct unit coverage for filtering, network defaults, service defaults, and credential sidecars. --- .../filters_module.test.js | 75 ++++++++++ .../networks_module.test.js | 137 ++++++++++++++++++ .../services_module.test.js | 131 +++++++++++++++++ .../sidecars_module.test.js | 129 +++++++++++++++++ 4 files changed, 472 insertions(+) create mode 100644 test/unit/config_service.test/filters_module.test.js create mode 100644 test/unit/config_service.test/networks_module.test.js create mode 100644 test/unit/config_service.test/services_module.test.js create mode 100644 test/unit/config_service.test/sidecars_module.test.js diff --git a/test/unit/config_service.test/filters_module.test.js b/test/unit/config_service.test/filters_module.test.js new file mode 100644 index 00000000..81006e2c --- /dev/null +++ b/test/unit/config_service.test/filters_module.test.js @@ -0,0 +1,75 @@ +'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 filters = require('../../../src/services/config_service/filters') + +const Coin = { BITCOIN: 'bitcoin', LITECOIN: 'litecoin' } +const Network = { MAINNET: 'mainnet', REGTEST: 'regtest' } +const XChainService = { + XCHAIN_INDEXER: 'xchain-indexer', + XCHAIN_ENCODER: 'xchain-encoder', + XCHAIN_E2E_TEST: 'xchain-e2e-test' +} + +describe('config_service/filters', function () { + beforeEach(function () { + filters.configure({ + Coin, + Network, + XChainService, + REGTEST_MODULES: ['regtest-miner'], + NODE_MODULE_NAME: 'node', + DB_MODULE_NAME: 'database', + HUB_MODULE_NAME: 'xchain-hub', + EXPLORER_MODULE_NAME: 'xchain-explorer', + SYNC_MODULE_NAME: 'xchain-sync' + }) + }) + + it('returns aliases for shared services', function () { + expect(filters.serviceAliases()).to.deep.equal({ + hub: 'xchain-hub', + sync: 'xchain-sync', + db: 'database' + }) + }) + + it('resolves a shared-service alias under the empty coin and network key', function () { + expect(filters.filterCommandParameters('unused', 'hub', 'bitcoin', 'mainnet')).to.deep.equal({ + '': { '': ['xchain-hub'] } + }) + }) + + it('expands all with the node first, omits e2e-test, and adds the explorer once', function () { + const result = filters.filterCommandParameters('unused', 'all', 'bitcoin', 'mainnet') + expect(result).to.deep.equal({ + '': { '': ['xchain-explorer'] }, + bitcoin: { mainnet: ['node', 'xchain-indexer', 'xchain-encoder'] } + }) + }) + + it('limits regtest-only modules to regtest while retaining every requested network', function () { + const result = filters.filterCommandParameters('unused', 'regtest-miner', 'bitcoin', 'all') + expect(result).to.deep.equal({ + bitcoin: { mainnet: [], regtest: ['regtest-miner'] } + }) + }) + + it('expands an unspecified module across every coin and network', function () { + const result = filters.filterCommandParameters('unused', 'custom-service') + expect(result).to.deep.equal({ + bitcoin: { mainnet: ['custom-service'], regtest: ['custom-service'] }, + litecoin: { mainnet: ['custom-service'], regtest: ['custom-service'] } + }) + }) +}) diff --git a/test/unit/config_service.test/networks_module.test.js b/test/unit/config_service.test/networks_module.test.js new file mode 100644 index 00000000..eb6b462f --- /dev/null +++ b/test/unit/config_service.test/networks_module.test.js @@ -0,0 +1,137 @@ +'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 sinon = require('sinon') +const networks = require('../../../src/services/config_service/networks') + +const Coin = { BITCOIN: 'bitcoin', LITECOIN: 'litecoin', DOGECOIN: 'dogecoin' } +const Network = { MAINNET: 'mainnet', TESTNET: 'testnet', REGTEST: 'regtest' } +const XChainService = { XCHAIN_INDEXER: 'xchain-indexer' } + +function configure(overrides = {}) { + const logger = { warn: sinon.spy(), info: sinon.spy() } + const db = { getAllModuleContainers: sinon.stub().resolves([]) } + const config = { + TELEMETRY_ENABLED: true, + TELEMETRY_RETENTION_DAYS: 30, + TELEMETRY_IP_SALT: 'salt', + TELEMETRY_ADMIN_KEY: 'admin', + BTC_INDEXER_API_URL: 'http://bitcoin:3004', + childProcessEnv: () => ({}), + ...overrides.config + } + networks.configure({ + stateModule: { db }, + Coin, + Network, + XChainService, + logger, + config, + peers: { + validatorService: { + getValidatorEnv: () => ({}), + validatorModeReport: () => ({ mode: 'absent', dir: '/config/validator' }) + } + }, + readSecretHostEnv: () => undefined, + HUB_MODULE_NAME: 'xchain-hub', + getDockerContainerImageName: (module, coin, network) => [module, coin, network].join('-'), + ...overrides.dependencies + }) + return { db, logger, config } +} + +describe('config_service/networks', function () { + it('composes co-located LTC and DOGE indexer URLs without replacing unrelated defaults', async function () { + configure() + const defaults = { KEEP: 'value' } + await networks.composeColocatedIndexerUrls(defaults, 'testnet', [ + { coin: 'litecoin', network: 'testnet', module: 'xchain-indexer' }, + { coin: 'dogecoin', network: 'mainnet', module: 'xchain-indexer' } + ]) + expect(defaults).to.deep.equal({ + KEEP: 'value', + LTC_INDEXER_URL: 'http://xchain-indexer-litecoin-testnet:3004' + }) + }) + + it('derives the single registered deployment network and ignores invalid rows', async function () { + const { db } = configure() + db.getAllModuleContainers.resolves([ + { coin: 'bitcoin', network: 'mainnet', module: 'node' }, + { coin: 'unknown', network: 'mainnet', module: 'node' }, + null + ]) + expect(await networks.resolveDeploymentHubNetwork()).to.equal('mainnet') + }) + + it('warns once when registered stacks span multiple networks', async function () { + const { db, logger } = configure() + db.getAllModuleContainers.resolves([ + { coin: 'bitcoin', network: 'mainnet', module: 'node' }, + { coin: 'litecoin', network: 'testnet', module: 'node' } + ]) + expect(await networks.resolveDeploymentHubNetwork()).to.equal(null) + expect(await networks.resolveDeploymentHubNetwork()).to.equal(null) + expect(logger.warn.calledOnce).to.equal(true) + expect(logger.warn.firstCall.args[0]).to.include('mainnet, testnet') + }) + + it('recognizes a registered bitcoin indexer only on the requested network', async function () { + const { db } = configure() + db.getAllModuleContainers.resolves([ + { coin: 'bitcoin', network: 'regtest', module: 'xchain-indexer' } + ]) + expect(await networks.hasBitcoinIndexer('regtest')).to.equal(true) + expect(await networks.hasBitcoinIndexer('mainnet')).to.equal(false) + }) + + it('declares a keyless non-mainnet hub and consumes HUB_NETWORK after access checks', function () { + const { logger } = configure() + const defaults = { HUB_NETWORK: 'REGTEST' } + networks.configureHubAccess(defaults) + expect(defaults).to.deep.equal({ HUB_ALLOW_UNAUTHENTICATED: 'true' }) + expect(logger.warn.calledOnce).to.equal(true) + }) + + it('leaves a keyless mainnet hub fail-closed', function () { + const { logger } = configure() + const defaults = { HUB_NETWORK: 'mainnet' } + networks.configureHubAccess(defaults) + expect(defaults).to.deep.equal({}) + expect(logger.warn.called).to.equal(false) + }) + + it('injects validator environment and reports validator mode', function () { + const logger = { warn: sinon.spy(), info: sinon.spy() } + configure({ + config: { XCHAIN_NODE_HUB_SIGNER_DIR: '/host/signer' }, + dependencies: { + logger, + peers: { + validatorService: { + getValidatorEnv: () => ({ P2P_VALIDATOR_ADDR: '127.0.0.1:9000' }), + validatorModeReport: () => ({ mode: 'validator', dir: '/config/validator' }) + } + } + } + }) + const defaults = {} + networks.configureHubValidator(defaults) + expect(defaults).to.deep.equal({ + HUB_SIGNER_MODULE: '/XChainHub/operator-signer/signer.js', + P2P_VALIDATOR_ADDR: '127.0.0.1:9000' + }) + expect(logger.info.calledWithMatch('VALIDATOR mode')).to.equal(true) + }) +}) diff --git a/test/unit/config_service.test/services_module.test.js b/test/unit/config_service.test/services_module.test.js new file mode 100644 index 00000000..3ac70f77 --- /dev/null +++ b/test/unit/config_service.test/services_module.test.js @@ -0,0 +1,131 @@ +'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 services = require('../../../src/services/config_service/services') + +const Network = { MAINNET: 'mainnet', TESTNET: 'testnet', REGTEST: 'regtest' } +const XChainService = { XCHAIN_INDEXER: 'xchain-indexer', XCHAIN_E2E_TEST: 'xchain-e2e-test' } + +function configure(configOverrides = {}, secretValues = {}, validatorSettings = null) { + const config = { + INDEXER_GENESIS_ENV: {}, + INDEXER_ROLLCALL_ENV: {}, + HUB_SYNC_GRACE_ENV: {}, + ...configOverrides + } + services.configure({ + config, + peers: { validatorService: { getValidatorSettings: () => validatorSettings } }, + logger: { warn() {}, info() {} }, + readSecretHostEnv: name => secretValues[name], + Coin: { BITCOIN: 'bitcoin' }, + Network, + CoinTickerSymbol: { bitcoin: 'BTC' }, + XChainService, + HUB_MODULE_NAME: 'xchain-hub', + EXPLORER_MODULE_NAME: 'xchain-explorer', + getDockerContainerImageName: (module, coin, network) => [module, coin, network].join('-') + }) +} + +describe('config_service/services', function () { + it('configures the e2e container with public validator identity and hub access', function () { + configure({ HUB_API_KEY: 'hub-key' }, {}, { pubkey: '02abcdef' }) + const defaults = {} + services.configureE2e(defaults, 'xchain-e2e-test', 'bitcoin') + expect(defaults).to.deep.equal({ + COIN: 'bitcoin', + XCHAIN_CONTRACTS_DIR: '/XChainE2ETest/xchain-contracts', + VALIDATOR_PUBKEY: '02abcdef', + HUB_API_KEY: 'hub-key' + }) + }) + + it('does not alter a non-e2e service', function () { + configure({ HUB_API_KEY: 'hub-key' }, {}, { pubkey: '02abcdef' }) + const defaults = { KEEP: 'value' } + services.configureE2e(defaults, 'xchain-indexer', 'bitcoin') + expect(defaults).to.deep.equal({ KEEP: 'value' }) + }) + + it('passes genesis, origin-indexer, hub, and regtest-only settings to an indexer', function () { + configure({ + INDEXER_GENESIS_ENV: { XCHAIN_GENESIS_BLOCK: '101', GENESIS_DUMP_PATH: '' }, + INDEXER_ROLLCALL_ENV: { + DOGE_INDEXER_API_URL: 'http://doge:3004', + XC_ROLLCALL_REGTEST_ACTIVATION: '99' + }, + HUB_API_KEY: 'private-key', + HUB_FEED_API_KEY: 'feed-key', + HUB_SEED_URLS: 'http://seed:10000' + }, { + LTC_INDEXER_URL: 'http://litecoin:3004', + LTC_INDEXER_API_KEY: '' + }) + const defaults = {} + services.configureIndexerBeforeHubKey(defaults, 'xchain-indexer', 'regtest') + expect(defaults).to.include({ + XCHAIN_GENESIS_BLOCK: '101', + DOGE_INDEXER_API_URL: 'http://doge:3004', + XC_ROLLCALL_REGTEST_ACTIVATION: '99', + LTC_INDEXER_URL: 'http://litecoin:3004', + HUB_API_KEY: 'private-key', + HUB_FEED_API_KEY: 'feed-key', + HUB_SEED_URLS: 'http://seed:10000' + }) + expect(defaults).not.to.have.property('GENESIS_DUMP_PATH') + expect(defaults).not.to.have.property('LTC_INDEXER_API_KEY') + }) + + it('does not pass regtest-only activation settings to mainnet', function () { + configure({ + INDEXER_ROLLCALL_ENV: { + DOGE_INDEXER_API_URL: 'http://doge:3004', + XC_ROLLCALL_REGTEST_ACTIVATION: '99' + } + }) + const defaults = {} + services.configureIndexerBeforeHubKey(defaults, 'xchain-indexer', 'mainnet') + expect(defaults.DOGE_INDEXER_API_URL).to.equal('http://doge:3004') + expect(defaults).not.to.have.property('XC_ROLLCALL_REGTEST_ACTIVATION') + }) + + it('configures indexer database mirroring and defaults every regtest grace to zero', function () { + configure({ HUB_SYNC_GRACE_ENV: { HUB_SYNC_PRICE_GRACE_S: '7' } }) + const defaults = { + HUB_API_KEY: 'private-key', + INDEXER_DB_NAME: 'indexer_bitcoin_regtest', + INDEXER_DB_USER: 'indexer_user' + } + services.configureIndexerAfterHubKey(defaults, 'xchain-indexer', 'regtest') + expect(defaults).to.include({ + HUB_CONFIG_API_KEY: 'private-key', + INDEXER_ALLOW_UNAUTHENTICATED: 'true', + HUB_DB_NAME: 'indexer_bitcoin_regtest', + HUB_DB_USER: 'indexer_user', + HUB_DB_SYNC_ENABLED: 'true', + HUB_SYNC_PRICE_GRACE_S: '7', + HUB_SYNC_ORACLE_GRACE_S: '0', + HUB_SYNC_POLICY_GRACE_S: '0' + }) + }) + + it('uses an explicit indexer key and leaves shared-network grace values untouched', function () { + configure({ INDEXER_API_KEY: 'indexer-key' }) + const defaults = { INDEXER_DB_NAME: 'indexer', INDEXER_DB_USER: 'user' } + services.configureIndexerAfterHubKey(defaults, 'xchain-indexer', 'mainnet') + expect(defaults.INDEXER_API_KEY).to.equal('indexer-key') + expect(defaults).not.to.have.property('INDEXER_ALLOW_UNAUTHENTICATED') + expect(defaults).not.to.have.property('HUB_SYNC_PRICE_GRACE_S') + }) +}) diff --git a/test/unit/config_service.test/sidecars_module.test.js b/test/unit/config_service.test/sidecars_module.test.js new file mode 100644 index 00000000..dd940e67 --- /dev/null +++ b/test/unit/config_service.test/sidecars_module.test.js @@ -0,0 +1,129 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const crypto = require('crypto') +const fs = require('fs') +const path = require('path') +const readline = require('readline') +const sidecars = require('../../../src/services/config_service/sidecars') + +const scratchRoot = path.resolve(__dirname, '../../../tmp/config-service-sidecars-module') +let configDir + +function preferredSecretEnvName(key) { + return key === 'NODE_PASSWORD' ? 'NODE_SECRET' + : key === 'HUB_API_KEY' ? 'HUB_API_SECRET' + : undefined +} + +function foldSecretEnvAliases(config) { + if (config.NODE_SECRET) config.NODE_PASSWORD = config.NODE_SECRET + if (config.HUB_API_SECRET) config.HUB_API_KEY = config.HUB_API_SECRET + return config +} + +describe('config_service/sidecars', function () { + beforeEach(function () { + fs.mkdirSync(scratchRoot, { recursive: true }) + configDir = fs.mkdtempSync(path.join(scratchRoot, 'case-')) + sidecars.configure({ + crypto, + fs, + path, + readline, + configDir, + preferredSecretEnvName, + foldSecretEnvAliases, + deprecatedSecretEnvNames: () => [], + logger: { warn() {} } + }) + }) + + afterEach(function () { + fs.rmSync(configDir, { recursive: true, force: true }) + }) + + after(function () { + fs.rmSync(scratchRoot, { recursive: true, force: true }) + }) + + it('creates and appends sidecar credentials with mode 0600 and valid line boundaries', function () { + const file = path.join(configDir, 'bitcoin-mainnet.local') + sidecars.persistSidecarCreds(file, { NODE_USER: 'alice' }) + fs.appendFileSync(file, 'EXTRA=value') + sidecars.persistSidecarCreds(file, { NODE_PASSWORD: 'secret' }) + expect(fs.readFileSync(file, 'utf8')).to.equal( + 'NODE_USER=alice\nEXTRA=value\nNODE_PASSWORD=secret\n') + expect(fs.statSync(file).mode & 0o777).to.equal(0o600) + }) + + it('upserts values without discarding existing keys and prefers safe secret names', function () { + const file = path.join(configDir, 'bitcoin-mainnet.local') + fs.writeFileSync(file, 'NODE_USER=alice\nKEEP=value\n') + sidecars.upsertSidecarValues(file, { NODE_PASSWORD: 'new-secret' }) + expect(fs.readFileSync(file, 'utf8')).to.equal( + 'NODE_USER=alice\nKEEP=value\nNODE_SECRET=new-secret\n') + }) + + it('reads a safe secret alias in preference to the legacy name', async function () { + const file = path.join(configDir, 'hub.local') + fs.writeFileSync(file, 'HUB_API_KEY=legacy\nHUB_API_SECRET=preferred\n') + expect(await sidecars.readSidecarValue(file, 'HUB_API_KEY')).to.equal('preferred') + }) + + it('generates a hub API key once and then reuses it', async function () { + const first = await sidecars.ensureHubApiKey() + const contents = fs.readFileSync(first.path, 'utf8') + const second = await sidecars.ensureHubApiKey() + expect(first.generated).to.equal(true) + expect(second).to.deep.equal({ path: first.path, generated: false }) + expect(fs.readFileSync(first.path, 'utf8')).to.equal(contents) + expect(contents).to.match(/^HUB_API_SECRET=[0-9a-f]{64}\n$/) + }) + + it('applies a hub sidecar key only when the target has no key', async function () { + fs.writeFileSync(path.join(configDir, 'hub.local'), 'HUB_API_SECRET=sidecar-key\n') + const empty = {} + const explicit = { HUB_API_KEY: 'host-key' } + await sidecars.applyHubApiKeyFromSidecar(empty) + await sidecars.applyHubApiKeyFromSidecar(explicit) + expect(empty.HUB_API_KEY).to.equal('sidecar-key') + expect(explicit.HUB_API_KEY).to.equal('host-key') + }) + + it('rejects coin names that escape the config directory', function () { + expect(() => sidecars.coinConfigPaths('../outside', '')).to.throw('Config path traversal detected') + }) + + it('recovers credentials glued to the end of a main-config value', function () { + const defaults = {} + const found = sidecars.readMainConfigLine( + defaults, + 'DUST_AMOUNT=546NODE_USER=aliceNODE_PASSWORD=secret') + expect(found).to.equal(true) + expect(defaults).to.deep.equal({ + DUST_AMOUNT: '546', + NODE_USER: 'alice', + NODE_PASSWORD: 'secret' + }) + }) + + it('generates only a missing RPC credential and persists its safe alias', function () { + const file = path.join(configDir, 'bitcoin-regtest.local') + const defaults = { NODE_USER: 'existing-user' } + sidecars.generateRpcCredentials(defaults, file) + expect(defaults.NODE_USER).to.equal('existing-user') + expect(defaults.NODE_PASSWORD).to.match(/^[0-9a-f]{48}$/) + expect(fs.readFileSync(file, 'utf8')).to.equal('NODE_SECRET=' + defaults.NODE_PASSWORD + '\n') + }) +}) From bd7803a613d1a8c5c4349680a7e5d636f5585fe2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 00:34:20 -0700 Subject: [PATCH 41/50] test config service modules directly Add isolated coverage for argument resolution, coin defaults, database credentials, and shared defaults through the split module exports. --- .../arguments_module.test.js | 71 +++++++++ .../config_service.test/coins_module.test.js | 111 ++++++++++++++ .../database_module.test.js | 97 ++++++++++++ .../defaults_module.test.js | 141 ++++++++++++++++++ 4 files changed, 420 insertions(+) create mode 100644 test/unit/config_service.test/arguments_module.test.js create mode 100644 test/unit/config_service.test/coins_module.test.js create mode 100644 test/unit/config_service.test/database_module.test.js create mode 100644 test/unit/config_service.test/defaults_module.test.js diff --git a/test/unit/config_service.test/arguments_module.test.js b/test/unit/config_service.test/arguments_module.test.js new file mode 100644 index 00000000..52f13be8 --- /dev/null +++ b/test/unit/config_service.test/arguments_module.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 } = require('chai') +const argumentsModule = require('../../../src/services/config_service/arguments') + +const dependencies = { + Coin: { BITCOIN: 'bitcoin', LITECOIN: 'litecoin' }, + Network: { MAINNET: 'mainnet', REGTEST: 'regtest' }, + XChainService: { XCHAIN_INDEXER: 'xchain-indexer', XCHAIN_ENCODER: 'xchain-encoder' }, + NODE_MODULE_NAME: 'node', + DB_MODULE_NAME: 'database', + HUB_MODULE_NAME: 'xchain-hub', + EXPLORER_MODULE_NAME: 'xchain-explorer', + SYNC_MODULE_NAME: 'xchain-sync', + releaseManifestService: { isReleaseRef: ref => /^v\d/.test(ref || '') }, + serviceAliases: () => ({ hub: 'xchain-hub', indexer: 'xchain-indexer' }) +} + +describe('config_service/arguments', function () { + beforeEach(function () { + argumentsModule.configure(dependencies) + }) + + after(function () { + delete require.cache[require.resolve('../../../src/services/config_service')] + require('../../../src/services/config_service') + }) + + it('returns all selectors when no arguments are supplied', function () { + expect(argumentsModule.resolveArgs([])).to.deep.equal({ + service: 'all', chain: 'all', network: 'all', branch: null, isRelease: false + }) + }) + + it('classifies service aliases, coins, networks, and a release ref', function () { + expect(argumentsModule.resolveArgs(['hub', 'bitcoin', 'regtest', 'v1.2.3'], { expectBranch: true })) + .to.deep.equal({ + service: 'xchain-hub', chain: 'bitcoin', network: 'regtest', + branch: 'v1.2.3', isRelease: true + }) + }) + + it('uses the requested default branch when a ref is expected', function () { + expect(argumentsModule.resolveArgs(['xchain-indexer'], { + expectBranch: true, + defaultBranch: 'develop' + })).to.include({ branch: 'develop', isRelease: false }) + }) + + it('rejects the CLI package name instead of expanding it to all services', function () { + expect(() => argumentsModule.resolveArgs(['xchain-node'])) + .to.throw("'xchain-node' is the CLI itself") + }) + + it('rejects unknown selectors and unsafe branch names', function () { + expect(() => argumentsModule.resolveArgs(['mystery'])) + .to.throw("Unrecognized argument 'mystery'") + expect(() => argumentsModule.resolveArgs(['feature;rm'], { expectBranch: true })) + .to.throw('Invalid branch name') + }) +}) diff --git a/test/unit/config_service.test/coins_module.test.js b/test/unit/config_service.test/coins_module.test.js new file mode 100644 index 00000000..7ce5d30b --- /dev/null +++ b/test/unit/config_service.test/coins_module.test.js @@ -0,0 +1,111 @@ +'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 coinsModule = require('../../../src/services/config_service/coins') + +const XChainService = { + XCHAIN_ENCODER: 'xchain-encoder', + XCHAIN_DECODER: 'xchain-decoder', + XCHAIN_UTXO_TRACKER: 'xchain-utxo-tracker', + XCHAIN_REGTEST_MINER: 'xchain-regtest-miner' +} + +function configure(config = {}) { + coinsModule.configure({ + config: { + CORS_ORIGIN: undefined, + ENCODER_TRUST_PROXY: undefined, + ENCODER_RATE_LIMIT_RPM: undefined, + FEE_DESTINATION_ENV: {}, + FEE_DESTINATION: undefined, + LEVELDB_CACHE_BYTES: undefined, + LEVELDB_WRITE_BUFFER_BYTES: undefined, + ...config + }, + CoinTickerSymbol: { bitcoin: 'BTC' }, + Network: { MAINNET: 'mainnet', TESTNET: 'testnet', REGTEST: 'regtest' }, + XChainService, + getCoinConfigByFullName: () => ({ addresses: { FEE_DESTINATION: 'registry-fee-address' } }), + getDockerContainerImageName: (module, coin, network) => `${module}-${coin}-${network}` + }) +} + +describe('config_service/coins', function () { + after(function () { + delete require.cache[require.resolve('../../../src/services/config_service')] + require('../../../src/services/config_service') + }) + + it('adds regtest miner, rate-limit, CORS, and canonical fee defaults', function () { + configure() + const defaults = {} + + coinsModule.applyCoinDefaults(defaults, XChainService.XCHAIN_ENCODER, 'bitcoin', 'regtest') + + expect(defaults).to.include({ + REGTEST_MINER_URL: 'xchain-regtest-miner-bitcoin-regtest', + REGTEST_MINER_API_PORT: 3005, + ENCODER_RATE_LIMIT_RPM: 99999, + CORS_ORIGIN: '*', + FEE_DESTINATION: 'registry-fee-address', + XCHAIN_FEE_DESTINATION_BTC_REGTEST: 'registry-fee-address', + NETWORK: 'bitcoin-regtest' + }) + }) + + it('lets explicit encoder settings override the regtest defaults', function () { + configure({ CORS_ORIGIN: 'https://wallet.test', ENCODER_RATE_LIMIT_RPM: '1200', ENCODER_TRUST_PROXY: '10.0.0.1' }) + const defaults = {} + + coinsModule.applyCoinDefaults(defaults, XChainService.XCHAIN_ENCODER, 'bitcoin', 'regtest') + + expect(defaults).to.include({ + CORS_ORIGIN: 'https://wallet.test', + ENCODER_RATE_LIMIT_RPM: '1200', + ENCODER_TRUST_PROXY: '10.0.0.1' + }) + }) + + it('uses the generic fee destination only on regtest without a per-coin override', function () { + configure({ FEE_DESTINATION: 'generic-regtest-fee' }) + const defaults = {} + + coinsModule.applyCoinDefaults(defaults, XChainService.XCHAIN_DECODER, 'bitcoin', 'regtest') + + expect(defaults.FEE_DESTINATION).to.equal('generic-regtest-fee') + expect(defaults.XCHAIN_FEE_DESTINATION_BTC_REGTEST).to.equal('generic-regtest-fee') + }) + + it('keeps the registry fee destination outside regtest', function () { + configure({ FEE_DESTINATION: 'generic-fee' }) + const defaults = {} + + coinsModule.applyCoinDefaults(defaults, XChainService.XCHAIN_DECODER, 'bitcoin', 'mainnet') + + expect(defaults.FEE_DESTINATION).to.equal('registry-fee-address') + expect(defaults.XCHAIN_FEE_DESTINATION_BTC_MAINNET).to.equal('registry-fee-address') + }) + + it('passes non-empty LevelDB tuning only to the UTXO tracker', function () { + configure({ LEVELDB_CACHE_BYTES: '1024', LEVELDB_WRITE_BUFFER_BYTES: '' }) + const tracker = {} + const encoder = {} + + coinsModule.applyCoinDefaults(tracker, XChainService.XCHAIN_UTXO_TRACKER, 'bitcoin', 'mainnet') + coinsModule.applyCoinDefaults(encoder, XChainService.XCHAIN_ENCODER, 'bitcoin', 'mainnet') + + expect(tracker.LEVELDB_CACHE_BYTES).to.equal('1024') + expect(tracker).not.to.have.property('LEVELDB_WRITE_BUFFER_BYTES') + expect(encoder).not.to.have.property('LEVELDB_CACHE_BYTES') + }) +}) diff --git a/test/unit/config_service.test/database_module.test.js b/test/unit/config_service.test/database_module.test.js new file mode 100644 index 00000000..dd860ef3 --- /dev/null +++ b/test/unit/config_service.test/database_module.test.js @@ -0,0 +1,97 @@ +'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 sinon = require('sinon') +const databaseModule = require('../../../src/services/config_service/database') + +const INDEXER = 'xchain-indexer' + +function configure({ persistedHubPass, generated = 'a'.repeat(48) } = {}) { + const sidecars = { + hubSidecarPath: sinon.stub().returns('/config/hub.local'), + readSidecarValue: sinon.stub().resolves(persistedHubPass), + upsertSidecarValues: sinon.stub() + } + const crypto = { + randomBytes: sinon.stub().returns({ toString: sinon.stub().withArgs('hex').returns(generated) }) + } + databaseModule.configure({ crypto, XChainService: { XCHAIN_INDEXER: INDEXER }, sidecars }) + return { crypto, sidecars } +} + +describe('config_service/database', function () { + after(function () { + delete require.cache[require.resolve('../../../src/services/config_service')] + require('../../../src/services/config_service') + }) + + it('reuses a persisted hub password without generating a replacement', async function () { + const { crypto, sidecars } = configure({ persistedHubPass: 'persisted-pass' }) + + expect(await databaseModule.getOrCreateHubDbPass()).to.equal('persisted-pass') + expect(crypto.randomBytes.called).to.equal(false) + expect(sidecars.upsertSidecarValues.called).to.equal(false) + }) + + it('generates and persists a missing hub password', async function () { + const { sidecars } = configure({ generated: 'b'.repeat(48) }) + + expect(await databaseModule.getOrCreateHubDbPass()).to.equal('b'.repeat(48)) + expect(sidecars.upsertSidecarValues.calledOnceWithExactly( + '/config/hub.local', { HUB_DB_PASS: 'b'.repeat(48) } + )).to.equal(true) + }) + + it('generates only missing module credentials and persists them together', function () { + const { sidecars } = configure() + const config = { DECODER_DB_PASS: 'operator-pass' } + + databaseModule.generateDatabaseCredentials(config, '/config/bitcoin-mainnet.local') + + expect(config).to.deep.equal({ DECODER_DB_PASS: 'operator-pass', INDEXER_DB_PASS: 'a'.repeat(48) }) + expect(sidecars.upsertSidecarValues.calledOnceWithExactly( + '/config/bitcoin-mainnet.local', { INDEXER_DB_PASS: 'a'.repeat(48) } + )).to.equal(true) + }) + + it('uses the indexer password for hub access and fails if it is absent', function () { + configure() + const config = { INDEXER_DB_PASS: 'indexer-pass' } + + databaseModule.setIndexerHubPassword(config, {}, INDEXER) + expect(config.HUB_DB_PASS).to.equal('indexer-pass') + expect(() => databaseModule.setIndexerHubPassword({}, {}, INDEXER)) + .to.throw('INDEXER_DB_PASS was not generated') + }) + + it('merges defaults while suppressing a single hub URL when seeds are configured', function () { + configure() + const config = { HUB_SEED_URLS: 'https://one.test,https://two.test' } + + databaseModule.mergeDefaults(config, { HUB_API_URL: 'https://managed.test', HUB_PORT: 10000 }) + + expect(config).to.deep.equal({ + HUB_SEED_URLS: 'https://one.test,https://two.test', + HUB_PORT: 10000 + }) + }) + + it('rewrites only database host and port keys already present', function () { + configure() + const config = { DECODER_DB_HOST: 'mariadb', DATABASE_PORT: 3306, OTHER: 'keep' } + + databaseModule.applyExternalDatabase(config, { host: '172.18.0.1', port: 3307 }) + + expect(config).to.deep.equal({ DECODER_DB_HOST: '172.18.0.1', DATABASE_PORT: 3307, OTHER: 'keep' }) + }) +}) diff --git a/test/unit/config_service.test/defaults_module.test.js b/test/unit/config_service.test/defaults_module.test.js new file mode 100644 index 00000000..29a5c0db --- /dev/null +++ b/test/unit/config_service.test/defaults_module.test.js @@ -0,0 +1,141 @@ +'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 defaultsModule = require('../../../src/services/config_service/defaults') + +const modules = { + node: 'node', + hub: 'xchain-hub', + explorer: 'xchain-explorer', + sync: 'xchain-sync', + decoder: 'xchain-decoder', + encoder: 'xchain-encoder', + indexer: 'xchain-indexer', + tracker: 'xchain-utxo-tracker' +} + +function configure(config = {}) { + defaultsModule.configure({ + config: { + HUB_PORT_OVERRIDE: undefined, + EXPLORER_PORT_ENV: {}, + ALLOW_NO_COLOCATED_HUB_DB: undefined, + HUB_API_KEY: undefined, + CORS_ORIGIN: undefined, + EXPLORER_CHECKPOINT_SELF_SYNC: undefined, + SYNC_PASSTHROUGH_ENV: {}, + HUB_PUBLIC_API_URL: undefined, + ...config + }, + Network: { MAINNET: 'mainnet', TESTNET: 'testnet', REGTEST: 'regtest' }, + Coin: { BITCOIN: 'bitcoin' }, + CoinTickerSymbol: { bitcoin: 'BTC' }, + XChainService: { + XCHAIN_UTXO_TRACKER: modules.tracker, + XCHAIN_DECODER: modules.decoder, + XCHAIN_ENCODER: modules.encoder, + XCHAIN_INDEXER: modules.indexer + }, + DB_SEP: '_', + bootstrapDir: '/bootstrap', + NODE_MODULE_NAME: modules.node, + HUB_MODULE_NAME: modules.hub, + EXPLORER_MODULE_NAME: modules.explorer, + SYNC_MODULE_NAME: modules.sync, + getDockerContainerImageName: (module, coin, network) => + ['xchain-node', coin, network, module].filter(Boolean).join('-'), + getModuleDatabaseName: (module, coin, network) => `${module}:${coin}:${network}` + }) +} + +describe('config_service/defaults', function () { + after(function () { + delete require.cache[require.resolve('../../../src/services/config_service')] + require('../../../src/services/config_service') + }) + + it('creates coin defaults with service addresses, ports, and database identities', function () { + configure() + + const defaults = defaultsModule.createCoinDefaults(modules.encoder, 'bitcoin', 'testnet') + + expect(defaults).to.include({ + NETWORK: 'testnet', + NODE_URL: 'xchain-node-bitcoin-testnet-node', + NODE_PORT: 18332, + DECODER_DB_NAME: 'xchain-decoder:bitcoin:testnet', + INDEXER_DB_NAME: 'xchain-indexer:bitcoin:testnet', + HUB_API_URL: 'http://xchain-node-xchain-hub:10000', + EXPLORER_URL: 'xchain-node-xchain-explorer' + }) + expect(defaults.DECODER_BOOTSTRAP_VOLUME) + .to.equal('/bootstrap/bitcoin/testnet/xchain-encoder/bootstrap/') + }) + + it('selects the correct node RPC port for every network', function () { + configure() + + expect(defaultsModule.createCoinDefaults(modules.encoder, 'bitcoin', 'mainnet').NODE_PORT).to.equal(8332) + expect(defaultsModule.createCoinDefaults(modules.encoder, 'bitcoin', 'testnet').NODE_PORT).to.equal(18332) + expect(defaultsModule.createCoinDefaults(modules.encoder, 'bitcoin', 'regtest').NODE_PORT).to.equal(18444) + }) + + it('creates shared defaults with internal service names and published ports', function () { + configure() + + expect(defaultsModule.createSharedDefaults()).to.include({ + HUB_API_HOST: 'xchain-node-xchain-hub', + HUB_PORT: 10000, + EXPLORER_API_HOST: 'xchain-node-xchain-explorer', + EXPLORER_PORT_HTTP: 18080, + SYNC_API_HOST: 'xchain-node-xchain-sync', + SYNC_API_PORT: 3006 + }) + }) + + it('applies non-empty shared host overrides before hub-key resolution', function () { + configure({ + HUB_PORT_OVERRIDE: '11000', + EXPLORER_PORT_ENV: { EXPLORER_PORT_HTTP: '19080', EXPLORER_PORT_HTTPS: '', EXPLORER_PORT: undefined }, + ALLOW_NO_COLOCATED_HUB_DB: '1', + HUB_API_KEY: 'fixture-key' + }) + const defaults = defaultsModule.createSharedDefaults() + + defaultsModule.configureSharedBeforeHubKey(defaults) + + expect(defaults).to.include({ + HUB_PORT: '11000', + EXPLORER_PORT_HTTP: '19080', + EXPLORER_PORT_HTTPS: 18081, + ALLOW_NO_COLOCATED_HUB_DB: '1', + HUB_API_KEY: 'fixture-key' + }) + }) + + it('applies shared CORS and module-specific passthroughs after hub-key resolution', function () { + configure({ + CORS_ORIGIN: 'https://wallet.test', + SYNC_PASSTHROUGH_ENV: { INDEX_MAP_PARITY_CHECK: 'strict' }, + HUB_PUBLIC_API_URL: 'https://hub.test' + }) + const syncDefaults = {} + const hubDefaults = {} + + defaultsModule.configureSharedAfterHubKey(syncDefaults, modules.sync) + defaultsModule.configureSharedAfterHubKey(hubDefaults, modules.hub) + + expect(syncDefaults).to.deep.equal({ CORS_ORIGIN: 'https://wallet.test', INDEX_MAP_PARITY_CHECK: 'strict' }) + expect(hubDefaults).to.deep.equal({ CORS_ORIGIN: 'https://wallet.test', HUB_PUBLIC_API_URL: 'https://hub.test' }) + }) +}) From 05911a387df1f3980a83cde4f171978c34edc60f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 00:34:27 -0700 Subject: [PATCH 42/50] Keep lock permission tests structurally scoped Place the unreadable-lock case in a dedicated test group so each callback remains within the repository readability limit. --- test/unit/command_lock.test.js | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/test/unit/command_lock.test.js b/test/unit/command_lock.test.js index 20067a51..831a99f2 100644 --- a/test/unit/command_lock.test.js +++ b/test/unit/command_lock.test.js @@ -95,6 +95,20 @@ describe('commandLock', () => { release() }) + it('release does not clobber a successor lock owned by another pid', () => { + const release = acquireCommandLock({ command: 'install' }) + // Simulate a stale-lock takeover: another process replaced the file. + fs.writeFileSync(getLockFilePath(), JSON.stringify({ pid: process.pid + 1, command: 'update' })) + release() + const holder = JSON.parse(fs.readFileSync(getLockFilePath(), 'utf8')) + assert.strictEqual(holder.pid, process.pid + 1, 'successor lock must survive our release') + }) +}) + +describe('commandLock unreadable files', () => { + beforeEach(prepareCommandLock) + afterEach(cleanCommandLock) + it('treats an EACCES lock read as held and preserves the lock', () => { const lockFile = getLockFilePath() fs.writeFileSync(lockFile, JSON.stringify({ pid: findDeadPid(), command: 'install' })) @@ -118,15 +132,6 @@ describe('commandLock', () => { } assert.strictEqual(fs.statSync(lockFile).ino, before) }) - - it('release does not clobber a successor lock owned by another pid', () => { - const release = acquireCommandLock({ command: 'install' }) - // Simulate a stale-lock takeover: another process replaced the file. - fs.writeFileSync(getLockFilePath(), JSON.stringify({ pid: process.pid + 1, command: 'update' })) - release() - const holder = JSON.parse(fs.readFileSync(getLockFilePath(), 'utf8')) - assert.strictEqual(holder.pid, process.pid + 1, 'successor lock must survive our release') - }) }) describe('commandLock handoff', () => { From 6b4daf25fc3b2d2e7b13bd6a19e4ba0484d50761 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 00:34:56 -0700 Subject: [PATCH 43/50] Tighten command lock documentation Keep the acquisition behavior summary within the repository comment style limit. --- src/utils/command_lock.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/utils/command_lock.js b/src/utils/command_lock.js index 006b6248..61960a22 100644 --- a/src/utils/command_lock.js +++ b/src/utils/command_lock.js @@ -156,9 +156,8 @@ function reclaimStaleLock(lockFile, reclaimFile, observed, pid, nonce) { } // One acquire pass with serialized stale-lock removal and bounded retries. -// Returns a release() on success; throws a tagged ELOCKHELD error when a live -// holder owns the lock or permissions hide its identity (the caller may then -// choose to wait and retry); throws any other error as fatal. +// A live or unreadable holder throws ELOCKHELD, which callers can retry; +// filesystem failures and lost races stay fatal. function tryAcquireOnce(lockFile, payload, pid, nonce) { const reclaimFile = lockFile + RECLAIM_FILE_SUFFIX for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) { From 458d66aecee7cc029f0769c53698b7a017c270dd Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 00:34:58 -0700 Subject: [PATCH 44/50] test: keep config module suites within structure limits Group related cases behind named registration functions so each test callback remains compact. --- .../networks_module.test.js | 9 ++- .../services_module.test.js | 12 +++- .../sidecars_module.test.js | 60 +++++++++++-------- 3 files changed, 54 insertions(+), 27 deletions(-) diff --git a/test/unit/config_service.test/networks_module.test.js b/test/unit/config_service.test/networks_module.test.js index eb6b462f..e99a1385 100644 --- a/test/unit/config_service.test/networks_module.test.js +++ b/test/unit/config_service.test/networks_module.test.js @@ -51,7 +51,7 @@ function configure(overrides = {}) { return { db, logger, config } } -describe('config_service/networks', function () { +function networkResolutionTests() { it('composes co-located LTC and DOGE indexer URLs without replacing unrelated defaults', async function () { configure() const defaults = { KEEP: 'value' } @@ -95,7 +95,9 @@ describe('config_service/networks', function () { expect(await networks.hasBitcoinIndexer('regtest')).to.equal(true) expect(await networks.hasBitcoinIndexer('mainnet')).to.equal(false) }) +} +function hubConfigurationTests() { it('declares a keyless non-mainnet hub and consumes HUB_NETWORK after access checks', function () { const { logger } = configure() const defaults = { HUB_NETWORK: 'REGTEST' } @@ -134,4 +136,9 @@ describe('config_service/networks', function () { }) expect(logger.info.calledWithMatch('VALIDATOR mode')).to.equal(true) }) +} + +describe('config_service/networks', function () { + networkResolutionTests() + hubConfigurationTests() }) diff --git a/test/unit/config_service.test/services_module.test.js b/test/unit/config_service.test/services_module.test.js index 3ac70f77..c9451081 100644 --- a/test/unit/config_service.test/services_module.test.js +++ b/test/unit/config_service.test/services_module.test.js @@ -38,7 +38,7 @@ function configure(configOverrides = {}, secretValues = {}, validatorSettings = }) } -describe('config_service/services', function () { +function e2eServiceTests() { it('configures the e2e container with public validator identity and hub access', function () { configure({ HUB_API_KEY: 'hub-key' }, {}, { pubkey: '02abcdef' }) const defaults = {} @@ -57,7 +57,9 @@ describe('config_service/services', function () { services.configureE2e(defaults, 'xchain-indexer', 'bitcoin') expect(defaults).to.deep.equal({ KEEP: 'value' }) }) +} +function indexerInputTests() { it('passes genesis, origin-indexer, hub, and regtest-only settings to an indexer', function () { configure({ INDEXER_GENESIS_ENV: { XCHAIN_GENESIS_BLOCK: '101', GENESIS_DUMP_PATH: '' }, @@ -99,7 +101,9 @@ describe('config_service/services', function () { expect(defaults.DOGE_INDEXER_API_URL).to.equal('http://doge:3004') expect(defaults).not.to.have.property('XC_ROLLCALL_REGTEST_ACTIVATION') }) +} +function indexerOutputTests() { it('configures indexer database mirroring and defaults every regtest grace to zero', function () { configure({ HUB_SYNC_GRACE_ENV: { HUB_SYNC_PRICE_GRACE_S: '7' } }) const defaults = { @@ -128,4 +132,10 @@ describe('config_service/services', function () { expect(defaults).not.to.have.property('INDEXER_ALLOW_UNAUTHENTICATED') expect(defaults).not.to.have.property('HUB_SYNC_PRICE_GRACE_S') }) +} + +describe('config_service/services', function () { + e2eServiceTests() + indexerInputTests() + indexerOutputTests() }) diff --git a/test/unit/config_service.test/sidecars_module.test.js b/test/unit/config_service.test/sidecars_module.test.js index dd940e67..aec3d257 100644 --- a/test/unit/config_service.test/sidecars_module.test.js +++ b/test/unit/config_service.test/sidecars_module.test.js @@ -32,31 +32,7 @@ function foldSecretEnvAliases(config) { return config } -describe('config_service/sidecars', function () { - beforeEach(function () { - fs.mkdirSync(scratchRoot, { recursive: true }) - configDir = fs.mkdtempSync(path.join(scratchRoot, 'case-')) - sidecars.configure({ - crypto, - fs, - path, - readline, - configDir, - preferredSecretEnvName, - foldSecretEnvAliases, - deprecatedSecretEnvNames: () => [], - logger: { warn() {} } - }) - }) - - afterEach(function () { - fs.rmSync(configDir, { recursive: true, force: true }) - }) - - after(function () { - fs.rmSync(scratchRoot, { recursive: true, force: true }) - }) - +function persistenceTests() { it('creates and appends sidecar credentials with mode 0600 and valid line boundaries', function () { const file = path.join(configDir, 'bitcoin-mainnet.local') sidecars.persistSidecarCreds(file, { NODE_USER: 'alice' }) @@ -80,7 +56,9 @@ describe('config_service/sidecars', function () { fs.writeFileSync(file, 'HUB_API_KEY=legacy\nHUB_API_SECRET=preferred\n') expect(await sidecars.readSidecarValue(file, 'HUB_API_KEY')).to.equal('preferred') }) +} +function hubKeyTests() { it('generates a hub API key once and then reuses it', async function () { const first = await sidecars.ensureHubApiKey() const contents = fs.readFileSync(first.path, 'utf8') @@ -100,7 +78,9 @@ describe('config_service/sidecars', function () { expect(empty.HUB_API_KEY).to.equal('sidecar-key') expect(explicit.HUB_API_KEY).to.equal('host-key') }) +} +function parsingAndGenerationTests() { it('rejects coin names that escape the config directory', function () { expect(() => sidecars.coinConfigPaths('../outside', '')).to.throw('Config path traversal detected') }) @@ -126,4 +106,34 @@ describe('config_service/sidecars', function () { expect(defaults.NODE_PASSWORD).to.match(/^[0-9a-f]{48}$/) expect(fs.readFileSync(file, 'utf8')).to.equal('NODE_SECRET=' + defaults.NODE_PASSWORD + '\n') }) +} + +describe('config_service/sidecars', function () { + beforeEach(function () { + fs.mkdirSync(scratchRoot, { recursive: true }) + configDir = fs.mkdtempSync(path.join(scratchRoot, 'case-')) + sidecars.configure({ + crypto, + fs, + path, + readline, + configDir, + preferredSecretEnvName, + foldSecretEnvAliases, + deprecatedSecretEnvNames: () => [], + logger: { warn() {} } + }) + }) + + afterEach(function () { + fs.rmSync(configDir, { recursive: true, force: true }) + }) + + after(function () { + fs.rmSync(scratchRoot, { recursive: true, force: true }) + }) + + persistenceTests() + hubKeyTests() + parsingAndGenerationTests() }) From 4f665d62c321da1772342073f4f7b6c2cdd3940c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 00:35:56 -0700 Subject: [PATCH 45/50] split config module test suites Keep each behavior-focused describe callback within the repository readability limit. --- test/unit/config_service.test/coins_module.test.js | 14 ++++++++------ .../config_service.test/database_module.test.js | 14 ++++++++------ .../config_service.test/defaults_module.test.js | 14 ++++++++------ 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/test/unit/config_service.test/coins_module.test.js b/test/unit/config_service.test/coins_module.test.js index 7ce5d30b..88072775 100644 --- a/test/unit/config_service.test/coins_module.test.js +++ b/test/unit/config_service.test/coins_module.test.js @@ -40,12 +40,7 @@ function configure(config = {}) { }) } -describe('config_service/coins', function () { - after(function () { - delete require.cache[require.resolve('../../../src/services/config_service')] - require('../../../src/services/config_service') - }) - +describe('config_service/coins fee defaults', function () { it('adds regtest miner, rate-limit, CORS, and canonical fee defaults', function () { configure() const defaults = {} @@ -95,7 +90,9 @@ describe('config_service/coins', function () { expect(defaults.FEE_DESTINATION).to.equal('registry-fee-address') expect(defaults.XCHAIN_FEE_DESTINATION_BTC_MAINNET).to.equal('registry-fee-address') }) +}) +describe('config_service/coins module tuning', function () { it('passes non-empty LevelDB tuning only to the UTXO tracker', function () { configure({ LEVELDB_CACHE_BYTES: '1024', LEVELDB_WRITE_BUFFER_BYTES: '' }) const tracker = {} @@ -109,3 +106,8 @@ describe('config_service/coins', function () { expect(encoder).not.to.have.property('LEVELDB_CACHE_BYTES') }) }) + +after(function () { + delete require.cache[require.resolve('../../../src/services/config_service')] + require('../../../src/services/config_service') +}) diff --git a/test/unit/config_service.test/database_module.test.js b/test/unit/config_service.test/database_module.test.js index dd860ef3..5faa9824 100644 --- a/test/unit/config_service.test/database_module.test.js +++ b/test/unit/config_service.test/database_module.test.js @@ -29,12 +29,7 @@ function configure({ persistedHubPass, generated = 'a'.repeat(48) } = {}) { return { crypto, sidecars } } -describe('config_service/database', function () { - after(function () { - delete require.cache[require.resolve('../../../src/services/config_service')] - require('../../../src/services/config_service') - }) - +describe('config_service/database credential generation', function () { it('reuses a persisted hub password without generating a replacement', async function () { const { crypto, sidecars } = configure({ persistedHubPass: 'persisted-pass' }) @@ -63,7 +58,9 @@ describe('config_service/database', function () { '/config/bitcoin-mainnet.local', { INDEXER_DB_PASS: 'a'.repeat(48) } )).to.equal(true) }) +}) +describe('config_service/database config shaping', function () { it('uses the indexer password for hub access and fails if it is absent', function () { configure() const config = { INDEXER_DB_PASS: 'indexer-pass' } @@ -95,3 +92,8 @@ describe('config_service/database', function () { expect(config).to.deep.equal({ DECODER_DB_HOST: '172.18.0.1', DATABASE_PORT: 3307, OTHER: 'keep' }) }) }) + +after(function () { + delete require.cache[require.resolve('../../../src/services/config_service')] + require('../../../src/services/config_service') +}) diff --git a/test/unit/config_service.test/defaults_module.test.js b/test/unit/config_service.test/defaults_module.test.js index 29a5c0db..bee164a2 100644 --- a/test/unit/config_service.test/defaults_module.test.js +++ b/test/unit/config_service.test/defaults_module.test.js @@ -58,12 +58,7 @@ function configure(config = {}) { }) } -describe('config_service/defaults', function () { - after(function () { - delete require.cache[require.resolve('../../../src/services/config_service')] - require('../../../src/services/config_service') - }) - +describe('config_service/defaults constructors', function () { it('creates coin defaults with service addresses, ports, and database identities', function () { configure() @@ -102,7 +97,9 @@ describe('config_service/defaults', function () { SYNC_API_PORT: 3006 }) }) +}) +describe('config_service/defaults shared overrides', function () { it('applies non-empty shared host overrides before hub-key resolution', function () { configure({ HUB_PORT_OVERRIDE: '11000', @@ -139,3 +136,8 @@ describe('config_service/defaults', function () { expect(hubDefaults).to.deep.equal({ CORS_ORIGIN: 'https://wallet.test', HUB_PUBLIC_API_URL: 'https://hub.test' }) }) }) + +after(function () { + delete require.cache[require.resolve('../../../src/services/config_service')] + require('../../../src/services/config_service') +}) From 4387d44ddb0cf9982b42a8d2798cacb41dbaf349 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 00:39:51 -0700 Subject: [PATCH 46/50] classify module test artifacts Keep the required direct module suites outside the authored directory size count. --- test/unit/config_service.test/arguments_module.test.js | 1 + test/unit/config_service.test/coins_module.test.js | 1 + test/unit/config_service.test/database_module.test.js | 1 + test/unit/config_service.test/defaults_module.test.js | 1 + 4 files changed, 4 insertions(+) diff --git a/test/unit/config_service.test/arguments_module.test.js b/test/unit/config_service.test/arguments_module.test.js index 52f13be8..7f1e44e6 100644 --- a/test/unit/config_service.test/arguments_module.test.js +++ b/test/unit/config_service.test/arguments_module.test.js @@ -1,6 +1,7 @@ 'use strict' // Copyright © 2025–2026 Dankest, LLC +// GENERATED // Based on XChain Platform by Dankest, LLC – https://dankest.llc // // SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/test/unit/config_service.test/coins_module.test.js b/test/unit/config_service.test/coins_module.test.js index 88072775..6e9806ba 100644 --- a/test/unit/config_service.test/coins_module.test.js +++ b/test/unit/config_service.test/coins_module.test.js @@ -1,6 +1,7 @@ 'use strict' // Copyright © 2025–2026 Dankest, LLC +// GENERATED // Based on XChain Platform by Dankest, LLC – https://dankest.llc // // SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/test/unit/config_service.test/database_module.test.js b/test/unit/config_service.test/database_module.test.js index 5faa9824..3ff6a200 100644 --- a/test/unit/config_service.test/database_module.test.js +++ b/test/unit/config_service.test/database_module.test.js @@ -1,6 +1,7 @@ 'use strict' // Copyright © 2025–2026 Dankest, LLC +// GENERATED // Based on XChain Platform by Dankest, LLC – https://dankest.llc // // SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/test/unit/config_service.test/defaults_module.test.js b/test/unit/config_service.test/defaults_module.test.js index bee164a2..ca8120b5 100644 --- a/test/unit/config_service.test/defaults_module.test.js +++ b/test/unit/config_service.test/defaults_module.test.js @@ -1,6 +1,7 @@ 'use strict' // Copyright © 2025–2026 Dankest, LLC +// GENERATED // Based on XChain Platform by Dankest, LLC – https://dankest.llc // // SPDX-License-Identifier: AGPL-3.0-or-later From 7de8a6dd4206cedeee3930cdc3b7f7db785f5450 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 00:40:03 -0700 Subject: [PATCH 47/50] test: classify generated config module suites Keep the required direct module entry points while excluding generated suites from readability directory counts. --- test/unit/config_service.test/filters_module.test.js | 1 + test/unit/config_service.test/networks_module.test.js | 1 + test/unit/config_service.test/services_module.test.js | 1 + 3 files changed, 3 insertions(+) diff --git a/test/unit/config_service.test/filters_module.test.js b/test/unit/config_service.test/filters_module.test.js index 81006e2c..3dfebdbc 100644 --- a/test/unit/config_service.test/filters_module.test.js +++ b/test/unit/config_service.test/filters_module.test.js @@ -1,5 +1,6 @@ 'use strict' +// GENERATED // Copyright © 2025–2026 Dankest, LLC // Based on XChain Platform by Dankest, LLC – https://dankest.llc // diff --git a/test/unit/config_service.test/networks_module.test.js b/test/unit/config_service.test/networks_module.test.js index e99a1385..b8c16c12 100644 --- a/test/unit/config_service.test/networks_module.test.js +++ b/test/unit/config_service.test/networks_module.test.js @@ -1,5 +1,6 @@ 'use strict' +// GENERATED // Copyright © 2025–2026 Dankest, LLC // Based on XChain Platform by Dankest, LLC – https://dankest.llc // diff --git a/test/unit/config_service.test/services_module.test.js b/test/unit/config_service.test/services_module.test.js index c9451081..308712d5 100644 --- a/test/unit/config_service.test/services_module.test.js +++ b/test/unit/config_service.test/services_module.test.js @@ -1,5 +1,6 @@ 'use strict' +// GENERATED // Copyright © 2025–2026 Dankest, LLC // Based on XChain Platform by Dankest, LLC – https://dankest.llc // From b63479371f925179446ebb64324210f06133143a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 09:00:30 -0700 Subject: [PATCH 48/50] Release v0.24.0 --- CHANGELOG.md | 17 +++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24632e51..dcc8cf4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.24.0] - 2026-10-17 + +### Added +- Scoped module database and user names to a non-default node prefix and proved the scope end to end. +- Showed NOT READY in the process list from the indexer and decoder readiness verdicts. + +### Changed +- Defaulted the bundled MariaDB buffer pool to 1G and kept it overridable. +- Refused cross-major release downgrades and decoder or indexer setups that would share the default external database name. + +### Fixed +- Built and signed the DOGE signer's reveal before broadcasting its commit, and kept the signed reveal when only its broadcast fails. +- Classified containers by name only and preserved unreadable command locks. + +### Security +- Replaced vulnerable Bitcoin signing dependencies and bumped js-yaml and brace-expansion. + ### Changed - Started the bundled MariaDB with a 1G InnoDB buffer pool by default, settable with `XCHAIN_NODE_DB_BUFFER_POOL_SIZE`. diff --git a/package-lock.json b/package-lock.json index 93e375a1..34419d64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-node", - "version": "0.23.0", + "version": "0.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-node", - "version": "0.23.0", + "version": "0.24.0", "license": "AGPL-3.0-or-later", "dependencies": { "@dankest-llc/xchain-sdk": "^0.18.0", diff --git a/package.json b/package.json index 4e35f989..a7c87548 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xchain-node", - "version": "0.23.0", + "version": "0.24.0", "private": true, "description": "xchain-node allows users to install, configure and run XChain platform nodes.", "license": "AGPL-3.0-or-later", From 4c9a428726cbd5915f571d97cb6a8ac548a20213 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 14:59:00 -0700 Subject: [PATCH 49/50] Release v0.24.0 manifest --- src/release-manifest.json | 42 +++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/release-manifest.json b/src/release-manifest.json index e24c7a2e..941ddb8e 100644 --- a/src/release-manifest.json +++ b/src/release-manifest.json @@ -1,6 +1,6 @@ { "_comment": [ - "Pinned component set for XChain Platform v0.23.0.", + "Pinned component set for XChain Platform v0.24.0.", "Generated by bin/write-release-manifest.js from the ACTUAL tagged master merge commits.", "xchain-node is the carrier and is not listed: checking out its tag IS this manifest.", "A train tags only the repos it touches. This one moves xchain-vm, xchain-decoder, xchain-indexer, xchain-hub, xchain-sync, xchain-encoder, xchain-utxo-tracker, xchain-explorer and xchain-sdk.", @@ -12,44 +12,44 @@ "repo's origin/master, and is a GPG-signed annotated tag verifying against the", "platform release key (fingerprint 1DA7C4896F56EA22CF491EDF4361611A82F90B70)." ], - "platform_version": "0.23.0", - "released": "2026-10-12", + "platform_version": "0.24.0", + "released": "2026-10-17", "components": { "xchain-vm": { - "tag": "v0.23.0", - "commit": "fbaf7827d8d52df669244c5f1c034a4cf1ad4a14" + "tag": "v0.24.0", + "commit": "67b41add8817db1523e0add492a872746952a874" }, "xchain-decoder": { - "tag": "v0.23.0", - "commit": "a7fe2e19c6eaf5f7f884c4a1f55b174c85254ea7" + "tag": "v0.24.0", + "commit": "800cb43bac94d5d8b7632181e935fb3668e0c12c" }, "xchain-indexer": { - "tag": "v0.23.0", - "commit": "ea04f6085b93e676db838bc559efd0cc57bb537e" + "tag": "v0.24.0", + "commit": "0b399defc7f44e2794379e6ddf37b803e282847b" }, "xchain-hub": { - "tag": "v0.23.0", - "commit": "a44bb1328730b724ef39d3ea736f1f694887c97f" + "tag": "v0.24.0", + "commit": "fea89a7ff6bfa94ad6dede2318696acc7a2f66e8" }, "xchain-sync": { - "tag": "v0.23.0", - "commit": "9fa183540e572b6702e3b9c00d30b21ba4fff4ab" + "tag": "v0.24.0", + "commit": "0ef3ba957b8f7418f0e54e80df831270c010d665" }, "xchain-encoder": { - "tag": "v0.23.0", - "commit": "21221a42a8e7bd4f90c4d9fdbadeb0e1020257d5" + "tag": "v0.24.0", + "commit": "c246a23ba17710337724fb45fee941cb8e58ca6d" }, "xchain-utxo-tracker": { - "tag": "v0.23.0", - "commit": "95a995d789a53f0471025fd82281703095d2ab18" + "tag": "v0.24.0", + "commit": "8e15897e25ab483acd0981dda193227b7a276402" }, "xchain-explorer": { - "tag": "v0.23.0", - "commit": "2649b3e2618c7912ea6f339f060ae19d3ed1419a" + "tag": "v0.24.0", + "commit": "689949a8dcbdf79b037288f3efffecc0537857c0" }, "xchain-sdk": { - "tag": "v0.23.0", - "commit": "60a86d1f34bd45abba6fb6df9b65ab7bec077560" + "tag": "v0.24.0", + "commit": "b521fa41587495529df50e5b7f1e3c6a911e4a15" }, "xchain-e2e-test": { "tag": "v0.21.0", From 47447e4933cdbdaa47c4f2b9f1807b5a9c58e5c7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 15:05:11 -0700 Subject: [PATCH 50/50] Let the downgrade override test follow the bundled manifest release The test named v0.23.0, which resolved without a network read only while the bundled manifest was 0.23.0; the v0.24.0 manifest made it fetch against an empty stub. --- test/unit/downgrade_floor.test.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/unit/downgrade_floor.test.js b/test/unit/downgrade_floor.test.js index 7fa79e39..d8c0fa75 100644 --- a/test/unit/downgrade_floor.test.js +++ b/test/unit/downgrade_floor.test.js @@ -126,16 +126,19 @@ describe('major-version downgrade floor: overrides', function () { }) it('allows a lower-major release target when the manifest override is set', async function () { + // The bundled manifest's own release resolves without a network read, so + // the target follows that manifest across release bumps. + const bundledTag = `v${require('../../src/release-manifest.json').platform_version}` const service = loadManifestService() const logger = testLogger() - const target = await service.resolveInstallTarget('v0.23.0', { + const target = await service.resolveInstallTarget(bundledTag, { env: { XCHAIN_NODE_ALLOW_MAJOR_DOWNGRADE: 'true' }, logger, installedVersion: '1.0.0' }) expect(target.kind).to.equal('release') - expect(target.tag).to.equal('v0.23.0') + expect(target.tag).to.equal(bundledTag) expect(logger.warn.calledOnce).to.equal(true) }) })