diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2fa49a3..f7d9615 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/Dockerfile b/Dockerfile
index dc0baae..f014665 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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`
diff --git a/README.md b/README.md
index aa586f0..5f112fb 100644
--- a/README.md
+++ b/README.md
@@ -4,8 +4,8 @@
# XChain Platform Decoder
-
-
+
+
@@ -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,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) |
diff --git a/docker-compose.yml b/docker-compose.yml
index 0ce0e24..e83bd00 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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:
diff --git a/package-lock.json b/package-lock.json
index 80f213e..1124c84 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "xchain-decoder",
- "version": "0.17.0",
+ "version": "0.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xchain-decoder",
- "version": "0.17.0",
+ "version": "0.18.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"axios": "^1.18.1",
diff --git a/package.json b/package.json
index d669edc..c3f9791 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.17.0",
+ "version": "0.18.0",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
diff --git a/src/api.js b/src/api.js
index 890a4cc..e82ce45 100644
--- a/src/api.js
+++ b/src/api.js
@@ -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,
diff --git a/src/coins/BTC.js b/src/coins/BTC.js
index c846405..caf3614 100644
--- a/src/coins/BTC.js
+++ b/src/coins/BTC.js
@@ -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,
diff --git a/src/coins/consensus_pin.js b/src/coins/consensus_pin.js
index 53a2113..1adccc8 100644
--- a/src/coins/consensus_pin.js
+++ b/src/coins/consensus_pin.js
@@ -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',
diff --git a/test/unit/statusLagField.test.js b/test/unit/statusLagField.test.js
new file mode 100644
index 0000000..6443e8c
--- /dev/null
+++ b/test/unit/statusLagField.test.js
@@ -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')
+ })
+})