Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ 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]

## [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.

## [0.16.0] - 2026-09-08

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<p align="center">
<img src="https://img.shields.io/badge/version-0.16.0-blue" alt="Version">
<img src="https://img.shields.io/badge/tests-2%2C030%2B%20passing-brightgreen" alt="Tests">
<img src="https://img.shields.io/badge/tests-2%2C074%2B%20passing-brightgreen" alt="Tests">
<img src="https://img.shields.io/badge/node-%3E%3D22-green" alt="Node">
<img src="https://img.shields.io/badge/license-AGPL--3.0--or--later-blue" alt="License">
</p>
Expand Down Expand Up @@ -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 <name>` 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) |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,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",
Expand Down
54 changes: 53 additions & 1 deletion src/BlockchainConnector.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
module.exports.envInt = envInt
// Exported so the reachability reducer can be tested without a connector or a node.
module.exports.nodeReachabilityFrom = nodeReachabilityFrom
71 changes: 56 additions & 15 deletions src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -114,6 +115,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
Expand Down Expand Up @@ -195,6 +216,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
Expand Down Expand Up @@ -224,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
Expand Down Expand Up @@ -263,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
Expand Down Expand Up @@ -415,6 +430,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,
Expand Down Expand Up @@ -529,6 +547,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
Expand All @@ -555,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
Expand All @@ -567,4 +608,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 }
module.exports = { makeRpcBatchGuard, registerLiveRoute, startApi, noteProbeFailure, nodeReachabilityFields, _resetProbeLogState, _ageProbeLogState, PROBE_LOG_WINDOW_MS }
10 changes: 6 additions & 4 deletions src/batchSubCommandCapture.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading