From e2436aa7026b70200474a465c93282b6f5263cfc Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 9 Sep 2026 12:26:46 -0700 Subject: [PATCH 1/5] feat(health): report node reachability on every health surface The connector stamps the last successful and the last failed node RPC, and /live, /status and the health method carry node_last_ok_at plus a node_unreachable object (since, last answer, seconds) while the most recent call failed. A decoder whose coin node never answered once sat five and a half days reading healthy on every surface an operator polls; this is the field that says so. Reported only: the healthy gate is unchanged and a test pins that /live stays 200 while the node is unreachable, because a restart cannot fix an outage upstream of the service. --- CHANGELOG.md | 5 + src/BlockchainConnector.js | 54 ++++- src/api.js | 32 ++- test/unit/nodeReachabilityStatus.test.js | 280 +++++++++++++++++++++++ 4 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 test/unit/nodeReachabilityStatus.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7727eac..9cf0e0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ 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). +## [Unreleased] + +### Added +- The health and status payloads carry `node_last_ok_at` and `node_unreachable`, so a coin node that has never answered is visible. + ## [0.16.0] - 2026-09-08 ### Added diff --git a/src/BlockchainConnector.js b/src/BlockchainConnector.js index 877ef6b..604be7a 100644 --- a/src/BlockchainConnector.js +++ b/src/BlockchainConnector.js @@ -286,12 +286,48 @@ function normalizeEndpoint(entry, defaultPort) { return protocol + match[2] + ':' + port } +// Reduce the three timestamps the connector records into the two fields every health +// surface publishes. Pure and exported so the rule lives in one place: a surface that +// re-derived "is the node reachable" from a counter would disagree with this one. +// +// Unreachable means the LATEST attempt failed: either nothing has ever succeeded, or +// the last failure is newer than the last success. `since` dates the outage from the +// last success when there was one, and from connector construction when there was +// never one, which is the case the defect report describes: a decoder whose node +// answered nothing in five and a half days while every surface read green. +// +// All three inputs are ms epoch, 0 meaning "never". +function nodeReachabilityFrom(startedAt, lastNodeOkAt, lastNodeFailAt, now = Date.now()) { + const lastOkIso = lastNodeOkAt > 0 ? new Date(lastNodeOkAt).toISOString() : null + const failing = lastNodeFailAt > 0 && (lastNodeOkAt === 0 || lastNodeFailAt > lastNodeOkAt) + if (!failing) return { node_last_ok_at: lastOkIso, node_unreachable: null } + const sinceMs = lastNodeOkAt > 0 ? lastNodeOkAt : startedAt + return { + node_last_ok_at: lastOkIso, + node_unreachable: { + since: new Date(sinceMs).toISOString(), + last_ok_at: lastOkIso, + // Floor, and clamped at 0: a health probe racing the recorded instant + // must never publish a negative age. + seconds: Math.max(0, Math.floor((now - sinceMs) / 1000)) + } + } +} + class BlockchainConnector { constructor(url, port, rpcUser, rpcPassword) { this.port = port this.rpcUser = rpcUser this.rpcPassword = rpcPassword this.rpcErrors = 0 + // Node reachability, recorded at the single POST choke point below so every + // RPC path through this class feeds it. Reported, never gated on: the healthy + // verdict deliberately ignores an upstream node outage (a restart cannot fix + // one, and gating re-opens the autoheal restart flap), which is exactly why the + // outage needs a surface of its own. + this.startedAt = Date.now() + this.lastNodeOkAt = 0 + this.lastNodeFailAt = 0 // RPC endpoint failover. A dead primary endpoint used to stall the // decoder forever, because the block loop retries RPC failures // indefinitely by design (skipping a block would corrupt the index). @@ -317,6 +353,12 @@ class BlockchainConnector { return this.endpoints[this.activeEndpointIndex] } + // Node reachability as the health surfaces publish it. Cheap and never throws, + // so a probe can call it per request. + nodeReachability(now = Date.now()) { + return nodeReachabilityFrom(this.startedAt, this.lastNodeOkAt, this.lastNodeFailAt, now) + } + // Single POST path for every RPC method: resets the consecutive-failure // counter on any answer from the node, and counts connection-level errors // toward failover before re-throwing for the caller's own retry handling. @@ -329,8 +371,16 @@ class BlockchainConnector { } }) this.connectionFailures = 0 + // The node answered. A JSON-RPC error carried in a 200 body (height out of + // range, tx not found) still resolves here and still counts as reached: + // this pair reports whether the node is ANSWERING, not whether the answer + // was the one the caller wanted. rpcErrors already counts the latter. + this.lastNodeOkAt = Date.now() return response } catch (error) { + // Timeouts (ECONNABORTED), socket/DNS faults and RPC errors delivered as + // HTTP 500 all land here, and all mean this attempt got no usable answer. + this.lastNodeFailAt = Date.now() if (error && error.response) { // An HTTP-level error (auth, queue-full 500, etc.) still proves // the endpoint is reachable; only unreachability drives failover. @@ -731,4 +781,6 @@ module.exports.encodeVarintHex = encodeVarintHex module.exports.stripAuxPowFromBlockHex = stripAuxPowFromBlockHex module.exports.skipAuxPow = skipAuxPow // Exported for the env-parsing regression test. -module.exports.envInt = envInt \ No newline at end of file +module.exports.envInt = envInt +// Exported so the reachability reducer can be tested without a connector or a node. +module.exports.nodeReachabilityFrom = nodeReachabilityFrom \ No newline at end of file diff --git a/src/api.js b/src/api.js index 6d781ec..823cd8e 100644 --- a/src/api.js +++ b/src/api.js @@ -114,6 +114,26 @@ const AUX_POW = process.env.AUX_POW === 'true' || process.env.AUX_POW === '1' // outputs paying it to transaction_outputs so the indexer can validate native-coin fee payments. const FEE_DESTINATION = resolveFeeDestination(NETWORK, process.env.FEE_DESTINATION || null) +// Node reachability for the health payloads: `node_last_ok_at` (the last successful +// node RPC, null if there has never been one) and `node_unreachable` (null, or the +// outage with its age in seconds). A decoder whose node never answered a single RPC +// is otherwise indistinguishable from a healthy one on every surface an operator polls; +// these two fields are that difference, reported and never gating. +// +// Fail-soft: an absent connector, or one from a build/test stub predating the method, +// reports the unknown-but-not-failing pair rather than throwing inside a probe. +function nodeReachabilityFields(decoder){ + const connector = decoder && decoder.connector + if (!connector || typeof connector.nodeReachability !== 'function'){ + return { node_last_ok_at: null, node_unreachable: null } + } + try { + return connector.nodeReachability() + } catch (e) { + return { node_last_ok_at: null, node_unreachable: null } + } +} + // Express middleware that bounds JSON-RPC batch size. express-json-rpc-router runs // Promise.all over every element of a batch array, while the per-IP rate limiter counts // the whole batch as ONE request. Without a cap, a single ~100kb array of thousands of @@ -195,6 +215,10 @@ function registerLiveRoute(app, decoder, isDecoderRunning){ // { node_height, stored_height, since } while the parse loop is waiting out // a node in initial block download below our tip, null otherwise. node_catching_up: (decoder && decoder.nodeCatchingUp) || null, + // node_last_ok_at + node_unreachable. Same reporting-not-gating contract as + // node_height_stale below, and the only surface that separates "the node has + // never answered" from "the node is fine". + ...nodeReachabilityFields(decoder), // A frozen node tip, reported but deliberately NOT gating. isStalled() // returns false while the tip is stale on purpose: restarting the container // cannot fix an upstream node outage, and gating on it re-opens the @@ -415,6 +439,9 @@ async function startApi(){ // { node_height, stored_height, since } while the parse loop is waiting // out a node in initial block download below our tip, null otherwise. node_catching_up: (decoder && decoder.nodeCatchingUp) || null, + // node_last_ok_at + node_unreachable: whether the coin node is answering + // this decoder at all, and since when it stopped. Reported, not gated on. + ...nodeReachabilityFields(decoder), // Set once an operator cleared a halt (db.clearReorgHalt); null while a // halt is live or none was ever recorded. reorg_halt_cleared_at: reorgHalt.cleared_at || null, @@ -529,6 +556,9 @@ async function startApi(){ // { node_height, stored_height, since } while the parse loop is waiting out // a node in initial block download below our tip, null otherwise. node_catching_up: (decoder && decoder.nodeCatchingUp) || null, + // node_last_ok_at + node_unreachable: whether the coin node is answering + // this decoder at all, and since when it stopped. Reported, not gated on. + ...nodeReachabilityFields(decoder), // Ships beside the boolean, never without it. "Not halted" is only an answer // if something looked, and the probe is fail-soft: its state starts at // not-halted with checked_at null, so a decoder that has NEVER completed a @@ -567,4 +597,4 @@ if (require.main === module) startApi() // startApi is exported so the crash handlers it installs can be driven for real // rather than asserted against the source text; the require.main guard above // still keeps a plain require from opening a port or a DB connection. -module.exports = { makeRpcBatchGuard, registerLiveRoute, startApi, noteProbeFailure, _resetProbeLogState, _ageProbeLogState, PROBE_LOG_WINDOW_MS } \ No newline at end of file +module.exports = { makeRpcBatchGuard, registerLiveRoute, startApi, noteProbeFailure, nodeReachabilityFields, _resetProbeLogState, _ageProbeLogState, PROBE_LOG_WINDOW_MS } \ No newline at end of file diff --git a/test/unit/nodeReachabilityStatus.test.js b/test/unit/nodeReachabilityStatus.test.js new file mode 100644 index 0000000..6d5ce29 --- /dev/null +++ b/test/unit/nodeReachabilityStatus.test.js @@ -0,0 +1,280 @@ +/********************************************************************* + * + * Copyright © 2025-2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC - https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * A node that never answered, made visible. + * + * An operator ran a decoder whose coin node answered no RPC at all: the log + * carried "Getting timeout trying to get blockchain info, trying again..." 2099 + * times over five and a half days, the restart count stayed 0 and the container + * healthcheck read healthy throughout. Nothing on any surface said the service had + * never reached its node. + * + * The healthy VERDICT is deliberately unchanged: isStalled() reports a + * never-polled decoder as not stale on purpose, because a restart cannot fix an + * upstream outage and gating on it re-opens the autoheal restart flap. What these + * pin is the VISIBILITY: the connector records when the node last answered and + * when it last failed, nodeReachability() reduces those to node_last_ok_at and + * node_unreachable, and both ride every payload that already carries + * node_catching_up. + */ + +'use strict' + +const assert = require('assert') +const fs = require('fs') +const http = require('http') +const path = require('path') +const express = require('express') + +const BlockchainConnector = require('../../src/BlockchainConnector') +const { nodeReachabilityFrom } = BlockchainConnector +const XChainDecoder = require('../../src/XChainDecoder') +const { registerLiveRoute, nodeReachabilityFields } = require('../../src/api') + +const T0 = Date.parse('2026-09-09T12:00:00.000Z') // connector construction +const OK = Date.parse('2026-09-09T12:10:00.000Z') +const FAIL = Date.parse('2026-09-09T12:20:00.000Z') +const NOW = Date.parse('2026-09-09T13:00:00.000Z') + +const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + +describe('nodeReachabilityFrom() (the reducer both fields are derived from)', function () { + it('reports nothing wrong before any attempt has been made', function () { + const r = nodeReachabilityFrom(T0, 0, 0, NOW) + assert.deepStrictEqual(r, { node_last_ok_at: null, node_unreachable: null }) + }) + + it('reports the last success and no outage while the latest attempt succeeded', function () { + const r = nodeReachabilityFrom(T0, OK, 0, NOW) + assert.strictEqual(r.node_last_ok_at, '2026-09-09T12:10:00.000Z') + assert.strictEqual(r.node_unreachable, null) + }) + + it('dates an outage from the last success when there was one', function () { + const r = nodeReachabilityFrom(T0, OK, FAIL, NOW) + assert.deepStrictEqual(Object.keys(r.node_unreachable).sort(), + ['last_ok_at', 'seconds', 'since']) + assert.strictEqual(r.node_unreachable.since, '2026-09-09T12:10:00.000Z') + assert.strictEqual(r.node_unreachable.last_ok_at, '2026-09-09T12:10:00.000Z') + assert.strictEqual(r.node_unreachable.seconds, 3000) + assert.strictEqual(r.node_last_ok_at, '2026-09-09T12:10:00.000Z') + }) + + it('dates an outage from connector start when the node NEVER answered', function () { + // The reported defect: no success to date the outage from, so the age is the + // life of the connector, and last_ok_at stays null rather than inventing one. + const r = nodeReachabilityFrom(T0, 0, FAIL, NOW) + assert.strictEqual(r.node_last_ok_at, null) + assert.strictEqual(r.node_unreachable.since, '2026-09-09T12:00:00.000Z') + assert.strictEqual(r.node_unreachable.last_ok_at, null) + assert.strictEqual(r.node_unreachable.seconds, 3600) + }) + + it('clears the outage as soon as one attempt succeeds again', function () { + // Failure at FAIL, success after it: the LATEST attempt is what decides. + const later = FAIL + 60000 + const r = nodeReachabilityFrom(T0, later, FAIL, NOW) + assert.strictEqual(r.node_unreachable, null, + 'a recovered node must not stay latched as unreachable') + assert.strictEqual(r.node_last_ok_at, new Date(later).toISOString()) + }) + + it('treats a failure at the same instant as the last success as recovered', function () { + // Strictly-newer, not newer-or-equal: two events in one millisecond must not + // flip a node that is answering into an outage with a zero-second age. + assert.strictEqual(nodeReachabilityFrom(T0, OK, OK, NOW).node_unreachable, null) + }) + + it('floors the age to whole seconds and never publishes a negative one', function () { + assert.strictEqual(nodeReachabilityFrom(T0, 0, FAIL, T0 + 1999).node_unreachable.seconds, 1) + assert.strictEqual(nodeReachabilityFrom(T0, 0, FAIL, T0 - 5000).node_unreachable.seconds, 0, + 'a probe racing the recorded instant must not report a negative outage') + }) + + it('emits ISO instants, not locale strings or epoch numbers', function () { + const r = nodeReachabilityFrom(T0, OK, FAIL, NOW) + assert.match(r.node_last_ok_at, ISO) + assert.match(r.node_unreachable.since, ISO) + assert.strictEqual(new Date(r.node_unreachable.since).toISOString(), r.node_unreachable.since) + }) + + it('defaults `now` to the wall clock, so a caller cannot forget to pass one', function () { + const r = nodeReachabilityFrom(Date.now() - 10000, 0, Date.now()) + assert.ok(r.node_unreachable.seconds >= 9 && r.node_unreachable.seconds <= 11, + 'expected roughly a ten second outage, got ' + r.node_unreachable.seconds) + }) +}) + +describe('the connector records both instants at its single POST choke point', function () { + function newConnector(){ + return new BlockchainConnector('127.0.0.1', '18443', 'u', 'p') + } + + it('starts with never-succeeded, never-failed and a start time', function () { + const c = newConnector() + assert.strictEqual(c.lastNodeOkAt, 0) + assert.strictEqual(c.lastNodeFailAt, 0) + assert.ok(c.startedAt > 0, 'the outage of a node that never answered is dated from here') + assert.deepStrictEqual(c.nodeReachability(), + { node_last_ok_at: null, node_unreachable: null }) + }) + + it('a successful POST stamps lastNodeOkAt and clears the verdict', async function () { + const c = newConnector() + c.lastNodeFailAt = Date.now() - 1000 + // rpcPost is the choke point every RPC method funnels through, so stubbing the + // transport under it exercises the real recording path. + const axios = require('axios') + const realPost = axios.post + axios.post = async () => ({ data: { result: 'ok' } }) + try { + await c.rpcPost({ method: 'getblockchaininfo' }) + } finally { + axios.post = realPost + } + assert.ok(c.lastNodeOkAt > 0) + assert.strictEqual(c.nodeReachability().node_unreachable, null) + }) + + it('a failing POST stamps lastNodeFailAt and rethrows the original error', async function () { + const c = newConnector() + const axios = require('axios') + const realPost = axios.post + const boom = new Error('timeout of 30000ms exceeded') + boom.code = 'ECONNABORTED' + axios.post = async () => { throw boom } + try { + await assert.rejects(() => c.rpcPost({ method: 'getblockchaininfo' }), + (err) => err.code === 'ECONNABORTED') + } finally { + axios.post = realPost + } + assert.ok(c.lastNodeFailAt > 0) + const r = c.nodeReachability() + assert.strictEqual(r.node_last_ok_at, null, 'this node has never answered') + assert.ok(r.node_unreachable, 'the timeout the operator saw 2099 times must show here') + assert.strictEqual(r.node_unreachable.last_ok_at, null) + }) + + it('every RPC method reaches the recording site through rpcPost', function () { + // Source-level: instrumenting per method is how the next added method silently + // escapes the surface. Nothing in this class may POST around the choke point. + const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'BlockchainConnector.js'), 'utf8') + const posts = SRC.match(/axios\.post\(/g) || [] + assert.strictEqual(posts.length, 1, 'axios.post must appear only inside rpcPost') + }) +}) + +describe('the reachability fields ride the health payloads', function () { + const API = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'api.js'), 'utf8') + + function liveApp(decoder, running = true){ + const app = express() + registerLiveRoute(app, decoder, () => running) + return app + } + + function getLive(app){ + return new Promise((resolve, reject) => { + const server = app.listen(0, () => { + http.get({ port: server.address().port, path: '/live' }, (res) => { + let body = '' + res.on('data', (c) => { body += c }) + res.on('end', () => { server.close(); resolve({ status: res.statusCode, body: JSON.parse(body) }) }) + }).on('error', (e) => { server.close(); reject(e) }) + }) + }) + } + + function probeDecoder(connector){ + const decoder = new XChainDecoder( + 'bitcoin-regtest', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null + ) + decoder.lastProcessedBlockIndex = 100 + decoder.blockchainInfoLastBlock = 100 + decoder.blockchainInfoLastRefreshAt = Date.now() + decoder.lastAdvanceAt = Date.now() + decoder.lastPollAt = Date.now() + decoder.db = { ping: async () => true } + decoder.connector = connector || { rpcErrors: 0 } + return decoder + } + + it('/live publishes the outage of a node that has never answered', async function () { + const connector = new BlockchainConnector('127.0.0.1', '18443', 'u', 'p') + connector.rpcErrors = 0 + connector.startedAt = Date.now() - 3600000 + connector.lastNodeFailAt = Date.now() + const res = await getLive(liveApp(probeDecoder(connector))) + assert.strictEqual(res.body.node_last_ok_at, null) + assert.ok(res.body.node_unreachable, 'the field the operator had no way to see') + assert.strictEqual(res.body.node_unreachable.last_ok_at, null) + assert.ok(res.body.node_unreachable.seconds >= 3599) + assert.match(res.body.node_unreachable.since, ISO) + }) + + it('/live still answers 200 while the node is unreachable, by design', async function () { + // The visibility is new; the verdict is not. A restart cannot fix an upstream + // outage, so gating here would restart-flap a decoder that is doing its job. + const connector = new BlockchainConnector('127.0.0.1', '18443', 'u', 'p') + connector.rpcErrors = 0 + connector.lastNodeFailAt = Date.now() + const res = await getLive(liveApp(probeDecoder(connector))) + assert.strictEqual(res.status, 200) + assert.strictEqual(res.body.status, 'healthy') + assert.ok(res.body.node_unreachable) + }) + + it('/live publishes both keys as null when the node is answering', async function () { + const connector = new BlockchainConnector('127.0.0.1', '18443', 'u', 'p') + connector.rpcErrors = 0 + connector.lastNodeOkAt = Date.now() + const res = await getLive(liveApp(probeDecoder(connector))) + assert.ok('node_last_ok_at' in res.body, 'an omitted key reads as "this build cannot tell you"') + assert.ok('node_unreachable' in res.body) + assert.match(res.body.node_last_ok_at, ISO) + assert.strictEqual(res.body.node_unreachable, null) + }) + + it('/live carries both keys against a decoder whose connector is an old stub', async function () { + const res = await getLive(liveApp(probeDecoder())) + assert.strictEqual(res.body.node_last_ok_at, null) + assert.strictEqual(res.body.node_unreachable, null) + }) + + // /status and the JSON-RPC health method are built inside startApi(), which binds a + // port and a live decoder, so those two are pinned at source level, the shape + // nodeCatchingUpStatus.test.js uses for the same reason. + it('every payload carrying node_catching_up also spreads the reachability fields', function () { + const sites = [] + for (let at = API.indexOf('node_catching_up:'); at !== -1; at = API.indexOf('node_catching_up:', at + 1)) sites.push(at) + assert.strictEqual(sites.length, 3, 'three payloads carry node_catching_up: /live, rpc health, /status') + + for (const at of sites){ + assert.ok(/\.\.\.nodeReachabilityFields\(decoder\),/.test(API.slice(at, at + 500)), + 'a node_catching_up payload at offset ' + at + ' ships without the reachability fields') + } + }) + + it('reads the fields fail-soft, so a payload built without a connector cannot throw', function () { + assert.deepStrictEqual(nodeReachabilityFields(undefined), + { node_last_ok_at: null, node_unreachable: null }) + assert.deepStrictEqual(nodeReachabilityFields({}), + { node_last_ok_at: null, node_unreachable: null }) + assert.deepStrictEqual(nodeReachabilityFields({ connector: { rpcErrors: 0 } }), + { node_last_ok_at: null, node_unreachable: null }) + assert.deepStrictEqual( + nodeReachabilityFields({ connector: { nodeReachability(){ throw new Error('boom') } } }), + { node_last_ok_at: null, node_unreachable: null }) + }) +}) From 81540f66320fcc70b8ddf14bc6f1c4f830d060ab Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 9 Sep 2026 12:16:19 -0700 Subject: [PATCH 2/5] consensus(genesis-arm): arm the identity gates at mainnet genesis per the 2026-09-09 ruling Vendored ORACLE_FEE_SET_CAPTURE (at its base gate's instant), DISPENSER_EXPIRY_REALIGN and DISPENSER_CANCEL_GRACE maps armed byte-equal to the canonical; the batch-limits module regenerated by its sync tool; tests re-aimed onto the armed rule. Identity on the measured mainnet history (ISSUE and ANCHOR only); the from-genesis replay witness is the next milestone. --- src/batchSubCommandCapture.js | 10 +- src/protocol/constants.js | 36 ++--- src/protocol/indexerBatchLimits.js | 2 +- test/e2e/dispenserLifecycle.e2e.js | 5 +- test/unit/batchLimitsVendoring.test.js | 123 +++++++++++++++--- test/unit/dispenserCancelGrace.test.js | 35 ++++- .../dispenserCancelGraceActivation.test.js | 67 +++++++--- .../dispenserExpiryRealignActivation.test.js | 63 ++++++--- test/unit/dispenserOracleFeeOutput.test.js | 56 ++++++-- ...acleFeeOutputActivationConformance.test.js | 15 ++- 10 files changed, 315 insertions(+), 97 deletions(-) diff --git a/src/batchSubCommandCapture.js b/src/batchSubCommandCapture.js index b469199..735b80a 100644 --- a/src/batchSubCommandCapture.js +++ b/src/batchSubCommandCapture.js @@ -453,10 +453,12 @@ function hasProvablyRejectedBatch(subCommands, aliases, consensusNetwork, blockT // // Its own vendored per-network instant, NOT the ordering argument the caps lean on. That // argument is specific to BATCH_ISSUANCE_LIMITS, whose instant the decoder's capture gate is -// required to sit at or after; the weighting flag has no such relationship and today it is -// the counter-example, with mainnet capture ARMED and the weighting instant still on the -// house sentinel. An absent or DISARMED (null) entry is inactive at every block time, which -// leaves today's over-capture in place rather than inventing a suppression rule. +// required to sit at or after; the weighting flag has the opposite relationship. Since the +// 2026-09-09 ruling armed it at mainnet genesis it sits BELOW capture there, and that is +// safe because the indexer applies the budget only inside its BATCH_ISSUANCE_LIMITS guard, +// which shares capture's instant: below it neither side weighs, and this mirror captures +// nothing to suppress. An absent or DISARMED (null) entry is inactive at every block time, +// which leaves over-capture in place rather than inventing a suppression rule. function isBatchCostWeightingActive(consensusNetwork, blockTime){ const activation = COST_WEIGHTING_ACTIVATION[consensusNetwork] if (typeof activation !== 'number') return false diff --git a/src/protocol/constants.js b/src/protocol/constants.js index e2a5d26..c8323ef 100644 --- a/src/protocol/constants.js +++ b/src/protocol/constants.js @@ -355,11 +355,12 @@ const ORACLE_FEE_OUTPUT_ACTIVATION = { // single-pick therefore stays live BELOW the gate, and a re-decode of pre-flag-day history // reproduces exactly what the fleet wrote live. // -// null means DISARMED (never active), the fail-closed default: mainnet and testnet keep the -// legacy single-pick until that network's maintainers ratify an instant, chosen with the -// fleet's upgrade state in hand, because arming it too early forks the chain and arming it in -// the past rewrites agreed history. regtest holds no agreed history (its chains are recreated -// per run), so it is genesis-on and exercises the set path in the regtest venues. +// mainnet is ARMED at the base gate's own instant by the 2026-09-09 ruling, the earliest the +// ordering above permits: the indexed mainnet history holds 0 dispensers (measured +// 2026-09-09), so set capture persists exactly the output set the legacy single-pick did and +// the arm rewrites no agreed history. A from-genesis OLD-vs-ON replay witness per chain is the +// proof. regtest holds no agreed history (its chains are recreated per run), so it is +// genesis-on and exercises the set path in the regtest venues. // // DEPLOY DEADLINE, once an instant is armed: EVERY decoder on that network MUST be running the // armed value before the instant, or the fleet splits on the first refill of a source holding @@ -369,7 +370,7 @@ const ORACLE_FEE_OUTPUT_ACTIVATION = { // keeps the two copies in lockstep and refuses a value that precedes // ORACLE_FEE_OUTPUT_ACTIVATION. const ORACLE_FEE_SET_CAPTURE_ACTIVATION = { - mainnet: null, // DISARMED: awaiting the operator's ratified per-network instant + mainnet: 1786060800, // ARMED by the 2026-09-09 ruling at its base gate's own instant, the earliest the ordering above permits; identity on the indexed mainnet history (0 dispensers, measured 2026-09-09) // ARMED AT GENESIS (instant 0 = always in force), operator-ratified 2026-08-18 under the // pre-launch ruling that every feature must be ACTIVE on testnet. This gate fixes a defect // that spends a payer native coin and gives nothing back, so a public testnet WILL hit it. @@ -405,11 +406,12 @@ const ORACLE_FEE_SET_CAPTURE_ACTIVATION = { // The legacy block-start soft-expire therefore stays live BELOW the gate, and a re-decode of // pre-flag-day history reproduces exactly what the fleet wrote live. // -// null means DISARMED (never active), the fail-closed default: mainnet and testnet keep the -// legacy block-start expiry until that network's maintainers ratify an instant, chosen with the -// fleet's upgrade state in hand, because arming it too early forks the chain and arming it in -// the past rewrites agreed history. regtest holds no agreed history (its chains are recreated -// per run), so it is genesis-on and exercises the realigned path in the regtest venues. +// mainnet is ARMED at genesis (instant 0) by the 2026-09-09 ruling: the indexed mainnet history +// holds 0 dispensers and 0 dispenses (measured 2026-09-09), so no block ever carried an expiry +// boundary the realigned soft-expire could move and the arm rewrites no agreed history. A +// from-genesis OLD-vs-ON replay witness per chain is the proof. regtest holds no agreed history +// (its chains are recreated per run), so it is genesis-on and exercises the realigned path in +// the regtest venues. // // DEPLOY DEADLINE, once an instant is armed: EVERY decoder on that network MUST be running the // armed value before the instant, or the fleet splits on the first block whose header time @@ -418,7 +420,7 @@ const ORACLE_FEE_SET_CAPTURE_ACTIVATION = { // Vendored byte-equal into xchain-decoder/src/protocol/constants.js; the conformance suite // keeps the two copies in lockstep. const DISPENSER_EXPIRY_REALIGN_ACTIVATION = { - mainnet: null, // DISARMED: awaiting the operator's ratified per-network instant + mainnet: 0, // ARMED at genesis by the 2026-09-09 ruling: identity on the indexed mainnet history (0 dispensers, 0 dispenses, measured 2026-09-09) // ARMED AT GENESIS (instant 0 = always in force), operator-ratified 2026-08-18 under the // pre-launch ruling that every feature must be ACTIVE on testnet. This gate fixes a defect // that spends a payer native coin and gives nothing back, so a public testnet WILL hit it. @@ -458,10 +460,10 @@ const DISPENSER_EXPIRY_REALIGN_ACTIVATION = { // capture set therefore stays live BELOW the gate, and a re-decode of pre-flag-day history // reproduces exactly what the fleet wrote. // -// null means DISARMED (never active), the fail-closed default: mainnet keeps the unwidened -// capture set until that network's maintainers ratify an instant, chosen with the fleet's -// upgrade state in hand, because arming it too early forks the chain and arming it in the -// past rewrites agreed history. +// mainnet is ARMED at genesis (instant 0) by the 2026-09-09 ruling: the indexed mainnet +// history holds 0 dispensers and 0 dispenses (measured 2026-09-09), so the widened capture +// set admits no output the unwidened one missed and the arm rewrites no agreed history. A +// from-genesis OLD-vs-ON replay witness per chain is the proof. // // DEPLOY DEADLINE, once an instant is armed: EVERY decoder on that network MUST be running // the armed value before the instant, or the fleet splits on the first block whose header @@ -470,7 +472,7 @@ const DISPENSER_EXPIRY_REALIGN_ACTIVATION = { // Vendored byte-equal into xchain-decoder/src/protocol/constants.js; the conformance suite // keeps the two copies in lockstep. const DISPENSER_CANCEL_GRACE_ACTIVATION = { - mainnet: null, // DISARMED: awaiting the operator's ratified per-network instant + mainnet: 0, // ARMED at genesis by the 2026-09-09 ruling: identity on the indexed mainnet history (0 dispensers, 0 dispenses, measured 2026-09-09) // ARMED AT GENESIS (instant 0 = always in force), matching the sibling // DISPENSER_EXPIRY_REALIGN_ACTIVATION under the pre-launch ruling that every feature must // be ACTIVE on testnet. This gate closes a defect that spends a payer's native coin and diff --git a/src/protocol/indexerBatchLimits.js b/src/protocol/indexerBatchLimits.js index 59730c4..496f955 100644 --- a/src/protocol/indexerBatchLimits.js +++ b/src/protocol/indexerBatchLimits.js @@ -77,7 +77,7 @@ const COMMAND_WEIGHTS = { // on wherever the decoder's capture gate is: mainnet capture is armed while this instant is // still the house sentinel. null means DISARMED, which is inactive at every block time. const COST_WEIGHTING_ACTIVATION = { - "mainnet": 9999999999, + "mainnet": 0, "testnet": 0, "regtest": 0, }; diff --git a/test/e2e/dispenserLifecycle.e2e.js b/test/e2e/dispenserLifecycle.e2e.js index 432a5b0..9b92e51 100644 --- a/test/e2e/dispenserLifecycle.e2e.js +++ b/test/e2e/dispenserLifecycle.e2e.js @@ -113,8 +113,9 @@ describe('E2E: DISPENSER Lifecycle', function () { // sweep runs AFTER the block's own transactions, exactly where the indexer's // processExpirations sits. An already-past expiration is therefore stamped by // the block that CARRIES the create, not the one after it. (Below the gate the - // sweep ran first and a create could never be expired by its own block, which - // is the legacy behavior mainnet/testnet keep until an instant is ratified.) + // sweep ran first and a create could never be expired by its own block. Every + // network is armed at genesis since the 2026-09-09 ruling, so that legacy path + // is what a re-decode runs only on a network armed mid-chain.) // // Expiry is a SOFT expire, not a delete: db.deleteOpenDispensers stamps // the expiring block height into expired_block_index so a reorg can diff --git a/test/unit/batchLimitsVendoring.test.js b/test/unit/batchLimitsVendoring.test.js index cee8889..ef0f185 100644 --- a/test/unit/batchLimitsVendoring.test.js +++ b/test/unit/batchLimitsVendoring.test.js @@ -47,6 +47,7 @@ const { BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION, subCommandCostWeight, subCommandLimitKey, subCommandTick, + isBatchCostWeightingActive, CHILD_ISSUE_KEY } = require('../../src/batchSubCommandCapture.js'); const ACTION_ALIASES = require('../../src/actionAliases.js'); const sync = require('../tools/sync-batch-limits.js'); @@ -56,6 +57,12 @@ const CORPUS = require('../fixtures/regtestBatchCorpus.json'); const REQUIRE_SIBLINGS = process.env.XCHAIN_REQUIRE_SIBLINGS === '1'; const T0 = 1700000000; +// One over-budget wire the gate blocks can drive without reaching into tier 3's vectors: +// 10 sub-commands, well under the 250-COUNT cap, weighing 271 against the 250 budget, so +// only the WEIGHT budget can ever suppress it. Same shape as tier 3's '9x EXECUTE + SEND'. +const WEIGHT_PROBE = 'BATCH|0|SEND|0|BTC|TICK|1|addr;' + + Array.from({ length: 9 }, () => 'EXECUTE|0|1|a').join(';'); + function siblingOrSkip(ctx, file) { if (fs.existsSync(file)) return true; if (REQUIRE_SIBLINGS) @@ -335,17 +342,67 @@ describe('BATCH limit vendoring and cross-repo conformance', function () { 'the indexer still dispatches'); }); - it('does NOT assume the weighting flag is on wherever capture is, which is why it is gated', function () { - // The counter-example that killed the ordering shortcut, pinned so it stays a - // counter-example: mainnet capture is armed and mainnet weighting is not. An - // ungated budget would suppress mainnet capture today. + it('never applies the budget where the indexer would not: capture is the narrower gate', function () { + // The ordering that protects the money-bearing direction, re-derived after the + // 2026-09-09 genesis arm moved mainnet weighting from the house sentinel to 0. + // + // The old shape of this test pinned "mainnet capture is armed and mainnet + // weighting is not", which was true and is not any more. What replaces it is + // stronger, because it holds in the direction that costs money rather than + // merely being a fact about two numbers: + // + // * the indexer's budget is a strict refinement of BATCH_ISSUANCE_LIMITS. + // src/actions/batch.js reads its BATCH_COST_WEIGHTING verdict ONLY inside + // `if(limitsActive)` blocks, so below that gate's mainnet instant no bound + // runs at all, whatever the weighting instant says; + // * this decoder cannot suppress there either, because captureCommands exits + // with the un-expanded passthrough while the CAPTURE gate is inactive, and + // mainnet capture arms at that same instant. + // + // So the window where the vendored weighting instant reads "on" but the indexer + // applies no budget is exactly the window where this module captures nothing to + // suppress. Under-capture, the direction that loses a settlement output, is + // impossible in it. Both halves are driven, not asserted about the constants. const captureGate = BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION.mainnet; const weightGate = VENDORED_MODULE.COST_WEIGHTING_ACTIVATION.mainnet; if (captureGate === null || typeof weightGate !== 'number') return; - assert.ok(captureGate < weightGate, - 'mainnet capture (' + captureGate + ') is no longer earlier than mainnet ' + - 'weighting (' + weightGate + '); re-read isBatchCostWeightingActive before ' + - 'relying on the gate, and re-derive whether the budget may now be unconditional'); + + assert.ok(weightGate <= captureGate, + 'mainnet weighting (' + weightGate + ') now arms AFTER capture (' + captureGate + + '); a batch could then be captured with the budget still off here while the ' + + 'indexer applied it, which is over-capture in the other direction'); + + // Inside the window: the vendored weighting gate reads active, and capture does + // not, so no batch reaches the budget. + const inside = captureGate - 1; + assert.strictEqual(isBatchCostWeightingActive('mainnet', inside), true, + 'the vendored mainnet weighting instant is 0, so it must read active below capture'); + assert.deepStrictEqual( + captureCommands(WEIGHT_PROBE, 'mainnet', inside), [WEIGHT_PROBE], + 'capture must still be OFF inside the window: an over-budget batch that ' + + 'reached the budget here would be suppressed while the indexer dispatched it'); + + // At and above the instant both gates are on together, which is the state the + // tier 3 block drives against the real handler. + assert.strictEqual(isBatchCostWeightingActive('mainnet', captureGate), true); + assert.deepStrictEqual(captureCommands(WEIGHT_PROBE, 'mainnet', captureGate), [], + 'at the shared instant the mirror must suppress the over-budget batch, ' + + 'because the indexer rejects it there'); + }); + + it('testnet and regtest have no such window: capture and weighting both arm at genesis', function () { + // The two networks the window argument does not need, pinned so a future + // per-network re-pin cannot open one quietly. + for (const network of ['testnet', 'regtest']) { + const captureGate = BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION[network]; + const weightGate = VENDORED_MODULE.COST_WEIGHTING_ACTIVATION[network]; + assert.strictEqual(captureGate, 0, network + ' capture is no longer genesis-active'); + assert.strictEqual(weightGate, 0, network + ' weighting is no longer genesis-active'); + assert.strictEqual(isBatchCostWeightingActive(network, 0), true, + network + ' must weigh from block 0'); + assert.deepStrictEqual(captureCommands(WEIGHT_PROBE, network, 0), [], + network + ' must suppress the over-budget batch from block 0'); + } }); }); @@ -371,7 +428,14 @@ describe('BATCH limit vendoring and cross-repo conformance', function () { wire: 'BATCH|0|' + Array.from({ length: 10 }, () => 'AIRDROP|0|BTC|TICK|1|a').join(';') }, ]; - const MAINNET_LIVE = 1800000000; // above mainnet capture, below the weighting sentinel + // Above mainnet capture, which since the 2026-09-09 genesis arm is also above the + // point where the indexer's own weight budget becomes reachable (its BATCH_COST_ + // WEIGHTING verdict is read only inside the BATCH_ISSUANCE_LIMITS guard, and that + // gate's mainnet instant is the capture instant). Both sides weigh here. + const MAINNET_LIVE = 1800000000; + // Inside the inverted window instead: the weighting instant is 0 so the vendored + // gate reads active, but capture is off here and the indexer applies no bound. + const MAINNET_WINDOW = BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION.mainnet - 1; it('suppresses an over-budget batch on regtest, where the handler rejects it whole', async function () { if (!siblingOrSkip(this, sync.INDEXER_BATCH)) return; @@ -388,19 +452,44 @@ describe('BATCH limit vendoring and cross-repo conformance', function () { } }); - it('still captures an over-budget batch on MAINNET, where the flag is unarmed', async function () { - // The under-capture control, and the reason the rule is gated instead of - // unconditional. Pre-gate reasoning would have suppressed these. + it('still captures an over-budget batch inside the inverted MAINNET window', async function () { + // The under-capture control, re-aimed at the window the 2026-09-09 genesis arm + // opened. Mainnet BATCH_COST_WEIGHTING is now 0, so the vendored gate reads + // active below the capture instant; the real handler applies NO bound there, + // because it reads that verdict only inside its BATCH_ISSUANCE_LIMITS guard and + // that gate arms at the capture instant. This is the case that would lose a + // settlement output if the mirror ever suppressed on the weighting instant alone. if (!siblingOrSkip(this, sync.INDEXER_BATCH)) return; for (const vector of WEIGHT_VECTORS) { const status = await indexerStatus(vector.wire, - { network: 'mainnet', blockTime: MAINNET_LIVE }); + { network: 'mainnet', blockTime: MAINNET_WINDOW }); assert.strictEqual(status, 'valid', - vector.name + ': premise wrong, mainnet handler said ' + status); - const view = captureCommands(vector.wire, 'mainnet', MAINNET_LIVE); - assert.strictEqual(view.length, subCommandsOf(vector.wire).length, + vector.name + ': premise wrong, mainnet handler said ' + status + + ' inside the window; the budget is no longer nested under BATCH_ISSUANCE_LIMITS'); + // Capture is off here, so the mirror hands back the un-expanded batch rather + // than suppressing it. Nothing the handler dispatches is dropped. + assert.deepStrictEqual(captureCommands(vector.wire, 'mainnet', MAINNET_WINDOW), + [vector.wire], 'UNDER-CAPTURE on mainnet: the mirror suppressed ' + vector.name + - ', which the real handler dispatches in full'); + ' inside the window, which the real handler dispatches in full'); + } + }); + + it('and agrees with the handler ABOVE the shared instant, where both weigh', async function () { + // The other side of the same boundary, and the state mainnet is actually in + // today. Once capture is on, BATCH_ISSUANCE_LIMITS is on too, so the indexer's + // budget is reachable and both sides must reach the same verdict. Without this + // the case above would also pass if the mirror had simply stopped suppressing. + if (!siblingOrSkip(this, sync.INDEXER_BATCH)) return; + for (const vector of WEIGHT_VECTORS) { + const status = await indexerStatus(vector.wire, + { network: 'mainnet', blockTime: MAINNET_LIVE }); + assert.strictEqual(status, 'invalid: COMMAND (limit)', + vector.name + ': the mainnet handler said ' + status + ' above the ' + + 'capture instant, where the weight budget is reachable'); + assert.deepStrictEqual(captureCommands(vector.wire, 'mainnet', MAINNET_LIVE), [], + 'OVER-CAPTURE on mainnet: the mirror captured ' + vector.name + + ', which the real handler rejects whole'); } }); diff --git a/test/unit/dispenserCancelGrace.test.js b/test/unit/dispenserCancelGrace.test.js index 8aca4e1..8f47c9a 100644 --- a/test/unit/dispenserCancelGrace.test.js +++ b/test/unit/dispenserCancelGrace.test.js @@ -35,7 +35,8 @@ const sinon = require('sinon') const XChainDecoder = require('../../src/XChainDecoder') const Database = require('../../src/db.js') -const { DISPENSER_CANCEL_GRACE_SECONDS, +const { DISPENSER_CANCEL_GRACE_ACTIVATION, + DISPENSER_CANCEL_GRACE_SECONDS, cancelGraceFloor } = require('../../src/dispenserCancelGrace') const PREV_WIRE = Buffer.from( @@ -219,11 +220,22 @@ describe('dispenser cancellation grace: decoder capture outlasts the indexer fil }) it('keeps the unwidened capture set below the flag-day (the other side of the gate)', async () => { - // Same blocks, same model, DISARMED network. This is the behavior the fleet runs today - // and the behavior a from-genesis re-decode of pre-flag-day history must reproduce. + // Same blocks, same model, gate DISARMED. Every network in the map is armed at genesis + // since the 2026-09-09 ruling, so the below-gate branch is reached by disarming mainnet + // in place for the length of this test. The branch stays live code: it is what a + // from-genesis re-decode runs on any network that arms mid-chain, and dropping the + // assertion would let the widened set become unconditional without a test noticing. const payAt = EXPIRATION + 1800 const model = fundedCancelledDispenser() - await runTwoBlocks('mainnet', EXPIRATION + 1, payAt, model) + const saved = DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet + DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet = null + try { + await runTwoBlocks('mainnet', EXPIRATION + 1, payAt, model) + } finally { + DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet = saved + } + assert.strictEqual(DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet, 0, + 'the map must be back to the genesis arm after the probe') assert.strictEqual(model.rows[0].expiredBlockIndex, 0) const payLoad = model.captureLoads[1] @@ -233,6 +245,21 @@ describe('dispenser cancellation grace: decoder capture outlasts the indexer fil 'below the gate the expired dispenser stays out of the capture set') }) + it('carries the grace on mainnet at genesis, the state the 2026-09-09 ruling armed', async () => { + // The armed mainnet path driven through the real block loop, not just the helper: the + // same cancelled dispenser is captured for a payment inside the indexer's fill window. + const payAt = EXPIRATION + 1800 + const model = fundedCancelledDispenser() + await runTwoBlocks('mainnet', EXPIRATION + 1, payAt, model) + + assert.strictEqual(model.rows[0].expiredBlockIndex, 0, + 'block 0 must have soft-expired the dispenser, or this test proves nothing') + const payLoad = model.captureLoads[1] + assert.strictEqual(payLoad.floor, payAt - DISPENSER_CANCEL_GRACE_SECONDS) + assert.ok(payLoad.set.has(ADDR), + 'mainnet is armed at genesis, so the grace carries the address into the capture set') + }) + it('closes capture once the indexer can no longer settle a fill', async () => { // The grace is a window, not an amnesty: past expiration + grace the address leaves the // capture set, and by then the indexer stopped matching the dispenser long ago. diff --git a/test/unit/dispenserCancelGraceActivation.test.js b/test/unit/dispenserCancelGraceActivation.test.js index 600a503..683dc24 100644 --- a/test/unit/dispenserCancelGraceActivation.test.js +++ b/test/unit/dispenserCancelGraceActivation.test.js @@ -20,11 +20,14 @@ // matching what the fleet wrote live; // * arming it on a network whose decoders are not all running the value forks the fleet at // the first block that passes a cancelled dispenser's expiration. -// So mainnet is DISARMED (null) until the operator ratifies an instant, and the helper fails -// closed on anything that is not a number. +// mainnet is ARMED AT GENESIS (instant 0) by the 2026-09-09 ruling. Arming it there rewrites +// nothing: the indexed mainnet history holds 0 dispensers and 0 dispenses (measured +// 2026-09-09), so the widened capture set admits no output the unwidened one missed. The helper +// still fails closed on anything that is not a number, which is what the null sentinel remains +// for on any network that has not armed. // // Two tiers, so a one-sided edit fails somewhere no matter which checkout is present: -// 1. PIN - the vendored map has the disarmed/genesis-on shape, in this repo alone. +// 1. PIN - the vendored map has the genesis-on shape on every network, in this repo alone. // 2. DOCS - it is value-identical to the canonical map in // xchain-documentation/protocol/constants.js. // Tier 2 skips when the sibling checkout is absent (standalone deploy); set @@ -67,14 +70,26 @@ function indexerCloseDelay(){ describe('DISPENSER_CANCEL_GRACE_ACTIVATION conformance', function () { - it('keeps mainnet DISARMED, with testnet and regtest genesis-on', function () { - // Teeth for the ratification requirement: a number on MAINNET means someone armed a - // consensus boundary without the operator's ratified instant. - assert.strictEqual(DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet, null); + it('arms mainnet at genesis by the 2026-09-09 ruling, with testnet and regtest genesis-on', function () { + // Teeth for the ruling: mainnet sits at instant 0, which is identity on the indexed + // mainnet history (0 dispensers, 0 dispenses, measured 2026-09-09). Any other mainnet + // value re-introduces a boundary block the fleet could split on, so it fails here. + assert.strictEqual(DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet, 0); assert.strictEqual(DISPENSER_CANCEL_GRACE_ACTIVATION.testnet, 0); assert.strictEqual(DISPENSER_CANCEL_GRACE_ACTIVATION.regtest, 0); }); + it('mainnet carries the grace from block time 0 upward, floor and all', function () { + // The behaviour the arm buys: every mainnet block, the genesis instant included, keeps + // a just-expired dispenser in the capture set for one grace window, so a cancelled + // dispenser can never take a payment the decoder drops. + assert.strictEqual(isDispenserCancelGraceActive('mainnet', 0), true); + assert.strictEqual(isDispenserCancelGraceActive('mainnet', 1786060800), true); + assert.strictEqual(isDispenserCancelGraceActive('mainnet', 4000000000), true); + assert.strictEqual(cancelGraceFloor('mainnet', 4000000000), + 4000000000 - DISPENSER_CANCEL_GRACE_SECONDS); + }); + it('is value-identical to the canonical map in xchain-documentation', function () { if (!siblingOrSkip(this, DOCS_CONSTANTS)) return; const canon = require(DOCS_CONSTANTS).DISPENSER_CANCEL_GRACE_ACTIVATION; @@ -89,13 +104,23 @@ describe('DISPENSER_CANCEL_GRACE_ACTIVATION conformance', function () { 'the decoder fleet at the first block that passes a cancelled dispenser expiration'); }); - it('a DISARMED network is inactive at every block time, including absurd ones', function () { - // A `time >= null` coercion would read 0 and arm mainnet from genesis, which is the - // failure this pins. - assert.strictEqual(isDispenserCancelGraceActive('mainnet', 0), false); - assert.strictEqual(isDispenserCancelGraceActive('mainnet', 1786060800), false); - assert.strictEqual(isDispenserCancelGraceActive('mainnet', 4000000000), false); - assert.strictEqual(cancelGraceFloor('mainnet', 4000000000), null); + it('a DISARMED (null) network is inactive at every block time, including absurd ones', function () { + // No network carries the null sentinel now that mainnet is armed, so disarm one in + // place for the length of this test and drive the REAL helper. A `time >= null` + // coercion would read 0 and widen the capture set from genesis on a network whose + // fleet never armed it, which is the failure this pins. + const saved = DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet; + DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet = null; + try { + assert.strictEqual(isDispenserCancelGraceActive('mainnet', 0), false); + assert.strictEqual(isDispenserCancelGraceActive('mainnet', 1786060800), false); + assert.strictEqual(isDispenserCancelGraceActive('mainnet', 4000000000), false); + assert.strictEqual(cancelGraceFloor('mainnet', 4000000000), null); + } finally { + DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet = saved; + } + assert.strictEqual(DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet, 0, + 'the map must be back to the genesis arm after the probe'); }); it('testnet and regtest are active from genesis', function () { @@ -119,11 +144,11 @@ describe('DISPENSER_CANCEL_GRACE_ACTIVATION conformance', function () { assert.strictEqual(cancelGraceFloor('regtest', NaN), null); }); - it('flips exactly at the armed instant once a network IS armed (>= semantics)', function () { - // The map is disarmed today, so arm a network in place for the length of this test and + it('flips exactly at the armed instant when a network is armed mid-chain (>= semantics)', function () { + // Mainnet arms at 0, so move it to a mid-chain instant for the length of this test and // drive the REAL helper (the module reads the map per call, so the mutation is - // visible). This pins the boundary the operator will ratify onto: >=, so the block AT - // the instant already carries the grace, matching every protocol_changes gate. + // visible). This pins the boundary semantics any later arm inherits: >=, so the block + // AT the instant already carries the grace, matching every protocol_changes gate. const ARMED = 1789430400; const saved = DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet; DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet = ARMED; @@ -139,8 +164,10 @@ describe('DISPENSER_CANCEL_GRACE_ACTIVATION conformance', function () { } finally { DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet = saved; } - assert.strictEqual(isDispenserCancelGraceActive('mainnet', ARMED), false, - 'the map must be back to DISARMED after the probe'); + assert.strictEqual(DISPENSER_CANCEL_GRACE_ACTIVATION.mainnet, 0, + 'the map must be back to the genesis arm after the probe'); + assert.strictEqual(isDispenserCancelGraceActive('mainnet', ARMED - 1), true, + 'and the restored genesis arm covers the block the probe held below its instant'); }); it('the floor is exactly one grace window below the block time', function () { diff --git a/test/unit/dispenserExpiryRealignActivation.test.js b/test/unit/dispenserExpiryRealignActivation.test.js index e45a39b..39eb898 100644 --- a/test/unit/dispenserExpiryRealignActivation.test.js +++ b/test/unit/dispenserExpiryRealignActivation.test.js @@ -19,11 +19,14 @@ // what the fleet wrote live; // * arming it on a network whose decoders are not all running the value forks the fleet at the // first boundary block. -// So the map is DISARMED (null) on mainnet and testnet until the operator ratifies a per-network -// instant, and the helper fails closed on anything that is not a number. +// mainnet is ARMED AT GENESIS (instant 0) by the 2026-09-09 ruling. Arming it there rewrites +// nothing: the indexed mainnet history holds 0 dispensers and 0 dispenses (measured 2026-09-09), +// so no mainnet block ever carried an expiry boundary the realigned soft-expire could move. The +// helper still fails closed on anything that is not a number, which is what the null sentinel +// remains for on any network that has not armed. // // Two tiers, so a one-sided edit fails somewhere no matter which checkout is present: -// 1. PIN - the vendored map has the disarmed/genesis-on shape, in this repo alone. +// 1. PIN - the vendored map has the genesis-on shape on every network, in this repo alone. // 2. DOCS - it is value-identical to the canonical map in // xchain-documentation/protocol/constants.js. // Tier 2 skips when the sibling checkout is absent (standalone deploy); set @@ -51,16 +54,26 @@ function siblingOrSkip(ctx, file){ describe('DISPENSER_EXPIRY_REALIGN_ACTIVATION conformance', function () { - it('keeps mainnet DISARMED, with testnet and regtest genesis-on', function () { - // Teeth for the ratification requirement: a number on MAINNET means someone armed a - // consensus boundary without the operator's ratified instant. Testnet was ratified at + it('arms mainnet at genesis by the 2026-09-09 ruling, with testnet and regtest genesis-on', function () { + // Teeth for the ruling: mainnet sits at instant 0, which is identity on the indexed + // mainnet history (0 dispensers, 0 dispenses, measured 2026-09-09). Any other mainnet + // value re-introduces a boundary block, so it has to fail here. Testnet was ratified at // instant 0 on 2026-08-18 (pre-launch, every feature active on testnet), which is safe // only because testnet decoder/indexer state is rebuilt from the chain before launch. - assert.strictEqual(DISPENSER_EXPIRY_REALIGN_ACTIVATION.mainnet, null); + assert.strictEqual(DISPENSER_EXPIRY_REALIGN_ACTIVATION.mainnet, 0); assert.strictEqual(DISPENSER_EXPIRY_REALIGN_ACTIVATION.testnet, 0); assert.strictEqual(DISPENSER_EXPIRY_REALIGN_ACTIVATION.regtest, 0); }); + it('mainnet is realigned from block time 0 upward, with no boundary block left', function () { + // The behaviour the arm buys: every mainnet block, including the genesis instant + // itself, measures expiry where the indexer does, so no header time can fall on a + // side of the gate the fleet disagrees about. + assert.strictEqual(isDispenserExpiryRealignActive('mainnet', 0), true); + assert.strictEqual(isDispenserExpiryRealignActive('mainnet', 1786060800), true); + assert.strictEqual(isDispenserExpiryRealignActive('mainnet', 4000000000), true); + }); + it('is value-identical to the canonical map in xchain-documentation', function () { if (!siblingOrSkip(this, DOCS_CONSTANTS)) return; const canon = require(DOCS_CONSTANTS).DISPENSER_EXPIRY_REALIGN_ACTIVATION; @@ -75,12 +88,22 @@ describe('DISPENSER_EXPIRY_REALIGN_ACTIVATION conformance', function () { 'the decoder fleet at the first boundary block'); }); - it('a DISARMED network is inactive at every block time, including absurd ones', function () { - // Mainnet is the network still carrying the null sentinel. A `time >= null` coercion - // would read 0 and arm it from genesis, which is the failure this pins. - assert.strictEqual(isDispenserExpiryRealignActive('mainnet', 0), false); - assert.strictEqual(isDispenserExpiryRealignActive('mainnet', 1786060800), false); - assert.strictEqual(isDispenserExpiryRealignActive('mainnet', 4000000000), false); + it('a DISARMED (null) network is inactive at every block time, including absurd ones', function () { + // No network carries the null sentinel now that mainnet is armed, so disarm one in + // place for the length of this test and drive the REAL helper. A `time >= null` + // coercion would read 0 and arm the network from genesis, which is the failure this + // pins for whichever network is next added to the map unarmed. + const saved = DISPENSER_EXPIRY_REALIGN_ACTIVATION.mainnet; + DISPENSER_EXPIRY_REALIGN_ACTIVATION.mainnet = null; + try { + assert.strictEqual(isDispenserExpiryRealignActive('mainnet', 0), false); + assert.strictEqual(isDispenserExpiryRealignActive('mainnet', 1786060800), false); + assert.strictEqual(isDispenserExpiryRealignActive('mainnet', 4000000000), false); + } finally { + DISPENSER_EXPIRY_REALIGN_ACTIVATION.mainnet = saved; + } + assert.strictEqual(DISPENSER_EXPIRY_REALIGN_ACTIVATION.mainnet, 0, + 'the map must be back to the genesis arm after the probe'); }); it('testnet is active from genesis, so the launch runs the realigned path', function () { @@ -107,11 +130,11 @@ describe('DISPENSER_EXPIRY_REALIGN_ACTIVATION conformance', function () { assert.strictEqual(isDispenserExpiryRealignActive('regtest', 'not-a-time'), false); }); - it('flips exactly at the armed instant once a network IS armed (>= semantics)', function () { - // The map is disarmed today, so arm a network in place for the length of this test and + it('flips exactly at the armed instant when a network is armed mid-chain (>= semantics)', function () { + // Mainnet arms at 0, so move it to a mid-chain instant for the length of this test and // drive the REAL helper (the module reads the map per call, so the mutation is visible). - // This pins the boundary the operator will ratify onto: >=, so the block AT the instant - // is already realigned, matching every protocol_changes gate. + // This pins the boundary semantics any later arm inherits: >=, so the block AT the + // instant is already realigned, matching every protocol_changes gate. const ARMED = 1789430400; const saved = DISPENSER_EXPIRY_REALIGN_ACTIVATION.mainnet; DISPENSER_EXPIRY_REALIGN_ACTIVATION.mainnet = ARMED; @@ -125,7 +148,9 @@ describe('DISPENSER_EXPIRY_REALIGN_ACTIVATION conformance', function () { } finally { DISPENSER_EXPIRY_REALIGN_ACTIVATION.mainnet = saved; } - assert.strictEqual(isDispenserExpiryRealignActive('mainnet', ARMED), false, - 'the map must be back to DISARMED after the probe'); + assert.strictEqual(DISPENSER_EXPIRY_REALIGN_ACTIVATION.mainnet, 0, + 'the map must be back to the genesis arm after the probe'); + assert.strictEqual(isDispenserExpiryRealignActive('mainnet', ARMED - 1), true, + 'and the restored genesis arm covers the block the probe held below its instant'); }); }); diff --git a/test/unit/dispenserOracleFeeOutput.test.js b/test/unit/dispenserOracleFeeOutput.test.js index 86d4049..c391b07 100644 --- a/test/unit/dispenserOracleFeeOutput.test.js +++ b/test/unit/dispenserOracleFeeOutput.test.js @@ -237,12 +237,42 @@ describe('DISPENSER PRICE v1 oracle-fee output capture', function () { outputs: [{ destinationAddress: payTo, vout: 0, amount: '0.00000600' }] }, ]) - // regtest is genesis-on for both gates; mainnet at the base flag-day has capture on - // and set capture still DISARMED, which is the pre-fix behavior to preserve. + // regtest is genesis-on for both gates. mainnet arms set capture at the base gate's own + // instant since the 2026-09-09 ruling, so no mainnet block time sits between the two + // gates any more: the pre-fix single-pick behavior is reached by disarming the set gate + // in place instead. It stays live code for any network that arms mid-chain, and a + // re-decode of pre-flag-day history must still reproduce it. const ABOVE = { network: 'bitcoin-regtest', blockTime: T0 } const BELOW = { network: 'bitcoin-mainnet', blockTime: ORACLE_FEE_OUTPUT_ACTIVATION.mainnet, feeDestination: null } + // Run `fn` with mainnet set capture disarmed, restoring the ruling's armed value even + // if the body throws, so a failure here cannot leak a null into a later test. + async function withSetCaptureDisarmed(fn){ + const saved = ORACLE_FEE_SET_CAPTURE_ACTIVATION.mainnet + ORACLE_FEE_SET_CAPTURE_ACTIVATION.mainnet = null + try { await fn() } + finally { ORACLE_FEE_SET_CAPTURE_ACTIVATION.mainnet = saved } + assert.strictEqual(ORACLE_FEE_SET_CAPTURE_ACTIVATION.mainnet, + ORACLE_FEE_OUTPUT_ACTIVATION.mainnet, + 'the map must be back to the armed instant after the probe') + } + + it('captures the oracle of a NON-top-ranked open dispenser on ARMED mainnet', async () => { + // The state the 2026-09-09 ruling put mainnet in, driven at the armed instant: the + // refill of the older row captures its own oracle, not the top-ranked one's. + const model = new DispenserModel() + const decoder = buildDecoder(twoOpenThenRefill(ORACLE_A), model, + { network: 'bitcoin-mainnet', blockTime: ORACLE_FEE_SET_CAPTURE_ACTIVATION.mainnet, + feeDestination: null }) + + await decoder.start() + + assert.strictEqual(model.rows.length, 2, 'both creates registered open dispensers') + assert.strictEqual(decoder.captured.length, 1) + assert.strictEqual(decoder.captured[0].destinationAddress, ORACLE_A) + }) + it('captures the oracle of a NON-top-ranked open dispenser above the gate', async () => { const model = new DispenserModel() const decoder = buildDecoder(twoOpenThenRefill(ORACLE_A), model, ABOVE) @@ -281,23 +311,27 @@ describe('DISPENSER PRICE v1 oracle-fee output capture', function () { // The defect itself, pinned. Changing this is a consensus change: a re-decode // of pre-flag-day history must reproduce the output set the fleet wrote live. const model = new DispenserModel() - const decoder = buildDecoder(twoOpenThenRefill(ORACLE_A), model, BELOW) + await withSetCaptureDisarmed(async () => { + const decoder = buildDecoder(twoOpenThenRefill(ORACLE_A), model, BELOW) - await decoder.start() + await decoder.start() - assert.strictEqual(model.rows.length, 2, 'both creates registered open dispensers') - assert.strictEqual(decoder.captured.length, 0, - 'below the gate the wrong oracle is resolved and no output is persisted') + assert.strictEqual(model.rows.length, 2, 'both creates registered open dispensers') + assert.strictEqual(decoder.captured.length, 0, + 'below the gate the wrong oracle is resolved and no output is persisted') + }) }) it('keeps the legacy single-pick below the gate: the top-ranked row still captures', async () => { const model = new DispenserModel() - const decoder = buildDecoder(twoOpenThenRefill(ORACLE_B), model, BELOW) + await withSetCaptureDisarmed(async () => { + const decoder = buildDecoder(twoOpenThenRefill(ORACLE_B), model, BELOW) - await decoder.start() + await decoder.start() - assert.strictEqual(decoder.captured.length, 1) - assert.strictEqual(decoder.captured[0].destinationAddress, ORACLE_B) + assert.strictEqual(decoder.captured.length, 1) + assert.strictEqual(decoder.captured[0].destinationAddress, ORACLE_B) + }) }) }) diff --git a/test/unit/oracleFeeOutputActivationConformance.test.js b/test/unit/oracleFeeOutputActivationConformance.test.js index 84d1068..b855014 100644 --- a/test/unit/oracleFeeOutputActivationConformance.test.js +++ b/test/unit/oracleFeeOutputActivationConformance.test.js @@ -96,10 +96,21 @@ describe('ORACLE_FEE_OUTPUT_ACTIVATION conformance', function () { // membership over every open Mode B dispenser of the paying source. That changes the set of // outputs persisted to transaction_outputs, so it is consensus-affecting in both directions: // arming it early on a fleet that has not deployed forks the chain, and arming it in the past -// rewrites agreed history on a re-decode. null means DISARMED, which is the fail-closed -// default a network sits at until its maintainers ratify an instant. +// rewrites agreed history on a re-decode. mainnet is ARMED by the 2026-09-09 ruling at the base +// gate's own instant, the earliest the ordering below permits, and rewrites nothing because the +// indexed mainnet history holds 0 dispensers (measured 2026-09-09). null stays the fail-closed +// reading for any network that has not armed. describe('ORACLE_FEE_SET_CAPTURE_ACTIVATION conformance', function () { + it('arms mainnet at the base gate instant by the 2026-09-09 ruling', function () { + // Teeth for the ruling AND for its ordering constraint in one place: the widening + // starts exactly where capture itself starts, so no mainnet block sits between the two + // gates, and a re-decode of the (dispenser-free) history persists the same output set. + assert.strictEqual(ORACLE_FEE_SET_CAPTURE_ACTIVATION.mainnet, PINNED_MAINNET_ACTIVATION); + assert.strictEqual(ORACLE_FEE_SET_CAPTURE_ACTIVATION.mainnet, + ORACLE_FEE_OUTPUT_ACTIVATION.mainnet); + }); + it('carries a block time or null (DISARMED) per network, regtest genesis-on', function () { const networks = Object.keys(ORACLE_FEE_SET_CAPTURE_ACTIVATION); assert.deepStrictEqual(networks.sort(), ['mainnet', 'regtest', 'testnet'], From 36165d34f813a640bb475caa8bde11498ea24cc1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 10 Sep 2026 07:18:38 -0700 Subject: [PATCH 3/5] fix(decoder): land the 2026-09-09 fix batch - fix(decoder): pass siblings-ref at both CI call sites so release PRs check siblings at the train branch - fix(decoder): pin the original unique-index migration revision in the checksum rebaseline so a production node stops logging a mismatch Squashed from the local landing commits: e6415ad9 bacefb8c --- .github/workflows/ci.yml | 16 ++++++++++ CHANGELOG.md | 3 ++ src/db.js | 47 +++++++++++++++++++++++++----- test/unit/migration-runner.test.js | 41 ++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8c2469..5af882e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,16 @@ on: jobs: ci: uses: XChain-Platform/.github/.github/workflows/ci-reusable.yml@6f4d39ae85787fc31e90a31588d87610a2c33103 # pin: XChain-Platform/.github @ master 2026-08-14; bump deliberately + with: + # On a release or hotfix PR, check the siblings out at the PR's own + # branch so a release-branch-only change to a file a cross-repo guard + # reads is tested against the train, not against develop. The shared + # workflow otherwise pins siblings at develop for any pull request, + # which is what let this repo's release PRs go red on commits that + # touch neither the guard nor the file it reads. Empty everywhere + # else, which keeps the existing behaviour. Same expression as + # xchain-indexer and xchain-explorer. + siblings-ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || '' }} # Cross-repo drift guards: this repo vendors the canonical coin registry from # xchain-hub, and per-repo CI cannot see sibling repos, so a drifted vendored @@ -109,6 +119,12 @@ jobs: # Same roster (.ci-siblings) and same layout the shared workflow uses. - name: Check out declared sibling repositories uses: XChain-Platform/.github/actions/checkout-siblings@master # one definition for every call site; see the action for why this tracks master + # The ratchet re-runs the unit suite, so it needs the same release/hotfix + # awareness as the gate's own siblings-ref above, or it measures a suite + # whose cross-repo guards disagree with the gate for a reason that exists + # nowhere but in CI. + with: + ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || '' }} - name: Use Node.js 22 uses: actions/setup-node@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf0e0d..039790a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - The health and status payloads carry `node_last_ok_at` and `node_unreachable`, so a coin node that has never answered is visible. +### Fixed +- Nodes that applied the original `2026-05-28-unique-index-tables.sql` are healed instead of logging a checksum mismatch at every startup. + ## [0.16.0] - 2026-09-08 ### Added diff --git a/src/db.js b/src/db.js index 04731da..820bf29 100644 --- a/src/db.js +++ b/src/db.js @@ -2905,9 +2905,10 @@ class Database { // DBs recorded whichever revision they applied first). Executable SQL is // byte-identical across every pinned revision (verified: strip `--` comment // lines and blank lines; the residue hashes identically from first commit to -// HEAD) for every entry EXCEPT the byte-order one at the bottom, which is -// justified by a measured data equivalence instead and carries that argument in -// full at its own entry rather than relying on this blanket sentence. +// HEAD) for every entry EXCEPT two, which are justified by a measured data +// equivalence instead and each carry that argument in full at its own entry +// rather than relying on this blanket sentence: the byte-order one at the +// bottom, and the 8151979 revision of the unique-index one. // Applied fleet-wide through code deploy: both the startup auto-run and // `node src/migrate.js` pass through this heal before the mismatch guard, so no // direct schema_migrations SQL is ever needed. Mirrors xchain-indexer/src/db.js. @@ -2980,12 +2981,42 @@ Database.MIGRATION_CHECKSUM_REBASELINES = { ], to: 'b03b41b6fcabef9c959851ede9b75cc9089cef7c015bdd69cfcea74ad5acea7a', // comment tidy (HEAD) }, - // Comment-only edit: the fleet recorded 50a5e83, which is the revision that ADDED the - // `@mempool_has_ids` guard, so the guarded UPDATEs are what actually ran. 7817e6c then - // added the license header. Stripped residue verified IDENTICAL between 50a5e83 and - // HEAD, so this entry meets the ordinary contract above. + // TWO revisions are pinned here and they are blessed for DIFFERENT reasons, so both are + // stated rather than filed together under the blanket sentence above. + // + // 50a5e83 (8845b9ad): the revision that ADDED the `@mempool_has_ids` guard, so the + // guarded UPDATEs are what actually ran. 7817e6c then added the license header. + // Stripped residue verified IDENTICAL between 50a5e83 and HEAD: ordinary contract. + // + // 8151979 (e1f7df79): the ORIGINAL shipped revision, applied by every node deployed in + // the 2026-06-10 .. 2026-07-10 window (one production BTC node among them, which is why its decoder + // logged the mismatch every startup). Its residue is NOT identical to HEAD's: 50a5e83 + // rewrote four mempool_transactions repoints from bare statements into + // `SET @s := IF(@mempool_has_ids, '', 'DO 0')` + PREPARE/EXECUTE. + // This is therefore a DATA equivalence, not a text one, and it is decided by the + // ledger row itself rather than assumed: + // + // - the recorded row EXISTS, so the file ran to completion on that database; + // - the 8151979 form references mempool_transactions.source_id / destination_id / + // tx_hash_id unguarded, so completion is only possible where those columns were + // present (otherwise MariaDB aborts the statement with errno 1054 and the runner + // records nothing); + // - columns present is exactly the branch HEAD's guard takes (@mempool_has_ids = 1), + // and the string it then PREPAREs is the same UPDATE / DELETE text. + // + // So on every database this heals, the two revisions executed the identical statements. + // The guard only diverges on the post-2026-06-15-mempool-raw-strings schema, where the + // old form could not have been recorded as applied in the first place. + // + // The check to re-run before extending this entry to a new database: if a row for this + // file can ever be present WITHOUT the migration having completed (a runner that stamps + // before applying, or a hand-inserted ledger row), the argument above does not carry and + // the schema must be reconciled instead. '2026-05-28-unique-index-tables.sql': { - from: '8845b9addc0990b0433f8862969b57cb472535474b4b4d5576c408db777b57ce', // 50a5e83..7817e6c^ + from: [ + 'e1f7df7973881b6fcaa5535fe5aca86b82bb7f45fa4e7e5fdcf9c5859c468207', // 8151979..50a5e83^ + '8845b9addc0990b0433f8862969b57cb472535474b4b4d5576c408db777b57ce', // 50a5e83..7817e6c^ + ], to: '4f7f53ea5423d5ad50e0a2136243dab9e215033e6a110c7b47e66ba5361d44c2', // 7817e6c (HEAD) }, // THE ONE ENTRY THAT DOES NOT MEET THE BYTE-IDENTICAL-SQL CONTRACT ABOVE, said plainly diff --git a/test/unit/migration-runner.test.js b/test/unit/migration-runner.test.js index fefd624..e2ebaf9 100644 --- a/test/unit/migration-runner.test.js +++ b/test/unit/migration-runner.test.js @@ -517,6 +517,47 @@ describe('runMigrations() checksum re-bless path @regression', function () { assert.deepStrictEqual(updates, []); assert.deepStrictEqual(res, { applied: [], pending: [], baselined: [], lockSkipped: false }); }); + + // a production BTC decoder recorded 2026-05-28-unique-index-tables.sql at its ORIGINAL + // shipped revision (8151979, deployed 2026-06-10 .. 2026-07-10), which predates the + // `@mempool_has_ids` guard revision the table pinned. Only the guard revision was + // blessed, so that node tripped the immutability guard at every startup. Drive the real + // runMigrations() over the real committed file with the historical hash in the ledger: + // it must heal to the committed sha256 rather than throw. + describe('2026-05-28-unique-index-tables.sql historical revisions', function () { + + const REAL_FILE = '2026-05-28-unique-index-tables.sql'; + // sha256 of the file as shipped by 8151979, before the mempool guard landed. This is + // what the affected fleet DBs carry in schema_migrations; it is a fixed historical + // fact, so it is pinned here rather than recomputed. + const SHIPPED_8151979 = 'e1f7df7973881b6fcaa5535fe5aca86b82bb7f45fa4e7e5fdcf9c5859c468207'; + const GUARDED_50a5e83 = '8845b9addc0990b0433f8862969b57cb472535474b4b4d5576c408db777b57ce'; + + const realPath = path.join(__dirname, '..', '..', 'src', 'sql', 'migrations', REAL_FILE); + const realContent = fs.readFileSync(realPath, 'utf8'); + const realSum = crypto.createHash('sha256').update(realContent).digest('hex'); + + for (const [label, recorded] of [ + ['the original shipped revision (8151979)', SHIPPED_8151979], + ['the guarded revision (50a5e83)', GUARDED_50a5e83], + ]) { + it('heals a ledger recording ' + label, async function () { + const root = tmpMigrationsDir(REAL_FILE, realContent); + const { db, updates } = makeDb(root, [{ name: REAL_FILE, checksum: recorded }]); + const res = await db.runMigrations({ includeManual: true }); + assert.deepStrictEqual(updates, [[realSum, REAL_FILE]], + 'expected the ledger to be healed to the committed checksum'); + assert.deepStrictEqual(res.applied, [], 'an already-applied file must not re-run'); + }); + } + + it('still fails closed on a revision that was never shipped', async function () { + const root = tmpMigrationsDir(REAL_FILE, realContent); + const { db, updates } = makeDb(root, [{ name: REAL_FILE, checksum: 'd'.repeat(64) }]); + await assert.rejects(() => db.runMigrations({ includeManual: true }), /content CHANGED/); + assert.deepStrictEqual(updates, [], 'an unpinned hash must not be healed'); + }); + }); }); // Functional coverage of the per-file scoping (--file / opts.only): drive the real From a6c633a3673c6a1df814dafac7ab2cc3ec506c24 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 10 Sep 2026 07:55:17 -0700 Subject: [PATCH 4/5] fix(decoder): exit cleanly on SIGTERM instead of parking until docker's SIGKILL The signal handler only set stopFlag. The parse loop did break at its next block boundary, but the Express listener and the MariaDB pool kept the event loop alive and nothing called exit, so every `docker stop` of a decoder ended in exit 137, and a kill mid-rollback is what resets the dispenser purge budget. src/shutdown.js is the indexer's bounded drain: flip the health flag, stop(), close the listener and wait for the loop together, close both pools last, and hard-exit non-zero at SHUTDOWN_TIMEOUT_MS (default 100 s, under the 120 s budget xchain-node now gives a decoder). Database gains the close() it never had. The chaos source pin follows the handler into the drain. --- CHANGELOG.md | 1 + src/api.js | 39 ++-- src/db.js | 15 ++ src/shutdown.js | 187 +++++++++++++++++ test/chaos/CE08-signalHandling.chaos.js | 6 +- test/unit/shutdown.test.js | 265 ++++++++++++++++++++++++ 6 files changed, 497 insertions(+), 16 deletions(-) create mode 100644 src/shutdown.js create mode 100644 test/unit/shutdown.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 039790a..fa68faa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The health and status payloads carry `node_last_ok_at` and `node_unreachable`, so a coin node that has never answered is visible. ### Fixed +- `docker stop` now ends in a clean exit: the SIGTERM drain breaks the parse loop at a block boundary, closes the API listener and both database pools and exits 0 under a `SHUTDOWN_TIMEOUT_MS` hard-exit bound, where before the process parked until docker's SIGKILL. - Nodes that applied the original `2026-05-28-unique-index-tables.sql` are healed instead of logging a checksum mismatch at every startup. ## [0.16.0] - 2026-09-08 diff --git a/src/api.js b/src/api.js index 823cd8e..890a4cc 100644 --- a/src/api.js +++ b/src/api.js @@ -38,6 +38,7 @@ const bodyParser = require('body-parser'); const helmet = require('helmet'); const cors = require('cors'); const rateLimit = require('express-rate-limit'); +const { createShutdown, createDecoderDrain } = require('./shutdown'); const XChainDecoder = require('./XChainDecoder'); const { resolveFeeDestination } = require('./feeDestination'); const jsonRouter = require('express-json-rpc-router') @@ -248,7 +249,9 @@ async function startApi(){ const decoder = new XChainDecoder(NETWORK, DB_URL, DB_PORT, DECODER_DB_NAME, DECODER_DB_USER, DB_PASSWORD, NODE_URL, NODE_PORT, NODE_USER, NODE_PASSWORD, AUX_POW, FEE_DESTINATION); let decoderRunning = true let decoderError = null - decoder.start().then(() => { + // start() awaits the parse loop, so this promise SETTLES when the loop breaks: + // on a fatal error here, or on the stopFlag the drain sets at a block boundary. + const decoderExited = decoder.start().then(() => { // start() awaits the parse loop, so it RESOLVES only when the loop breaks: // the SIGTERM/stopFlag path, or any fall-through out of `while (true)`. // Without this, decoderRunning only ever went false on a REJECTION, so a @@ -287,18 +290,6 @@ async function startApi(){ process.exit(1) }) - // Graceful shutdown on process signals - const shutdown = () => { - console.log('Received shutdown signal, stopping decoder...') - // Flip BEFORE stop(): stop() only sets stopFlag, and the loop may take a - // whole iteration to notice. A drain must not answer /live with 200 in the - // window between the signal and the loop actually breaking. - decoderRunning = false - decoder.stop() - } - process.on('SIGTERM', shutdown) - process.on('SIGINT', shutdown) - // Crash visibility. Registered inside startApi(), not at module scope: several // unit suites require this module in-process under mocha to reach registerLiveRoute // and makeRpcBatchGuard, and mocha installs its own handlers. A module-scope @@ -585,9 +576,29 @@ async function startApi(){ app.use((req, res, next) => { if (req.body === undefined) req.body = {}; next(); }); app.use(jsonRouter({methods: jsonRpcController})) - app.listen(DECODER_API_PORT, () => { + const server = app.listen(DECODER_API_PORT, () => { console.log('API listening on port '+DECODER_API_PORT); }); + + // Graceful shutdown. node is PID 1 in the image, so `docker stop` delivers + // SIGTERM here. The earlier handler only set stopFlag: the loop broke, but + // this listener and the DB pool kept the process alive and nothing exited, + // so every stop ended in docker's SIGKILL. The drain is bounded by its own + // hard-exit timer (src/shutdown.js) because installing a handler removes + // node's default terminate. + const shutdown = createShutdown({ + drain: createDecoderDrain({ + decoder: decoder, + server: server, + loopSettled: decoderExited, + // Flip BEFORE stop(): stop() only sets stopFlag and the loop may take a + // whole iteration to notice. A drain must not answer /live with 200 in + // the window between the signal and the loop actually breaking. + onDraining: () => { decoderRunning = false } + }) + }) + process.on('SIGTERM', () => shutdown('SIGTERM')) + process.on('SIGINT', () => shutdown('SIGINT')) } // Auto-start only when run directly (node src/api.js), so the module can be required by diff --git a/src/db.js b/src/db.js index 820bf29..7febc10 100644 --- a/src/db.js +++ b/src/db.js @@ -117,6 +117,21 @@ class Database { } + // Drain support (src/shutdown.js): release a transaction connection still + // held, which the drain normally never sees because it waits for the parse + // loop to break at a block boundary, then end the pool so nothing keeps the + // event loop alive. Idempotent: a second call finds no pool and returns. + async close(){ + if(this.transactionConnection){ + try { await this.transactionConnection.release(); } catch(_){} + this.transactionConnection = null; + } + const pool = this.pool; + if(!pool) return; + this.pool = null; + await pool.end(); + } + // Seam over the driver: mariadb's createConnection export is // non-configurable, so tests stub this method instead of the module. _createConnection(connectionParams){ diff --git a/src/shutdown.js b/src/shutdown.js new file mode 100644 index 0000000..0c85bde --- /dev/null +++ b/src/shutdown.js @@ -0,0 +1,187 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * XChain Decoder - Graceful shutdown + * + * Bounded, idempotent drain for SIGTERM/SIGINT, the same shape as the + * indexer's src/shutdown.js. The Dockerfile CMD runs node as PID 1, so + * `docker stop` delivers SIGTERM here. + * + * Before this file the handler in api.js only set the decoder's stopFlag. The + * parse loop did break at its next block boundary, but the Express listener + * and the MariaDB pool kept the event loop alive and nothing called exit, so + * the process parked with the loop stopped until docker's SIGKILL: every stop + * of a decoder, ever, ended in exit 137 (measured by an operator with + * `docker stop -t 180` on a BTC mainnet stack, 2026-09-10). A killed decoder + * mid-rollback is the case that matters, because an interrupted rollback is + * what resets the dispenser purge budget. + * + * Registering a handler REMOVES node's default terminate, so the handler + * carries its own hard-exit timer: a drain that hangs must still end the + * process, or a stop becomes a container that lingers under any supervisor + * with a long grace period, which is strictly worse than the kill. + * + ********************************************************************/ + +// Hard-exit budget for the whole drain. xchain-node stops a decoder with a +// 120 s budget (and stamps it on the container as --stop-timeout), so the +// default sits under that: an overrun that ends in our own logged exit is +// diagnosable, one that ends in the daemon's SIGKILL is not. On a container +// created before the budget existed docker's ten seconds still applies and +// this timer never gets to fire; nothing here can change that from inside. +// SHUTDOWN_TIMEOUT_MS overrides for a slow chain. +const DEFAULT_SHUTDOWN_TIMEOUT_MS = 100000; + +function resolveTimeoutMs(timeoutMs, env){ + if(Number.isFinite(timeoutMs) && timeoutMs > 0) return timeoutMs; + const raw = parseInt((env || process.env).SHUTDOWN_TIMEOUT_MS, 10); + return (Number.isFinite(raw) && raw > 0) ? raw : DEFAULT_SHUTDOWN_TIMEOUT_MS; +} + +// Close an http.Server and resolve once it has stopped listening. Idle keep-alive +// sockets would otherwise hold close() open indefinitely while no request is in +// flight, so they are dropped explicitly; requests already being served finish. +function closeServer(server){ + return new Promise((resolve) => { + if(!server || typeof server.close !== 'function') return resolve(); + let settled = false; + const done = () => { if(!settled){ settled = true; resolve(); } }; + try { + server.close(done); + if(typeof server.closeIdleConnections === 'function') server.closeIdleConnections(); + } catch(_){ + done(); + } + }); +} + +// Best-effort close of a set of Database handles, deduped by identity. A pool +// that refuses to close must not abort the rest of the drain. +async function closeDatabases(handles, log){ + const logger = log || console; + const seen = new Set(); + for(const db of (handles || [])){ + if(!db || typeof db.close !== 'function' || seen.has(db)) continue; + seen.add(db); + try { await db.close(); } + catch(err){ logger.warn('Shutdown: closing a database pool failed: ' + (err && err.message ? err.message : err)); } + } +} + +/** + * Build an idempotent signal handler that runs `drain` under a hard-exit bound. + * + * @param {object} opts + * @param {function} opts.drain async work to finish before exiting + * @param {number} [opts.timeoutMs] hard-exit budget (default SHUTDOWN_TIMEOUT_MS / 100000) + * @param {function} [opts.exit] process-exit seam (tests pass their own) + * @param {object} [opts.log] console-shaped logger + * @returns {function(string): void} handler to register on SIGTERM / SIGINT + */ +function createShutdown({ drain, timeoutMs, exit, log } = {}){ + const onExit = exit || ((code) => process.exit(code)); + const logger = log || console; + const budget = resolveTimeoutMs(timeoutMs); + let signalled = false; + + return function shutdown(signal){ + // A second signal must not restart the sequence: re-entering would call + // stop() and close pools underneath a drain already using them. + if(signalled){ + logger.log('Shutdown already in progress; ignoring ' + (signal || 'signal') + '.'); + return; + } + signalled = true; + logger.log('Received ' + (signal || 'signal') + ', draining (hard exit in ' + budget + 'ms)...'); + + let finished = false; + const timer = setTimeout(() => { + if(finished) return; + finished = true; + // Non-zero: the drain did NOT complete, so work was cut off exactly as a + // SIGKILL would have cut it. A clean drain below exits 0. + logger.error('Shutdown drain exceeded ' + budget + 'ms; exiting hard.'); + onExit(1); + }, budget); + + Promise.resolve().then(() => drain()).then( + () => { + if(finished) return; + finished = true; + clearTimeout(timer); + logger.log('Shutdown drain complete; exiting.'); + onExit(0); + }, + (err) => { + if(finished) return; + finished = true; + clearTimeout(timer); + logger.error('Shutdown drain failed:', err); + onExit(1); + } + ); + }; +} + +/** + * The decoder's drain, as its own function so the exit path is unit-testable. + * + * Order is load-bearing: + * 1. flip the health flag FIRST: stop() only sets stopFlag and the parse loop + * can take a whole block to notice, and /live must not answer 200 for a + * decoder that is leaving. + * 2. stop() the decoder (stopFlag; the loop clears its own mempool interval + * on the way out). + * 3. drain the HTTP server and the parse loop together; the loop breaks at + * the top of its iteration, never mid-transaction. + * 4. close the two DB pools LAST, since both of the above still need them. + * + * The wait on step 3 is unbounded HERE and bounded by the caller's hard-exit + * timer, because both ways it can overrun (a block or a rollback slower than + * the budget, or a boot still inside the DB connect retry that never entered + * the loop) should end in a logged non-zero exit, not a clean one. + * + * @param {object} opts + * @param {object} opts.decoder XChainDecoder instance + * @param {object} opts.server http.Server returned by app.listen() + * @param {Promise} [opts.loopSettled] promise that settles when start()'s loop exits + * @param {function} [opts.onDraining] flips the api-local decoderRunning flag + * @param {object} [opts.log] console-shaped logger + */ +function createDecoderDrain({ decoder, server, loopSettled, onDraining, log } = {}){ + const logger = log || console; + return async function drain(){ + if(typeof onDraining === 'function') onDraining(); + if(decoder && typeof decoder.stop === 'function') decoder.stop(); + + await Promise.all([ + closeServer(server), + // start() resolves when the parse loop breaks on stopFlag. It is already + // .catch()'d at the call site (a fatal decoder error exits 1 there), so a + // rejection here is that same handled error and must not fail the drain. + Promise.resolve(loopSettled).catch(() => {}) + ]); + + await closeDatabases(decoder ? [decoder.db, decoder.mempoolDb] : [], logger); + }; +} + +module.exports = { + DEFAULT_SHUTDOWN_TIMEOUT_MS, + resolveTimeoutMs, + closeServer, + closeDatabases, + createShutdown, + createDecoderDrain +}; diff --git a/test/chaos/CE08-signalHandling.chaos.js b/test/chaos/CE08-signalHandling.chaos.js index 2851e60..0bedf27 100644 --- a/test/chaos/CE08-signalHandling.chaos.js +++ b/test/chaos/CE08-signalHandling.chaos.js @@ -144,10 +144,12 @@ describe('CE-08: Signal Handling and Graceful Shutdown', function () { assert.ok(/decoder\.start\(\)\s*\.then\(/.test(apiSource), 'decoder.start() should have a .then() that observes a clean loop exit') - const shutdownBody = apiSource.slice(apiSource.indexOf('const shutdown = () =>'), + const shutdownBody = apiSource.slice(apiSource.indexOf('createDecoderDrain('), apiSource.indexOf("process.on('SIGTERM'")) assert.ok(shutdownBody.includes('decoderRunning = false'), - 'shutdown() should mark the decoder not-running before stopping it') + 'the drain should mark the decoder not-running before stopping it') + // The drain itself (flag first, stop, listener and loop, pools last, hard + // exit) is pinned behaviourally in test/unit/shutdown.test.js. // Whether /live actually turns 503 on a silent heartbeat is pinned // behaviourally against the shipped route in // test/unit/decoderLiveHeartbeat.test.js. A grep for `isPollSilent` here would diff --git a/test/unit/shutdown.test.js b/test/unit/shutdown.test.js new file mode 100644 index 0000000..ac5acfb --- /dev/null +++ b/test/unit/shutdown.test.js @@ -0,0 +1,265 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Pins the container exit path. `docker stop` sends SIGTERM to node (PID 1 via the +// Dockerfile's exec-form CMD) and this drain is everything between that signal and +// the process ending. Before it existed the handler only set stopFlag, the listener +// and the pool kept the process alive, and every stop ended in SIGKILL (exit 137). + +const assert = require('assert'); +const { createShutdown, createDecoderDrain, closeServer, closeDatabases, resolveTimeoutMs, DEFAULT_SHUTDOWN_TIMEOUT_MS } = require('../../src/shutdown'); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +async function waitUntil(predicate, timeoutMs = 5000, intervalMs = 10){ + const deadline = Date.now() + timeoutMs; + while(Date.now() < deadline){ + if(await predicate()) return true; + await sleep(intervalMs); + } + return Boolean(await predicate()); +} + +const silentLog = { log(){}, warn(){}, error(){} }; + +// Minimal XChainDecoder stand-in: records call ORDER, because the ordering is the +// contract (health flag before stop, pools closed last). +function makeDecoder(order){ + let resolveLoop; + const loop = new Promise((res) => { resolveLoop = res; }); + const db = (name) => ({ + closed: false, + async close(){ this.closed = true; order.push('close:' + name); } + }); + return { + stopped: false, + db: db('db'), + mempoolDb: db('mempoolDb'), + loop, + // The real stop() only sets stopFlag; the loop breaks at the top of its next + // iteration, which the test models by resolving the loop promise later. + stop(){ this.stopped = true; order.push('stop'); setImmediate(resolveLoop); } + }; +} + +function makeServer(order){ + return { + closed: false, + idleDropped: false, + close(cb){ this.closed = true; order.push('server.close'); setImmediate(cb); }, + closeIdleConnections(){ this.idleDropped = true; } + }; +} + +describe('graceful shutdown', function(){ + + describe('createShutdown', function(){ + + it('runs the drain and exits zero when it completes', async function(){ + const codes = []; + let drained = false; + const shutdown = createShutdown({ + drain: async () => { drained = true; }, + exit: (c) => codes.push(c), + log: silentLog + }); + shutdown('SIGTERM'); + assert.ok(await waitUntil(() => codes.length > 0), 'timed out waiting for the clean drain to reach exit()'); + assert.strictEqual(drained, true); + assert.deepStrictEqual(codes, [0]); + }); + + it('is idempotent: a second signal does not re-enter the drain', async function(){ + const codes = []; + let calls = 0; + const shutdown = createShutdown({ + drain: async () => { calls++; await sleep(20); }, + exit: (c) => codes.push(c), + log: silentLog + }); + shutdown('SIGTERM'); + shutdown('SIGTERM'); + shutdown('SIGINT'); + assert.ok(await waitUntil(() => codes.length > 0), 'timed out waiting for the single in-flight drain to reach exit()'); + assert.strictEqual(calls, 1, 'drain must run exactly once'); + assert.deepStrictEqual(codes, [0]); + }); + + // The reason the handler is safe to install at all: registering one REMOVES + // node's default terminate, so without this bound a hung drain turns every + // stop into a container that lingers until the supervisor's grace expires. + it('hard-exits non-zero when the drain overruns its budget', async function(){ + const codes = []; + const shutdown = createShutdown({ + drain: () => new Promise(() => {}), // never settles + timeoutMs: 20, + exit: (c) => codes.push(c), + log: silentLog + }); + shutdown('SIGTERM'); + assert.ok(await waitUntil(() => codes.length > 0), 'timed out waiting for the hard-exit timer to fire'); + assert.deepStrictEqual(codes, [1]); + }); + + it('exits non-zero when the drain throws, and only once', async function(){ + const codes = []; + const shutdown = createShutdown({ + drain: async () => { throw new Error('pool refused to close'); }, + timeoutMs: 50, + exit: (c) => codes.push(c), + log: silentLog + }); + shutdown('SIGTERM'); + // Outlive the 50ms hard-exit timer to prove it was cleared. + await sleep(120); + assert.deepStrictEqual(codes, [1]); + }); + + it('does not fire the hard-exit timer after a clean drain', async function(){ + const codes = []; + const shutdown = createShutdown({ + drain: async () => {}, + timeoutMs: 20, + exit: (c) => codes.push(c), + log: silentLog + }); + shutdown('SIGTERM'); + await sleep(80); + assert.deepStrictEqual(codes, [0], 'a cleared timer must not add a second exit'); + }); + }); + + describe('resolveTimeoutMs', function(){ + it('prefers an explicit budget, then the env var, then the default', function(){ + assert.strictEqual(resolveTimeoutMs(1234, {}), 1234); + assert.strictEqual(resolveTimeoutMs(undefined, { SHUTDOWN_TIMEOUT_MS: '4321' }), 4321); + assert.strictEqual(resolveTimeoutMs(undefined, {}), DEFAULT_SHUTDOWN_TIMEOUT_MS); + assert.strictEqual(resolveTimeoutMs(0, { SHUTDOWN_TIMEOUT_MS: 'nonsense' }), DEFAULT_SHUTDOWN_TIMEOUT_MS); + }); + + // xchain-node stops a decoder with a 120 s budget and stamps it on the + // container; the drain's own bound must end in a LOGGED exit before that. + it('stays under the 120 s budget xchain-node gives a decoder', function(){ + assert.ok(DEFAULT_SHUTDOWN_TIMEOUT_MS < 120000, + 'a budget at or above the container stop-timeout ends in the daemon\'s SIGKILL, which is what this replaces'); + assert.ok(DEFAULT_SHUTDOWN_TIMEOUT_MS > 10000, + 'a block boundary on a mainnet chain is not reached in docker\'s ten seconds'); + }); + }); + + describe('closeServer', function(){ + it('resolves once, and drops idle keep-alive sockets that would hold close() open', async function(){ + const order = []; + const server = makeServer(order); + await closeServer(server); + assert.strictEqual(server.closed, true); + assert.strictEqual(server.idleDropped, true); + }); + + it('resolves on a missing or closeless server rather than hanging the drain', async function(){ + await closeServer(null); + await closeServer({}); + }); + }); + + describe('closeDatabases', function(){ + it('closes each handle once and survives one that refuses', async function(){ + let closes = 0; + const ok = { async close(){ closes++; } }; + const bad = { async close(){ throw new Error('refused'); } }; + await closeDatabases([ok, ok, bad, null, {}], silentLog); + assert.strictEqual(closes, 1); + }); + }); + + describe('createDecoderDrain', function(){ + + it('flips health, stops the decoder, drains the server and loop, then closes both pools', async function(){ + const order = []; + const decoder = makeDecoder(order); + const server = makeServer(order); + let running = true; + + const drain = createDecoderDrain({ + decoder, + server, + loopSettled: decoder.loop, + onDraining: () => { running = false; order.push('health-flag'); }, + log: silentLog + }); + await drain(); + + assert.strictEqual(running, false, '/live must stop reporting the decoder running'); + assert.strictEqual(decoder.stopped, true); + assert.strictEqual(server.closed, true); + assert.ok(order.indexOf('health-flag') < order.indexOf('stop'), 'health flag must flip before stop(), not after'); + for(const name of ['db', 'mempoolDb']){ + assert.ok(order.indexOf('close:' + name) > order.indexOf('server.close'), name + ' must close after the server has drained'); + assert.ok(order.indexOf('close:' + name) > order.indexOf('stop'), name + ' must close after the parse loop was told to stop'); + } + assert.ok(decoder.db.closed && decoder.mempoolDb.closed); + }); + + it('waits for the parse loop to break before closing pools', async function(){ + const order = []; + const decoder = makeDecoder(order); + const server = makeServer(order); + + let breakLoop; + const loop = new Promise((res) => { breakLoop = res; }); + const drain = createDecoderDrain({ decoder, server, loopSettled: loop, log: silentLog }); + + let settled = false; + const running = drain().then(() => { settled = true; }); + + await sleep(30); + assert.strictEqual(settled, false, 'the drain must not finish while the parse loop is mid-block'); + assert.strictEqual(decoder.db.closed, false, 'closing a pool under an open block transaction is the exact abort this fix removes'); + + breakLoop(); + await running; + assert.strictEqual(decoder.db.closed, true); + }); + + it('survives a rejected loop promise', async function(){ + const order = []; + const decoder = makeDecoder(order); + const server = makeServer(order); + const drain = createDecoderDrain({ + decoder, server, + loopSettled: Promise.reject(new Error('fatal decoder error')), + log: silentLog + }); + await drain(); + assert.strictEqual(decoder.db.closed, true); + }); + + it('drains a partially-built process without throwing', async function(){ + const drain = createDecoderDrain({ decoder: null, server: null, log: silentLog }); + await drain(); + }); + }); + + // The Database class had no close() at all, which is half of why the process + // could not exit: the pool's sockets kept the event loop alive. + describe('Database.close()', function(){ + it('ends the pool once and releases a held transaction connection first', async function(){ + const Database = require('../../src/db'); + const db = Object.create(Database.prototype); + const calls = []; + db.transactionConnection = { async release(){ calls.push('release'); } }; + db.pool = { async end(){ calls.push('end'); } }; + await db.close(); + await db.close(); + assert.deepStrictEqual(calls, ['release', 'end']); + assert.strictEqual(db.transactionConnection, null); + assert.strictEqual(db.pool, null); + }); + }); +}); From f06abac2dd20bd0dadb9724a0b9e71290190160e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 10 Sep 2026 11:18:35 -0700 Subject: [PATCH 5/5] release: v0.17.0 Bumps the package and lockfile to 0.17.0 and dates the 0.17.0 changelog entry. The vendored mainnet activation twins ship armed at genesis. --- CHANGELOG.md | 5 +++++ README.md | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa68faa..2fa49a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.17.0] - 2026-09-10 + ### Added - The health and status payloads carry `node_last_ok_at` and `node_unreachable`, so a coin node that has never answered is visible. +### Changed +- The vendored mainnet activation twins are armed at genesis under the 2026-09-09 ruling. + ### Fixed - `docker stop` now ends in a clean exit: the SIGTERM drain breaks the parse loop at a block boundary, closes the API listener and both database pools and exits 0 under a `SHUTDOWN_TIMEOUT_MS` hard-exit bound, where before the process parked until docker's SIGKILL. - Nodes that applied the original `2026-05-28-unique-index-tables.sql` are healed instead of logging a checksum mismatch at every startup. diff --git a/README.md b/README.md index 9a92047..aa586f0 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Version - Tests + Tests Node License

@@ -113,7 +113,7 @@ neither source sets one, so these defaults hold on an unconfigured box: | `npm run migrate` | Apply pending database migrations (auto + manual; `--file ` scopes to specific migration(s)) | | `npm run ci` | The full no-external-services gate: unit, security, smoke, regression, chaos, and a 100-iteration fuzz pass (about a minute) | | `npm run test:smoke` | Smoke tests (58 tests, no external services) | -| `npm run test:unit` | Unit tests (1,549 tests, no external services) | +| `npm run test:unit` | Unit tests (1,593 tests, no external services) | | `npm run test:security` | Security tests (83 tests, no external services) | | `npm run test:integration` | Integration tests (30 tests; brings up its own throwaway regtest node and MariaDB, requires Docker) | | `npm run test:e2e` | End-to-end tests (72 tests; brings up its own throwaway regtest node and MariaDB on separate ports, requires Docker) | diff --git a/package-lock.json b/package-lock.json index 9a4b230..80f213e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-decoder", - "version": "0.16.0", + "version": "0.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-decoder", - "version": "0.16.0", + "version": "0.17.0", "license": "AGPL-3.0-or-later", "dependencies": { "axios": "^1.18.1", diff --git a/package.json b/package.json index 91646cf..d669edc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xchain-decoder", "description": "xchain-decoder decodes XChain platform transactions from a given blockchain and populates a database with the decoded data.", - "version": "0.16.0", + "version": "0.17.0", "license": "AGPL-3.0-or-later", "repository": { "type": "git",