Skip to content
Merged
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.14.0] - 2026-09-02

### Fixed
- `install <ref> xchain-hub` no longer fails with HTTP 401 on a host provisioned by the runbook: the CLI now sends the hub API key that `validator init` generated.
- The checkpoint config block ships `hub_url` beside `self_sync`, so a fresh install resolves its checkpoint peer.
- Checkpoint self-sync resolves once per push, so every installed coin gets a checkpoint block instead of only the first.
- `xchain-node rollback` prints the recovery path and exits 1 rather than hanging in the precheck.

### Activation
- This train carries a consensus change in the indexer and hub: attestation responsible-set widening activates on Bitcoin testnet at block 150780 and on regtest from genesis, and is inert on mainnet. Update every indexer and hub before that height; a node left on an older build will judge attestation responses differently once a widened one lands.

## [0.12.3] - 2026-09-01

### Fixed
Expand Down
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "xchain-node",
"version": "0.12.3",
"version": "0.14.0",
"description": "xchain-node allows users to install, configure and run XChain platform nodes.",
"license": "AGPL-3.0-or-later",
"repository": {
Expand Down
28 changes: 23 additions & 5 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ async function parseCommand() {
// operator can prepare their validator identity before any stack is up.
const parentName = actionCommand.parent && actionCommand.parent.name()
if (commandName === 'validator' || parentName === 'validator') return
// `rollback` is declared but unimplemented: its action only names the
// reset-and-restore recovery path and exits non-zero. Provisioning
// Docker/MariaDB/hub and taking the mutating lock to reach a two-line
// refusal is what made it read as a hang. Measured 2026-08-30 while
// repairing a regtest indexer: an operator reached for `rollback`
// mid-incident and waited ~10 minutes on a command that printed nothing.
// It stays listed in mutatingCommands above so that a real
// implementation, which would drop this early return, is serialized.
if (commandName === 'rollback') return

// preCheck provisions shared containers/DB/hub (buildDatabaseModule,
// ensureXchainNodeAccess, scanAndRegisterModules, installHubModule) for
Expand Down Expand Up @@ -526,16 +535,25 @@ gate could report them, so the cron exited 0 while a consumer archive went stale

program
.command('rollback')
.description('Rollback XChain service to a set block_index')
.description('NOT IMPLEMENTED - prints the reset + bootstrap restore path for recovering a service to a block_index')
.argument('<block_index>', 'The index of the last known good block')
.argument('<service>', '(xchain-decoder, xchain-utxo-tracker, xchain-indexer, all)')
.argument('<chain>', '(bitcoin, litecoin, dogecoin)')
.argument('<network>', '(mainnet, testnet, regtest)')
.action(async () => {
.action(async (blockIndex, service, chain, network) => {
// Not yet implemented. Fail loudly instead of silently doing nothing,
// so operators don't believe a rollback occurred.
console.error('`rollback` is not yet implemented. To recover a service to a known-good block, use `reset` followed by a bootstrap restore.')
process.exitCode = 1
// so operators don't believe a rollback occurred. This is reached
// during an incident, so it prints the runnable recovery path with
// the operator's own arguments already substituted in, and exits
// through process.exit(): setting process.exitCode alone left the
// process alive on whatever handles were open, which is how a
// command that had already printed its answer still looked hung.
console.error('`rollback` is not yet implemented; nothing was rolled back.')
console.error(`To recover ${service} (${chain} ${network}) to block ${blockIndex}, use reset followed by a bootstrap restore:`)
console.error(` xchain-node reset ${service} ${chain} ${network}`)
console.error(` xchain-node bootstrap restore ${service} ${chain} ${network}`)
console.error('Restore rewinds to the newest bootstrap at or before that block, then the service re-parses forward.')
return process.exit(1)
})

program
Expand Down
14 changes: 13 additions & 1 deletion src/precheck.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const { dataDir, moduleDir, tmpDir, containersFilesDir,
const { db, isVerbose } = require('./state')
const { redactSecrets } = require('./utils/helpers')
const { checkDockerInstalledAndReachable, createDockerNetwork, checkContainerdDataRootRelocation } = require('./services/DockerService')
const { getDockerNetwork } = require('./services/ConfigService')
const { getDockerNetwork, applyHubApiKeyFromSidecar } = require('./services/ConfigService')
const { checkAllRemoteVersions } = require('./services/VersionService')
const { getStatus } = require('./services/StatusService')
const { installHubModule, updateHub } = require('./services/HubService')
Expand Down Expand Up @@ -163,6 +163,18 @@ async function preCheck(checkVersions = false, syncHubConfig = true, moduleRef =
if (isVerbose()) console.log("Getting modules status")
await getStatus(null, null, false, checkVersions)

// The CLI authenticates to the hub with the SAME credential the hub was deployed
// with. `validator init` mints that key into config/hub.local, and getDefaultConfig
// reads the sidecar when it builds the hub container's env, so the hub boots keyed;
// but HubConnector only sends what is in process.env, which dotenv fills from .env
// alone. On every validator host provisioned per the runbook that left the CLI's
// own updateconfig push keyless against a keyed hub: `install xchain-hub` started
// the hub and then failed with "HTTP 401" on its config push, and every
// state-changing command after it did the same. Same precedence as the container
// env: a host-env HUB_API_KEY still wins, the sidecar only fills an empty one, and
// this never mints (a host with no sidecar stays keyless exactly as before).
await applyHubApiKeyFromSidecar(process.env)

try {
if (isVerbose()) console.log("Checking/Installing hub module")
await installHubModule(moduleRef)
Expand Down
24 changes: 13 additions & 11 deletions src/release-manifest.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
{
"_comment": [
"Pinned component set for XChain Platform v0.12.3.",
"Pinned component set for XChain Platform v0.14.0.",
"Written at ceremony step 6 from the ACTUAL tagged master merge commits.",
"xchain-node is the carrier and is not listed: checking out its tag IS this manifest.",
"A patch train tags only the repos it touches. This one moves the carrier and",
"xchain-hub; every other component is unchanged from v0.12.1 or earlier 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.",
"A train tags only the repos it touches. This one moves xchain-indexer and xchain-hub",
"(the attestation responsible-set liveness ladder, flag day BTC testnet 150780) and",
"the carrier; xchain-documentation is tagged with the train but not pinned here.",
"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.",
"Each commit below is the master MERGE commit its tag names, and every tag is",
"GPG-signed by the platform release key and reports verified against the",
"tagger identity releases@xchain.io."
],
"platform_version": "0.12.3",
"released": "2026-09-01",
"platform_version": "0.14.0",
"released": "2026-09-02",
"components": {
"xchain-vm": {
"tag": "v0.12.0",
Expand All @@ -23,12 +25,12 @@
"commit": "9f333337c1362ff2651aa8877d7a5445476b5b12"
},
"xchain-indexer": {
"tag": "v0.12.1",
"commit": "aee2bf21ecd6dc0fa90c26e7108dbedceb609bda"
"tag": "v0.14.0",
"commit": "e0d183eaf4011859b9c4f44c34112d61d43be28f"
},
"xchain-hub": {
"tag": "v0.12.3",
"commit": "bb5b0d180b544ea08b5720b9f8416256fd07641c"
"tag": "v0.14.0",
"commit": "e7f3b9728847a3f7916d6e410bc0f727e6b2b844"
},
"xchain-sync": {
"tag": "v0.12.0",
Expand Down
10 changes: 10 additions & 0 deletions src/services/ConfigService.js
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,15 @@ async function getDefaultConfig(module, coin, network) {
// startup assertion to a warning and says nothing about whether a
// local mirror should be provisioned). Emitted only when opted in, so
// a deployment that never uses self-sync carries no unused hub URL.
//
// This env is no longer the mirror writer's ONLY source: the same URL
// now ships inside the checkpoint config block beside self_sync
// (HubService.buildCheckpointConfig), because these two lived on
// different delivery paths - container env written at install time
// versus the hub's config push - and opting in after the container
// existed left the explorer self-syncing with nowhere to sync from.
// Kept for the explorer's other hub reads (HubOperationalCache) and as
// the fallback for hand-written config.json deployments.
if (process.env.EXPLORER_CHECKPOINT_SELF_SYNC !== undefined && process.env.EXPLORER_CHECKPOINT_SELF_SYNC !== "") {
defaultValues.HUB_API_URL = process.env.HUB_API_URL ||
("http://" + getDockerContainerImageName(HUB_MODULE_NAME, "", "") + ":" + defaultValues.HUB_PORT)
Expand Down Expand Up @@ -1269,6 +1278,7 @@ module.exports = {
upsertSidecarValues,
readSidecarValue,
ensureHubApiKey,
applyHubApiKeyFromSidecar,
filterCommandParameters,
resolveArgs
}
70 changes: 66 additions & 4 deletions src/services/HubService.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,20 @@ function buildHubModuleConfig(nextModule, defaultConfigCoinNetwork, ctx) {
// defaultConfigCoinNetwork fields buildHubModuleConfig('xchain-indexer', ...)
// reads above, rather than re-deriving them, to guarantee byte-identical
// values instead of two independent paths that could drift apart.
//
// hub_url ships IN this block, beside self_sync, so the pairing is structural:
// whatever condition produces self_sync produces the URL with it. Emitted by
// separate conditions, an explorer opted in after its container exists is told
// to self-sync with no hub URL to sync from, warns once at startup and then
// serves the frozen mirror indefinitely. This block reaches the explorer over
// the hub's config push; HUB_API_URL is a container env ConfigService writes at
// install/recreate time from the same host env var, and the explorer falls back
// to it for hand-written config.json deployments.
function buildCheckpointConfig(defaultConfigCoinNetwork) {
return {
hub_url: process.env.HUB_API_URL ||
("http://" + getDockerContainerImageName(HUB_MODULE_NAME, "", "") + ":" +
defaultConfigCoinNetwork.HUB_PORT),
db_host: defaultConfigCoinNetwork.INDEXER_DB_HOST,
db_port: defaultConfigCoinNetwork.INDEXER_DB_PORT,
user: defaultConfigCoinNetwork.INDEXER_DB_USER,
Expand All @@ -78,13 +90,52 @@ function buildCheckpointConfig(defaultConfigCoinNetwork) {
self_sync: true
}
}

// Is the self-synced checkpoint mirror opted in for THIS deployment?
//
// EXPLORER_CHECKPOINT_SELF_SYNC is a host env read at command time, but the
// checkpoint blocks it produces are written per coin/network into stores that are
// only ever upserted, never reconciled. So the opt-in has to outlive the shell that
// first set it, and as a bare `process.env` read it did not: a coin installed later
// from a shell that never exported the env (a second terminal, a cron-driven update,
// an operator who sourced a different env file) got NO checkpoint block, while the
// coins installed earlier kept theirs. The explorer then serves exactly one coin's
// hub-mirrored routes as a fail-loud 500 - price_snapshots, oracle_prices,
// state_checkpoints, capability_snapshots, cross_chain_matches - while every sibling
// coin answers normally, which reads as a broken query rather than the config gap it
// is (and with ALLOW_NO_COLOCATED_HUB_DB=1 the explorer boots anyway, so nothing at
// startup says so either). Measured on a regtest venue whose LTC leg 500'd on
// /RLTC/api/price_snapshots/FINALIZED/status while RBTC and RDOGE were fine.
//
// The installed explorer container's own env is the durable record of the earlier
// opt-in: ConfigService writes HUB_API_URL into the explorer ONLY inside the same
// opt-in branch, so its presence there means self-sync was chosen for this
// deployment. Reading it back makes every later push emit the block for every
// installed coin, so the gap self-heals on the next mutation instead of needing a
// hand-edit. Tolerant by design: no explorer container, or an unreadable one, is
// simply "not opted in".
async function isCheckpointSelfSyncEnabled(deps = {}) {
const env = deps.env || process.env
if (env.EXPLORER_CHECKPOINT_SELF_SYNC !== undefined && env.EXPLORER_CHECKPOINT_SELF_SYNC !== "") return true

const readEnv = deps.readContainerEnv || readContainerEnv
const containerEnv = await readEnv(getDockerContainerImageName(EXPLORER_MODULE_NAME, "", ""), deps)
if (!containerEnv) return false

return (containerEnv.EXPLORER_CHECKPOINT_SELF_SYNC !== undefined && containerEnv.EXPLORER_CHECKPOINT_SELF_SYNC !== "") ||
(containerEnv.HUB_API_URL !== undefined && containerEnv.HUB_API_URL !== "")
}
const { db, getLastStatus, isStatusUpdated, isVerbose } = require('../state')
const { sleep, redactSecrets } = require('../utils/helpers')
const { getDefaultConfig, getDockerContainerImageName, getDockerNetwork } = require('./ConfigService')
const { statusChanged, getStatus, getInstalledCoinsAndNetworks } = require('./StatusService')
const { addContainerToNetwork } = require('./DockerService')
const { cloneGit, buildAndUp } = require('./ModuleService')
const { addUserPasswordToDatabase, getExternalDbConfig } = require('./DatabaseService')
// The explorer container's env is the durable record of the checkpoint self-sync
// opt-in; this reader already exists for the DB-credential drift guard and is
// tolerant of a missing container, which is exactly the posture wanted here.
const { readContainerEnv } = require('./DbCredentialDrift')
const HubConnector = require('../HubConnector.js')

async function updateHubOrExplorer(module) {
Expand Down Expand Up @@ -118,6 +169,11 @@ async function updateHubOrExplorer(module) {
// saved at the first-run prompt would be misreported to the hub (uuid:52c5b5f1).
const externalDbCfg = EXTERNAL_DB ? await getExternalDbConfig() : null

// Resolved once per push, for the same reason: the answer is a property of the
// deployment, not of the coin/network being emitted, so every installed coin gets
// the same verdict and none is silently left without a checkpoint block.
const checkpointSelfSync = await isCheckpointSelfSyncEnabled()

if (module === "xchain-explorer") {
jsonConfig["configs"] = []
jsonConfig = jsonConfig["configs"]
Expand Down Expand Up @@ -150,9 +206,9 @@ async function updateHubOrExplorer(module) {
// Row 39: advertise a self-synced checkpoint schema for this coin/
// network once an indexer is actually installed for it (the
// checkpoint config needs the indexer's own DB host/port/user/pass)
// and the operator opted in. See buildCheckpointConfig above.
if (process.env.EXPLORER_CHECKPOINT_SELF_SYNC !== undefined && process.env.EXPLORER_CHECKPOINT_SELF_SYNC !== "" &&
XChainService.XCHAIN_INDEXER in lastStatus[nextCoin][nextNetwork]) {
// and the operator opted in (once, at any point in this deployment's
// life: see isCheckpointSelfSyncEnabled). See buildCheckpointConfig above.
if (checkpointSelfSync && XChainService.XCHAIN_INDEXER in lastStatus[nextCoin][nextNetwork]) {
const checkpointConfig = buildCheckpointConfig(defaultConfigCoinNetwork)
if (module === "xchain-explorer") {
nextConfigObject.checkpoint = checkpointConfig
Expand Down Expand Up @@ -363,5 +419,11 @@ async function installHubModule(branch = null) {
module.exports = {
updateHubOrExplorer,
updateHub,
installHubModule
installHubModule,
// Exported for the unit suite: the self_sync/hub_url pairing is the whole
// point of this block and must be pinned without booting a docker install.
buildCheckpointConfig,
// Same: the opt-in must survive a shell that never exported the env, and that
// is pinned against a stubbed container-env read rather than a live docker.
isCheckpointSelfSyncEnabled
}
Loading
Loading