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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.18.0] - 2026-09-11

### Fixed
- `GET /status` publishes a `lag` field, so the node's bootstrap health gate no longer refuses the payload when it falls back to this route.
- The Docker image no longer tries to bake a `.env` file, so the build succeeds on the legacy builder and configuration reaches the container as environment only.

## [0.17.0] - 2026-09-10

### Added
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ COPY ./src /XChainDecoder/src
# is also applied in-process at require time (src/applyBufferutilsPatch.js), so
# non-Docker runs and node_modules refreshes are covered even without this COPY.
COPY ./src/bufferutils.js /XChainDecoder/node_modules/bitcoinjs-lib/src/bufferutils.js
COPY ./.en[v] /XChainDecoder/.env
# No .env is baked in: configuration reaches the container as environment
# (xchain-node at `docker run`, docker-compose.yml via env_file). An optional
# `COPY ./.en[v]` glob here builds only under BuildKit.

# Exec-form node, not `npm run api` (which is this exact command). npm builds an
# npm -> sh -c -> node tree and no wrapper forwards signals, so `docker stop`
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
# XChain Platform Decoder

<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%2C074%2B%20passing-brightgreen" alt="Tests">
<img src="https://img.shields.io/badge/version-0.17.0-blue" alt="Version">
<img src="https://img.shields.io/badge/tests-2%2C077%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,593 tests, no external services) |
| `npm run test:unit` | Unit tests (1,596 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
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ version: '3'
services:
xchain_decoder:
build: .
# The image bakes no .env (Dockerfile); the README's .env is handed to the
# container here instead.
env_file: .env
volumes:
- xchain_decoder_data:/data/
networks:
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.17.0",
"version": "0.18.0",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
Expand Down
7 changes: 7 additions & 0 deletions src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -536,11 +536,18 @@ async function startApi(){
if (dbOk && typeof decoder.checkReorgHalt === 'function'){
try { reorgHalt = await decoder.checkReorgHalt() } catch (e) { noteProbeFailure('reorg_halt', '/status', e) }
}
// RULED 2026-09-01: xchain-node's BootstrapHealthGate refuses any
// /status payload with no lag key (lagKeys: lag_blocks, blockLag, lag) once it
// falls back to this route. getSyncStatus() already reports the same
// node-height-minus-processed-height gap the JSON-RPC health method and /live
// publish, null before the first processed block rather than a false zero.
const syncStatus = decoder.getSyncStatus()
const healthy = decoderRunning && dbOk
res.status(healthy ? 200 : 503).json({
status: healthy ? 'healthy' : 'unhealthy',
db: dbOk,
running: decoderRunning,
lag: syncStatus.lag,
reorg_halted: reorgHalt.halted,
reorg_halt_reason: reorgHalt.reason,
reorg_halted_at: reorgHalt.at,
Expand Down
11 changes: 8 additions & 3 deletions src/coins/BTC.js
Original file line number Diff line number Diff line change
Expand Up @@ -347,9 +347,14 @@ module.exports = {
// capability SLASH block. Mainnet-inert until the EQUIV_HEADER flag-day.
CONFIG_SLASH: { BOUNTY_BPS: 500, BOUNTY_FLOOR: '50.00000000', BOUNTY_CAP: '1000.00000000' },

// Full-node possession-proof / verified-validator tier (NODEPROOF.md). The
// regtest-only env/sidecar overrides are applied by index.js for regtest ONLY;
// mainnet/testnet keep these frozen defaults with no env surface.
// Full-node possession-proof / verified-validator tier; see
// xchain-documentation/protocol/actions/nodeproof.md for the challenge/verdict
// action and xchain-documentation/getting-started/running-a-validator.md for the
// tier walkthrough. REWARD_SHARE 0 and an empty GENESIS_VERIFIERS below are the
// deliberate pre-activation state: the tier ships inert until a later flag-day
// arms it, not a bug. The regtest-only env/sidecar overrides are applied by
// index.js for regtest ONLY; mainnet/testnet keep these frozen defaults with no
// env surface.
FULLNODE: {
CHALLENGE_INTERVAL_BLOCKS: 144,
CONFIRM_DEPTH: 100,
Expand Down
16 changes: 9 additions & 7 deletions src/coins/consensus_pin.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,15 @@ module.exports = {
// untouched and were re-verified as unchanged by this edit.
// REGENERATED 2026-09-01: GAS_SCHEDULE gains SWEEP_BASE,
// SWEEP_PER_ITEM, CALLBACK_BASE and CALLBACK_PER_RECIPIENT, the unified prices
// SWEEP and CALLBACK move onto at the UNIFIED_FEES_SWEEP_CALLBACK flag day (both
// networks UNARMED; regtest genesis-active). GAS_SCHEDULE is hashed whole by
// consensusSubset(), so ADDING a key moves every hash even while the flag that
// reads it is unarmed, and the same one-wave rollout rule as every regeneration
// above applies in full: every service bundling these must ship the new values
// together, and a straggler fail-closes on verifyConsensusPin() at boot rather
// than forking. Mainnet stays null (Phase 6 arms it).
// SWEEP and CALLBACK move onto at the UNIFIED_FEES_SWEEP_CALLBACK flag day
// (mainnet armed at genesis since the genesis arm; testnet armed
// 2026-10-01T00:00:00Z; regtest genesis-active). GAS_SCHEDULE is hashed whole by
// consensusSubset(), so ADDING a key moves every hash regardless of which
// networks have the flag armed, and the same one-wave rollout rule as every
// regeneration above applies in full: every service bundling these must ship
// the new values together, and a straggler fail-closes on verifyConsensusPin()
// at boot rather than forking. CONSENSUS_CONFIG_PIN.mainnet above stays null
// regardless (Phase 6 arms that separate pin).
testnet: {
BTC: 'd3c66a4fb288b2666a2a4fad85200bbeac162bb36fed8a3eddcfc7b2d4d48070',
LTC: 'ae94a951a838e64f9c36e503b978d9b9ad5ea74f7b443465baaabca8f675ea0d',
Expand Down
65 changes: 65 additions & 0 deletions test/unit/statusLagField.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*********************************************************************
*
* 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.
*
**********************************************************************
* GET /status carried no lag field, so xchain-node's
* BootstrapHealthGate refused every payload it fell back to reading from this
* decoder (lagKeys checked there: lag_blocks, blockLag, lag - see
* BootstrapHealthGate.js). RULED 2026-09-01: publish a lag field on /status,
* Proposal B (a bespoke computation) not adopted; reuse the same
* decoder.getSyncStatus().lag the JSON-RPC health method and /live already
* publish, so all three surfaces report the identical gap.
*
* api.js registers GET /status inside startApi(), which opens a real listening
* socket and is not reachable from a unit test (see decoderHaltDiagnostics.test.js's
* "api.js GET /status halt surface (source pin)" block, which pins reorg_halted
* the same way). This file pins the lag field at the source for the same reason.
*/

'use strict'

const assert = require('assert')
const fs = require('fs')
const path = require('path')

const src = fs.readFileSync(path.join(__dirname, '../../src/api.js'), 'utf8')

function statusRouteBody() {
const at = src.indexOf("app.get('/status'")
assert.ok(at > -1, 'GET /status route missing from api.js')
return src.slice(at, at + 3000)
}

describe('api.js GET /status publishes a lag field', function () {
it('reads sync status via decoder.getSyncStatus() before building the response', function () {
const body = statusRouteBody()
assert.ok(/const\s+syncStatus\s*=\s*decoder\.getSyncStatus\(\)/.test(body),
'GET /status must call decoder.getSyncStatus() to know its own lag')
})

it('publishes lag on the JSON body, matching one of the keys BootstrapHealthGate checks', function () {
const body = statusRouteBody()
// BootstrapHealthGate.js lagKeys = ['lag_blocks', 'blockLag', 'lag']; any one
// satisfies the gate, and this route uses the same 'lag' name getSyncStatus()
// and the JSON-RPC health method already publish.
assert.ok(/lag:\s*syncStatus\.lag/.test(body),
'GET /status still has no lag key, so BootstrapHealthGate refuses its fallback payload')
})

it('computes syncStatus before the res.json() call, not after', function () {
const body = statusRouteBody()
const syncAt = body.search(/const\s+syncStatus\s*=\s*decoder\.getSyncStatus\(\)/)
const statusAt = body.search(/status:\s*healthy\s*\?\s*'healthy'/)
assert.ok(syncAt > -1 && statusAt > -1 && syncAt < statusAt,
'syncStatus must be computed before it is spread into the response body')
})
})
Loading