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