diff --git a/.ci-siblings b/.ci-siblings
index ece25c0c..a4133e21 100644
--- a/.ci-siblings
+++ b/.ci-siblings
@@ -9,9 +9,9 @@
# test/unit/sibling-coverage.test.js, which reports the surface this file leaves
# uncovered; the same gap applies to a sibling that is declared but missing.
#
-# ci-dispatch.sh ships each of these at its origin/master (falling back to local
-# HEAD) and REFUSES the push if one is not a checkout beside this repo, so a
-# declared sibling can never be silently absent.
+# ci-dispatch.sh ships each of these at the pushed branch (falling back to its
+# origin/master, then local HEAD) and REFUSES the push if one is not a checkout
+# beside this repo, so a declared sibling can never be silently absent.
# Consensus-primitive conformance and the state-commitment reference.
xchain-documentation
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 318b0d06..b44f9748 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,7 +22,7 @@ jobs:
uses: XChain-Platform/.github/.github/workflows/ci-reusable.yml@6f4d39ae85787fc31e90a31588d87610a2c33103 # pin: XChain-Platform/.github @ master 2026-08-14; bump deliberately
with:
# A hotfix or release PR compares against siblings at the same branch, not develop.
- siblings-ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || '' }}
+ siblings-ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || github.base_ref || '' }}
# Override the Node version for a repo if ever needed:
# node-version: "20"
@@ -78,7 +78,7 @@ jobs:
- 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
with:
- ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || '' }}
+ ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || github.base_ref || '' }}
- name: Use Node.js 22
uses: actions/setup-node@v4
@@ -154,7 +154,7 @@ jobs:
uses: actions/checkout@v4
with:
repository: XChain-Platform/xchain-hub
- ref: ${{ github.ref == 'refs/heads/master' && 'master' || 'develop' }}
+ ref: ${{ github.base_ref || (github.ref == 'refs/heads/master' && 'master' || 'develop') }}
ssh-key: ${{ secrets.XCHAIN_HUB_DEPLOY_KEY }}
path: xchain-hub
@@ -214,7 +214,7 @@ jobs:
- 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
with:
- ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || '' }}
+ ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || github.base_ref || '' }}
- name: Use Node.js 22
uses: actions/setup-node@v4
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 01e24357..cf7ac3ac 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.20.1] - 2026-09-23
+
+### Fixed
+- Re-slid BTC and DOGE testnet activations with LTC inert, and made replication halt durably on repeated market key conflicts.
+
+
## [0.20.0] - 2026-09-17
### Changed
diff --git a/Dockerfile b/Dockerfile
index b6d7575f..1a2ca0e8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,9 +1,11 @@
-# Pinned to node:22-bookworm, the tag .nvmrc and package.json engines already
-# declare and the sibling service images already build on. `node:latest` floats:
-# xchain-node rebuilds this image on every update (ModuleService.buildAndUp), so
-# a routine rolling upgrade silently moves the runtime off the declared Node 22
-# with no signal anywhere.
-FROM node:22-bookworm
+# Pinned by digest to the node:22.23.2-bookworm image whose V8/ICU build match
+# xchain-vm's consensus runtime pin: the floating node:22-bookworm tag moved to
+# a Node 22 patch whose V8/ICU no longer match, so validators built from it
+# would fail checkConsensusRuntime(). `node:latest` floats too: xchain-node
+# rebuilds this image on every update (ModuleService.buildAndUp), so a routine
+# rolling upgrade silently moves the runtime off the declared Node 22 with no
+# signal anywhere.
+FROM node:22.23.2-bookworm@sha256:dd5847a04b0deee391fa145f1f4c6d214196668b6bcc7988ebed67249f226844
RUN mkdir /XChainIndexerSync/
COPY ./package.json /XChainIndexerSync/package.json
diff --git a/README.md b/README.md
index 196dd717..91388f51 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
# XChain Sync
-
+
diff --git a/bin/ci-full.sh b/bin/ci-full.sh
index 67782992..0450c82e 100755
--- a/bin/ci-full.sh
+++ b/bin/ci-full.sh
@@ -49,7 +49,35 @@ SELF="$(pwd)"
SIB="$(cd .. && pwd)"
FAILED=""
+# >>> ci-tier (generated block; re-run the tier wirer to update) >>>
+# Tier classes. A push grades the FAST tier only: the unit job, the pin and
+# drift guards, and the structure and hygiene checks the hook runs before it
+# dispatches. The tiers named below (coverage re-runs, perf scenarios) are
+# skipped when the gate sets CI_TIER=fast, and each skip is recorded so the
+# closing verdict can never claim a green it did not earn. Nothing stops
+# being graded: a scheduled sweep re-runs this same script with CI_TIER=full
+# on every repo every three hours and before any release or deploy, and a
+# red there is tracked down and fixed first. CI_TIER is unset for a hand
+# run, so a bare `npm run ci:full` still runs every tier as it always did.
+CI_TIER_FULL_ONLY=(
+ "coverage ratchet (coverage:check)"
+)
+DEFERRED=""
+ci_tier_deferred() {
+ [ "${CI_TIER:-full}" = "fast" ] || return 1
+ local t
+ for t in ${CI_TIER_FULL_ONLY[@]+"${CI_TIER_FULL_ONLY[@]}"}; do
+ if [ "$t" = "$1" ]; then
+ DEFERRED="$DEFERRED [$1]"
+ echo; echo "ci:full ===== $1 DEFERRED (CI_TIER=fast, runs in the full sweep) ====="
+ return 0
+ fi
+ done
+ return 1
+}
+# <<< ci-tier <<<
run_tier() {
+ ci_tier_deferred "$1" && return 0 # ci-tier guard (generated)
local name="$1"; shift
echo; echo "ci:full ===== $name ====="
if "$@"; then
@@ -97,12 +125,13 @@ run_tier "ci" npm run ci
# helper already defaults to those ports and credentials, so no env override
# is needed once the stack is up.
E2E_COMPOSE="test/e2e/docker-compose.e2e.yml"
+E2E_DB_PORT_RESOLVED="$(node bin/fixture-ports.js port E2E_DB_PORT)" || exit 1
e2e_compose_down() {
- docker compose -f "$E2E_COMPOSE" down -v >/dev/null 2>&1
+ node bin/fixture-ports.js compose "$E2E_COMPOSE" down -v >/dev/null 2>&1
}
trap e2e_compose_down EXIT
run_tier "e2e: bring up service containers (source-db, replica-db)" \
- docker compose -f "$E2E_COMPOSE" up -d --wait
+ node bin/fixture-ports.js compose "$E2E_COMPOSE" up -d --wait
# Cross-repo consensus drift guards (rollback-coverage and friends) live in
# the unit tier but the shared `ci` job never checks out a sibling, so they
@@ -125,7 +154,7 @@ run_tier "e2e: e2e tier (test:e2e:ci)" npm run test:e2e:ci
# Reuses source-db (:23306) with the admin credentials, not the e2e
# xchain-node user, matching the workflow step exactly.
run_tier "e2e: integration tier (green suites, test:integration:ci)" \
- env TEST_DB_HOST=127.0.0.1 TEST_DB_PORT=23306 TEST_DB_USER=root TEST_DB_PASS=test \
+ env TEST_DB_HOST=127.0.0.1 TEST_DB_PORT="$E2E_DB_PORT_RESOLVED" TEST_DB_USER=root TEST_DB_PASS=test \
npm run test:integration:ci
run_tier "e2e: tear down service containers" e2e_compose_down
@@ -156,8 +185,20 @@ run_tier "identity pin (armed map, vendored coins)" node bin/pin-identity.js --c
run_tier "coverage ratchet (coverage:check)" npm run coverage:check
echo
+# >>> ci-tier summary (generated) >>>
+echo "ci:full: tier class ${CI_TIER:-full}"
+if [ -n "${DEFERRED:-}" ]; then
+ echo "ci:full: DEFERRED to the full sweep:$DEFERRED"
+fi
+# <<< ci-tier summary <<<
if [ -n "$FAILED" ]; then
echo "ci:full: RED tiers:$FAILED"
exit 1
fi
-echo "ci:full: all tiers green (same set GitHub CI runs)"
+# >>> ci-tier verdict (generated) >>>
+if [ "${CI_TIER:-full}" = "fast" ]; then
+ echo "ci:full: all FAST tiers green; the DEFERRED tiers above were NOT graded here"
+else
+ echo "ci:full: all tiers green (same set GitHub CI runs)"
+fi
+# <<< ci-tier verdict <<<
diff --git a/bin/fixture-ports.js b/bin/fixture-ports.js
new file mode 100644
index 00000000..fe9927b4
--- /dev/null
+++ b/bin/fixture-ports.js
@@ -0,0 +1,106 @@
+#!/usr/bin/env node
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const REPO = path.join(__dirname, '..');
+const SPECS = Object.freeze({
+ 'test/e2e/docker-compose.e2e.yml': Object.freeze({
+ E2E_DB_PORT: 23306,
+ E2E_REPLICA_DB_PORT: 23307,
+ E2E_SOURCE2_DB_PORT: 23308
+ }),
+ 'test/chaos/fixtures/docker-compose.chaos.yml': Object.freeze({
+ SOURCE_DIRECT_PORT: 33065,
+ TOXIPROXY_PORT: 8474,
+ SOURCE_PROXY_PORT: 33060,
+ REPLICA_PROXY_PORT: 33061,
+ WS_PROXY_PORT: 33062
+ })
+});
+
+function integer(value, label) {
+ const text = String(value);
+ if (!/^(0|[1-9][0-9]*)$/.test(text)) {
+ throw new Error(`${label} must be a non-negative integer, got ${JSON.stringify(text)}`);
+ }
+ return Number(text);
+}
+
+function offset(env) {
+ const source = env || process.env;
+ return source.CI_PORT_OFFSET === undefined || source.CI_PORT_OFFSET === ''
+ ? 0
+ : integer(source.CI_PORT_OFFSET, 'CI_PORT_OFFSET');
+}
+
+function basePort(name) {
+ for (const spec of Object.values(SPECS)) {
+ if (Object.prototype.hasOwnProperty.call(spec, name)) return spec[name];
+ }
+ throw new Error(`unknown fixture port ${name}`);
+}
+
+function port(name, env) {
+ const source = env || process.env;
+ const value = source[name] === undefined || source[name] === ''
+ ? basePort(name) + offset(source)
+ : integer(source[name], name);
+ if (value < 1 || value > 65535) throw new Error(`${name} resolves outside the TCP port range: ${value}`);
+ return value;
+}
+
+function composeEnvironment(file, env) {
+ const spec = SPECS[file];
+ if (!spec) throw new Error(`unknown fixture compose file ${file}`);
+ const result = { ...(env || process.env) };
+ for (const name of Object.keys(spec)) result[name] = String(port(name, result));
+ return result;
+}
+
+function render(file, env) {
+ const spec = SPECS[file];
+ if (!spec) throw new Error(`unknown fixture compose file ${file}`);
+ let source = fs.readFileSync(path.join(REPO, file), 'utf8');
+ for (const [name, base] of Object.entries(spec)) {
+ source = source.split(`\${${name}:-${base}}`).join(String(port(name, env)));
+ }
+ return source;
+}
+
+function compose(file, args, env) {
+ const result = spawnSync('docker', ['compose', '-f', file, ...args], {
+ cwd: REPO,
+ env: composeEnvironment(file, env),
+ stdio: 'inherit'
+ });
+ if (result.error) throw result.error;
+ return result.status === null ? 1 : result.status;
+}
+
+function main(argv) {
+ const [command, name, ...args] = argv;
+ if (command === 'render' && name && args.length === 0) {
+ process.stdout.write(render(name));
+ return 0;
+ }
+ if (command === 'port' && name && args.length === 0) {
+ process.stdout.write(String(port(name)) + '\n');
+ return 0;
+ }
+ if (command === 'compose' && name && args.length > 0) return compose(name, args);
+ throw new Error('usage: fixture-ports.js ');
+}
+
+if (require.main === module) {
+ try {
+ process.exitCode = main(process.argv.slice(2));
+ } catch (err) {
+ console.error(err.message);
+ process.exitCode = 1;
+ }
+}
+
+module.exports = { SPECS, offset, port, composeEnvironment, render };
diff --git a/bin/markets-id-parity.js b/bin/markets-id-parity.js
new file mode 100644
index 00000000..9aedc0f2
--- /dev/null
+++ b/bin/markets-id-parity.js
@@ -0,0 +1,187 @@
+#!/usr/bin/env node
+/*********************************************************************
+ *
+ * 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.
+ *
+ **********************************************************************
+ *
+ * Do the configured sync sources agree on markets.id for each traded pair?
+ *
+ * A replica applies the markets full-dump as an UPSERT that carries the
+ * source's AUTO_INCREMENT id, so two sources that number the same pair
+ * differently hand a replica two id spaces, and a replica that holds a row at
+ * an id the other source gives a different pair fails that upsert with
+ * ER_DUP_ENTRY (ClientSync halts on it as apply-duplicate-key). This measures
+ * whether that skew exists.
+ *
+ * READ-ONLY. Three GET routes per source, the same ones a replica calls:
+ * /status/indexer// the source tip
+ * /snapshot/indexer///since/?skip_lookups=1
+ * the markets full-dump
+ * /snapshot-rows/indexer///index_tickers ticker names, paged
+ * Pairs are compared by ticker NAME, because index_tickers ids are node-local
+ * too; a native-coin side (tick id 0) is keyed by its coin id.
+ *
+ * USAGE
+ * SYNC_SOURCES=http://a:3006,http://b:3006 node bin/markets-id-parity.js --chain BTC --network mainnet
+ * node bin/markets-id-parity.js --chain BTC --network mainnet --sources http://a:3006,http://b:3006
+ * SYNC_UPSTREAM_KEY is sent as the bearer token when set.
+ *
+ * EXIT 0 every source agrees, 1 ids differ or pairs are missing, 2 usage or fetch error.
+ */
+
+'use strict';
+
+const zlib = require('zlib');
+const axios = require('axios');
+
+const TICKER_PAGE = 50000;
+
+// Parse argv and env into the run's options, or throw a usage error.
+function parseArgs(argv, env){
+ let opts = { chain: null, network: null, sources: null };
+ for(let i = 0; i < argv.length; i++){
+ let a = argv[i];
+ if(a === '--chain') opts.chain = argv[++i];
+ else if(a === '--network') opts.network = argv[++i];
+ else if(a === '--sources') opts.sources = argv[++i];
+ else throw new Error('unknown argument: ' + a);
+ }
+ let raw = opts.sources || env.SYNC_SOURCES || '';
+ opts.sources = raw.split(',').map(s => s.trim().replace(/\/+$/, '')).filter(s => s);
+ if(!opts.chain || !opts.network) throw new Error('--chain and --network are required');
+ if(opts.sources.length < 2) throw new Error('need at least two sources (--sources or SYNC_SOURCES)');
+ opts.headers = env.SYNC_UPSTREAM_KEY ? { Authorization: 'Bearer ' + env.SYNC_UPSTREAM_KEY } : {};
+ return opts;
+}
+
+// GET a JSON body that may arrive gzipped.
+async function getJson(url, headers){
+ let res = await axios.get(url, { headers, responseType: 'arraybuffer', timeout: 600000, decompress: true });
+ let body = res.data;
+ if(body && typeof body === 'object' && !Buffer.isBuffer(body)) return body;
+ if(Buffer.isBuffer(body)){
+ try { body = zlib.gunzipSync(body); } catch(e){ /* already inflated */ }
+ }
+ return JSON.parse(body.toString());
+}
+
+// Ticker id to name for one source, paged by id.
+async function fetchTickers(base, headers){
+ let names = new Map();
+ let after = 0;
+ for(;;){
+ let page = await getJson(base.rows + '/index_tickers?after_id=' + after + '&limit=' + TICKER_PAGE, headers);
+ for(let r of page.rows || []) names.set(String(r.id), String(r.tick));
+ if(!page.has_more) return names;
+ after = page.max_id;
+ }
+}
+
+// The markets rows and ticker names one source serves at its current tip.
+async function fetchSource(source, opts){
+ let path = '/indexer/' + opts.chain + '/' + opts.network;
+ let status = await getJson(source + '/status' + path, opts.headers);
+ // Refuse a null height: since/0 would pull the whole history, not one block.
+ let tip = (status.block_height == null) ? NaN : Number(status.block_height);
+ if(!Number.isInteger(tip) || tip < 1) throw new Error(source + ' reported no block_height');
+ let snap = await getJson(source + '/snapshot' + path + '/since/' + tip + '?skip_lookups=1', opts.headers);
+ let tickers = await fetchTickers({ rows: source + '/snapshot-rows' + path }, opts.headers);
+ return { tip, markets: (snap.tables && snap.tables.markets) || [], tickers };
+}
+
+// Name one side of a pair by ticker, or by coin id when it is the native coin.
+function sideName(tickId, coinId, tickers){
+ let t = String(tickId == null ? '' : tickId);
+ if(t === '0') return 'coin#' + String(coinId == null ? 0 : coinId);
+ return tickers.has(t) ? tickers.get(t) : '?tick#' + t;
+}
+
+// Map each pair name to its markets.id for one source.
+function idsByPair(markets, tickers){
+ let out = new Map();
+ for(let m of markets){
+ let pair = sideName(m.tick1_id, m.coin1_id, tickers) + '/' + sideName(m.tick2_id, m.coin2_id, tickers);
+ out.set(pair, String(m.id));
+ }
+ return out;
+}
+
+// Compare per-source pair maps: pairs missing somewhere, pairs whose id differs,
+// and ids that name different pairs on different sources (the upsert collision shape).
+function compareSources(maps){
+ let names = Object.keys(maps);
+ let allPairs = new Set();
+ for(let n of names) for(let p of maps[n].keys()) allPairs.add(p);
+ let missing = [], differing = [];
+ for(let pair of [...allPairs].sort()){
+ let ids = {};
+ for(let n of names) ids[n] = maps[n].has(pair) ? maps[n].get(pair) : null;
+ let present = Object.values(ids).filter(v => v !== null);
+ if(present.length < names.length) missing.push({ pair, ids });
+ if(new Set(present).size > 1) differing.push({ pair, ids });
+ }
+ return { pairs: allPairs.size, missing, differing, collisions: idCollisions(maps) };
+}
+
+// Ids that name one pair on one source and a different pair on another.
+function idCollisions(maps){
+ let byId = new Map();
+ for(let [src, m] of Object.entries(maps)){
+ for(let [pair, id] of m){
+ if(!byId.has(id)) byId.set(id, {});
+ byId.get(id)[src] = pair;
+ }
+ }
+ let out = [];
+ for(let [id, pairs] of byId)
+ if(new Set(Object.values(pairs)).size > 1) out.push({ id, pairs });
+ return out.sort((a, b) => Number(a.id) - Number(b.id));
+}
+
+// Human-readable report, ending with the verdict line.
+function renderReport(opts, fetched, result){
+ let lines = ['markets id parity: ' + opts.chain + '/' + opts.network];
+ for(let s of opts.sources)
+ lines.push(' ' + s + ' tip ' + fetched[s].tip + ' markets ' + fetched[s].markets.length);
+ lines.push('pairs seen: ' + result.pairs);
+ lines.push('pairs missing on some source: ' + result.missing.length);
+ for(let d of result.missing.slice(0, 20)) lines.push(' ' + d.pair + ' ' + JSON.stringify(d.ids));
+ lines.push('pairs whose id differs: ' + result.differing.length);
+ for(let d of result.differing.slice(0, 20)) lines.push(' ' + d.pair + ' ' + JSON.stringify(d.ids));
+ lines.push('ids naming different pairs across sources: ' + result.collisions.length);
+ for(let c of result.collisions.slice(0, 20)) lines.push(' id ' + c.id + ' ' + JSON.stringify(c.pairs));
+ let agree = !result.differing.length && !result.missing.length;
+ lines.push(agree ? 'VERDICT: sources agree on markets.id for every pair'
+ : 'VERDICT: markets ids are NOT reproducible across these sources');
+ return { text: lines.join('\n'), agree };
+}
+
+async function main(){
+ let opts;
+ try { opts = parseArgs(process.argv.slice(2), process.env); }
+ catch(e){ console.error('usage error: ' + e.message); process.exitCode = 2; return; }
+ let fetched = {};
+ try {
+ for(let s of opts.sources) fetched[s] = await fetchSource(s, opts);
+ } catch(e){
+ console.error('fetch failed: ' + (e.message || e));
+ process.exitCode = 2;
+ return;
+ }
+ let maps = {};
+ for(let s of opts.sources) maps[s] = idsByPair(fetched[s].markets, fetched[s].tickers);
+ let report = renderReport(opts, fetched, compareSources(maps));
+ console.log(report.text);
+ process.exitCode = report.agree ? 0 : 1;
+}
+
+if(require.main === module) main();
+
+module.exports = { parseArgs, fetchSource, sideName, idsByPair, compareSources, idCollisions, renderReport };
diff --git a/bin/pins/identity.json b/bin/pins/identity.json
index 0cd4d957..bd356877 100644
--- a/bin/pins/identity.json
+++ b/bin/pins/identity.json
@@ -1,5 +1,5 @@
{
- "armed_map_fingerprint": "f3d4455007a84ab8000c47f586c0806097449574002f4bcf0f043ef3998a87d1",
+ "armed_map_fingerprint": "4ca8bc02fc0247f255ab10ab56325f04f8d75486b855f6d4ca0e9331582be429",
"armed_map_fingerprint_version": 2,
"armedMapRows": {
"archive_rollback_author_scope_activation.ARCHIVE_AUTHOR_SCOPE_JOIN_SQL": "c8b6c1753f86fb65689fbc5d72f001e9467d5553a91895a21c9cf48d643e8895",
@@ -40,7 +40,7 @@
"swq_source_cap_activation.STAKE_WEIGHT_MAX_KEYS_PER_SOURCE": "a68b412c4282555f15546cf6e1fc42893b7e07f271557ceb021821098dd66c1b",
"swq_source_cap_activation.STAKE_WEIGHT_MAX_SOURCES": "40510175845988f13f6162ed8526f0b09f73384467fa855e1e79b44a56562a58",
"swq_source_cap_activation.SWQ_SOURCE_CAP_ACTIVATION": "d9105d53199963f94287f25b3d87d1e625b77d56ac8baab6d6ee64029b0fae74",
- "train_activation.TRAIN_ACTIVATION": "3f842b0a9bec3dfc429b72e9172b941e262d8a1eef56eb2b4c60f63fdd90b8e4"
+ "train_activation.TRAIN_ACTIVATION": "49f6acf148e96dd7ec179071e452506a31d007794b2e7a4c683be688ccc1bea9"
},
"armed_map_rows": 39,
"carrier_logic_digest": "f142db83339b2a74577e6e79d49cffc45c45ac642bcd57609b554f98a2f2e068",
diff --git a/bin/pins/m2-name-delta.json b/bin/pins/m2-name-delta.json
index 7105372b..a5e23678 100644
--- a/bin/pins/m2-name-delta.json
+++ b/bin/pins/m2-name-delta.json
@@ -19,6 +19,7 @@
"_bootstrapRotateSources": "bootstrapRotateSources",
"_buildBlockPayload": "buildBlockPayload",
"_call": "call",
+ "_cappedStakeWeightsSql": "cappedStakeWeightsSql",
"_checkpointRootsMatchLocal": "checkpointRootsMatchLocal",
"_clearBootstrapBase": "clearBootstrapBase",
"_collectSmtTouchedKeys": "collectSmtTouchedKeys",
@@ -85,6 +86,7 @@
"_sendRestHeartbeat": "sendRestHeartbeat",
"_shouldReconcileDispensers": "shouldReconcileDispensers",
"_sourceBlockHash": "sourceBlockHash",
+ "_stakeWeightsSql": "stakeWeightsSql",
"_startClientMode": "startClientMode",
"_startClientSyncForChain": "startClientSyncForChain",
"_startPollerForChain": "startPollerForChain",
diff --git a/bin/pins/suite-title-splits.json b/bin/pins/suite-title-splits.json
index 94933440..371f71b2 100644
--- a/bin/pins/suite-title-splits.json
+++ b/bin/pins/suite-title-splits.json
@@ -169,6 +169,13 @@
"test/unit/hub_client.test/09_parse_endpoints.test.js",
"test/unit/hub_client.test/10_credential_tier.test.js"
],
+ "test/unit/observability.test.js": [
+ "test/unit/observability.test.js",
+ "test/unit/observability.test/01_metrics.test.js",
+ "test/unit/observability.test/02_log_shipper.test.js",
+ "test/unit/observability.test/03_routes.test.js",
+ "test/unit/observability.test/04_flush_and_health.test.js"
+ ],
"test/unit/server_poller.test.js": [
"test/unit/server_poller.test.js",
"test/unit/server_poller.test/01_resume_cursor_restart_resume_regression.test.js",
diff --git a/package-lock.json b/package-lock.json
index f242cfa6..14733368 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "xchain-sync",
- "version": "0.20.0",
+ "version": "0.20.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xchain-sync",
- "version": "0.20.0",
+ "version": "0.20.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"acorn": "8.18.0",
diff --git a/package.json b/package.json
index a8cb859f..6a69137f 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "xchain-sync",
"description": "Database replication service for the XChain Platform: syncs indexer and decoder databases to validators and consumers via REST snapshots and WebSocket streaming",
- "version": "0.20.0",
+ "version": "0.20.1",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
@@ -43,6 +43,8 @@
"test:integration:parity": "mocha --timeout 30000 --exit test/integration/index_map_parity.test.js test/integration/index_map_parity_http.test.js",
"test:integration:ci": "mocha --require ./test/setup/index.js --timeout 120000 --exit 'test/integration/**/*.test.js' --exclude 'test/integration/client_bootstrap.test.js' --exclude 'test/integration/client_live_sync.test.js' --exclude 'test/integration/emissions_parity.test.js' --exclude 'test/integration/lifecycle.test.js'",
"test:e2e": "mocha --timeout 600000 --slow 30000 --recursive 'test/e2e/**/*.test.js'",
+ "test:e2e:up": "node bin/fixture-ports.js compose test/e2e/docker-compose.e2e.yml up -d --wait",
+ "test:e2e:down": "node bin/fixture-ports.js compose test/e2e/docker-compose.e2e.yml down -v",
"test:e2e:ci": "mocha --require ./test/setup/index.js --timeout 600000 --slow 30000 --recursive 'test/e2e/**/*.test.js' --exit",
"test:regression": "mocha --timeout 10000 --exit --grep @regression --recursive 'test/unit/**/*.test.js'",
"test:fuzz": "mocha --timeout 0 --recursive 'test/fuzz/suites/**/*.fuzz.js'",
@@ -56,6 +58,8 @@
"test:perf:pool": "node test/perf/helpers/pool_fanout_load.js",
"test:perf:stampede": "mocha --timeout 900000 --slow 30000 --exit test/perf/scenarios/08_bootstrap_stampede.test.js",
"test:chaos": "mocha --timeout 240000 --recursive 'test/chaos/**/*.test.js'",
+ "test:chaos:up": "node bin/fixture-ports.js compose test/chaos/fixtures/docker-compose.chaos.yml up -d --wait",
+ "test:chaos:down": "node bin/fixture-ports.js compose test/chaos/fixtures/docker-compose.chaos.yml down -v",
"test:mutate": "npx stryker run test/mutation/stryker.config.json",
"test:mutate:quick": "npx stryker run test/mutation/stryker.quick.config.json",
"test:mutate:check": "npx stryker run test/mutation/stryker.config.json --incremental",
diff --git a/src/SyncService.js b/src/SyncService.js
index c3fa6a5e..7a4add97 100644
--- a/src/SyncService.js
+++ b/src/SyncService.js
@@ -284,7 +284,7 @@ class SyncService {
await db.ensureReplicaUtf8mb4Columns();
// Fail closed on collation drift in the columns the stake-weight
// snapshot orders on. The follower rebuilds stakes_root from the
- // byte-mirrored _cappedStakeWeightsSql, whose window caps truncate on
+ // byte-mirrored cappedStakeWeightsSql, whose window caps truncate on
// that order, so a replica whose index_addresses.address collation
// deviates from the source's picks different cap survivors and then
// halts on a root it computed wrong. Runs AFTER the schema self-heal
diff --git a/src/api.js b/src/api.js
index 61dc862e..172b7302 100644
--- a/src/api.js
+++ b/src/api.js
@@ -181,6 +181,26 @@ function applyReplicaFreshness(row, pollerStatus){
return row;
}
+async function applyProtocolHaltFreshness(row, db, dbType){
+ if(!db || typeof db.getActiveHalt !== 'function'){
+ row.replica_halted = null;
+ return row;
+ }
+ try {
+ row.replica_halted = !!(await db.getActiveHalt(dbType));
+ } catch(e){
+ row.replica_halted = null;
+ row.replica_stale = true;
+ row.lag_blocks = null;
+ return row;
+ }
+ if(row.replica_halted){
+ row.replica_stale = true;
+ row.lag_blocks = null;
+ }
+ return row;
+}
+
// One /health databases[] row for a chain, and the verdict it implies.
//
// Module-scope and exported for the same reason buildStatusRow is: this is the
@@ -227,6 +247,13 @@ function healthEntryDegraded(entry){
return entry.circuit === 'open' || entry.poll_error_count > 0 || entry.halted === true;
}
+function clientMissingTables(syncService, chain, network, dbType){
+ let sync = (typeof syncService.getClientSync === 'function')
+ ? syncService.getClientSync(chain, network, dbType) : null;
+ return (sync && typeof sync.getMissingTables === 'function')
+ ? sync.getMissingTables() : null;
+}
+
// Build the status row for one (db, dbType, chain, network) tuple.
//
// Module-scope (not a closure inside startApi) and exported so the row shape is
@@ -266,6 +293,7 @@ async function buildStatusRow(syncService, db, dbType, chain, network){
? pollerStatus.poll_error_count : 0
};
applyReplicaFreshness(row, pollerStatus);
+ await applyProtocolHaltFreshness(row, db, dbType);
if(dbType === 'decoder'){
row.block_hash = hashRow ? hashRow.block_hash : null;
} else {
@@ -288,6 +316,18 @@ async function buildStatusRow(syncService, db, dbType, chain, network){
console.error('[API] index_map_checksum compute failed for %s/%s at block %s (advisory, returning null):', chain, network, polledBlock, e.message);
}
}
+ // Advisory tokens fold-column parity (NON-consensus, default off), the only
+ // check that sees an ISSUE edit or its reorg reversal missing on a replica.
+ // See BlockHasher.computeTokenFoldChecksum; null => follower skips.
+ row.token_fold_parity = null;
+ if(cfg['TOKEN_FOLD_PARITY_CHECK'] && polledBlock !== null){
+ try {
+ row.token_fold_parity = await new BlockHasher(db, statusUtil).computeTokenFoldChecksum(polledBlock);
+ } catch(e){
+ getLogger().error('[API] token_fold_parity compute failed for ' + chain + '/' + network +
+ ' at block ' + polledBlock + ' (advisory, returning null): ' + e.message);
+ }
+ }
}
// Advisory per-table CONTENT parity (NON-consensus, default off).
// Published for BOTH dbTypes, unlike the three hashes and the index-map
@@ -461,17 +501,8 @@ async function buildStatusRow(syncService, db, dbType, chain, network){
// indexer split); omit rather than fail the whole status.
}
}
- // Replica-completeness gap, made monitorable.
- //
- // Every apply path tolerates errno 1146 so a replica whose schema lags the
- // source does not wedge; the consequence is that entire tables can fail to
- // arrive while this row still reports halted:false and lag_blocks:0, and
- // table_counts cannot show it because a missing table is simply absent from
- // the object (indistinguishable from a table nobody counted). Publish the
- // names instead, so a monitor can alert on a non-empty array rather than on
- // repeated ER_NO_SUCH_TABLE stack traces under a green status. null means the
- // table listing itself failed: unknown, NOT "nothing missing".
- row.missing_tables = missingReplicatedTables(present, dbType);
+ // ClientSync owns the source-scoped verdict; null means either schema is unknown.
+ row.missing_tables = clientMissingTables(syncService, chain, network, dbType);
return row;
}
@@ -874,13 +905,16 @@ async function startApi(){
}
});
- // GET /snapshot-dispensers/:dbType/:chain/:network?after_tx=&after_addr=&limit=
- // One keyset page of the decoder `dispensers` table for the client's replace-table
- // reconcile. dispensers rides neither the block stream nor the id-cursor lookup
- // paging (no monotonic id; the decoder soft-expires/hard-purges rows), so the
- // client periodically re-dumps the full table and swaps it in atomically; see
- // SnapshotBuilder.streamDispensers + ClientSync.reconcileDispensers. Decoder-only;
- // rate-limited as an incremental fetch.
+ // GET /snapshot-dispensers/:dbType/:chain/:network?after_tx=&after_addr=
+ // The WHOLE decoder `dispensers` table in one statement-consistent response
+ // (has_more always false) for the client's replace-table reconcile. dispensers
+ // rides neither the block stream nor the id-cursor lookup paging (no monotonic id;
+ // the decoder soft-expires/hard-purges rows), so the client periodically re-dumps
+ // it and swaps it in atomically; see SnapshotBuilder.streamDispensers +
+ // ClientSync.reconcileDispensers. The cursor params filter within that one query;
+ // a `limit` param from an older client is ignored, and the response's only size
+ // ceiling is the client's SNAPSHOT_MAX_CONTENT. Decoder-only; rate-limited as an
+ // incremental fetch.
app.get('/snapshot-dispensers/:dbType/:chain/:network', incrSnapshotLimiter, async (req, res) => {
if(cfg['SYNC_MODE'] !== 'server')
return res.status(403).json({ error: 'Snapshots only available in server mode', code: 'FORBIDDEN' });
@@ -895,8 +929,6 @@ async function startApi(){
if((req.query.after_tx !== undefined && (isNaN(afterTx) || afterTx < 0)) ||
(req.query.after_addr !== undefined && (isNaN(afterAddr) || afterAddr < 0)))
return res.status(400).json({ error: 'Invalid cursor', code: 'BAD_REQUEST' });
- let limit = parseInt(req.query.limit);
- if(isNaN(limit)) limit = undefined; // builder applies its default
let db = syncService.getDatabase(chain, network, dbType);
if(!db) return res.status(404).json({ error: 'Chain/network/dbType not found', code: 'NOT_FOUND' });
@@ -905,7 +937,7 @@ async function startApi(){
if(!builder) return res.status(500).json({ error: 'Snapshot builder not initialized', code: 'INTERNAL_ERROR' });
try {
- await builder.streamDispensers(db, afterTx, afterAddr, limit, res);
+ await builder.streamDispensers(db, afterTx, afterAddr, res);
} catch(e){
console.error('[API error] /snapshot-dispensers/:dbType/:chain/:network:', e);
if(!res.headersSent)
@@ -1254,5 +1286,5 @@ if(require.main === module){
startApi();
}
-module.exports = { trustProxyHops, snapshotKey, createRateLimiters, applyReplicaFreshness,
+module.exports = { trustProxyHops, snapshotKey, createRateLimiters, applyReplicaFreshness, applyProtocolHaltFreshness,
buildHealthEntry, healthEntryDegraded, buildStatusRow, startApi };
diff --git a/src/checkpoint.js b/src/checkpoint.js
index dfbfa9af..6a5a1a0f 100644
--- a/src/checkpoint.js
+++ b/src/checkpoint.js
@@ -43,8 +43,9 @@ const SPKI_ED25519_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
// The canonical signing string:
// XCHECKPOINT|CHAIN|NETWORK|BLOCK_INDEX|BLOCK_HASH|LEDGER_HASH|ACTIONS_HASH|CONTRACT_HASH|CHECKPOINT_SEQ|SNAPSHOT_BLOCK
// It MUST stay byte-identical to every other producer and verifier of it: the hub's
-// StateCheckpointEngine and StateAnchorPublisher, the indexer's ANCHOR verifier and
-// recovery wrapper, the SDK's checkpoint.js and the explorer's canonicalCheckpointString.
+// StateCheckpointEngine and StateAnchorPublisher, the indexer's ANCHOR verifier, recovery
+// wrapper and bridge-proof checkpoint_source.js, the SDK's checkpoint.js and the explorer's
+// canonicalCheckpointString.
function canonicalCheckpoint(cp){
if (!cp) throw new Error('CheckpointVerifier: checkpoint object required');
let raw = ['XCHECKPOINT', cp.chain, cp.network, String(cp.block_index), cp.block_hash,
diff --git a/src/client/applier.js b/src/client/applier.js
index ae8d1e41..4abcf77f 100644
--- a/src/client/applier.js
+++ b/src/client/applier.js
@@ -100,7 +100,7 @@ class ClientApplier {
// overlap) a no-op, mirroring the server's recordBlock INSERT IGNORE.
'sync_meta',
// merkle_epochs is append-only (epoch UNIQUE); INSERT IGNORE makes its
- // full-dump re-send on an incremental catch-up idempotent (item 4622).
+ // full-dump re-send on an incremental catch-up idempotent.
'merkle_epochs',
// validator_rewards has a UNIQUE key (source_id, signing_pubkey_id,
// reward_type, round_reference, round_qualifier). The recovery-redriven collector
@@ -148,11 +148,23 @@ class ClientApplier {
'rollcall_gates'
]);
+ // Repair-only identities for replicated lookup tables whose natural value must
+ // agree with the source at the source's carried id. These tables are normally
+ // INSERT IGNORE because every block may re-send them, but that cannot repair an
+ // id mapping changed by first-seen AUTO_INCREMENT order: PRIMARY collisions look
+ // benign even when the row at that id has a different value, while natural-key
+ // collisions silently keep the same value at the wrong id. Upsert-only replicated
+ // tables therefore need ID-stable migrations; this map is the bounded recovery
+ // path for an already-unstable replica during a from-zero lookup repair.
+ this.repairNaturalKeyColumns = new Map([
+ ['index_statuses', ['status']]
+ ]);
+
// Mutable aggregates that the indexer full-dump re-sends with their CURRENT
// value (markets = OHLCV; attest_validator_stats = running counters). On a
// non-empty replica a plain INSERT collides on their UNIQUE key (ER_DUP_ENTRY,
// which aborts the catch-up transaction) and INSERT IGNORE would keep the
- // STALE row, so they must UPSERT to overwrite with the source values (4622).
+ // STALE row, so they must UPSERT to overwrite with the source values.
this.upsertFullDumpTables = new Set([
'markets',
'attest_validator_stats'
@@ -184,7 +196,7 @@ class ClientApplier {
//
// Nothing joins `blocks.id`: the source's own createBlock INSERTs without it,
// the other *_hash_id columns point into index_transactions, and the client
- // cursor is `SELECT MAX(block_index)` (db.js getLastBlock), never an id.
+ // cursor is the highest block_index (db getLastBlock), never an id.
this.localSurrogateIdTables = new Map([
['blocks', 'block_index']
]);
@@ -226,12 +238,24 @@ class ClientApplier {
// would leave the re-dump with nothing to collide on and append duplicate rows
// silently. attest_validator_stats has no such gap: validator_pubkey_provider has
// been in its CREATE TABLE since the table was introduced and no migration adds
- // it, so every replica that has the table has the key.
+ // it, so every replica that has the table has the key. Keeping the source id means
+ // a replica whose markets ids have skewed from the source's can hit that same 1062;
+ // ClientSync.noteUpsertDuplicateKey turns a repeat of it into a durable halt.
this.localSurrogateIdOnlyTables = new Set([
'attest_validator_stats'
]);
}
+ /**
+ * Apply a single block payload from a WebSocket event.
+ *
+ * Runs the whole block inside one transaction: a duplicate or malformed payload
+ * returns before anything is written, and any failure rolls the block back so
+ * ClientSync retries it rather than committing part of it.
+ *
+ * @param {object} payload the server's block event: block_index, data (a
+ * { table: [rows] } map) and any updated_rows
+ */
async applyBlock(payload){
// Clear any prior block's computed roots up front: on an early return
// (malformed payload or an already-applied duplicate) ClientSync must NOT
@@ -384,10 +408,8 @@ class ClientApplier {
if(!pairs.size) return [];
let aIn = Array.from(addrIds);
let tIn = Array.from(tickIds);
- let addrRows = await this.db.doQuery(
- 'SELECT id, address FROM index_addresses WHERE id IN (' + aIn.map(() => '?').join(',') + ')', aIn);
- let tickRows = await this.db.doQuery(
- 'SELECT id, tick FROM index_tickers WHERE id IN (' + tIn.map(() => '?').join(',') + ')', tIn);
+ let addrRows = await this.db.findIndexAddressTextByIds(aIn);
+ let tickRows = await this.db.findIndexTickTextByIds(tIn);
let addrMap = new Map(); for(let r of addrRows) addrMap.set(String(r.id), r.address);
let tickMap = new Map(); for(let r of tickRows) tickMap.set(String(r.id), r.tick);
let out = [];
@@ -455,10 +477,7 @@ class ClientApplier {
// the narrow catch at the escrow-gate rederive below.
let localTables = [];
try {
- let schemaRows = await this.db.doQuery(
- "SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE'",
- [this.db.dbName]
- );
+ let schemaRows = await this.db.findStreamableTableNames();
localTables = (schemaRows || [])
.map(r => r.table_name || r.TABLE_NAME)
.filter(t => t && !OPERATOR_LOCAL_TABLES.has(t));
@@ -493,7 +512,7 @@ class ClientApplier {
logger.error('Skipping clear of invalid table: ' + tables[i]);
continue;
}
- await this.db.doQuery('DELETE FROM `' + tables[i] + '`');
+ await this.db.deleteAllRows(tables[i]);
}
for(let table of tables){
@@ -524,9 +543,7 @@ class ClientApplier {
// the hub's full lowercase coin name into the constructor), so the full name
// matches zero rows and the cleanup silently no-ops on every production chain.
try {
- await this.db.doQuery(
- 'DELETE FROM state_tree_roots WHERE chain = ? AND network = ? AND block_index >= ?',
- [this.coinTicker, this.network, snapshotData.block_height]);
+ await this.db.deleteStateTreeRootsFromBlock(this.coinTicker, this.network, snapshotData.block_height);
} catch(e){
if(e.errno !== 1146 && e.errno !== 1054) throw e;
}
@@ -547,9 +564,16 @@ class ClientApplier {
}
}
- // opts.strictIgnoreCheck: see the SHOW WARNINGS block in insertRows.
- // Set only by ClientSync's from-zero lookup repair; every other caller (ordinary
- // live/catch-up apply) omits it and keeps the cheap, silent INSERT IGNORE path.
+ /**
+ * Apply an incremental snapshot.
+ *
+ * opts.strictIgnoreCheck: see the SHOW WARNINGS block in insertRows.
+ * Set only by ClientSync's from-zero lookup repair; every other caller (ordinary
+ * live/catch-up apply) omits it and keeps the cheap, silent INSERT IGNORE path.
+ *
+ * @param {object} snapshotData the server's catch-up payload since a block
+ * @param {object} [opts]
+ */
async applyIncrementalSnapshot(snapshotData, opts){
if(!snapshotData || !snapshotData.tables) return;
@@ -564,11 +588,7 @@ class ClientApplier {
await this.db.beginTransaction();
try {
- for(let table in snapshotData.tables){
- let rows = snapshotData.tables[table];
- if(!rows || rows.length === 0) continue;
- await this.insertRows(table, rows, opts);
- }
+ await this.insertSnapshotTables(snapshotData.tables, opts);
// Rebuild balances if this snapshot touched credits/debits. The
// incremental catch-up inserts new credit/debit rows, but the
// balances table is a derived aggregate. Without recomputing it
@@ -607,6 +627,22 @@ class ClientApplier {
}
}
+ // One incremental snapshot's tables, inserted in payload order inside the
+ // caller's transaction. The strict option is only set by the from-zero lookup
+ // repair: reconcile the carried id/status pairs before INSERT IGNORE so both a
+ // natural-key collision and a wrong row hidden by a PRIMARY collision are
+ // corrected.
+ async insertSnapshotTables(tables, opts){
+ for(let table in tables){
+ let rows = tables[table];
+ if(!rows || rows.length === 0) continue;
+ let repairKeyColumns = this.repairNaturalKeyColumns.get(table);
+ if(opts && opts.strictIgnoreCheck && repairKeyColumns)
+ await this.reconcileLookupRows(table, rows, repairKeyColumns);
+ await this.insertRows(table, rows, opts);
+ }
+ }
+
// Replace the decoder `dispensers` table wholesale from a freshly-fetched full
// set. dispensers is excluded from the block stream and the id-cursor lookup
// paging (no monotonic id; the decoder soft-expires then hard-purges rows), so
@@ -622,7 +658,7 @@ class ClientApplier {
if(!Array.isArray(rows)) return;
await this.db.beginTransaction();
try {
- await this.db.doQuery('DELETE FROM `dispensers`');
+ await this.db.deleteAllDispensers();
if(rows.length) await this.insertRows('dispensers', rows);
await this.db.commitTransaction();
} catch(e){
@@ -635,6 +671,9 @@ class ClientApplier {
async insertRows(table, rows, opts){
if(!rows || rows.length === 0) return;
+ // The table name is spliced into every statement below rather than bound as a
+ // parameter, so refuse anything that is not a plain identifier before it can
+ // reach the database.
let tableCheck = validation.validateIdentifier(table);
if(!tableCheck.valid){
// Fail closed, not open: a `return` here silently drops every row for this
@@ -699,13 +738,11 @@ class ClientApplier {
let deleteBatch = 500;
for(let i = 0; i < keyValues.length; i += deleteBatch){
let slice = keyValues.slice(i, i + deleteBatch);
- await this.db.doQuery(
- 'DELETE FROM `' + table + '` WHERE `' + naturalKey + '` IN (' +
- slice.map(() => '?').join(', ') + ')',
- slice);
+ await this.db.deleteRowsByKeyValues(table, naturalKey, slice);
}
}
+ // Column names are spliced in the same way, so each one gets the same check.
for(let col of columns){
let colCheck = validation.validateIdentifier(col);
if(!colCheck.valid){
@@ -714,27 +751,16 @@ class ClientApplier {
throw new Error('Rejected column name in insertRows: ' + col + ' (' + colCheck.reason + ')');
}
}
- let colList = columns.map(c => '`' + c + '`').join(', ');
- let placeholders = columns.map(() => '?').join(', ');
-
- let insertPrefix = useIgnore
- ? 'INSERT IGNORE INTO `' + table + '` (' + colList + ') VALUES '
- : 'INSERT INTO `' + table + '` (' + colList + ') VALUES ';
- // Mutable-aggregate full-dump tables overwrite their existing row so a
- // re-dump on a non-empty replica refreshes (not skips) stale values.
- let updateSuffix = useUpsert
- ? ' ON DUPLICATE KEY UPDATE ' + columns.map(c => '`' + c + '` = VALUES(`' + c + '`)').join(', ')
- : '';
+ // Mutable-aggregate full-dump tables (useUpsert) overwrite their existing row so
+ // a re-dump on a non-empty replica refreshes (not skips) stale values.
// Batch inserts in groups of 100 for efficiency
let batchSize = 100;
for(let i = 0; i < rows.length; i += batchSize){
let batch = rows.slice(i, i + batchSize);
- let valueClauses = [];
let args = [];
for(let row of batch){
- valueClauses.push('(' + placeholders + ')');
for(let col of columns){
// decodeValue restores base64 binary sentinels back to Buffers
// before insert (the inverse of SnapshotBuilder/BlockBroadcaster
@@ -743,10 +769,16 @@ class ClientApplier {
}
}
- let query = insertPrefix + valueClauses.join(', ') + updateSuffix;
- await this.db.doQuery(query, args);
+ try {
+ await this.db.insertRowValues(table, columns, batch.length, args, useIgnore, useUpsert);
+ } catch(e){
+ // Name the upsert table on the error so ClientSync can tell a repeating
+ // full-dump duplicate key apart from any other apply failure.
+ if(useUpsert && e && typeof e === 'object' && e.upsertTable === undefined) e.upsertTable = table;
+ throw e;
+ }
- // 5284: events rows >64KB silently truncate on a still-TEXT (pre-migration)
+ // events rows >64KB silently truncate on a still-TEXT (pre-migration)
// replica when INSERT IGNORE is used: the id collision guard skips the row
// on re-send, so the truncated copy is never healed. Detect this by reading
// SHOW WARNINGS immediately after (SHOW WARNINGS is session-scoped and is
@@ -798,7 +830,7 @@ class ClientApplier {
if(suspect.length){
let retired = await this.retireStaleNaturalKeyRows(table, batch, rows, suspect);
if(retired.length){
- await this.db.doQuery(query, args);
+ await this.db.insertRowValues(table, columns, batch.length, args, useIgnore, useUpsert);
suspect = this.suspectIgnoreWarnings(await this.db.doQuery('SHOW WARNINGS'));
}
for(let w of suspect){
@@ -829,6 +861,40 @@ class ClientApplier {
return suspect;
}
+ // Remove rows that conflict with the source page by either surrogate id or natural
+ // key. The caller immediately re-inserts the page in the same transaction. Probing
+ // exact pairs first keeps an already-converged repair idempotent and avoids writes.
+ async reconcileLookupRows(table, rows, keyColumns){
+ let retireIds = new Set();
+ for(let row of rows){
+ let id = row ? row.id : undefined;
+ if(id === undefined || id === null)
+ throw new Error('Lookup repair row for ' + table + ' is missing id');
+
+ let values = [];
+ for(let column of keyColumns){
+ if(row[column] === undefined)
+ throw new Error('Lookup repair row for ' + table + ' is missing natural key ' + column);
+ values.push(row[column]);
+ }
+
+ let keyHolders = await this.db.findRowIdsByKeyColumns(table, keyColumns, values);
+ if(keyHolders && keyHolders.length === 1 && Number(keyHolders[0].id) === Number(id))
+ continue;
+
+ let idHolder = await this.db.findRowIdById(table, id);
+ for(let holder of (idHolder || [])) retireIds.add(Number(holder.id));
+
+ for(let holder of (keyHolders || [])) retireIds.add(Number(holder.id));
+ }
+
+ for(let id of retireIds){
+ await this.db.deleteRowById(table, id);
+ logger.warn('LOOKUP_ID_STATUS_RECONCILED table=' + table + ' retired_id=' + id +
+ ' cause=first_seen_auto_increment_id_instability');
+ }
+ }
+
// Retire the rows of a superseded lookup generation so the source's rows can land.
//
// Bounded by the page's own id window, which is what makes the "the source does not
@@ -868,22 +934,19 @@ class ClientApplier {
for(let row of batch){
let id = Number(row.id);
- let mine = await this.db.doQuery('SELECT id FROM `' + table + '` WHERE id = ? LIMIT 1', [id]);
+ let mine = await this.db.findRowIdById(table, id);
if(mine && mine.length) continue; // the source's row is already here
let values = keyColumns.map(c => row[c]);
if(values.some(v => v === undefined)) continue;
- let holder = await this.db.doQuery(
- 'SELECT id FROM `' + table + '` WHERE ' +
- keyColumns.map(c => '`' + c + '` = ?').join(' AND ') + ' LIMIT 2',
- values);
+ let holder = await this.db.findRowIdsByKeyColumns(table, keyColumns, values);
if(!holder || holder.length !== 1) continue; // absent, or ambiguous: not this shape
let holderId = Number(holder[0].id);
if(holderId === id || carried.has(holderId)) continue;
if(holderId < low || holderId > high) continue;
- await this.db.doQuery('DELETE FROM `' + table + '` WHERE id = ?', [holderId]);
+ await this.db.deleteRowById(table, holderId);
retired.push(holderId);
logger.warn('STALE_LOOKUP_GENERATION_RETIRED table=' + table + ' key=' + indexName +
' natural_key=' + JSON.stringify(keyColumns.map((c, i) => c + '=' + values[i]).join(',')) +
@@ -899,10 +962,7 @@ class ClientApplier {
async uniqueKeyColumns(table, indexName){
let check = validation.validateIdentifier(indexName);
if(!check.valid) return [];
- let rows = await this.db.doQuery(
- "SELECT column_name FROM information_schema.statistics " +
- "WHERE table_schema = ? AND table_name = ? AND index_name = ? ORDER BY seq_in_index ASC",
- [this.db.dbName, table, indexName]);
+ let rows = await this.db.findIndexColumnNames(table, indexName);
let columns = [];
for(let r of (rows || [])){
let name = String(r.column_name || r.COLUMN_NAME || '');
@@ -939,14 +999,7 @@ class ClientApplier {
// (d.block_index = B live; d.block_index >= since on an incremental catch-up).
async mirrorAnchorRewardReconcile(scopeSql, scopeArgs){
try {
- await this.db.doQuery(
- "DELETE vr FROM validator_rewards vr " +
- "JOIN anchor_reward_reconcile_log d " +
- " ON d.source_id = vr.source_id AND d.signing_pubkey_id = vr.signing_pubkey_id " +
- " AND d.reward_type = vr.reward_type AND d.round_reference <=> vr.round_reference " +
- " AND d.round_qualifier = vr.round_qualifier " +
- "WHERE " + scopeSql,
- scopeArgs);
+ await this.db.deleteReconciledValidatorRewards(scopeSql, scopeArgs);
} catch(e){
// Schema-gap errors (log table / columns absent on an older replica) are safe
// to skip: such a replica received no log rows either. Anything else must
@@ -1013,27 +1066,17 @@ class ClientApplier {
throw new Error('Rejected column name in upsertRows: ' + col + ' (' + colCheck.reason + ')');
}
}
- let colList = columns.map(c => '`' + c + '`').join(', ');
- let placeholders = columns.map(() => '?').join(', ');
- // VALUES(col) back-reference is the MariaDB idiom for "the value this row
- // would have inserted"; updating the key column to itself is a harmless no-op.
- let updateList = columns.map(c => '`' + c + '` = VALUES(`' + c + '`)').join(', ');
-
- let insertPrefix = 'INSERT INTO `' + table + '` (' + colList + ') VALUES ';
- let updateSuffix = ' ON DUPLICATE KEY UPDATE ' + updateList;
-
+ // A plain INSERT with the ON DUPLICATE KEY UPDATE suffix: every carried column
+ // is written on both insert and update.
let batchSize = 100;
for(let i = 0; i < rows.length; i += batchSize){
let batch = rows.slice(i, i + batchSize);
- let valueClauses = [];
let args = [];
for(let row of batch){
- valueClauses.push('(' + placeholders + ')');
for(let col of columns)
args.push(decodeValue(row[col] !== undefined ? row[col] : null));
}
- let query = insertPrefix + valueClauses.join(', ') + updateSuffix;
- await this.db.doQuery(query, args);
+ await this.db.insertRowValues(table, columns, batch.length, args, false, true);
}
}
diff --git a/src/client/block_hasher.js b/src/client/block_hasher.js
index 35b5ae71..31522726 100644
--- a/src/client/block_hasher.js
+++ b/src/client/block_hasher.js
@@ -55,6 +55,7 @@ const DEFAULT_CONTENT_PARITY_WINDOW = 100;
const replicatedTables = require('../schema/replicated_tables');
const lifecycle = require('../table_lifecycle');
+const tokenRefold = require('../db/token_refold');
const { buildStateHashData } = require('../consensus/state_hash');
const { gasTickSymbol } = require('../consensus-constants');
const { canonicalizeHashAddress } = require('../util/protocol_address_roles');
@@ -65,8 +66,9 @@ const STATE_KEY_COLLATION_KEY = 'state_key_collation_activation.STATE_KEY_COLLAT
class BlockHasher {
- // db: a DB handle exposing async doQuery(sql, params) against the REPLICA
- // (schema-identical to the indexer, with surrogate ids preserved).
+ // db: a DB handle exposing async doQuery(sql, params) and
+ // doQueryStrict(sql, params) against the REPLICA (schema-identical to
+ // the indexer, with surrogate ids preserved).
// util: xchain-sync Utility; its getDataHash() is the conformance copy of
// the indexer's (JSON.stringify(Object.assign({}, data), bigint->string),
// SHA-256 hex).
@@ -82,6 +84,9 @@ class BlockHasher {
// omitted -> legacy folding collation, matching pre-activation blocks. Live
// recompute callers MUST pass them or the replica gates differently than the
// source at/after an armed height and false-halts on divergence.
+ // Gather every preimage row set with doQueryStrict: a swallowed read error
+ // returning [] would hash a truncated preimage and halt on a false divergence
+ // instead of surfacing as a recompute error (same rule as db/actions.js).
async computeBlockHashes(block_index, network, coin){
let query = null;
let actions = [];
@@ -108,7 +113,7 @@ class BlockHasher {
a.block_index=?
ORDER BY
c.action_index ASC, a1.address COLLATE utf8_bin ASC, t1.tick COLLATE utf8mb4_bin ASC, c.amount ASC`;
- ledger.credits = await this.db.doQuery(query, [block_index]);
+ ledger.credits = await this.db.doQueryStrict(query, [block_index]);
query = `SELECT
d.action_index,
a1.address AS address,
@@ -122,7 +127,7 @@ class BlockHasher {
a.block_index=?
ORDER BY
d.action_index ASC, a1.address COLLATE utf8_bin ASC, t1.tick COLLATE utf8mb4_bin ASC, d.amount ASC`;
- ledger.debits = await this.db.doQuery(query, [block_index]);
+ ledger.debits = await this.db.doQueryStrict(query, [block_index]);
query = `SELECT
e.action_index,
a1.address AS address,
@@ -136,7 +141,7 @@ class BlockHasher {
a.block_index=?
ORDER BY
e.action_index ASC, a1.address COLLATE utf8_bin ASC, t1.tick COLLATE utf8mb4_bin ASC, e.amount ASC`;
- ledger.escrows = await this.db.doQuery(query, [block_index]);
+ ledger.escrows = await this.db.doQueryStrict(query, [block_index]);
// CONSENSUS: canonicalize protocol special addresses (BURN/GAS/DONATE/REWARD)
// to their chain-independent role token, byte-for-byte mirror of
// xchain-indexer/src/db/actions.js getBlockHashes. A per-chain special address (e.g. an
@@ -157,7 +162,7 @@ class BlockHasher {
a.block_index=?
ORDER BY
a.action_index ASC`;
- actions = await this.db.doQuery(query, [block_index]);
+ actions = await this.db.doQueryStrict(query, [block_index]);
let contracts_data = {
contracts: [],
state: [],
@@ -173,7 +178,7 @@ class BlockHasher {
LEFT JOIN index_statuses s1 ON (s1.id=c.status_id)
WHERE a.block_index=?
ORDER BY c.action_index ASC`;
- contracts_data.contracts = await this.db.doQuery(query, [block_index]);
+ contracts_data.contracts = await this.db.doQueryStrict(query, [block_index]);
// contract state (latest value per key written in this block).
// state_key collation is flag-day gated, byte-for-byte mirror of
// xchain-indexer/src/db/actions.js getBlockHashes(): legacy folding
@@ -190,7 +195,7 @@ class BlockHasher {
GROUP BY contract_index, state_key` + stateKeyCollate + `
) latest ON cs.id = latest.max_id
ORDER BY cs.contract_index ASC, cs.state_key` + stateKeyCollate + ` ASC`;
- contracts_data.state = await this.db.doQuery(query, [block_index]);
+ contracts_data.state = await this.db.doQueryStrict(query, [block_index]);
// Executions, resolved the same way as deployments above.
query = `SELECT ce.action_index, ce.contract_index, a1.address AS caller_address, ce.gas_used, s1.status AS status, ce.emitted_count
FROM contract_executions ce
@@ -198,7 +203,7 @@ class BlockHasher {
LEFT JOIN index_statuses s1 ON (s1.id=ce.status_id)
WHERE a.block_index=?
ORDER BY ce.action_index ASC`;
- contracts_data.executions = await this.db.doQuery(query, [block_index]);
+ contracts_data.executions = await this.db.doQueryStrict(query, [block_index]);
// Emissions carry no block column, so scope comes through their execution.
query = `SELECT em.execution_index, em.emitted_action, em.action_index, em.position
FROM contract_emissions em
@@ -206,7 +211,7 @@ class BlockHasher {
INNER JOIN actions a ON (a.action_index=ce.action_index)
WHERE a.block_index=?
ORDER BY em.execution_index ASC, em.position ASC`;
- contracts_data.emissions = await this.db.doQuery(query, [block_index]);
+ contracts_data.emissions = await this.db.doQueryStrict(query, [block_index]);
// Deposits, with the resolved secondary sort keys pinned to a BINARY collation so
// the tie-break order cannot vary with a node's default collation.
query = `SELECT d.action_index, d.contract_index, a1.address AS source_address, t1.tick AS tick, d.amount, s1.status AS status
@@ -216,7 +221,7 @@ class BlockHasher {
LEFT JOIN index_statuses s1 ON (s1.id=d.status_id)
WHERE a.block_index=?
ORDER BY d.action_index ASC, d.contract_index ASC, a1.address COLLATE utf8_bin ASC, t1.tick COLLATE utf8mb4_bin ASC, d.amount ASC, s1.status COLLATE utf8_bin ASC`;
- contracts_data.deposits = await this.db.doQuery(query, [block_index]);
+ contracts_data.deposits = await this.db.doQueryStrict(query, [block_index]);
// Same resolution and tie-order treatment as deposits.
query = `SELECT w.action_index, w.contract_index, a1.address AS source_address, t1.tick AS tick, w.amount, s1.status AS status
FROM withdrawals w
@@ -225,7 +230,7 @@ class BlockHasher {
LEFT JOIN index_statuses s1 ON (s1.id=w.status_id)
WHERE a.block_index=?
ORDER BY w.action_index ASC, w.contract_index ASC, a1.address COLLATE utf8_bin ASC, t1.tick COLLATE utf8mb4_bin ASC, w.amount ASC, s1.status COLLATE utf8_bin ASC`;
- contracts_data.withdrawals = await this.db.doQuery(query, [block_index]);
+ contracts_data.withdrawals = await this.db.doQueryStrict(query, [block_index]);
// Previous block's committed hashes, which chain this block to the last.
let prev_block_index = block_index - 1;
query = `SELECT
@@ -239,7 +244,7 @@ class BlockHasher {
LEFT JOIN index_transactions t3 ON (t3.id=b.contract_hash_id)
WHERE
b.block_index=?`;
- let results = await this.db.doQuery(query, [prev_block_index]);
+ let results = await this.db.doQueryStrict(query, [prev_block_index]);
if(results.length > 0){
hashes['ledger'] = results[0].ledger;
hashes['actions'] = results[0].actions;
@@ -326,6 +331,31 @@ class BlockHasher {
return this.util.getDataHash({ index_map: mapped });
}
+ // ADVISORY, NON-CONSENSUS, same posture as computeIndexMapChecksum. The tokens
+ // metadata columns ISSUE folds in place (owner, locks, callback, lists, mint window,
+ // bridge policy) sit in no block hash, no state_hash class and no content-parity
+ // window, so a replica that missed an edit (updated_rows class 7) or its reversal on a
+ // reorg (ClientRollback -> token_refold.refoldTokenRows) diverged with nothing to see
+ // it. This digests every tokens row's fold columns (token_refold.FOLD_COLUMNS).
+ //
+ // The table is current state, not a block window, so the SOURCE bounds it instead:
+ // a row whose last_action_index is above the last action at uptoBlock was edited
+ // after the height this status publishes, and is left out and named in `ahead`. A
+ // FOLLOWER passes that list back as opts.exclude and applies no bound of its own, so
+ // a replica row wrongly carrying a later last_action_index still lands in its digest.
+ async computeTokenFoldChecksum(uptoBlock, opts){
+ let follower = !!(opts && Array.isArray(opts.exclude));
+ let bound = null;
+ if(!follower){
+ let r = await this.db.doQuery("SELECT MAX(action_index) AS m FROM actions WHERE block_index <= ?",
+ [uptoBlock], null, { rethrow: true });
+ bound = (r && r.length && r[0].m !== null && r[0].m !== undefined) ? String(r[0].m) : '0';
+ }
+ let res = await tokenRefold.tokenFoldRows(this.db, bound, follower ? opts.exclude : []);
+ return { h: this.util.getDataHash({ token_fold: res.rows }), n: res.rows.length,
+ ahead: follower ? opts.exclude.map(String) : res.ahead };
+ }
+
// ADVISORY, NON-CONSENSUS. Same posture as computeIndexMapChecksum
// above: not a block hash, not in BLOCK_HASH_VERSION, no indexer twin. Its only
// conformance requirement is server-vs-client agreement, and both sides call
diff --git a/src/client/hash_verifier.js b/src/client/hash_verifier.js
index 0c053305..83d92087 100644
--- a/src/client/hash_verifier.js
+++ b/src/client/hash_verifier.js
@@ -56,9 +56,9 @@ class HashVerifier {
};
}
- // Advisory: compare local vs remote per-table content checksums (NON-consensus,
- // ). Both sides are BlockHasher.computeTableContentChecksums results over
- // the SAME window and the same published bounds.
+ // Advisory: compare local vs remote per-table content checksums (NON-consensus).
+ // Both sides are BlockHasher.computeTableContentChecksums results over the SAME
+ // window and the same published bounds.
//
// The comparison is deliberately gated on EQUAL ROW COUNTS, and that gate is what
// makes the signal trustworthy rather than noisy:
diff --git a/src/client/rollback.js b/src/client/rollback.js
index 61e861ee..beef30ab 100644
--- a/src/client/rollback.js
+++ b/src/client/rollback.js
@@ -15,14 +15,18 @@
* XChain Indexer Sync - Client Rollback
*
* Handles rolling back the local replica database to a given block.
- * Table lists are copied from xchain-indexer/src/rollback.js and
- * MUST be kept in sync when new tables are added to the indexer.
- * test/unit/rollback/rollback_coverage.test.js enforces that the sync set
+ * The generic indexer table lists come from lifecycle.replicaRollbackTables()
+ * (src/table_lifecycle.js) and the decoder lists from TOPOLOGY.decoder
+ * (src/schema/replicated_tables.js): a new table is declared there, never
+ * added to a list here. Only the bespoke in-place resets and restores are
+ * hand-written, mirroring xchain-indexer/src/rollback/.
+ * test/unit/rollback_coverage.test.js enforces that the sync set
* covers every table ServerPoller replicates.
*
********************************************************************/
const balanceHelpers = require('../db/balance_helpers');
+const tokenRefold = require('../db/token_refold');
const lifecycle = require('../table_lifecycle');
const replicatedTables = require('../schema/replicated_tables');
const { activationDelayBlocks, gasTickSymbol } = require('../consensus-constants');
@@ -80,7 +84,7 @@ class ClientRollback {
this.activationDelay = delay;
// Generic rollback table lists, generated from the table-lifecycle
- // registry (src/tableLifecycle.js, the byte-identical twin of the
+ // registry (src/table_lifecycle.js, the byte-identical twin of the
// xchain-indexer copy). replicaRollbackTables() yields exactly the
// source indexer's generic lists minus indexer-local tables that never
// exist on a replica (e.g. pending_hub_pushes), so the two rollbacks
@@ -88,7 +92,7 @@ class ClientRollback {
// registry joins both sides at once. Per-table rationale lives with
// the registry entries; the bespoke in-place resets/restores below
// stay hand-written (and remain drift-guarded by the parity tests in
- // test/unit/rollback/rollback_coverage.test.js).
+ // test/unit/rollback_coverage.test.js).
let rollbackLists = lifecycle.replicaRollbackTables();
this.blockTables = rollbackLists.blockTables;
this.indexTables = rollbackLists.indexTables;
@@ -104,7 +108,7 @@ class ClientRollback {
// purely local artifacts that no longer feed any consensus value: under the
// current BLOCK_HASH_VERSION the block hashes are computed from the RESOLVED strings
// (address/tick/action/status), not from address_id/tick_id/etc. (see
- // xchain-indexer/src/db/actions.js getBlockHashes + xchain-sync/src/BlockHasher.js). If a
+ // xchain-indexer/src/db/actions.js getBlockHashes + xchain-sync/src/client/block_hasher.js). If a
// lookup id is ever reintroduced into a consensus-visible projection, these orphan
// rows would silently fork hashes after a reorg and this skip would become a bug.
@@ -120,10 +124,11 @@ class ClientRollback {
// Tx-scoped tables, deleted by tx_index for the rolled-back blocks' transactions.
// Also topology-derived. dispensers is absent from the topology's txScoped by
// design: it is not per-block replicated (the decoder live-prunes it, which
- // the block stream can't model (see replicatedTables.js)); it SEEDS from the
- // full snapshot and is then held in parity by the periodic apply-side reconcile
- // (ClientSync.reconcileDispensers -> ClientApplier.applyDispensersReplace),
- // which replicatedTables.js:47-49 names as the whole of its parity story.
+ // the block stream can't model (see src/schema/replicated_tables.js)); it SEEDS from
+ // the full snapshot and is then held in parity by the periodic apply-side reconcile
+ // (ClientSync.reconcileDispensers -> ClientApplier.applyDispensersReplace), which
+ // the dispensers (decoder) carve-out in src/schema/replicated_tables.js names as
+ // the whole of its parity story.
// Deleting its rows on a reorg would corrupt that replicated state with no
// per-block stream to restore them before the next reconcile, so a reorg leaves
// dispensers untouched.
@@ -797,6 +802,17 @@ class ClientRollback {
}
}
+ // Ticks whose `issues` history this rollback truncates, read while the orphaned
+ // rows still exist. Refolded near the end (see refoldTokenRows below).
+ let issueTickIds = [];
+ if(firstActionIndex !== null){
+ try {
+ issueTickIds = await tokenRefold.collectIssueTickIds(this.db, firstActionIndex);
+ } catch(e){
+ if(e.errno !== 1146 && e.errno !== 1054) throw e;
+ }
+ }
+
if(firstActionIndex !== null){
for(let table of this.dataTables){
try {
@@ -1163,6 +1179,24 @@ class ClientRollback {
if(e.errno !== 1146){ logger.error(util.format('rebuildBalances after rollback failed:', e)); throw e; }
}
+ // Refold the tokens metadata of every tick the orphaned range issued on, the
+ // reverse leg of updated_rows class 7. An orphaned ISSUE that edited a surviving
+ // token (owner, a lock, the callback, a list, the mint window, the bridge policy)
+ // rewrote the row in place, and the generic delete cannot touch it: its
+ // action_index is the FIRST issuance. The source refolds it from the surviving
+ // issues (xchain-indexer rollback/commit.js updateTokens), and no forward window
+ // ever re-sends it, so without this the replica kept the orphaned edit forever.
+ // Runs after every delete, as the source's refresh does, and BEFORE the supply
+ // recompute below, which reads tokens.decimals.
+ try {
+ let refold = await tokenRefold.refoldTokenRows(this.db, issueTickIds);
+ if(refold.skipped.length)
+ logger.warn('ClientRollback: tokens refold skipped ' + refold.skipped.length + ' tick(s) whose first ' +
+ 'issuance is not held locally (truncated replica?); tick_ids ' + refold.skipped.slice(0, 20).join(','));
+ } catch(e){
+ if(e.errno !== 1146 && e.errno !== 1054){ logger.error(util.format('refoldTokenRows after rollback failed:', e)); throw e; }
+ }
+
// Recompute tokens.supply from the surviving credits/debits/escrows. The
// orphaned MINT's credit row is deleted above, but tokens.supply was mutated
// IN PLACE (no new row), so it survives the block-scoped deletes with the
@@ -1250,8 +1284,8 @@ class ClientRollback {
// after orphaned offers/statuses are deleted) and ClientApplier (forward-apply
// path, after the block's offers/statuses are inserted) so both derive
// byte-identical gate values. The SQL between the // markers
-// is kept logically identical with xchain-indexer/src/rollback.js (cross-repo drift
-// guard in test/unit/rollback/rollback_coverage.test.js). Uses db.doQuery so it joins
+// is kept logically identical with xchain-indexer/src/rollback/rederive.js (cross-repo
+// drift guard in test/unit/rollback_coverage.test.js). Uses db.doQuery so it joins
// whatever transaction the caller already opened.
// Affected set = currently-escrowed tokens (Class A) UNION tokens with a
// surviving still-escrowed GIVE_OWNERSHIP offer (Class B).
@@ -1296,8 +1330,8 @@ async function rederiveEscrowGate(db){
// coinpay_action_index IS the match's action_index. Both statements touch only rows whose
// status disagrees and no-op when the target status has never been minted locally, so
// neither can blank a status_id. The SQL between the //
-// markers is kept logically identical with xchain-indexer/src/rollback.js (cross-repo
-// drift guard in test/unit/rollback/rollback_coverage.test.js). Uses db.doQuery so it joins
+// markers is kept logically identical with xchain-indexer/src/rollback/rederive.js
+// (cross-repo drift guard in test/unit/rollback_coverage.test.js). Uses db.doQuery so it joins
// whatever transaction the caller already opened.
//
// Skipped on a truncated replica: it holds only [base..tip] of coinpay_statuses, so
diff --git a/src/client/sync.js b/src/client/sync.js
index e4a6f30a..a2cd1a8e 100644
--- a/src/client/sync.js
+++ b/src/client/sync.js
@@ -51,9 +51,9 @@ const envConfig = require('../config');
// Tables whose row counts cannot converge between source and replica, and so are
// never a completeness signal. See the exclusion in verifyTableCounts for the
-// mechanism; kept here as a named set so a second such table is added in one place
-// rather than at each call site's excludeTables argument.
-const OPERATIONAL_LOG_TABLES = new Set(['events']);
+// mechanism. Declared once in replicated_tables.js, which the content-parity plan
+// also reads, so the count and content checks cannot disagree about a table.
+const OPERATIONAL_LOG_TABLES = new Set(replicatedTables.OPERATIONAL_LOG_TABLES);
// Permanent bootstrap exhaustion. start()-time throws already unwind to
// SyncService's sync.start().catch(... process.exit(1)) restart contract on their
@@ -67,16 +67,24 @@ class BootstrapExhaustedError extends Error {}
// Is the decoder `dispensers` table due a wall-clock reconcile? The one term of the
// reconcile decision that carries no cycle-counter side effect, so the recurring status
-// tick can sample it without corrupting the every-Nth catch-up cadence. Due only once
-// some reconcile has stamped a time: a replica that has never converged dispensers is the
-// firstResume case, owned by the catch-up path, and firing that from a tick would retry a
-// failing re-dump on every tick instead of once per interval.
-function dispenserIntervalDue(config, lastReconcileAt, nowMs){
+// tick can sample it without corrupting the every-Nth catch-up cadence. Measured from the
+// later of the last success and `since` (the tick's last attempt, else when live-follow
+// began), so a snapshot-bootstrapped replica that never reconciled is still bounded and a
+// failing re-dump retries once per interval, not once per tick. No time known: not due.
+function dispenserIntervalDue(config, lastReconcileAt, nowMs, since){
let maxIntervalMs = parseInt(config['DISPENSERS_RECONCILE_MAX_INTERVAL_MS'], 10);
if(isNaN(maxIntervalMs) || maxIntervalMs < 0) maxIntervalMs = 1800000;
if(maxIntervalMs === 0) return false; // explicitly disabled
- if(lastReconcileAt == null) return false;
- return (nowMs - lastReconcileAt) >= maxIntervalMs;
+ let from = latestTime(lastReconcileAt, since);
+ if(from == null) return false;
+ return (nowMs - from) >= maxIntervalMs;
+}
+
+// Return the later of two optional epoch-ms times, or null when neither is set.
+function latestTime(a, b){
+ if(a == null) return (b == null) ? null : b;
+ if(b == null) return a;
+ return Math.max(a, b);
}
class ClientSync {
@@ -96,6 +104,9 @@ class ClientSync {
this.hashVerifier = hashVerifier;
this.config = config;
this.util = util;
+ this.maxRollbackDepth = envConfig.resolveMaxRollbackDepth(
+ this.chain, this.network, this.config['MAX_ROLLBACK_DEPTH'],
+ this.config['MAX_ROLLBACK_DEPTH_EXPLICIT']);
// Independent block-hash recomputation (true byzantine / replication-
// integrity detection). Verifies the replicated raw rows actually hash to
// the committed hash, rather than trusting verbatim-replicated hashes.
@@ -130,7 +141,7 @@ class ClientSync {
// weakening gates the repo declares UNSAFE to turn off (operator decision
// 2026-06-12): the all-gates-off posture stays an explicit operator choice.
let modeKey = 'SYNC_MODE_' + String(this.chain).toUpperCase();
- this._syncMode = process.env[modeKey] || this.config[modeKey] || 'full';
+ this._syncMode = envConfig.envValueByName(modeKey) || this.config[modeKey] || 'full';
if(this.dbType === 'indexer' && this._syncMode === 'infra-only'){
let haltingGates = [];
if(this.config['VERIFY_RECOMPUTE']) haltingGates.push('VERIFY_RECOMPUTE');
@@ -205,7 +216,7 @@ class ClientSync {
// misconfigured small depth can't quietly strand the replica. (depth 0 = full-
// history replica, not truncated, so it is exempt.)
if(this._truncatedDepth >= 1){
- let maxRollback = Number(this.config['MAX_ROLLBACK_DEPTH']);
+ let maxRollback = Number(this.maxRollbackDepth);
if(!Number.isFinite(maxRollback) || maxRollback < 1) maxRollback = 100;
if(this._truncatedDepth <= maxRollback){
let clamped = maxRollback + 1;
@@ -340,6 +351,11 @@ class ClientSync {
this._replicaGapAlertSweeps = this.numericSetting('REPLICA_GAP_ALERT_SWEEPS', 2, 1);
this._replicaGapAlertRepeatMs = this.numericSetting('REPLICA_GAP_ALERT_REPEAT_MS', 21600000, 0);
+ // Validated table names returned by the primary source's /schema endpoint.
+ // null means no source schema has been observed, so a missing-table verdict
+ // would be unknown rather than empty.
+ this._sourceTables = null;
+
// Throttled gap logging. On an inherently fast chain (e.g. Dogecoin
// testnet, which mints blocks at ~10/sec and is tens of millions of
// blocks high) the replica perpetually trails the live tip, so every
@@ -445,12 +461,14 @@ class ClientSync {
async warnMissingTables(){
try {
let present = await this.db.listExistingTables();
- let missing = replicatedTables.missingReplicatedTables(present, this.dbType);
+ let missing = replicatedTables.missingReplicatedTables(
+ present, this.dbType, this._sourceTables
+ );
this._missingTables = missing;
if(missing && missing.length){
getLogger().warn('MISSING_REPLICATED_TABLES: ' + this.chain + '/' + this.network + '/' +
this.dbType + ' replica schema is missing ' + missing.length +
- ' table(s) that this build replicates per block: ' + missing.join(', ') +
+ ' source table(s) that this build replicates per block: ' + missing.join(', ') +
'. Rows for these tables are SKIPPED (errno 1146 is tolerated so a schema gap ' +
'cannot wedge the replica), so replication is partial while /status still ' +
'reports halted:false. Migrate this replica to the source schema; the same ' +
@@ -463,8 +481,8 @@ class ClientSync {
}
}
- // Per-block replicated tables absent from this replica's schema, or null when
- // the check has not run / could not read the table listing.
+ // Source-side per-block replicated tables absent from this replica's schema,
+ // or null when the check has not run or either table listing is unknown.
getMissingTables(){ return this._missingTables === undefined ? null : this._missingTables; }
// Multi-source Byzantine quorum helpers.
@@ -678,6 +696,8 @@ class ClientSync {
this.lastHashes = await this.db.getBlockHashRow(this.lastAppliedBlock);
+ // Start the dispensers wall clock at live-follow: a full snapshot seeds the table at parity.
+ if(this.dbType === 'decoder') this._dispenserClockArmedAt = Date.now();
this.connectWebSockets();
// Keep alive
@@ -777,15 +797,19 @@ class ClientSync {
if(!schema || !schema.tables) return;
// Validate every table name + DDL up front, then collect the apply set.
+ // Keep the independently validated name set for missing-table checks: a
+ // source on an older release legitimately omits tables known to this build.
+ let sourceTables = new Set();
let pending = [];
for(let tableName in schema.tables){
let createSql = schema.tables[tableName];
- if(!createSql) continue;
let idCheck = validation.validateIdentifier(tableName);
if(!idCheck.valid){
getLogger().error('Rejected table name from schema: ' + tableName + ' (' + idCheck.reason + ')');
continue;
}
+ sourceTables.add(tableName);
+ if(!createSql) continue;
let ddlCheck = validation.validateDdl(createSql);
if(!ddlCheck.valid){
getLogger().error('Rejected DDL for table ' + tableName + ': ' + ddlCheck.reason);
@@ -793,6 +817,7 @@ class ClientSync {
}
pending.push({ tableName, createSql });
}
+ this._sourceTables = sourceTables;
// Multi-pass fixpoint. A CREATE can fail because a table it FK-references
// has not been created yet; retrying the not-yet-applied tables until a
@@ -911,6 +936,62 @@ class ClientSync {
this._applyTimers.clear();
}
+ // Table and key of an upsert full-dump duplicate-key failure (errno 1062), or null
+ // for any other error. An upsert absorbs its own row's key collision, so a 1062 means
+ // the UPDATE leg moved a key (markets.id) onto a value another replica row holds.
+ upsertDuplicateKeyTarget(e){
+ if(!e || e.errno !== 1062 || typeof e.upsertTable !== 'string') return null;
+ let m = /Duplicate entry '(.*?)' for key '([^']*)'/.exec(String(e.sqlMessage || e.message || ''));
+ return { table: e.upsertTable, entry: m ? m[1] : null, key: m ? m[2] : null };
+ }
+
+ // Halt when an upsert full-dump fails on the same table and key twice with no
+ // successful apply between (id-space skew no retry can clear); a first hit retries.
+ async noteUpsertDuplicateKey(source, e){
+ let target = this.upsertDuplicateKeyTarget(e);
+ if(!target) return false;
+ let id = target.table + '|' + target.key + '|' + target.entry;
+ let repeat = (this._upsertDupKeyTarget === id);
+ this._upsertDupKeyTarget = id;
+ if(!repeat){
+ getLogger().warn('Upsert full-dump of ' + target.table + ' hit a duplicate key (' +
+ target.entry + ' on ' + target.key + '); retrying once before halting');
+ return false;
+ }
+ // Reset so a replica resumed after an operator clear gets its one retry again.
+ this._upsertDupKeyTarget = null;
+ await this.haltOnUpsertDuplicateKey(source, target);
+ return true;
+ }
+
+ // Durable halt for a repeating upsert duplicate key: same recordHalt/isHalted
+ // persistence and /status surface as the schema-apply halt, its own reason.
+ async haltOnUpsertDuplicateKey(source, target){
+ if(this._halted) return;
+ let blockIndex = (this.lastAppliedBlock != null) ? this.lastAppliedBlock : 0;
+ let detail = [target];
+ this._halted = {
+ blockIndex, reason: 'apply-duplicate-key',
+ mismatches: detail, sources: [source],
+ at: new Date().toISOString()
+ };
+ try { await this.db.recordHalt(this.dbType, blockIndex, this._halted.reason, detail, [source]); }
+ catch(e){ getLogger().error(util.format('CRITICAL: failed to persist duplicate-key halt (still halting in-memory):', e)); }
+ getLogger().error('================================================================');
+ getLogger().error('UPSERT DUPLICATE KEY HALT: ' + this.chain + '/' + this.network + '/' + this.dbType);
+ getLogger().error('the ' + target.table + ' full-dump from ' + source + ' failed twice on duplicate entry ' +
+ target.entry + ' for key ' + target.key + ': a replica row already holds a key the');
+ getLogger().error('source assigns to a different row (id-space skew from a source re-index, a');
+ getLogger().error('cross-source bootstrap, or a row the source deleted and this replica kept).');
+ getLogger().error('Retrying the same payload cannot clear it. HALTING (applying no further blocks).');
+ getLogger().error('Operator must re-bootstrap this replica or reconcile the colliding row, then clear the halt.');
+ getLogger().error('================================================================');
+ this.pendingHashes.clear();
+ this._strictConfirmPending.clear();
+ for(let [, timer] of this._applyTimers) clearTimeout(timer);
+ this._applyTimers.clear();
+ }
+
// True when a snapshot download aborted because the body outgrew the axios
// ceiling (SNAPSHOT_MAX_CONTENT). One definition for both the incremental
// fallback and the bootstrap halt, so the two can never disagree on what the
@@ -1683,6 +1764,7 @@ class ClientSync {
(typeof snapshotData.block_height === 'number') ? snapshotData.block_height : sinceBlock))
return;
await this.withApplyLock(() => this.applier.applyIncrementalSnapshot(snapshotData));
+ this._upsertDupKeyTarget = null;
if(typeof snapshotData.block_height === 'number')
this.lastAppliedBlock = snapshotData.block_height;
@@ -1800,6 +1882,7 @@ class ClientSync {
return;
}
getLogger().error(util.format('Incremental catch-up failed:', e));
+ if(await this.noteUpsertDuplicateKey(source, e)) return;
// Schema-gap failures are fixable right now: heal and retry once.
// The heal's debounce bounds the recursion: a second schema-gap
// failure inside the window returns false and falls through.
@@ -1959,6 +2042,7 @@ class ClientSync {
}
await this.verifyTableContentParity(source, blockHeight, remoteStatus);
+ await this.verifyTokenFoldParity(source, blockHeight, remoteStatus);
return verdict;
} catch(e){
getLogger().error(util.format('Hash verification failed against ' + source + ':', e));
@@ -2038,6 +2122,50 @@ class ClientSync {
}
}
+ // Advisory tokens fold-column parity (NON-consensus; never halts, never throws).
+ // Same preconditions as the two checks above: both sides opted in and we are AT
+ // the source's published height. The source's `ahead` ticks (edited after that
+ // height on its live table) are fed back as the exclusion, so both digests cover
+ // the same ticks. A mismatch means this replica holds an ISSUE edit the source
+ // does not, or lacks one it has: the forward class-7 carry or the rollback refold
+ // went wrong.
+ async verifyTokenFoldParity(source, blockHeight, remoteStatus){
+ if(!this.config['TOKEN_FOLD_PARITY_CHECK']) return null;
+ let remote = remoteStatus && remoteStatus.token_fold_parity;
+ if(!remote || typeof remote.h !== 'string') return null;
+ if(Number(remoteStatus.block_height) !== Number(blockHeight)) return null;
+ try {
+ let local = await this.blockHasher.computeTokenFoldChecksum(blockHeight, { exclude: remote.ahead || [] });
+ if(local.h !== remote.h){
+ getLogger().warn('TOKEN_FOLD_PARITY mismatch at block ' + blockHeight + ' against ' + source +
+ ': local=' + local.h + ' (' + local.n + ' rows) source=' + remote.h + ' (' + remote.n + ' rows)' +
+ ' (advisory, NOT halting; tokens owner/lock/callback/list/mint-window/bridge columns diverged)');
+ await this.recordSyncStateCounter('token_fold_mismatch', blockHeight);
+ return false;
+ }
+ getLogger().info('Token fold parity passed against ' + source + ' (' + local.n + ' rows)');
+ return true;
+ } catch(e){
+ getLogger().error(util.format('Token fold parity check errored at block %s (advisory, ignoring):', blockHeight, e.message));
+ return null;
+ }
+ }
+
+ // Durable running count plus last block for one advisory mismatch kind, under
+ // dbType-namespaced sync-state keys. Best-effort health signal; never throws.
+ async recordSyncStateCounter(kind, blockIndex){
+ try {
+ if(!this.db || typeof this.db.setSyncState !== 'function') return;
+ let countKey = kind + '_count:' + this.dbType;
+ let cur = (typeof this.db.getSyncState === 'function') ? await this.db.getSyncState(countKey) : null;
+ let n = (cur != null && Number.isFinite(Number(cur))) ? Number(cur) + 1 : 1;
+ await this.db.setSyncState(countKey, String(n));
+ await this.db.setSyncState(kind + '_last_block:' + this.dbType, String(blockIndex));
+ } catch(e){
+ // advisory; swallow
+ }
+ }
+
// Durably count advisory table-content parity mismatches, the twin of
// recordIndexMapMismatch above and never a consensus gate. Never throws. Also
// stores the diverging TABLE NAMES, because unlike the index-map counter this
@@ -2343,7 +2471,7 @@ class ClientSync {
// every sweep or never).
numericSetting(key, fallback, min){
let raw = (this.config && this.config[key] != null && this.config[key] !== '')
- ? this.config[key] : process.env[key];
+ ? this.config[key] : envConfig.envValueByName(key);
let n = Number(raw);
if(!Number.isFinite(n)) n = fallback;
if(min != null && n < min) n = min;
@@ -2512,14 +2640,6 @@ class ClientSync {
return this._exactParityTableSet;
}
- // Re-fetch the decoder `dispensers` table in full and replace the local copy.
- // dispensers cannot ride the block stream or the id-cursor lookup paging (no
- // monotonic id; the decoder soft-expires then hard-purges rows), so a truncated
- // bootstrap never seeds it and an incremental catch-up lets it drift. This keyset-
- // paged re-dump + atomic replace (ClientApplier.applyDispensersReplace) is the
- // convergence path; verifyDecoderCompleteness then verifies row counts without
- // false alarms. Decoder-only, best-effort: any fetch/parse failure aborts WITHOUT
- // touching the local table (the replace runs only once every page is in hand).
// Decide whether to reconcile the decoder `dispensers` table on this catch-up cycle
// (advances the per-process cycle counter as a side effect). Reconcile when:
// (a) firstResume - nothing reconciled yet this process (a resume that skipped
@@ -2535,7 +2655,7 @@ class ClientSync {
// healthy live-following replica never enters a catch-up at all, which is
// precisely the cadence this clause claims to bound.
// `_lastDispenserReconcileAt` is stamped by reconcileDispensers on success (covering
- // the bootstrap reconcile too), so firstResume is false once any reconcile has run.
+ // the from-height bootstrap reconcile too), so firstResume is false once any has run.
shouldReconcileDispensers(nowMs){
this._catchUpCount = (this._catchUpCount || 0) + 1;
let every = parseInt(this.config['DISPENSERS_RECONCILE_EVERY'], 10);
@@ -2551,22 +2671,36 @@ class ClientSync {
// Wall-clock term of the reconcile decision, WITHOUT shouldReconcileDispensers'
// cycle-counter side effect, so a recurring caller can sample the same bound without
- // corrupting the every-Nth catch-up cadence.
+ // corrupting the every-Nth catch-up cadence. Falls back to the last attempt, else the
+ // live-follow start, so a replica that never reconciled is bounded too.
dispenserReconcileIntervalDue(nowMs){
- return dispenserIntervalDue(this.config, this._lastDispenserReconcileAt, nowMs);
+ let since = latestTime(this._lastDispenserReconcileAttemptAt, this._dispenserClockArmedAt);
+ return dispenserIntervalDue(this.config, this._lastDispenserReconcileAt, nowMs, since);
}
+ // Re-fetch the decoder `dispensers` table in full and replace the local copy.
+ // dispensers cannot ride the block stream or the id-cursor lookup paging (no
+ // monotonic id; the decoder soft-expires then hard-purges rows), so a truncated
+ // bootstrap never seeds it and an incremental catch-up lets it drift. The source
+ // serves the whole table in one statement-consistent response (the has_more walk
+ // stays so a source that still pages completes too), then an atomic replace
+ // (ClientApplier.applyDispensersReplace) converges it. Decoder-only, best-effort:
+ // any fetch/parse failure aborts WITHOUT touching the local table. Every page is
+ // checked against SCHEMA_VERSION like the lookup-page and snapshot channels, so a
+ // code-version mismatch aborts before the replace (the status-tick caller has no
+ // earlier version-checked apply in front of it).
async reconcileDispensers(source){
if(this.dbType !== 'decoder') return;
if(!source) return;
+ // Stamp the attempt so a failing re-dump is retried once per interval, not per tick.
+ this._lastDispenserReconcileAttemptAt = Date.now();
+ let expected = SCHEMA_VERSION[this.dbType];
try {
let all = [];
let afterTx = null, afterAddr = null;
- let pageSize = this.lookupPageSize();
for(let guard = 0; guard < 1000000; guard++){
let url = source + '/snapshot-dispensers/' + this.dbType + '/' + this.chain + '/' + this.network +
- '?limit=' + pageSize +
- (afterTx !== null ? '&after_tx=' + afterTx + '&after_addr=' + afterAddr : '');
+ (afterTx !== null ? '?after_tx=' + afterTx + '&after_addr=' + afterAddr : '');
let response = await axios.get(url, {
headers: this.upstreamHeaders(),
responseType: 'arraybuffer',
@@ -2579,6 +2713,10 @@ class ClientSync {
try { jsonStr = zlib.gunzipSync(jsonStr); } catch(e){}
}
let page = JSON.parse(jsonStr.toString());
+ if(page.schema_version !== expected){
+ throw new Error('Dispensers page schema mismatch: server=' +
+ page.schema_version + ' client=' + expected);
+ }
let rows = Array.isArray(page.rows) ? page.rows : [];
for(let r of rows) all.push(r);
if(!page.has_more || rows.length === 0) break;
@@ -2754,7 +2892,7 @@ class ClientSync {
// it: the one cadence the bound claims to protect against (no catch-ups at
// all) was the one it could not reach, and the replica went on serving rows
// the source soft-expired or hard-purged for the life of the process. Row
- // counts cannot substitute (replicatedTables.js: a soft-expire leaves counts
+ // counts cannot substitute (src/schema/replicated_tables.js: a soft-expire leaves counts
// equal, a hard-purge leaves the replica ahead, reported for indexer only).
// Deliberately NOT folded into maybeVerifyCompleteness: that sweep returns
// early when COMPLETENESS_CHECK_INTERVAL is falsy and when the heights differ,
@@ -3891,7 +4029,7 @@ class ClientSync {
if(this.lastAppliedBlock !== null){
let depth = this.lastAppliedBlock - event.block_index + 1;
- if(depth > this.config['MAX_ROLLBACK_DEPTH']){
+ if(depth > this.maxRollbackDepth){
// A reorg too deep to roll back safely must FAIL CLOSED, not fail open.
// Returning bare here would leave lastAppliedBlock pointing at the now-
// orphaned tip: every canonical block the source re-streams from
@@ -3904,7 +4042,7 @@ class ClientSync {
// a durable halt via the same contract used for consensus divergence and let
// the operator investigate/clear, rather than advancing onto the fork.
await this.haltOnDivergence(event.block_index,
- [{ field: 'rollback_depth', depth, max: this.config['MAX_ROLLBACK_DEPTH'] }],
+ [{ field: 'rollback_depth', depth, max: this.maxRollbackDepth }],
this.sources.slice(0, 1), 'max-rollback-depth-exceeded');
return; // halted: no rollback, lastAppliedBlock left as-is, no further applies
}
diff --git a/src/config.js b/src/config.js
index 858146b2..de930b6e 100644
--- a/src/config.js
+++ b/src/config.js
@@ -42,6 +42,13 @@ function parseIntMin1(val, defaultVal){
return Math.max(1, parseIntSafe(val, defaultVal));
}
+// Parse an integer, falling back to the default (not clamping) below the minimum,
+// so an out-of-range value can never select a disable or a one-row page by accident.
+function parseIntAtLeast(val, min, defaultVal){
+ let parsed = parseIntSafe(val, defaultVal);
+ return parsed < min ? defaultVal : parsed;
+}
+
// A comma-separated list as trimmed, de-duplicated, non-empty entries; unset -> [].
function parseCsvSet(val){
return [...new Set((val || '').split(',').map(s => s.trim()).filter(s => s.length > 0))];
@@ -69,6 +76,15 @@ function bootstrapDepthKey(chain, network){
return String(ticker).toUpperCase() + ':' + String(network).toUpperCase();
}
+function resolveMaxRollbackDepth(chain, network, configuredDepth, explicitOverride){
+ const configured = parseIntMin1(configuredDepth, 100)
+ if(explicitOverride === true) return configured
+ if(explicitOverride !== false) return configured
+ return coinTicker(String(chain)) === 'LTC' && String(network).toLowerCase() === 'testnet'
+ ? 5000
+ : configured
+}
+
// Canonical key for a SYNC_BOOTSTRAP_DEPTH__ env name, or null when
// the suffix has no CHAIN_NETWORK shape at all (e.g. SYNC_BOOTSTRAP_DEPTH_BADKEY).
// An unrecognized chain still yields a key: it is a real key that matches no chain,
@@ -154,16 +170,24 @@ module.exports = {
/** The replication connection to measure replica lag on, empty when unset. */
replicaConnectionFromEnv: () => process.env.SYNC_REPLICA_CONNECTION,
- /** One variable whose NAME the caller computes (SYNC_MODE_, a pinned-validator override). */
+ /**
+ * One variable whose NAME the caller computes (a per-chain key such as
+ * SYNC_MODE_ or a pinned-validator override), read when called, so a
+ * variable set after boot is still seen. undefined when unset.
+ */
envValueByName: (name) => process.env[name],
- /** The live environment, for a caller that also accepts an injected one in tests. */
+ /**
+ * The live environment object, for a resolver or startup scanner that also
+ * accepts an injected environment in tests and falls back to this one.
+ */
envSource: () => process.env,
bootstrapDepthKey,
bootstrapDepthEnvKey,
unmatchedBootstrapDepthKeys,
assertBootstrapDepthChains,
+ resolveMaxRollbackDepth,
getConfig: function(){
let config = {};
@@ -289,12 +313,12 @@ module.exports = {
// Transparency endpoint rate limit (requests per minute per IP)
config['TRANSPARENCY_RATE_LIMIT'] = parseInt(process.env.TRANSPARENCY_RATE_LIMIT) || 10;
- // WebSocket backpressure (item 5410): a replica is dropped only when its send buffer
- // is genuinely stuck, not merely slow. MAX_BYTES caps per-peer server memory (a peer
- // accumulating past this is not draining); STALL_MS is how long the buffer may go
- // without making downward progress before the peer is dropped. This replaces the old
- // count-based WS_BACKPRESSURE_LIMIT, which dropped slow-but-draining replicas and
- // thrashed them into re-bootstraps.
+ // WebSocket backpressure: a replica is dropped only when its send buffer is
+ // genuinely stuck, not merely slow. MAX_BYTES caps per-peer server memory
+ // (a peer accumulating past this is not draining); STALL_MS is how long the
+ // buffer may go without making downward progress before the peer is dropped.
+ // This replaces the old count-based WS_BACKPRESSURE_LIMIT, which dropped
+ // slow-but-draining replicas and thrashed them into re-bootstraps.
config['WS_BACKPRESSURE_MAX_BYTES'] = parseIntMin1(process.env.WS_BACKPRESSURE_MAX_BYTES, 16777216); // 16 MiB
config['WS_BACKPRESSURE_STALL_MS'] = parseIntMin1(process.env.WS_BACKPRESSURE_STALL_MS, 30000); // 30 s
if(process.env.WS_BACKPRESSURE_LIMIT !== undefined)
@@ -326,6 +350,7 @@ module.exports = {
// Security: Maximum rollback depth from a single source (blocks)
config['MAX_ROLLBACK_DEPTH'] = parseIntMin1(process.env.MAX_ROLLBACK_DEPTH, 100);
+ config['MAX_ROLLBACK_DEPTH_EXPLICIT'] = process.env.MAX_ROLLBACK_DEPTH !== undefined;
// Security: Reject blocks on cross-source verification timeout (instead of applying from primary)
config['HASH_CONFIRM_STRICT'] = (process.env.HASH_CONFIRM_STRICT || '').toLowerCase() === 'true';
@@ -411,6 +436,22 @@ module.exports = {
// that endpoint is operator-polled rather than hot.
config['COMPLETENESS_CHECK_INTERVAL'] = parseIntMin0(process.env.COMPLETENESS_CHECK_INTERVAL, 3600000);
+ // DISPENSERS_RECONCILE_EVERY: a decoder client replaces its `dispensers` table
+ // every Nth incremental catch-up, since the table rides no block stream (>= 1).
+ config['DISPENSERS_RECONCILE_EVERY'] = parseIntAtLeast(process.env.DISPENSERS_RECONCILE_EVERY, 1, 20);
+
+ // DISPENSERS_RECONCILE_MAX_INTERVAL_MS: wall-clock bound (ms) on that reconcile,
+ // sampled on catch-ups and on the live status tick. 0 disables the bound.
+ config['DISPENSERS_RECONCILE_MAX_INTERVAL_MS'] = parseIntAtLeast(process.env.DISPENSERS_RECONCILE_MAX_INTERVAL_MS, 0, 1800000);
+
+ // LOOKUP_PAGE_SIZE: rows per page when a client pages the append-only lookup
+ // tables by id cursor (>= 1); the client clamps it to 100000.
+ config['LOOKUP_PAGE_SIZE'] = parseIntAtLeast(process.env.LOOKUP_PAGE_SIZE, 1, 50000);
+
+ // GAP_LOG_INTERVAL_MS: throttle window (ms) for the client's catch-up gap log
+ // summaries (>= 1).
+ config['GAP_LOG_INTERVAL_MS'] = parseIntAtLeast(process.env.GAP_LOG_INTERVAL_MS, 1, 30000);
+
// INDEX_MAP_PARITY_CHECK: advisory id->address map parity. Default OFF, and
// UNLIKE the VERIFY_* gates above it NEVER halts: a mismatch is logged + counted
// only. It catches a replica whose index_addresses id->address map content
@@ -435,6 +476,15 @@ module.exports = {
// OFF by default: it reads a window of ~93 indexer tables per status poll.
config['TABLE_CONTENT_PARITY_CHECK'] = (process.env.TABLE_CONTENT_PARITY_CHECK || '').toLowerCase() === 'true';
+ // TOKEN_FOLD_PARITY_CHECK: advisory digest of the tokens metadata columns ISSUE
+ // folds in place (BlockHasher.computeTokenFoldChecksum). Those columns are in no
+ // consensus hash and are excluded from the content-parity windows as in-place
+ // state, so a replica missing an edit or its reorg reversal is visible only here.
+ // Same posture as the two checks above: read on both sides, NEVER halts, a
+ // mismatch is logged and durably counted, OFF by default (one tokens scan per
+ // status poll on the source).
+ config['TOKEN_FOLD_PARITY_CHECK'] = (process.env.TOKEN_FOLD_PARITY_CHECK || '').toLowerCase() === 'true';
+
// TABLE_CONTENT_PARITY_WINDOW: how many blocks (and, for the append-only
// lookups that carry no block column, how many ids) each content checksum
// spans. Server-side setting: the source publishes the window it used and a
diff --git a/src/consensus/equivocation_header.js b/src/consensus/equivocation_header.js
index 48e11991..c043bdec 100644
--- a/src/consensus/equivocation_header.js
+++ b/src/consensus/equivocation_header.js
@@ -18,11 +18,15 @@
* EQUIV|||||
*
* The canonical source of record is
- * xchain-documentation/protocol/reference-impl/equivocation_header.js; it is
- * vendored BYTE-IDENTICALLY into xchain-hub, xchain-indexer, xchain-explorer,
- * xchain-sdk and xchain-sync. Every PBFT/consensus engine prefixes its canonical
- * through here, the settlement gates (cross_settle, xexec, xcall, anchor, price,
- * attest) and the recovery verifier re-derive it to re-verify quorum signatures,
+ * xchain-indexer/src/consensus/equivocation_header.js; it is vendored
+ * BYTE-IDENTICALLY into xchain-hub, xchain-explorer, xchain-sdk, xchain-sync and
+ * xchain-documentation/protocol/reference-impl/consensus/equivocation_header.js.
+ * Edit the indexer copy only and re-run reconcile-twins.sh to re-vendor every
+ * other copy; never edit a vendored copy.
+ *
+ * Every PBFT/consensus engine prefixes its canonical through here, the settlement
+ * gates (cross_settle, xexec, xcall, anchor, price, attest) and the recovery
+ * verifier re-derive it to re-verify quorum signatures,
* and the SLASH v0 action verifies equivocation proofs against it. Adding ``
* makes equivocation provable WITHOUT false-positiving honest view changes (which
* re-sign different content for the same round under a different view). Equivocation
@@ -38,8 +42,9 @@
* the same anchor. The cross-service conformance suite
* (ConsensusPrimitiveConformance.test.js, driven by
* xchain-documentation/protocol/test-vectors) runs in every repo and asserts BOTH the
- * behavior (canonical vectors) AND byte-identity of the local copy to this canonical
- * source, so any unmirrored edit fails CI everywhere (a divergence forks the chain).
+ * behavior (canonical vectors) AND byte-identity of the local copy to the
+ * xchain-documentation copy, so any unmirrored edit fails CI everywhere (a
+ * divergence forks the chain).
*
********************************************************************/
diff --git a/src/consensus/gate_registry/shared_rows_1.js b/src/consensus/gate_registry/shared_rows_1.js
index bf311be0..568d8ff5 100644
--- a/src/consensus/gate_registry/shared_rows_1.js
+++ b/src/consensus/gate_registry/shared_rows_1.js
@@ -295,11 +295,11 @@ addGate('anchor_reward_activation.ANCHOR_ATTEST_ARRIVAL_MARGIN_S', 'constant', 6
// that window.
addGate('anchor_reward_activation.ANCHOR_ATTEST_BARRIER_ACTIVATION', 'height', {
mainnet: null, // INERT under the 2026-08-29 mainnet write hold
- // SIZED 2026-09-16 20:41Z, on the BTC clock because this member is BTC-only: the same
- // instant as the family's BTC CONSUMER height, so the one member that keeps BOTH
- // certificates gains them together rather than carrying a lone extra rule for 6 h.
- // Above the same roll and the same epoch close; the canon carries the measurement.
- testnet: 153266,
+ // SIZED 2026-09-16 20:41Z, RE-SLID 2026-09-19 and 2026-09-23, on the BTC clock because this
+ // member is BTC-only: the same instant as the family's BTC CONSUMER height, so the one
+ // member that keeps BOTH certificates gains them together rather than carrying a lone extra
+ // rule for 6 h. The canon carries the measurement.
+ testnet: 154291,
regtest: UNPINNED, // shares the family's arming seam so one venue lever arms both
});
diff --git a/src/consensus/gate_registry/shared_rows_2.js b/src/consensus/gate_registry/shared_rows_2.js
index c7fd765d..0063a165 100644
--- a/src/consensus/gate_registry/shared_rows_2.js
+++ b/src/consensus/gate_registry/shared_rows_2.js
@@ -216,9 +216,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_ACTIVATION', 'height', {
'BTC:mainnet': null,
'LTC:mainnet': null,
'DOGE:mainnet': null,
- 'BTC:testnet': 153222, // SIZED 2026-09-16 20:41Z: epoch close 153,216 + 6 buried; tip 152,756 + 466 at 498.7 s/blk, about 64.5 h
- 'LTC:testnet': 4891504, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 4,889,190 + 2314 at 82.5 s/blk
- 'DOGE:testnet': 67911796, // RE-CUT 2026-09-17 22:45Z onto that instant: tip 67,904,912 + 6884 at 27.7 s/blk
+ 'BTC:testnet': 154234, // RE-SLID 2026-09-23: train 154,074 + 160 blocks (17 h at the 383.04 s/blk bound, 25.6 h at the 575.89 s/blk 84 h trailing mean), the v0.20.1 patch reslide
+ 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train
+ 'DOGE:testnet': 67936053, // RE-SLID 2026-09-23: tip 67,924,397 at 17:48Z + 11656 blocks (83.8 h at 25.89 s/blk, the 84 h trailing mean, the same instant as the BTC producer), the v0.20.1 patch reslide
'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration
'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration
'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration
@@ -228,9 +228,9 @@ addGate('mirror_admission_activation.MIRROR_ADMISSION_CONSUMER_ACTIVATION', 'hei
'BTC:mainnet': null,
'LTC:mainnet': null,
'DOGE:mainnet': null,
- 'BTC:testnet': 153266, // its producer + 44 blocks, about 6 h: strictly above, never equal
- 'LTC:testnet': 4891766, // its producer + 262 blocks, about 6 h at 82.5 s/blk
- 'DOGE:testnet': 67912575, // its producer + 779 blocks, about 6 h at 27.7 s/blk
+ 'BTC:testnet': 154291, // RE-SLID 2026-09-23: its producer + 57 blocks (6 h at the 383.04 s/blk bound, 9.1 h at the 575.89 s/blk 84 h trailing mean), strictly above, never equal
+ 'LTC:testnet': null, // disabled for v0.20.1, 2026-09-18: LTC:testnet mirror admission ships null on this train; arms on a later train
+ 'DOGE:testnet': 67936888, // RE-SLID 2026-09-23: its producer + 835 blocks (6 h at 25.89 s/blk, the 84 h trailing mean), strictly above, never equal
'BTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration
'LTC:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration
'DOGE:regtest': UNPINNED, // ARMS by XC_MIRROR_ADMISSION_ACTIVATION at registration
diff --git a/src/consensus/gate_registry/shared_rows_5.js b/src/consensus/gate_registry/shared_rows_5.js
index cbf28976..fde6a96c 100644
--- a/src/consensus/gate_registry/shared_rows_5.js
+++ b/src/consensus/gate_registry/shared_rows_5.js
@@ -108,9 +108,16 @@ addGate('train_activation.TRAIN_ACTIVATION', 'ruleset', {
// the LTC leg of this family two days off its BTC counterpart a day after the first cut.
// That lead is the rolling-upgrade window the fleet roll must finish inside (24x the 90
// minute roll budget), and every testnet mirror-admission height sits above it on the same
- // BTC clock (the BTC producer at 153,222 is 106 blocks and about 17.0 h further up), so a
- // node lacking this rule set halts before it can grade an admission-stamped row.
- '0.20.0': { mainnet: 9999999999, testnet: 153116, regtest: 0 },
+ // BTC clock, so a node lacking this rule set halts before it can grade an admission-stamped
+ // row.
+ // RE-SLID 2026-09-23 for the v0.20.1 patch train, after the live tips overran the
+ // 2026-09-19 slide before the freeze: margin is 40 h to the nearest armed height, converted
+ // at each coin's fastest defensible cadence. Chain_tip TBTC 153,698 at 2026-09-23T15:55Z
+ // + 376 blocks, ceil(40 h / 383.04 s per block, the least-squares bound). The
+ // mirror-admission family below re-slides onto the same instant plus its own 17 h and 6 h
+ // offsets. LTC:testnet mirror admission ships disabled on this train and is
+ // untouched by this reslide; it arms on a later train.
+ '0.20.0': { mainnet: 9999999999, testnet: 154074, regtest: 0 },
});
// xchain_bridge_activation
diff --git a/src/consensus/stake_weighted_quorum.js b/src/consensus/stake_weighted_quorum.js
index 76fdd4ed..11665534 100644
--- a/src/consensus/stake_weighted_quorum.js
+++ b/src/consensus/stake_weighted_quorum.js
@@ -14,17 +14,20 @@
*
* THE single, CONSENSUS-CRITICAL implementation of the stake-weighted quorum
* predicate. The canonical source of record is
- * xchain-documentation/protocol/reference-impl/stake_weighted_quorum.js; it is
- * vendored BYTE-IDENTICALLY into xchain-hub, xchain-indexer, xchain-explorer,
- * xchain-sdk and xchain-sync. Every PBFT tally engine, every settlement gate
- * (cross_settle, xexec, xcall, anchor, price, attest), the recovery verifier and
- * the client/explorer checkpoint verifiers resolve through this predicate so they
- * can never drift; a divergence forks the chain.
+ * xchain-indexer/src/consensus/stake_weighted_quorum.js; it is vendored
+ * BYTE-IDENTICALLY into xchain-hub, xchain-explorer, xchain-sdk, xchain-sync and
+ * xchain-documentation/protocol/reference-impl/consensus/stake_weighted_quorum.js.
+ * Edit the indexer copy only and re-run reconcile-twins.sh to re-vendor every
+ * other copy; never edit a vendored copy. Every PBFT tally engine, every
+ * settlement gate (cross_settle, xexec, xcall, anchor, price, attest), the
+ * recovery verifier and the client/explorer checkpoint verifiers resolve through
+ * this predicate so they can never drift; a divergence forks the chain.
*
* The cross-service conformance suite (ConsensusPrimitiveConformance.test.js,
* driven by xchain-documentation/protocol/test-vectors) runs in every repo and
* asserts BOTH the behavior (canonical vectors) AND byte-identity of the local
- * copy to this canonical source, so any unmirrored edit fails CI everywhere.
+ * copy to the xchain-documentation copy, so any unmirrored edit fails CI
+ * everywhere.
*
* Self-contained on mathjs bignumber (exact, never a JS double): the predicate is
* a pure function with no injected utility instance, so it is identical in every
diff --git a/src/db/credits.js b/src/db/credits.js
index f4070728..b882469b 100644
--- a/src/db/credits.js
+++ b/src/db/credits.js
@@ -65,4 +65,31 @@ module.exports = {
[completedStatusId, from, to], conn);
},
+ /**
+ * The current tokens row of every tick a credit, debit or escrow touched in
+ * this window, which is exactly the set whose supply may have moved. Each
+ * ledger table joins actions on its own and the tick ids are UNIONed, so the
+ * block-range predicate drives from actions into each table's index instead of
+ * materializing all three ledgers first. UNION, not UNION ALL, keeps the tick
+ * set distinct.
+ *
+ * @param {number} from first block of the window, inclusive
+ * @param {number} to last block of the window, inclusive
+ * @param {object} [conn] a connection to read on, when the caller holds one
+ * @returns {Promise} the driver's row array
+ */
+ async findLedgerTouchedTokens(from, to, conn){
+ return await this.doQuery(
+ "SELECT t.* FROM `tokens` t WHERE t.tick_id IN (" +
+ "SELECT c.tick_id FROM credits c JOIN actions a ON a.action_index = c.action_index " +
+ "WHERE a.block_index BETWEEN ? AND ? AND c.tick_id IS NOT NULL " +
+ "UNION " +
+ "SELECT d.tick_id FROM debits d JOIN actions a ON a.action_index = d.action_index " +
+ "WHERE a.block_index BETWEEN ? AND ? AND d.tick_id IS NOT NULL " +
+ "UNION " +
+ "SELECT e.tick_id FROM escrows e JOIN actions a ON a.action_index = e.action_index " +
+ "WHERE a.block_index BETWEEN ? AND ? AND e.tick_id IS NOT NULL)",
+ [from, to, from, to, from, to], conn);
+ },
+
};
diff --git a/src/db/index.js b/src/db/index.js
index 0209ee8f..b938f629 100644
--- a/src/db/index.js
+++ b/src/db/index.js
@@ -610,10 +610,10 @@ class Database {
definition: 'CHAR(64) NULL AFTER `block_merkle_root`' },
{ table: 'state_tree_roots', column: 'contract_state_root_shadow',
definition: 'CHAR(64) NULL AFTER `contract_state_root`' },
- // Stage B's shadow column ( B3), same reasoning as the two
- // above: state_tree_roots is follower-derived, so an aged replica
- // never gains it from the definition file and the first shadow-window
- // block would fail its INSERT with errno 1054.
+ // Stage B's shadow column, with the same reasoning as the two above:
+ // state_tree_roots is follower-derived, so an aged replica never gains
+ // it from the definition file and the first shadow-window block would
+ // fail its INSERT with errno 1054.
{ table: 'state_tree_roots', column: 'balances_root_escrow_shadow',
definition: 'CHAR(64) NULL AFTER `contract_state_root_shadow`' },
// The key-rebuild preconditions ride the same ADD COLUMN loop, so the columns
@@ -749,7 +749,7 @@ class Database {
// assertStakeWeightOrderingCollation, sharing that module's ONE definition of the
// declared contract so the two services cannot disagree about what "undrifted"
// means. The follower rebuilds stakes_root from the byte-mirrored
- // _cappedStakeWeightsSql; its window caps truncate on this order, so a drifted
+ // cappedStakeWeightsSql; its window caps truncate on this order, so a drifted
// collation selects different cap survivors and the replica halts on a root it
// computed wrong. Once the collation gate is armed, a drifted CHARSET fails the
// query outright (errno 1253), so halting at boot with the column named is the
diff --git a/src/db/index_lookups.js b/src/db/index_lookups.js
index 17cca073..aa77fe04 100644
--- a/src/db/index_lookups.js
+++ b/src/db/index_lookups.js
@@ -57,4 +57,26 @@ module.exports = {
return await this.doQuery("SELECT * FROM pubkeys WHERE address_id IN (" + ids.map(() => '?').join(',') + ")", ids, conn);
},
+ /**
+ * The address string for each of a set of index_addresses ids.
+ *
+ * @param {Array} aIn distinct index_addresses ids, at least one
+ * @returns {Promise} the driver's row array, rows of { id, address }
+ */
+ async findIndexAddressTextByIds(aIn){
+ return await this.doQuery(
+ 'SELECT id, address FROM index_addresses WHERE id IN (' + aIn.map(() => '?').join(',') + ')', aIn);
+ },
+
+ /**
+ * The tick string for each of a set of index_tickers ids.
+ *
+ * @param {Array} tIn distinct index_tickers ids, at least one
+ * @returns {Promise} the driver's row array, rows of { id, tick }
+ */
+ async findIndexTickTextByIds(tIn){
+ return await this.doQuery(
+ 'SELECT id, tick FROM index_tickers WHERE id IN (' + tIn.map(() => '?').join(',') + ')', tIn);
+ },
+
};
diff --git a/src/db/stakes.js b/src/db/stakes.js
index 4ad2a98c..66b480ac 100644
--- a/src/db/stakes.js
+++ b/src/db/stakes.js
@@ -31,14 +31,15 @@ const logger = getLogger();
module.exports = {
// Light-client stakes_root support (SPV spec sec.4.1, BTC-only). Source-deduped
- // capability stake-weight query, ported VERBATIM from xchain-indexer/src/db.js
- // _stakeWeightsSql: it MUST produce a byte-identical SQL string + arg order or
+ // capability stake-weight query, ported VERBATIM from
+ // xchain-indexer/src/db/stakes/effective_set_sql.js
+ // stakeWeightsSql: it MUST produce a byte-identical SQL string + arg order or
// the follower's stakes_root diverges from the indexer's committed root and the
// state-commitment check false-halts. The cross-repo drift guard in
- // test/unit/rollback/rollback_coverage.test.js locks the two together. Reads only tables
+ // test/unit/rollback_coverage.test.js locks the two together. Reads only tables
// xchain-sync replicates (stakes, delegations, stake_key_revocations,
// capability_slash_events, index_addresses, index_pubkeys).
- _stakeWeightsSql(valid_id, blockIndex, minStake){
+ stakeWeightsSql(valid_id, blockIndex, minStake){
// Precision: DECIMAL(30,8) (22 integer digits, 8 fractional) is sufficient because the
// staking tick is XCHAIN at 8 decimals and total supply stays far below 10^22; every
// same-version node truncates identically, so the stake-weight tally is deterministic.
@@ -96,7 +97,7 @@ module.exports = {
},
// SWQ source-cap wrapper (SWQ-TRUNC-1 liveness half). Wraps an inner source-keyed
- // stake-weight builder ({sql,args} from _stakeWeightsSql or the sync AsOf variant)
+ // stake-weight builder ({sql,args} from stakeWeightsSql or the sync AsOf variant)
// and replaces the raw key-row LIMIT with a windowed cap on the consensus UNIT:
// DISTINCT staking SOURCES (DENSE_RANK over source) plus a per-source key bound
// (ROW_NUMBER per source). One source can no longer fill the window and evict
@@ -117,7 +118,7 @@ module.exports = {
// truncate on, so the collation decides which sources and which keys survive into
// the hashed stakes_root. Below the height the emitted SQL is byte-identical to
// what shipped before the gate; the suffix is '' and concatenates away.
- _cappedStakeWeightsSql(inner, maxSources, maxKeys, binCollation){
+ cappedStakeWeightsSql(inner, maxSources, maxKeys, binCollation){
let c = stakeWeightCollation.stakeWeightCollate(binCollation);
let sql = `SELECT r.pubkey AS pubkey, r.source AS source, r.weight AS weight, r._sr AS _sr
FROM (
@@ -135,8 +136,8 @@ module.exports = {
// Apply the cap regime in force for `coin`/`network` at `blockIndex` to an inner
// source-keyed stake-weight builder, returning { rows:[{pubkey,source,weight}],
// truncated }. Twin of the indexer's stakeWeightsWithCap gate: at/after
- // SWQ_SOURCE_CAP_ACTIVATION the windowed source-cap (_cappedStakeWeightsSql);
- // below it the legacy uncapped key-row LIMIT. The gate + caps + _cappedStakeWeightsSql
+ // SWQ_SOURCE_CAP_ACTIVATION the windowed source-cap (cappedStakeWeightsSql);
+ // below it the legacy uncapped key-row LIMIT. The gate + caps + cappedStakeWeightsSql
// are byte-mirrored to the indexer so the follower's stakes_root set is identical on
// both sides of the height. Sync reads coin/network from the caller (it has no
// per-chain config); a null coin/network stays inert (legacy uncapped path).
@@ -149,7 +150,7 @@ module.exports = {
if(swqCap.isSwqSourceCapActive(blockIndex, network, coin)){
let maxSources = swqCap.STAKE_WEIGHT_MAX_SOURCES;
let maxKeys = swqCap.STAKE_WEIGHT_MAX_KEYS_PER_SOURCE;
- let capped = this._cappedStakeWeightsSql(inner, maxSources, maxKeys, binCollation);
+ let capped = this.cappedStakeWeightsSql(inner, maxSources, maxKeys, binCollation);
// Strict for the M-17 reason getBlockLeafRows is: this row set IS the
// stakes_root, and the SPV checkpoint forward-follow
// (ClientSync.oraclePublishSetAt) reads it with NO transaction open, so a
@@ -190,7 +191,7 @@ module.exports = {
// rethrow: a null here would silently return the empty stake set (M-17).
let valid_id = await this.getStatusId('valid', { rethrow: true });
if(valid_id === null) return [];
- let sw = this._stakeWeightsSql(valid_id, blockIndex, String(minStake));
+ let sw = this.stakeWeightsSql(valid_id, blockIndex, String(minStake));
let { rows } = await this.applyStakeWeightCap(sw, blockIndex, limit, coin, network, 'getStakeWeightsByCapability(' + capability + ')');
return rows;
},
@@ -212,14 +213,14 @@ module.exports = {
// add-back there would double-count and fork the committed ledger. This is a NO-OP
// when no slash with block_index > S exists (addback is NULL -> COALESCE 0), so it
// returns a result byte-identical to getStakeWeightsByCapability(cap, S) computed
- // in order at S. _stakeWeightsSql (the cross-repo byte-identical twin) is deliberately
+ // in order at S. stakeWeightsSql (the cross-repo byte-identical twin) is deliberately
// NOT reused/modified here so the drift guard and the consensus query stay untouched.
async getStakeWeightsByCapabilityAsOf(capability, snapshotBlock, minStake, limit, coin, network){
// rethrow for the same M-17 reason, and this is the caller that runs with NO
// transaction open (ClientSync.oraclePublishSetAt).
let valid_id = await this.getStatusId('valid', { rethrow: true });
if(valid_id === null) return [];
- // Membership exclusion is identical to _stakeWeightsSql: a key slashed at
+ // Membership exclusion is identical to stakeWeightsSql: a key slashed at
// block > S has cse.block_index > S, so NOT EXISTS is TRUE and the key is
// correctly KEPT in the set at S. Only the q-subquery AMOUNT is reconstructed.
const slashExcl = (keyCol) =>
@@ -272,7 +273,7 @@ module.exports = {
) ek ON ek.source_id = q.source_id
JOIN index_pubkeys ip ON ip.id = ek.pubkey_id`;
// Arg order tracks the placeholders left-to-right: the addback block bound
- // first, then the same sequence _stakeWeightsSql uses (all historical-block
+ // first, then the same sequence stakeWeightsSql uses (all historical-block
// args bound to snapshotBlock), then the LIMIT.
let args = [snapshotBlock,
valid_id, snapshotBlock, snapshotBlock, String(minStake),
diff --git a/src/db/state_tree_roots.js b/src/db/state_tree_roots.js
index fbab6036..690a1fe7 100644
--- a/src/db/state_tree_roots.js
+++ b/src/db/state_tree_roots.js
@@ -56,4 +56,19 @@ module.exports = {
'SELECT state_root, block_merkle_root FROM state_tree_roots WHERE block_index=? LIMIT 1', [blockIndex]);
},
+ /**
+ * Delete one chain's root rows at and above a height, the same predicate a
+ * rollback applies to this table. `chain` is the TICKER every writer stores.
+ *
+ * @param {string} chain the coin ticker
+ * @param {string} network
+ * @param {number} blockHeight first height to delete, inclusive
+ * @returns {Promise} the driver's result
+ */
+ async deleteStateTreeRootsFromBlock(chain, network, blockHeight){
+ return await this.doQuery(
+ 'DELETE FROM state_tree_roots WHERE chain = ? AND network = ? AND block_index >= ?',
+ [chain, network, blockHeight]);
+ },
+
};
diff --git a/src/db/tables.js b/src/db/tables.js
index c6e30318..d7875ef1 100644
--- a/src/db/tables.js
+++ b/src/db/tables.js
@@ -23,6 +23,7 @@
const path = require('path');
const lifecycle = require('../table_lifecycle');
const { assertValidIdentifier } = require('./shared.js');
+const { ARCHIVE_HEAD_VERSIONS_SQL, ARCHIVE_CHUNK_HEIGHT_COL } = require('../consensus/state_hash');
module.exports = {
@@ -366,4 +367,296 @@ module.exports = {
);
},
+ /**
+ * Empty one table on the replica before a full snapshot re-imports it. DELETE
+ * rather than TRUNCATE, because MariaDB refuses TRUNCATE on a table a foreign
+ * key references. The caller validates the name first.
+ *
+ * @param {string} table
+ * @returns {Promise} the driver's result
+ */
+ async deleteAllRows(table){
+ return await this.doQuery('DELETE FROM `' + table + '`');
+ },
+
+ /**
+ * Empty the decoder's dispensers table ahead of a reconcile re-insert, inside
+ * the caller's transaction.
+ *
+ * @returns {Promise} the driver's result
+ */
+ async deleteAllDispensers(){
+ return await this.doQuery('DELETE FROM `dispensers`');
+ },
+
+ /**
+ * Clear every row already holding one of these natural-key values, so a
+ * re-sent row replaces rather than collides. Both names are interpolated
+ * because an identifier cannot be a bind parameter; the caller validates them.
+ *
+ * @param {string} table
+ * @param {string} naturalKey the column the values belong to
+ * @param {Array} slice the values, a bounded chunk of them
+ * @returns {Promise} the driver's result
+ */
+ async deleteRowsByKeyValues(table, naturalKey, slice){
+ return await this.doQuery(
+ 'DELETE FROM `' + table + '` WHERE `' + naturalKey + '` IN (' +
+ slice.map(() => '?').join(', ') + ')',
+ slice);
+ },
+
+ /**
+ * One multi-row INSERT of `rowCount` rows over `columns`, with `args` holding
+ * every row's values in column order. `useIgnore` skips a row whose key already
+ * exists; `useUpsert` overwrites the existing row with the carried values. The
+ * caller validates every identifier and decodes the values.
+ *
+ * @param {string} table
+ * @param {Array} columns
+ * @param {number} rowCount
+ * @param {Array} args
+ * @param {boolean} useIgnore
+ * @param {boolean} useUpsert
+ * @returns {Promise} the driver's result
+ */
+ async insertRowValues(table, columns, rowCount, args, useIgnore, useUpsert){
+ let colList = columns.map(c => '`' + c + '`').join(', ');
+ let placeholders = columns.map(() => '?').join(', ');
+
+ let insertPrefix = useIgnore
+ ? 'INSERT IGNORE INTO `' + table + '` (' + colList + ') VALUES '
+ : 'INSERT INTO `' + table + '` (' + colList + ') VALUES ';
+ // VALUES(col) back-reference is the MariaDB idiom for "the value this row
+ // would have inserted"; updating the key column to itself is a harmless no-op.
+ let updateSuffix = useUpsert
+ ? ' ON DUPLICATE KEY UPDATE ' + columns.map(c => '`' + c + '` = VALUES(`' + c + '`)').join(', ')
+ : '';
+
+ let valueClauses = [];
+ for(let i = 0; i < rowCount; i++) valueClauses.push('(' + placeholders + ')');
+
+ let query = insertPrefix + valueClauses.join(', ') + updateSuffix;
+ return await this.doQuery(query, args);
+ },
+
+ /**
+ * The row holding one surrogate id, if any.
+ *
+ * @param {string} table
+ * @param {number} id
+ * @returns {Promise} the driver's row array, at most one row
+ */
+ async findRowIdById(table, id){
+ return await this.doQuery('SELECT id FROM `' + table + '` WHERE id = ? LIMIT 1', [id]);
+ },
+
+ /**
+ * The ids of rows matching one natural key. LIMIT 2, because the caller only
+ * needs to tell "exactly one holder" from "none" or "ambiguous".
+ *
+ * @param {string} table
+ * @param {Array} keyColumns validated column names of the key
+ * @param {Array} values one value per key column
+ * @returns {Promise} the driver's row array, at most two rows
+ */
+ async findRowIdsByKeyColumns(table, keyColumns, values){
+ return await this.doQuery(
+ 'SELECT id FROM `' + table + '` WHERE ' +
+ keyColumns.map(c => '`' + c + '` = ?').join(' AND ') + ' LIMIT 2',
+ values);
+ },
+
+ /**
+ * Delete one row by its surrogate id.
+ *
+ * @param {string} table
+ * @param {number} holderId
+ * @returns {Promise} the driver's result
+ */
+ async deleteRowById(table, holderId){
+ return await this.doQuery('DELETE FROM `' + table + '` WHERE id = ?', [holderId]);
+ },
+
+ /**
+ * The ordered column names of one index of one table in this database.
+ *
+ * @param {string} table
+ * @param {string} indexName
+ * @returns {Promise} the driver's row array, one row per column in index order
+ */
+ async findIndexColumnNames(table, indexName){
+ return await this.doQuery(
+ "SELECT column_name FROM information_schema.statistics " +
+ "WHERE table_schema = ? AND table_name = ? AND index_name = ? ORDER BY seq_in_index ASC",
+ [this.dbName, table, indexName]);
+ },
+
+ // The updated-rows channel's reads (server/updated_rows.js). Each returns the
+ // CURRENT full state of surviving rows mutated in place inside a block window,
+ // which the action-scoped stream cannot carry because the row's own action is
+ // older than the window. Table names are interpolated because an identifier
+ // cannot be a bind parameter; every one comes from that module's fixed lists.
+
+ /**
+ * Rows whose deactivation_block stamp falls in the window. The caller shifts
+ * the window by the chain's activation delay, because a stamp is written that
+ * many blocks ahead of the action that set it.
+ *
+ * @param {string} table
+ * @param {number} fromStamp first stamp value, inclusive
+ * @param {number} toStamp last stamp value, inclusive
+ * @param {object} [conn] a connection to read on, when the caller holds one
+ * @returns {Promise} the driver's row array
+ */
+ async findDeactivationStampedRows(table, fromStamp, toStamp, conn){
+ return await this.doQuery(
+ "SELECT * FROM `" + table + "` WHERE deactivation_block IS NOT NULL AND deactivation_block BETWEEN ? AND ?",
+ [fromStamp, toStamp], conn);
+ },
+
+ /**
+ * Stake or unstake rows a SLASH reduced in this window, reached through the
+ * debit log entry that records the reduction.
+ *
+ * @param {{table: string, debits: string, target: string}} spec one SLASH_SPECS entry
+ * @param {number} from first block of the window, inclusive
+ * @param {number} to last block of the window, inclusive
+ * @param {object} [conn] a connection to read on, when the caller holds one
+ * @returns {Promise} the driver's row array
+ */
+ async findSlashDebitedRows(spec, from, to, conn){
+ return await this.doQuery(
+ "SELECT t.* FROM `" + spec.table + "` t " +
+ "JOIN `" + spec.debits + "` d ON d.stake_action_index = t.action_index " +
+ "WHERE d.target_table = ? AND d.block_index BETWEEN ? AND ?",
+ [spec.target, from, to], conn);
+ },
+
+ /**
+ * Contract stake rows a DELEGATE v1 signing-key rotation rewrote in this
+ * window, pinned to a block only by the rotations journal.
+ *
+ * @param {string} rotTbl one ROTATION_TABLES entry
+ * @param {number} from first block of the window, inclusive
+ * @param {number} to last block of the window, inclusive
+ * @param {object} [conn] a connection to read on, when the caller holds one
+ * @returns {Promise} the driver's row array
+ */
+ async findRotatedStakeRows(rotTbl, from, to, conn){
+ return await this.doQuery(
+ "SELECT t.* FROM `" + rotTbl + "` t " +
+ "JOIN `contract_delegation_rotations` r ON r.stake_action_index = t.action_index " +
+ "WHERE r.target_table = ? AND r.block_index BETWEEN ? AND ?",
+ [rotTbl, from, to], conn);
+ },
+
+ /**
+ * Version 0 request rows whose request_status resolved in this window.
+ *
+ * @param {string} table one REQUEST_STATUS_TABLES entry
+ * @param {number} from first block of the window, inclusive
+ * @param {number} to last block of the window, inclusive
+ * @param {object} [conn] a connection to read on, when the caller holds one
+ * @returns {Promise} the driver's row array
+ */
+ async findResolvedRequestRows(table, from, to, conn){
+ return await this.doQuery(
+ "SELECT * FROM `" + table + "` WHERE version = 0 AND resolved_block BETWEEN ? AND ?",
+ [from, to], conn);
+ },
+
+ /**
+ * Poll rows finalized in this window, or whose deferred binding callback
+ * fired at a due block in this window.
+ *
+ * @param {string} table one POLL_FINALIZE_TABLES entry
+ * @param {number} from first block of the window, inclusive
+ * @param {number} to last block of the window, inclusive
+ * @param {object} [conn] a connection to read on, when the caller holds one
+ * @returns {Promise} the driver's row array
+ */
+ async findFinalizedPollRows(table, from, to, conn){
+ return await this.doQuery(
+ "SELECT * FROM `" + table + "` WHERE resolved_block BETWEEN ? AND ? " +
+ "OR (callback_due_block BETWEEN ? AND ? AND callback_execute_action_index IS NOT NULL)",
+ [from, to, from, to], conn);
+ },
+
+ /**
+ * Unstake rows whose cooldown matured in this window.
+ *
+ * @param {string} table one COOLDOWN_STATUS_TABLES entry
+ * @param {number} from first block of the window, inclusive
+ * @param {number} to last block of the window, inclusive
+ * @param {object} [conn] a connection to read on, when the caller holds one
+ * @returns {Promise} the driver's row array
+ */
+ async findMaturedCooldownRows(table, from, to, conn){
+ return await this.doQuery(
+ "SELECT * FROM `" + table + "` WHERE cooldown_end_block BETWEEN ? AND ?",
+ [from, to], conn);
+ },
+
+ /**
+ * BET rows matching a window predicate the caller built over the spec's
+ * stamp columns.
+ *
+ * @param {{table: string}} spec one BET_STATUS_SPECS entry
+ * @param {string} where the OR-joined stamp predicate
+ * @param {Array} args one window pair per stamp column
+ * @param {object} [conn] a connection to read on, when the caller holds one
+ * @returns {Promise} the driver's row array
+ */
+ async findBetStampedRows(spec, where, args, conn){
+ return await this.doQuery(
+ "SELECT * FROM `" + spec.table + "` WHERE " + where, args, conn);
+ },
+
+ /**
+ * Archive-head anchor parents stamped invalid_archive by a completing chunk
+ * that landed in this window. The height key is the shared chunk-height column
+ * from stateHash.js, which a v2 continuation row actually populates.
+ *
+ * @param {number} from first block of the window, inclusive
+ * @param {number} to last block of the window, inclusive
+ * @param {object} [conn] a connection to read on, when the caller holds one
+ * @returns {Promise} the driver's row array
+ */
+ async findInvalidArchiveHeadRows(from, to, conn){
+ return await this.doQuery(
+ "SELECT DISTINCT p.* FROM anchor_actions p " +
+ "JOIN anchor_actions c ON c.version = 2 AND c.match_batch_seq = p.match_batch_seq " +
+ "JOIN index_statuses ps ON ps.id = p.status_id AND ps.status = 'invalid_archive' " +
+ "JOIN index_statuses cs ON cs.id = c.status_id AND cs.status = 'valid' " +
+ "WHERE p.version " + ARCHIVE_HEAD_VERSIONS_SQL + " AND " + ARCHIVE_CHUNK_HEIGHT_COL + " BETWEEN ? AND ?",
+ [from, to], conn);
+ },
+
+ /**
+ * ATTEST batch heads whose failure verdict was stamped when a valid
+ * continuation by the same author completed the batch inside this window.
+ *
+ * @param {number} headVersion the batch head's attests version
+ * @param {number} continuationVersion the continuation chunk's attests version
+ * @param {string} completionStamp the status suffix a completion stamp carries
+ * @param {number} from first block of the window, inclusive
+ * @param {number} to last block of the window, inclusive
+ * @param {object} [conn] a connection to read on, when the caller holds one
+ * @returns {Promise} the driver's row array
+ */
+ async findFailedAttestBatchHeads(headVersion, continuationVersion, completionStamp, from, to, conn){
+ return await this.doQuery(
+ "SELECT ah.* FROM attests ah " +
+ "JOIN index_statuses ahs ON ahs.id = ah.status_id AND ahs.status LIKE ? " +
+ "JOIN actions aha ON aha.action_index = ah.action_index " +
+ "JOIN attests ac ON ac.request_id = ah.request_id " +
+ "AND ac.version = " + continuationVersion + " AND ac.batch_chunk_index IS NOT NULL " +
+ "JOIN index_statuses acs ON acs.id = ac.status_id AND acs.status = 'valid' " +
+ "JOIN actions aca ON aca.action_index = ac.action_index AND aca.source_id = aha.source_id " +
+ "WHERE ah.version = " + headVersion + " AND ah.batch_chunk_index = 0 " +
+ "AND ac.block_index BETWEEN ? AND ?",
+ ['%' + completionStamp, from, to], conn);
+ },
+
};
diff --git a/src/db/token_refold.js b/src/db/token_refold.js
new file mode 100644
index 00000000..a5a2cff1
--- /dev/null
+++ b/src/db/token_refold.js
@@ -0,0 +1,298 @@
+/*********************************************************************
+ *
+ * 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.
+ *
+ **********************************************************************
+ *
+ * Replica refold of the `tokens` metadata columns from surviving `issues`
+ *
+ * The reverse leg of updated_rows class 7 (src/server/updated_rows/token_rows.js).
+ * The source rewrites the WHOLE tokens row from the tick's valid ISSUE history on
+ * every refresh, and its reorg refreshes every tick the orphaned range touched:
+ *
+ * xchain-indexer/src/rollback/index.js:165 collectAffectedEntities (issues read
+ * at src/db/rollback/read_phase.js:136)
+ * xchain-indexer/src/rollback/commit.js:60 updateTokens(tickers, true)
+ * xchain-indexer/src/db/database/ledger_checks.js:130 updateTokenInfo
+ * xchain-indexer/src/db/issues/token_info.js:29 getTokenInfo, whose replay is
+ * rowsQuery (:84), rowValues (:131) and foldRow (:169)
+ * xchain-indexer/src/db/tokens/token_writer.js:26 createToken, via
+ * normalizeDataValues (src/db/database/normalize.js:136), fields (:57) and
+ * updateSql (:113)
+ *
+ * A replica cannot call that code, and nothing streams the refolded row back: the
+ * row's action_index is pinned at the first issuance, and the surviving issues are
+ * older than any window the stream will carry again. So this module restates the
+ * fold, step for step, over the replica's own replicated `issues` rows, and writes
+ * the same column values the source's UPDATE binds. `supply` is left to
+ * balance_helpers.recomputeTokenSupplies, which already mirrors getTokenSupply and
+ * must run AFTER this (it reads tokens.decimals). `bridged`, `escrow_action_index`,
+ * `coin_price` and `coin_floor` are not fold output and are never touched.
+ *
+ * test/unit/token_refold.test.js runs the indexer's own getTokenInfo + createToken
+ * beside this port over the same issue rows when the sibling checkout is present.
+ *
+ ********************************************************************/
+
+'use strict';
+
+const mathjs = require('mathjs');
+
+// Wire-field lists the source's normalizeDataValues applies, restricted to the keys
+// the fold produces (xchain-indexer/src/config/wire_fields.js, token_limits.js).
+const NUMBER_FIELDS = ['ALLOW_LIST', 'BLOCK_LIST', 'CALLBACK_AMOUNT', 'CALLBACK_BLOCK', 'DECIMALS',
+ 'MAX_SUPPLY', 'MAX_MINT', 'MINT_ADDRESS_MAX', 'MINT_START_BLOCK', 'MINT_STOP_BLOCK'];
+const LIST_FIELDS = ['ALLOW_LIST', 'BLOCK_LIST'];
+const INTEGER_FIELDS = ['ALLOW_LIST', 'BLOCK_LIST', 'CALLBACK_BLOCK', 'MINT_START_BLOCK', 'MINT_STOP_BLOCK'];
+const U64_MAX = '18446744073709551615';
+// LOCK_BRIDGE is deliberately absent: the source's LOCK_FIELDS list omits it too.
+const LOCK_FIELDS = ['LOCK_MAX_SUPPLY', 'LOCK_MINT', 'LOCK_MINT_SUPPLY', 'LOCK_MAX_MINT',
+ 'LOCK_DESCRIPTION', 'LOCK_SLEEP', 'LOCK_CALLBACK'];
+const MIN_TOKEN_DECIMALS = 0;
+const MAX_TOKEN_DECIMALS = 18;
+
+// The fold-owned columns, in the order the source's UPDATE lists them (minus supply).
+// Shared with the advisory parity digest so the refold and its detector cover one set.
+const FOLD_COLUMNS = Object.freeze(['max_supply', 'max_mint', 'decimals', 'description',
+ 'lock_max_supply', 'lock_mint', 'lock_mint_supply', 'lock_max_mint', 'lock_description',
+ 'lock_sleep', 'lock_callback', 'callback_block', 'callback_tick_id', 'callback_amount',
+ 'allow_list', 'block_list', 'mint_address_max', 'mint_start_block', 'mint_stop_block',
+ 'bridge_chains', 'min_depth', 'lock_bridge', 'owner_id', 'last_action_index']);
+
+const CHUNK = 500;
+
+// Source Utility.isNull / isNumeric / exceedsUnsignedColumn / bcformat, restated.
+function isNull(v){ return (v === null || v === undefined || v === ''); }
+function isNumeric(v){ return typeof v === 'bigint' || (!isNaN(parseFloat(v)) && isFinite(v)); }
+function exceedsU64(v){
+ let raw = String(v).trim();
+ if(/^[+-]?[0-9]+$/.test(raw)){ let n = BigInt(raw); return (n < 0n || n > BigInt(U64_MAX)); }
+ let approx = Number(raw);
+ return (Number.isFinite(approx) && (approx < 0 || approx > Number(U64_MAX)));
+}
+function bcformat(num, decimals){
+ let str = String(num).trim();
+ let bn = (str === 'NaN' || str === 'Infinity' || str === '-Infinity' || !isNumeric(num))
+ ? mathjs.bignumber(0) : mathjs.bignumber(str);
+ return mathjs.format(bn, { notation: 'fixed', precision: isNull(decimals) ? 0 : parseInt(decimals) });
+}
+
+// One issues row keyed as the source's token-info fields (rowValues). Ids stand in for
+// the strings the source later interns back to the same ids: the tick, the owner
+// (transfer address when it resolves, else the action's source address) and the
+// callback tick (only when it resolves, since the source's LEFT JOIN gives null).
+function rowValues(row){
+ return {
+ ACTION_INDEX: row.action_index,
+ TICK_ID: row.tick_id,
+ OWNER_ID: (row.transfer_addr_id !== null && row.transfer_addr_id !== undefined) ? row.transfer_addr_id : row.source_addr_id,
+ MAX_SUPPLY: row.max_supply, MAX_MINT: row.max_mint,
+ DECIMALS: (!isNull(row.decimals)) ? parseInt(row.decimals) : 0,
+ DESCRIPTION: row.description,
+ LOCK_MAX_SUPPLY: row.lock_max_supply, LOCK_MINT_SUPPLY: row.lock_mint_supply,
+ LOCK_MINT: row.lock_mint, LOCK_MAX_MINT: row.lock_max_mint,
+ LOCK_DESCRIPTION: row.lock_description, LOCK_SLEEP: row.lock_sleep, LOCK_CALLBACK: row.lock_callback,
+ CALLBACK_TICK_ID: row.callback_tick_ref, CALLBACK_BLOCK: row.callback_block, CALLBACK_AMOUNT: row.callback_amount,
+ ALLOW_LIST: row.allow_list, BLOCK_LIST: row.block_list,
+ BRIDGE_CHAINS: row.bridge_chains, MIN_DEPTH: row.min_depth, LOCK_BRIDGE: row.lock_bridge,
+ MINT_ADDRESS_MAX: row.mint_address_max, MINT_START_BLOCK: row.mint_start_block, MINT_STOP_BLOCK: row.mint_stop_block,
+ };
+}
+
+// foldRow, verbatim in effect: a set LOCK_ never unsets, DECIMALS never drops, an empty
+// value inherits. ACTION_INDEX is overwritten by every row (the source's first-issuance
+// branch does not `continue`), so it ends on the LAST valid issue, which is the value the
+// source's UPDATE writes to last_action_index.
+function foldRow(data, arr){
+ for(let key in arr){
+ let value = arr[key];
+ if(key === 'ACTION_INDEX' && isNull(data[key])) data[key] = value;
+ if(key.substr(0, 5) === 'LOCK_' && data[key] == 1) continue;
+ if(key === 'DECIMALS' && data[key] > value) continue;
+ if(isNull(value)) continue;
+ data[key] = value;
+ }
+}
+
+// normalizeDataValues over the fold's keys. The fold carries no ACTION key, so the
+// source's per-action text truncations never apply.
+function normalize(input){
+ let data = Object.assign({}, input);
+ for(let key in data)
+ if(!isNull(data[key]) && typeof data[key] === 'object' && !Buffer.isBuffer(data[key])) data[key] = String(data[key]);
+ for(let f of LIST_FIELDS) if(!isNull(data[f]) && !isNumeric(data[f])) data[f] = null;
+ for(let f of NUMBER_FIELDS) if(isNull(data[f]) || !isNumeric(data[f])) data[f] = null;
+ for(let f of INTEGER_FIELDS) if(!isNull(data[f]) && exceedsU64(data[f])) data[f] = null;
+ for(let f of LOCK_FIELDS){
+ let v = data[f];
+ if(typeof v === 'string' && isNumeric(v)) v = parseInt(v);
+ data[f] = ([0, 1].indexOf(v) === -1) ? null : v;
+ }
+ if(!isNull(data.DECIMALS) && (data.DECIMALS < MIN_TOKEN_DECIMALS || data.DECIMALS > MAX_TOKEN_DECIMALS)) data.DECIMALS = null;
+ return data;
+}
+
+// createToken's fields(): the fold state rendered as the column values its UPDATE binds.
+function tokenColumns(folded){
+ let d = normalize(folded);
+ let num = (v) => (!isNull(v) && isNumeric(v)) ? v : 0;
+ let decimals = (!isNull(d.DECIMALS) && isNumeric(d.DECIMALS)) ? parseInt(d.DECIMALS) : 0;
+ let c = {
+ max_supply: num(d.MAX_SUPPLY), max_mint: num(d.MAX_MINT), mint_address_max: num(d.MINT_ADDRESS_MAX),
+ decimals: decimals, description: (d.DESCRIPTION === undefined) ? null : d.DESCRIPTION,
+ lock_max_supply: (d.LOCK_MAX_SUPPLY == 1) ? 1 : 0, lock_mint: (d.LOCK_MINT == 1) ? 1 : 0,
+ lock_mint_supply: (d.LOCK_MINT_SUPPLY == 1) ? 1 : 0, lock_max_mint: (d.LOCK_MAX_MINT == 1) ? 1 : 0,
+ lock_description: (d.LOCK_DESCRIPTION == 1) ? 1 : 0, lock_sleep: (d.LOCK_SLEEP == 1) ? 1 : 0,
+ lock_callback: (d.LOCK_CALLBACK == 1) ? 1 : 0,
+ callback_block: (d.CALLBACK_BLOCK > 0) ? d.CALLBACK_BLOCK : 0,
+ callback_tick_id: isNull(d.CALLBACK_TICK_ID) ? null : d.CALLBACK_TICK_ID,
+ callback_amount: num(d.CALLBACK_AMOUNT),
+ allow_list: (!isNull(d.ALLOW_LIST) && isNumeric(d.ALLOW_LIST)) ? parseInt(d.ALLOW_LIST) : null,
+ block_list: (!isNull(d.BLOCK_LIST) && isNumeric(d.BLOCK_LIST)) ? parseInt(d.BLOCK_LIST) : null,
+ mint_start_block: num(d.MINT_START_BLOCK), mint_stop_block: num(d.MINT_STOP_BLOCK),
+ bridge_chains: (!isNull(d.BRIDGE_CHAINS) && String(d.BRIDGE_CHAINS) !== '-') ? String(d.BRIDGE_CHAINS) : null,
+ min_depth: (!isNull(d.MIN_DEPTH) && isNumeric(d.MIN_DEPTH)) ? parseInt(d.MIN_DEPTH) : null,
+ lock_bridge: (d.LOCK_BRIDGE == 1) ? 1 : 0,
+ owner_id: isNull(d.OWNER_ID) ? null : d.OWNER_ID,
+ last_action_index: d.ACTION_INDEX,
+ };
+ // The source formats the amount limits at the token's precision; callback_amount stays raw.
+ if(decimals >= MIN_TOKEN_DECIMALS && decimals <= MAX_TOKEN_DECIMALS){
+ c.max_supply = bcformat(c.max_supply, decimals);
+ c.max_mint = bcformat(c.max_mint, decimals);
+ c.mint_address_max = bcformat(c.mint_address_max, decimals);
+ }
+ return c;
+}
+
+// Fold rows already grouped in (tick_id, action_index) order into one column set per
+// tick. Each set also carries first_action_index (not a FOLD_COLUMN, never written): the
+// first surviving valid issue, which refoldTokenRows checks against tokens.action_index.
+function foldIssueRows(rows){
+ let byTick = new Map();
+ let first = new Map();
+ for(let row of (rows || [])){
+ let key = String(row.tick_id);
+ if(!byTick.has(key)){ byTick.set(key, {}); first.set(key, row.action_index); }
+ foldRow(byTick.get(key), rowValues(row));
+ }
+ let out = new Map();
+ for(let [tick, data] of byTick) out.set(tick, Object.assign(tokenColumns(data), { first_action_index: first.get(tick) }));
+ return out;
+}
+
+// rowsQuery for a set of ticks. The INNER JOINs are the source's, so an issue whose
+// action, transaction, ticker, source address or status does not resolve drops out on
+// both sides alike.
+function foldRowsSql(n){
+ return "SELECT i.tick_id, i.action_index, i.max_supply, i.max_mint, i.decimals, i.description, " +
+ "i.lock_max_supply, i.lock_mint_supply, i.lock_mint, i.lock_max_mint, i.lock_description, " +
+ "i.lock_sleep, i.lock_callback, i.callback_block, i.callback_amount, i.mint_address_max, " +
+ "i.mint_start_block, i.mint_stop_block, i.allow_list, i.block_list, i.bridge_chains, " +
+ "i.min_depth, i.lock_bridge, a2.id AS source_addr_id, a3.id AS transfer_addr_id, " +
+ "t3.id AS callback_tick_ref " +
+ "FROM issues i " +
+ "INNER JOIN actions a1 ON (a1.action_index=i.action_index) " +
+ "INNER JOIN transactions t1 ON (t1.tx_index=a1.tx_index) " +
+ "INNER JOIN index_tickers t2 ON (t2.id=i.tick_id) " +
+ "INNER JOIN index_addresses a2 ON (a2.id=a1.source_id) " +
+ "INNER JOIN index_statuses s1 ON (s1.id=i.status_id) " +
+ "LEFT JOIN index_addresses a3 ON (a3.id=i.transfer_id) " +
+ "LEFT JOIN index_tickers t3 ON (t3.id=i.callback_tick_id) " +
+ "WHERE s1.status='valid' AND i.tick_id IN (" + new Array(n).fill('?').join(',') + ") " +
+ "ORDER BY i.tick_id ASC, i.action_index ASC";
+}
+
+async function foldTicks(db, tickIds){
+ let out = new Map();
+ for(let i = 0; i < tickIds.length; i += CHUNK){
+ let part = tickIds.slice(i, i + CHUNK);
+ let rows = await db.doQuery(foldRowsSql(part.length), part, null, { rethrow: true });
+ for(let [k, v] of foldIssueRows(rows)) out.set(k, v);
+ }
+ return out;
+}
+
+// Ticks named by any issues row in the orphaned range, valid or not, read BEFORE the
+// dataTables delete removes those rows. The source's read phase reads `issues` with no
+// status filter too, so both sides refresh the same set. An empty result makes the
+// refold below a no-op, which is what a rollback that touched no ISSUE must be.
+async function collectIssueTickIds(db, firstActionIndex){
+ let rows = await db.doQuery(
+ "SELECT DISTINCT tick_id FROM issues WHERE action_index >= ? AND tick_id IS NOT NULL ORDER BY tick_id",
+ [firstActionIndex], null, { rethrow: true });
+ return (rows || []).map(r => r.tick_id);
+}
+
+// The surviving tokens rows' first-issuance action_index, by tick_id.
+async function tokenAnchors(db, tickIds){
+ let out = new Map();
+ for(let i = 0; i < tickIds.length; i += CHUNK){
+ let part = tickIds.slice(i, i + CHUNK);
+ let rows = await db.doQuery("SELECT tick_id, action_index FROM tokens WHERE tick_id IN (" +
+ new Array(part.length).fill('?').join(',') + ")", part, null, { rethrow: true });
+ for(let r of (rows || [])) out.set(String(r.tick_id), String(r.action_index));
+ }
+ return out;
+}
+
+// Rewrite each collected tick's fold columns from its surviving valid issues. A tick
+// with no surviving valid issue is skipped, as the source skips it (getTokenInfo
+// returns false); its row, if it had one, went with the dataTables delete. UPDATE only:
+// the replica never mints a tokens id the source did not stream.
+//
+// A tick is refolded only when its FIRST surviving valid issue is the row's own
+// first issuance (tokens.action_index). Otherwise this replica does not hold the
+// tick's whole history (a truncated SYNC_BOOTSTRAP_DEPTH replica keeps only
+// [base..tip] of issues), and a fold over the tail would write a row the source never
+// had. Those ticks are returned as skipped, left as they are, and reported by the
+// caller; TOKEN_FOLD_PARITY_CHECK is what then shows whether they diverged.
+async function refoldTokenRows(db, tickIds){
+ let result = { refolded: 0, skipped: [] };
+ if(!tickIds || tickIds.length === 0) return result;
+ let folded = await foldTicks(db, tickIds);
+ let anchors = await tokenAnchors(db, [...folded.keys()]);
+ let sets = FOLD_COLUMNS.map(c => c + '=?').join(', ');
+ for(let [tick, cols] of folded){
+ if(!anchors.has(tick)) continue;
+ if(anchors.get(tick) !== String(cols.first_action_index)){ result.skipped.push(tick); continue; }
+ await db.doQuery("UPDATE tokens SET " + sets + " WHERE tick_id=?",
+ FOLD_COLUMNS.map(c => cols[c]).concat([tick]), null, { rethrow: true });
+ result.refolded++;
+ }
+ return result;
+}
+
+// Advisory parity digest input: every tokens row's fold columns (supply and the
+// non-fold columns excluded, since they move outside the fold) in tick_id order.
+// Rows whose last_action_index is above `maxActionIndex` are left out and returned by
+// tick_id, so a source whose indexer has run past the published height can name the
+// ticks it edited since, and the follower can leave out the same ones.
+async function tokenFoldRows(db, maxActionIndex, excludeTickIds){
+ let rows = await db.doQuery(
+ "SELECT tick_id, action_index, " + FOLD_COLUMNS.join(', ') + " FROM tokens ORDER BY tick_id ASC",
+ [], null, { rethrow: true });
+ let skip = new Set((excludeTickIds || []).map(String));
+ let kept = [], ahead = [];
+ for(let r of (rows || [])){
+ let tick = String(r.tick_id);
+ let beyond = (maxActionIndex !== null && maxActionIndex !== undefined && r.last_action_index !== null &&
+ BigInt(String(r.last_action_index)) > BigInt(String(maxActionIndex)));
+ if(beyond) ahead.push(tick);
+ if(beyond || skip.has(tick)) continue;
+ let o = { tick_id: tick, action_index: r.action_index === null ? null : String(r.action_index) };
+ for(let c of FOLD_COLUMNS) o[c] = (r[c] === null || r[c] === undefined) ? null : String(r[c]);
+ kept.push(o);
+ }
+ return { rows: kept, ahead: ahead };
+}
+
+module.exports = { FOLD_COLUMNS, foldIssueRows, collectIssueTickIds, refoldTokenRows, tokenFoldRows };
diff --git a/src/db/validator_rewards.js b/src/db/validator_rewards.js
index d5642568..895c44b5 100644
--- a/src/db/validator_rewards.js
+++ b/src/db/validator_rewards.js
@@ -73,4 +73,24 @@ module.exports = {
[from, to], conn);
},
+ /**
+ * Delete every validator_rewards row a replicated reconcile-log pre-image
+ * names, keyed on the full five-column reward identity. round_qualifier is part
+ * of the key because two distinct archive rewards can share the other four.
+ *
+ * @param {string} scopeSql predicate over the log alias `d` bounding its rows to the applied window
+ * @param {Array} scopeArgs the predicate's bind values
+ * @returns {Promise} the driver's result
+ */
+ async deleteReconciledValidatorRewards(scopeSql, scopeArgs){
+ return await this.doQuery(
+ "DELETE vr FROM validator_rewards vr " +
+ "JOIN anchor_reward_reconcile_log d " +
+ " ON d.source_id = vr.source_id AND d.signing_pubkey_id = vr.signing_pubkey_id " +
+ " AND d.reward_type = vr.reward_type AND d.round_reference <=> vr.round_reference " +
+ " AND d.round_qualifier = vr.round_qualifier " +
+ "WHERE " + scopeSql,
+ scopeArgs);
+ },
+
};
diff --git a/src/http/cors_origin.js b/src/http/cors_origin.js
index b28a427f..ad5561c2 100644
--- a/src/http/cors_origin.js
+++ b/src/http/cors_origin.js
@@ -38,8 +38,8 @@
*
* Identical by intent to xchain-encoder/src/corsOrigin.js, xchain-hub's
* src/lib/corsOrigin.js, xchain-indexer/src/corsOrigin.js,
- * xchain-utxo-tracker/src/corsOrigin.js and xchain-sdk/src/corsOrigin.js; keep
- * the six in step .
+ * xchain-utxo-tracker/src/corsOrigin.js and xchain-sdk/src/corsOrigin.js;
+ * keep the six in step.
*
* @param {string|undefined|null} raw - the raw CORS_ORIGIN value
* @returns {false|string|string[]} `false` (disabled), `'*'` (any), one origin, or an allowlist
diff --git a/src/http/shutdown.js b/src/http/shutdown.js
index b1bd92dd..47bc8651 100644
--- a/src/http/shutdown.js
+++ b/src/http/shutdown.js
@@ -30,10 +30,12 @@
const envConfig = require('../config');
-// Hard-exit budget for the whole drain. Docker's default stop grace is 10s and
-// xchain-node issues a bare `docker stop`, so the default sits under it: an
-// overrun that ends in our own logged exit is diagnosable, one that ends in the
-// daemon's SIGKILL is not. SHUTDOWN_TIMEOUT_MS overrides.
+// Hard-exit budget for the whole drain. xchain-node stops a sync service with a
+// 30 s budget (stamped on the container as --stop-timeout), and a container
+// created before that budget existed still gets docker's ten seconds, so the
+// default sits under both: an overrun that ends in our own logged exit is
+// diagnosable, one that ends in the daemon's SIGKILL is not.
+// SHUTDOWN_TIMEOUT_MS overrides.
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 8000;
function resolveTimeoutMs(timeoutMs, env){
diff --git a/src/observability/index.js b/src/observability/index.js
index f07981c4..26b8de5b 100644
--- a/src/observability/index.js
+++ b/src/observability/index.js
@@ -78,6 +78,17 @@ let _shipperAttached = false;
// (` warn [svc] warn [svc] msg`).
let _sink = null;
+// routeLabel's unmatched-path fallback (below) hands out one label per
+// distinct first path segment, and that segment is chosen by whoever sends
+// the request. Without a cap, a flood of distinct unmatched segments buys one
+// series per request against the SHARED per-metric budget every route draws
+// from (Registry maxSeries, metrics.js), and once that budget is spent the
+// service's own routes can no longer register a series either. Past
+// UNMATCHED_ROUTE_LABEL_CAP distinct segments, every further one collapses
+// onto a single overflow label instead of buying its own series.
+const UNMATCHED_ROUTE_LABEL_CAP = 20;
+const _unmatchedRouteLabels = new Set();
+
const CONSOLE_METHODS = { log: 'info', info: 'info', warn: 'warn', error: 'error', debug: 'debug' };
function toBool(v, fallback = false) {
@@ -110,7 +121,10 @@ function timingSafeEqual(a, b) {
// Path label for HTTP metrics. Express route patterns ("/hub-db/snapshot/:t")
// are already low-cardinality; a raw URL is not, so anything without a matched
// route falls back to its first path segment. This is the difference between a
-// dozen series and one per block height.
+// dozen series and one per block height. The first segment is still whatever
+// the requester sent, so past UNMATCHED_ROUTE_LABEL_CAP distinct segments seen
+// (above), later ones share a fixed overflow label instead of each buying a
+// new series.
function routeLabel(req) {
if (req.route && req.route.path) {
const base = req.baseUrl || '';
@@ -119,7 +133,14 @@ function routeLabel(req) {
}
const raw = (req.originalUrl || req.url || '/').split('?')[0];
const seg = raw.split('/').filter(Boolean)[0];
- return seg ? `/${seg}` : '/';
+ if (!seg) return '/';
+ const label = `/${seg}`;
+ if (_unmatchedRouteLabels.has(label)) return label;
+ if (_unmatchedRouteLabels.size < UNMATCHED_ROUTE_LABEL_CAP) {
+ _unmatchedRouteLabels.add(label);
+ return label;
+ }
+ return '/_unmatched';
}
// Express dispatches its router stack in registration order, so a timing
@@ -394,6 +415,7 @@ function _resetObservability() {
_logger = null;
_registry = null;
_shipperAttached = false;
+ _unmatchedRouteLabels.clear();
}
module.exports = {
diff --git a/src/schema/generated_columns.js b/src/schema/generated_columns.js
index b41b54b3..df8f668a 100644
--- a/src/schema/generated_columns.js
+++ b/src/schema/generated_columns.js
@@ -44,7 +44,7 @@ const GENERATED_COLUMNS = Object.freeze({
const _sets = new Map();
function generatedColumns(table){
if(!_sets.has(table))
- _sets.set(table, new Set(GENERATED_COLUMNS[table] || []));
+ _sets.set(table, new Set(Object.prototype.hasOwnProperty.call(GENERATED_COLUMNS, table) ? GENERATED_COLUMNS[table] : []));
return _sets.get(table);
}
diff --git a/src/schema/replicated_tables.js b/src/schema/replicated_tables.js
index c389cc75..9154461f 100644
--- a/src/schema/replicated_tables.js
+++ b/src/schema/replicated_tables.js
@@ -84,7 +84,7 @@ const lifecycle = require('../table_lifecycle');
// verification path consumes the flattened union via getReplicatedTables().
//
// The INDEXER topology is generated from the table-lifecycle registry
-// (src/tableLifecycle.js, byte-identical twin of the xchain-indexer copy):
+// (src/table_lifecycle.js, byte-identical twin of the xchain-indexer copy):
// each indexer table's registry entry declares its stream scope, so adding a
// table there simultaneously adds it to the per-block stream, the /status
// completeness count, and both rollback sets. The DECODER topology stays
@@ -128,9 +128,9 @@ const TOPOLOGY = {
// it converges via full snapshot + the periodic re-dump/replace reconcile,
// which is the ONLY thing keeping it in parity. The count is a post-replace
// equality sanity check, not a backstop: the hard-purge DELETE gap leaves
- // the replica ahead (_verifyTableCounts flags remote > local only) and a
- // soft-expire UPDATE leaves counts equal, so neither can ever fire, and
- // _incrementalCatchUp excludes the table on every non-reconcile cycle.
+ // the replica ahead (ClientSync.verifyTableCounts flags remote > local only)
+ // and a soft-expire UPDATE leaves counts equal, so neither can ever fire, and
+ // ClientSync.incrementalCatchUp excludes the table on every non-reconcile cycle.
special: ['dispensers']
},
@@ -163,15 +163,26 @@ function getReplicatedTables(dbType){
return [...new Set(all)];
}
+// Replicated tables whose content cannot converge between source and replica, so
+// neither the /status row-count check (ClientSync.verifyTableCounts) nor the
+// content-parity plan may compare them. Both read this one declaration.
+//
+// `events` is an append-only operational log keyed by an AUTO_INCREMENT id both
+// sides generate independently, applied with INSERT IGNORE, so a source row whose
+// id the replica already used is dropped and the replica keeps its own row there.
+// The id-windowed content digest then sees equal counts over different rows.
+const OPERATIONAL_LOG_TABLES = Object.freeze(['events']);
+
// The advisory content-parity plan for a dbType: the replicated tables whose
// CONTENT (not merely their row count) a follower can prove against the source,
// each paired with the bound its checksum window uses.
//
// Coverage is the per-block replicated set minus the two exclusion classes the
-// registry declares (src/tableLifecycle.js CONTENT_PARITY_*): the operator
+// registry declares (src/table_lifecycle.js CONTENT_PARITY_*): the operator
// carve-outs (markets, decoder dispensers) and the in-place mutated tables,
// which the enforced state_hash already commits and which have no stable window
-// content. Derived from the same topology the stream and the row counts use, so
+// content. The operational logs above are out too, as the count check leaves
+// them out. Derived from the same topology the stream and the row counts use, so
// a table added to replication joins this check with no second list to update.
//
// bound values, consumed by BlockHasher.computeTableContentChecksums:
@@ -192,6 +203,7 @@ function contentParityPlan(dbType){
let add = (table, bound) => {
if(mutable.has(table)) return; // committed by state_hash instead
if(lifecycle.contentParityCarveOut(table, type) !== null) return; // operator ruling
+ if(OPERATIONAL_LOG_TABLES.includes(table)) return; // ids diverge by construction
if(plan.some(p => p.table === table)) return; // topology buckets can overlap
plan.push({ table: table, bound: bound });
};
@@ -207,7 +219,7 @@ function contentParityPlan(dbType){
// Every replicated table that is NOT in the content-parity plan, mapped to the
// reason it is out. Exists so the coverage guard can assert the complement is
-// exactly the two declared exclusion classes and nothing has silently fallen
+// exactly the declared exclusions and nothing has silently fallen
// through: a replicated table that is neither checked nor knowingly excluded is
// the defect was raised for.
function contentParityExclusions(dbType){
@@ -218,6 +230,8 @@ function contentParityExclusions(dbType){
let carve = lifecycle.contentParityCarveOut(table, type);
if(carve !== null) out[table] = 'operator-carve-out: ' + carve;
else if(mutable.has(table)) out[table] = 'in-place mutated; committed by the enforced state_hash class instead';
+ else if(OPERATIONAL_LOG_TABLES.includes(table))
+ out[table] = 'operational log: independently generated ids applied with INSERT IGNORE; neither count nor id-windowed content can converge';
}
return out;
}
@@ -232,15 +246,17 @@ function contentParityExclusions(dbType){
// names the gap: any table in the per-block replicated set that this schema
// lacks is a table replication will skip without ever failing.
//
-// Deliberately a superset signal. A table absent on BOTH the source and this
-// replica (source older than this build) is reported too, because from here the
-// two cases are indistinguishable and reporting the harmless one costs an
-// operator one migration check, while missing the real one costs silent data
-// loss. Returns null when the table listing itself is unavailable: "unknown"
-// must not read as "nothing missing".
-function missingReplicatedTables(present, dbType){
+// Client callers pass the validated source table set so a mixed-version source
+// does not make build-newer tables look like replica gaps. Server callers omit
+// it and continue checking their own schema against this build's topology.
+// Returns null when either required listing is unavailable: "unknown" must not
+// read as "nothing missing".
+function missingReplicatedTables(present, dbType, sourcePresent){
if(!present || typeof present.has !== 'function') return null;
- return getReplicatedTables(dbType).filter(t => !present.has(t)).sort();
+ if(sourcePresent === null || (sourcePresent !== undefined && typeof sourcePresent.has !== 'function')) return null;
+ return getReplicatedTables(dbType)
+ .filter(t => (sourcePresent === undefined || sourcePresent.has(t)) && !present.has(t))
+ .sort();
}
// The cursor column for id-ordered paging of an append-only lookup table
@@ -263,5 +279,5 @@ function lookupCursorColumn(table){
module.exports = {
getTopology, getReplicatedTables, missingReplicatedTables, lookupCursorColumn,
- contentParityPlan, contentParityExclusions
+ contentParityPlan, contentParityExclusions, OPERATIONAL_LOG_TABLES
};
diff --git a/src/server/block_broadcaster/broadcast_and_heartbeats.js b/src/server/block_broadcaster/broadcast_and_heartbeats.js
index ad0240f6..2aeb53a5 100644
--- a/src/server/block_broadcaster/broadcast_and_heartbeats.js
+++ b/src/server/block_broadcaster/broadcast_and_heartbeats.js
@@ -206,14 +206,16 @@ module.exports = {
let data = isPreSerialized ? message : JSON.stringify(message, bigIntReplacer);
- // Backpressure (item 5410): drop a peer only when it is genuinely stuck, not merely
- // slow. Two independent signals on the OS send buffer:
- // 1) a hard byte ceiling - the peer is accumulating unboundedly (server-memory risk);
- // 2) a non-draining stall timeout - the buffer has not made any downward progress for
- // WS_BACKPRESSURE_STALL_MS. The stall timer resets on ANY drop in bufferedAmount,
- // so a slow-but-draining replica keeps resetting and stays connected instead of
- // being force-dropped into a re-bootstrap thrash loop (the old count-based check
- // dropped it because the buffer rarely returned to exactly zero under load).
+ // Backpressure: drop a peer only when it is genuinely stuck, not merely slow.
+ // Two independent signals on the OS send buffer:
+ // 1) a hard byte ceiling - the peer is accumulating unboundedly
+ // (server-memory risk);
+ // 2) a non-draining stall timeout - the buffer has not made any downward
+ // progress for WS_BACKPRESSURE_STALL_MS. The stall timer resets on ANY
+ // drop in bufferedAmount, so a slow-but-draining replica keeps resetting
+ // and stays connected instead of being force-dropped into a re-bootstrap
+ // thrash loop (the old count-based check dropped it because the buffer
+ // rarely returned to exactly zero under load).
let buffered = ws.bufferedAmount;
let drop = null;
if(buffered > this.config['WS_BACKPRESSURE_MAX_BYTES']){
diff --git a/src/server/poller.js b/src/server/poller.js
index e26bf452..a50aad77 100644
--- a/src/server/poller.js
+++ b/src/server/poller.js
@@ -43,10 +43,10 @@ const envConfig = require('../config');
const logger = getLogger();
// How many recently broadcast block hashes to retain in memory for the
-// net-forward reorg walk-back (item 4830). Comfortably above the source
-// indexer's MAX_ROLLBACK_DEPTH (100) so a deep same-interval reorg can be
-// walked back one height per poll against the pre-reorg hash we recorded,
-// rather than against a fresh (post-reorg) source read that always matches.
+// net-forward reorg walk-back. Comfortably above the source indexer's
+// MAX_ROLLBACK_DEPTH (100), so a deep same-interval reorg can be walked back one
+// height per poll against the pre-reorg hash we recorded, rather than against a
+// fresh (post-reorg) source read that always matches.
const RECENT_HASH_CAP = 256;
// A per-table read in buildBlockPayload may legitimately fail because the source
@@ -93,16 +93,16 @@ class ServerPoller {
this.activationDelay = (delay === undefined) ? null : delay;
this.lastPolledBlock = null;
- // Hash of lastPolledBlock's content on the source, so a net-forward reorg
+ // Hash of lastPolledBlock's content on the source. A net-forward reorg
// (rollback + readvance within one poll interval, which keeps the height
- // monotonic) is detectable by a changed hash, not just a lower height (4623).
+ // monotonic) is detectable by a changed hash, not just a lower height.
this.lastPolledBlockHash = null;
// Bounded map of recently broadcast block hashes (block_index -> content
// hash WE broadcast for that height). On a net-forward reorg the walk-back
// seeds lastPolledBlockHash from the PRE-reorg hash recorded here, so a
- // reorg deeper than one block keeps walking back over subsequent polls
- // (item 4830). Works for both dbTypes (the decoder has no sync_meta to read
- // a recorded hash from). Capped to the last RECENT_HASH_CAP heights.
+ // reorg deeper than one block keeps walking back over subsequent polls. This
+ // works for both dbTypes (the decoder has no sync_meta to read a recorded
+ // hash from). Capped to the last RECENT_HASH_CAP heights.
this.recentBroadcastHashes = new Map();
this.running = false;
@@ -268,8 +268,8 @@ class ServerPoller {
// leaves currentBlock >= lastPolledBlock, so the height-only check below never
// fires, yet the block we already broadcast was orphaned and re-mined. Detect
// it by re-reading the source hash at lastPolledBlock; a change means the chain
- // forked at or below it. Roll back one block and re-read the prior hash so a
- // deeper reorg is walked back over subsequent polls (item 4623).
+ // forked at or below it. Roll back one block and re-read the prior hash so
+ // a deeper reorg is walked back over subsequent polls.
if(this.lastPolledBlockHash !== null){
let srcHash = await this.sourceBlockHash(this.lastPolledBlock);
if(srcHash !== null && srcHash !== this.lastPolledBlockHash){
@@ -417,11 +417,11 @@ class ServerPoller {
// Broadcast to subscribers (infraTables enables filtering for infra-only subscribers)
this.broadcaster.broadcast(this.chain, this.network, payload, this.infraTables);
- // Track the hash we just broadcast so the next poll can detect a
- // net-forward reorg that rewrites this block (item 4623).
+ // Track the hash we just broadcast so the next poll can detect
+ // a net-forward reorg that rewrites this block.
this.lastPolledBlockHash = (this.dbType === 'decoder') ? payload.block_hash : payload.ledger_hash;
- // Record it for the net-forward walk-back so a deeper reorg can be
- // detected against this pre-reorg hash on a later poll (item 4830).
+ // Record it for the net-forward walk-back so a deeper reorg can
+ // be detected against this pre-reorg hash on a later poll.
this.recentBroadcastHashes.set(nextBlock, this.lastPolledBlockHash);
if(nextBlock > RECENT_HASH_CAP)
this.recentBroadcastHashes.delete(nextBlock - RECENT_HASH_CAP - 1);
diff --git a/src/server/snapshot_builder.js b/src/server/snapshot_builder.js
index 9ad73d1c..350b60f0 100644
--- a/src/server/snapshot_builder.js
+++ b/src/server/snapshot_builder.js
@@ -92,15 +92,10 @@ const bigIntReplacer = (k, v) => typeof v === 'bigint' ? v.toString() : v;
const OPERATOR_LOCAL_TABLES = new Set([
...tableLifecycle.tablesWhere(t =>
['local', 'hub-mirror', 'follower-derived'].includes(t.replication)),
- // mempool_transactions: node-local, non-deterministic observation state (its own
- // schema comment forbids sharing raw values across nodes). It is a DECODER-DB
- // table with no registry entry (the registry covers the indexer DB). Every other
- // channel already excludes it: the per-block stream (replicatedTables.js), the
- // incremental snapshot (decoderSkip in streamIncrementalSnapshot), and the
- // /status completeness count (getReplicatedTables). Listing it here closes the
- // one remaining leak: the FULL snapshot used to ship the source's
- // bootstrap-instant mempool, freezing it forever on full-bootstrap decoder
- // replicas while incremental-bootstrap replicas held zero rows for the same table.
+ // Keep mempool_transactions out of the FULL snapshot too: it is node-local decoder
+ // state with no registry entry, and every other channel (block stream, incremental
+ // snapshot, /status count) already skips it, so shipping it would freeze the
+ // source's bootstrap-instant mempool on full-bootstrap replicas.
'mempool_transactions',
// sync_halt, sync_state: replica-local durable CONTROL tables the source never
// ships (created by db.verifySyncTables for both dbTypes; no registry entry).
@@ -226,6 +221,14 @@ const PRIORITY_TABLES = [
// sync_meta carries a FK on pubkeys.
const TRAILING_TABLES = ['balances', 'sync_meta', 'pubkeys'];
+// Indexer full-dump tables outside the lookup topology that are still append-only,
+// mapped to the column that pages them in-band. Neither is synced out of band, so
+// both stream under skipLookups too. pubkeys has no surrogate id and pages by its
+// address_id PRIMARY KEY: sound only inside one read view, since across requests
+// that cursor skips late rows (see replicatedTables.lookupCursorColumn), so it
+// must never join the out-of-band rows route.
+const INDEXER_INBAND_PAGED = Object.freeze({ events: 'id', pubkeys: 'address_id' });
+
// Order a set of snapshot table names into the builder's dependency order:
// priority tables first (in declared order), everything else alphabetically,
// trailing tables last. Exported (alongside OPERATOR_LOCAL_TABLES) so
@@ -249,6 +252,42 @@ function orderSnapshotTables(allTables){
return ordered;
}
+// Classify the decoder tables for an incremental snapshot. The three streamed
+// buckets derive from TOPOLOGY.decoder so they cannot drift from the per-block
+// stream; only the skip set is declared, and a unit test proves the four exhaustive.
+//
+// Decoder full-dump tables: index_* and pubkeys are small + append-only;
+// the client uses INSERT IGNORE so re-sending existing rows is a no-op.
+// `events` is full-dumped too: it carries no block_index/tx_index cursor
+// to scope incrementally, so the only way an incrementally-caught-up
+// follower converges its events table is a complete re-dump. It is safe
+// to re-send because events has an AUTO_INCREMENT `id` PK and the client
+// applies all incremental rows with INSERT IGNORE (existing ids are no-ops).
+//
+// dispensers is skipped here for the same reason it is not per-block
+// streamed: the decoder soft-expires dispensers (UPDATE expired_block_index)
+// and defers the hard-purge to purgeExpiredDispensers. An insert-only
+// incremental delta (the tx_index->block_index join) would re-introduce the
+// count divergence on any follower that catches up incrementally, and a plain
+// re-dump would collide on the (tx_index, address_id) PK (dispensers is not in
+// ClientApplier.ignoreTables, so it is not INSERT IGNORE). dispensers seeds
+// from the full snapshot and is then held in parity SOLELY by the apply-side
+// reconcile: ClientApplier.applyDispensersReplace via
+// ClientSync.reconcileDispensers, gated by DISPENSERS_RECONCILE_EVERY /
+// DISPENSERS_RECONCILE_MAX_INTERVAL_MS. Its decoder /status completeness count
+// (replicatedTables `special`) is a post-replace equality sanity check, not a
+// backstop: a soft-expire UPDATE leaves counts equal and a hard-purge DELETE
+// leaves the replica ahead, which verifyTableCounts does not report.
+function decoderIncrementalSets(){
+ let topology = replicatedTables.getTopology('decoder');
+ return {
+ blockScoped: new Set(topology.blockScoped),
+ txScoped: new Set(topology.txScoped),
+ fullDump: new Set(topology.index),
+ skip: new Set(['mempool_transactions', 'dispensers'])
+ };
+}
+
class SnapshotBuilder {
constructor(util) {
@@ -540,32 +579,12 @@ class SnapshotBuilder {
let tableOrder = await this.getOrderedTables(db, conn);
let first = true;
- // Scoping rules per dbType.
- // Decoder full-dump tables: index_* and pubkeys are small + append-only;
- // the client uses INSERT IGNORE so re-sending existing rows is a no-op.
- // `events` is full-dumped too: it carries no block_index/tx_index cursor
- // to scope incrementally, so the only way an incrementally-caught-up
- // follower converges its events table is a complete re-dump. It is safe
- // to re-send because events has an AUTO_INCREMENT `id` PK and the client
- // applies all incremental rows with INSERT IGNORE (existing ids are no-ops).
- let decoderBlockScoped = new Set(['blocks', 'transactions']);
- let decoderTxScoped = new Set(['transaction_outputs']);
- let decoderFullDump = new Set(['index_addresses', 'index_transactions', 'pubkeys', 'events']);
- // dispensers is skipped here for the same reason it is not per-block
- // streamed: the decoder soft-expires dispensers (UPDATE expired_block_index)
- // and defers the hard-purge to purgeExpiredDispensers. An insert-only
- // incremental delta (the tx_index->block_index join) would re-introduce the
- // count divergence on any follower that catches up incrementally, and a plain
- // re-dump would collide on the (tx_index, address_id) PK (dispensers is not in
- // ClientApplier.ignoreTables, so it is not INSERT IGNORE). dispensers seeds
- // from the full snapshot and is then held in parity SOLELY by the apply-side
- // reconcile: ClientApplier.applyDispensersReplace via
- // ClientSync.reconcileDispensers, gated by DISPENSERS_RECONCILE_EVERY /
- // DISPENSERS_RECONCILE_MAX_INTERVAL_MS. Its decoder /status completeness count
- // (replicatedTables `special`) is a post-replace equality sanity check, not a
- // backstop: a soft-expire UPDATE leaves counts equal and a hard-purge DELETE
- // leaves the replica ahead, which verifyTableCounts does not report.
- let decoderSkip = new Set(['mempool_transactions', 'dispensers']);
+ // Scoping rules per dbType. Decoder buckets: see decoderIncrementalSets.
+ let decoderSets = decoderIncrementalSets();
+ let decoderBlockScoped = decoderSets.blockScoped;
+ let decoderTxScoped = decoderSets.txScoped;
+ let decoderFullDump = decoderSets.fullDump;
+ let decoderSkip = decoderSets.skip;
// Indexer block-scoped set. These tables carry a block_index but no
// action_index, so the action_index branch below cannot reach them.
@@ -685,17 +704,16 @@ class SnapshotBuilder {
continue;
}
- // The indexer `events` log is the one indexerFullDump member that is
- // append-only on an AUTO_INCREMENT id yet absent from lookupSet: its
+ // The indexer `events` log and `pubkeys` cache are the indexerFullDump
+ // members that are append-only yet absent from lookupSet: their
// replication class is 'snapshot', not 'stream:index', so the branch
- // above cannot reach it and it fell to the bundled SELECT * below,
- // materializing the whole audit log per catch-up. Page it
- // by the same id cursor, which emits a byte-identical "events":[...]
- // key, so no client, protocol, or schema change is implied. Unlike the
- // lookupSet tables it is NOT synced out of band, so it must still be
- // streamed under skipLookups rather than skipped.
- if(dbType === 'indexer' && table === 'events' && indexerFullDump.has(table)){
- first = await this.streamLookupPaged(writer, db, table, conn, first);
+ // above cannot reach them and the bundled SELECT * below would
+ // materialize the whole table per catch-up. Page each by its
+ // INDEXER_INBAND_PAGED cursor, which emits a byte-identical key, so no
+ // client, protocol, or schema change is implied. Unlike the lookupSet
+ // tables they are NOT synced out of band, so they stream under skipLookups.
+ if(dbType === 'indexer' && Object.hasOwn(INDEXER_INBAND_PAGED, table) && indexerFullDump.has(table)){
+ first = await this.streamLookupPaged(writer, db, table, conn, first, INDEXER_INBAND_PAGED[table]);
continue;
}
@@ -929,8 +947,9 @@ class SnapshotBuilder {
// array (or gzip buffer) all at once. `first` tracks whether any table key has
// been written yet (for the inter-table comma); returns the updated value.
// Uses the shared REPEATABLE READ conn, so paging is consistent across batches.
- async streamLookupPaged(writer, db, table, conn, first){
- let col = replicatedTables.lookupCursorColumn(table);
+ // `cursorCol` overrides the lookup cursor for a table paged only in-band.
+ async streamLookupPaged(writer, db, table, conn, first, cursorCol){
+ let col = cursorCol || replicatedTables.lookupCursorColumn(table);
let after = 0;
let wrote = false;
let firstRow = true;
@@ -1024,7 +1043,7 @@ class SnapshotBuilder {
}
}
- // Stream one keyset-ordered page of the decoder `dispensers` table. dispensers
+ // Stream the whole decoder `dispensers` table, keyset-ordered. dispensers
// is excluded from both the incremental block stream and the id-cursor lookup
// paging (streamTableRowsById): it has no monotonic surrogate id (PK is
// (tx_index, address_id)) and the decoder soft-expires then hard-purges rows, so
@@ -1049,7 +1068,7 @@ class SnapshotBuilder {
// cursor params stay honoured (filtered within the same single query) and
// has_more is always false, so an old paging client simply completes its walk
// in one round trip.
- async streamDispensers(db, afterTx, afterAddr, limit, res){
+ async streamDispensers(db, afterTx, afterAddr, res){
let dbType = (db && db.dbType) || 'indexer';
if(dbType !== 'decoder'){
return res.status(400).json({ error: 'dispensers reconcile is decoder-only' });
@@ -1107,5 +1126,6 @@ SnapshotBuilder.SnapshotStreamWriter = SnapshotStreamWriter;
SnapshotBuilder.OPERATOR_LOCAL_TABLES = OPERATOR_LOCAL_TABLES;
SnapshotBuilder.SOURCE_UNSTREAMED_TABLES = SOURCE_UNSTREAMED_TABLES;
SnapshotBuilder.orderSnapshotTables = orderSnapshotTables;
+SnapshotBuilder.decoderIncrementalSets = decoderIncrementalSets;
module.exports = SnapshotBuilder;
diff --git a/src/server/updated_rows.js b/src/server/updated_rows.js
index dbbab58e..22da0d70 100644
--- a/src/server/updated_rows.js
+++ b/src/server/updated_rows.js
@@ -69,6 +69,14 @@
* cursor, so the action-scoped stream misses every later supply bump. Found via
* the ticks touched by a credit / debit / escrow row in this window, since those
* ledger tables are action-scoped and pin the supply change to a block.
+ * - metadata refresh on a surviving tokens row (the indexer re-derives every
+ * derived token column from the `issues` history on each valid ISSUE, so an
+ * EDIT of an existing tick - ownership TRANSFER, description, the locks, the
+ * callback and list fields, the bridge opt-in - is an in-place UPDATE). Both
+ * action_index and last_action_index stay pinned at the first issuance, below
+ * the cursor, so the action-scoped stream misses the edit. Found via the
+ * ticks carrying a valid `issues` row in this window, since `issues` is
+ * action-scoped and pins the edit to a block.
*
* tokens.escrow_action_index rides along here (the tokens class selects `t.*`), so the
* source's own authoritative gate value lands on the replica. The follower ALSO
@@ -86,18 +94,8 @@ const {
POLL_FINALIZE_TABLES, COOLDOWN_STATUS_TABLES, ATTEST_BATCH_HEAD_VERSION,
ATTEST_BATCH_CONTINUATION_VERSION, ATTEST_BATCH_COMPLETION_STAMP, BET_STATUS_SPECS
} = require('./updated_rows/table_specs.js');
-
-// table -> Map(action_index -> row). The Map dedups rows reached by more than
-// one class (e.g. a stake both deactivated and slashed in the same window) by
-// their UNIQUE action_index, so each table emits each surviving row once.
-function add(acc, table, rows){
- if(!rows || rows.length === 0) return;
- let m = acc[table] || (acc[table] = new Map());
- for(let r of rows){
- if(r && r.action_index !== undefined && r.action_index !== null)
- m.set(String(r.action_index), r);
- }
-}
+const { add } = require('./updated_rows/accumulator.js');
+const { collectTokenSupplyRows, collectTokenEditRows } = require('./updated_rows/token_rows.js');
// Query classes skip missing tables or columns on older source schemas. Other
// numeric database errors propagate to the caller.
@@ -108,11 +106,12 @@ async function collectDeactivationAndSlashRows(db, from, to, activationDelay, co
if(activationDelay != null){
for(let table of DEACTIVATION_TABLES){
try {
- let rows = await db.doQuery(
- "SELECT * FROM `" + table + "` WHERE deactivation_block IS NOT NULL AND deactivation_block BETWEEN ? AND ?",
- [from + activationDelay, to + activationDelay], conn);
+ let rows = await db.findDeactivationStampedRows(table, from + activationDelay, to + activationDelay, conn);
add(acc, table, rows);
- } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; }
+ } catch(e){
+ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e;
+ // Table/column may not exist on older source schemas; skip.
+ }
}
}
@@ -121,13 +120,12 @@ async function collectDeactivationAndSlashRows(db, from, to, activationDelay, co
// (avoids DISTINCT over wide/blob columns).
for(let spec of SLASH_SPECS){
try {
- let rows = await db.doQuery(
- "SELECT t.* FROM `" + spec.table + "` t " +
- "JOIN `" + spec.debits + "` d ON d.stake_action_index = t.action_index " +
- "WHERE d.target_table = ? AND d.block_index BETWEEN ? AND ?",
- [spec.target, from, to], conn);
+ let rows = await db.findSlashDebitedRows(spec, from, to, conn);
add(acc, spec.table, rows);
- } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; }
+ } catch(e){
+ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e;
+ // Table may not exist on older source schemas; skip.
+ }
}
}
@@ -139,13 +137,12 @@ async function collectRotationAndRequestRows(db, from, to, conn, acc){
// staker set than the source (and slashes a key the source no longer carries).
for(let rotTbl of ROTATION_TABLES){
try {
- let rows = await db.doQuery(
- "SELECT t.* FROM `" + rotTbl + "` t " +
- "JOIN `contract_delegation_rotations` r ON r.stake_action_index = t.action_index " +
- "WHERE r.target_table = ? AND r.block_index BETWEEN ? AND ?",
- [rotTbl, from, to], conn);
+ let rows = await db.findRotatedStakeRows(rotTbl, from, to, conn);
add(acc, rotTbl, rows);
- } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; }
+ } catch(e){
+ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e;
+ // Table may not exist on older source schemas; skip.
+ }
}
// 3. request_status flips on surviving v0 attest/xcall request rows. Keyed on
@@ -153,11 +150,12 @@ async function collectRotationAndRequestRows(db, from, to, conn, acc){
// and the deadline-expiry flip paths, mirroring ClientRollback's reset key.
for(let table of REQUEST_STATUS_TABLES){
try {
- let rows = await db.doQuery(
- "SELECT * FROM `" + table + "` WHERE version = 0 AND resolved_block BETWEEN ? AND ?",
- [from, to], conn);
+ let rows = await db.findResolvedRequestRows(table, from, to, conn);
add(acc, table, rows);
- } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; }
+ } catch(e){
+ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e;
+ // Table/column may not exist on older source schemas; skip.
+ }
}
}
@@ -172,12 +170,12 @@ async function collectPollAndCooldownRows(db, from, to, conn, acc){
// predicate: polls has one row shape (the v0 create), unlike attests/xcalls.
for(let table of POLL_FINALIZE_TABLES){
try {
- let rows = await db.doQuery(
- "SELECT * FROM `" + table + "` WHERE resolved_block BETWEEN ? AND ? " +
- "OR (callback_due_block BETWEEN ? AND ? AND callback_execute_action_index IS NOT NULL)",
- [from, to, from, to], conn);
+ let rows = await db.findFinalizedPollRows(table, from, to, conn);
add(acc, table, rows);
- } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; }
+ } catch(e){
+ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e;
+ // Table/column may not exist on older source schemas; skip.
+ }
}
// 4. cooldown-maturity status_id flip on surviving unstakes / contract_unstakes.
@@ -190,11 +188,12 @@ async function collectPollAndCooldownRows(db, from, to, conn, acc){
// the UNIQUE action_index against any SLASH row for the same unstake.
for(let table of COOLDOWN_STATUS_TABLES){
try {
- let rows = await db.doQuery(
- "SELECT * FROM `" + table + "` WHERE cooldown_end_block BETWEEN ? AND ?",
- [from, to], conn);
+ let rows = await db.findMaturedCooldownRows(table, from, to, conn);
add(acc, table, rows);
- } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; }
+ } catch(e){
+ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e;
+ // Table/column may not exist on older source schemas; skip.
+ }
}
}
@@ -210,10 +209,12 @@ async function collectBetStatusRows(db, from, to, conn, acc){
let where = spec.stamps.map(col => "`" + col + "` BETWEEN ? AND ?").join(' OR ');
let args = [];
for(let i = 0; i < spec.stamps.length; i++){ args.push(from); args.push(to); }
- let rows = await db.doQuery(
- "SELECT * FROM `" + spec.table + "` WHERE " + where, args, conn);
+ let rows = await db.findBetStampedRows(spec, where, args, conn);
add(acc, spec.table, rows);
- } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; }
+ } catch(e){
+ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e;
+ // Table/columns may not exist on older source schemas; skip.
+ }
}
}
@@ -237,15 +238,12 @@ async function collectInvalidArchiveRows(db, from, to, conn, acc){
// this fix must be live BEFORE the state-hash flag day or the follower halts on
// a parent row it was never sent.
try {
- let anchorRows = await db.doQuery(
- "SELECT DISTINCT p.* FROM anchor_actions p " +
- "JOIN anchor_actions c ON c.version = 2 AND c.match_batch_seq = p.match_batch_seq " +
- "JOIN index_statuses ps ON ps.id = p.status_id AND ps.status = 'invalid_archive' " +
- "JOIN index_statuses cs ON cs.id = c.status_id AND cs.status = 'valid' " +
- "WHERE p.version " + ARCHIVE_HEAD_VERSIONS_SQL + " AND " + ARCHIVE_CHUNK_HEIGHT_COL + " BETWEEN ? AND ?",
- [from, to], conn);
+ let anchorRows = await db.findInvalidArchiveHeadRows(from, to, conn);
add(acc, 'anchor_actions', anchorRows);
- } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; }
+ } catch(e){
+ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e;
+ // Table/columns may not exist on older source schemas; skip.
+ }
}
async function collectAttestBatchHeadRows(db, from, to, conn, acc){
@@ -290,70 +288,13 @@ async function collectAttestBatchHeadRows(db, from, to, conn, acc){
// carry must be live before any future state-hash twin of this class arms, or a
// follower halts on a head row it was never sent.
try {
- let attestHeadRows = await db.doQuery(
- "SELECT ah.* FROM attests ah " +
- "JOIN index_statuses ahs ON ahs.id = ah.status_id AND ahs.status LIKE ? " +
- "JOIN actions aha ON aha.action_index = ah.action_index " +
- "JOIN attests ac ON ac.request_id = ah.request_id " +
- "AND ac.version = " + ATTEST_BATCH_CONTINUATION_VERSION + " AND ac.batch_chunk_index IS NOT NULL " +
- "JOIN index_statuses acs ON acs.id = ac.status_id AND acs.status = 'valid' " +
- "JOIN actions aca ON aca.action_index = ac.action_index AND aca.source_id = aha.source_id " +
- "WHERE ah.version = " + ATTEST_BATCH_HEAD_VERSION + " AND ah.batch_chunk_index = 0 " +
- "AND ac.block_index BETWEEN ? AND ?",
- ['%' + ATTEST_BATCH_COMPLETION_STAMP, from, to], conn);
+ let attestHeadRows = await db.findFailedAttestBatchHeads(
+ ATTEST_BATCH_HEAD_VERSION, ATTEST_BATCH_CONTINUATION_VERSION, ATTEST_BATCH_COMPLETION_STAMP, from, to, conn);
add(acc, 'attests', attestHeadRows);
- } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; }
-}
-
-async function collectTokenSupplyRows(db, from, to, conn, acc){
- // 6. tokens.supply refresh on surviving token rows. The indexer materialises
- // tokens.supply as an in-place UPDATE (db.createToken on DEPLOY/ISSUE/MINT and
- // db.updateTokens after order/swap/dispense settlement and STAKE rebalances). The
- // row's action_index stays at the DEPLOY action, and last_action_index is also
- // written back to that same DEPLOY index (createToken sets both from the first
- // valid issuance), so BOTH columns sit below the catch-up cursor: the
- // action-scoped stream keyed on action_index never carries the later supply bump.
- // Followers therefore served a stale supply (invisible to /status counts and not
- // covered by any hash). Supply changes exactly when a credit / debit / escrow row
- // is written for the tick, and those ledger tables ARE action-scoped (they ride the
- // per-block / catch-up stream). So the set of ticks whose supply moved in this
- // window is exactly the set of tick_ids touched by a credit / debit / escrow row
- // whose action falls in [from, to]. We carry the CURRENT full tokens row for those
- // ticks (SELECT t.* -> the source `id`, which followers replicate verbatim, so the
- // follower's INSERT ... ON DUPLICATE KEY UPDATE lands on the matching PRIMARY KEY
- // row and overwrites supply to the source's current value). Idempotent: re-sending
- // an already-current row is a no-op. Reorg-safe: on rollback the source
- // re-materialises supply (rollback.js -> updateTokens) and the next forward window's
- // ledger changes re-emit the refreshed row; in-order block apply means a later
- // window's row never lands before an earlier one. tokens.supply stays out of the
- // consensus block hashes, but since 2026-07-07 this class HAS a state_hash twin:
- // buildStateHashData's token_supply class hashes (tick, supply) for the same
- // ledger-touched tick set (flag-day gated per chain via
- // TOKEN_SUPPLY_STATE_HASH_ACTIVATION), so once armed, a follower that drops this
- // upsert halts at the block instead of serving a stale supply.
- try {
- // Join each ledger table to `actions` independently and UNION the tick_ids,
- // rather than UNION ALL-ing the three full tables into a derived table and
- // joining once. The derived-table form forces MariaDB to materialise every
- // credits/debits/escrows row before the block-range predicate can apply (it
- // cannot push `a.block_index BETWEEN ? AND ?` down into the UNION ALL), an
- // O(total ledger size) scan on every block/catch-up window. Per-branch joins
- // let the optimiser drive from `actions` (block_index range) into each table
- // via its action_index index. UNION (not UNION ALL) preserves the original
- // SELECT DISTINCT semantics, so the emitted tick set is byte-identical.
- let tokenRows = await db.doQuery(
- "SELECT t.* FROM `tokens` t WHERE t.tick_id IN (" +
- "SELECT c.tick_id FROM credits c JOIN actions a ON a.action_index = c.action_index " +
- "WHERE a.block_index BETWEEN ? AND ? AND c.tick_id IS NOT NULL " +
- "UNION " +
- "SELECT d.tick_id FROM debits d JOIN actions a ON a.action_index = d.action_index " +
- "WHERE a.block_index BETWEEN ? AND ? AND d.tick_id IS NOT NULL " +
- "UNION " +
- "SELECT e.tick_id FROM escrows e JOIN actions a ON a.action_index = e.action_index " +
- "WHERE a.block_index BETWEEN ? AND ? AND e.tick_id IS NOT NULL)",
- [from, to, from, to, from, to], conn);
- add(acc, 'tokens', tokenRows);
- } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; }
+ } catch(e){
+ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e;
+ // Table/columns may not exist on older source schemas (pre-batch-rail builds); skip.
+ }
}
// Collect the in-place-mutated surviving rows for the block window [fromBlock, toBlock].
@@ -379,6 +320,7 @@ async function collectUpdatedRows(db, fromBlock, toBlock, activationDelay, conn)
await collectInvalidArchiveRows(db, from, to, conn, acc);
await collectAttestBatchHeadRows(db, from, to, conn, acc);
await collectTokenSupplyRows(db, from, to, conn, acc);
+ await collectTokenEditRows(db, from, to, conn, acc);
let out = {};
for(let table in acc){
let arr = Array.from(acc[table].values());
diff --git a/src/server/updated_rows/accumulator.js b/src/server/updated_rows/accumulator.js
new file mode 100644
index 00000000..288e926c
--- /dev/null
+++ b/src/server/updated_rows/accumulator.js
@@ -0,0 +1,31 @@
+/*********************************************************************
+ *
+ * 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 Sync - Updated rows: the row accumulator
+ *
+ ********************************************************************/
+
+// table -> Map(action_index -> row). The Map dedups rows reached by more than
+// one class (e.g. a stake both deactivated and slashed in the same window) by
+// their UNIQUE action_index, so each table emits each surviving row once.
+function add(acc, table, rows){
+ if(!rows || rows.length === 0) return;
+ let m = acc[table] || (acc[table] = new Map());
+ for(let r of rows){
+ if(r && r.action_index !== undefined && r.action_index !== null)
+ m.set(String(r.action_index), r);
+ }
+}
+
+module.exports = { add };
diff --git a/src/server/updated_rows/token_rows.js b/src/server/updated_rows/token_rows.js
new file mode 100644
index 00000000..86b77495
--- /dev/null
+++ b/src/server/updated_rows/token_rows.js
@@ -0,0 +1,118 @@
+/*********************************************************************
+ *
+ * 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 Sync - Updated rows: the two tokens-table classes
+ *
+ ********************************************************************/
+
+const { add } = require('./accumulator.js');
+
+// The two classes that refresh a surviving `tokens` row, split out of the entry because
+// they share one shape: the row's action_index is pinned at the tick's first issuance, so
+// nothing the tick does later moves it back into the action-scoped stream's window. They
+// differ only in what pins the mutation to a block - a ledger row for supply, an `issues`
+// row for the metadata. Each adds to `acc` in place, like every other class here.
+
+async function collectTokenSupplyRows(db, from, to, conn, acc){
+ // 6. tokens.supply refresh on surviving token rows. The indexer materialises
+ // tokens.supply as an in-place UPDATE (db.createToken on DEPLOY/ISSUE/MINT and
+ // db.updateTokens after order/swap/dispense settlement and STAKE rebalances). The
+ // row's action_index stays at the DEPLOY action, and last_action_index is also
+ // written back to that same DEPLOY index (createToken sets both from the first
+ // valid issuance), so BOTH columns sit below the catch-up cursor: the
+ // action-scoped stream keyed on action_index never carries the later supply bump.
+ // Followers therefore served a stale supply (invisible to /status counts and not
+ // covered by any hash). Supply changes exactly when a credit / debit / escrow row
+ // is written for the tick, and those ledger tables ARE action-scoped (they ride the
+ // per-block / catch-up stream). So the set of ticks whose supply moved in this
+ // window is exactly the set of tick_ids touched by a credit / debit / escrow row
+ // whose action falls in [from, to]. We carry the CURRENT full tokens row for those
+ // ticks (SELECT t.* -> the source `id`, which followers replicate verbatim, so the
+ // follower's INSERT ... ON DUPLICATE KEY UPDATE lands on the matching PRIMARY KEY
+ // row and overwrites supply to the source's current value). Idempotent: re-sending
+ // an already-current row is a no-op. Reorg-safe: on rollback the source
+ // re-materialises supply (rollback.js -> updateTokens) and the next forward window's
+ // ledger changes re-emit the refreshed row; in-order block apply means a later
+ // window's row never lands before an earlier one. tokens.supply stays out of the
+ // consensus block hashes, but since 2026-07-07 this class HAS a state_hash twin:
+ // buildStateHashData's token_supply class hashes (tick, supply) for the same
+ // ledger-touched tick set (flag-day gated per chain via
+ // TOKEN_SUPPLY_STATE_HASH_ACTIVATION), so once armed, a follower that drops this
+ // upsert halts at the block instead of serving a stale supply.
+ try {
+ // Join each ledger table to `actions` independently and UNION the tick_ids,
+ // rather than UNION ALL-ing the three full tables into a derived table and
+ // joining once. The derived-table form forces MariaDB to materialise every
+ // credits/debits/escrows row before the block-range predicate can apply (it
+ // cannot push `a.block_index BETWEEN ? AND ?` down into the UNION ALL), an
+ // O(total ledger size) scan on every block/catch-up window. Per-branch joins
+ // let the optimiser drive from `actions` (block_index range) into each table
+ // via its action_index index. UNION (not UNION ALL) preserves the original
+ // SELECT DISTINCT semantics, so the emitted tick set is byte-identical.
+ let tokenRows = await db.findLedgerTouchedTokens(from, to, conn);
+ add(acc, 'tokens', tokenRows);
+ } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; }
+}
+
+async function collectTokenEditRows(db, from, to, conn, acc){
+ // 7. tokens metadata refresh on surviving token rows, class 6's sibling one column
+ // family over. Every valid ISSUE re-derives the WHOLE tokens row from the tick's
+ // `issues` history (issue/settle.js -> createToken, then updateTokens ->
+ // getTokenInfo's replay), so an ISSUE that EDITS an existing tick is an in-place
+ // UPDATE of owner_id, description, the seven locks, the callback and list fields,
+ // the mint window and the bridge opt-in. action_index and last_action_index both
+ // stay pinned at the FIRST issuance, so - exactly as with supply - the
+ // action-scoped stream carries the new `issues` row but never the edited `tokens`
+ // row it produced.
+ //
+ // Class 6 hid this for every edit that also moves a balance (its ledger-touched
+ // tick set catches those), which is why it surfaced as an intermittent bug rather
+ // than a permanent one: a fee-free edit that writes NO credit / debit / escrow row
+ // in its own tick left the replica on the pre-edit row until some unrelated later
+ // action on the tick moved a balance and re-emitted it. An ownership TRANSFER
+ // (`ISSUE|0|||||||`) is exactly that shape, so explorer read APIs
+ // served the OLD owner indefinitely while consensus had the new one
+ // until this class ships (ownership-gated client UI reads this field).
+ //
+ // Keyed on the tick's valid `issues` rows in the window rather than on the ISSUE
+ // action alone: `issues` stores every ISSUE, valid or not, and only a valid one
+ // reaches createToken, so an invalid edit must not re-emit (harmless, but it would
+ // make the class claim a mutation that never happened). SELECT t.* and the
+ // add()-by-action_index dedup are class 6's, so a tick reached by both classes in
+ // one window emits once.
+ //
+ // FORWARD ONLY here. A reorg that orphans an edit-ISSUE leaves no valid `issues`
+ // row in any later window, so nothing re-emits the row while the source re-folds
+ // back (rollback/commit.js -> updateTokens). The reverse leg is replica-side:
+ // ClientRollback refolds every tick the orphaned range issued on from the surviving
+ // issues (src/db/token_refold.js), inside the rollback transaction.
+ //
+ // UN-GATED, like classes 5 and 5b: shipping a row is not a hash preimage, and no
+ // state_hash class covers these columns (the token_supply twin hashes (tick, supply)
+ // only). Both legs are instead watched by the advisory TOKEN_FOLD_PARITY_CHECK
+ // digest (BlockHasher.computeTokenFoldChecksum), which logs and counts a divergence
+ // but never halts. This carry must stay live before any future state-hash twin
+ // arms, or a follower would halt on a row it was never sent.
+ try {
+ let tokenRows = await db.doQuery(
+ "SELECT t.* FROM `tokens` t WHERE t.tick_id IN (" +
+ "SELECT i.tick_id FROM issues i " +
+ "JOIN actions a ON a.action_index = i.action_index " +
+ "JOIN index_statuses s ON s.id = i.status_id AND s.status = 'valid' " +
+ "WHERE a.block_index BETWEEN ? AND ? AND i.tick_id IS NOT NULL)",
+ [from, to], conn);
+ add(acc, 'tokens', tokenRows);
+ } catch(e){ if(e && typeof e.errno === 'number' && e.errno !== 1146 && e.errno !== 1054) throw e; }
+}
+module.exports = { collectTokenSupplyRows, collectTokenEditRows };
diff --git a/src/table_lifecycle.js b/src/table_lifecycle.js
index af5c299d..c3277b39 100644
--- a/src/table_lifecycle.js
+++ b/src/table_lifecycle.js
@@ -21,14 +21,14 @@
*
* 1. REPLICATION - how (and whether) xchain-sync delivers the table to
* followers. Generates the per-block stream topology
- * (xchain-sync/src/replicatedTables.js TOPOLOGY.indexer).
+ * (xchain-sync/src/schema/replicated_tables.js TOPOLOGY.indexer).
* 2. ROLLBACK - how a chain reorg unwinds the table, on the source
- * indexer (src/rollback.js) and on every replica
- * (xchain-sync/src/ClientRollback.js). Generates both
+ * indexer (src/rollback/index.js) and on every replica
+ * (xchain-sync/src/client/rollback.js). Generates both
* sets of generic delete lists.
* 3. HASH COVERAGE- which integrity hash (if any) would catch a divergence
* in the table. Declarative: guards in
- * test/unit/hash-coverage.test.js bind the declarations
+ * test/unit/hub/hash_coverage.test.js bind the declarations
* to the actual hashing code.
*
* A fourth artifact, the advisory TABLE_CONTENT_PARITY_CHECK coverage set,
@@ -37,13 +37,13 @@
*
* Adding a table: create src/sql/.sql, then add ONE entry to the
* registry parts under table_lifecycle/ declaring all three dimensions.
- * test/unit/rollback-coverage.test.js fails until the entry exists, and the
+ * test/unit/rollback/rollback_coverage.test.js fails until the entry exists, and the
* per-dimension guards fail until the entry matches reality. Classify by
* understanding the table, not by silencing the tests.
*
* BYTE-ALIGNED TWIN: copied verbatim, with its table_lifecycle/ parts, into
- * xchain-sync/src/tableLifecycle.js (sync has no dependency on this package
- * by design; same convention as stateHash.js / merkle.js). Edit here, then
+ * xchain-sync/src/table_lifecycle.js (sync has no dependency on this package
+ * by design; same convention as state_hash.js / merkle.js). Edit here, then
* `cp` to the twin; the sync rollback-coverage suite asserts byte-identity.
*
* Entry fields:
@@ -67,10 +67,10 @@
* a reader assuming the default raises errno 1054, which every
* forward channel swallows as an older source schema: silent
* non-delivery, never an error.
- * xchain-sync test/unit/streamScopeColumns.test.js binds this
+ * xchain-sync test/unit/stream_scope_columns.test.js binds this
* field to the owning DDL, so a scope column the schema does not
* have fails the build instead of shipping un-replicated.
- * rollback source-indexer reorg handling (src/rollback.js):
+ * rollback source-indexer reorg handling (src/rollback/index.js):
* 'action' generic DELETE by action_index (dataTables)
* 'block' generic DELETE by block_index (blockTables)
* 'index' block-scoped lookup delete (indexTables)
@@ -80,7 +80,7 @@
* 'lookup' append-only id-keyed dedup lookup; orphaned
* rows are inert (only ever referenced by id)
* null for owner 'sync' (not a source-indexer table)
- * replicaRollback replica-side reorg handling (ClientRollback.js):
+ * replicaRollback replica-side reorg handling (xchain-sync/src/client/rollback.js):
* 'mirror' same generic list as the source
* 'recomputed' | 'special' | 'exempt' | 'lookup' as above
* 'local' indexer-local; table never exists on replicas
@@ -88,13 +88,13 @@
* refreshed by the recompute pass (coverage is a union)
* hashed { classes: [...], note }. classes from:
* 'ledger' | 'actions' | 'contracts' the three consensus
- * block hashes (db.js getBlockHashes)
+ * block hashes (src/db/actions.js getBlockHashes)
* 'state_hash' replication-integrity 4th hash over in-place
- * mutations + backdated credits (stateHash.js)
+ * mutations + backdated credits (src/consensus/state_hash.js)
* 'state_commitment' light-client SMT roots
- * (stateCommitment.js: balances + BTC stakes)
+ * (src/state_commitment/: balances + BTC stakes)
* 'index_map' id->string delta class of state_hash
- * (armed per-chain; stateHash.js)
+ * (armed per-chain; src/consensus/state_hash.js)
* 'quorum' not block-hashed, but every row carries (or
* is derived under) federation quorum signatures
* classes may be empty; the note must then say why no hash is
@@ -152,13 +152,13 @@ const ORPHAN_SWEEPS = [
// per-table content checksum over a bounded block window: xchain-sync computes
// it in BlockHasher.computeTableContentChecksums, the source publishes it on
// /status (api.js) and a follower at the same height recomputes and compares in
-// ClientSync._verifyAgainstSource. Because both sides run the SAME method over
+// ClientSync.verifyTableContentParity. Because both sides run the SAME method over
// the SAME published bound, equal count + different checksum means content
// divergence, which is exactly the class the row counts cannot see.
//
// This block is the coverage contract. The guards bind it to the code:
-// xchain-sync test/unit/tableContentParity.test.js (every replicated table is
-// committed by something) and this repo's test/unit/hash-coverage.test.js (the
+// xchain-sync test/unit/table_content_parity.test.js (every replicated table is
+// committed by something) and this repo's test/unit/hub/hash_coverage.test.js (the
// carve-outs stay pinned to the operator ruling).
//
// There are exactly TWO exclusion classes, and a table outside both is covered:
@@ -169,7 +169,7 @@ const ORPHAN_SWEEPS = [
// purge deferred out of band. Neither has a block bound a source and a
// follower can agree on, so a checksum over them would false-alarm rather
// than detect. Both keep the convergence channel they already have (the
-// snapshot upsert; ClientSync._reconcileDispensers' periodic replace).
+// snapshot upsert; ClientSync.reconcileDispensers' periodic replace).
//
// 2. IN-PLACE MUTATED tables: exactly the tables declaring the 'state_hash'
// class above. A row of theirs written in block N is edited again in a
@@ -209,8 +209,8 @@ const CONTENT_PARITY_CARVE_OUTS = Object.freeze([
// preimage, so the check still covers everything the two sides must agree on.
// `validator_rewards.id` reaches the same place by a different route: the row
// normally streams with every column, carrying the source id, but the RB-ANCHOR
-// reorg restore (xchain-indexer/src/rollback.js and its mirror in
-// xchain-sync/src/ClientRollback.js) re-INSERTs a deleted loser naming only
+// reorg restore (xchain-indexer/src/rollback/index.js and its mirror in
+// xchain-sync/src/client/rollback.js) re-INSERTs a deleted loser naming only
// source_id/signing_pubkey_id/reward_type/round_reference/amount/block_index/
// derive_block_index, so each side mints its own AUTO_INCREMENT value off a
// counter the other never sees (the source burns values on every ignored
@@ -242,7 +242,7 @@ function tablesWhere(fn){
return TABLES.filter(fn).map(t => t.table);
}
-// Source-indexer generic rollback lists (xchain-indexer/src/rollback.js).
+// Source-indexer generic rollback lists (xchain-indexer/src/rollback/index.js).
function rollbackTables(){
return {
dataTables: tablesWhere(t => t.rollback === 'action'),
@@ -251,7 +251,7 @@ function rollbackTables(){
};
}
-// Replica generic rollback lists (xchain-sync/src/ClientRollback.js): the
+// Replica generic rollback lists (xchain-sync/src/client/rollback.js): the
// source lists minus indexer-local tables that never exist on a replica.
function replicaRollbackTables(){
return {
@@ -261,10 +261,10 @@ function replicaRollbackTables(){
};
}
-// Per-block stream topology for the indexer DB (xchain-sync/src/
-// replicatedTables.js TOPOLOGY.indexer). The decoder DB topology is NOT
+// Per-block stream topology for the indexer DB (xchain-sync/src/schema/
+// replicated_tables.js TOPOLOGY.indexer). The decoder DB topology is NOT
// generated from this registry: that schema is owned by xchain-decoder and
-// stays declared literally in replicatedTables.js.
+// stays declared literally in replicated_tables.js.
function streamTopology(){
let scoped = (scope) => tablesWhere(t => t.replication === 'stream:' + scope);
return {
diff --git a/src/table_lifecycle/action_tables.js b/src/table_lifecycle/action_tables.js
index 323076e0..b155a6b5 100644
--- a/src/table_lifecycle/action_tables.js
+++ b/src/table_lifecycle/action_tables.js
@@ -126,7 +126,7 @@ const TABLES = [
{ table: 'sweeps', owner: 'indexer', replication: 'stream:action', rollback: 'action', replicaRollback: 'mirror', hashed: DERIVED },
{ table: 'tokens', owner: 'indexer', replication: 'stream:action', rollback: 'action', replicaRollback: 'mirror', alsoRecomputed: true,
hashed: { classes: ['state_hash'],
- note: 'The in-place supply mutation on a surviving token row (carried forward by the updated_rows tokens-supply class) is covered by the state_hash token_supply class: (tick, supply) per ledger-touched tick, flag-day gated per chain (TOKEN_SUPPLY_STATE_HASH_ACTIVATION, armed 2026-07-07 at tip + margin). Supply is also recomputed and sanity-checked against credits/debits/escrows each block; new rows are otherwise action-derived. This closes a supply-forward gap that would otherwise exist.' } },
+ note: 'The in-place supply mutation on a surviving token row (carried forward by the updated_rows tokens-supply class) is covered by the state_hash token_supply class: (tick, supply) per ledger-touched tick, flag-day gated per chain (TOKEN_SUPPLY_STATE_HASH_ACTIVATION, armed 2026-07-07 at tip + margin). Supply is also recomputed and sanity-checked against credits/debits/escrows each block; new rows are otherwise action-derived. This closes a supply-forward gap that would otherwise exist. The row\'s OTHER derived columns (owner_id, description, the locks, the callback and list fields, the bridge opt-in) are re-derived from the `issues` history by every valid ISSUE, an in-place mutation with the same pinned action_index; the updated_rows tokens-EDIT class carries those forward, keyed on the tick\'s valid issues rows in the window. That class has NO state_hash twin, so a follower that misses one diverges silently rather than halting; closing that gap needs the tokens-EDIT class to carry its own state_hash coverage.' } },
{ table: 'stakes', owner: 'indexer', replication: 'stream:action', rollback: 'action', replicaRollback: 'mirror',
hashed: { classes: ['state_hash', 'state_commitment'],
note: 'state_hash covers the in-place deactivation_block stamps and capability SLASH amount cuts; the light-client state commitment covers active BTC stake weights.' } },
@@ -165,7 +165,7 @@ const TABLES = [
note: 'The in-place invalid_archive stamp on a surviving v1 parent is covered by the state_hash anchor_invalid class. New rows are otherwise action-derived; status_id is deliberately in no block-hash projection.' } },
{ table: 'attests', owner: 'indexer', replication: 'stream:action', rollback: 'action', replicaRollback: 'mirror',
hashed: { classes: ['state_hash'],
- note: 'The v0 request_status terminal flip (updateAttestationRequestStatus) is an in-place mutation on a surviving row; the state_hash request_status class covers it. Its reorg-side reset, resetOrphanedAttestRequests (src/db/rollback/in_place_flips.js), puts an orphaned flip back to pending on both the source and the replica. Four more in-place writers exist. setAttestationResponseCallbackIndex and setAttestationResponseBatchIndex stamp action_index links (callback_execute_action_index, batch_action_index) on a surviving v1 row; both are display-only and deliberately in no hash projection (state_hash reads attests at version 0 only). setAttestBatchStatus re-stamps status_id on a surviving v5 batch head when the completing v6 continuation fails reassembly or quorum, and that stamp is a KNOWN GAP: it is in no state_hash class and not carried by the updated_rows forward channel. Its reorg-side reset is restoreStampedAttestHeads (src/db/rollback/batch_heads.js), which puts the head back to valid on both the source and the replica (ClientRollback.js mirrors it); only the forward carry and the hash class remain open. Closing the remainder needs a flag-day-gated class in the anchor_invalid shape plus the forward carry.' } },
+ note: 'The v0 request_status terminal flip (updateAttestationRequestStatus) is an in-place mutation on a surviving row; the state_hash request_status class covers it. Its reorg-side reset, resetOrphanedAttestRequests (src/db/rollback/in_place_flips.js), puts an orphaned flip back to pending on both the source and the replica. Four more in-place writers exist. setAttestationResponseCallbackIndex and setAttestationResponseBatchIndex stamp action_index links (callback_execute_action_index, batch_action_index) on a surviving v1 row; both are display-only and deliberately in no hash projection (state_hash reads attests at version 0 only). setAttestBatchStatus re-stamps status_id on a surviving v5 batch head when the completing v6 continuation fails reassembly or quorum, and that stamp is a KNOWN GAP: it is in no state_hash class and not carried by the updated_rows forward channel. Its reorg-side reset is restoreStampedAttestHeads (src/db/rollback/batch_heads.js), which puts the head back to valid on both the source and the replica (xchain-sync/src/client/rollback.js mirrors it); only the forward carry and the hash class remain open. Closing the remainder needs a flag-day-gated class in the anchor_invalid shape plus the forward carry.' } },
{ table: 'prices', owner: 'indexer', replication: 'stream:action', rollback: 'action', replicaRollback: 'mirror', hashed: DERIVED },
{ table: 'pending_hub_pushes', owner: 'indexer', replication: 'local', rollback: 'action', replicaRollback: 'local',
hashed: { classes: [], note: 'Indexer-local outbound hub-push queue; never replicated (OPERATOR_LOCAL) and meaningless on a replica.' } },
diff --git a/src/table_lifecycle/block_and_special_tables.js b/src/table_lifecycle/block_and_special_tables.js
index 7e237aa6..644b6bbe 100644
--- a/src/table_lifecycle/block_and_special_tables.js
+++ b/src/table_lifecycle/block_and_special_tables.js
@@ -126,7 +126,7 @@ const TABLES = [
note: 'Hub-mirrored XCALL relay rows; the indexer only SELECTs them. Same local pre-delete + hub-retraction-backstop model as cross_chain_matches.' },
{ table: 'bridge_transfers', owner: 'indexer', replication: 'hub-mirror', rollback: 'exempt', replicaRollback: 'special',
hashed: { classes: ['quorum'], note: 'Hub-federation co-signed XBRIDGE transfer rows; the settle pass re-verifies the signature set against the cross_chain capability snapshot at snapshot_block before it credits anything.' },
- note: 'Hub-mirrored one-sided bridge state (src_chain/src_action_index name the lock or burn leg), not produced by local block processing; the indexer only SELECTs it. Hub retraction is the unwind: _applyRetraction deletes the mirrored row under the mandatory push_generation fence. UNLIKE cross_chain_matches/calls there is as yet NO local pre-delete leg inside the CROSS-CHAIN-MIRROR-REORG-DELETE markers in rollback.js / ClientRollback.js, so the hub-blip window those markers close is still open for this table; adding it is a byte-identical twin edit in both files and is tracked outside this registry. An APPLIED leg is unwound by its bridge_settlements row instead, which is rollback \'action\'. Exempt = not a generic-list delete.' },
+ note: 'Hub-mirrored one-sided bridge state (src_chain/src_action_index name the lock or burn leg), not produced by local block processing; the indexer only SELECTs it. Hub retraction is the unwind: _applyRetraction deletes the mirrored row under the mandatory push_generation fence. Like cross_chain_matches/calls, both sides also locally pre-delete the orphaned range inside the CROSS-CHAIN-MIRROR-REORG-DELETE markers (indexer src/db/rollback/sweeps.js, sync src/client/rollback.js) with a one-sided src_chain/src_action_index predicate, closing the hub-blip window before that retraction lands; one marker-guarded delete per side is the whole leg. An APPLIED leg is unwound by its bridge_settlements row instead, which is rollback \'action\'. Exempt = not a generic-list delete.' },
{ table: 'oracle_prices', owner: 'indexer', replication: 'hub-mirror', rollback: 'exempt', replicaRollback: 'special',
hashed: { classes: [], note: 'Hub-mirrored permissionless PRICE v1 rows; consensus effects (fee quotes) re-verify against them deterministically per block.' },
note: 'action_index here refers to the row\'s SOURCE chain, usually a different chain from the one reorging, so a blanket local-height delete would corrupt the mirror; both sides delete only rows tagged with the local chain, and source-chain reorgs converge mirror-side via the pushpricereorg rail.' },
diff --git a/test/chaos/fixtures/docker-compose.chaos.yml b/test/chaos/fixtures/docker-compose.chaos.yml
index c0b7df35..ec55dc5b 100644
--- a/test/chaos/fixtures/docker-compose.chaos.yml
+++ b/test/chaos/fixtures/docker-compose.chaos.yml
@@ -10,7 +10,7 @@ services:
MARIADB_USER: xchain-node
MARIADB_PASSWORD: xchain-fixture-throwaway
ports:
- - "33065:3306" # Direct access for admin seeding (bypasses toxiproxy)
+ - "${SOURCE_DIRECT_PORT:-33065}:3306" # Direct access for admin seeding (bypasses toxiproxy)
networks:
- chaos-net
tmpfs:
@@ -43,10 +43,10 @@ services:
toxiproxy:
image: ghcr.io/shopify/toxiproxy:2.9.0
ports:
- - "8474:8474" # Toxiproxy HTTP API
- - "33060:33060" # Source DB proxy endpoint
- - "33061:33061" # Replica DB proxy endpoint
- - "33062:33062" # WebSocket proxy endpoint (for CE-NET tests)
+ - "${TOXIPROXY_PORT:-8474}:8474" # Toxiproxy HTTP API
+ - "${SOURCE_PROXY_PORT:-33060}:33060" # Source DB proxy endpoint
+ - "${REPLICA_PROXY_PORT:-33061}:33061" # Replica DB proxy endpoint
+ - "${WS_PROXY_PORT:-33062}:33062" # WebSocket proxy endpoint (for CE-NET tests)
extra_hosts:
- "host-gateway:host-gateway"
networks:
diff --git a/test/chaos/helpers/chaos-setup.js b/test/chaos/helpers/chaos-setup.js
index 250ea1a7..e9604227 100644
--- a/test/chaos/helpers/chaos-setup.js
+++ b/test/chaos/helpers/chaos-setup.js
@@ -32,12 +32,13 @@ const testDb = require('../../e2e/helpers/testDb');
const fixtures = require('../../e2e/helpers/fixtures');
const ServerProcess = require('../../e2e/helpers/serverProcess');
const ClientProcess = require('../../e2e/helpers/clientProcess');
+const fixturePorts = require('../../../bin/fixture-ports.js');
// Connection constants: proxied ports from docker-compose.chaos.yml
const CHAOS_DB_HOST = process.env.CHAOS_DB_HOST || '127.0.0.1';
-const SOURCE_PROXY_PORT = parseInt(process.env.SOURCE_PROXY_PORT || '33060', 10);
-const REPLICA_PROXY_PORT = parseInt(process.env.REPLICA_PROXY_PORT || '33061', 10);
-const SOURCE_DIRECT_PORT = parseInt(process.env.SOURCE_DIRECT_PORT || '33065', 10);
+const SOURCE_PROXY_PORT = fixturePorts.port('SOURCE_PROXY_PORT');
+const REPLICA_PROXY_PORT = fixturePorts.port('REPLICA_PROXY_PORT');
+const SOURCE_DIRECT_PORT = fixturePorts.port('SOURCE_DIRECT_PORT');
const CHAOS_DB_USER = 'xchain-node';
const CHAOS_DB_PASS = 'xchain-fixture-throwaway';
diff --git a/test/chaos/helpers/toxiproxy-client.js b/test/chaos/helpers/toxiproxy-client.js
index c48065da..e8240e1e 100644
--- a/test/chaos/helpers/toxiproxy-client.js
+++ b/test/chaos/helpers/toxiproxy-client.js
@@ -25,9 +25,10 @@
'use strict';
const http = require('http');
+const fixturePorts = require('../../../bin/fixture-ports.js');
const TOXIPROXY_HOST = process.env.TOXIPROXY_HOST || '127.0.0.1';
-const TOXIPROXY_PORT = parseInt(process.env.TOXIPROXY_PORT || '8474', 10);
+const TOXIPROXY_PORT = fixturePorts.port('TOXIPROXY_PORT');
const SOURCE_PROXY = {
name: 'source_db_chaos',
diff --git a/test/chaos/network_partition.test.js b/test/chaos/network_partition.test.js
index 119bb767..997e1a4a 100644
--- a/test/chaos/network_partition.test.js
+++ b/test/chaos/network_partition.test.js
@@ -37,6 +37,7 @@
const { expect } = require('chai');
const testDb = require('../e2e/helpers/testDb');
+const fixturePorts = require('../../bin/fixture-ports.js');
const {
assertReplicaMatchesSource,
@@ -71,7 +72,7 @@ describe('Chaos: Network Partition', function () {
let server, client;
const SERVER_PORT = 30300;
- const WS_PROXY_PORT = 33062;
+ const WS_PROXY_PORT = fixturePorts.port('WS_PROXY_PORT');
before(async function () {
await waitForToxiproxy();
diff --git a/test/chaos/replica_db_resilience.test.js b/test/chaos/replica_db_resilience.test.js
index 7fd94a21..8537f474 100644
--- a/test/chaos/replica_db_resilience.test.js
+++ b/test/chaos/replica_db_resilience.test.js
@@ -154,6 +154,8 @@ describe('CE-DST-01: Complete Replica DB Unavailability', function () {
// Seed blocks while replica is down
await seedSourceBlocks(11, 20);
await server.poll();
+ // Keep the replica down while the broadcast lands (exposure window; each
+ // event against a dead replica costs a 10s acquire, and recovery gates).
await sleep(3000);
// Restore replica
@@ -229,6 +231,8 @@ describe('CE-DST-03: Connection Pool Exhaustion', function () {
// Seed blocks; server broadcasts, client receives but can't write
await seedSourceBlocks(11, 15);
await server.poll();
+ // Hold the client stuck on held writes, then prove the server still
+ // answers (negative window: a stuck write never settles to poll on).
await sleep(8000);
// Server must stay reachable even though the client is stuck
@@ -245,6 +249,8 @@ describe('CE-DST-03: Connection Pool Exhaustion', function () {
// Seed new blocks into source; server will broadcast them
await seedSourceBlocks(11, 15);
await server.poll();
+ // Keep writes held before lifting the toxic (exposure window, not a
+ // gate: the recovery wait below decides).
await sleep(5000);
await replicaFaults.reset();
@@ -273,6 +279,7 @@ describe('CE-DST-04: Intermittent Connection Drops', function () {
// Force multiple poll cycles to broadcast blocks
for (let i = 0; i < 15; i++) {
try { await server.poll(); } catch { /* expected */ }
+ // Pace the forced polls so resets land across many writes.
await sleep(300);
}
@@ -287,6 +294,8 @@ describe('CE-DST-04: Intermittent Connection Drops', function () {
it('all blocks eventually reach replica after toxic removal', async function () {
await replicaFaults.resetConnections(0.3);
+ // Expose writes to resets before lifting the toxic (exposure window;
+ // the recovery wait below decides).
await sleep(3000);
await replicaFaults.reset();
@@ -313,6 +322,8 @@ describe('CE-DST-05: Replica Down → Blocks Accumulate → Recovery', function
// Take replica DB down
await replicaFaults.dbDown();
+ // Hold the outage before blocks appear (exposure window; the client
+ // exposes no replica-down state to wait on).
await sleep(2000);
// Seed a batch of blocks into source while replica is down
@@ -321,14 +332,17 @@ describe('CE-DST-05: Replica Down → Blocks Accumulate → Recovery', function
// Server polls and broadcasts (client receives via WebSocket but can't write)
for (let i = 0; i < 10; i++) {
try { await server.poll(); } catch { /* expected */ }
+ // Pace the forced polls.
await sleep(300);
}
- // Wait for blocks to be broadcast and fail on client
+ // Let broadcasts fail on the client before restore (exposure window:
+ // settling all 20 at a 10s acquire each would outrun the test budget).
await sleep(5000);
// Restore replica DB
await replicaFaults.dbUp();
+ // Settle after restore (not a gate: the recovery wait below decides).
await sleep(2000);
// Client should detect gap and perform incremental catch-up
diff --git a/test/chaos/source_db_resilience.test.js b/test/chaos/source_db_resilience.test.js
index 1e0646d1..16450095 100644
--- a/test/chaos/source_db_resilience.test.js
+++ b/test/chaos/source_db_resilience.test.js
@@ -71,6 +71,19 @@ const {
REPLICA_PROXY
} = require('./helpers/toxiproxy-client');
+// Seed through the proxied source, retrying until it answers after a restore
+// (the budget covers the 30s breaker cooldown; seedBlocks skips written blocks).
+async function seedWhenSourceAnswers(start, end, timeoutMs = 60000) {
+ const deadline = Date.now() + timeoutMs;
+ let lastError;
+ while (Date.now() < deadline) {
+ try { return await seedSourceBlocks(start, end); } catch (e) { lastError = e; }
+ // Pace the retries.
+ await sleep(250);
+ }
+ throw lastError;
+}
+
describe('Chaos: Source Database Resilience', function () {
let server, client;
@@ -141,11 +154,11 @@ describe('CE-SRC-01: Complete Source DB Unavailability', function () {
it('server recovers and resumes sync after source DB is restored', async function () {
await sourceFaults.dbDown();
- await sleep(5000);
+ // Restore only once the server has FELT the outage (a failed poll cycle).
+ await waitForServerPollFailures(server, 1, 30000);
await sourceFaults.dbUp();
- await sleep(2000); // allow pool to reconnect
- await seedSourceBlocks(21, 25);
+ await seedWhenSourceAnswers(21, 25);
await server.poll();
@@ -228,7 +241,8 @@ describe('CE-SRC-03: Connection Pool Exhaustion', function () {
it('server stays alive when all DB connections are held for 30s', async function () {
await sourceFaults.timeout(30000);
- // Wait for several poll cycles to fail (exhaust the 10-connection pool)
+ // Hold the pool exhausted, then prove the server still answers (negative
+ // window: held reads hang rather than fail, so there is nothing to poll).
await sleep(8000);
const alive = await isServerAlive(server.getUrl());
@@ -238,6 +252,8 @@ describe('CE-SRC-03: Connection Pool Exhaustion', function () {
it('server recovers after timeout toxic is removed', async function () {
await sourceFaults.timeout(30000);
+ // Keep the pool held long enough to stall polls (exposure window, not a
+ // gate: the recovery wait below decides).
await sleep(5000);
await sourceFaults.reset();
@@ -245,7 +261,8 @@ describe('CE-SRC-03: Connection Pool Exhaustion', function () {
const sourceDbDirect = require('./helpers/chaos-setup').getSourceDbDirect();
await fixtures.seedBlocks(sourceDbDirect, 21, 23);
- // Allow circuit breaker to recover (up to 30s cooldown + half-open attempt)
+ // Give held reads time to release before the forced poll (cooldown
+ // settle; the breaker exposes no state to wait on).
await sleep(5000);
await server.poll();
@@ -269,6 +286,7 @@ describe('CE-SRC-04: Intermittent Connection Drops', function () {
for (let i = 0; i < 20; i++) {
try { await server.poll(); } catch { /* expected failures */ }
+ // Pace the forced polls so resets land across many cycles.
await sleep(200);
}
@@ -286,6 +304,7 @@ describe('CE-SRC-04: Intermittent Connection Drops', function () {
for (let i = 0; i < 10; i++) {
try { await server.poll(); } catch { /* expected */ }
+ // Pace the forced polls so resets land across many cycles.
await sleep(300);
}
@@ -296,6 +315,8 @@ describe('CE-SRC-04: Intermittent Connection Drops', function () {
it('success rate returns to 100% after toxic is removed', async function () {
await sourceFaults.resetConnections(0.3);
+ // Expose the pool to resets before lifting the toxic (exposure window;
+ // the five polls below are the assertion).
await sleep(2000);
await sourceFaults.reset();
@@ -326,19 +347,24 @@ describe('CE-SRC-05: Source Down → Blocks Accumulate → Recovery', function (
expect(preOutageBlock).to.be.at.least(20);
await sourceFaults.dbDown();
- await sleep(3000);
+ // Accumulate blocks only once the server has FELT the outage.
+ await waitForServerPollFailures(server, 1, 30000);
// Seed blocks via DIRECT connection (bypasses disabled proxy)
await seedSourceDirect(21, 35);
- // Let poll failures accumulate against the circuit breaker.
- await sleep(5000);
+ // Let poll failures accumulate against the circuit breaker: one more
+ // failed cycle, which waits out a 10s pool acquire timeout.
+ await waitForServerPollFailures(server, 1, 60000);
await sourceFaults.dbUp();
+ // Settle after restore (not a gate: the polls below tolerate failure
+ // and the recovery wait decides).
await sleep(2000);
for (let i = 0; i < 10; i++) {
try { await server.poll(); } catch { /* circuit may still be half-open */ }
+ // Pace the forced polls.
await sleep(500);
}
diff --git a/test/chaos/sync_resilience.test.js b/test/chaos/sync_resilience.test.js
index 1e26aae3..dd2892e8 100644
--- a/test/chaos/sync_resilience.test.js
+++ b/test/chaos/sync_resilience.test.js
@@ -46,7 +46,7 @@ const {
assertHashesMatch
} = require('../e2e/helpers/assertions');
-const { waitForClientDisconnect } = require('../e2e/helpers/waitFor');
+const { waitForClientDisconnect, waitForServerPollFailures } = require('../e2e/helpers/waitFor');
const {
bootstrapDatabases,
@@ -113,18 +113,22 @@ async function recoverFromCompoundFailure() {
// Seed blocks via DIRECT connection while proxy is disabled
await seedSourceDirect(16, 25);
- // Let the server's circuit breaker start failing before crashing it.
- await sleep(5000);
+ // Crash the server only once it has FELT the outage: a failed poll cycle,
+ // not a duration (the first failure lands on the severed pooled socket).
+ await waitForServerPollFailures(server, 1, 30000);
// Phase 2: Server crashes (while source is still down)
await server.stop();
server = null;
- // Client loses WebSocket connection
- await sleep(3000);
+ // Restart only after the client has SEEN the socket close, so the reconnect
+ // path is what heals the gap.
+ await waitForClientDisconnect(client);
// Phase 3: Recovery (source comes back, server restarts)
await sourceFaults.dbUp();
+ // Hold the restored source briefly before restart (settle, not a gate:
+ // pool acquires back off and retry, and the recovery wait below gates).
await sleep(2000);
server = createServer(SERVER_PORT);
@@ -133,6 +137,21 @@ async function recoverFromCompoundFailure() {
// Force server to poll and catch up on blocks 16-25
for (let i = 0; i < 10; i++) {
try { await server.poll(); } catch { /* circuit may be recovering */ }
+ // Pace the forced polls (the background loop also polls every 200ms).
+ await sleep(500);
+ }
+}
+
+// Lift the source latency, settle, then force paced polls over re-seeded blocks.
+async function liftLatencyAndForcePolls() {
+ await sourceFaults.reset();
+ // Settle after the toxic is lifted (not a gate: the polls below tolerate
+ // failure and the recovery wait decides).
+ await sleep(1000);
+
+ for (let i = 0; i < 10; i++) {
+ try { await server.poll(); } catch { /* recovery in progress */ }
+ // Pace the forced polls.
await sleep(500);
}
}
@@ -223,6 +242,8 @@ describe('CE-SYNC-02: Block Gap Detection → Incremental Catch-Up', function ()
// Seed more blocks while client is disconnected
await seedSourceBlocks(31, 50);
await server.poll();
+ // Let the 31-50 broadcast pass while no client is attached, so the gap
+ // is real (no observable: a broadcast to zero subscribers leaves no trace).
await sleep(1000);
// Don't do full bootstrap; just connect live sync
@@ -274,10 +295,14 @@ describe('CE-SYNC-03: Reorg During Active Sync', function () {
// Force server poll; will detect reorg (currentBlock=17 < lastPolled=20)
try { await server.poll(); } catch { /* may fail under latency */ }
+ // Keep the reorg in flight under latency before re-seeding (exposure
+ // window, not a gate: the recovery wait and hash checks below decide).
await sleep(2000);
// Server broadcasts reorg event; client should rollback to block 17
const replicaDb = require('./helpers/chaos-setup').getReplicaDb();
+ // Give the reorg broadcast time to land before the replacement blocks
+ // appear (exposure window; the outcome is asserted after recovery).
await sleep(3000);
// Different creditAmount so the assertions below can confirm this is
@@ -285,15 +310,8 @@ describe('CE-SYNC-03: Reorg During Active Sync', function () {
// Re-seed blocks 18-22 with different data
await fixtures.seedBlocks(sourceDbDirect, 18, 22, { creditAmount: '7777' });
- // Remove latency for recovery
- await sourceFaults.reset();
- await sleep(1000);
-
- // Force polls to process re-seeded blocks
- for (let i = 0; i < 10; i++) {
- try { await server.poll(); } catch { /* recovery in progress */ }
- await sleep(500);
- }
+ // Remove latency for recovery, then force polls over the re-seeded blocks
+ await liftLatencyAndForcePolls();
// Wait for replica to reach block 22
const recoveryMs = await waitForSyncRecovery(22, 60000);
diff --git a/test/e2e/decoder_lifecycle.test/helpers/decoder_lifecycle_harness.js b/test/e2e/decoder_lifecycle.test/helpers/decoder_lifecycle_harness.js
index ac2b02ea..1a77f893 100644
--- a/test/e2e/decoder_lifecycle.test/helpers/decoder_lifecycle_harness.js
+++ b/test/e2e/decoder_lifecycle.test/helpers/decoder_lifecycle_harness.js
@@ -38,36 +38,24 @@
'use strict';
-const http = require('http');
-const express = require('express');
-const cors = require('cors');
-const { parseCorsOrigin } = require('../../../../src/http/cors_origin');
-const WebSocket = require('ws');
const sinon = require('sinon');
-const Database = require('../../../../src/db');
-const ServerPoller = require('../../../../src/server/poller');
-const BlockBroadcaster = require('../../../../src/server/block_broadcaster');
-const SnapshotBuilder = require('../../../../src/server/snapshot_builder');
-const ClientSync = require('../../../../src/client/sync');
-const ClientApplier = require('../../../../src/client/applier');
-const ClientRollback = require('../../../../src/client/rollback');
-const HashVerifier = require('../../../../src/client/hash_verifier');
-const Utility = require('../../../../src/util');
-// Proxy-trust and rate-limiter wiring comes from the real api.js rather than a
-// parallel copy: how req.ip resolves and which limiter guards which route are
-// production decisions, and a harness that re-declares them cannot notice when
-// production drifts. api.js guards its env check and listen() behind
-// require.main === module, so requiring it here opens no port.
-const { trustProxyHops, createRateLimiters } = require('../../../../src/api');
+const Database = require('../../../../src/db');
+const ClientSync = require('../../../../src/client/sync');
+const ClientApplier = require('../../../../src/client/applier');
+const ClientRollback = require('../../../../src/client/rollback');
+const HashVerifier = require('../../../../src/client/hash_verifier');
+const Utility = require('../../../../src/util');
const decoderFixtures = require('../../helpers/decoderFixtures');
const { getMariadb } = require('../../helpers/mariadbLoader');
+const ServerProcess = require('../../helpers/serverProcess');
+const fixturePorts = require('../../../../bin/fixture-ports.js');
const SOURCE_HOST = process.env.E2E_DB_HOST || '127.0.0.1';
-const SOURCE_PORT = parseInt(process.env.E2E_DB_PORT) || 23306;
+const SOURCE_PORT = fixturePorts.port('E2E_DB_PORT');
const REPLICA_HOST = process.env.E2E_REPLICA_DB_HOST || '127.0.0.1';
-const REPLICA_PORT = parseInt(process.env.E2E_REPLICA_DB_PORT) || 23307;
+const REPLICA_PORT = fixturePorts.port('E2E_REPLICA_DB_PORT');
const DB_USER = process.env.E2E_DB_USER || 'xchain-node';
const DB_PASS = process.env.E2E_DB_PASS || 'xchain-fixture-throwaway';
@@ -101,128 +89,12 @@ const SERVER_CONFIG = {
TRANSPARENCY_RATE_LIMIT: 100000
};
-function mountStatusRoute(app, sourceDb){
- let validateDbType = (dt) => (dt === 'indexer' || dt === 'decoder') ? dt : null;
- app.get('/status/:dbType/:chain/:network', async (req, res) => {
- let dbType = validateDbType(req.params.dbType);
- if(!dbType) return res.status(400).json({ error: 'Invalid dbType' });
- if(req.params.chain !== CHAIN || req.params.network !== NETWORK)
- return res.status(404).json({ error: 'Chain/network not found' });
- try {
- let last = await sourceDb.getLastBlock();
- let row = last !== null ? await sourceDb.getBlockHashRow(last) : null;
- let body = {
- chain: req.params.chain,
- network: req.params.network,
- dbType: dbType,
- block_height: row ? Number(row.block_index) : null,
- block_time: row ? Number(row.block_time) : null
- };
- if(dbType === 'decoder'){
- body.block_hash = row ? row.block_hash : null;
- } else {
- body.ledger_hash = row ? row.ledger_hash : null;
- body.actions_hash = row ? row.actions_hash : null;
- body.contract_hash = row ? row.contract_hash : null;
- }
- res.json(body);
- } catch(e){
- res.status(500).json({ error: e.message });
- }
- });
-}
-
-function mountSchemaRoute(app, sourceDb){
- app.get('/schema/:dbType/:chain/:network', async (req, res) => {
- try {
- let tables = await sourceDb.doQuery(
- "SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE' ORDER BY table_name",
- [sourceDb.dbName]
- );
- let schema = {};
- for(let row of tables){
- let tn = row.table_name || row.TABLE_NAME;
- let ddl = await sourceDb.doQuery("SHOW CREATE TABLE `" + tn + "`");
- if(ddl.length > 0) schema[tn] = ddl[0]['Create Table'];
- }
- res.json({ chain: req.params.chain, network: req.params.network, dbType: req.params.dbType, tables: schema });
- } catch(e){
- res.status(500).json({ error: e.message });
- }
- });
-}
-
-function mountSnapshotRoutes(app, sourceDb, snapshotBuilder, limiters){
- app.get('/snapshot/:dbType/:chain/:network', limiters.fullSnapshotLimiter, async (req, res) => {
- try {
- await snapshotBuilder.streamFullSnapshot(sourceDb, res);
- } catch(e){
- if(!res.headersSent) res.status(500).json({ error: e.message });
- }
- });
-
- app.get('/snapshot/:dbType/:chain/:network/since/:blockHeight', limiters.incrSnapshotLimiter, async (req, res) => {
- let since = parseInt(req.params.blockHeight);
- if(isNaN(since) || since < 0) return res.status(400).json({ error: 'Invalid blockHeight' });
- try {
- await snapshotBuilder.streamIncrementalSnapshot(sourceDb, since, res);
- } catch(e){
- if(!res.headersSent) res.status(500).json({ error: e.message });
- }
- });
-}
-
-function mountTransparencyRoute(app, limiters){
- // Transparency is indexer-only; decoder requests must return 400.
- app.get('/transparency/:dbType/:chain/:network/roots', limiters.transparencyLimiter, (req, res) => {
- if(req.params.dbType !== 'indexer')
- return res.status(400).json({ error: 'Transparency log is indexer-only' });
- res.json({ entries: [] });
- });
-}
-
-// Build the mini HTTP+WS server that mirrors src/api.js for decoder
-// surface. Reuses real BlockBroadcaster + SnapshotBuilder + ServerPoller
-// so the test exercises actual Phase 3 code paths.
-function buildServer(sourceDb, broadcaster, snapshotBuilder, cfg){
- let app = express();
- // Must precede the limiters, which read req.ip: same ordering requirement
- // startApi() has.
- app.set('trust proxy', trustProxyHops(cfg['TRUST_PROXY']));
- app.use(cors({ origin: parseCorsOrigin(process.env.CORS_ORIGIN), methods: ['GET'] }));
-
- // The limiter instances startApi() mounts, on the routes it guards.
- let limiters = createRateLimiters(cfg);
- app.use(limiters.backstopLimiter);
- mountStatusRoute(app, sourceDb);
- mountSchemaRoute(app, sourceDb);
- mountSnapshotRoutes(app, sourceDb, snapshotBuilder, limiters);
- mountTransparencyRoute(app, limiters);
-
- let server = http.createServer(app);
- let wss = new WebSocket.Server({ noServer: true });
- server.on('upgrade', (request, socket, head) => {
- let m = request.url.match(/^\/subscribe\/([^\/]+)\/([^\/]+)\/([^\/\?]+)/);
- if(!m){ socket.destroy(); return; }
- let [, dbType, chain, network] = m;
- if(dbType !== 'indexer' && dbType !== 'decoder'){ socket.destroy(); return; }
- wss.handleUpgrade(request, socket, head, (ws) => {
- broadcaster.addSubscription(ws, request, chain, network, 'full', dbType);
- });
- });
- return server;
-}
-
class DecoderLifecycle {
constructor(assignState){
this.assignState = assignState;
this.sourceDb = null;
this.replicaDb = null;
- this.broadcaster = null;
- this.snapshotBuilder = null;
- this.poller = null;
- this.server = null;
- this.pollInterval = null;
+ this.serverProcess = null;
this.client = null;
}
@@ -272,18 +144,14 @@ class DecoderLifecycle {
async teardown(){
sinon.restore();
if(this.client) this.client.stop();
- if(this.pollInterval) clearInterval(this.pollInterval);
- if(this.poller) this.poller.stop();
- if(this.server) await new Promise(r => this.server.close(r));
+ if(this.serverProcess) await this.serverProcess.stop();
if(this.sourceDb) await this.sourceDb.close();
if(this.replicaDb) await this.replicaDb.close();
}
async reset(){
if(this.client) { this.client.stop(); this.client = null; }
- if(this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; }
- if(this.poller) { this.poller.stop(); this.poller = null; }
- if(this.server) { await new Promise(r => this.server.close(r)); this.server = null; }
+ if(this.serverProcess) { await this.serverProcess.stop(); this.serverProcess = null; }
await decoderFixtures.truncateAll(this.sourceDb);
await decoderFixtures.truncateAll(this.replicaDb);
this.publish();
@@ -297,23 +165,9 @@ class DecoderLifecycle {
}
async startServer(overrides){
- // One config object for the broadcaster, the poller and the app, so a
- // test that flips TRUST_PROXY moves both budget surfaces at once, the
- // way a deployment does.
- let cfg = Object.assign({}, SERVER_CONFIG, overrides);
-
- this.broadcaster = new BlockBroadcaster(cfg);
- this.snapshotBuilder = new SnapshotBuilder(util);
- this.poller = new ServerPoller(CHAIN, NETWORK, this.sourceDb, this.broadcaster, null, cfg, util);
- this.server = buildServer(this.sourceDb, this.broadcaster, this.snapshotBuilder, cfg);
- await new Promise(r => this.server.listen(SERVER_PORT, r));
-
- // Drive the poller manually; match the indexer e2e harness pattern.
- this.poller.lastPolledBlock = await this.sourceDb.getLastBlock();
- await this.poller.updateStatus();
- this.pollInterval = setInterval(async () => {
- try { await this.poller.poll(); } catch(e){}
- }, 200);
+ this.serverProcess = new ServerProcess(this.sourceDb, SERVER_PORT, CHAIN, NETWORK);
+ Object.assign(this.serverProcess.config, SERVER_CONFIG, overrides);
+ await this.serverProcess.start();
}
makeClient(){
diff --git a/test/e2e/dispensers_reconcile.test.js b/test/e2e/dispensers_reconcile.test.js
index f38adc0b..f0def7e3 100644
--- a/test/e2e/dispensers_reconcile.test.js
+++ b/test/e2e/dispensers_reconcile.test.js
@@ -16,7 +16,7 @@
*
* dispensers rides neither the block stream nor the id-cursor lookup
* paging (no monotonic id; the decoder soft-expires via UPDATE then
- * hard-purges via DELETE). SnapshotBuilder's decoderSkip set lives inside
+ * hard-purges via DELETE). SnapshotBuilder's decoder skip set applies only to
* streamIncrementalSnapshot, so the INCREMENTAL path skips dispensers while
* the FULL snapshot carries it: the full-snapshot table filter excludes only
* OPERATOR_LOCAL_TABLES / SOURCE_UNSTREAMED_TABLES, and dispensers is in
@@ -61,11 +61,12 @@ const Utility = require('../../src/util');
const decoderFixtures = require('./helpers/decoderFixtures');
const { getMariadb } = require('./helpers/mariadbLoader');
+const fixturePorts = require('../../bin/fixture-ports.js');
const SOURCE_HOST = process.env.E2E_DB_HOST || '127.0.0.1';
-const SOURCE_PORT = parseInt(process.env.E2E_DB_PORT) || 23306;
+const SOURCE_PORT = fixturePorts.port('E2E_DB_PORT');
const REPLICA_HOST = process.env.E2E_REPLICA_DB_HOST || '127.0.0.1';
-const REPLICA_PORT = parseInt(process.env.E2E_REPLICA_DB_PORT) || 23307;
+const REPLICA_PORT = fixturePorts.port('E2E_REPLICA_DB_PORT');
const DB_USER = process.env.E2E_DB_USER || 'xchain-node';
const DB_PASS = process.env.E2E_DB_PASS || 'xchain-fixture-throwaway';
@@ -124,9 +125,7 @@ function buildServer(sourceDb, broadcaster, snapshotBuilder){
if((req.query.after_tx !== undefined && (isNaN(afterTx) || afterTx < 0)) ||
(req.query.after_addr !== undefined && (isNaN(afterAddr) || afterAddr < 0)))
return res.status(400).json({ error: 'Invalid cursor' });
- let limit = parseInt(req.query.limit);
- if(isNaN(limit)) limit = undefined;
- try { await snapshotBuilder.streamDispensers(sourceDb, afterTx, afterAddr, limit, res); }
+ try { await snapshotBuilder.streamDispensers(sourceDb, afterTx, afterAddr, res); }
catch(e){ if(!res.headersSent) res.status(500).json({ error: e.message }); }
});
@@ -234,8 +233,8 @@ describe('E2E: Decoder dispensers reconcile', function() {
}
// `every` => DISPENSERS_RECONCILE_EVERY; `pageSize` => LOOKUP_PAGE_SIZE
- // (forced smaller than the fixture row count to prove a small page size does
- // NOT split the dump: streamDispensers is deliberately single-response and
+ // (forced smaller than the fixture row count to prove the lookup page size does
+ // NOT bound the dump: streamDispensers is deliberately single-response and
// answers has_more=false whatever the cursor params say, so the client's
// fetch walk completes in one round trip. That contract is pinned directly in
// test/unit/snapshot_builder.test.js, describe('streamDispensers')).
diff --git a/test/e2e/docker-compose.e2e.yml b/test/e2e/docker-compose.e2e.yml
index c0ba3ac4..8ee875e2 100644
--- a/test/e2e/docker-compose.e2e.yml
+++ b/test/e2e/docker-compose.e2e.yml
@@ -18,7 +18,7 @@ services:
MARIADB_USER: xchain-node
MARIADB_PASSWORD: xchain-fixture-throwaway
ports:
- - "23306:3306"
+ - "${E2E_DB_PORT:-23306}:3306"
tmpfs:
- /var/lib/mysql
healthcheck:
@@ -36,7 +36,7 @@ services:
MARIADB_USER: xchain-node
MARIADB_PASSWORD: xchain-fixture-throwaway
ports:
- - "23307:3306"
+ - "${E2E_REPLICA_DB_PORT:-23307}:3306"
tmpfs:
- /var/lib/mysql
healthcheck:
@@ -54,7 +54,7 @@ services:
MARIADB_USER: xchain-node
MARIADB_PASSWORD: xchain-fixture-throwaway
ports:
- - "23308:3306"
+ - "${E2E_SOURCE2_DB_PORT:-23308}:3306"
tmpfs:
- /var/lib/mysql
healthcheck:
diff --git a/test/e2e/helpers/fixtures.js b/test/e2e/helpers/fixtures.js
index d917628a..596c1749 100644
--- a/test/e2e/helpers/fixtures.js
+++ b/test/e2e/helpers/fixtures.js
@@ -40,8 +40,12 @@ function blockHash(blockIndex, label) {
async function computeAndInsertBlockHashes(db, blockIndex, conn) {
// When called inside a fixture block transaction, every read/write must ride
// that transaction's connection or the hasher can't see the block's own
- // uncommitted rows. BlockHasher only needs doQuery, so a thin facade pins it.
- let hasherDb = conn ? { doQuery: (q, a) => db.doQuery(q, a, conn) } : db;
+ // uncommitted rows. BlockHasher only needs doQuery and doQueryStrict, so a thin
+ // facade pins both.
+ let hasherDb = conn ? {
+ doQuery: (q, a) => db.doQuery(q, a, conn),
+ doQueryStrict: (q, a) => db.doQueryStrict(q, a, conn)
+ } : db;
let computed = await new BlockHasher(hasherDb, _util).computeBlockHashes(blockIndex);
let ids = {};
for (let [field, hash] of [['ledger_hash_id', computed.ledger_hash],
diff --git a/test/e2e/helpers/serverProcess.js b/test/e2e/helpers/serverProcess.js
index aae2a268..37cd034e 100644
--- a/test/e2e/helpers/serverProcess.js
+++ b/test/e2e/helpers/serverProcess.js
@@ -31,6 +31,7 @@ class ServerProcess {
this.port = port;
this.chain = chain || 'bitcoin';
this.network = network || 'mainnet';
+ this.dbType = sourceDb.dbType || 'indexer';
this.config = {
SYNC_MODE: 'server',
@@ -54,6 +55,7 @@ class ServerProcess {
TRANSPARENCY_RATE_LIMIT: 100000
};
+ this.app = null;
this.server = null;
this.wss = null;
this.broadcaster = null;
@@ -74,7 +76,7 @@ class ServerProcess {
async start() {
this.broadcaster = new BlockBroadcaster(this.config);
- this.log = new TransparencyLog(this.sourceDb);
+ this.log = this.dbType === 'indexer' ? new TransparencyLog(this.sourceDb) : null;
this.snapshotBuilder = new SnapshotBuilder(testDb.util);
this.poller = new ServerPoller(
this.chain, this.network, this.sourceDb,
@@ -82,6 +84,7 @@ class ServerProcess {
);
let app = express();
+ this.app = app;
// Must precede the limiters below (they read req.ip): same ordering
// requirement as api.js's startApi(). Deriving from trustProxyHops
// rather than a re-declared literal is the whole point of this seam:
@@ -101,8 +104,8 @@ class ServerProcess {
// backs the e2e suite, so its surface needs to match the real API
// after the Phase 3 path migration; otherwise the suite would
// either 404 on status checks or pass-by-accident on snapshots.
- // :dbType is one of 'indexer' or 'decoder'; this helper only seeds
- // indexer-shaped data, so 'decoder' requests respond as a stub.
+ // :dbType is one of 'indexer' or 'decoder'. The source DB type selects
+ // the matching hash shape for status and polling.
let validateDbType = (dt) => (dt === 'indexer' || dt === 'decoder') ? dt : null;
@@ -113,15 +116,18 @@ class ServerProcess {
let hashRow = lastBlock !== null ? await this.sourceDb.getBlockHashRow(lastBlock) : null;
let result = {};
result[this.chain] = {};
- result[this.chain][this.network] = {
- indexer: {
- block_height: hashRow ? Number(hashRow.block_index) : null,
- block_time: hashRow ? Number(hashRow.block_time) : null,
- ledger_hash: hashRow ? hashRow.ledger_hash : null,
- actions_hash: hashRow ? hashRow.actions_hash : null,
- contract_hash:hashRow ? hashRow.contract_hash : null
- }
+ let status = {
+ block_height: hashRow ? Number(hashRow.block_index) : null,
+ block_time: hashRow ? Number(hashRow.block_time) : null
};
+ if (this.dbType === 'decoder') {
+ status.block_hash = hashRow ? hashRow.block_hash : null;
+ } else {
+ status.ledger_hash = hashRow ? hashRow.ledger_hash : null;
+ status.actions_hash = hashRow ? hashRow.actions_hash : null;
+ status.contract_hash = hashRow ? hashRow.contract_hash : null;
+ }
+ result[this.chain][this.network] = { [this.dbType]: status };
result.last_updated = new Date().toISOString();
res.json(result);
} catch (e) {
@@ -146,7 +152,7 @@ class ServerProcess {
last_updated: new Date().toISOString()
};
if (dbType === 'decoder') {
- body.block_hash = hashRow ? hashRow.ledger_hash : null; // stub: helper seeds indexer rows
+ body.block_hash = hashRow ? hashRow.block_hash : null;
} else {
body.ledger_hash = hashRow ? hashRow.ledger_hash : null;
body.actions_hash = hashRow ? hashRow.actions_hash : null;
diff --git a/test/e2e/helpers/testDb.js b/test/e2e/helpers/testDb.js
index 84444ff2..64d600bf 100644
--- a/test/e2e/helpers/testDb.js
+++ b/test/e2e/helpers/testDb.js
@@ -14,19 +14,20 @@ const { getMariadb } = require('./mariadbLoader');
const Utility = require('../../../src/util');
const { splitSqlStatements } = require('../../../src/db/sql_util');
const validation = require('../../../src/util/validation');
+const fixturePorts = require('../../../bin/fixture-ports.js');
const TEST_DB_HOST = process.env.E2E_DB_HOST || '127.0.0.1';
-const TEST_DB_PORT = parseInt(process.env.E2E_DB_PORT) || 23306;
+const TEST_DB_PORT = fixturePorts.port('E2E_DB_PORT');
const TEST_DB_USER = process.env.E2E_DB_USER || 'xchain-node';
const TEST_DB_PASS = process.env.E2E_DB_PASS || 'xchain-fixture-throwaway';
const REPLICA_DB_HOST = process.env.E2E_REPLICA_DB_HOST || '127.0.0.1';
-const REPLICA_DB_PORT = parseInt(process.env.E2E_REPLICA_DB_PORT) || 23307;
+const REPLICA_DB_PORT = fixturePorts.port('E2E_REPLICA_DB_PORT');
const REPLICA_DB_USER = process.env.E2E_REPLICA_DB_USER || 'xchain-node';
const REPLICA_DB_PASS = process.env.E2E_REPLICA_DB_PASS || 'xchain-fixture-throwaway';
const SOURCE2_DB_HOST = process.env.E2E_SOURCE2_DB_HOST || '127.0.0.1';
-const SOURCE2_DB_PORT = parseInt(process.env.E2E_SOURCE2_DB_PORT) || 23308;
+const SOURCE2_DB_PORT = fixturePorts.port('E2E_SOURCE2_DB_PORT');
const SOURCE2_DB_USER = process.env.E2E_SOURCE2_DB_USER || 'xchain-node';
const SOURCE2_DB_PASS = process.env.E2E_SOURCE2_DB_PASS || 'xchain-fixture-throwaway';
diff --git a/test/e2e/server_process_trust_proxy.test.js b/test/e2e/server_process_trust_proxy.test.js
new file mode 100644
index 00000000..8740f670
--- /dev/null
+++ b/test/e2e/server_process_trust_proxy.test.js
@@ -0,0 +1,87 @@
+// 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.
+
+const assert = require('assert');
+const sinon = require('sinon');
+const ServerProcess = require('./helpers/serverProcess');
+
+const SERVER_PORT = 29951;
+const FORWARDED_IP = '198.51.100.7';
+
+let server;
+const sourceDb = {
+ dbType: 'indexer',
+ getLastBlock: async () => null,
+ getReplicaStatus: async () => ({ isReplica: false })
+};
+
+function stubConsole() {
+ sinon.stub(console, 'log');
+ sinon.stub(console, 'error');
+}
+
+async function restoreConsoleAndStopServer() {
+ sinon.restore();
+ if (server) await server.stop();
+}
+
+async function stopServer() {
+ if (server) {
+ await server.stop();
+ server = null;
+ }
+}
+
+async function requestContext(trustProxy) {
+ server = new ServerProcess(sourceDb, SERVER_PORT);
+ if (trustProxy === undefined) delete server.config.TRUST_PROXY;
+ else server.config.TRUST_PROXY = trustProxy;
+ await server.start();
+
+ server.app.get('/request-context', (req, res) => {
+ res.json({ ip: req.ip, protocol: req.protocol });
+ });
+
+ let response = await fetch(server.getUrl() + '/request-context', {
+ headers: {
+ 'x-forwarded-for': FORWARDED_IP,
+ 'x-forwarded-proto': 'https'
+ }
+ });
+ assert.strictEqual(response.status, 200);
+ return response.json();
+}
+
+async function honourForwardedClientAndProtocol() {
+ this.timeout(30000);
+ let context = await requestContext(true);
+
+ assert.strictEqual(context.ip, FORWARDED_IP);
+ assert.strictEqual(context.protocol, 'https');
+}
+
+async function ignoreForwardedClientAndProtocol() {
+ this.timeout(30000);
+ let context = await requestContext(undefined);
+
+ assert.notStrictEqual(context.ip, FORWARDED_IP);
+ assert.strictEqual(context.protocol, 'http');
+}
+
+function registerProxyTrustTests() {
+ before(stubConsole);
+ after(restoreConsoleAndStopServer);
+ afterEach(stopServer);
+
+ it('honours forwarded client and protocol when TRUST_PROXY is true', honourForwardedClientAndProtocol);
+ it('ignores forwarded client and protocol when TRUST_PROXY is unset', ignoreForwardedClientAndProtocol);
+}
+
+describe('E2E: ServerProcess proxy trust', registerProxyTrustTests);
diff --git a/test/fixtures/gen-block-hash-vectors.js b/test/fixtures/gen-block-hash-vectors.js
index 994e2c87..97e5e3d4 100644
--- a/test/fixtures/gen-block-hash-vectors.js
+++ b/test/fixtures/gen-block-hash-vectors.js
@@ -71,7 +71,8 @@ const results = [
async function main(){
let call = 0;
- const mockDb = { doQuery: async () => results[call++] };
+ const next = async () => results[call++];
+ const mockDb = { doQuery: next, doQueryStrict: next };
// Hash with the INDEXER's getDataHash so the expected values are authentic.
const hasher = new BlockHasher(mockDb, new IndexerUtil());
const expected = await hasher.computeBlockHashes(BLOCK_INDEX);
diff --git a/test/fuzz/suites/tier1_client_applier.fuzz.js b/test/fuzz/suites/tier1_client_applier.fuzz.js
index 39c95388..d98fa595 100644
--- a/test/fuzz/suites/tier1_client_applier.fuzz.js
+++ b/test/fuzz/suites/tier1_client_applier.fuzz.js
@@ -61,6 +61,71 @@ async function runAndLog(fn, input) {
}
}
+// snapshotTablesObject (helpers/generators/payloads.js) fills every table,
+// including blocks, with genericDataRow()'s random column names, which essentially
+// never happen to name the column block_index. ClientApplier.insertRows requires
+// that natural key for blocks (localSurrogateIdTables), so an unpatched generated
+// snapshot throws on an apply path unrelated to what these properties exercise.
+// Patching it in here keeps the fix inside this suite rather than reshaping the
+// shared generator for every other suite that uses it.
+function withBlockNaturalKeys(snapshot) {
+ if (!snapshot || !snapshot.tables || !snapshot.tables.blocks) return snapshot;
+ return {
+ ...snapshot,
+ tables: {
+ ...snapshot.tables,
+ blocks: snapshot.tables.blocks.map((row, index) => ({
+ ...row,
+ block_index: row.block_index == null ? index : row.block_index,
+ })),
+ },
+ };
+}
+
+// FK edges documented independently of the ordering algorithm: pubkeys.address_id ->
+// index_addresses.id (applier.js) and blocks.*_hash_id -> index_transactions.id
+// (sync.js). Used as the oracle for the FK-safe delete-order property below instead
+// of the production orderSnapshotTables function, since asserting against that
+// function's own output would pass even if its ordering logic regressed.
+const BLOCKS_FK_CHILD_BEFORE_PARENT = [
+ ['pubkeys', 'index_addresses'],
+ ['blocks', 'index_transactions'],
+];
+const BLOCKS_FK_CANDIDATE_TABLES = [
+ 'pubkeys', 'index_addresses', 'blocks', 'index_transactions',
+ 'index_actions', 'transactions', 'actions', 'balances', 'sync_meta',
+ 'fuzz_alpha', 'fuzz_omega',
+];
+
+// Property body for the FK-safe delete-order test: applies a full snapshot naming
+// exactly tableNames and asserts every table was deleted exactly once, with every
+// known FK child deleted before its parent.
+async function assertFkSafeDeleteOrder(tableNames) {
+ let snapshot = withBlockNaturalKeys({
+ schema_version: SCHEMA_VERSION.indexer,
+ block_height: 100,
+ tables: Object.fromEntries(tableNames.map(n => [n, [{ id: 1 }]])),
+ });
+
+ db.doQuery.resetHistory();
+ await applier.applyFullSnapshot(snapshot);
+
+ let deleteOrder = [];
+ for (let i = 0; i < db.doQuery.callCount; i++) {
+ let sql = db.doQuery.getCall(i).args[0];
+ let m = typeof sql === 'string' && sql.match(/^DELETE FROM `(.+)`$/);
+ if (m) deleteOrder.push(m[1]);
+ }
+
+ assert.deepStrictEqual([...deleteOrder].sort(), [...tableNames].sort());
+
+ for (let [child, parent] of BLOCKS_FK_CHILD_BEFORE_PARENT) {
+ if (!tableNames.includes(child) || !tableNames.includes(parent)) continue;
+ assert.ok(deleteOrder.indexOf(child) < deleteOrder.indexOf(parent),
+ child + ' must be deleted before ' + parent + ', got order: ' + deleteOrder.join(','));
+ }
+}
+
describe('Tier 1 - ClientApplier @tier1', function () {
this.timeout(0);
useClientApplierHooks();
@@ -148,40 +213,19 @@ describe('Tier 1 - ClientApplier @tier1', function () {
return fc.assert(fc.asyncProperty(
partialSnapshotPayload(),
async (snapshot) => {
- await applier.applyFullSnapshot(snapshot);
+ await applier.applyFullSnapshot(withBlockNaturalKeys(snapshot));
}
), { numRuns: NUM_RUNS });
});
- it('clears tables in reverse order via DELETE (FK-safe)', function () {
+ it('clears tables via DELETE, children before parents (FK-safe)', function () {
// applyFullSnapshot clears via `DELETE FROM` (not TRUNCATE) for FK
- // compatibility, iterating the table keys in reverse so child tables are
- // cleared before their parents. Capture the DELETE order from doQuery.
+ // compatibility. assertFkSafeDeleteOrder checks the result against the
+ // documented FK graph rather than the production ordering function.
return fc.assert(fc.asyncProperty(
- fc.array(
- fc.string({ unit: fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')), minLength: 1, maxLength: 15 }),
- { minLength: 2, maxLength: 8 }
- ).filter(names => new Set(names).size === names.length),
- async (tableNames) => {
- let snapshot = {
- schema_version: SCHEMA_VERSION.indexer,
- block_height: 100,
- tables: Object.fromEntries(tableNames.map(n => [n, [{ id: 1 }]])),
- };
-
- db.doQuery.resetHistory();
- await applier.applyFullSnapshot(snapshot);
-
- let deleteOrder = [];
- for (let i = 0; i < db.doQuery.callCount; i++) {
- let sql = db.doQuery.getCall(i).args[0];
- let m = typeof sql === 'string' && sql.match(/^DELETE FROM `(.+)`$/);
- if (m) deleteOrder.push(m[1]);
- }
-
- let expectedOrder = [...tableNames].reverse();
- assert.deepStrictEqual(deleteOrder, expectedOrder);
- }
+ fc.uniqueArray(fc.constantFrom(...BLOCKS_FK_CANDIDATE_TABLES),
+ { minLength: 2, maxLength: BLOCKS_FK_CANDIDATE_TABLES.length }),
+ assertFkSafeDeleteOrder
), { numRuns: Math.min(NUM_RUNS, 500) });
});
});
@@ -197,7 +241,7 @@ describe('Tier 1 - ClientApplier @tier1', function () {
return fc.assert(fc.asyncProperty(
partialSnapshotPayload(),
async (snapshot) => {
- await applier.applyIncrementalSnapshot(snapshot);
+ await applier.applyIncrementalSnapshot(withBlockNaturalKeys(snapshot));
}
), { numRuns: NUM_RUNS });
});
@@ -209,7 +253,7 @@ describe('Tier 1 - ClientApplier @tier1', function () {
// Add since_block to make it incremental
snapshot.since_block = 1;
db.truncateTable.resetHistory();
- await applier.applyIncrementalSnapshot(snapshot);
+ await applier.applyIncrementalSnapshot(withBlockNaturalKeys(snapshot));
assert.strictEqual(db.truncateTable.callCount, 0);
}
), { numRuns: NUM_RUNS });
diff --git a/test/integration/armed_source_handshake.test.js b/test/integration/armed_source_handshake.test.js
index 40ff5f1e..eff102b7 100644
--- a/test/integration/armed_source_handshake.test.js
+++ b/test/integration/armed_source_handshake.test.js
@@ -24,75 +24,16 @@
// anywhere, unlike the rest of test/integration.
const assert = require('assert');
-const http = require('http');
-const express = require('express');
const axios = require('axios');
const WebSocket = require('ws');
-const { createApiKeyMiddleware, safeEqual } = require('../../src/http/middleware');
-const ClientSync = require('../../src/client/sync');
-const HashVerifier = require('../../src/client/hash_verifier');
-const Utility = require('../../src/util');
-
-const PORT = 19477;
-const SERVER_KEY = 'armed-source-key';
-
-// Enough of a ClientSync to exercise the outbound credential path. The replication
-// collaborators are never reached: every assertion here is decided by the source's
-// guard, at or before the response.
-function makeClient(configOverrides){
- const noop = () => {};
- const db = { query: async () => [], getConnection: async () => ({ release: noop }) };
- const stub = { apply: noop, rollback: noop };
- const config = Object.assign({
- SYNC_SOURCES: 'http://127.0.0.1:' + PORT,
- VERIFY_HASHES: false,
- CLIENT_RECONNECT_DELAY: 5000,
- HASH_CONFIRM_TIMEOUT: 5000,
- SNAPSHOT_MAX_CONTENT: 16 * 1024 * 1024,
- WS_MAX_PAYLOAD: 16 * 1024 * 1024,
- MAX_ROLLBACK_DEPTH: 10,
- GAP_LOG_INTERVAL_MS: 30000
- }, configOverrides || {});
- return new ClientSync('bitcoin', 'mainnet', db, stub, stub, new HashVerifier(), config, new Utility());
-}
+const {
+ PORT, SERVER_KEY, makeClient, registerArmedSourceHooks
+} = require('./armed_source_handshake.test/helpers/armed_source_handshake_suite');
describe('Integration: armed source handshake (server-tier rollout rehearsal)', function(){
- let server, wss;
-
- // An ARMED source. The REST side mounts the SAME createApiKeyMiddleware api.js
- // mounts app-wide. The WS side is a transcription of api.js's upgrade guard,
- // because that handler is inline in startApi and not exported: it shares the
- // real safeEqual, but a change to the guard's SHAPE would not fail here. What
- // these cases are authoritative about is the CLIENT half, which is what the
- // credential split changed.
- before(function(done){
- const app = express();
- app.use(createApiKeyMiddleware(SERVER_KEY));
- app.get('/snapshot/:dbType/:chain/:network', (req, res) => res.json({ ok: true, rows: [] }));
- app.get('/health', (req, res) => res.json({ status: 'healthy' }));
-
- server = http.createServer(app);
- wss = new WebSocket.Server({ noServer: true });
-
- server.on('upgrade', (request, socket, head) => {
- const authHeader = request.headers['authorization'];
- if(!authHeader || !safeEqual(authHeader, 'Bearer ' + SERVER_KEY)){
- socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
- socket.destroy();
- return;
- }
- wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request));
- });
-
- server.listen(PORT, '127.0.0.1', done);
- });
-
- after(function(done){
- if(wss) wss.close();
- server.close(done);
- });
+ registerArmedSourceHooks();
it('REST: a client carrying the upstream key reads a snapshot from an armed source', async function(){
const sync = makeClient({ SYNC_UPSTREAM_KEY: SERVER_KEY });
diff --git a/test/integration/armed_source_handshake.test/helpers/armed_source_handshake_suite.js b/test/integration/armed_source_handshake.test/helpers/armed_source_handshake_suite.js
new file mode 100644
index 00000000..1ddb15f2
--- /dev/null
+++ b/test/integration/armed_source_handshake.test/helpers/armed_source_handshake_suite.js
@@ -0,0 +1,323 @@
+// 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.
+
+// Covers shared armed source setup. One part of armed_source_handshake.test.js.
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const WebSocket = require('ws');
+const sinon = require('sinon');
+const proxyquire = require('proxyquire');
+const { createApiKeyMiddleware, safeEqual } = require('../../../../src/http/middleware');
+const ClientSync = require('../../../../src/client/sync');
+const HashVerifier = require('../../../../src/client/hash_verifier');
+const Utility = require('../../../../src/util');
+const checkpointReads = require('../../../../src/db/state_checkpoints');
+
+const PORT = 19477;
+const SERVER_KEY = 'armed-source-key';
+
+// Enough of a ClientSync to exercise the outbound credential path. The replication
+// collaborators are never reached: every assertion here is decided by the source's
+// guard, at or before the response.
+function makeClient(configOverrides){
+ const noop = () => {};
+ const db = { query: async () => [], getConnection: async () => ({ release: noop }) };
+ const stub = { apply: noop, rollback: noop };
+ const config = Object.assign({
+ SYNC_SOURCES: 'http://127.0.0.1:' + PORT,
+ VERIFY_HASHES: false,
+ CLIENT_RECONNECT_DELAY: 5000,
+ HASH_CONFIRM_TIMEOUT: 5000,
+ SNAPSHOT_MAX_CONTENT: 16 * 1024 * 1024,
+ WS_MAX_PAYLOAD: 16 * 1024 * 1024,
+ MAX_ROLLBACK_DEPTH: 10,
+ GAP_LOG_INTERVAL_MS: 30000
+ }, configOverrides || {});
+ return new ClientSync('bitcoin', 'mainnet', db, stub, stub, new HashVerifier(), config, new Utility());
+}
+
+function registerArmedSourceHooks(){
+ let server, wss;
+
+ // An ARMED source. The REST side mounts the SAME createApiKeyMiddleware api.js
+ // mounts app-wide. The WS side is a transcription of api.js's upgrade guard,
+ // because that handler is inline in startApi and not exported: it shares the
+ // real safeEqual, but a change to the guard's SHAPE would not fail here. What
+ // these cases are authoritative about is the CLIENT half, which is what the
+ // credential split changed.
+ before(function(done){
+ const app = express();
+ app.use(createApiKeyMiddleware(SERVER_KEY));
+ app.get('/snapshot/:dbType/:chain/:network', (req, res) => res.json({ ok: true, rows: [] }));
+ app.get('/health', (req, res) => res.json({ status: 'healthy' }));
+
+ server = http.createServer(app);
+ wss = new WebSocket.Server({ noServer: true });
+
+ server.on('upgrade', (request, socket, head) => {
+ const authHeader = request.headers['authorization'];
+ if(!authHeader || !safeEqual(authHeader, 'Bearer ' + SERVER_KEY)){
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
+ socket.destroy();
+ return;
+ }
+ wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request));
+ });
+
+ server.listen(PORT, '127.0.0.1', done);
+ });
+
+ after(function(done){
+ if(wss) wss.close();
+ server.close(done);
+ });
+}
+
+// Boots the REAL startApi() with the network edges stubbed out. Hands back the
+// express app it built, the raw `server.on('upgrade', ...)` handler (captured
+// off the fake http.Server so it can be invoked directly, since it is declared
+// inline in startApi and never exported), and the fake WebSocket.Server so a
+// test can see whether handleUpgrade was ever reached. Same shape as the
+// api_rate_limit_proxy_security suite's bootRealApi, plus the upgrade capture:
+// without capturing the real handler, the WS guard above was only ever
+// exercised by registerArmedSourceHooks's hand-transcribed copy, which shares
+// safeEqual but not the handler's own shape.
+async function bootRealApi(envOverrides){
+ let prior = {};
+ for(let key of Object.keys(envOverrides || {})){
+ prior[key] = process.env[key];
+ let val = envOverrides[key];
+ if(val === undefined) delete process.env[key];
+ else process.env[key] = val;
+ }
+
+ let capturedApp = null;
+ let upgradeHandler = null;
+ let fakeServer = {
+ on: (event, handler) => { if(event === 'upgrade') upgradeHandler = handler; },
+ listen: () => {}
+ };
+ let fakeWss = {
+ on: () => {},
+ clients: new Set(),
+ handleUpgrade: sinon.spy((request, socket, head, cb) => {}),
+ emit: () => {}
+ };
+
+ class SyncServiceStub {
+ start(){ return Promise.resolve(); }
+ getBroadcaster(){ return { addSubscription: () => {} }; }
+ getChains(){ return []; }
+ getDatabase(){ return { fake: true }; }
+ getClientSync(){ return null; }
+ }
+
+ // startApi arms long-lived intervals; a test process must not inherit them.
+ // express-rate-limit's MemoryStore also calls setInterval and unrefs the
+ // handle it gets back, so the stub returns an unref-able stand-in rather
+ // than null.
+ let intervals = sinon.stub(global, 'setInterval').returns({ unref(){}, ref(){} });
+
+ try {
+ let api = proxyquire('../../../../src/api', {
+ 'http': { createServer: (app) => { capturedApp = app; return fakeServer; } },
+ 'ws': { Server: function(){ return fakeWss; } },
+ './SyncService': SyncServiceStub
+ });
+ await api.startApi();
+ } finally {
+ intervals.restore();
+ for(let key of Object.keys(prior)){
+ if(prior[key] === undefined) delete process.env[key];
+ else process.env[key] = prior[key];
+ }
+ }
+
+ return { app: capturedApp, upgradeHandler, wss: fakeWss };
+}
+
+// Puts a real startApi()-built app on a loopback port for HTTP assertions.
+async function listenRealApi(app){
+ let server = await new Promise((resolve) => {
+ let s = app.listen(0, '127.0.0.1', () => resolve(s));
+ });
+ return {
+ port: server.address().port,
+ close: () => new Promise((resolve) => server.close(resolve))
+ };
+}
+
+function makeUpgradeReq(headers){
+ return { headers: headers || {}, url: '/subscribe/indexer/bitcoin/mainnet' };
+}
+
+// Enough of a raw net.Socket for the guard's rejection path: it only ever
+// writes the 401 status line and destroys the connection.
+function makeSocket(){
+ return {
+ written: [],
+ destroyed: false,
+ write(chunk){ this.written.push(String(chunk)); },
+ destroy(){ this.destroyed = true; }
+ };
+}
+
+describe('Security: /halt/clear and the WebSocket upgrade guard, against the REAL api.js', function(){
+ let harness = null;
+
+ afterEach(async function(){
+ if(harness) await harness.close();
+ harness = null;
+ sinon.restore();
+ });
+
+ describe('POST /halt/clear/:dbType/:chain/:network', function(){
+
+ it('401s when no SYNC_API_KEY is configured at all, even carrying a header', async function(){
+ let { app } = await bootRealApi({ SYNC_API_KEY: undefined, SYNC_MODE: 'client' });
+ harness = await listenRealApi(app);
+
+ let res = await fetch('http://127.0.0.1:' + harness.port + '/halt/clear/indexer/bitcoin/mainnet', {
+ method: 'POST',
+ headers: { authorization: 'Bearer whatever' }
+ });
+ assert.strictEqual(res.status, 401);
+ });
+
+ it('401s on the wrong key once a key IS configured', async function(){
+ let { app } = await bootRealApi({ SYNC_API_KEY: 'test-halt-key', SYNC_MODE: 'client' });
+ harness = await listenRealApi(app);
+
+ let res = await fetch('http://127.0.0.1:' + harness.port + '/halt/clear/indexer/bitcoin/mainnet', {
+ method: 'POST',
+ headers: { authorization: 'Bearer wrong-key' }
+ });
+ assert.strictEqual(res.status, 401);
+ });
+
+ it('403s in server mode even carrying the correct key: halt-clear applies to client mode only', async function(){
+ let { app } = await bootRealApi({ SYNC_API_KEY: 'test-halt-key', SYNC_MODE: 'server' });
+ harness = await listenRealApi(app);
+
+ let res = await fetch('http://127.0.0.1:' + harness.port + '/halt/clear/indexer/bitcoin/mainnet', {
+ method: 'POST',
+ headers: { authorization: 'Bearer test-halt-key' }
+ });
+ assert.strictEqual(res.status, 403);
+ });
+ });
+
+ describe('WebSocket upgrade guard (server.on(\'upgrade\', ...))', function(){
+
+ it('401s a keyless upgrade once a key IS configured', async function(){
+ let { upgradeHandler } = await bootRealApi({ SYNC_API_KEY: 'test-ws-key' });
+ let socket = makeSocket();
+
+ upgradeHandler(makeUpgradeReq(), socket, Buffer.alloc(0));
+
+ assert.strictEqual(socket.destroyed, true);
+ assert.ok(socket.written.join('').includes('401'));
+ });
+
+ it('401s an upgrade carrying the wrong key', async function(){
+ let { upgradeHandler } = await bootRealApi({ SYNC_API_KEY: 'test-ws-key' });
+ let socket = makeSocket();
+
+ upgradeHandler(makeUpgradeReq({ authorization: 'Bearer nope' }), socket, Buffer.alloc(0));
+
+ assert.strictEqual(socket.destroyed, true);
+ assert.ok(socket.written.join('').includes('401'));
+ });
+
+ it('does not reject a correctly-keyed upgrade: it reaches wss.handleUpgrade instead', async function(){
+ let { upgradeHandler, wss } = await bootRealApi({ SYNC_API_KEY: 'test-ws-key' });
+ let socket = makeSocket();
+
+ upgradeHandler(makeUpgradeReq({ authorization: 'Bearer test-ws-key' }), socket, Buffer.alloc(0));
+
+ assert.strictEqual(socket.written.length, 0, 'no 401 status line should be written on a valid key');
+ assert.ok(wss.handleUpgrade.called);
+ });
+ });
+});
+
+// The signed-checkpoint reads api.js's /checkpoint/... routes call through to
+// (src/db/state_checkpoints.js, a Database mixin). Exercised directly against
+// the mixin's own SQL, not the HTTP layer above it, since what varies between
+// the three reads is the query shape and its bound params, not how api.js
+// turns a 404/500 around them.
+describe('Unit: state_checkpoints reads (SQL shape, limit, empty result)', function(){
+
+ function fakeDb(rows){
+ return { doQuery: sinon.stub().resolves(rows) };
+ }
+
+ describe('getLatestCheckpoint', function(){
+
+ it('takes the newest row overall: DESC by block_index then checkpoint_seq, capped at one', async function(){
+ let db = fakeDb([{ block_index: 5 }]);
+ await checkpointReads.getLatestCheckpoint.call(db);
+
+ let [sql, params] = db.doQuery.firstCall.args;
+ assert.ok(/FROM state_checkpoints\b/.test(sql));
+ assert.ok(/ORDER BY block_index DESC, checkpoint_seq DESC/.test(sql));
+ assert.ok(/LIMIT 1\s*$/.test(sql.trim()));
+ assert.strictEqual(params, undefined);
+ });
+
+ it('returns an empty array when the table has no rows', async function(){
+ let db = fakeDb([]);
+ let rows = await checkpointReads.getLatestCheckpoint.call(db);
+ assert.deepStrictEqual(rows, []);
+ });
+ });
+
+ describe('findCheckpointsInRange', function(){
+
+ it('bounds block_index to [from, to], keeps only the max checkpoint_seq per height, and passes the caller limit through', async function(){
+ let db = fakeDb([]);
+ await checkpointReads.findCheckpointsInRange.call(db, 10, 20, 500);
+
+ let [sql, params] = db.doQuery.firstCall.args;
+ assert.ok(/WHERE block_index >= \? AND block_index <= \?/.test(sql));
+ assert.ok(/checkpoint_seq = \(SELECT MAX\(s2\.checkpoint_seq\) FROM state_checkpoints s2 WHERE s2\.block_index = sc\.block_index\)/.test(sql));
+ assert.ok(/ORDER BY block_index ASC LIMIT \?/.test(sql));
+ assert.deepStrictEqual(params, [10, 20, 500]);
+ });
+
+ it('returns an empty array when nothing falls in range', async function(){
+ let db = fakeDb([]);
+ let rows = await checkpointReads.findCheckpointsInRange.call(db, 1, 2, 10);
+ assert.deepStrictEqual(rows, []);
+ });
+ });
+
+ describe('getCheckpointAtHeight', function(){
+
+ it('filters on the exact height and takes the newest checkpoint_seq there', async function(){
+ let db = fakeDb([]);
+ await checkpointReads.getCheckpointAtHeight.call(db, 42);
+
+ let [sql, params] = db.doQuery.firstCall.args;
+ assert.ok(/WHERE block_index=\?/.test(sql));
+ assert.ok(/ORDER BY checkpoint_seq DESC LIMIT 1/.test(sql));
+ assert.deepStrictEqual(params, [42]);
+ });
+
+ it('returns an empty array when no checkpoint exists at that height', async function(){
+ let db = fakeDb([]);
+ let rows = await checkpointReads.getCheckpointAtHeight.call(db, 999);
+ assert.deepStrictEqual(rows, []);
+ });
+ });
+});
+
+module.exports = { PORT, SERVER_KEY, makeClient, registerArmedSourceHooks };
diff --git a/test/integration/balance_rebuild_scoped.test.js b/test/integration/balance_rebuild_scoped.test.js
index 0c3050ec..c92386b2 100644
--- a/test/integration/balance_rebuild_scoped.test.js
+++ b/test/integration/balance_rebuild_scoped.test.js
@@ -7,142 +7,3 @@
// General Public License v3.0 or later; see LICENSE.md. A commercial
// license (without AGPL source-disclosure terms) is available -
// contact legal@dankest.llc.
-
-const assert = require('assert');
-const sinon = require('sinon');
-const setup = require('./helpers/setup');
-const testDb = require('./helpers/testDb');
-const balanceHelpers = require('../../src/db/balance_helpers');
-const ClientApplier = require('../../src/client/applier');
-
-// Behavioural proof for the scoped rebuild optimisation: against a real
-// MariaDB, a rebuild scoped to the ids a batch of new rows touched must
-// leave the aggregate table EXACTLY as a full-table rebuild would, with the same
-// rows, same string amounts, untouched pairs preserved, zeroed pairs
-// pruned, invalid-status custody excluded. The unit suite pins the SQL
-// shape; this pins the arithmetic the database actually performs.
-
-async function snapshotBalances(db) {
- let rows = await db.doQuery(
- 'SELECT address_id, tick_id, amount FROM balances ORDER BY address_id, tick_id');
- return rows.map(r => [Number(r.address_id), Number(r.tick_id), String(r.amount)]);
-}
-
-async function insertLedgerRows(db, table, rows) {
- for (let r of rows) {
- await db.doQuery(
- 'INSERT INTO `' + table + '` (action_index, address_id, tick_id, amount) VALUES (?, ?, ?, ?)',
- [r.action_index, r.address_id, r.tick_id, r.amount]);
- }
-}
-
-async function insertCustodyRows(db, table, rows) {
- for (let r of rows) {
- await db.doQuery(
- 'INSERT INTO `' + table + '` (action_index, contract_index, source_id, tick_id, amount, status_id, block_index) VALUES (?, ?, ?, ?, ?, ?, ?)',
- [r.action_index, r.contract_index, r.source_id || 1, r.tick_id, r.amount, r.status_id, r.block_index || 1]);
- }
-}
-
-describe('Integration: scoped balance rebuilds', function() {
-
- let db;
-
- before(async function() {
- await setup.globalSetup();
- db = setup.getReplicaDb();
- sinon.stub(console, 'log');
- sinon.stub(console, 'error');
- });
-
- after(async function() {
- sinon.restore();
- await setup.globalTeardown();
- });
-
- beforeEach(async function() {
- await testDb.truncateAll(db);
- });
-
- describe('balances', function() {
-
- // History across addresses 1-5 / ticks 1-3, with big-number amounts.
- // Pair (5,3) nets to exactly zero in history (must never appear).
- const history = {
- credits: [
- { action_index: 1, address_id: 1, tick_id: 1, amount: '1000' },
- { action_index: 2, address_id: 2, tick_id: 1, amount: '250' },
- { action_index: 3, address_id: 2, tick_id: 2, amount: '99999999999999999999999999999999999999' },
- { action_index: 4, address_id: 3, tick_id: 2, amount: '7' },
- { action_index: 5, address_id: 4, tick_id: 3, amount: '12345678901234567890' },
- { action_index: 6, address_id: 5, tick_id: 3, amount: '500' }
- ],
- debits: [
- { action_index: 7, address_id: 1, tick_id: 1, amount: '400' },
- { action_index: 8, address_id: 2, tick_id: 1, amount: '50' },
- { action_index: 9, address_id: 5, tick_id: 3, amount: '500' }
- ]
- };
-
- // New rows touch addresses {2,3} × ticks {1,2}; they drive pair (2,1)
- // to exactly zero, so the scoped rebuild must also PRUNE it. The scope
- // rectangle includes pair (3,1) (no rows at all) and pair (2,2)
- // (history only, untouched by the new rows); both must come out
- // exactly as the full rebuild leaves them.
- const newCredits = [
- { action_index: 10, address_id: 3, tick_id: 2, amount: '13' }
- ];
- const newDebits = [
- { action_index: 11, address_id: 2, tick_id: 1, amount: '200' }
- ];
- const scope = { addressIds: [2, 3], tickIds: [1, 2] };
-
- it('a scoped rebuild leaves the table exactly as a full rebuild would', async function() {
- await insertLedgerRows(db, 'credits', history.credits);
- await insertLedgerRows(db, 'debits', history.debits);
- await balanceHelpers.rebuildBalances(db);
-
- await insertLedgerRows(db, 'credits', newCredits);
- await insertLedgerRows(db, 'debits', newDebits);
-
- await balanceHelpers.rebuildBalances(db, scope);
- let scoped = await snapshotBalances(db);
-
- await balanceHelpers.rebuildBalances(db);
- let full = await snapshotBalances(db);
-
- assert.deepStrictEqual(scoped, full);
-
- // Spot-checks on the interesting rows so a both-paths-wrong
- // regression can't slip through the equivalence assertion.
- assert.ok(!scoped.some(r => r[0] === 2 && r[1] === 1), 'zeroed pair (2,1) pruned');
- assert.ok(scoped.some(r => r[0] === 1 && r[1] === 1 && r[2] === '600'), 'untouched pair (1,1) intact');
- assert.ok(scoped.some(r => r[0] === 2 && r[1] === 2 && r[2] === '99999999999999999999999999999999999999'),
- 'big-number amount survives the scoped recompute byte-for-byte');
- assert.ok(scoped.some(r => r[0] === 3 && r[1] === 2 && r[2] === '20'), 'touched pair (3,2) recomputed');
- });
-
- it('ClientApplier.applyBlock drives the scoped rebuild end-to-end', async function() {
- await insertLedgerRows(db, 'credits', history.credits);
- await insertLedgerRows(db, 'debits', history.debits);
- await balanceHelpers.rebuildBalances(db);
-
- db.dbType = 'indexer';
- let applier = new ClientApplier(db, testDb.util);
- let spy = sinon.spy(balanceHelpers, 'rebuildBalances');
- try {
- await applier.applyBlock({ block_index: 999, data: { credits: newCredits, debits: newDebits } });
- assert.deepStrictEqual(spy.firstCall.args[1], { addressIds: [3, 2], tickIds: [2, 1] },
- 'applier passed a scope (not a full rebuild)');
- } finally {
- spy.restore();
- delete db.dbType;
- }
-
- let applied = await snapshotBalances(db);
- await balanceHelpers.rebuildBalances(db);
- assert.deepStrictEqual(applied, await snapshotBalances(db));
- });
- });
-
-});
diff --git a/test/integration/balance_rebuild_scoped.test/01_full_rebuild.test.js b/test/integration/balance_rebuild_scoped.test/01_full_rebuild.test.js
new file mode 100644
index 00000000..b3518138
--- /dev/null
+++ b/test/integration/balance_rebuild_scoped.test/01_full_rebuild.test.js
@@ -0,0 +1,40 @@
+// 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.
+
+// Covers scoped and full rebuild parity. One part of balance_rebuild_scoped.test.js.
+const assert = require('assert');
+const balanceHelpers = require('../../../src/db/balance_helpers');
+const suite = require('./helpers/balance_rebuild_scoped_suite');
+
+describe('Integration: scoped balance rebuilds', function() {
+ suite.installHooks();
+ describe('balances', function() {
+ it('a scoped rebuild leaves the table exactly as a full rebuild would', async function() {
+ await suite.insertLedgerRows(suite.db, 'credits', suite.history.credits);
+ await suite.insertLedgerRows(suite.db, 'debits', suite.history.debits);
+ await balanceHelpers.rebuildBalances(suite.db);
+ await suite.insertLedgerRows(suite.db, 'credits', suite.newCredits);
+ await suite.insertLedgerRows(suite.db, 'debits', suite.newDebits);
+ await balanceHelpers.rebuildBalances(suite.db, suite.scope);
+ let scoped = await suite.snapshotBalances(suite.db);
+ await balanceHelpers.rebuildBalances(suite.db);
+ let full = await suite.snapshotBalances(suite.db);
+ assert.deepStrictEqual(scoped, full);
+
+ // Spot-checks on the interesting rows so a both-paths-wrong
+ // regression can't slip through the equivalence assertion.
+ assert.ok(!scoped.some(r => r[0] === 2 && r[1] === 1), 'zeroed pair (2,1) pruned');
+ assert.ok(scoped.some(r => r[0] === 1 && r[1] === 1 && r[2] === '600'), 'untouched pair (1,1) intact');
+ assert.ok(scoped.some(r => r[0] === 2 && r[1] === 2 && r[2] === '99999999999999999999999999999999999999'),
+ 'big-number amount survives the scoped recompute byte-for-byte');
+ assert.ok(scoped.some(r => r[0] === 3 && r[1] === 2 && r[2] === '20'), 'touched pair (3,2) recomputed');
+ });
+ });
+});
diff --git a/test/integration/balance_rebuild_scoped.test/02_client_applier.test.js b/test/integration/balance_rebuild_scoped.test/02_client_applier.test.js
new file mode 100644
index 00000000..7b31de76
--- /dev/null
+++ b/test/integration/balance_rebuild_scoped.test/02_client_applier.test.js
@@ -0,0 +1,41 @@
+// 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.
+
+// Covers the client applier rebuild path. One part of balance_rebuild_scoped.test.js.
+const assert = require('assert');
+const sinon = require('sinon');
+const balanceHelpers = require('../../../src/db/balance_helpers');
+const ClientApplier = require('../../../src/client/applier');
+const suite = require('./helpers/balance_rebuild_scoped_suite');
+
+describe('Integration: scoped balance rebuilds', function() {
+ suite.installHooks();
+ describe('balances', function() {
+ it('ClientApplier.applyBlock drives the scoped rebuild end-to-end', async function() {
+ await suite.insertLedgerRows(suite.db, 'credits', suite.history.credits);
+ await suite.insertLedgerRows(suite.db, 'debits', suite.history.debits);
+ await balanceHelpers.rebuildBalances(suite.db);
+ suite.db.dbType = 'indexer';
+ let applier = new ClientApplier(suite.db, suite.testDb.util);
+ let spy = sinon.spy(balanceHelpers, 'rebuildBalances');
+ try {
+ await applier.applyBlock({ block_index: 999, data: { credits: suite.newCredits, debits: suite.newDebits } });
+ assert.deepStrictEqual(spy.firstCall.args[1], { addressIds: [3, 2], tickIds: [2, 1] },
+ 'applier passed a scope (not a full rebuild)');
+ } finally {
+ spy.restore();
+ delete suite.db.dbType;
+ }
+ let applied = await suite.snapshotBalances(suite.db);
+ await balanceHelpers.rebuildBalances(suite.db);
+ assert.deepStrictEqual(applied, await suite.snapshotBalances(suite.db));
+ });
+ });
+});
diff --git a/test/integration/balance_rebuild_scoped.test/helpers/balance_rebuild_scoped_suite.js b/test/integration/balance_rebuild_scoped.test/helpers/balance_rebuild_scoped_suite.js
new file mode 100644
index 00000000..01b17db8
--- /dev/null
+++ b/test/integration/balance_rebuild_scoped.test/helpers/balance_rebuild_scoped_suite.js
@@ -0,0 +1,105 @@
+// 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.
+
+// Provides scoped balance fixtures and hooks. One part of balance_rebuild_scoped.test.js.
+const sinon = require('sinon');
+const setup = require('../../helpers/setup');
+const testDb = require('../../helpers/testDb');
+
+let db;
+
+// Behavioural proof for the scoped rebuild optimisation: against a real
+// MariaDB, a rebuild scoped to the ids a batch of new rows touched must
+// leave the aggregate table EXACTLY as a full-table rebuild would, with the same
+// rows, same string amounts, untouched pairs preserved, zeroed pairs
+// pruned, invalid-status custody excluded. The unit suite pins the SQL
+// shape; this pins the arithmetic the database actually performs.
+
+async function snapshotBalances(db) {
+ let rows = await db.doQuery(
+ 'SELECT address_id, tick_id, amount FROM balances ORDER BY address_id, tick_id');
+ return rows.map(r => [Number(r.address_id), Number(r.tick_id), String(r.amount)]);
+}
+
+async function insertLedgerRows(db, table, rows) {
+ for (let r of rows) {
+ await db.doQuery(
+ 'INSERT INTO `' + table + '` (action_index, address_id, tick_id, amount) VALUES (?, ?, ?, ?)',
+ [r.action_index, r.address_id, r.tick_id, r.amount]);
+ }
+}
+
+async function insertCustodyRows(db, table, rows) {
+ for (let r of rows) {
+ await db.doQuery(
+ 'INSERT INTO `' + table + '` (action_index, contract_index, source_id, tick_id, amount, status_id, block_index) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ [r.action_index, r.contract_index, r.source_id || 1, r.tick_id, r.amount, r.status_id, r.block_index || 1]);
+ }
+}
+
+// History across addresses 1-5 / ticks 1-3, with big-number amounts.
+// Pair (5,3) nets to exactly zero in history (must never appear).
+const history = {
+ credits: [
+ { action_index: 1, address_id: 1, tick_id: 1, amount: '1000' },
+ { action_index: 2, address_id: 2, tick_id: 1, amount: '250' },
+ { action_index: 3, address_id: 2, tick_id: 2, amount: '99999999999999999999999999999999999999' },
+ { action_index: 4, address_id: 3, tick_id: 2, amount: '7' },
+ { action_index: 5, address_id: 4, tick_id: 3, amount: '12345678901234567890' },
+ { action_index: 6, address_id: 5, tick_id: 3, amount: '500' }
+ ],
+ debits: [
+ { action_index: 7, address_id: 1, tick_id: 1, amount: '400' },
+ { action_index: 8, address_id: 2, tick_id: 1, amount: '50' },
+ { action_index: 9, address_id: 5, tick_id: 3, amount: '500' }
+ ]
+};
+
+// New rows touch addresses {2,3} × ticks {1,2}; they drive pair (2,1)
+// to exactly zero, so the scoped rebuild must also PRUNE it. The scope
+// rectangle includes pair (3,1) (no rows at all) and pair (2,2)
+// (history only, untouched by the new rows); both must come out
+// exactly as the full rebuild leaves them.
+const newCredits = [
+ { action_index: 10, address_id: 3, tick_id: 2, amount: '13' }
+];
+const newDebits = [
+ { action_index: 11, address_id: 2, tick_id: 1, amount: '200' }
+];
+const scope = { addressIds: [2, 3], tickIds: [1, 2] };
+
+function installHooks() {
+ before(async function() {
+ await setup.globalSetup();
+ db = setup.getReplicaDb();
+ sinon.stub(console, 'log');
+ sinon.stub(console, 'error');
+ });
+ after(async function() {
+ sinon.restore();
+ await setup.globalTeardown();
+ });
+ beforeEach(async function() {
+ await testDb.truncateAll(db);
+ });
+}
+
+module.exports = {
+ get db() { return db; },
+ history,
+ newCredits,
+ newDebits,
+ scope,
+ testDb,
+ installHooks,
+ snapshotBalances,
+ insertLedgerRows,
+ insertCustodyRows
+};
diff --git a/test/integration/binary_replication.test.js b/test/integration/binary_replication.test.js
index b05d8ec7..62f67c9b 100644
--- a/test/integration/binary_replication.test.js
+++ b/test/integration/binary_replication.test.js
@@ -9,11 +9,8 @@
// contact legal@dankest.llc.
const assert = require('assert');
-const zlib = require('zlib');
-const { PassThrough } = require('stream');
const WebSocket = require('ws');
-const setup = require('./helpers/setup');
const testDb = require('./helpers/testDb');
const fixtures = require('./helpers/fixtures');
@@ -21,84 +18,38 @@ const SnapshotBuilder = require('../../src/server/snapshot_builder');
const ClientApplier = require('../../src/client/applier');
const BlockBroadcaster = require('../../src/server/block_broadcaster');
const { BINARY_TAG } = require('../../src/util/wire_codec');
-
-// Binary payload chosen to break a naive toString() round-trip: contains a NUL,
-// 0xFF, and bytes that are not valid UTF-8.
-const RAW_HEX = '00ff8042deadbeef13fe7f';
-const RAW_BUF = Buffer.from(RAW_HEX, 'hex');
-
-// gated_files (indexer, MEDIUMBLOB raw_data) is the live-observed corruption
-// site. Its raw_data exercises the exact same encode/decode path as the
-// decoder's transactions.raw_data, so covering it here covers both columns.
-async function seedGatedFile(db, actionIndex){
- await db.doQuery(
- "INSERT INTO gated_files (action_index, gate_ticker, encryption_method, key_hash, status_id, raw_data) " +
- "VALUES (?, ?, ?, ?, ?, UNHEX(?))",
- [actionIndex, 'GATETOK', 1, 'ab'.repeat(32), null, RAW_HEX]
- );
-}
-
-async function readRawData(db, actionIndex){
- let rows = await db.doQuery("SELECT raw_data FROM gated_files WHERE action_index = ?", [actionIndex]);
- return rows.length > 0 ? rows[0].raw_data : null;
-}
-
-// Capture a full snapshot stream into a parsed JSON object (gunzip the gzip body
-// SnapshotBuilder pipes to its res).
-async function captureFullSnapshot(builder, db){
- let chunks = [];
- let res = new PassThrough();
- res.setHeader = () => {};
- res.status = () => ({ json: () => {} });
- res.on('data', c => chunks.push(c));
- let ended = new Promise(resolve => res.on('end', resolve));
- await builder.streamFullSnapshot(db, res);
- await ended;
- let body = zlib.gunzipSync(Buffer.concat(chunks)).toString('utf8');
- return { json: JSON.parse(body), body };
-}
+const {
+ RAW_HEX, RAW_BUF, seedGatedFile, readRawData, captureFullSnapshot,
+ makeBroadcastEvent, registerBinaryReplicationHooks
+} = require('./binary_replication.test/helpers/binary_replication_suite');
describe('Integration: binary column replication (F2)', function(){
- let sourceDb, replicaDb;
-
- before(async function(){
- await setup.globalSetup();
- });
-
- after(async function(){
- await setup.globalTeardown();
- });
-
- beforeEach(async function(){
- await setup.resetDatabases();
- sourceDb = setup.getSourceDb();
- replicaDb = setup.getReplicaDb();
- });
+ const suite = registerBinaryReplicationHooks();
it('source stores true binary (sanity check: seed not corrupted by the helper)', async function(){
- await fixtures.seedBlocks(sourceDb, 1, 1);
- await seedGatedFile(sourceDb, 100);
- let src = await readRawData(sourceDb, 100);
+ await fixtures.seedBlocks(suite.sourceDb, 1, 1);
+ await seedGatedFile(suite.sourceDb, 100);
+ let src = await readRawData(suite.sourceDb, 100);
assert.ok(Buffer.isBuffer(src), 'source raw_data should be a Buffer');
assert.ok(src.equals(RAW_BUF), 'source bytes must match the seed');
});
it('full snapshot round-trips a blob byte-for-byte', async function(){
- await fixtures.seedBlocks(sourceDb, 1, 1);
- await seedGatedFile(sourceDb, 100);
+ await fixtures.seedBlocks(suite.sourceDb, 1, 1);
+ await seedGatedFile(suite.sourceDb, 100);
let builder = new SnapshotBuilder(testDb.util);
- let { json, body } = await captureFullSnapshot(builder, sourceDb);
+ let { json, body } = await captureFullSnapshot(builder, suite.sourceDb);
// Wire format: base64 sentinel, NOT the mangled {"type":"Buffer"} shape.
assert.ok(body.includes(BINARY_TAG), 'wire must carry the binary sentinel');
assert.ok(!body.includes('"type":"Buffer"'), 'wire must not contain a mangled Buffer object');
- let applier = new ClientApplier(replicaDb, testDb.util);
+ let applier = new ClientApplier(suite.replicaDb, testDb.util);
await applier.applyFullSnapshot(json);
- let dst = await readRawData(replicaDb, 100);
+ let dst = await readRawData(suite.replicaDb, 100);
assert.ok(Buffer.isBuffer(dst), 'replica raw_data should be a Buffer');
assert.ok(dst.equals(RAW_BUF), 'replica bytes must equal source bytes');
});
@@ -106,16 +57,7 @@ describe('Integration: binary column replication (F2)', function(){
it('live block broadcast round-trips a blob byte-for-byte', async function(){
// Build the live block payload exactly as ServerPoller would, then push it
// through the real BlockBroadcaster to capture the serialized wire message.
- let event = {
- type: 'block', chain: 'bitcoin', network: 'mainnet', dbType: 'indexer',
- block_index: 1, block_time: 100,
- data: {
- gated_files: [
- { action_index: 200, gate_ticker: 'GATETOK', encryption_method: 1,
- key_hash: 'cd'.repeat(32), status_id: null, raw_data: Buffer.from(RAW_HEX, 'hex') }
- ]
- }
- };
+ let event = makeBroadcastEvent();
let captured = [];
let broadcaster = new BlockBroadcaster({ WS_MAX_PER_IP: 100, WS_BACKPRESSURE_LIMIT: 1000, TRUST_PROXY: false });
@@ -129,10 +71,10 @@ describe('Integration: binary column replication (F2)', function(){
assert.ok(!captured[0].includes('"type":"Buffer"'), 'wire must not contain a mangled Buffer object');
let payload = JSON.parse(captured[0]);
- let applier = new ClientApplier(replicaDb, testDb.util);
+ let applier = new ClientApplier(suite.replicaDb, testDb.util);
await applier.applyBlock(payload);
- let dst = await readRawData(replicaDb, 200);
+ let dst = await readRawData(suite.replicaDb, 200);
assert.ok(Buffer.isBuffer(dst), 'replica raw_data should be a Buffer');
assert.ok(dst.equals(RAW_BUF), 'replica bytes must equal source bytes');
});
diff --git a/test/integration/binary_replication.test/helpers/binary_replication_suite.js b/test/integration/binary_replication.test/helpers/binary_replication_suite.js
new file mode 100644
index 00000000..00c1c365
--- /dev/null
+++ b/test/integration/binary_replication.test/helpers/binary_replication_suite.js
@@ -0,0 +1,84 @@
+// 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.
+
+// Covers shared binary replication fixtures. One part of binary_replication.test.js.
+const zlib = require('zlib');
+const { PassThrough } = require('stream');
+const setup = require('../../helpers/setup');
+
+// Binary payload chosen to break a naive toString() round-trip: contains a NUL,
+// 0xFF, and bytes that are not valid UTF-8.
+const RAW_HEX = '00ff8042deadbeef13fe7f';
+const RAW_BUF = Buffer.from(RAW_HEX, 'hex');
+
+// gated_files (indexer, MEDIUMBLOB raw_data) is the live-observed corruption
+// site. Its raw_data exercises the exact same encode/decode path as the
+// decoder's transactions.raw_data, so covering it here covers both columns.
+async function seedGatedFile(db, actionIndex){
+ await db.doQuery(
+ "INSERT INTO gated_files (action_index, gate_ticker, encryption_method, key_hash, status_id, raw_data) " +
+ "VALUES (?, ?, ?, ?, ?, UNHEX(?))",
+ [actionIndex, 'GATETOK', 1, 'ab'.repeat(32), null, RAW_HEX]
+ );
+}
+
+async function readRawData(db, actionIndex){
+ let rows = await db.doQuery("SELECT raw_data FROM gated_files WHERE action_index = ?", [actionIndex]);
+ return rows.length > 0 ? rows[0].raw_data : null;
+}
+
+// Capture a full snapshot stream into a parsed JSON object (gunzip the gzip body
+// SnapshotBuilder pipes to its res).
+async function captureFullSnapshot(builder, db){
+ let chunks = [];
+ let res = new PassThrough();
+ res.setHeader = () => {};
+ res.status = () => ({ json: () => {} });
+ res.on('data', c => chunks.push(c));
+ let ended = new Promise(resolve => res.on('end', resolve));
+ await builder.streamFullSnapshot(db, res);
+ await ended;
+ let body = zlib.gunzipSync(Buffer.concat(chunks)).toString('utf8');
+ return { json: JSON.parse(body), body };
+}
+
+function makeBroadcastEvent(){
+ return {
+ type: 'block', chain: 'bitcoin', network: 'mainnet', dbType: 'indexer',
+ block_index: 1, block_time: 100,
+ data: {
+ gated_files: [
+ { action_index: 200, gate_ticker: 'GATETOK', encryption_method: 1,
+ key_hash: 'cd'.repeat(32), status_id: null, raw_data: Buffer.from(RAW_HEX, 'hex') }
+ ]
+ }
+ };
+}
+
+function registerBinaryReplicationHooks(){
+ const suite = {};
+ before(async function(){
+ await setup.globalSetup();
+ });
+ after(async function(){
+ await setup.globalTeardown();
+ });
+ beforeEach(async function(){
+ await setup.resetDatabases();
+ suite.sourceDb = setup.getSourceDb();
+ suite.replicaDb = setup.getReplicaDb();
+ });
+ return suite;
+}
+
+module.exports = {
+ RAW_HEX, RAW_BUF, seedGatedFile, readRawData, captureFullSnapshot,
+ makeBroadcastEvent, registerBinaryReplicationHooks
+};
diff --git a/test/integration/emissions_parity.test.js b/test/integration/emissions_parity.test.js
index c948ef97..91ed22a1 100644
--- a/test/integration/emissions_parity.test.js
+++ b/test/integration/emissions_parity.test.js
@@ -34,6 +34,7 @@ const assert = require('assert');
const setup = require('./helpers/setup');
const testDb = require('./helpers/testDb');
const fixtures = require('./helpers/fixtures');
+const { assertStreamedEmissions } = require('./emissions_parity.test/helpers/emissions_parity_suite');
describe('Integration: contract_emissions reorg-safe streaming (emissions fix) @regression', function () {
this.timeout(30000);
@@ -83,15 +84,8 @@ describe('Integration: contract_emissions reorg-safe streaming (emissions fix) @
"INSERT INTO contract_emissions (execution_index, emitted_action, action_index, position) VALUES (?, ?, ?, ?)",
[execAction, 'SLASH', null, 1]);
- // The fix: execution_index-scoped stream returns BOTH, ORDER BY execution_index, position.
const streamed = await sourceDb.getEmissionRowsForBlock(B);
- assert.strictEqual(streamed.length, 2,
- 'getEmissionRowsForBlock must include the NULL-action_index SLASH row the hash counts');
- assert.deepStrictEqual(streamed.map(r => r.emitted_action), ['ORDER', 'SLASH'],
- 'rows ordered by execution_index then position');
- const slash = streamed.find(r => r.emitted_action === 'SLASH');
- assert.ok(slash, 'SLASH emission present in the streamed set');
- assert.strictEqual(slash.action_index, null, 'SLASH emission carries NULL action_index');
+ assertStreamedEmissions(streamed);
// The bug it fixes: the generic action-scoped INNER JOIN silently drops the NULL row,
// so a follower fed by this path would recompute a divergent contract_hash and halt.
diff --git a/test/integration/emissions_parity.test/helpers/emissions_parity_suite.js b/test/integration/emissions_parity.test/helpers/emissions_parity_suite.js
new file mode 100644
index 00000000..469d7953
--- /dev/null
+++ b/test/integration/emissions_parity.test/helpers/emissions_parity_suite.js
@@ -0,0 +1,27 @@
+/*********************************************************************
+ *
+ * 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.
+ *
+ ********************************************************************/
+
+// Covers streamed emission assertions. One part of emissions_parity.test.js.
+const assert = require('assert');
+
+function assertStreamedEmissions(streamed){
+ // The fix: execution_index-scoped stream returns BOTH, ORDER BY execution_index, position.
+ assert.strictEqual(streamed.length, 2,
+ 'getEmissionRowsForBlock must include the NULL-action_index SLASH row the hash counts');
+ assert.deepStrictEqual(streamed.map(r => r.emitted_action), ['ORDER', 'SLASH'],
+ 'rows ordered by execution_index then position');
+ const slash = streamed.find(r => r.emitted_action === 'SLASH');
+ assert.ok(slash, 'SLASH emission present in the streamed set');
+ assert.strictEqual(slash.action_index, null, 'SLASH emission carries NULL action_index');
+}
+
+module.exports = { assertStreamedEmissions };
diff --git a/test/integration/helpers/testDb.js b/test/integration/helpers/testDb.js
index a17bc448..858f196c 100644
--- a/test/integration/helpers/testDb.js
+++ b/test/integration/helpers/testDb.js
@@ -268,8 +268,8 @@ class TestDatabase {
// wrapper already provides.
const RealDatabase = require('../../../src/db');
for (const method of ['getStateRootsRow', 'getBlockLeafRows',
- 'getStakeWeightsByCapability', '_stakeWeightsSql',
- 'applyStakeWeightCap', '_cappedStakeWeightsSql',
+ 'getStakeWeightsByCapability', 'stakeWeightsSql',
+ 'applyStakeWeightCap', 'cappedStakeWeightsSql',
// getBlockScopedRows keys by lifecycle.blockKey(table), never
// a fixed block_index (rollcalls scope by close_block).
'getBlockScopedRows'])
diff --git a/test/integration/index_map_parity_http.test.js b/test/integration/index_map_parity_http.test.js
index ad715a7d..0653fcda 100644
--- a/test/integration/index_map_parity_http.test.js
+++ b/test/integration/index_map_parity_http.test.js
@@ -22,160 +22,51 @@
// 100% shipped code.
const assert = require('assert');
-const sinon = require('sinon');
-const http = require('http');
-const express = require('express');
-const cors = require('cors');
-const { parseCorsOrigin } = require('../../src/http/cors_origin');
-const setup = require('./helpers/setup');
-const testDb = require('./helpers/testDb');
-const fixtures = require('./helpers/fixtures');
-const Database = require('../../src/db');
-const BlockHasher = require('../../src/client/block_hasher');
-const HashVerifier = require('../../src/client/hash_verifier');
-const ClientApplier= require('../../src/client/applier');
-const ClientRollback = require('../../src/client/rollback');
-const ClientSync = require('../../src/client/sync');
-const { getReplicatedTables } = require('../../src/schema/replicated_tables');
-
-const PORT = 19733;
-const H = 3; // tip block both sides are seeded to
-const CHAIN = 'bitcoin', NETWORK = 'mainnet';
-
-// Deterministic in-block id->address subset (block_index set) shared by both DBs.
-// High explicit ids avoid colliding with the AUTO_INCREMENT ids seedBlocks uses.
-const STAMPED = [
- { id: 1001, address: 'bc1qstamped0alice000000000000000000000aaa', block_index: 1 },
- { id: 1002, address: 'bc1qstamped0bob00000000000000000000000bbb', block_index: 2 },
- { id: 1003, address: 'bc1qstamped0carol000000000000000000000ccc', block_index: 3 },
-];
-
-async function stamp(db, rows){
- await db.doQuery("DELETE FROM index_addresses WHERE block_index IS NOT NULL");
- for(let r of rows)
- await db.doQuery("INSERT INTO index_addresses (`id`,`address`,`block_index`) VALUES (?,?,?)",
- [r.id, r.address, r.block_index]);
-}
-
-// Faithful mirror of api.js buildStatusRow (server mode) for the fields the client
-// reads. Uses the SHIPPED BlockHasher.computeIndexMapChecksum for the checksum.
-function makeStatusHandler(sourceDb, util, cfg){
- return async function(req, res){
- try {
- let hashRow = await sourceDb.getBlockHashRow(H);
- let row = {
- block_height: H,
- source_height: H,
- lag_blocks: 0,
- ledger_hash: hashRow ? hashRow.ledger_hash : null,
- actions_hash: hashRow ? hashRow.actions_hash : null,
- contract_hash: hashRow ? hashRow.contract_hash : null,
- };
- row.table_counts = {};
- for(let t of getReplicatedTables('indexer')){
- try { row.table_counts[t] = await sourceDb.getTableCount(t); } catch(e){}
- }
- row.index_map_checksum = null;
- if(cfg['INDEX_MAP_PARITY_CHECK'])
- row.index_map_checksum = await new BlockHasher(sourceDb, util).computeIndexMapChecksum(H);
- res.json(row);
- } catch(e){ res.status(500).json({ error: e.message }); }
- };
-}
+const {
+ PORT, H, STAMPED, stamp, registerIndexMapParityHttpHooks
+} = require('./index_map_parity_http.test/helpers/index_map_parity_http_suite');
describe('Integration: index-map parity over HTTP (e2e)', function() {
this.timeout(180000);
- let sourceDb, replicaDb, realReplica, server, util, clientSync, warnSpy;
- const SERVER_CFG = { INDEX_MAP_PARITY_CHECK: true };
const countKey = 'index_map_mismatch_count:indexer';
const lastKey = 'index_map_mismatch_last_block:indexer';
-
- before(async function() {
- await setup.globalSetup();
- sourceDb = setup.getSourceDb();
- replicaDb = setup.getReplicaDb();
- util = testDb.util;
-
- // Identical, deterministic block chain on both DBs so getBlockHashRow(H)
- // and the three committed hashes match (no noisy cross-source error).
- await fixtures.seedBlocks(sourceDb, 1, H);
- await fixtures.seedBlocks(replicaDb, 1, H);
- await stamp(sourceDb, STAMPED);
- await stamp(replicaDb, STAMPED);
-
- // Real src/db.js Database for the replica: ClientSync needs getBlockHashRow,
- // getTableCount, doQuery AND getSyncState/setSyncState (the TestDatabase
- // wrapper lacks the sync_state methods the counter uses).
- realReplica = new Database(testDb.TEST_DB_HOST, testDb.TEST_DB_PORT,
- testDb.REPLICA_DB_NAME, testDb.TEST_DB_USER, testDb.TEST_DB_PASS, util, 'indexer');
-
- // Real HTTP server publishing the shipped checksum on /status.
- let app = express();
- app.use(cors({ origin: parseCorsOrigin(process.env.CORS_ORIGIN), methods: ['GET'] }));
- app.get('/status/:dbType/:chain/:network', makeStatusHandler(sourceDb, util, SERVER_CFG));
- await new Promise(r => { server = http.createServer(app).listen(PORT, r); });
-
- let applier = new ClientApplier(realReplica, util, CHAIN, NETWORK);
- let rollback = new ClientRollback(realReplica, util, CHAIN, 'regtest');
- let cfg = {
- SYNC_SOURCES: 'http://127.0.0.1:' + PORT,
- INDEX_MAP_PARITY_CHECK: true,
- VERIFY_HASHES: true,
- VERIFY_RECOMPUTE: false, // skip the consensus-recompute halt path
- VERIFY_STATE_HASH: false,
- VERIFY_STATE_COMMITMENT: false,
- SYNC_BOOTSTRAP_DEPTH: {}
- };
- clientSync = new ClientSync(CHAIN, NETWORK, realReplica, applier, rollback, new HashVerifier(), cfg, util);
- });
-
- after(async function() {
- if(server) await new Promise(r => server.close(r));
- if(realReplica && realReplica.close){ try { await realReplica.close(); } catch(e){} }
- await setup.globalTeardown();
- });
-
- beforeEach(function(){ warnSpy = sinon.spy(console, 'warn'); });
- afterEach(function(){ warnSpy.restore(); });
-
- const sawParityWarn = () => warnSpy.getCalls().some(c =>
- String(c.args[0] || '').includes('INDEX_MAP_PARITY mismatch'));
+ const suite = registerIndexMapParityHttpHooks();
it('faithful replica: /status checksum matches, no mismatch, no counter', async function() {
- await stamp(replicaDb, STAMPED); // ensure faithful
- await clientSync.verifyAgainstSource('http://127.0.0.1:' + PORT, H);
- assert.strictEqual(sawParityWarn(), false, 'no parity warning on a faithful replica');
- let c = await realReplica.getSyncState(countKey);
+ await stamp(suite.replicaDb, STAMPED); // ensure faithful
+ await suite.clientSync.verifyAgainstSource('http://127.0.0.1:' + PORT, H);
+ assert.strictEqual(suite.sawParityWarn(), false, 'no parity warning on a faithful replica');
+ let c = await suite.realReplica.getSyncState(countKey);
assert.strictEqual(c, null, 'counter unset when everything agrees');
});
it('divergence: equal-count swapped identity fires advisory mismatch, no halt', async function() {
// Replica id=1002 locally points elsewhere; SAME row count, different content.
- await realReplica.doQuery("UPDATE index_addresses SET address=? WHERE id=1002",
+ await suite.realReplica.doQuery("UPDATE index_addresses SET address=? WHERE id=1002",
['bc1qstamped0MALLORY00000000000000000mmmmmmm']);
- await clientSync.verifyAgainstSource('http://127.0.0.1:' + PORT, H);
+ await suite.clientSync.verifyAgainstSource('http://127.0.0.1:' + PORT, H);
- assert.strictEqual(sawParityWarn(), true, 'parity mismatch must be logged');
- assert.strictEqual(clientSync.isHalted(), false, 'advisory: client must NOT halt');
- let c = await realReplica.getSyncState(countKey);
- let lb = await realReplica.getSyncState(lastKey);
+ assert.strictEqual(suite.sawParityWarn(), true, 'parity mismatch must be logged');
+ assert.strictEqual(suite.clientSync.isHalted(), false, 'advisory: client must NOT halt');
+ let c = await suite.realReplica.getSyncState(countKey);
+ let lb = await suite.realReplica.getSyncState(lastKey);
assert.strictEqual(c, '1', 'mismatch counter incremented');
assert.strictEqual(lb, String(H), 'last divergent block recorded');
});
it('recovery: faithful again raises no new mismatch (counter steady)', async function() {
- await realReplica.doQuery("UPDATE index_addresses SET address=? WHERE id=1002",
+ await suite.realReplica.doQuery("UPDATE index_addresses SET address=? WHERE id=1002",
[STAMPED[1].address]); // restore
// Add a benign NULL-block row the source lacks (API read-path seed): excluded.
- await realReplica.doQuery("INSERT INTO index_addresses (`id`,`address`,`block_index`) VALUES (?,?,NULL)",
+ await suite.realReplica.doQuery("INSERT INTO index_addresses (`id`,`address`,`block_index`) VALUES (?,?,NULL)",
[2001, 'bc1qapiseed00000000000000000000000000seed1']);
- await clientSync.verifyAgainstSource('http://127.0.0.1:' + PORT, H);
+ await suite.clientSync.verifyAgainstSource('http://127.0.0.1:' + PORT, H);
- assert.strictEqual(sawParityWarn(), false, 'no false alarm once faithful (NULL-block excluded)');
- let c = await realReplica.getSyncState(countKey);
+ assert.strictEqual(suite.sawParityWarn(), false, 'no false alarm once faithful (NULL-block excluded)');
+ let c = await suite.realReplica.getSyncState(countKey);
assert.strictEqual(c, '1', 'counter unchanged from the single real divergence');
});
});
diff --git a/test/integration/index_map_parity_http.test/helpers/index_map_parity_http_suite.js b/test/integration/index_map_parity_http.test/helpers/index_map_parity_http_suite.js
new file mode 100644
index 00000000..737f1e55
--- /dev/null
+++ b/test/integration/index_map_parity_http.test/helpers/index_map_parity_http_suite.js
@@ -0,0 +1,131 @@
+// 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.
+
+// Covers shared HTTP parity setup. One part of index_map_parity_http.test.js.
+const sinon = require('sinon');
+const http = require('http');
+const express = require('express');
+const cors = require('cors');
+const { parseCorsOrigin } = require('../../../../src/http/cors_origin');
+const setup = require('../../helpers/setup');
+const testDb = require('../../helpers/testDb');
+const fixtures = require('../../helpers/fixtures');
+const Database = require('../../../../src/db');
+const BlockHasher = require('../../../../src/client/block_hasher');
+const HashVerifier = require('../../../../src/client/hash_verifier');
+const ClientApplier= require('../../../../src/client/applier');
+const ClientRollback = require('../../../../src/client/rollback');
+const ClientSync = require('../../../../src/client/sync');
+const { getReplicatedTables } = require('../../../../src/schema/replicated_tables');
+
+const PORT = 19733;
+const H = 3; // tip block both sides are seeded to
+const CHAIN = 'bitcoin', NETWORK = 'mainnet';
+const SERVER_CFG = { INDEX_MAP_PARITY_CHECK: true };
+
+// Deterministic in-block id->address subset (block_index set) shared by both DBs.
+// High explicit ids avoid colliding with the AUTO_INCREMENT ids seedBlocks uses.
+const STAMPED = [
+ { id: 1001, address: 'bc1qstamped0alice000000000000000000000aaa', block_index: 1 },
+ { id: 1002, address: 'bc1qstamped0bob00000000000000000000000bbb', block_index: 2 },
+ { id: 1003, address: 'bc1qstamped0carol000000000000000000000ccc', block_index: 3 },
+];
+
+async function stamp(db, rows){
+ await db.doQuery("DELETE FROM index_addresses WHERE block_index IS NOT NULL");
+ for(let r of rows)
+ await db.doQuery("INSERT INTO index_addresses (`id`,`address`,`block_index`) VALUES (?,?,?)",
+ [r.id, r.address, r.block_index]);
+}
+
+// Faithful mirror of api.js buildStatusRow (server mode) for the fields the client
+// reads. Uses the SHIPPED BlockHasher.computeIndexMapChecksum for the checksum.
+function makeStatusHandler(sourceDb, util, cfg){
+ return async function(req, res){
+ try {
+ let hashRow = await sourceDb.getBlockHashRow(H);
+ let row = {
+ block_height: H,
+ source_height: H,
+ lag_blocks: 0,
+ ledger_hash: hashRow ? hashRow.ledger_hash : null,
+ actions_hash: hashRow ? hashRow.actions_hash : null,
+ contract_hash: hashRow ? hashRow.contract_hash : null,
+ };
+ row.table_counts = {};
+ for(let t of getReplicatedTables('indexer')){
+ try { row.table_counts[t] = await sourceDb.getTableCount(t); } catch(e){}
+ }
+ row.index_map_checksum = null;
+ if(cfg['INDEX_MAP_PARITY_CHECK'])
+ row.index_map_checksum = await new BlockHasher(sourceDb, util).computeIndexMapChecksum(H);
+ res.json(row);
+ } catch(e){ res.status(500).json({ error: e.message }); }
+ };
+}
+
+function createClientSync(realReplica, util){
+ let applier = new ClientApplier(realReplica, util, CHAIN, NETWORK);
+ let rollback = new ClientRollback(realReplica, util, CHAIN, 'regtest');
+ let cfg = {
+ SYNC_SOURCES: 'http://127.0.0.1:' + PORT,
+ INDEX_MAP_PARITY_CHECK: true,
+ VERIFY_HASHES: true,
+ VERIFY_RECOMPUTE: false, // skip the consensus-recompute halt path
+ VERIFY_STATE_HASH: false,
+ VERIFY_STATE_COMMITMENT: false,
+ SYNC_BOOTSTRAP_DEPTH: {}
+ };
+ return new ClientSync(CHAIN, NETWORK, realReplica, applier, rollback, new HashVerifier(), cfg, util);
+}
+
+function registerIndexMapParityHttpHooks(){
+ const suite = {};
+ before(async function() {
+ await setup.globalSetup();
+ suite.sourceDb = setup.getSourceDb();
+ suite.replicaDb = setup.getReplicaDb();
+ const util = testDb.util;
+
+ // Identical, deterministic block chain on both DBs so getBlockHashRow(H)
+ // and the three committed hashes match (no noisy cross-source error).
+ await fixtures.seedBlocks(suite.sourceDb, 1, H);
+ await fixtures.seedBlocks(suite.replicaDb, 1, H);
+ await stamp(suite.sourceDb, STAMPED);
+ await stamp(suite.replicaDb, STAMPED);
+
+ // Real src/db.js Database for the replica: ClientSync needs getBlockHashRow,
+ // getTableCount, doQuery AND getSyncState/setSyncState (the TestDatabase
+ // wrapper lacks the sync_state methods the counter uses).
+ suite.realReplica = new Database(testDb.TEST_DB_HOST, testDb.TEST_DB_PORT,
+ testDb.REPLICA_DB_NAME, testDb.TEST_DB_USER, testDb.TEST_DB_PASS, util, 'indexer');
+
+ // Real HTTP server publishing the shipped checksum on /status.
+ let app = express();
+ app.use(cors({ origin: parseCorsOrigin(process.env.CORS_ORIGIN), methods: ['GET'] }));
+ app.get('/status/:dbType/:chain/:network', makeStatusHandler(suite.sourceDb, util, SERVER_CFG));
+ await new Promise(r => { suite.server = http.createServer(app).listen(PORT, r); });
+ suite.clientSync = createClientSync(suite.realReplica, util);
+ });
+
+ after(async function() {
+ if(suite.server) await new Promise(r => suite.server.close(r));
+ if(suite.realReplica && suite.realReplica.close){ try { await suite.realReplica.close(); } catch(e){} }
+ await setup.globalTeardown();
+ });
+
+ beforeEach(function(){ suite.warnSpy = sinon.spy(console, 'warn'); });
+ afterEach(function(){ suite.warnSpy.restore(); });
+ suite.sawParityWarn = () => suite.warnSpy.getCalls().some(c =>
+ String(c.args[0] || '').includes('INDEX_MAP_PARITY mismatch'));
+ return suite;
+}
+
+module.exports = { PORT, H, STAMPED, stamp, registerIndexMapParityHttpHooks };
diff --git a/test/integration/server_polling.test.js b/test/integration/server_polling.test.js
index 4cc40d26..c92386b2 100644
--- a/test/integration/server_polling.test.js
+++ b/test/integration/server_polling.test.js
@@ -7,300 +7,3 @@
// General Public License v3.0 or later; see LICENSE.md. A commercial
// license (without AGPL source-disclosure terms) is available -
// contact legal@dankest.llc.
-
-const assert = require('assert');
-const sinon = require('sinon');
-const setup = require('./helpers/setup');
-const testDb = require('./helpers/testDb');
-const fixtures = require('./helpers/fixtures');
-const ServerPoller = require('../../src/server/poller');
-
-describe('Integration: ServerPoller', function() {
-
- let sourceDb, poller, broadcaster, transparencyLog, config;
-
- before(async function() {
- await setup.globalSetup();
- });
-
- after(async function() {
- await setup.globalTeardown();
- });
-
- beforeEach(async function() {
- sourceDb = setup.getSourceDb();
- await testDb.truncateAll(sourceDb);
-
- broadcaster = {
- broadcast: sinon.stub(),
- updateStatus: sinon.stub(),
- getSubscriberCount: sinon.stub().returns(0)
- };
- transparencyLog = {
- recordBlock: sinon.stub().resolves(),
- pruneFrom: sinon.stub().resolves()
- };
- config = { BLOCK_POLL_INTERVAL: 100 };
-
- poller = new ServerPoller('bitcoin', 'mainnet', sourceDb, broadcaster, transparencyLog, config, testDb.util);
- sinon.stub(console, 'log');
- sinon.stub(console, 'error');
- });
-
- afterEach(function() {
- sinon.restore();
- });
-
- describe('poll', function() {
- it('returns early when no blocks in DB', async function() {
- await poller.poll();
- assert.strictEqual(broadcaster.broadcast.called, false);
- });
-
- it('initializes lastPolledBlock on first poll', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 3);
- poller.lastPolledBlock = null;
-
- await poller.poll();
-
- assert.strictEqual(poller.lastPolledBlock, 3);
- assert.strictEqual(broadcaster.broadcast.called, false); // initialization only
- assert.strictEqual(broadcaster.updateStatus.calledOnce, true);
- });
-
- it('detects and processes new blocks', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 1);
- poller.lastPolledBlock = 0;
-
- await poller.poll();
-
- assert.strictEqual(broadcaster.broadcast.calledOnce, true);
- let event = broadcaster.broadcast.firstCall.args[2];
- assert.strictEqual(event.type, 'block');
- assert.strictEqual(event.block_index, 1);
- assert.strictEqual(event.chain, 'bitcoin');
- assert.strictEqual(event.network, 'mainnet');
- assert.ok(event.ledger_hash);
- assert.ok(event.actions_hash);
- assert.ok(event.contract_hash);
- assert.ok(event.data);
- });
-
- it('processes multiple sequential blocks', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 5);
- poller.lastPolledBlock = 0;
-
- await poller.poll();
-
- assert.strictEqual(broadcaster.broadcast.callCount, 5);
- assert.strictEqual(poller.lastPolledBlock, 5);
-
- // Verify block order
- for (let i = 0; i < 5; i++) {
- let event = broadcaster.broadcast.getCall(i).args[2];
- assert.strictEqual(event.block_index, i + 1);
- }
- });
-
- it('records each block in transparency log', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 3);
- poller.lastPolledBlock = 0;
-
- await poller.poll();
-
- assert.strictEqual(transparencyLog.recordBlock.callCount, 3);
- });
-
- it('detects reorg when block count decreases', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 10);
- poller.lastPolledBlock = 10;
-
- // Simulate reorg at source
- await fixtures.deleteBlocksFrom(sourceDb, 8);
-
- await poller.poll();
-
- assert.strictEqual(broadcaster.broadcast.calledOnce, true);
- let event = broadcaster.broadcast.firstCall.args[2];
- assert.strictEqual(event.type, 'reorg');
- assert.strictEqual(event.block_index, 8); // currentBlock(7) + 1
- assert.strictEqual(poller.lastPolledBlock, 7);
- });
-
- it('does nothing when no new blocks', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 5);
- poller.lastPolledBlock = 5;
-
- await poller.poll();
-
- assert.strictEqual(broadcaster.broadcast.called, false);
- assert.strictEqual(transparencyLog.recordBlock.called, false);
- });
- });
-
- describe('buildBlockPayload', function() {
- it('builds payload with correct structure from real DB', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 1);
-
- let payload = await poller.buildBlockPayload(1);
-
- assert.strictEqual(payload.type, 'block');
- assert.strictEqual(payload.chain, 'bitcoin');
- assert.strictEqual(payload.network, 'mainnet');
- assert.strictEqual(payload.block_index, 1);
- assert.ok(payload.block_time > 0);
- assert.ok(payload.ledger_hash);
- assert.ok(payload.data);
- });
-
- it('includes block-scoped table rows', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 1);
- let payload = await poller.buildBlockPayload(1);
-
- assert.ok(payload.data.blocks);
- assert.strictEqual(payload.data.blocks.length, 1);
- });
-
- it('includes transactions', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 1);
- let payload = await poller.buildBlockPayload(1);
-
- assert.ok(payload.data.transactions);
- assert.strictEqual(payload.data.transactions.length, 1);
- });
-
- it('includes action-scoped rows (credits)', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 1);
- let payload = await poller.buildBlockPayload(1);
-
- assert.ok(payload.data.credits);
- assert.strictEqual(payload.data.credits.length, 1);
- assert.strictEqual(payload.data.credits[0].amount, '1000');
- });
-
- it('includes index_transactions referenced by block', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 1);
- let payload = await poller.buildBlockPayload(1);
-
- assert.ok(payload.data.index_transactions);
- assert.ok(payload.data.index_transactions.length >= 3); // ledger, actions, contract hashes
- });
-
- it('includes index_addresses referenced by transactions', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 1);
- let payload = await poller.buildBlockPayload(1);
-
- assert.ok(payload.data.index_addresses);
- assert.ok(payload.data.index_addresses.length >= 1);
- });
-
- it('returns null for non-existent block', async function() {
- let payload = await poller.buildBlockPayload(999);
- assert.strictEqual(payload, null);
- });
- });
-
- // The payload build used to issue one getActionScopedRows per registry table
- // (86 today, growing with every replicated table added); it now asks
- // getNonEmptyActionScopedTables once and fetches only what answers. payload.data
- // feeds a consensus hash followers recompute, so the only acceptable evidence is
- // byte-identity against a REAL database, not a mock: these run the same block
- // through both paths on the same rows and compare the serialized payloads.
- describe('buildBlockPayload action-scoped probe', function() {
- // Same build with the probe removed from the db object, which is the pre-fix
- // query-every-table path verbatim.
- // getNonEmptyActionScopedTables lives on TestDatabase's PROTOTYPE, so it is
- // shadowed with an own `undefined` rather than deleted: a delete removes nothing
- // and the comparison would silently be probe-against-probe, which passes while
- // proving nothing.
- async function buildUnprobed(blockIndex) {
- sourceDb.getNonEmptyActionScopedTables = undefined;
- try {
- assert.strictEqual(typeof sourceDb.getNonEmptyActionScopedTables, 'undefined');
- return await poller.buildBlockPayload(blockIndex);
- } finally {
- delete sourceDb.getNonEmptyActionScopedTables;
- }
- }
-
- it('emits a byte-identical payload on a block that has rows', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 1);
-
- let probed = await poller.buildBlockPayload(1);
- let unprobed = await buildUnprobed(1);
-
- assert.ok(probed.data.credits && probed.data.credits.length === 1,
- 'the block must carry action-scoped rows, or this proves nothing');
- assert.strictEqual(JSON.stringify(probed), JSON.stringify(unprobed));
- });
-
- it('emits a byte-identical payload on a block with no action-scoped rows', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 2);
- await sourceDb.doQuery("DELETE FROM credits");
-
- let probed = await poller.buildBlockPayload(2);
- let unprobed = await buildUnprobed(2);
-
- assert.ok(!probed.data.credits, 'no action-scoped rows in this block');
- assert.strictEqual(JSON.stringify(probed), JSON.stringify(unprobed));
- });
-
- it('cuts the per-table round-trips to the tables that actually have rows', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 1);
- const spy = sinon.spy(sourceDb, 'getActionScopedRows');
-
- await poller.buildBlockPayload(1);
- const probedFetches = spy.getCalls().map(c => c.args[0]);
- spy.resetHistory();
-
- await buildUnprobed(1);
- const unprobedFetches = spy.getCalls().map(c => c.args[0]);
-
- assert.ok(unprobedFetches.length > 40,
- 'the unprobed path really does walk the whole registry (' + unprobedFetches.length + ')');
- assert.ok(probedFetches.length < unprobedFetches.length,
- 'the probe must remove round-trips (' + probedFetches.length + ' vs ' + unprobedFetches.length + ')');
- // Every table still fetched is one the unprobed path also fetched with rows.
- for (const table of probedFetches)
- assert.ok(unprobedFetches.includes(table), table + ' fetched by the probed path only');
- });
-
- it('agrees with the real fetch on which tables are empty', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 1);
- const candidates = poller.actionScopedTables
- .filter(t => t !== 'actions' && t !== 'contract_emissions');
-
- const nonEmpty = await sourceDb.getNonEmptyActionScopedTables(candidates, 1);
-
- // The safety property, checked table by table against the real database:
- // probe membership must equal "getActionScopedRows returns rows".
- for (const table of candidates) {
- let rows = [];
- try {
- rows = await sourceDb.getActionScopedRows(table, 1);
- } catch (e) {
- continue; // table absent from this schema; the probe skips it too
- }
- assert.strictEqual(nonEmpty.has(table), rows.length > 0,
- 'probe disagrees with the real fetch on ' + table);
- }
- });
- });
-
- describe('updateStatus', function() {
- it('updates broadcaster status with real block data', async function() {
- await fixtures.seedBlocks(sourceDb, 1, 5);
- poller.lastPolledBlock = 5;
-
- await poller.updateStatus();
-
- assert.strictEqual(broadcaster.updateStatus.calledOnce, true);
- let args = broadcaster.updateStatus.firstCall.args;
- assert.strictEqual(args[0], 'bitcoin');
- assert.strictEqual(args[1], 'mainnet');
- assert.strictEqual(args[2].block_height, 5);
- assert.ok(args[2].ledger_hash);
- assert.ok(args[2].block_time > 0);
- });
- });
-});
diff --git a/test/integration/server_polling.test/01_poll_start.test.js b/test/integration/server_polling.test/01_poll_start.test.js
new file mode 100644
index 00000000..9488bc30
--- /dev/null
+++ b/test/integration/server_polling.test/01_poll_start.test.js
@@ -0,0 +1,47 @@
+// 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.
+
+// Covers initial polling outcomes. One part of server_polling.test.js.
+const suite = require('./helpers/server_polling_suite');
+
+describe('Integration: ServerPoller', function() {
+ suite.installHooks();
+ describe('poll', function() {
+ it('returns early when no blocks in DB', async function() {
+ await suite.poller.poll();
+ suite.assert.strictEqual(suite.broadcaster.broadcast.called, false);
+ });
+
+ it('initializes lastPolledBlock on first poll', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 3);
+ suite.poller.lastPolledBlock = null;
+ await suite.poller.poll();
+ suite.assert.strictEqual(suite.poller.lastPolledBlock, 3);
+ suite.assert.strictEqual(suite.broadcaster.broadcast.called, false); // initialization only
+ suite.assert.strictEqual(suite.broadcaster.updateStatus.calledOnce, true);
+ });
+
+ it('detects and processes new blocks', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1);
+ suite.poller.lastPolledBlock = 0;
+ await suite.poller.poll();
+ suite.assert.strictEqual(suite.broadcaster.broadcast.calledOnce, true);
+ let event = suite.broadcaster.broadcast.firstCall.args[2];
+ suite.assert.strictEqual(event.type, 'block');
+ suite.assert.strictEqual(event.block_index, 1);
+ suite.assert.strictEqual(event.chain, 'bitcoin');
+ suite.assert.strictEqual(event.network, 'mainnet');
+ suite.assert.ok(event.ledger_hash);
+ suite.assert.ok(event.actions_hash);
+ suite.assert.ok(event.contract_hash);
+ suite.assert.ok(event.data);
+ });
+ });
+});
diff --git a/test/integration/server_polling.test/02_poll_continuation.test.js b/test/integration/server_polling.test/02_poll_continuation.test.js
new file mode 100644
index 00000000..e31569ac
--- /dev/null
+++ b/test/integration/server_polling.test/02_poll_continuation.test.js
@@ -0,0 +1,60 @@
+// 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.
+
+// Covers continued polling outcomes. One part of server_polling.test.js.
+const suite = require('./helpers/server_polling_suite');
+
+describe('Integration: ServerPoller', function() {
+ suite.installHooks();
+ describe('poll', function() {
+ it('processes multiple sequential blocks', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 5);
+ suite.poller.lastPolledBlock = 0;
+ await suite.poller.poll();
+ suite.assert.strictEqual(suite.broadcaster.broadcast.callCount, 5);
+ suite.assert.strictEqual(suite.poller.lastPolledBlock, 5);
+
+ // Verify block order
+ for (let i = 0; i < 5; i++) {
+ let event = suite.broadcaster.broadcast.getCall(i).args[2];
+ suite.assert.strictEqual(event.block_index, i + 1);
+ }
+ });
+
+ it('records each block in transparency log', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 3);
+ suite.poller.lastPolledBlock = 0;
+ await suite.poller.poll();
+ suite.assert.strictEqual(suite.transparencyLog.recordBlock.callCount, 3);
+ });
+
+ it('detects reorg when block count decreases', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 10);
+ suite.poller.lastPolledBlock = 10;
+
+ // Simulate reorg at source
+ await suite.fixtures.deleteBlocksFrom(suite.sourceDb, 8);
+ await suite.poller.poll();
+ suite.assert.strictEqual(suite.broadcaster.broadcast.calledOnce, true);
+ let event = suite.broadcaster.broadcast.firstCall.args[2];
+ suite.assert.strictEqual(event.type, 'reorg');
+ suite.assert.strictEqual(event.block_index, 8); // currentBlock(7) + 1
+ suite.assert.strictEqual(suite.poller.lastPolledBlock, 7);
+ });
+
+ it('does nothing when no new blocks', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 5);
+ suite.poller.lastPolledBlock = 5;
+ await suite.poller.poll();
+ suite.assert.strictEqual(suite.broadcaster.broadcast.called, false);
+ suite.assert.strictEqual(suite.transparencyLog.recordBlock.called, false);
+ });
+ });
+});
diff --git a/test/integration/server_polling.test/03_payload_rows.test.js b/test/integration/server_polling.test/03_payload_rows.test.js
new file mode 100644
index 00000000..77373ca9
--- /dev/null
+++ b/test/integration/server_polling.test/03_payload_rows.test.js
@@ -0,0 +1,51 @@
+// 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.
+
+// Covers primary payload rows. One part of server_polling.test.js.
+const suite = require('./helpers/server_polling_suite');
+
+describe('Integration: ServerPoller', function() {
+ suite.installHooks();
+ describe('buildBlockPayload', function() {
+ it('builds payload with correct structure from real DB', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1);
+ let payload = await suite.poller.buildBlockPayload(1);
+ suite.assert.strictEqual(payload.type, 'block');
+ suite.assert.strictEqual(payload.chain, 'bitcoin');
+ suite.assert.strictEqual(payload.network, 'mainnet');
+ suite.assert.strictEqual(payload.block_index, 1);
+ suite.assert.ok(payload.block_time > 0);
+ suite.assert.ok(payload.ledger_hash);
+ suite.assert.ok(payload.data);
+ });
+
+ it('includes block-scoped table rows', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1);
+ let payload = await suite.poller.buildBlockPayload(1);
+ suite.assert.ok(payload.data.blocks);
+ suite.assert.strictEqual(payload.data.blocks.length, 1);
+ });
+
+ it('includes transactions', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1);
+ let payload = await suite.poller.buildBlockPayload(1);
+ suite.assert.ok(payload.data.transactions);
+ suite.assert.strictEqual(payload.data.transactions.length, 1);
+ });
+
+ it('includes action-scoped rows (credits)', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1);
+ let payload = await suite.poller.buildBlockPayload(1);
+ suite.assert.ok(payload.data.credits);
+ suite.assert.strictEqual(payload.data.credits.length, 1);
+ suite.assert.strictEqual(payload.data.credits[0].amount, '1000');
+ });
+ });
+});
diff --git a/test/integration/server_polling.test/04_payload_indexes.test.js b/test/integration/server_polling.test/04_payload_indexes.test.js
new file mode 100644
index 00000000..3c6419fc
--- /dev/null
+++ b/test/integration/server_polling.test/04_payload_indexes.test.js
@@ -0,0 +1,36 @@
+// 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.
+
+// Covers indexed payload rows and misses. One part of server_polling.test.js.
+const suite = require('./helpers/server_polling_suite');
+
+describe('Integration: ServerPoller', function() {
+ suite.installHooks();
+ describe('buildBlockPayload', function() {
+ it('includes index_transactions referenced by block', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1);
+ let payload = await suite.poller.buildBlockPayload(1);
+ suite.assert.ok(payload.data.index_transactions);
+ suite.assert.ok(payload.data.index_transactions.length >= 3); // ledger, actions, contract hashes
+ });
+
+ it('includes index_addresses referenced by transactions', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1);
+ let payload = await suite.poller.buildBlockPayload(1);
+ suite.assert.ok(payload.data.index_addresses);
+ suite.assert.ok(payload.data.index_addresses.length >= 1);
+ });
+
+ it('returns null for non-existent block', async function() {
+ let payload = await suite.poller.buildBlockPayload(999);
+ suite.assert.strictEqual(payload, null);
+ });
+ });
+});
diff --git a/test/integration/server_polling.test/05_action_probe_payloads.test.js b/test/integration/server_polling.test/05_action_probe_payloads.test.js
new file mode 100644
index 00000000..23fb4de3
--- /dev/null
+++ b/test/integration/server_polling.test/05_action_probe_payloads.test.js
@@ -0,0 +1,42 @@
+// 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.
+
+// Covers probe payload parity. One part of server_polling.test.js.
+const suite = require('./helpers/server_polling_suite');
+
+describe('Integration: ServerPoller', function() {
+ suite.installHooks();
+
+ // The payload build asks getNonEmptyActionScopedTables once and fetches only
+ // the tables that answer, rather than one getActionScopedRows per registry
+ // table (86 today, growing with every replicated table added). payload.data
+ // feeds a consensus hash followers recompute, so the only acceptable evidence is
+ // byte-identity against a REAL database, not a mock: these run the same block
+ // through both paths on the same rows and compare the serialized payloads.
+ describe('buildBlockPayload action-scoped probe', function() {
+ it('emits a byte-identical payload on a block that has rows', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1);
+ let probed = await suite.poller.buildBlockPayload(1);
+ let unprobed = await suite.buildUnprobed(1);
+ suite.assert.ok(probed.data.credits && probed.data.credits.length === 1,
+ 'the block must carry action-scoped rows, or this proves nothing');
+ suite.assert.strictEqual(JSON.stringify(probed), JSON.stringify(unprobed));
+ });
+
+ it('emits a byte-identical payload on a block with no action-scoped rows', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 2);
+ await suite.sourceDb.doQuery("DELETE FROM credits");
+ let probed = await suite.poller.buildBlockPayload(2);
+ let unprobed = await suite.buildUnprobed(2);
+ suite.assert.ok(!probed.data.credits, 'no action-scoped rows in this block');
+ suite.assert.strictEqual(JSON.stringify(probed), JSON.stringify(unprobed));
+ });
+ });
+});
diff --git a/test/integration/server_polling.test/06_action_probe_efficiency.test.js b/test/integration/server_polling.test/06_action_probe_efficiency.test.js
new file mode 100644
index 00000000..36d845f3
--- /dev/null
+++ b/test/integration/server_polling.test/06_action_probe_efficiency.test.js
@@ -0,0 +1,54 @@
+// 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.
+
+// Covers probe efficiency and empty tables. One part of server_polling.test.js.
+const suite = require('./helpers/server_polling_suite');
+
+describe('Integration: ServerPoller', function() {
+ suite.installHooks();
+ describe('buildBlockPayload action-scoped probe', function() {
+ it('cuts the per-table round-trips to the tables that actually have rows', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1);
+ const spy = suite.sinon.spy(suite.sourceDb, 'getActionScopedRows');
+ await suite.poller.buildBlockPayload(1);
+ const probedFetches = spy.getCalls().map(c => c.args[0]);
+ spy.resetHistory();
+ await suite.buildUnprobed(1);
+ const unprobedFetches = spy.getCalls().map(c => c.args[0]);
+ suite.assert.ok(unprobedFetches.length > 40,
+ 'the unprobed path really does walk the whole registry (' + unprobedFetches.length + ')');
+ suite.assert.ok(probedFetches.length < unprobedFetches.length,
+ 'the probe must remove round-trips (' + probedFetches.length + ' vs ' + unprobedFetches.length + ')');
+ // Every table still fetched is one the unprobed path also fetched with rows.
+ for (const table of probedFetches)
+ suite.assert.ok(unprobedFetches.includes(table), table + ' fetched by the probed path only');
+ });
+
+ it('agrees with the real fetch on which tables are empty', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 1);
+ const candidates = suite.poller.actionScopedTables
+ .filter(t => t !== 'actions' && t !== 'contract_emissions');
+ const nonEmpty = await suite.sourceDb.getNonEmptyActionScopedTables(candidates, 1);
+
+ // The safety property, checked table by table against the real database:
+ // probe membership must equal "getActionScopedRows returns rows".
+ for (const table of candidates) {
+ let rows = [];
+ try {
+ rows = await suite.sourceDb.getActionScopedRows(table, 1);
+ } catch (e) {
+ continue; // table absent from this schema; the probe skips it too
+ }
+ suite.assert.strictEqual(nonEmpty.has(table), rows.length > 0,
+ 'probe disagrees with the real fetch on ' + table);
+ }
+ });
+ });
+});
diff --git a/test/integration/server_polling.test/07_update_status.test.js b/test/integration/server_polling.test/07_update_status.test.js
new file mode 100644
index 00000000..3fdb08a6
--- /dev/null
+++ b/test/integration/server_polling.test/07_update_status.test.js
@@ -0,0 +1,30 @@
+// 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.
+
+// Covers broadcaster status updates. One part of server_polling.test.js.
+const suite = require('./helpers/server_polling_suite');
+
+describe('Integration: ServerPoller', function() {
+ suite.installHooks();
+ describe('updateStatus', function() {
+ it('updates broadcaster status with real block data', async function() {
+ await suite.fixtures.seedBlocks(suite.sourceDb, 1, 5);
+ suite.poller.lastPolledBlock = 5;
+ await suite.poller.updateStatus();
+ suite.assert.strictEqual(suite.broadcaster.updateStatus.calledOnce, true);
+ let args = suite.broadcaster.updateStatus.firstCall.args;
+ suite.assert.strictEqual(args[0], 'bitcoin');
+ suite.assert.strictEqual(args[1], 'mainnet');
+ suite.assert.strictEqual(args[2].block_height, 5);
+ suite.assert.ok(args[2].ledger_hash);
+ suite.assert.ok(args[2].block_time > 0);
+ });
+ });
+});
diff --git a/test/integration/server_polling.test/helpers/server_polling_suite.js b/test/integration/server_polling.test/helpers/server_polling_suite.js
new file mode 100644
index 00000000..1ac40d8a
--- /dev/null
+++ b/test/integration/server_polling.test/helpers/server_polling_suite.js
@@ -0,0 +1,77 @@
+// 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.
+
+// Provides the polling fixtures and hooks. One part of server_polling.test.js.
+const assert = require('assert');
+const sinon = require('sinon');
+const setup = require('../../helpers/setup');
+const testDb = require('../../helpers/testDb');
+const fixtures = require('../../helpers/fixtures');
+const ServerPoller = require('../../../../src/server/poller');
+
+let sourceDb, poller, broadcaster, transparencyLog, config;
+
+function installHooks() {
+ before(async function() {
+ await setup.globalSetup();
+ });
+ after(async function() {
+ await setup.globalTeardown();
+ });
+ beforeEach(async function() {
+ sourceDb = setup.getSourceDb();
+ await testDb.truncateAll(sourceDb);
+ broadcaster = {
+ broadcast: sinon.stub(),
+ updateStatus: sinon.stub(),
+ getSubscriberCount: sinon.stub().returns(0)
+ };
+ transparencyLog = {
+ recordBlock: sinon.stub().resolves(),
+ pruneFrom: sinon.stub().resolves()
+ };
+ config = { BLOCK_POLL_INTERVAL: 100 };
+ poller = new ServerPoller('bitcoin', 'mainnet', sourceDb, broadcaster, transparencyLog, config, testDb.util);
+ sinon.stub(console, 'log');
+ sinon.stub(console, 'error');
+ });
+ afterEach(function() {
+ sinon.restore();
+ });
+}
+
+// Same build with the probe removed from the db object, which is the pre-fix
+// query-every-table path verbatim.
+// getNonEmptyActionScopedTables lives on TestDatabase's PROTOTYPE, so it is
+// shadowed with an own `undefined` rather than deleted: a delete removes nothing
+// and the comparison would silently be probe-against-probe, which passes while
+// proving nothing.
+async function buildUnprobed(blockIndex) {
+ sourceDb.getNonEmptyActionScopedTables = undefined;
+ try {
+ assert.strictEqual(typeof sourceDb.getNonEmptyActionScopedTables, 'undefined');
+ return await poller.buildBlockPayload(blockIndex);
+ } finally {
+ delete sourceDb.getNonEmptyActionScopedTables;
+ }
+}
+
+module.exports = {
+ assert,
+ sinon,
+ testDb,
+ fixtures,
+ get sourceDb() { return sourceDb; },
+ get poller() { return poller; },
+ get broadcaster() { return broadcaster; },
+ get transparencyLog() { return transparencyLog; },
+ installHooks,
+ buildUnprobed
+};
diff --git a/test/integration/tokens.test/owner_edit_refresh.test.js b/test/integration/tokens.test/owner_edit_refresh.test.js
new file mode 100644
index 00000000..802efc49
--- /dev/null
+++ b/test/integration/tokens.test/owner_edit_refresh.test.js
@@ -0,0 +1,180 @@
+// 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.
+
+// Behavioural proof for the tokens-EDIT updated-rows class, against real MariaDB and
+// the real indexer schema.
+//
+// WHY THIS EXISTS. xchain-indexer#39: after a valid ownership transfer
+// (ISSUE|0|||||||) the explorer served the OLD owner indefinitely,
+// and flipped to the new one only once some unrelated later action touched the token.
+// The source indexer was right the whole time; the replica the explorer reads was not.
+// `tokens.action_index` is pinned at the tick's FIRST issuance, so an edit never
+// re-enters the action-scoped stream's window, and the only carry that existed keyed
+// on the ticks touched by a credit / debit / escrow row - which an ownership transfer
+// does not write. That is why it looked intermittent: an edit that also moved a
+// balance rode the supply class and looked fine.
+//
+// The unit tier pins the SQL shape. This pins what MariaDB actually selects over the
+// real schema, and carries the row the whole way: source rows -> collectUpdatedRows
+// -> ClientApplier.upsertRows -> the replica's own tokens.owner_id.
+
+const assert = require('assert');
+const sinon = require('sinon');
+const setup = require('../helpers/setup');
+const testDb = require('../helpers/testDb');
+
+const { collectUpdatedRows } = require('../../../src/server/updated_rows');
+const ClientApplier = require('../../../src/client/applier');
+
+const TICK = 'MGRTEST';
+const OLD = 'nOLDownerAddressForTheIssue39Repro';
+const NEW = 'nNEWownerAddressForTheIssue39Repro';
+const GENESIS_BLOCK = 100; // the tick's first issuance
+const EDIT_BLOCK = 200; // the ownership transfer, far above it
+
+// One ISSUE: its actions row (what pins it to a block), its transactions row and its
+// issues row. `transfer_id` null on the genesis issuance, the new owner on the edit.
+async function seedIssue(db, ids, opts){
+ await db.doQuery(
+ 'INSERT INTO transactions (tx_index, block_index, tx_hash_id, source_id) VALUES (?, ?, ?, ?)',
+ [opts.action_index, opts.block_index, opts.action_index, ids.old]);
+ await db.doQuery(
+ 'INSERT INTO actions (action_index, block_index, tx_index, tx_vout, action_id, action_format, source_id) ' +
+ 'VALUES (?, ?, ?, 0, ?, 0, ?)',
+ [opts.action_index, opts.block_index, opts.action_index, ids.action, ids.old]);
+ await db.doQuery(
+ 'INSERT INTO issues (action_index, tick_id, transfer_id, status_id) VALUES (?, ?, ?, ?)',
+ [opts.action_index, ids.tick, opts.transfer_id, opts.status_id]);
+}
+
+// The interned ids every row above is keyed by, plus the tokens row the genesis
+// issuance produced (owner = OLD, action_index pinned at the genesis action).
+async function seedTick(db){
+ await db.doQuery('INSERT INTO index_tickers (tick) VALUES (?)', [TICK]);
+ await db.doQuery('INSERT INTO index_addresses (address) VALUES (?), (?)', [OLD, NEW]);
+ await db.doQuery('INSERT INTO index_actions (action) VALUES (?)', ['ISSUE']);
+ await db.doQuery('INSERT INTO index_statuses (status) VALUES (?), (?)', ['valid', 'invalid: issued by another address']);
+ await db.doQuery('INSERT INTO index_transactions (hash) VALUES (?), (?)', ['tx_genesis_issue', 'tx_owner_transfer']);
+ let ids = {
+ tick: (await db.doQuery('SELECT id FROM index_tickers WHERE tick=?', [TICK]))[0].id,
+ old: (await db.doQuery('SELECT id FROM index_addresses WHERE address=?', [OLD]))[0].id,
+ new: (await db.doQuery('SELECT id FROM index_addresses WHERE address=?', [NEW]))[0].id,
+ action: (await db.doQuery('SELECT id FROM index_actions WHERE action=?', ['ISSUE']))[0].id,
+ valid: (await db.doQuery('SELECT id FROM index_statuses WHERE status=?', ['valid']))[0].id,
+ invalid: (await db.doQuery('SELECT id FROM index_statuses WHERE status=?', ['invalid: issued by another address']))[0].id
+ };
+ await seedIssue(db, ids, { action_index: 1, block_index: GENESIS_BLOCK, transfer_id: null, status_id: ids.valid });
+ await db.doQuery(
+ 'INSERT INTO tokens (tick_id, owner_id, action_index, last_action_index, supply, decimals) VALUES (?, ?, 1, 1, ?, 0)',
+ [ids.tick, ids.old, '1000']);
+ return ids;
+}
+
+// What the source indexer does when the transfer settles: the issues row, then the
+// in-place tokens UPDATE (createToken's UPDATE binds owner_id and leaves action_index
+// at the first issuance). No credit / debit / escrow row: a transfer moves no balance,
+// which is the whole point of the repro.
+async function applyTransferOnSource(db, ids){
+ await seedIssue(db, ids, { action_index: 2, block_index: EDIT_BLOCK, transfer_id: ids.new, status_id: ids.valid });
+ await db.doQuery('UPDATE tokens SET owner_id=?, last_action_index=1 WHERE tick_id=?', [ids.new, ids.tick]);
+}
+
+async function ownerOf(db, ids){
+ let rows = await db.doQuery('SELECT owner_id FROM tokens WHERE tick_id=? LIMIT 1', [ids.tick]);
+ return rows.length ? Number(rows[0].owner_id) : null;
+}
+
+describe('Integration: tokens owner refresh after an edit-ISSUE (xchain-indexer#39)', function(){
+
+ let sourceDb, replicaDb;
+
+ before(async function(){
+ await setup.globalSetup();
+ sourceDb = setup.getSourceDb();
+ replicaDb = setup.getReplicaDb();
+ sinon.stub(console, 'log');
+ sinon.stub(console, 'error');
+ });
+
+ after(async function(){
+ sinon.restore();
+ await setup.globalTeardown();
+ });
+
+ beforeEach(async function(){
+ await setup.resetDatabases();
+ });
+
+ it('carries the new owner to the replica in the transfer block, with no ledger row anywhere', async function(){
+ let ids = await seedTick(sourceDb);
+ // The replica is where the explorer reads: it holds the pre-transfer row.
+ let replicaIds = await seedTick(replicaDb);
+ assert.strictEqual(await ownerOf(replicaDb, replicaIds), replicaIds.old);
+
+ await applyTransferOnSource(sourceDb, ids);
+ assert.strictEqual(await ownerOf(sourceDb, ids), ids.new, 'source indexer is right immediately');
+
+ // No ledger row exists at all, so the supply class contributes nothing and the
+ // edit class is the only thing that can carry this row.
+ for(let table of ['credits', 'debits', 'escrows'])
+ assert.strictEqual(await testDb.getRowCount(sourceDb, table), 0);
+
+ let updated = await collectUpdatedRows(sourceDb, EDIT_BLOCK, EDIT_BLOCK, 6);
+ assert.ok(updated.tokens, 'the transfer block must carry the tokens row');
+ assert.strictEqual(updated.tokens.length, 1);
+ assert.strictEqual(Number(updated.tokens[0].owner_id), ids.new);
+
+ await new ClientApplier(replicaDb, testDb.util).upsertRows('tokens', updated.tokens);
+ assert.strictEqual(await ownerOf(replicaDb, replicaIds), replicaIds.new,
+ 'the replica the explorer reads must serve the new owner in this same block');
+ });
+
+});
+
+describe('Integration: tokens owner refresh, the windows that must stay quiet', function(){
+
+ let sourceDb;
+
+ before(async function(){
+ await setup.globalSetup();
+ sourceDb = setup.getSourceDb();
+ sinon.stub(console, 'log');
+ sinon.stub(console, 'error');
+ });
+
+ after(async function(){
+ sinon.restore();
+ await setup.globalTeardown();
+ });
+
+ beforeEach(async function(){
+ await setup.resetDatabases();
+ });
+
+ it('does not carry the row for a block whose only ISSUE on the tick is invalid', async function(){
+ let ids = await seedTick(sourceDb);
+ // An ISSUE from the wrong address never reaches createToken, so no tokens row
+ // was mutated and there is nothing to refresh.
+ await seedIssue(sourceDb, ids, { action_index: 2, block_index: EDIT_BLOCK, transfer_id: ids.new, status_id: ids.invalid });
+
+ let updated = await collectUpdatedRows(sourceDb, EDIT_BLOCK, EDIT_BLOCK, 6);
+ assert.ok(!updated.tokens, 'an invalid ISSUE mutates nothing and must not re-emit the row');
+ });
+
+ it('does not carry the row for a block the tick had no ISSUE in', async function(){
+ let ids = await seedTick(sourceDb);
+ await applyTransferOnSource(sourceDb, ids);
+
+ // The window the reporter polled through: every block after the transfer, none
+ // of which touches the tick. The class is keyed on the edit's own block.
+ let updated = await collectUpdatedRows(sourceDb, EDIT_BLOCK + 1, EDIT_BLOCK + 24, 6);
+ assert.ok(!updated.tokens, 'only the edit block re-emits the row');
+ });
+});
diff --git a/test/integration/token_supply_recompute.test.js b/test/integration/tokens.test/supply_recompute.test.js
similarity index 97%
rename from test/integration/token_supply_recompute.test.js
rename to test/integration/tokens.test/supply_recompute.test.js
index 38d481ce..c1e92e27 100644
--- a/test/integration/token_supply_recompute.test.js
+++ b/test/integration/tokens.test/supply_recompute.test.js
@@ -10,9 +10,9 @@
const assert = require('assert');
const sinon = require('sinon');
-const setup = require('./helpers/setup');
-const testDb = require('./helpers/testDb');
-const balanceHelpers = require('../../src/db/balance_helpers');
+const setup = require('../helpers/setup');
+const testDb = require('../helpers/testDb');
+const balanceHelpers = require('../../../src/db/balance_helpers');
// Behavioural proof for recomputeTokenSupplies (the reorg tokens.supply fix). The
// unit suite pins the SQL *shape*; this pins the arithmetic MariaDB actually
diff --git a/test/security/api_rate_limit_proxy_security.test.js b/test/security/api_rate_limit_proxy_security.test.js
index 7a5666f1..a30e523a 100644
--- a/test/security/api_rate_limit_proxy_security.test.js
+++ b/test/security/api_rate_limit_proxy_security.test.js
@@ -15,140 +15,43 @@
// production limiter instances (createRateLimiters) and the production key
// function (snapshotKey), not a re-declaration of them.
-const assert = require('assert');
-const sinon = require('sinon');
-const express = require('express');
-const proxyquire = require('proxyquire');
// src/api.js is the process entry point and patches console as it loads. The unit
// tier's bootstrap opts out of that; the security tier has no bootstrap, so this file
// opts out itself, or every later suite that stubs console stops seeing logger output.
process.env.XCHAIN_LOG_PATCH = '0';
-const { trustProxyHops, snapshotKey, createRateLimiters } = require('../../src/api');
-
-// A stand-in for the co-located Apache: the socket peer is loopback and the
-// real client address arrives appended to X-Forwarded-For.
-const PROXY_HOST = '127.0.0.1';
-
-const CLIENT_A = '198.51.100.7';
-const CLIENT_B = '198.51.100.8';
-const SPOOFED = '203.0.113.66';
-
-// Boots a throwaway server carrying the same proxy-trust seam and the same
-// snapshot limiter the service mounts. The echo route reports both the IP
-// express resolved and the exact bucket key the limiter charged.
-async function startHarness(options){
- let cfg = Object.assign({
- SNAPSHOT_RATE_FULL: 100,
- SNAPSHOT_RATE_INCR: 100,
- TRANSPARENCY_RATE_LIMIT: 100
- }, options.cfg || {});
-
- let app = express();
- app.set('trust proxy', trustProxyHops(options.trustProxy));
-
- let limiters = createRateLimiters(cfg);
- app.use(limiters.backstopLimiter);
- app.get('/snapshot/:dbType/:chain/:network', limiters.fullSnapshotLimiter, (req, res) => {
- res.json({ ip: req.ip, key: snapshotKey(req) });
- });
-
- let server = await new Promise((resolve) => {
- let s = app.listen(0, PROXY_HOST, () => resolve(s));
- });
-
- return {
- port: server.address().port,
- close: () => new Promise((resolve) => server.close(resolve))
- };
+const {
+ assert,
+ bootRealApi,
+ CLIENT_A,
+ CLIENT_B,
+ get,
+ listenRealApi,
+ PROXY_HOST,
+ registerHooks,
+ sinon,
+ SPOOFED,
+ startHarness,
+ trustProxyHops
+} = require('./api_rate_limit_proxy_security.test/helpers/api_rate_limit_proxy_security_suite');
+
+let harness = null;
+
+function beforeHook() {
+ // express-rate-limit logs a validation notice when it sees an
+ // X-Forwarded-For header with 'trust proxy' off; that is the very
+ // combination two of these tests exercise on purpose.
+ sinon.stub(console, 'error');
+ sinon.stub(console, 'warn');
}
-// One request through the harness. forwardedFor is the X-Forwarded-For value as
-// it would look leaving Apache (client-supplied entries first, real client last).
-async function get(harness, forwardedFor, path){
- let headers = {};
- if(forwardedFor) headers['x-forwarded-for'] = forwardedFor;
- let res = await fetch('http://' + PROXY_HOST + ':' + harness.port + (path || '/snapshot/indexer/BTC/mainnet'), { headers });
- let body = null;
- try { body = await res.json(); } catch(e){ body = null; }
- return { status: res.status, body };
-}
-
-// Boots the REAL startApi() with the network edges stubbed out, and hands back
-// the express app it built. Nothing else reaches the production wiring: the
-// harness above builds its own app, so without this a deleted app.set() in
-// startApi would go unnoticed.
-async function bootRealApi(trustProxyEnv, extraEnv){
- let env = Object.assign({ TRUST_PROXY: trustProxyEnv }, extraEnv || {});
- let prior = {};
- for(let key of Object.keys(env)){
- prior[key] = process.env[key];
- if(env[key] === undefined) delete process.env[key];
- else process.env[key] = env[key];
- }
-
- let capturedApp = null;
- let fakeServer = { on: () => {}, listen: () => {} };
- let fakeWss = { on: () => {}, clients: new Set(), handleUpgrade: () => {} };
-
- class SyncServiceStub {
- start(){ return Promise.resolve(); }
- getBroadcaster(){ return null; }
- getChains(){ return []; }
- getDatabase(){ return null; }
- }
-
- // startApi arms three long-lived intervals; a test process must not inherit them.
- let intervals = sinon.stub(global, 'setInterval').returns(null);
-
- try {
- let api = proxyquire('../../src/api', {
- 'http': { createServer: (app) => { capturedApp = app; return fakeServer; } },
- 'ws': { Server: function(){ return fakeWss; } },
- './SyncService': SyncServiceStub
- });
- await api.startApi();
- } finally {
- intervals.restore();
- for(let key of Object.keys(prior)){
- if(prior[key] === undefined) delete process.env[key];
- else process.env[key] = prior[key];
- }
- }
-
- return capturedApp;
-}
-
-// Puts a real startApi()-built app on a loopback port. Its snapshot route 404s
-// (the stubbed SyncService owns no databases), which is fine: the limiter has
-// already charged the bucket by then, so 404 means "served" and 429 means
-// "rate limited" just as they would against a real source.
-async function listenRealApi(app){
- let server = await new Promise((resolve) => {
- let s = app.listen(0, PROXY_HOST, () => resolve(s));
- });
- return {
- port: server.address().port,
- close: () => new Promise((resolve) => server.close(resolve))
- };
+async function afterHook() {
+ if(harness) await harness.close();
+ harness = null;
+ sinon.restore();
}
describe('API rate-limit proxy trust security', function(){
-
- let harness = null;
-
- beforeEach(function(){
- // express-rate-limit logs a validation notice when it sees an
- // X-Forwarded-For header with 'trust proxy' off; that is the very
- // combination two of these tests exercise on purpose.
- sinon.stub(console, 'error');
- sinon.stub(console, 'warn');
- });
-
- afterEach(async function(){
- if(harness) await harness.close();
- harness = null;
- sinon.restore();
- });
+ registerHooks(beforeHook, afterHook);
// ── trustProxyHops: one hop, never true ──
@@ -169,6 +72,11 @@ describe('API rate-limit proxy trust security', function(){
});
});
+});
+
+describe('API rate-limit proxy trust security', function(){
+ registerHooks(beforeHook, afterHook);
+
// ── the setting reaches the app startApi actually serves ──
describe('startApi wiring', function(){
@@ -207,86 +115,4 @@ describe('API rate-limit proxy trust security', function(){
});
});
- // ── TRUST_PROXY=false: the header is not evidence ──
-
- describe('TRUST_PROXY=false', function(){
-
- it('keys on the socket address and ignores a spoofed X-Forwarded-For', async function(){
- harness = await startHarness({ trustProxy: false });
- let res = await get(harness, SPOOFED);
- assert.strictEqual(res.status, 200);
- assert.strictEqual(res.body.ip, PROXY_HOST);
- assert.strictEqual(res.body.key, PROXY_HOST + '|indexer/BTC/mainnet');
- });
-
- it('does not let a spoofed header split one caller into separate buckets', async function(){
- harness = await startHarness({ trustProxy: false, cfg: { SNAPSHOT_RATE_FULL: 2 } });
- let first = await get(harness, '203.0.113.1');
- let second = await get(harness, '203.0.113.2');
- let third = await get(harness, '203.0.113.3');
- assert.strictEqual(first.status, 200);
- assert.strictEqual(second.status, 200);
- assert.strictEqual(third.status, 429, 'rotating X-Forwarded-For must not buy a fresh budget');
- });
- });
-
- // ── TRUST_PROXY=true: one hop resolves the real client ──
-
- describe('TRUST_PROXY=true', function(){
-
- it('keys on the client address the proxy appended, not the socket', async function(){
- harness = await startHarness({ trustProxy: true });
- let res = await get(harness, CLIENT_A);
- assert.strictEqual(res.status, 200);
- assert.strictEqual(res.body.ip, CLIENT_A);
- assert.strictEqual(res.body.key, CLIENT_A + '|indexer/BTC/mainnet');
- });
-
- it('ignores a client-supplied entry to the LEFT of the proxy-appended address', async function(){
- harness = await startHarness({ trustProxy: true });
- let res = await get(harness, SPOOFED + ', ' + CLIENT_A);
- assert.strictEqual(res.status, 200);
- assert.strictEqual(res.body.ip, CLIENT_A, 'one trusted hop must stop at the address Apache observed');
- assert.ok(!res.body.key.includes(SPOOFED), 'spoofed entry leaked into the rate-limit key');
- });
-
- it('ignores a long spoofed prefix, however many entries the client prepends', async function(){
- harness = await startHarness({ trustProxy: true });
- let res = await get(harness, '203.0.113.1, 203.0.113.2, 203.0.113.3, ' + CLIENT_A);
- assert.strictEqual(res.status, 200);
- assert.strictEqual(res.body.ip, CLIENT_A);
- });
-
- // The actual defect in : without the seam both clients resolve to
- // 127.0.0.1, share one bucket, and the first caller 429s the second.
- it('gives two different client addresses independent snapshot buckets', async function(){
- harness = await startHarness({ trustProxy: true, cfg: { SNAPSHOT_RATE_FULL: 2 } });
-
- assert.strictEqual((await get(harness, CLIENT_A)).status, 200);
- assert.strictEqual((await get(harness, CLIENT_A)).status, 200);
- assert.strictEqual((await get(harness, CLIENT_A)).status, 429, 'client A should have exhausted its own budget');
-
- let other = await get(harness, CLIENT_B);
- assert.strictEqual(other.status, 200, 'client B was 429d by client A exhausting a shared global bucket');
- assert.strictEqual(other.body.key, CLIENT_B + '|indexer/BTC/mainnet');
- });
-
- it('keeps one client on separate buckets per chain, as the key intends', async function(){
- harness = await startHarness({ trustProxy: true, cfg: { SNAPSHOT_RATE_FULL: 1 } });
- assert.strictEqual((await get(harness, CLIENT_A, '/snapshot/indexer/BTC/mainnet')).status, 200);
- assert.strictEqual((await get(harness, CLIENT_A, '/snapshot/indexer/BTC/mainnet')).status, 429);
- assert.strictEqual((await get(harness, CLIENT_A, '/snapshot/indexer/LTC/mainnet')).status, 200);
- assert.strictEqual((await get(harness, CLIENT_A, '/snapshot/decoder/BTC/mainnet')).status, 200);
- });
-
- // Trusting the forwarded header is what makes IPv6 rotation reachable:
- // a /64 holder could otherwise mint a fresh bucket per request.
- it('collapses an IPv6 client to its network so rotation buys no new budget', async function(){
- harness = await startHarness({ trustProxy: true, cfg: { SNAPSHOT_RATE_FULL: 2 } });
- assert.strictEqual((await get(harness, '2001:db8:0:1::1')).status, 200);
- assert.strictEqual((await get(harness, '2001:db8:0:1::2')).status, 200);
- let third = await get(harness, '2001:db8:0:1::3');
- assert.strictEqual(third.status, 429, 'addresses in one IPv6 allocation must share a bucket');
- });
- });
});
diff --git a/test/security/api_rate_limit_proxy_security.test/helpers/api_rate_limit_proxy_security_suite.js b/test/security/api_rate_limit_proxy_security.test/helpers/api_rate_limit_proxy_security_suite.js
new file mode 100644
index 00000000..0e274c98
--- /dev/null
+++ b/test/security/api_rate_limit_proxy_security.test/helpers/api_rate_limit_proxy_security_suite.js
@@ -0,0 +1,143 @@
+// 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.
+
+// Shared HTTP harness and hook registration. One part of api_rate_limit_proxy_security.test.js.
+const assert = require('assert');
+const sinon = require('sinon');
+const express = require('express');
+const proxyquire = require('proxyquire');
+const { trustProxyHops, snapshotKey, createRateLimiters } = require('../../../../src/api');
+
+// A stand-in for the co-located Apache: the socket peer is loopback and the
+// real client address arrives appended to X-Forwarded-For.
+const PROXY_HOST = '127.0.0.1';
+
+const CLIENT_A = '198.51.100.7';
+const CLIENT_B = '198.51.100.8';
+const SPOOFED = '203.0.113.66';
+
+// Boots a throwaway server carrying the same proxy-trust seam and the same
+// snapshot limiter the service mounts. The echo route reports both the IP
+// express resolved and the exact bucket key the limiter charged.
+async function startHarness(options){
+ let cfg = Object.assign({
+ SNAPSHOT_RATE_FULL: 100,
+ SNAPSHOT_RATE_INCR: 100,
+ TRANSPARENCY_RATE_LIMIT: 100
+ }, options.cfg || {});
+
+ let app = express();
+ app.set('trust proxy', trustProxyHops(options.trustProxy));
+
+ let limiters = createRateLimiters(cfg);
+ app.use(limiters.backstopLimiter);
+ app.get('/snapshot/:dbType/:chain/:network', limiters.fullSnapshotLimiter, (req, res) => {
+ res.json({ ip: req.ip, key: snapshotKey(req) });
+ });
+
+ let server = await new Promise((resolve) => {
+ let s = app.listen(0, PROXY_HOST, () => resolve(s));
+ });
+
+ return {
+ port: server.address().port,
+ close: () => new Promise((resolve) => server.close(resolve))
+ };
+}
+
+// One request through the harness. forwardedFor is the X-Forwarded-For value as
+// it would look leaving Apache (client-supplied entries first, real client last).
+async function get(harness, forwardedFor, path){
+ let headers = {};
+ if(forwardedFor) headers['x-forwarded-for'] = forwardedFor;
+ let res = await fetch('http://' + PROXY_HOST + ':' + harness.port + (path || '/snapshot/indexer/BTC/mainnet'), { headers });
+ let body = null;
+ try { body = await res.json(); } catch(e){ body = null; }
+ return { status: res.status, body };
+}
+
+// Boots the REAL startApi() with the network edges stubbed out, and hands back
+// the express app it built. Nothing else reaches the production wiring: the
+// harness above builds its own app, so without this a deleted app.set() in
+// startApi would go unnoticed.
+async function bootRealApi(trustProxyEnv, extraEnv){
+ let env = Object.assign({ TRUST_PROXY: trustProxyEnv }, extraEnv || {});
+ let prior = {};
+ for(let key of Object.keys(env)){
+ prior[key] = process.env[key];
+ if(env[key] === undefined) delete process.env[key];
+ else process.env[key] = env[key];
+ }
+
+ let capturedApp = null;
+ let fakeServer = { on: () => {}, listen: () => {} };
+ let fakeWss = { on: () => {}, clients: new Set(), handleUpgrade: () => {} };
+
+ class SyncServiceStub {
+ start(){ return Promise.resolve(); }
+ getBroadcaster(){ return null; }
+ getChains(){ return []; }
+ getDatabase(){ return null; }
+ }
+
+ // startApi arms three long-lived intervals; a test process must not inherit them.
+ let intervals = sinon.stub(global, 'setInterval').returns(null);
+
+ try {
+ let api = proxyquire('../../../../src/api', {
+ 'http': { createServer: (app) => { capturedApp = app; return fakeServer; } },
+ 'ws': { Server: function(){ return fakeWss; } },
+ './SyncService': SyncServiceStub
+ });
+ await api.startApi();
+ } finally {
+ intervals.restore();
+ for(let key of Object.keys(prior)){
+ if(prior[key] === undefined) delete process.env[key];
+ else process.env[key] = prior[key];
+ }
+ }
+
+ return capturedApp;
+}
+
+// Puts a real startApi()-built app on a loopback port. Its snapshot route 404s
+// (the stubbed SyncService owns no databases), which is fine: the limiter has
+// already charged the bucket by then, so 404 means "served" and 429 means
+// "rate limited" just as they would against a real source.
+async function listenRealApi(app){
+ let server = await new Promise((resolve) => {
+ let s = app.listen(0, PROXY_HOST, () => resolve(s));
+ });
+ return {
+ port: server.address().port,
+ close: () => new Promise((resolve) => server.close(resolve))
+ };
+}
+
+function registerHooks(beforeHook, afterHook) {
+ beforeEach(beforeHook);
+ afterEach(afterHook);
+}
+
+module.exports = {
+ assert,
+ bootRealApi,
+ CLIENT_A,
+ CLIENT_B,
+ get,
+ listenRealApi,
+ PROXY_HOST,
+ registerHooks,
+ sinon,
+ SPOOFED,
+ startHarness,
+ trustProxyHops
+};
diff --git a/test/security/api_rate_limit_proxy_security.test/proxy_modes.test.js b/test/security/api_rate_limit_proxy_security.test/proxy_modes.test.js
new file mode 100644
index 00000000..83df5124
--- /dev/null
+++ b/test/security/api_rate_limit_proxy_security.test/proxy_modes.test.js
@@ -0,0 +1,154 @@
+// 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.
+//
+// Behind a co-located reverse proxy, express resolved every
+// request to 127.0.0.1 because 'trust proxy' was never set, so the snapshot
+// rate limiters keyed one shared global bucket and a single caller could drain
+// every validator's snapshot budget. These tests drive real HTTP through the
+// production limiter instances (createRateLimiters) and the production key
+// function (snapshotKey), not a re-declaration of them.
+
+// src/api.js is the process entry point and patches console as it loads. The unit
+// tier's bootstrap opts out of that; the security tier has no bootstrap, so this file
+// opts out itself, or every later suite that stubs console stops seeing logger output.
+process.env.XCHAIN_LOG_PATCH = '0';
+const {
+ assert,
+ bootRealApi,
+ CLIENT_A,
+ CLIENT_B,
+ get,
+ listenRealApi,
+ PROXY_HOST,
+ registerHooks,
+ sinon,
+ SPOOFED,
+ startHarness,
+ trustProxyHops
+} = require('./helpers/api_rate_limit_proxy_security_suite');
+
+let harness = null;
+
+function beforeHook() {
+ // express-rate-limit logs a validation notice when it sees an
+ // X-Forwarded-For header with 'trust proxy' off; that is the very
+ // combination two of these tests exercise on purpose.
+ sinon.stub(console, 'error');
+ sinon.stub(console, 'warn');
+}
+
+async function afterHook() {
+ if(harness) await harness.close();
+ harness = null;
+ sinon.restore();
+}
+
+
+// Covers explicit proxy modes. One part of api_rate_limit_proxy_security.test.js.
+describe('API rate-limit proxy trust security', function(){
+ registerHooks(beforeHook, afterHook);
+
+ // ── TRUST_PROXY=false: the header is not evidence ──
+
+ describe('TRUST_PROXY=false', function(){
+
+ it('keys on the socket address and ignores a spoofed X-Forwarded-For', async function(){
+ harness = await startHarness({ trustProxy: false });
+ let res = await get(harness, SPOOFED);
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.ip, PROXY_HOST);
+ assert.strictEqual(res.body.key, PROXY_HOST + '|indexer/BTC/mainnet');
+ });
+
+ it('does not let a spoofed header split one caller into separate buckets', async function(){
+ harness = await startHarness({ trustProxy: false, cfg: { SNAPSHOT_RATE_FULL: 2 } });
+ let first = await get(harness, '203.0.113.1');
+ let second = await get(harness, '203.0.113.2');
+ let third = await get(harness, '203.0.113.3');
+ assert.strictEqual(first.status, 200);
+ assert.strictEqual(second.status, 200);
+ assert.strictEqual(third.status, 429, 'rotating X-Forwarded-For must not buy a fresh budget');
+ });
+ });
+
+});
+
+describe('API rate-limit proxy trust security', function(){
+ registerHooks(beforeHook, afterHook);
+
+ // ── TRUST_PROXY=true: one hop resolves the real client ──
+
+ describe('TRUST_PROXY=true', function(){
+
+ it('keys on the client address the proxy appended, not the socket', async function(){
+ harness = await startHarness({ trustProxy: true });
+ let res = await get(harness, CLIENT_A);
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.ip, CLIENT_A);
+ assert.strictEqual(res.body.key, CLIENT_A + '|indexer/BTC/mainnet');
+ });
+
+ it('ignores a client-supplied entry to the LEFT of the proxy-appended address', async function(){
+ harness = await startHarness({ trustProxy: true });
+ let res = await get(harness, SPOOFED + ', ' + CLIENT_A);
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.ip, CLIENT_A, 'one trusted hop must stop at the address Apache observed');
+ assert.ok(!res.body.key.includes(SPOOFED), 'spoofed entry leaked into the rate-limit key');
+ });
+
+ it('ignores a long spoofed prefix, however many entries the client prepends', async function(){
+ harness = await startHarness({ trustProxy: true });
+ let res = await get(harness, '203.0.113.1, 203.0.113.2, 203.0.113.3, ' + CLIENT_A);
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.ip, CLIENT_A);
+ });
+
+ });
+});
+
+describe('API rate-limit proxy trust security', function(){
+ registerHooks(beforeHook, afterHook);
+
+ describe('TRUST_PROXY=true', function(){
+
+ // The actual defect in : without the seam both clients resolve to
+ // 127.0.0.1, share one bucket, and the first caller 429s the second.
+ it('gives two different client addresses independent snapshot buckets', async function(){
+ harness = await startHarness({ trustProxy: true, cfg: { SNAPSHOT_RATE_FULL: 2 } });
+
+ assert.strictEqual((await get(harness, CLIENT_A)).status, 200);
+ assert.strictEqual((await get(harness, CLIENT_A)).status, 200);
+ assert.strictEqual((await get(harness, CLIENT_A)).status, 429, 'client A should have exhausted its own budget');
+
+ let other = await get(harness, CLIENT_B);
+ assert.strictEqual(other.status, 200, 'client B was 429d by client A exhausting a shared global bucket');
+ assert.strictEqual(other.body.key, CLIENT_B + '|indexer/BTC/mainnet');
+ });
+
+ it('keeps one client on separate buckets per chain, as the key intends', async function(){
+ harness = await startHarness({ trustProxy: true, cfg: { SNAPSHOT_RATE_FULL: 1 } });
+ assert.strictEqual((await get(harness, CLIENT_A, '/snapshot/indexer/BTC/mainnet')).status, 200);
+ assert.strictEqual((await get(harness, CLIENT_A, '/snapshot/indexer/BTC/mainnet')).status, 429);
+ assert.strictEqual((await get(harness, CLIENT_A, '/snapshot/indexer/LTC/mainnet')).status, 200);
+ assert.strictEqual((await get(harness, CLIENT_A, '/snapshot/decoder/BTC/mainnet')).status, 200);
+ });
+
+ // Trusting the forwarded header is what makes IPv6 rotation reachable:
+ // a /64 holder could otherwise mint a fresh bucket per request.
+ it('collapses an IPv6 client to its network so rotation buys no new budget', async function(){
+ harness = await startHarness({ trustProxy: true, cfg: { SNAPSHOT_RATE_FULL: 2 } });
+ assert.strictEqual((await get(harness, '2001:db8:0:1::1')).status, 200);
+ assert.strictEqual((await get(harness, '2001:db8:0:1::2')).status, 200);
+ let third = await get(harness, '2001:db8:0:1::3');
+ assert.strictEqual(third.status, 429, 'addresses in one IPv6 allocation must share a bucket');
+ });
+ });
+});
+
diff --git a/test/security/api_security.test.js b/test/security/api_security.test.js
index 1c98798c..65db1646 100644
--- a/test/security/api_security.test.js
+++ b/test/security/api_security.test.js
@@ -8,38 +8,22 @@
// license (without AGPL source-disclosure terms) is available -
// contact legal@dankest.llc.
-const assert = require('assert');
-const sinon = require('sinon');
-const { createApiKeyMiddleware, safeEqual } = require('../../src/http/middleware');
-
-function createMockReq(authHeader){
- let req = { headers: {} };
- if(authHeader !== undefined)
- req.headers['authorization'] = authHeader;
- return req;
-}
-
-function createMockRes(){
- let res = {
- _statusCode: null,
- _body: null,
- status: function(code){
- res._statusCode = code;
- return res;
- },
- json: function(body){
- res._body = body;
- return res;
- }
- };
- return res;
+const {
+ assert,
+ createApiKeyMiddleware,
+ createMockReq,
+ createMockRes,
+ registerHooks,
+ safeEqual,
+ sinon
+} = require('./api_security.test/helpers/api_security_suite');
+
+function afterHook() {
+ sinon.restore();
}
describe('API security', function(){
-
- afterEach(function(){
- sinon.restore();
- });
+ registerHooks(afterHook);
// ── createApiKeyMiddleware ──
@@ -96,6 +80,14 @@ describe('API security', function(){
assert.strictEqual(res._statusCode, 401);
});
+ });
+});
+
+describe('API security', function(){
+ registerHooks(afterHook);
+
+ describe('createApiKeyMiddleware', function(){
+
it('returns 401 when header present but no Bearer prefix', function(){
let middleware = createApiKeyMiddleware('secret123');
let req = createMockReq('secret123');
@@ -146,65 +138,4 @@ describe('API security', function(){
});
});
- // Constant-time comparison used by every Bearer-key check (REST middleware,
- // /halt/clear, and the WS upgrade). Locks in correctness and the null/length
- // guards so a refactor can't silently reintroduce a `===` timing leak.
- describe('safeEqual', function(){
-
- it('true for identical strings', function(){
- assert.strictEqual(safeEqual('Bearer abc123', 'Bearer abc123'), true);
- });
-
- it('false for a one-character difference of equal length', function(){
- assert.strictEqual(safeEqual('Bearer abc123', 'Bearer abc124'), false);
- });
-
- it('false for a length mismatch (prefix of the key)', function(){
- assert.strictEqual(safeEqual('Bearer abc', 'Bearer abc123'), false);
- });
-
- it('false when either side is null or undefined', function(){
- assert.strictEqual(safeEqual(undefined, 'Bearer k'), false);
- assert.strictEqual(safeEqual('Bearer k', null), false);
- });
-
- it('true for two empty/absent values (both coerce to empty)', function(){
- assert.strictEqual(safeEqual('', ''), true);
- assert.strictEqual(safeEqual(undefined, null), true);
- });
- });
-
- // ── Error response sanitization ──
-
- describe('error response sanitization', function(){
-
- it('API error responses should use generic message pattern', function(){
- // This test validates the pattern used in api.js error handlers.
- // The actual routes use: res.status(500).json({ error: 'Internal server error' })
- // If the code accidentally leaks e.message, this test documents the expected contract.
- let genericError = { error: 'Internal server error' };
- let sensitiveError = { error: 'ER_ACCESS_DENIED_ERROR: Access denied for user \'root\'@\'172.18.0.1\'' };
-
- // The generic error should NOT contain connection details
- assert.strictEqual(genericError.error.includes('Access denied'), false);
- assert.strictEqual(genericError.error.includes('172.18'), false);
- assert.strictEqual(genericError.error, 'Internal server error');
-
- // A sensitive error would contain them; this is what we prevent
- assert.strictEqual(sensitiveError.error.includes('Access denied'), true);
- });
-
- it('Internal server error message does not vary by exception type', function(){
- // Regardless of exception type, the API should always return the same message
- let dbError = new Error('ER_TABLE_NOT_FOUND');
- let connError = new Error('ECONNREFUSED 172.18.0.1:3306');
- let syntaxError = new Error("SELECT * FROM `evil;DROP`");
-
- // The sanitized response should be identical for all
- let sanitized = 'Internal server error';
- assert.notStrictEqual(sanitized, dbError.message);
- assert.notStrictEqual(sanitized, connError.message);
- assert.notStrictEqual(sanitized, syntaxError.message);
- });
- });
});
diff --git a/test/security/api_security.test/comparison_and_errors.test.js b/test/security/api_security.test/comparison_and_errors.test.js
new file mode 100644
index 00000000..b73b845d
--- /dev/null
+++ b/test/security/api_security.test/comparison_and_errors.test.js
@@ -0,0 +1,97 @@
+// 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.
+
+const {
+ assert,
+ createApiKeyMiddleware,
+ createMockReq,
+ createMockRes,
+ registerHooks,
+ safeEqual,
+ sinon
+} = require('./helpers/api_security_suite');
+
+function afterHook() {
+ sinon.restore();
+}
+
+
+// Covers comparison and error responses. One part of api_security.test.js.
+describe('API security', function(){
+ registerHooks(afterHook);
+
+ // Constant-time comparison used by every Bearer-key check (REST middleware,
+ // /halt/clear, and the WS upgrade). Locks in correctness and the null/length
+ // guards so a refactor can't silently reintroduce a `===` timing leak.
+ describe('safeEqual', function(){
+
+ it('true for identical strings', function(){
+ assert.strictEqual(safeEqual('Bearer abc123', 'Bearer abc123'), true);
+ });
+
+ it('false for a one-character difference of equal length', function(){
+ assert.strictEqual(safeEqual('Bearer abc123', 'Bearer abc124'), false);
+ });
+
+ it('false for a length mismatch (prefix of the key)', function(){
+ assert.strictEqual(safeEqual('Bearer abc', 'Bearer abc123'), false);
+ });
+
+ it('false when either side is null or undefined', function(){
+ assert.strictEqual(safeEqual(undefined, 'Bearer k'), false);
+ assert.strictEqual(safeEqual('Bearer k', null), false);
+ });
+
+ it('true for two empty/absent values (both coerce to empty)', function(){
+ assert.strictEqual(safeEqual('', ''), true);
+ assert.strictEqual(safeEqual(undefined, null), true);
+ });
+ });
+
+});
+
+describe('API security', function(){
+ registerHooks(afterHook);
+
+ // ── Error response sanitization ──
+
+ describe('error response sanitization', function(){
+
+ it('API error responses should use generic message pattern', function(){
+ // This test validates the pattern used in api.js error handlers.
+ // The actual routes use: res.status(500).json({ error: 'Internal server error' })
+ // If the code accidentally leaks e.message, this test documents the expected contract.
+ let genericError = { error: 'Internal server error' };
+ let sensitiveError = { error: 'ER_ACCESS_DENIED_ERROR: Access denied for user \'root\'@\'172.18.0.1\'' };
+
+ // The generic error should NOT contain connection details
+ assert.strictEqual(genericError.error.includes('Access denied'), false);
+ assert.strictEqual(genericError.error.includes('172.18'), false);
+ assert.strictEqual(genericError.error, 'Internal server error');
+
+ // A sensitive error would contain them; this is what we prevent
+ assert.strictEqual(sensitiveError.error.includes('Access denied'), true);
+ });
+
+ it('Internal server error message does not vary by exception type', function(){
+ // Regardless of exception type, the API should always return the same message
+ let dbError = new Error('ER_TABLE_NOT_FOUND');
+ let connError = new Error('ECONNREFUSED 172.18.0.1:3306');
+ let syntaxError = new Error("SELECT * FROM `evil;DROP`");
+
+ // The sanitized response should be identical for all
+ let sanitized = 'Internal server error';
+ assert.notStrictEqual(sanitized, dbError.message);
+ assert.notStrictEqual(sanitized, connError.message);
+ assert.notStrictEqual(sanitized, syntaxError.message);
+ });
+ });
+});
+
diff --git a/test/security/api_security.test/helpers/api_security_suite.js b/test/security/api_security.test/helpers/api_security_suite.js
new file mode 100644
index 00000000..51322f92
--- /dev/null
+++ b/test/security/api_security.test/helpers/api_security_suite.js
@@ -0,0 +1,51 @@
+// 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.
+
+// Shared fixtures and hook registration. One part of api_security.test.js.
+const assert = require('assert');
+const sinon = require('sinon');
+const { createApiKeyMiddleware, safeEqual } = require('../../../../src/http/middleware');
+
+function createMockReq(authHeader){
+ let req = { headers: {} };
+ if(authHeader !== undefined)
+ req.headers['authorization'] = authHeader;
+ return req;
+}
+
+function createMockRes(){
+ let res = {
+ _statusCode: null,
+ _body: null,
+ status: function(code){
+ res._statusCode = code;
+ return res;
+ },
+ json: function(body){
+ res._body = body;
+ return res;
+ }
+ };
+ return res;
+}
+
+function registerHooks(afterHook) {
+ afterEach(afterHook);
+}
+
+module.exports = {
+ assert,
+ createApiKeyMiddleware,
+ createMockReq,
+ createMockRes,
+ registerHooks,
+ safeEqual,
+ sinon
+};
diff --git a/test/security/block_broadcaster_security.test.js b/test/security/block_broadcaster_security.test.js
index 9da635cd..631ae128 100644
--- a/test/security/block_broadcaster_security.test.js
+++ b/test/security/block_broadcaster_security.test.js
@@ -8,43 +8,25 @@
// license (without AGPL source-disclosure terms) is available -
// contact legal@dankest.llc.
-const assert = require('assert');
-const sinon = require('sinon');
-const BlockBroadcaster = require('../../src/server/block_broadcaster');
-
-function createMockWs(ip){
- return {
- readyState: 1, // WebSocket.OPEN
- close: sinon.stub(),
- send: sinon.stub(),
- on: sinon.stub(),
- _syncChain: null,
- _syncNetwork: null,
- _syncIp: null,
- _syncBuffered: 0,
- bufferedAmount: 0
- };
+const {
+ assert,
+ BlockBroadcaster,
+ createMockReq,
+ createMockWs,
+ registerHooks,
+ sinon
+} = require('./block_broadcaster_security.test/helpers/block_broadcaster_security_suite');
+
+function beforeHook() {
+ sinon.stub(console, 'log');
}
-function createMockReq(socketIp, forwardedFor){
- let req = {
- headers: {},
- socket: { remoteAddress: socketIp }
- };
- if(forwardedFor)
- req.headers['x-forwarded-for'] = forwardedFor;
- return req;
+function afterHook() {
+ sinon.restore();
}
describe('BlockBroadcaster security', function(){
-
- beforeEach(function(){
- sinon.stub(console, 'log');
- });
-
- afterEach(function(){
- sinon.restore();
- });
+ registerHooks(beforeHook, afterHook);
// ── getIp: TRUST_PROXY=false (default) ──
@@ -72,6 +54,11 @@ describe('BlockBroadcaster security', function(){
});
});
+});
+
+describe('BlockBroadcaster security', function(){
+ registerHooks(beforeHook, afterHook);
+
// ── getIp: TRUST_PROXY=true ──
// ── getIp: TRUST_PROXY=true ──
@@ -115,6 +102,14 @@ describe('BlockBroadcaster security', function(){
assert.strictEqual(ip, '203.0.113.7');
});
+ });
+});
+
+describe('BlockBroadcaster security', function(){
+ registerHooks(beforeHook, afterHook);
+
+ describe('getIp: TRUST_PROXY=true', function(){
+
it('trims whitespace around the appended address', function(){
let broadcaster = new BlockBroadcaster({ TRUST_PROXY: true, WS_MAX_PER_IP: 3, WS_BACKPRESSURE_LIMIT: 50 });
let req = createMockReq('192.168.1.1', '10.0.0.1 , 172.16.0.1 ');
@@ -144,117 +139,4 @@ describe('BlockBroadcaster security', function(){
});
});
- // ── Per-IP limit through the trusted proxy ──
-
- describe('per-IP limit with TRUST_PROXY=true', function(){
-
- it('rotating the forged leading entry does not buy extra connections', function(){
- let config = { TRUST_PROXY: true, WS_MAX_PER_IP: 2, WS_BACKPRESSURE_LIMIT: 50 };
- let broadcaster = new BlockBroadcaster(config);
-
- // All three arrive from the same real client 203.0.113.7 (appended by Apache);
- // only the forgeable prefix changes between them.
- let results = [];
- for(let i = 1; i <= 3; i++){
- let ws = createMockWs();
- let req = createMockReq('127.0.0.1', '10.0.0.' + i + ', 203.0.113.7');
- results.push({ ws, added: broadcaster.addSubscription(ws, req, 'bitcoin', 'mainnet') });
- }
-
- assert.strictEqual(results[0].added, true);
- assert.strictEqual(results[1].added, true);
- assert.strictEqual(results[2].added, false);
- assert.strictEqual(results[2].ws.close.calledOnce, true);
- });
-
- it('two real clients behind the proxy get independent buckets', function(){
- let config = { TRUST_PROXY: true, WS_MAX_PER_IP: 2, WS_BACKPRESSURE_LIMIT: 50 };
- let broadcaster = new BlockBroadcaster(config);
-
- // Client A exhausts its cap.
- for(let i = 0; i < 2; i++){
- let ws = createMockWs();
- let added = broadcaster.addSubscription(ws, createMockReq('127.0.0.1', '203.0.113.7'), 'bitcoin', 'mainnet');
- assert.strictEqual(added, true);
- }
- let wsAOver = createMockWs();
- assert.strictEqual(
- broadcaster.addSubscription(wsAOver, createMockReq('127.0.0.1', '203.0.113.7'), 'bitcoin', 'mainnet'),
- false
- );
-
- // Client B is unaffected: a different appended address is a different bucket.
- let wsB = createMockWs();
- assert.strictEqual(
- broadcaster.addSubscription(wsB, createMockReq('127.0.0.1', '198.51.100.4'), 'bitcoin', 'mainnet'),
- true
- );
- assert.strictEqual(wsB._syncIp, '198.51.100.4');
- assert.strictEqual(wsB.close.called, false);
- });
-
- it('a client cannot exhaust another client bucket by claiming its address in the prefix', function(){
- let config = { TRUST_PROXY: true, WS_MAX_PER_IP: 2, WS_BACKPRESSURE_LIMIT: 50 };
- let broadcaster = new BlockBroadcaster(config);
-
- // Attacker names the victim in the forgeable prefix, twice.
- for(let i = 0; i < 2; i++){
- let ws = createMockWs();
- let added = broadcaster.addSubscription(
- ws, createMockReq('127.0.0.1', '198.51.100.4, 203.0.113.7'), 'bitcoin', 'mainnet');
- assert.strictEqual(added, true);
- }
-
- // The victim still connects: the attacker's traffic was booked to 203.0.113.7.
- let victim = createMockWs();
- assert.strictEqual(
- broadcaster.addSubscription(victim, createMockReq('127.0.0.1', '198.51.100.4'), 'bitcoin', 'mainnet'),
- true
- );
- assert.strictEqual(victim.close.called, false);
- });
- });
-
- // ── Per-IP limit not bypassed by header spoofing ──
-
- describe('per-IP limit with TRUST_PROXY=false', function(){
-
- it('spoofed x-forwarded-for cannot bypass per-IP limit', function(){
- let config = { TRUST_PROXY: false, WS_MAX_PER_IP: 2, WS_BACKPRESSURE_LIMIT: 50 };
- let broadcaster = new BlockBroadcaster(config);
-
- // Attacker sends 3 connections from same socket IP, each with different x-forwarded-for
- let ws1 = createMockWs();
- let req1 = createMockReq('1.1.1.1', '10.0.0.1');
- let added1 = broadcaster.addSubscription(ws1, req1, 'bitcoin', 'mainnet');
- assert.strictEqual(added1, true);
-
- let ws2 = createMockWs();
- let req2 = createMockReq('1.1.1.1', '10.0.0.2');
- let added2 = broadcaster.addSubscription(ws2, req2, 'bitcoin', 'mainnet');
- assert.strictEqual(added2, true);
-
- let ws3 = createMockWs();
- let req3 = createMockReq('1.1.1.1', '10.0.0.3');
- let added3 = broadcaster.addSubscription(ws3, req3, 'bitcoin', 'mainnet');
- // Third connection should be rejected: all are from 1.1.1.1 regardless of spoofed header
- assert.strictEqual(added3, false);
- assert.strictEqual(ws3.close.calledOnce, true);
- });
-
- it('different socket IPs are not affected by per-IP limit', function(){
- let config = { TRUST_PROXY: false, WS_MAX_PER_IP: 1, WS_BACKPRESSURE_LIMIT: 50 };
- let broadcaster = new BlockBroadcaster(config);
-
- let ws1 = createMockWs();
- let req1 = createMockReq('1.1.1.1');
- let added1 = broadcaster.addSubscription(ws1, req1, 'bitcoin', 'mainnet');
- assert.strictEqual(added1, true);
-
- let ws2 = createMockWs();
- let req2 = createMockReq('2.2.2.2');
- let added2 = broadcaster.addSubscription(ws2, req2, 'bitcoin', 'mainnet');
- assert.strictEqual(added2, true);
- });
- });
});
diff --git a/test/security/block_broadcaster_security.test/helpers/block_broadcaster_security_suite.js b/test/security/block_broadcaster_security.test/helpers/block_broadcaster_security_suite.js
new file mode 100644
index 00000000..9eaa16d9
--- /dev/null
+++ b/test/security/block_broadcaster_security.test/helpers/block_broadcaster_security_suite.js
@@ -0,0 +1,52 @@
+// 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.
+
+// Shared fixtures and hook registration. One part of block_broadcaster_security.test.js.
+const assert = require('assert');
+const sinon = require('sinon');
+const BlockBroadcaster = require('../../../../src/server/block_broadcaster');
+
+function createMockWs(ip){
+ return {
+ readyState: 1, // WebSocket.OPEN
+ close: sinon.stub(),
+ send: sinon.stub(),
+ on: sinon.stub(),
+ _syncChain: null,
+ _syncNetwork: null,
+ _syncIp: null,
+ _syncBuffered: 0,
+ bufferedAmount: 0
+ };
+}
+
+function createMockReq(socketIp, forwardedFor){
+ let req = {
+ headers: {},
+ socket: { remoteAddress: socketIp }
+ };
+ if(forwardedFor)
+ req.headers['x-forwarded-for'] = forwardedFor;
+ return req;
+}
+
+function registerHooks(beforeHook, afterHook) {
+ beforeEach(beforeHook);
+ afterEach(afterHook);
+}
+
+module.exports = {
+ assert,
+ BlockBroadcaster,
+ createMockReq,
+ createMockWs,
+ registerHooks,
+ sinon
+};
diff --git a/test/security/block_broadcaster_security.test/per_ip_limits.test.js b/test/security/block_broadcaster_security.test/per_ip_limits.test.js
new file mode 100644
index 00000000..1675dacf
--- /dev/null
+++ b/test/security/block_broadcaster_security.test/per_ip_limits.test.js
@@ -0,0 +1,160 @@
+// 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.
+
+const {
+ assert,
+ BlockBroadcaster,
+ createMockReq,
+ createMockWs,
+ registerHooks,
+ sinon
+} = require('./helpers/block_broadcaster_security_suite');
+
+function beforeHook() {
+ sinon.stub(console, 'log');
+}
+
+function afterHook() {
+ sinon.restore();
+}
+
+
+// Covers per-IP subscription limits. One part of block_broadcaster_security.test.js.
+describe('BlockBroadcaster security', function(){
+ registerHooks(beforeHook, afterHook);
+
+ // ── Per-IP limit through the trusted proxy ──
+
+ describe('per-IP limit with TRUST_PROXY=true', function(){
+
+ it('rotating the forged leading entry does not buy extra connections', function(){
+ let config = { TRUST_PROXY: true, WS_MAX_PER_IP: 2, WS_BACKPRESSURE_LIMIT: 50 };
+ let broadcaster = new BlockBroadcaster(config);
+
+ // All three arrive from the same real client 203.0.113.7 (appended by Apache);
+ // only the forgeable prefix changes between them.
+ let results = [];
+ for(let i = 1; i <= 3; i++){
+ let ws = createMockWs();
+ let req = createMockReq('127.0.0.1', '10.0.0.' + i + ', 203.0.113.7');
+ results.push({ ws, added: broadcaster.addSubscription(ws, req, 'bitcoin', 'mainnet') });
+ }
+
+ assert.strictEqual(results[0].added, true);
+ assert.strictEqual(results[1].added, true);
+ assert.strictEqual(results[2].added, false);
+ assert.strictEqual(results[2].ws.close.calledOnce, true);
+ });
+
+ });
+});
+
+describe('BlockBroadcaster security', function(){
+ registerHooks(beforeHook, afterHook);
+
+ describe('per-IP limit with TRUST_PROXY=true', function(){
+
+ it('two real clients behind the proxy get independent buckets', function(){
+ let config = { TRUST_PROXY: true, WS_MAX_PER_IP: 2, WS_BACKPRESSURE_LIMIT: 50 };
+ let broadcaster = new BlockBroadcaster(config);
+
+ // Client A exhausts its cap.
+ for(let i = 0; i < 2; i++){
+ let ws = createMockWs();
+ let added = broadcaster.addSubscription(ws, createMockReq('127.0.0.1', '203.0.113.7'), 'bitcoin', 'mainnet');
+ assert.strictEqual(added, true);
+ }
+ let wsAOver = createMockWs();
+ assert.strictEqual(
+ broadcaster.addSubscription(wsAOver, createMockReq('127.0.0.1', '203.0.113.7'), 'bitcoin', 'mainnet'),
+ false
+ );
+
+ // Client B is unaffected: a different appended address is a different bucket.
+ let wsB = createMockWs();
+ assert.strictEqual(
+ broadcaster.addSubscription(wsB, createMockReq('127.0.0.1', '198.51.100.4'), 'bitcoin', 'mainnet'),
+ true
+ );
+ assert.strictEqual(wsB._syncIp, '198.51.100.4');
+ assert.strictEqual(wsB.close.called, false);
+ });
+
+ it('a client cannot exhaust another client bucket by claiming its address in the prefix', function(){
+ let config = { TRUST_PROXY: true, WS_MAX_PER_IP: 2, WS_BACKPRESSURE_LIMIT: 50 };
+ let broadcaster = new BlockBroadcaster(config);
+
+ // Attacker names the victim in the forgeable prefix, twice.
+ for(let i = 0; i < 2; i++){
+ let ws = createMockWs();
+ let added = broadcaster.addSubscription(
+ ws, createMockReq('127.0.0.1', '198.51.100.4, 203.0.113.7'), 'bitcoin', 'mainnet');
+ assert.strictEqual(added, true);
+ }
+
+ // The victim still connects: the attacker's traffic was booked to 203.0.113.7.
+ let victim = createMockWs();
+ assert.strictEqual(
+ broadcaster.addSubscription(victim, createMockReq('127.0.0.1', '198.51.100.4'), 'bitcoin', 'mainnet'),
+ true
+ );
+ assert.strictEqual(victim.close.called, false);
+ });
+ });
+
+});
+
+describe('BlockBroadcaster security', function(){
+ registerHooks(beforeHook, afterHook);
+
+ // ── Per-IP limit not bypassed by header spoofing ──
+
+ describe('per-IP limit with TRUST_PROXY=false', function(){
+
+ it('spoofed x-forwarded-for cannot bypass per-IP limit', function(){
+ let config = { TRUST_PROXY: false, WS_MAX_PER_IP: 2, WS_BACKPRESSURE_LIMIT: 50 };
+ let broadcaster = new BlockBroadcaster(config);
+
+ // Attacker sends 3 connections from same socket IP, each with different x-forwarded-for
+ let ws1 = createMockWs();
+ let req1 = createMockReq('1.1.1.1', '10.0.0.1');
+ let added1 = broadcaster.addSubscription(ws1, req1, 'bitcoin', 'mainnet');
+ assert.strictEqual(added1, true);
+
+ let ws2 = createMockWs();
+ let req2 = createMockReq('1.1.1.1', '10.0.0.2');
+ let added2 = broadcaster.addSubscription(ws2, req2, 'bitcoin', 'mainnet');
+ assert.strictEqual(added2, true);
+
+ let ws3 = createMockWs();
+ let req3 = createMockReq('1.1.1.1', '10.0.0.3');
+ let added3 = broadcaster.addSubscription(ws3, req3, 'bitcoin', 'mainnet');
+ // Third connection should be rejected: all are from 1.1.1.1 regardless of spoofed header
+ assert.strictEqual(added3, false);
+ assert.strictEqual(ws3.close.calledOnce, true);
+ });
+
+ it('different socket IPs are not affected by per-IP limit', function(){
+ let config = { TRUST_PROXY: false, WS_MAX_PER_IP: 1, WS_BACKPRESSURE_LIMIT: 50 };
+ let broadcaster = new BlockBroadcaster(config);
+
+ let ws1 = createMockWs();
+ let req1 = createMockReq('1.1.1.1');
+ let added1 = broadcaster.addSubscription(ws1, req1, 'bitcoin', 'mainnet');
+ assert.strictEqual(added1, true);
+
+ let ws2 = createMockWs();
+ let req2 = createMockReq('2.2.2.2');
+ let added2 = broadcaster.addSubscription(ws2, req2, 'bitcoin', 'mainnet');
+ assert.strictEqual(added2, true);
+ });
+ });
+});
+
diff --git a/test/unit/balance_helpers.test.js b/test/unit/balance_helpers.test.js
index b1c132bb..2b7172bd 100644
--- a/test/unit/balance_helpers.test.js
+++ b/test/unit/balance_helpers.test.js
@@ -22,83 +22,180 @@ function fakeDb() {
return { queries, argsLog, doQuery: async (sql, args) => { queries.push(sql.replace(/\s+/g, ' ').trim()); argsLog.push(args || []); return []; } };
}
+function registerRebuildBalancesTests(getDb) {
+ it('clears the table before reinserting (DELETE then INSERT, in order)', function () {
+ const db = getDb();
+ assert.strictEqual(db.queries.length, 2);
+ assert.ok(/^DELETE FROM balances/i.test(db.queries[0]));
+ assert.ok(/INSERT INTO balances \(address_id, tick_id, amount\)/i.test(db.queries[1]));
+ });
+
+ it('aggregates credits minus debits per (address_id, tick_id)', function () {
+ const db = getDb();
+ const sql = db.queries[1];
+ assert.ok(/FROM credits/i.test(sql) && /FROM debits/i.test(sql), 'unions credits + debits');
+ assert.ok(/UNION ALL/i.test(sql));
+ assert.ok(/WHEN t\.type = 'credit' THEN CAST\(t\.amount AS DECIMAL\(65,18\)\) ELSE -CAST\(t\.amount AS DECIMAL\(65,18\)\) END/i.test(sql), 'credit positive, debit negative');
+ assert.ok(/GROUP BY address_id, tick_id/i.test(sql));
+ });
+
+ it('sums at DECIMAL(65,18) (bare VARCHAR amounts promote to DOUBLE and corrupt >16-digit amounts)', function () {
+ const db = getDb();
+ const sql = db.queries[1];
+ assert.ok(!/THEN t\.amount/i.test(sql), 'no un-cast amount may reach the SUM');
+ assert.ok(/CAST\(t\.amount AS DECIMAL\(65,18\)\)/i.test(sql));
+ });
+
+ it('renders amounts in the source indexer\'s minimal-decimal format', function () {
+ const db = getDb();
+ assert.ok(/TRIM\(TRAILING '\.' FROM TRIM\(TRAILING '0' FROM CAST\(/i.test(db.queries[1]),
+ 'trailing zeros then the bare dot are trimmed so rebuilt bytes match source bytes');
+ });
+
+ it('prunes rows that net to exactly zero (HAVING ... != 0)', function () {
+ const db = getDb();
+ assert.ok(/HAVING SUM\(.*\) != 0/i.test(db.queries[1]));
+ });
+
+ it('rejects if the DB layer fails (caller owns error handling)', async function () {
+ const boom = { doQuery: async () => { throw new Error('1146 no table'); } };
+ await assert.rejects(() => rebuildBalances(boom), /1146/);
+ });
+}
+
+function registerScopedRebuildTests(getDb) {
+ it('scopes the DELETE to the touched ids (parameterized IN-lists)', function () {
+ const db = getDb();
+ assert.ok(/^DELETE FROM balances WHERE address_id IN \(\?, \?\) AND tick_id IN \(\?\)$/i.test(db.queries[0]));
+ assert.deepStrictEqual(db.argsLog[0], [7, 9, 3]);
+ });
+
+ it('scopes BOTH union branches so the source scans stay bounded', function () {
+ const db = getDb();
+ const sql = db.queries[1];
+ const branchPreds = sql.match(/FROM (credits|debits) WHERE address_id IN \(\?, \?\) AND tick_id IN \(\?\)/gi) || [];
+ assert.strictEqual(branchPreds.length, 2, 'credits and debits both scoped');
+ assert.deepStrictEqual(db.argsLog[1], [7, 9, 3, 7, 9, 3], 'args repeated per branch');
+ });
+
+ it('keeps the full-rebuild aggregation semantics (signs, grouping, zero pruning)', function () {
+ const db = getDb();
+ const sql = db.queries[1];
+ assert.ok(/WHEN t\.type = 'credit' THEN CAST\(t\.amount AS DECIMAL\(65,18\)\) ELSE -CAST\(t\.amount AS DECIMAL\(65,18\)\) END/i.test(sql));
+ assert.ok(/GROUP BY address_id, tick_id/i.test(sql));
+ assert.ok(/HAVING SUM\(.*\) != 0/i.test(sql));
+ });
+
+ it('is a no-op when the scope is empty (nothing was touched)', async function () {
+ const empty = fakeDb();
+ await rebuildBalances(empty, { addressIds: [], tickIds: [] });
+ assert.strictEqual(empty.queries.length, 0);
+ });
+
+ it('an absent scope still issues the unscoped full rebuild', async function () {
+ const full = fakeDb();
+ await rebuildBalances(full);
+ assert.ok(/^DELETE FROM balances$/i.test(full.queries[0]));
+ assert.ok(!/WHERE/i.test(full.queries[0]));
+ });
+}
+
+function registerRecomputeUpdateTests(precisionDb) {
+ it('runs one UPDATE per distinct token precision, keyed by decimals', async function () {
+ const db = precisionDb([{ decimals: 8 }, { decimals: 0 }]);
+ await recomputeTokenSupplies(db);
+ const updates = db.queries.filter(q => /^UPDATE tokens/i.test(q));
+ assert.strictEqual(updates.length, 2, 'one UPDATE per distinct precision');
+ // Precision is the bind arg (a DECIMAL scale can't be parameterized, but the
+ // WHERE key can), so tokens are fixed precision-by-precision.
+ const updateArgs = db.argsLog.filter((_, i) => /^UPDATE tokens/i.test(db.queries[i]));
+ assert.deepStrictEqual(updateArgs.sort(), [[0], [8]]);
+ });
+
+ // Rows are summed EXACTLY (DECIMAL(60,18)) and the TOTAL is rounded once
+ // to the token's own scale: per-ROW rounding agreed with the indexer only
+ // while every amount sat on the token's grid, and inflates supply once
+ // fee amounts go finer than the tick.
+ it('sums (credits - debits) + escrows at the EXACT scale and rounds ONCE at the token scale', async function () {
+ const db = precisionDb([{ decimals: 8 }]);
+ await recomputeTokenSupplies(db);
+ const sql = db.queries.find(q => /^UPDATE tokens/i.test(q));
+ assert.ok(/FROM credits/i.test(sql) && /FROM debits/i.test(sql) && /FROM escrows/i.test(sql),
+ 'unions credits + debits + escrows (escrows folds into supply, unlike balances)');
+ assert.ok(/SELECT tick_id, ?CAST\(amount AS DECIMAL\(60,18\)\) AS amt FROM credits/i.test(sql), 'credit is positive');
+ assert.ok(/- ?CAST\(amount AS DECIMAL\(60,18\)\) AS amt FROM debits/i.test(sql), 'debit is negative');
+ assert.ok(/CAST\(amount AS DECIMAL\(60,18\)\) AS amt FROM escrows/i.test(sql), 'escrow is positive');
+ assert.ok(/CAST\(SUM\(amt\) AS DECIMAL\(60,8\)\)/i.test(sql),
+ 'the TOTAL is rounded once at the token scale, so it stays byte-identical to the source supply');
+ assert.ok(!/CAST\(amount AS DECIMAL\(60,8\)\)/i.test(sql),
+ 'must NOT round each ROW to the token scale (the per-row rounding overcharge shape)');
+ assert.ok(!/DECIMAL\(65,18\)/i.test(sql), 'must NOT use the fixed 65,18 scale (byte-identity needs the token scale)');
+ assert.ok(/GROUP BY tick_id/i.test(sql));
+ });
+}
+
+function registerRecomputeRenderingTests(precisionDb) {
+ it('LEFT JOINs so a token with no surviving ledger rows resolves to supply 0', async function () {
+ const db = precisionDb([{ decimals: 8 }]);
+ await recomputeTokenSupplies(db);
+ const sql = db.queries.find(q => /^UPDATE tokens/i.test(q));
+ assert.ok(/LEFT JOIN/i.test(sql), 'LEFT JOIN keeps tokens with no ledger rows');
+ assert.ok(/SET tok\.supply = COALESCE\(agg\.supply, '0'\)/i.test(sql), 'no-ledger token -> supply 0');
+ });
+
+ it('renders supply in the source minimal-decimal format for divisible tokens', async function () {
+ const db = precisionDb([{ decimals: 8 }]);
+ await recomputeTokenSupplies(db);
+ const sql = db.queries.find(q => /^UPDATE tokens/i.test(q));
+ assert.ok(/TRIM\(TRAILING '\.' FROM TRIM\(TRAILING '0' FROM CAST\(CAST\(SUM\(amt\) AS DECIMAL\(60,8\)\) AS CHAR\)\)\)/i.test(sql),
+ 'divisible: round the total once, then trim trailing zeros and the bare dot');
+ });
+
+ it('renders an integer (no zero-trim) for a non-divisible token (decimals=0)', async function () {
+ const db = precisionDb([{ decimals: 0 }]);
+ await recomputeTokenSupplies(db);
+ const sql = db.queries.find(q => /^UPDATE tokens/i.test(q));
+ // decimals=0: CAST AS CHAR has no '.', so the trailing-zero trim would eat
+ // integer zeros ('100' -> '1'); it must emit the integer verbatim.
+ assert.ok(/CAST\(CAST\(SUM\(amt\) AS DECIMAL\(60,0\)\) AS CHAR\) AS supply/i.test(sql));
+ assert.ok(!/TRIM\(TRAILING '0'/i.test(sql), 'no zero-trim on an integer supply');
+ });
+}
+
+function registerRecomputeGuardTests(precisionDb) {
+ it('clamps an out-of-range precision to [0,18] (no DECIMAL-scale injection)', async function () {
+ const db = precisionDb([{ decimals: 99 }]);
+ await recomputeTokenSupplies(db);
+ const sql = db.queries.find(q => /^UPDATE tokens/i.test(q));
+ assert.ok(/DECIMAL\(60,18\)/i.test(sql), 'clamped to the max scale');
+ assert.ok(!/DECIMAL\(60,99\)/i.test(sql));
+ });
+
+ it('is a no-op when there are no tokens', async function () {
+ const db = precisionDb([]);
+ await recomputeTokenSupplies(db);
+ assert.ok(!db.queries.some(q => /^UPDATE tokens/i.test(q)), 'no UPDATE without any token precisions');
+ });
+
+ it('rejects if the DB layer fails (caller owns error handling)', async function () {
+ const boom = { doQuery: async () => { throw new Error('1146 no table'); } };
+ await assert.rejects(() => recomputeTokenSupplies(boom), /1146/);
+ });
+}
+
describe('balance-helpers @money @regression', function () {
describe('rebuildBalances()', function () {
let db;
beforeEach(async function () { db = fakeDb(); await rebuildBalances(db); });
-
- it('clears the table before reinserting (DELETE then INSERT, in order)', function () {
- assert.strictEqual(db.queries.length, 2);
- assert.ok(/^DELETE FROM balances/i.test(db.queries[0]));
- assert.ok(/INSERT INTO balances \(address_id, tick_id, amount\)/i.test(db.queries[1]));
- });
-
- it('aggregates credits minus debits per (address_id, tick_id)', function () {
- const sql = db.queries[1];
- assert.ok(/FROM credits/i.test(sql) && /FROM debits/i.test(sql), 'unions credits + debits');
- assert.ok(/UNION ALL/i.test(sql));
- assert.ok(/WHEN t\.type = 'credit' THEN CAST\(t\.amount AS DECIMAL\(65,18\)\) ELSE -CAST\(t\.amount AS DECIMAL\(65,18\)\) END/i.test(sql), 'credit positive, debit negative');
- assert.ok(/GROUP BY address_id, tick_id/i.test(sql));
- });
-
- it('sums at DECIMAL(65,18) (bare VARCHAR amounts promote to DOUBLE and corrupt >16-digit amounts)', function () {
- const sql = db.queries[1];
- assert.ok(!/THEN t\.amount/i.test(sql), 'no un-cast amount may reach the SUM');
- assert.ok(/CAST\(t\.amount AS DECIMAL\(65,18\)\)/i.test(sql));
- });
-
- it('renders amounts in the source indexer\'s minimal-decimal format', function () {
- assert.ok(/TRIM\(TRAILING '\.' FROM TRIM\(TRAILING '0' FROM CAST\(/i.test(db.queries[1]),
- 'trailing zeros then the bare dot are trimmed so rebuilt bytes match source bytes');
- });
-
- it('prunes rows that net to exactly zero (HAVING ... != 0)', function () {
- assert.ok(/HAVING SUM\(.*\) != 0/i.test(db.queries[1]));
- });
-
- it('rejects if the DB layer fails (caller owns error handling)', async function () {
- const boom = { doQuery: async () => { throw new Error('1146 no table'); } };
- await assert.rejects(() => rebuildBalances(boom), /1146/);
- });
+ registerRebuildBalancesTests(() => db);
});
describe('rebuildBalances(): scoped', function () {
const scope = { addressIds: [7, 9], tickIds: [3] };
let db;
beforeEach(async function () { db = fakeDb(); await rebuildBalances(db, scope); });
-
- it('scopes the DELETE to the touched ids (parameterized IN-lists)', function () {
- assert.ok(/^DELETE FROM balances WHERE address_id IN \(\?, \?\) AND tick_id IN \(\?\)$/i.test(db.queries[0]));
- assert.deepStrictEqual(db.argsLog[0], [7, 9, 3]);
- });
-
- it('scopes BOTH union branches so the source scans stay bounded', function () {
- const sql = db.queries[1];
- const branchPreds = sql.match(/FROM (credits|debits) WHERE address_id IN \(\?, \?\) AND tick_id IN \(\?\)/gi) || [];
- assert.strictEqual(branchPreds.length, 2, 'credits and debits both scoped');
- assert.deepStrictEqual(db.argsLog[1], [7, 9, 3, 7, 9, 3], 'args repeated per branch');
- });
-
- it('keeps the full-rebuild aggregation semantics (signs, grouping, zero pruning)', function () {
- const sql = db.queries[1];
- assert.ok(/WHEN t\.type = 'credit' THEN CAST\(t\.amount AS DECIMAL\(65,18\)\) ELSE -CAST\(t\.amount AS DECIMAL\(65,18\)\) END/i.test(sql));
- assert.ok(/GROUP BY address_id, tick_id/i.test(sql));
- assert.ok(/HAVING SUM\(.*\) != 0/i.test(sql));
- });
-
- it('is a no-op when the scope is empty (nothing was touched)', async function () {
- const empty = fakeDb();
- await rebuildBalances(empty, { addressIds: [], tickIds: [] });
- assert.strictEqual(empty.queries.length, 0);
- });
-
- it('an absent scope still issues the unscoped full rebuild', async function () {
- const full = fakeDb();
- await rebuildBalances(full);
- assert.ok(/^DELETE FROM balances$/i.test(full.queries[0]));
- assert.ok(!/WHERE/i.test(full.queries[0]));
- });
+ registerScopedRebuildTests(() => db);
});
// Fix #5 (MED): ClientRollback rebuilt balances but not tokens.supply on reorg,
@@ -121,82 +218,9 @@ describe('balance-helpers @money @regression', function () {
};
}
- it('runs one UPDATE per distinct token precision, keyed by decimals', async function () {
- const db = precisionDb([{ decimals: 8 }, { decimals: 0 }]);
- await recomputeTokenSupplies(db);
- const updates = db.queries.filter(q => /^UPDATE tokens/i.test(q));
- assert.strictEqual(updates.length, 2, 'one UPDATE per distinct precision');
- // Precision is the bind arg (a DECIMAL scale can't be parameterized, but the
- // WHERE key can), so tokens are fixed precision-by-precision.
- const updateArgs = db.argsLog.filter((_, i) => /^UPDATE tokens/i.test(db.queries[i]));
- assert.deepStrictEqual(updateArgs.sort(), [[0], [8]]);
- });
-
- // Rows are summed EXACTLY (DECIMAL(60,18)) and the TOTAL is rounded once
- // to the token's own scale: per-ROW rounding agreed with the indexer only
- // while every amount sat on the token's grid, and inflates supply once
- // fee amounts go finer than the tick.
- it('sums (credits - debits) + escrows at the EXACT scale and rounds ONCE at the token scale', async function () {
- const db = precisionDb([{ decimals: 8 }]);
- await recomputeTokenSupplies(db);
- const sql = db.queries.find(q => /^UPDATE tokens/i.test(q));
- assert.ok(/FROM credits/i.test(sql) && /FROM debits/i.test(sql) && /FROM escrows/i.test(sql),
- 'unions credits + debits + escrows (escrows folds into supply, unlike balances)');
- assert.ok(/SELECT tick_id, ?CAST\(amount AS DECIMAL\(60,18\)\) AS amt FROM credits/i.test(sql), 'credit is positive');
- assert.ok(/- ?CAST\(amount AS DECIMAL\(60,18\)\) AS amt FROM debits/i.test(sql), 'debit is negative');
- assert.ok(/CAST\(amount AS DECIMAL\(60,18\)\) AS amt FROM escrows/i.test(sql), 'escrow is positive');
- assert.ok(/CAST\(SUM\(amt\) AS DECIMAL\(60,8\)\)/i.test(sql),
- 'the TOTAL is rounded once at the token scale, so it stays byte-identical to the source supply');
- assert.ok(!/CAST\(amount AS DECIMAL\(60,8\)\)/i.test(sql),
- 'must NOT round each ROW to the token scale (the per-row rounding overcharge shape)');
- assert.ok(!/DECIMAL\(65,18\)/i.test(sql), 'must NOT use the fixed 65,18 scale (byte-identity needs the token scale)');
- assert.ok(/GROUP BY tick_id/i.test(sql));
- });
-
- it('LEFT JOINs so a token with no surviving ledger rows resolves to supply 0', async function () {
- const db = precisionDb([{ decimals: 8 }]);
- await recomputeTokenSupplies(db);
- const sql = db.queries.find(q => /^UPDATE tokens/i.test(q));
- assert.ok(/LEFT JOIN/i.test(sql), 'LEFT JOIN keeps tokens with no ledger rows');
- assert.ok(/SET tok\.supply = COALESCE\(agg\.supply, '0'\)/i.test(sql), 'no-ledger token -> supply 0');
- });
-
- it('renders supply in the source minimal-decimal format for divisible tokens', async function () {
- const db = precisionDb([{ decimals: 8 }]);
- await recomputeTokenSupplies(db);
- const sql = db.queries.find(q => /^UPDATE tokens/i.test(q));
- assert.ok(/TRIM\(TRAILING '\.' FROM TRIM\(TRAILING '0' FROM CAST\(CAST\(SUM\(amt\) AS DECIMAL\(60,8\)\) AS CHAR\)\)\)/i.test(sql),
- 'divisible: round the total once, then trim trailing zeros and the bare dot');
- });
-
- it('renders an integer (no zero-trim) for a non-divisible token (decimals=0)', async function () {
- const db = precisionDb([{ decimals: 0 }]);
- await recomputeTokenSupplies(db);
- const sql = db.queries.find(q => /^UPDATE tokens/i.test(q));
- // decimals=0: CAST AS CHAR has no '.', so the trailing-zero trim would eat
- // integer zeros ('100' -> '1'); it must emit the integer verbatim.
- assert.ok(/CAST\(CAST\(SUM\(amt\) AS DECIMAL\(60,0\)\) AS CHAR\) AS supply/i.test(sql));
- assert.ok(!/TRIM\(TRAILING '0'/i.test(sql), 'no zero-trim on an integer supply');
- });
-
- it('clamps an out-of-range precision to [0,18] (no DECIMAL-scale injection)', async function () {
- const db = precisionDb([{ decimals: 99 }]);
- await recomputeTokenSupplies(db);
- const sql = db.queries.find(q => /^UPDATE tokens/i.test(q));
- assert.ok(/DECIMAL\(60,18\)/i.test(sql), 'clamped to the max scale');
- assert.ok(!/DECIMAL\(60,99\)/i.test(sql));
- });
-
- it('is a no-op when there are no tokens', async function () {
- const db = precisionDb([]);
- await recomputeTokenSupplies(db);
- assert.ok(!db.queries.some(q => /^UPDATE tokens/i.test(q)), 'no UPDATE without any token precisions');
- });
-
- it('rejects if the DB layer fails (caller owns error handling)', async function () {
- const boom = { doQuery: async () => { throw new Error('1146 no table'); } };
- await assert.rejects(() => recomputeTokenSupplies(boom), /1146/);
- });
+ registerRecomputeUpdateTests(precisionDb);
+ registerRecomputeRenderingTests(precisionDb);
+ registerRecomputeGuardTests(precisionDb);
});
});
diff --git a/test/unit/block_hasher.test.js b/test/unit/block_hasher.test.js
index dcf89422..b344aaec 100644
--- a/test/unit/block_hasher.test.js
+++ b/test/unit/block_hasher.test.js
@@ -26,10 +26,14 @@ const BlockHasher = require('../../src/client/block_hasher');
const Utility = require('../../src/util');
const vectors = require('../fixtures/block-hash-vectors.json');
-// A BlockHasher whose db.doQuery returns the canned result-sets in CALL ORDER.
+// A BlockHasher whose strict reader returns the canned result-sets in CALL ORDER.
+// The fail-soft reader throws, so a gather that regresses to doQuery fails here.
function hasherFor(results){
let i = 0;
- const db = { doQuery: async () => results[i++] };
+ const db = {
+ doQuery: async () => { throw new Error('fail-soft doQuery used for a consensus preimage gather'); },
+ doQueryStrict: async () => results[i++]
+ };
return new BlockHasher(db, new Utility());
}
@@ -66,6 +70,21 @@ describe('BlockHasher: independent recompute conformance @regression', function(
assert.ok(got.ledger_hash && got.actions_hash && got.contract_hash,
'empty block still yields the three chained hashes');
});
+
+ it('a read error rejects instead of hashing a truncated preimage', async function(){
+ // Model the production reader: doQuery swallows the error into [], doQueryStrict throws.
+ let call = 0;
+ const db = {
+ doQuery: async () => [],
+ doQueryStrict: async () => {
+ if(++call === 3) throw new Error('lock wait timeout on escrows');
+ return [];
+ }
+ };
+ await assert.rejects(new BlockHasher(db, new Utility()).computeBlockHashes(vectors.block_index),
+ /lock wait timeout on escrows/,
+ 'a failed gather must surface as a recompute error, never as an empty row set');
+ });
});
describe('BlockHasher: independent recompute conformance @regression', function(){
@@ -77,7 +96,8 @@ describe('BlockHasher: independent recompute conformance @regression', function(
// Capture emitted SQL while feeding empty result-sets.
function capturingHasher(calls){
- const db = { doQuery: async (sql) => { calls.push(sql); return []; } };
+ const capture = async (sql) => { calls.push(sql); return []; };
+ const db = { doQuery: capture, doQueryStrict: capture };
return new BlockHasher(db, new Utility());
}
const stateQueryOf = (calls) => {
diff --git a/test/unit/boundaries/circuit_breaker.test.js b/test/unit/boundaries/circuit_breaker.test.js
index 13a59fb8..ef1ef75d 100644
--- a/test/unit/boundaries/circuit_breaker.test.js
+++ b/test/unit/boundaries/circuit_breaker.test.js
@@ -10,32 +10,11 @@
const assert = require('assert');
const sinon = require('sinon');
-const Utility = require('../../../src/util');
-
-// We test the circuit breaker logic by constructing a Database instance
-// with a stubbed pool. Since the mariadb import is at module level, we
-// use proxyquire to inject a mock pool.
-const proxyquire = require('proxyquire');
-
-function createDatabase(poolStub){
- let mockMariadb = {
- createPool: sinon.stub().returns(poolStub)
- };
- let Database = proxyquire('../../../src/db', { 'mariadb': mockMariadb });
- let util = new Utility();
- return new Database('localhost', 3306, 'testdb', 'user', 'pass', util);
-}
+const { createDatabase, registerHooks } = require('./circuit_breaker.test/helpers/circuit_breaker_suite');
describe('Boundary: Circuit Breaker', function(){
-
let db, pool;
-
- beforeEach(function(){
- sinon.stub(console, 'log');
- sinon.stub(console, 'error');
- });
-
- afterEach(function(){ sinon.restore(); });
+ registerHooks();
describe('failure threshold (10)', function(){
it('9 failures: circuit stays closed', async function(){
@@ -78,158 +57,4 @@ describe('Boundary: Circuit Breaker', function(){
assert.strictEqual(db.circuitFailures, 10);
});
});
-
- describe('circuit open rejection', function(){
- it('rejects immediately during cooldown', async function(){
- pool = {
- getConnection: sinon.stub().rejects(new Error('fail')),
- end: sinon.stub()
- };
- db = createDatabase(pool);
- sinon.stub(db.util, 'sleep').resolves();
-
- // Open the circuit
- try { await db.getConnection(); } catch(e) {}
- assert.strictEqual(db.circuitState, 'open');
-
- // Set cooldown to future
- db.circuitOpenUntil = Date.now() + 30000;
-
- await assert.rejects(
- () => db.getConnection(),
- (err) => {
- let msg = (err && err.message) ? err.message : String(err);
- return msg.includes('Circuit breaker open');
- }
- );
- });
- });
-
- describe('half-open recovery', function(){
- it('transitions to half-open after cooldown expires', async function(){
- let conn = { release: sinon.stub() };
- pool = {
- getConnection: sinon.stub().resolves(conn),
- end: sinon.stub()
- };
- db = createDatabase(pool);
-
- // Simulate open circuit with expired cooldown
- db.circuitState = 'open';
- db.circuitFailures = 10;
- db.circuitOpenUntil = Date.now() - 1; // expired
-
- let result = await db.getConnection();
- assert.strictEqual(result, conn);
- assert.strictEqual(db.circuitState, 'closed');
- assert.strictEqual(db.circuitFailures, 0);
- });
-
- it('re-opens on failure during half-open', async function(){
- pool = {
- getConnection: sinon.stub().rejects(new Error('still down')),
- end: sinon.stub()
- };
- db = createDatabase(pool);
- sinon.stub(db.util, 'sleep').resolves();
-
- // Simulate half-open state
- db.circuitState = 'open';
- db.circuitFailures = 9;
- db.circuitOpenUntil = Date.now() - 1;
-
- await assert.rejects(() => db.getConnection());
- assert.strictEqual(db.circuitState, 'open');
- });
- });
-
- describe('max retry attempts (30)', function(){
- it('throws after 30 attempts without reaching circuit threshold', async function(){
- let callCount = 0;
- pool = {
- getConnection: sinon.stub().callsFake(async () => {
- callCount++;
- throw new Error('fail');
- }),
- end: sinon.stub()
- };
- db = createDatabase(pool);
- sinon.stub(db.util, 'sleep').resolves();
- // Set high threshold so circuit doesn't trip first
- db.circuitThreshold = 100;
-
- await assert.rejects(
- () => db.getConnection(),
- (err) => {
- let msg = (err && err.message) ? err.message : String(err);
- return msg.includes('30 attempts');
- }
- );
- assert.strictEqual(callCount, 30);
- });
- });
-
- describe('backoff delay calculation', function(){
- it('uses correct exponential progression', async function(){
- let delays = [];
- pool = {
- getConnection: sinon.stub().rejects(new Error('fail')),
- end: sinon.stub()
- };
- db = createDatabase(pool);
- db.circuitThreshold = 100; // prevent circuit from tripping
- sinon.stub(db.util, 'sleep').callsFake(async (ms) => { delays.push(ms); });
- sinon.stub(Math, 'random').returns(0); // no jitter
-
- try { await db.getConnection(); } catch(e) {}
-
- // First 6 delays: 500, 1000, 2000, 4000, 8000, 15000 (capped)
- assert.strictEqual(delays[0], 500);
- assert.strictEqual(delays[1], 1000);
- assert.strictEqual(delays[2], 2000);
- assert.strictEqual(delays[3], 4000);
- assert.strictEqual(delays[4], 8000);
- assert.strictEqual(delays[5], 15000); // capped at maxDelay
-
- // All subsequent delays should also be 15000 (capped)
- for(let i = 6; i < delays.length; i++){
- assert.strictEqual(delays[i], 15000);
- }
- });
-
- it('adds up to 30% jitter', async function(){
- let delays = [];
- pool = {
- getConnection: sinon.stub().rejects(new Error('fail')),
- end: sinon.stub()
- };
- db = createDatabase(pool);
- db.circuitThreshold = 100;
- sinon.stub(db.util, 'sleep').callsFake(async (ms) => { delays.push(ms); });
- sinon.stub(Math, 'random').returns(1); // max jitter
-
- try { await db.getConnection(); } catch(e) {}
-
- // First delay: 500 + floor(1 * 500 * 0.3) = 500 + 150 = 650
- assert.strictEqual(delays[0], 650);
- // Second: 1000 + floor(1 * 1000 * 0.3) = 1000 + 300 = 1300
- assert.strictEqual(delays[1], 1300);
- });
- });
-
- describe('transaction connection bypass', function(){
- it('returns transactionConnection when set (bypasses pool)', async function(){
- let txConn = { release: sinon.stub() };
- pool = {
- getConnection: sinon.stub().rejects(new Error('should not be called')),
- end: sinon.stub()
- };
- db = createDatabase(pool);
- db.transactionConnection = txConn;
-
- let result = await db.getConnection();
- assert.strictEqual(result, txConn);
- assert.strictEqual(pool.getConnection.called, false);
- });
- });
});
diff --git a/test/unit/boundaries/circuit_breaker.test/02_open_rejection.test.js b/test/unit/boundaries/circuit_breaker.test/02_open_rejection.test.js
new file mode 100644
index 00000000..b97dcd65
--- /dev/null
+++ b/test/unit/boundaries/circuit_breaker.test/02_open_rejection.test.js
@@ -0,0 +1,45 @@
+// 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.
+
+// Covers open-circuit rejection. One part of circuit_breaker.test.js.
+const assert = require('assert');
+const sinon = require('sinon');
+const { createDatabase, registerHooks } = require('./helpers/circuit_breaker_suite');
+
+describe('Boundary: Circuit Breaker', function(){
+ let db, pool;
+ registerHooks();
+
+ describe('circuit open rejection', function(){
+ it('rejects immediately during cooldown', async function(){
+ pool = {
+ getConnection: sinon.stub().rejects(new Error('fail')),
+ end: sinon.stub()
+ };
+ db = createDatabase(pool);
+ sinon.stub(db.util, 'sleep').resolves();
+
+ // Open the circuit
+ try { await db.getConnection(); } catch(e) {}
+ assert.strictEqual(db.circuitState, 'open');
+
+ // Set cooldown to future
+ db.circuitOpenUntil = Date.now() + 30000;
+
+ await assert.rejects(
+ () => db.getConnection(),
+ (err) => {
+ let msg = (err && err.message) ? err.message : String(err);
+ return msg.includes('Circuit breaker open');
+ }
+ );
+ });
+ });
+});
diff --git a/test/unit/boundaries/circuit_breaker.test/03_half_open.test.js b/test/unit/boundaries/circuit_breaker.test/03_half_open.test.js
new file mode 100644
index 00000000..382a7238
--- /dev/null
+++ b/test/unit/boundaries/circuit_breaker.test/03_half_open.test.js
@@ -0,0 +1,57 @@
+// 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.
+
+// Covers half-open recovery. One part of circuit_breaker.test.js.
+const assert = require('assert');
+const sinon = require('sinon');
+const { createDatabase, registerHooks } = require('./helpers/circuit_breaker_suite');
+
+describe('Boundary: Circuit Breaker', function(){
+ let db, pool;
+ registerHooks();
+
+ describe('half-open recovery', function(){
+ it('transitions to half-open after cooldown expires', async function(){
+ let conn = { release: sinon.stub() };
+ pool = {
+ getConnection: sinon.stub().resolves(conn),
+ end: sinon.stub()
+ };
+ db = createDatabase(pool);
+
+ // Simulate open circuit with expired cooldown
+ db.circuitState = 'open';
+ db.circuitFailures = 10;
+ db.circuitOpenUntil = Date.now() - 1; // expired
+
+ let result = await db.getConnection();
+ assert.strictEqual(result, conn);
+ assert.strictEqual(db.circuitState, 'closed');
+ assert.strictEqual(db.circuitFailures, 0);
+ });
+
+ it('re-opens on failure during half-open', async function(){
+ pool = {
+ getConnection: sinon.stub().rejects(new Error('still down')),
+ end: sinon.stub()
+ };
+ db = createDatabase(pool);
+ sinon.stub(db.util, 'sleep').resolves();
+
+ // Simulate half-open state
+ db.circuitState = 'open';
+ db.circuitFailures = 9;
+ db.circuitOpenUntil = Date.now() - 1;
+
+ await assert.rejects(() => db.getConnection());
+ assert.strictEqual(db.circuitState, 'open');
+ });
+ });
+});
diff --git a/test/unit/boundaries/circuit_breaker.test/04_max_retry.test.js b/test/unit/boundaries/circuit_breaker.test/04_max_retry.test.js
new file mode 100644
index 00000000..b829da12
--- /dev/null
+++ b/test/unit/boundaries/circuit_breaker.test/04_max_retry.test.js
@@ -0,0 +1,45 @@
+// 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.
+
+// Covers retry exhaustion. One part of circuit_breaker.test.js.
+const assert = require('assert');
+const sinon = require('sinon');
+const { createDatabase, registerHooks } = require('./helpers/circuit_breaker_suite');
+
+describe('Boundary: Circuit Breaker', function(){
+ let db, pool;
+ registerHooks();
+
+ describe('max retry attempts (30)', function(){
+ it('throws after 30 attempts without reaching circuit threshold', async function(){
+ let callCount = 0;
+ pool = {
+ getConnection: sinon.stub().callsFake(async () => {
+ callCount++;
+ throw new Error('fail');
+ }),
+ end: sinon.stub()
+ };
+ db = createDatabase(pool);
+ sinon.stub(db.util, 'sleep').resolves();
+ // Set high threshold so circuit doesn't trip first
+ db.circuitThreshold = 100;
+
+ await assert.rejects(
+ () => db.getConnection(),
+ (err) => {
+ let msg = (err && err.message) ? err.message : String(err);
+ return msg.includes('30 attempts');
+ }
+ );
+ assert.strictEqual(callCount, 30);
+ });
+ });
+});
diff --git a/test/unit/boundaries/circuit_breaker.test/05_backoff.test.js b/test/unit/boundaries/circuit_breaker.test/05_backoff.test.js
new file mode 100644
index 00000000..46799577
--- /dev/null
+++ b/test/unit/boundaries/circuit_breaker.test/05_backoff.test.js
@@ -0,0 +1,67 @@
+// 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.
+
+// Covers backoff timing. One part of circuit_breaker.test.js.
+const assert = require('assert');
+const sinon = require('sinon');
+const { createDatabase, registerHooks } = require('./helpers/circuit_breaker_suite');
+
+describe('Boundary: Circuit Breaker', function(){
+ let db, pool;
+ registerHooks();
+
+ describe('backoff delay calculation', function(){
+ it('uses correct exponential progression', async function(){
+ let delays = [];
+ pool = {
+ getConnection: sinon.stub().rejects(new Error('fail')),
+ end: sinon.stub()
+ };
+ db = createDatabase(pool);
+ db.circuitThreshold = 100; // prevent circuit from tripping
+ sinon.stub(db.util, 'sleep').callsFake(async (ms) => { delays.push(ms); });
+ sinon.stub(Math, 'random').returns(0); // no jitter
+
+ try { await db.getConnection(); } catch(e) {}
+
+ // First 6 delays: 500, 1000, 2000, 4000, 8000, 15000 (capped)
+ assert.strictEqual(delays[0], 500);
+ assert.strictEqual(delays[1], 1000);
+ assert.strictEqual(delays[2], 2000);
+ assert.strictEqual(delays[3], 4000);
+ assert.strictEqual(delays[4], 8000);
+ assert.strictEqual(delays[5], 15000); // capped at maxDelay
+
+ // All subsequent delays should also be 15000 (capped)
+ for(let i = 6; i < delays.length; i++){
+ assert.strictEqual(delays[i], 15000);
+ }
+ });
+
+ it('adds up to 30% jitter', async function(){
+ let delays = [];
+ pool = {
+ getConnection: sinon.stub().rejects(new Error('fail')),
+ end: sinon.stub()
+ };
+ db = createDatabase(pool);
+ db.circuitThreshold = 100;
+ sinon.stub(db.util, 'sleep').callsFake(async (ms) => { delays.push(ms); });
+ sinon.stub(Math, 'random').returns(1); // max jitter
+
+ try { await db.getConnection(); } catch(e) {}
+
+ // First delay: 500 + floor(1 * 500 * 0.3) = 500 + 150 = 650
+ assert.strictEqual(delays[0], 650);
+ // Second: 1000 + floor(1 * 1000 * 0.3) = 1000 + 300 = 1300
+ assert.strictEqual(delays[1], 1300);
+ });
+ });
+});
diff --git a/test/unit/boundaries/circuit_breaker.test/06_transaction.test.js b/test/unit/boundaries/circuit_breaker.test/06_transaction.test.js
new file mode 100644
index 00000000..ec98adf9
--- /dev/null
+++ b/test/unit/boundaries/circuit_breaker.test/06_transaction.test.js
@@ -0,0 +1,35 @@
+// 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.
+
+// Covers transaction connection bypass. One part of circuit_breaker.test.js.
+const assert = require('assert');
+const sinon = require('sinon');
+const { createDatabase, registerHooks } = require('./helpers/circuit_breaker_suite');
+
+describe('Boundary: Circuit Breaker', function(){
+ let db, pool;
+ registerHooks();
+
+ describe('transaction connection bypass', function(){
+ it('returns transactionConnection when set (bypasses pool)', async function(){
+ let txConn = { release: sinon.stub() };
+ pool = {
+ getConnection: sinon.stub().rejects(new Error('should not be called')),
+ end: sinon.stub()
+ };
+ db = createDatabase(pool);
+ db.transactionConnection = txConn;
+
+ let result = await db.getConnection();
+ assert.strictEqual(result, txConn);
+ assert.strictEqual(pool.getConnection.called, false);
+ });
+ });
+});
diff --git a/test/unit/boundaries/circuit_breaker.test/helpers/circuit_breaker_suite.js b/test/unit/boundaries/circuit_breaker.test/helpers/circuit_breaker_suite.js
new file mode 100644
index 00000000..fff62f9c
--- /dev/null
+++ b/test/unit/boundaries/circuit_breaker.test/helpers/circuit_breaker_suite.js
@@ -0,0 +1,36 @@
+// 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.
+
+// Covers shared fixtures and hooks. One part of circuit_breaker.test.js.
+const sinon = require('sinon');
+const Utility = require('../../../../../src/util');
+const proxyquire = require('proxyquire');
+
+// We test the circuit breaker logic by constructing a Database instance
+// with a stubbed pool. Since the mariadb import is at module level, we
+// use proxyquire to inject a mock pool.
+function createDatabase(poolStub){
+ let mockMariadb = {
+ createPool: sinon.stub().returns(poolStub)
+ };
+ let Database = proxyquire('../../../../../src/db', { 'mariadb': mockMariadb });
+ let util = new Utility();
+ return new Database('localhost', 3306, 'testdb', 'user', 'pass', util);
+}
+
+function registerHooks(){
+ beforeEach(function(){
+ sinon.stub(console, 'log');
+ sinon.stub(console, 'error');
+ });
+ afterEach(function(){ sinon.restore(); });
+}
+
+module.exports = { createDatabase, registerHooks };
diff --git a/test/unit/boundaries/config_parsing.test.js b/test/unit/boundaries/config_parsing.test.js
index 468a7d90..ead5e7b7 100644
--- a/test/unit/boundaries/config_parsing.test.js
+++ b/test/unit/boundaries/config_parsing.test.js
@@ -10,33 +10,10 @@
const assert = require('assert');
const config = require('../../../src/config');
+const { registerHooks } = require('./config_parsing.test/helpers/config_parsing_suite');
describe('Boundary: Config Parsing', function(){
-
- const ENV_KEYS = [
- 'SYNC_MODE', 'SYNC_API_PORT', 'HUB_API_HOST', 'HUB_PORT',
- 'CORS_ORIGIN', 'BLOCK_POLL_INTERVAL', 'WS_MAX_PER_IP',
- 'SNAPSHOT_RATE_FULL', 'SNAPSHOT_RATE_INCR', 'SYNC_SOURCES',
- 'VERIFY_HASHES', 'REPLICA_DB_HOST', 'REPLICA_DB_PORT',
- 'REPLICA_DB_USER', 'REPLICA_DB_PASS'
- ];
- let savedEnv = {};
-
- beforeEach(function(){
- for(let key of ENV_KEYS){
- savedEnv[key] = process.env[key];
- delete process.env[key];
- }
- });
-
- afterEach(function(){
- for(let key of ENV_KEYS){
- if(savedEnv[key] !== undefined)
- process.env[key] = savedEnv[key];
- else
- delete process.env[key];
- }
- });
+ registerHooks();
describe('zero values (falsy-zero)', function(){
it('preserves SYNC_API_PORT=0', function(){
@@ -59,148 +36,4 @@ describe('Boundary: Config Parsing', function(){
assert.strictEqual(config.getConfig().SNAPSHOT_RATE_INCR, 0);
});
});
-
- describe('negative values clamped', function(){
- it('clamps WS_MAX_PER_IP=-1 to 1', function(){
- process.env.WS_MAX_PER_IP = '-1';
- assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 1);
- });
-
- it('clamps HUB_PORT=-1 to 1', function(){
- process.env.HUB_PORT = '-1';
- assert.strictEqual(config.getConfig().HUB_PORT, 1);
- });
-
- it('clamps REPLICA_DB_PORT=-1 to 1', function(){
- process.env.REPLICA_DB_PORT = '-1';
- assert.strictEqual(config.getConfig().REPLICA_DB_PORT, 1);
- });
-
- it('clamps BLOCK_POLL_INTERVAL=-100 to 0', function(){
- process.env.BLOCK_POLL_INTERVAL = '-100';
- assert.strictEqual(config.getConfig().BLOCK_POLL_INTERVAL, 0);
- });
-
- it('clamps SNAPSHOT_RATE_FULL=-5 to 0', function(){
- process.env.SNAPSHOT_RATE_FULL = '-5';
- assert.strictEqual(config.getConfig().SNAPSHOT_RATE_FULL, 0);
- });
- });
-
- describe('NaN inputs default gracefully', function(){
- it('SYNC_API_PORT=abc defaults to 3006', function(){
- process.env.SYNC_API_PORT = 'abc';
- assert.strictEqual(config.getConfig().SYNC_API_PORT, 3006);
- });
-
- it('WS_MAX_PER_IP=xyz defaults to 100', function(){
- process.env.WS_MAX_PER_IP = 'xyz';
- assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 100);
- });
-
- it('HUB_PORT="" defaults to 10000', function(){
- process.env.HUB_PORT = '';
- assert.strictEqual(config.getConfig().HUB_PORT, 10000);
- });
-
- it('BLOCK_POLL_INTERVAL=undefined defaults to 3000', function(){
- delete process.env.BLOCK_POLL_INTERVAL;
- assert.strictEqual(config.getConfig().BLOCK_POLL_INTERVAL, 3000);
- });
- });
-
- describe('float strings truncated', function(){
- it('WS_MAX_PER_IP=3.7 becomes 3', function(){
- process.env.WS_MAX_PER_IP = '3.7';
- assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 3);
- });
-
- it('SYNC_API_PORT=3006.5 becomes 3006', function(){
- process.env.SYNC_API_PORT = '3006.5';
- assert.strictEqual(config.getConfig().SYNC_API_PORT, 3006);
- });
- });
-
- describe('large values', function(){
- it('SNAPSHOT_RATE_FULL=999999 preserved', function(){
- process.env.SNAPSHOT_RATE_FULL = '999999';
- assert.strictEqual(config.getConfig().SNAPSHOT_RATE_FULL, 999999);
- });
-
- it('BLOCK_POLL_INTERVAL=2147483647 preserved', function(){
- process.env.BLOCK_POLL_INTERVAL = '2147483647';
- assert.strictEqual(config.getConfig().BLOCK_POLL_INTERVAL, 2147483647);
- });
- });
-
- describe('VERIFY_HASHES case insensitivity', function(){
- it('"false" disables', function(){
- process.env.VERIFY_HASHES = 'false';
- assert.strictEqual(config.getConfig().VERIFY_HASHES, false);
- });
-
- it('"FALSE" disables', function(){
- process.env.VERIFY_HASHES = 'FALSE';
- assert.strictEqual(config.getConfig().VERIFY_HASHES, false);
- });
-
- it('"False" disables', function(){
- process.env.VERIFY_HASHES = 'False';
- assert.strictEqual(config.getConfig().VERIFY_HASHES, false);
- });
-
- it('"0" does NOT disable (not the word false)', function(){
- process.env.VERIFY_HASHES = '0';
- assert.strictEqual(config.getConfig().VERIFY_HASHES, true);
- });
-
- it('unset defaults to true', function(){
- delete process.env.VERIFY_HASHES;
- assert.strictEqual(config.getConfig().VERIFY_HASHES, true);
- });
- });
-
- describe('SYNC_SOURCES parsing boundaries', function(){
- it('empty string yields empty after getConfig', function(){
- process.env.SYNC_SOURCES = '';
- assert.strictEqual(config.getConfig().SYNC_SOURCES, '');
- });
-
- it('single URL preserved', function(){
- process.env.SYNC_SOURCES = 'http://server1';
- assert.strictEqual(config.getConfig().SYNC_SOURCES, 'http://server1');
- });
-
- it('trailing comma preserved in raw config (parsed by ClientSync)', function(){
- process.env.SYNC_SOURCES = 'http://s1,';
- assert.strictEqual(config.getConfig().SYNC_SOURCES, 'http://s1,');
- });
-
- it('whitespace preserved in raw config (parsed by ClientSync)', function(){
- process.env.SYNC_SOURCES = ' http://s1 , http://s2 ';
- assert.strictEqual(config.getConfig().SYNC_SOURCES, ' http://s1 , http://s2 ');
- });
- });
-
- describe('minimum-one clamping for ports', function(){
- it('WS_MAX_PER_IP=0 clamps to 1', function(){
- process.env.WS_MAX_PER_IP = '0';
- assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 1);
- });
-
- it('WS_MAX_PER_IP=1 stays 1', function(){
- process.env.WS_MAX_PER_IP = '1';
- assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 1);
- });
-
- it('HUB_PORT=0 clamps to 1', function(){
- process.env.HUB_PORT = '0';
- assert.strictEqual(config.getConfig().HUB_PORT, 1);
- });
-
- it('REPLICA_DB_PORT=0 clamps to 1', function(){
- process.env.REPLICA_DB_PORT = '0';
- assert.strictEqual(config.getConfig().REPLICA_DB_PORT, 1);
- });
- });
});
diff --git a/test/unit/boundaries/config_parsing.test/02_negative.test.js b/test/unit/boundaries/config_parsing.test/02_negative.test.js
new file mode 100644
index 00000000..ccdcd66b
--- /dev/null
+++ b/test/unit/boundaries/config_parsing.test/02_negative.test.js
@@ -0,0 +1,40 @@
+// 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.
+
+// Covers negative values. One part of config_parsing.test.js.
+const assert = require('assert');
+const config = require('../../../../src/config');
+const { registerHooks } = require('./helpers/config_parsing_suite');
+
+describe('Boundary: Config Parsing', function(){
+ registerHooks();
+ describe('negative values clamped', function(){
+ it('clamps WS_MAX_PER_IP=-1 to 1', function(){
+ process.env.WS_MAX_PER_IP = '-1';
+ assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 1);
+ });
+ it('clamps HUB_PORT=-1 to 1', function(){
+ process.env.HUB_PORT = '-1';
+ assert.strictEqual(config.getConfig().HUB_PORT, 1);
+ });
+ it('clamps REPLICA_DB_PORT=-1 to 1', function(){
+ process.env.REPLICA_DB_PORT = '-1';
+ assert.strictEqual(config.getConfig().REPLICA_DB_PORT, 1);
+ });
+ it('clamps BLOCK_POLL_INTERVAL=-100 to 0', function(){
+ process.env.BLOCK_POLL_INTERVAL = '-100';
+ assert.strictEqual(config.getConfig().BLOCK_POLL_INTERVAL, 0);
+ });
+ it('clamps SNAPSHOT_RATE_FULL=-5 to 0', function(){
+ process.env.SNAPSHOT_RATE_FULL = '-5';
+ assert.strictEqual(config.getConfig().SNAPSHOT_RATE_FULL, 0);
+ });
+ });
+});
diff --git a/test/unit/boundaries/config_parsing.test/03_nan.test.js b/test/unit/boundaries/config_parsing.test/03_nan.test.js
new file mode 100644
index 00000000..81470bfa
--- /dev/null
+++ b/test/unit/boundaries/config_parsing.test/03_nan.test.js
@@ -0,0 +1,36 @@
+// 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.
+
+// Covers invalid numeric inputs. One part of config_parsing.test.js.
+const assert = require('assert');
+const config = require('../../../../src/config');
+const { registerHooks } = require('./helpers/config_parsing_suite');
+
+describe('Boundary: Config Parsing', function(){
+ registerHooks();
+ describe('NaN inputs default gracefully', function(){
+ it('SYNC_API_PORT=abc defaults to 3006', function(){
+ process.env.SYNC_API_PORT = 'abc';
+ assert.strictEqual(config.getConfig().SYNC_API_PORT, 3006);
+ });
+ it('WS_MAX_PER_IP=xyz defaults to 100', function(){
+ process.env.WS_MAX_PER_IP = 'xyz';
+ assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 100);
+ });
+ it('HUB_PORT="" defaults to 10000', function(){
+ process.env.HUB_PORT = '';
+ assert.strictEqual(config.getConfig().HUB_PORT, 10000);
+ });
+ it('BLOCK_POLL_INTERVAL=undefined defaults to 3000', function(){
+ delete process.env.BLOCK_POLL_INTERVAL;
+ assert.strictEqual(config.getConfig().BLOCK_POLL_INTERVAL, 3000);
+ });
+ });
+});
diff --git a/test/unit/boundaries/config_parsing.test/04_numeric_ranges.test.js b/test/unit/boundaries/config_parsing.test/04_numeric_ranges.test.js
new file mode 100644
index 00000000..8bbc6a38
--- /dev/null
+++ b/test/unit/boundaries/config_parsing.test/04_numeric_ranges.test.js
@@ -0,0 +1,38 @@
+// 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.
+
+// Covers float and large values. One part of config_parsing.test.js.
+const assert = require('assert');
+const config = require('../../../../src/config');
+const { registerHooks } = require('./helpers/config_parsing_suite');
+
+describe('Boundary: Config Parsing', function(){
+ registerHooks();
+ describe('float strings truncated', function(){
+ it('WS_MAX_PER_IP=3.7 becomes 3', function(){
+ process.env.WS_MAX_PER_IP = '3.7';
+ assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 3);
+ });
+ it('SYNC_API_PORT=3006.5 becomes 3006', function(){
+ process.env.SYNC_API_PORT = '3006.5';
+ assert.strictEqual(config.getConfig().SYNC_API_PORT, 3006);
+ });
+ });
+ describe('large values', function(){
+ it('SNAPSHOT_RATE_FULL=999999 preserved', function(){
+ process.env.SNAPSHOT_RATE_FULL = '999999';
+ assert.strictEqual(config.getConfig().SNAPSHOT_RATE_FULL, 999999);
+ });
+ it('BLOCK_POLL_INTERVAL=2147483647 preserved', function(){
+ process.env.BLOCK_POLL_INTERVAL = '2147483647';
+ assert.strictEqual(config.getConfig().BLOCK_POLL_INTERVAL, 2147483647);
+ });
+ });
+});
diff --git a/test/unit/boundaries/config_parsing.test/05_verify_hashes.test.js b/test/unit/boundaries/config_parsing.test/05_verify_hashes.test.js
new file mode 100644
index 00000000..db0a6388
--- /dev/null
+++ b/test/unit/boundaries/config_parsing.test/05_verify_hashes.test.js
@@ -0,0 +1,40 @@
+// 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.
+
+// Covers hash verification parsing. One part of config_parsing.test.js.
+const assert = require('assert');
+const config = require('../../../../src/config');
+const { registerHooks } = require('./helpers/config_parsing_suite');
+
+describe('Boundary: Config Parsing', function(){
+ registerHooks();
+ describe('VERIFY_HASHES case insensitivity', function(){
+ it('"false" disables', function(){
+ process.env.VERIFY_HASHES = 'false';
+ assert.strictEqual(config.getConfig().VERIFY_HASHES, false);
+ });
+ it('"FALSE" disables', function(){
+ process.env.VERIFY_HASHES = 'FALSE';
+ assert.strictEqual(config.getConfig().VERIFY_HASHES, false);
+ });
+ it('"False" disables', function(){
+ process.env.VERIFY_HASHES = 'False';
+ assert.strictEqual(config.getConfig().VERIFY_HASHES, false);
+ });
+ it('"0" does NOT disable (not the word false)', function(){
+ process.env.VERIFY_HASHES = '0';
+ assert.strictEqual(config.getConfig().VERIFY_HASHES, true);
+ });
+ it('unset defaults to true', function(){
+ delete process.env.VERIFY_HASHES;
+ assert.strictEqual(config.getConfig().VERIFY_HASHES, true);
+ });
+ });
+});
diff --git a/test/unit/boundaries/config_parsing.test/06_sync_sources.test.js b/test/unit/boundaries/config_parsing.test/06_sync_sources.test.js
new file mode 100644
index 00000000..0588001c
--- /dev/null
+++ b/test/unit/boundaries/config_parsing.test/06_sync_sources.test.js
@@ -0,0 +1,36 @@
+// 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.
+
+// Covers sync source parsing. One part of config_parsing.test.js.
+const assert = require('assert');
+const config = require('../../../../src/config');
+const { registerHooks } = require('./helpers/config_parsing_suite');
+
+describe('Boundary: Config Parsing', function(){
+ registerHooks();
+ describe('SYNC_SOURCES parsing boundaries', function(){
+ it('empty string yields empty after getConfig', function(){
+ process.env.SYNC_SOURCES = '';
+ assert.strictEqual(config.getConfig().SYNC_SOURCES, '');
+ });
+ it('single URL preserved', function(){
+ process.env.SYNC_SOURCES = 'http://server1';
+ assert.strictEqual(config.getConfig().SYNC_SOURCES, 'http://server1');
+ });
+ it('trailing comma preserved in raw config (parsed by ClientSync)', function(){
+ process.env.SYNC_SOURCES = 'http://s1,';
+ assert.strictEqual(config.getConfig().SYNC_SOURCES, 'http://s1,');
+ });
+ it('whitespace preserved in raw config (parsed by ClientSync)', function(){
+ process.env.SYNC_SOURCES = ' http://s1 , http://s2 ';
+ assert.strictEqual(config.getConfig().SYNC_SOURCES, ' http://s1 , http://s2 ');
+ });
+ });
+});
diff --git a/test/unit/boundaries/config_parsing.test/07_minimum_ports.test.js b/test/unit/boundaries/config_parsing.test/07_minimum_ports.test.js
new file mode 100644
index 00000000..c858d978
--- /dev/null
+++ b/test/unit/boundaries/config_parsing.test/07_minimum_ports.test.js
@@ -0,0 +1,36 @@
+// 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.
+
+// Covers minimum port values. One part of config_parsing.test.js.
+const assert = require('assert');
+const config = require('../../../../src/config');
+const { registerHooks } = require('./helpers/config_parsing_suite');
+
+describe('Boundary: Config Parsing', function(){
+ registerHooks();
+ describe('minimum-one clamping for ports', function(){
+ it('WS_MAX_PER_IP=0 clamps to 1', function(){
+ process.env.WS_MAX_PER_IP = '0';
+ assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 1);
+ });
+ it('WS_MAX_PER_IP=1 stays 1', function(){
+ process.env.WS_MAX_PER_IP = '1';
+ assert.strictEqual(config.getConfig().WS_MAX_PER_IP, 1);
+ });
+ it('HUB_PORT=0 clamps to 1', function(){
+ process.env.HUB_PORT = '0';
+ assert.strictEqual(config.getConfig().HUB_PORT, 1);
+ });
+ it('REPLICA_DB_PORT=0 clamps to 1', function(){
+ process.env.REPLICA_DB_PORT = '0';
+ assert.strictEqual(config.getConfig().REPLICA_DB_PORT, 1);
+ });
+ });
+});
diff --git a/test/unit/boundaries/config_parsing.test/08_client_reconcile_knobs.test.js b/test/unit/boundaries/config_parsing.test/08_client_reconcile_knobs.test.js
new file mode 100644
index 00000000..ce5e4b57
--- /dev/null
+++ b/test/unit/boundaries/config_parsing.test/08_client_reconcile_knobs.test.js
@@ -0,0 +1,69 @@
+// 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.
+
+// Covers the client reconcile and paging knobs. One part of config_parsing.test.js.
+const assert = require('assert');
+const config = require('../../../../src/config');
+
+const KNOBS = ['DISPENSERS_RECONCILE_EVERY', 'DISPENSERS_RECONCILE_MAX_INTERVAL_MS',
+ 'LOOKUP_PAGE_SIZE', 'GAP_LOG_INTERVAL_MS'];
+
+// Clear the knobs around each test so an ambient value cannot leak in.
+function registerKnobHooks(){
+ let saved = {};
+ beforeEach(function(){
+ for(let key of KNOBS){ saved[key] = process.env[key]; delete process.env[key]; }
+ });
+ afterEach(function(){
+ for(let key of KNOBS){
+ if(saved[key] !== undefined) process.env[key] = saved[key];
+ else delete process.env[key];
+ }
+ });
+}
+
+describe('Boundary: Config Parsing', function(){
+ registerKnobHooks();
+ describe('client reconcile and paging knobs reach the live config', function(){
+ it('keep the reader defaults when unset', function(){
+ let cfg = config.getConfig();
+ assert.strictEqual(cfg.DISPENSERS_RECONCILE_EVERY, 20);
+ assert.strictEqual(cfg.DISPENSERS_RECONCILE_MAX_INTERVAL_MS, 1800000);
+ assert.strictEqual(cfg.LOOKUP_PAGE_SIZE, 50000);
+ assert.strictEqual(cfg.GAP_LOG_INTERVAL_MS, 30000);
+ });
+ it('pass a set value through', function(){
+ process.env.DISPENSERS_RECONCILE_EVERY = '5';
+ process.env.DISPENSERS_RECONCILE_MAX_INTERVAL_MS = '60000';
+ process.env.LOOKUP_PAGE_SIZE = '1000';
+ process.env.GAP_LOG_INTERVAL_MS = '5000';
+ let cfg = config.getConfig();
+ assert.strictEqual(cfg.DISPENSERS_RECONCILE_EVERY, 5);
+ assert.strictEqual(cfg.DISPENSERS_RECONCILE_MAX_INTERVAL_MS, 60000);
+ assert.strictEqual(cfg.LOOKUP_PAGE_SIZE, 1000);
+ assert.strictEqual(cfg.GAP_LOG_INTERVAL_MS, 5000);
+ });
+ it('keeps DISPENSERS_RECONCILE_MAX_INTERVAL_MS=0 as the documented disable', function(){
+ process.env.DISPENSERS_RECONCILE_MAX_INTERVAL_MS = '0';
+ assert.strictEqual(config.getConfig().DISPENSERS_RECONCILE_MAX_INTERVAL_MS, 0);
+ });
+ it('fall back to the default on a non-numeric or out-of-range value', function(){
+ process.env.DISPENSERS_RECONCILE_EVERY = 'later';
+ process.env.DISPENSERS_RECONCILE_MAX_INTERVAL_MS = '-1';
+ process.env.LOOKUP_PAGE_SIZE = '0';
+ process.env.GAP_LOG_INTERVAL_MS = '0';
+ let cfg = config.getConfig();
+ assert.strictEqual(cfg.DISPENSERS_RECONCILE_EVERY, 20);
+ assert.strictEqual(cfg.DISPENSERS_RECONCILE_MAX_INTERVAL_MS, 1800000, 'a negative value must not read as the disable');
+ assert.strictEqual(cfg.LOOKUP_PAGE_SIZE, 50000, '0 must not become a one-row page');
+ assert.strictEqual(cfg.GAP_LOG_INTERVAL_MS, 30000);
+ });
+ });
+});
diff --git a/test/unit/boundaries/config_parsing.test/helpers/config_parsing_suite.js b/test/unit/boundaries/config_parsing.test/helpers/config_parsing_suite.js
new file mode 100644
index 00000000..c2b898ca
--- /dev/null
+++ b/test/unit/boundaries/config_parsing.test/helpers/config_parsing_suite.js
@@ -0,0 +1,38 @@
+// 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.
+
+// Covers environment hooks. One part of config_parsing.test.js.
+const ENV_KEYS = [
+ 'SYNC_MODE', 'SYNC_API_PORT', 'HUB_API_HOST', 'HUB_PORT',
+ 'CORS_ORIGIN', 'BLOCK_POLL_INTERVAL', 'WS_MAX_PER_IP',
+ 'SNAPSHOT_RATE_FULL', 'SNAPSHOT_RATE_INCR', 'SYNC_SOURCES',
+ 'VERIFY_HASHES', 'REPLICA_DB_HOST', 'REPLICA_DB_PORT',
+ 'REPLICA_DB_USER', 'REPLICA_DB_PASS'
+];
+
+function registerHooks(){
+ let savedEnv = {};
+ beforeEach(function(){
+ for(let key of ENV_KEYS){
+ savedEnv[key] = process.env[key];
+ delete process.env[key];
+ }
+ });
+ afterEach(function(){
+ for(let key of ENV_KEYS){
+ if(savedEnv[key] !== undefined)
+ process.env[key] = savedEnv[key];
+ else
+ delete process.env[key];
+ }
+ });
+}
+
+module.exports = { registerHooks };
diff --git a/test/unit/boundaries/hash_continuity.test.js b/test/unit/boundaries/hash_continuity.test.js
index 57c570db..f9655832 100644
--- a/test/unit/boundaries/hash_continuity.test.js
+++ b/test/unit/boundaries/hash_continuity.test.js
@@ -9,123 +9,42 @@
// contact legal@dankest.llc.
const assert = require('assert');
-const HashVerifier = require('../../../src/client/hash_verifier');
+const { hashes, registerHooks } = require('./hash_continuity.test/helpers/hash_continuity_suite');
describe('Boundary: Hash Continuity Check', function(){
-
let verifier;
- const hashes = { ledger_hash: 'aaa', actions_hash: 'bbb', contract_hash: 'ccc' };
-
- beforeEach(function(){
- verifier = new HashVerifier();
- });
+ registerHooks(function(value){ verifier = value; });
describe('block index continuity (exact +1 requirement)', function(){
it('valid: 10 → 11 (sequential)', function(){
let result = verifier.verifyChainContinuity(10, hashes, { block_index: 11 });
assert.strictEqual(result.valid, true);
});
-
it('invalid: 10 → 12 (gap of 1)', function(){
let result = verifier.verifyChainContinuity(10, hashes, { block_index: 12 });
assert.strictEqual(result.valid, false);
assert.ok(result.reason.includes('expected 11'));
assert.ok(result.reason.includes('got 12'));
});
-
it('invalid: 10 → 10 (same block)', function(){
let result = verifier.verifyChainContinuity(10, hashes, { block_index: 10 });
assert.strictEqual(result.valid, false);
});
-
it('invalid: 10 → 9 (backward)', function(){
let result = verifier.verifyChainContinuity(10, hashes, { block_index: 9 });
assert.strictEqual(result.valid, false);
});
-
it('valid: 0 → 1 (zero-based chain)', function(){
let result = verifier.verifyChainContinuity(0, hashes, { block_index: 1 });
assert.strictEqual(result.valid, true);
});
-
it('invalid: 0 → 2 (skip from zero)', function(){
let result = verifier.verifyChainContinuity(0, hashes, { block_index: 2 });
assert.strictEqual(result.valid, false);
});
-
it('invalid: 0 → 0 (repeat at zero)', function(){
let result = verifier.verifyChainContinuity(0, hashes, { block_index: 0 });
assert.strictEqual(result.valid, false);
});
});
-
- describe('null prevBlockIndex (bootstrap)', function(){
- it('valid: null → 1 (first block)', function(){
- let result = verifier.verifyChainContinuity(null, null, { block_index: 1 });
- assert.strictEqual(result.valid, true);
- assert.strictEqual(result.reason, null);
- });
-
- it('valid: null → 0 (first block at zero)', function(){
- let result = verifier.verifyChainContinuity(null, null, { block_index: 0 });
- assert.strictEqual(result.valid, true);
- });
-
- it('valid: null → 999 (any block after bootstrap)', function(){
- let result = verifier.verifyChainContinuity(null, null, { block_index: 999 });
- assert.strictEqual(result.valid, true);
- });
- });
-
- describe('null prevHashes', function(){
- it('valid: prevBlockIndex=5, prevHashes=null → skips check', function(){
- let result = verifier.verifyChainContinuity(null, null, { block_index: 6 });
- assert.strictEqual(result.valid, true);
- });
- });
-
- describe('hash comparison boundaries', function(){
- it('match: all three hashes identical', function(){
- let result = verifier.compareBlockHashes(1, hashes, { ...hashes });
- assert.strictEqual(result.match, true);
- assert.strictEqual(result.mismatches.length, 0);
- });
-
- it('mismatch: single field different', function(){
- let result = verifier.compareBlockHashes(1, hashes, { ...hashes, ledger_hash: 'zzz' });
- assert.strictEqual(result.match, false);
- assert.strictEqual(result.mismatches.length, 1);
- });
-
- it('mismatch: all three fields different', function(){
- let result = verifier.compareBlockHashes(1, hashes, { ledger_hash: 'x', actions_hash: 'y', contract_hash: 'z' });
- assert.strictEqual(result.match, false);
- assert.strictEqual(result.mismatches.length, 3);
- });
-
- it('null vs string is mismatch', function(){
- let result = verifier.compareBlockHashes(1, hashes, { ledger_hash: null, actions_hash: 'bbb', contract_hash: 'ccc' });
- assert.strictEqual(result.match, false);
- });
-
- it('null vs null is match', function(){
- let n = { ledger_hash: null, actions_hash: null, contract_hash: null };
- let result = verifier.compareBlockHashes(1, n, { ...n });
- assert.strictEqual(result.match, true);
- });
-
- it('empty string vs empty string is match', function(){
- let e = { ledger_hash: '', actions_hash: '', contract_hash: '' };
- let result = verifier.compareBlockHashes(1, e, { ...e });
- assert.strictEqual(result.match, true);
- });
-
- it('empty string vs null is mismatch', function(){
- let a = { ledger_hash: '', actions_hash: '', contract_hash: '' };
- let b = { ledger_hash: null, actions_hash: null, contract_hash: null };
- let result = verifier.compareBlockHashes(1, a, b);
- assert.strictEqual(result.match, false);
- assert.strictEqual(result.mismatches.length, 3);
- });
- });
});
diff --git a/test/unit/boundaries/hash_continuity.test/02_null_inputs.test.js b/test/unit/boundaries/hash_continuity.test/02_null_inputs.test.js
new file mode 100644
index 00000000..0607c9bb
--- /dev/null
+++ b/test/unit/boundaries/hash_continuity.test/02_null_inputs.test.js
@@ -0,0 +1,39 @@
+// 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.
+
+// Covers null continuity inputs. One part of hash_continuity.test.js.
+const assert = require('assert');
+const { registerHooks } = require('./helpers/hash_continuity_suite');
+
+describe('Boundary: Hash Continuity Check', function(){
+ let verifier;
+ registerHooks(function(value){ verifier = value; });
+ describe('null prevBlockIndex (bootstrap)', function(){
+ it('valid: null → 1 (first block)', function(){
+ let result = verifier.verifyChainContinuity(null, null, { block_index: 1 });
+ assert.strictEqual(result.valid, true);
+ assert.strictEqual(result.reason, null);
+ });
+ it('valid: null → 0 (first block at zero)', function(){
+ let result = verifier.verifyChainContinuity(null, null, { block_index: 0 });
+ assert.strictEqual(result.valid, true);
+ });
+ it('valid: null → 999 (any block after bootstrap)', function(){
+ let result = verifier.verifyChainContinuity(null, null, { block_index: 999 });
+ assert.strictEqual(result.valid, true);
+ });
+ });
+ describe('null prevHashes', function(){
+ it('valid: prevBlockIndex=5, prevHashes=null → skips check', function(){
+ let result = verifier.verifyChainContinuity(null, null, { block_index: 6 });
+ assert.strictEqual(result.valid, true);
+ });
+ });
+});
diff --git a/test/unit/boundaries/hash_continuity.test/03_hash_comparison.test.js b/test/unit/boundaries/hash_continuity.test/03_hash_comparison.test.js
new file mode 100644
index 00000000..7871fee8
--- /dev/null
+++ b/test/unit/boundaries/hash_continuity.test/03_hash_comparison.test.js
@@ -0,0 +1,56 @@
+// 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.
+
+// Covers hash comparisons. One part of hash_continuity.test.js.
+const assert = require('assert');
+const { hashes, registerHooks } = require('./helpers/hash_continuity_suite');
+
+describe('Boundary: Hash Continuity Check', function(){
+ let verifier;
+ registerHooks(function(value){ verifier = value; });
+ describe('hash comparison boundaries', function(){
+ it('match: all three hashes identical', function(){
+ let result = verifier.compareBlockHashes(1, hashes, { ...hashes });
+ assert.strictEqual(result.match, true);
+ assert.strictEqual(result.mismatches.length, 0);
+ });
+ it('mismatch: single field different', function(){
+ let result = verifier.compareBlockHashes(1, hashes, { ...hashes, ledger_hash: 'zzz' });
+ assert.strictEqual(result.match, false);
+ assert.strictEqual(result.mismatches.length, 1);
+ });
+ it('mismatch: all three fields different', function(){
+ let result = verifier.compareBlockHashes(1, hashes, { ledger_hash: 'x', actions_hash: 'y', contract_hash: 'z' });
+ assert.strictEqual(result.match, false);
+ assert.strictEqual(result.mismatches.length, 3);
+ });
+ it('null vs string is mismatch', function(){
+ let result = verifier.compareBlockHashes(1, hashes, { ledger_hash: null, actions_hash: 'bbb', contract_hash: 'ccc' });
+ assert.strictEqual(result.match, false);
+ });
+ it('null vs null is match', function(){
+ let n = { ledger_hash: null, actions_hash: null, contract_hash: null };
+ let result = verifier.compareBlockHashes(1, n, { ...n });
+ assert.strictEqual(result.match, true);
+ });
+ it('empty string vs empty string is match', function(){
+ let e = { ledger_hash: '', actions_hash: '', contract_hash: '' };
+ let result = verifier.compareBlockHashes(1, e, { ...e });
+ assert.strictEqual(result.match, true);
+ });
+ it('empty string vs null is mismatch', function(){
+ let a = { ledger_hash: '', actions_hash: '', contract_hash: '' };
+ let b = { ledger_hash: null, actions_hash: null, contract_hash: null };
+ let result = verifier.compareBlockHashes(1, a, b);
+ assert.strictEqual(result.match, false);
+ assert.strictEqual(result.mismatches.length, 3);
+ });
+ });
+});
diff --git a/test/unit/boundaries/hash_continuity.test/helpers/hash_continuity_suite.js b/test/unit/boundaries/hash_continuity.test/helpers/hash_continuity_suite.js
new file mode 100644
index 00000000..96131a89
--- /dev/null
+++ b/test/unit/boundaries/hash_continuity.test/helpers/hash_continuity_suite.js
@@ -0,0 +1,21 @@
+// 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.
+
+// Covers hash fixtures and hooks. One part of hash_continuity.test.js.
+const HashVerifier = require('../../../../../src/client/hash_verifier');
+const hashes = { ledger_hash: 'aaa', actions_hash: 'bbb', contract_hash: 'ccc' };
+
+function registerHooks(setVerifier){
+ beforeEach(function(){
+ setVerifier(new HashVerifier());
+ });
+}
+
+module.exports = { hashes, registerHooks };
diff --git a/test/unit/boundaries/hub_port_parsing.test.js b/test/unit/boundaries/hub_port_parsing.test.js
index d69e6b8c..ec03e0ba 100644
--- a/test/unit/boundaries/hub_port_parsing.test.js
+++ b/test/unit/boundaries/hub_port_parsing.test.js
@@ -9,154 +9,32 @@
// contact legal@dankest.llc.
const assert = require('assert');
-const sinon = require('sinon');
const HubClient = require('../../../src/hub/client');
-const axios = require('axios');
+const { registerHooks } = require('./hub_port_parsing.test/helpers/hub_port_parsing_suite');
describe('Boundary: HubClient Port Parsing', function(){
-
- beforeEach(function(){
- sinon.stub(console, 'log');
- sinon.stub(console, 'error');
- });
-
- afterEach(function(){ sinon.restore(); });
-
+ registerHooks();
describe('parsePort static method', function(){
it('valid port: returns as-is', function(){
assert.strictEqual(HubClient.parsePort('3306', undefined), 3306);
});
-
it('zero: preserved (not treated as falsy)', function(){
assert.strictEqual(HubClient.parsePort('0', undefined), 0);
});
-
it('falls back to secondary when primary is null', function(){
assert.strictEqual(HubClient.parsePort(null, '5432'), 5432);
});
-
it('falls back to secondary when primary is undefined', function(){
assert.strictEqual(HubClient.parsePort(undefined, '5432'), 5432);
});
-
it('falls back to secondary when primary is empty string', function(){
assert.strictEqual(HubClient.parsePort('', '5432'), 5432);
});
-
it('defaults to 3306 when both are absent', function(){
assert.strictEqual(HubClient.parsePort(undefined, undefined), 3306);
});
-
it('defaults to 3306 when both are null', function(){
assert.strictEqual(HubClient.parsePort(null, null), 3306);
});
-
- it('defaults to 3306 when both are empty', function(){
- assert.strictEqual(HubClient.parsePort('', ''), 3306);
- });
-
- it('defaults to 3306 for non-numeric primary', function(){
- assert.strictEqual(HubClient.parsePort('abc', undefined), 3306);
- });
-
- it('uses secondary when primary is non-numeric', function(){
- // A non-numeric primary is not empty/null/undefined, so it is used directly (parseInt fails to NaN) rather than falling back to secondary.
- assert.strictEqual(HubClient.parsePort('abc', '5432'), 3306);
- });
-
- it('negative port defaults to 3306', function(){
- assert.strictEqual(HubClient.parsePort('-1', undefined), 3306);
- });
-
- it('integer value (not string) works', function(){
- assert.strictEqual(HubClient.parsePort(3307, undefined), 3307);
- });
-
- it('integer 0 preserved', function(){
- assert.strictEqual(HubClient.parsePort(0, undefined), 0);
- });
-
- it('float string truncated', function(){
- assert.strictEqual(HubClient.parsePort('3306.5', undefined), 3306);
- });
- });
-
- describe('getIndexerConfigs integration', function(){
- it('uses db_port from hub config', async function(){
- let hub = new HubClient('localhost', 10000);
- sinon.stub(axios, 'post').resolves({
- data: {
- jsonrpc: '2.0',
- result: {
- bitcoin: {
- mainnet: {
- 'xchain-indexer': {
- db_host: 'db.local',
- db_port: 13306,
- name: 'xchain_btc',
- user: 'root',
- pass: 'pass'
- }
- }
- }
- }
- }
- });
-
- let configs = await hub.getIndexerConfigs();
- assert.strictEqual(configs[0].db_port, 13306);
- axios.post.restore();
- });
-
- it('falls back to port when db_port absent', async function(){
- let hub = new HubClient('localhost', 10000);
- sinon.stub(axios, 'post').resolves({
- data: {
- jsonrpc: '2.0',
- result: {
- bitcoin: {
- mainnet: {
- 'xchain-indexer': {
- host: 'db.local',
- port: 5432,
- name: 'xchain_btc',
- user: 'root',
- pass: 'pass'
- }
- }
- }
- }
- }
- });
-
- let configs = await hub.getIndexerConfigs();
- assert.strictEqual(configs[0].db_port, 5432);
- axios.post.restore();
- });
-
- it('defaults to 3306 when neither port field present', async function(){
- let hub = new HubClient('localhost', 10000);
- sinon.stub(axios, 'post').resolves({
- data: {
- jsonrpc: '2.0',
- result: {
- bitcoin: {
- mainnet: {
- 'xchain-indexer': {
- host: 'db.local',
- name: 'xchain_btc',
- user: 'root',
- pass: 'pass'
- }
- }
- }
- }
- }
- });
-
- let configs = await hub.getIndexerConfigs();
- assert.strictEqual(configs[0].db_port, 3306);
- axios.post.restore();
- });
});
});
diff --git a/test/unit/boundaries/hub_port_parsing.test/02_parse_fallbacks.test.js b/test/unit/boundaries/hub_port_parsing.test/02_parse_fallbacks.test.js
new file mode 100644
index 00000000..df2ab936
--- /dev/null
+++ b/test/unit/boundaries/hub_port_parsing.test/02_parse_fallbacks.test.js
@@ -0,0 +1,42 @@
+// 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.
+
+// Covers port fallbacks. One part of hub_port_parsing.test.js.
+const assert = require('assert');
+const HubClient = require('../../../../src/hub/client');
+const { registerHooks } = require('./helpers/hub_port_parsing_suite');
+
+describe('Boundary: HubClient Port Parsing', function(){
+ registerHooks();
+ describe('parsePort static method', function(){
+ it('defaults to 3306 when both are empty', function(){
+ assert.strictEqual(HubClient.parsePort('', ''), 3306);
+ });
+ it('defaults to 3306 for non-numeric primary', function(){
+ assert.strictEqual(HubClient.parsePort('abc', undefined), 3306);
+ });
+ it('uses secondary when primary is non-numeric', function(){
+ // A non-numeric primary is not empty/null/undefined, so it is used directly (parseInt fails to NaN) rather than falling back to secondary.
+ assert.strictEqual(HubClient.parsePort('abc', '5432'), 3306);
+ });
+ it('negative port defaults to 3306', function(){
+ assert.strictEqual(HubClient.parsePort('-1', undefined), 3306);
+ });
+ it('integer value (not string) works', function(){
+ assert.strictEqual(HubClient.parsePort(3307, undefined), 3307);
+ });
+ it('integer 0 preserved', function(){
+ assert.strictEqual(HubClient.parsePort(0, undefined), 0);
+ });
+ it('float string truncated', function(){
+ assert.strictEqual(HubClient.parsePort('3306.5', undefined), 3306);
+ });
+ });
+});
diff --git a/test/unit/boundaries/hub_port_parsing.test/03_db_port.test.js b/test/unit/boundaries/hub_port_parsing.test/03_db_port.test.js
new file mode 100644
index 00000000..338efb99
--- /dev/null
+++ b/test/unit/boundaries/hub_port_parsing.test/03_db_port.test.js
@@ -0,0 +1,47 @@
+// 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.
+
+// Covers explicit database ports. One part of hub_port_parsing.test.js.
+const assert = require('assert');
+const sinon = require('sinon');
+const HubClient = require('../../../../src/hub/client');
+const axios = require('axios');
+const { registerHooks } = require('./helpers/hub_port_parsing_suite');
+
+describe('Boundary: HubClient Port Parsing', function(){
+ registerHooks();
+ describe('getIndexerConfigs integration', function(){
+ it('uses db_port from hub config', async function(){
+ let hub = new HubClient('localhost', 10000);
+ sinon.stub(axios, 'post').resolves({
+ data: {
+ jsonrpc: '2.0',
+ result: {
+ bitcoin: {
+ mainnet: {
+ 'xchain-indexer': {
+ db_host: 'db.local',
+ db_port: 13306,
+ name: 'xchain_btc',
+ user: 'root',
+ pass: 'pass'
+ }
+ }
+ }
+ }
+ }
+ });
+
+ let configs = await hub.getIndexerConfigs();
+ assert.strictEqual(configs[0].db_port, 13306);
+ axios.post.restore();
+ });
+ });
+});
diff --git a/test/unit/boundaries/hub_port_parsing.test/04_legacy_port.test.js b/test/unit/boundaries/hub_port_parsing.test/04_legacy_port.test.js
new file mode 100644
index 00000000..ed93a6a0
--- /dev/null
+++ b/test/unit/boundaries/hub_port_parsing.test/04_legacy_port.test.js
@@ -0,0 +1,47 @@
+// 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.
+
+// Covers legacy database ports. One part of hub_port_parsing.test.js.
+const assert = require('assert');
+const sinon = require('sinon');
+const HubClient = require('../../../../src/hub/client');
+const axios = require('axios');
+const { registerHooks } = require('./helpers/hub_port_parsing_suite');
+
+describe('Boundary: HubClient Port Parsing', function(){
+ registerHooks();
+ describe('getIndexerConfigs integration', function(){
+ it('falls back to port when db_port absent', async function(){
+ let hub = new HubClient('localhost', 10000);
+ sinon.stub(axios, 'post').resolves({
+ data: {
+ jsonrpc: '2.0',
+ result: {
+ bitcoin: {
+ mainnet: {
+ 'xchain-indexer': {
+ host: 'db.local',
+ port: 5432,
+ name: 'xchain_btc',
+ user: 'root',
+ pass: 'pass'
+ }
+ }
+ }
+ }
+ }
+ });
+
+ let configs = await hub.getIndexerConfigs();
+ assert.strictEqual(configs[0].db_port, 5432);
+ axios.post.restore();
+ });
+ });
+});
diff --git a/test/unit/boundaries/hub_port_parsing.test/05_default_port.test.js b/test/unit/boundaries/hub_port_parsing.test/05_default_port.test.js
new file mode 100644
index 00000000..98e93593
--- /dev/null
+++ b/test/unit/boundaries/hub_port_parsing.test/05_default_port.test.js
@@ -0,0 +1,46 @@
+// 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.
+
+// Covers default database ports. One part of hub_port_parsing.test.js.
+const assert = require('assert');
+const sinon = require('sinon');
+const HubClient = require('../../../../src/hub/client');
+const axios = require('axios');
+const { registerHooks } = require('./helpers/hub_port_parsing_suite');
+
+describe('Boundary: HubClient Port Parsing', function(){
+ registerHooks();
+ describe('getIndexerConfigs integration', function(){
+ it('defaults to 3306 when neither port field present', async function(){
+ let hub = new HubClient('localhost', 10000);
+ sinon.stub(axios, 'post').resolves({
+ data: {
+ jsonrpc: '2.0',
+ result: {
+ bitcoin: {
+ mainnet: {
+ 'xchain-indexer': {
+ host: 'db.local',
+ name: 'xchain_btc',
+ user: 'root',
+ pass: 'pass'
+ }
+ }
+ }
+ }
+ }
+ });
+
+ let configs = await hub.getIndexerConfigs();
+ assert.strictEqual(configs[0].db_port, 3306);
+ axios.post.restore();
+ });
+ });
+});
diff --git a/test/unit/boundaries/hub_port_parsing.test/helpers/hub_port_parsing_suite.js b/test/unit/boundaries/hub_port_parsing.test/helpers/hub_port_parsing_suite.js
new file mode 100644
index 00000000..8dec01f2
--- /dev/null
+++ b/test/unit/boundaries/hub_port_parsing.test/helpers/hub_port_parsing_suite.js
@@ -0,0 +1,22 @@
+// 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.
+
+// Covers shared hooks. One part of hub_port_parsing.test.js.
+const sinon = require('sinon');
+
+function registerHooks(){
+ beforeEach(function(){
+ sinon.stub(console, 'log');
+ sinon.stub(console, 'error');
+ });
+ afterEach(function(){ sinon.restore(); });
+}
+
+module.exports = { registerHooks };
diff --git a/test/unit/boundaries/poll_limit.test.js b/test/unit/boundaries/poll_limit.test.js
index f2709c55..a182116c 100644
--- a/test/unit/boundaries/poll_limit.test.js
+++ b/test/unit/boundaries/poll_limit.test.js
@@ -9,59 +9,11 @@
// contact legal@dankest.llc.
const assert = require('assert');
-const sinon = require('sinon');
-const ServerPoller = require('../../../src/server/poller');
-const Utility = require('../../../src/util');
-const { withDbMixins } = require('../../helpers/db_mixins.js');
-
-function createMockDb(){
- // Queries read through named Database methods. The real ones are installed for
- // any this fake does not stub, so they still reach doQuery below and every
- // doQuery call count these suites assert keeps counting them.
- return withDbMixins({
- getLastBlock: sinon.stub().resolves(null),
- getBlockHashRow: sinon.stub().resolves(null),
- getBlockScopedRows: sinon.stub().resolves([]),
- getTxScopedRows: sinon.stub().resolves([]),
- getActionScopedRows: sinon.stub().resolves([]),
- getEmissionRowsForBlock: sinon.stub().resolves([]),
- getTransactions: sinon.stub().resolves([]),
- getActions: sinon.stub().resolves([]),
- getStatusId: sinon.stub().resolves(null),
- doQuery: sinon.stub().resolves([]),
- beginReadSnapshot: sinon.stub().resolves({ mockSnapshotConn: true }),
- commitReadSnapshot: sinon.stub().resolves(),
- rollbackReadSnapshot: sinon.stub().resolves()
- });
-}
-
-function createMockBroadcaster(){
- return { broadcast: sinon.stub(), updateStatus: sinon.stub(), getSubscribers: sinon.stub().returns([]), getSubscriberCount: sinon.stub().returns(0) };
-}
-
-function createMockLog(){
- return { recordBlock: sinon.stub().resolves(), pruneFrom: sinon.stub().resolves() };
-}
+const { registerHooks } = require('./poll_limit.test/helpers/poll_limit_suite');
describe('Boundary: Poll Loop Limit (100 blocks)', function(){
-
let poller, db, broadcaster, log;
-
- beforeEach(function(){
- db = createMockDb();
- broadcaster = createMockBroadcaster();
- log = createMockLog();
- let util = new Utility();
- poller = new ServerPoller('bitcoin', 'mainnet', db, broadcaster, log, { BLOCK_POLL_INTERVAL: 100 }, util);
- db.getBlockHashRow.callsFake(async (idx) => ({
- block_index: idx, block_time: idx * 10,
- ledger_hash: 'l' + idx, actions_hash: 'a' + idx, contract_hash: 'c' + idx
- }));
- sinon.stub(console, 'log');
- sinon.stub(console, 'error');
- });
-
- afterEach(function(){ sinon.restore(); });
+ registerHooks(function(context){ ({ poller, db, broadcaster, log } = context); });
it('processes 0 blocks when at current (no-op)', async function(){
poller.lastPolledBlock = 50;
@@ -94,32 +46,4 @@ describe('Boundary: Poll Loop Limit (100 blocks)', function(){
assert.strictEqual(broadcaster.broadcast.callCount, 100);
assert.strictEqual(poller.lastPolledBlock, 100);
});
-
- it('caps at 100 blocks when 101 available', async function(){
- poller.lastPolledBlock = 0;
- db.getLastBlock.resolves(101);
- await poller.poll();
- assert.strictEqual(broadcaster.broadcast.callCount, 100);
- assert.strictEqual(poller.lastPolledBlock, 100);
- });
-
- it('processes remaining 1 block on second poll after cap', async function(){
- poller.lastPolledBlock = 0;
- db.getLastBlock.resolves(101);
- await poller.poll(); // processes 1–100
- assert.strictEqual(poller.lastPolledBlock, 100);
-
- broadcaster.broadcast.resetHistory();
- await poller.poll(); // processes 101
- assert.strictEqual(broadcaster.broadcast.callCount, 1);
- assert.strictEqual(poller.lastPolledBlock, 101);
- });
-
- it('caps at 100 blocks when 200 available', async function(){
- poller.lastPolledBlock = 0;
- db.getLastBlock.resolves(200);
- await poller.poll();
- assert.strictEqual(broadcaster.broadcast.callCount, 100);
- assert.strictEqual(poller.lastPolledBlock, 100);
- });
});
diff --git a/test/unit/boundaries/poll_limit.test/02_capped_polls.test.js b/test/unit/boundaries/poll_limit.test/02_capped_polls.test.js
new file mode 100644
index 00000000..fbf3bc5b
--- /dev/null
+++ b/test/unit/boundaries/poll_limit.test/02_capped_polls.test.js
@@ -0,0 +1,46 @@
+// 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.
+
+// Covers capped poll batches. One part of poll_limit.test.js.
+const assert = require('assert');
+const { registerHooks } = require('./helpers/poll_limit_suite');
+
+describe('Boundary: Poll Loop Limit (100 blocks)', function(){
+ let poller, db, broadcaster, log;
+ registerHooks(function(context){ ({ poller, db, broadcaster, log } = context); });
+
+ it('caps at 100 blocks when 101 available', async function(){
+ poller.lastPolledBlock = 0;
+ db.getLastBlock.resolves(101);
+ await poller.poll();
+ assert.strictEqual(broadcaster.broadcast.callCount, 100);
+ assert.strictEqual(poller.lastPolledBlock, 100);
+ });
+
+ it('processes remaining 1 block on second poll after cap', async function(){
+ poller.lastPolledBlock = 0;
+ db.getLastBlock.resolves(101);
+ await poller.poll(); // processes 1–100
+ assert.strictEqual(poller.lastPolledBlock, 100);
+
+ broadcaster.broadcast.resetHistory();
+ await poller.poll(); // processes 101
+ assert.strictEqual(broadcaster.broadcast.callCount, 1);
+ assert.strictEqual(poller.lastPolledBlock, 101);
+ });
+
+ it('caps at 100 blocks when 200 available', async function(){
+ poller.lastPolledBlock = 0;
+ db.getLastBlock.resolves(200);
+ await poller.poll();
+ assert.strictEqual(broadcaster.broadcast.callCount, 100);
+ assert.strictEqual(poller.lastPolledBlock, 100);
+ });
+});
diff --git a/test/unit/boundaries/poll_limit.test/helpers/poll_limit_suite.js b/test/unit/boundaries/poll_limit.test/helpers/poll_limit_suite.js
new file mode 100644
index 00000000..ca3bfabe
--- /dev/null
+++ b/test/unit/boundaries/poll_limit.test/helpers/poll_limit_suite.js
@@ -0,0 +1,64 @@
+// 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.
+
+// Covers poller fixtures and hooks. One part of poll_limit.test.js.
+const sinon = require('sinon');
+const ServerPoller = require('../../../../../src/server/poller');
+const Utility = require('../../../../../src/util');
+const { withDbMixins } = require('../../../../helpers/db_mixins.js');
+
+function createMockDb(){
+ // Queries read through named Database methods. The real ones are installed for
+ // any this fake does not stub, so they still reach doQuery below and every
+ // doQuery call count these suites assert keeps counting them.
+ return withDbMixins({
+ getLastBlock: sinon.stub().resolves(null),
+ getBlockHashRow: sinon.stub().resolves(null),
+ getBlockScopedRows: sinon.stub().resolves([]),
+ getTxScopedRows: sinon.stub().resolves([]),
+ getActionScopedRows: sinon.stub().resolves([]),
+ getEmissionRowsForBlock: sinon.stub().resolves([]),
+ getTransactions: sinon.stub().resolves([]),
+ getActions: sinon.stub().resolves([]),
+ getStatusId: sinon.stub().resolves(null),
+ doQuery: sinon.stub().resolves([]),
+ beginReadSnapshot: sinon.stub().resolves({ mockSnapshotConn: true }),
+ commitReadSnapshot: sinon.stub().resolves(),
+ rollbackReadSnapshot: sinon.stub().resolves()
+ });
+}
+
+function createMockBroadcaster(){
+ return { broadcast: sinon.stub(), updateStatus: sinon.stub(), getSubscribers: sinon.stub().returns([]), getSubscriberCount: sinon.stub().returns(0) };
+}
+
+function createMockLog(){
+ return { recordBlock: sinon.stub().resolves(), pruneFrom: sinon.stub().resolves() };
+}
+
+function registerHooks(setContext){
+ beforeEach(function(){
+ let db = createMockDb();
+ let broadcaster = createMockBroadcaster();
+ let log = createMockLog();
+ let util = new Utility();
+ let poller = new ServerPoller('bitcoin', 'mainnet', db, broadcaster, log, { BLOCK_POLL_INTERVAL: 100 }, util);
+ db.getBlockHashRow.callsFake(async (idx) => ({
+ block_index: idx, block_time: idx * 10,
+ ledger_hash: 'l' + idx, actions_hash: 'a' + idx, contract_hash: 'c' + idx
+ }));
+ sinon.stub(console, 'log');
+ sinon.stub(console, 'error');
+ setContext({ poller, db, broadcaster, log });
+ });
+ afterEach(function(){ sinon.restore(); });
+}
+
+module.exports = { registerHooks };
diff --git a/test/unit/boundaries/reorg_detection.test.js b/test/unit/boundaries/reorg_detection.test.js
index f365963b..925ce1cb 100644
--- a/test/unit/boundaries/reorg_detection.test.js
+++ b/test/unit/boundaries/reorg_detection.test.js
@@ -9,50 +9,11 @@
// contact legal@dankest.llc.
const assert = require('assert');
-const sinon = require('sinon');
-const ServerPoller = require('../../../src/server/poller');
-const Utility = require('../../../src/util');
-const { withDbMixins } = require('../../helpers/db_mixins.js');
-
-function createMockDb(){
- // Queries read through named Database methods. The real ones are installed for
- // any this fake does not stub, so they still reach doQuery below and every
- // doQuery call count these suites assert keeps counting them.
- return withDbMixins({
- getLastBlock: sinon.stub().resolves(null),
- getBlockHashRow: sinon.stub().resolves(null),
- getBlockScopedRows: sinon.stub().resolves([]),
- getTxScopedRows: sinon.stub().resolves([]),
- getActionScopedRows: sinon.stub().resolves([]),
- getEmissionRowsForBlock: sinon.stub().resolves([]),
- getTransactions: sinon.stub().resolves([]),
- getActions: sinon.stub().resolves([]),
- getStatusId: sinon.stub().resolves(null),
- doQuery: sinon.stub().resolves([]),
- beginReadSnapshot: sinon.stub().resolves({ mockSnapshotConn: true }),
- commitReadSnapshot: sinon.stub().resolves(),
- rollbackReadSnapshot: sinon.stub().resolves()
- });
-}
+const { registerHooks } = require('./reorg_detection.test/helpers/reorg_detection_suite');
describe('Boundary: Reorg Detection', function(){
-
let poller, db, broadcaster;
-
- beforeEach(function(){
- db = createMockDb();
- broadcaster = { broadcast: sinon.stub(), updateStatus: sinon.stub(), getSubscribers: sinon.stub().returns([]), getSubscriberCount: sinon.stub().returns(0) };
- let log = { recordBlock: sinon.stub().resolves(), pruneFrom: sinon.stub().resolves() };
- poller = new ServerPoller('bitcoin', 'mainnet', db, broadcaster, log, { BLOCK_POLL_INTERVAL: 100 }, new Utility());
- db.getBlockHashRow.callsFake(async (idx) => ({
- block_index: idx, block_time: idx * 10,
- ledger_hash: 'l', actions_hash: 'a', contract_hash: 'c'
- }));
- sinon.stub(console, 'log');
- sinon.stub(console, 'error');
- });
-
- afterEach(function(){ sinon.restore(); });
+ registerHooks(function(context){ ({ poller, db, broadcaster } = context); });
it('no change (currentBlock === lastPolledBlock): no-op', async function(){
poller.lastPolledBlock = 10;
@@ -90,57 +51,4 @@ describe('Boundary: Reorg Detection', function(){
assert.strictEqual(event.block_index, 91); // currentBlock + 1
assert.strictEqual(poller.lastPolledBlock, 90);
});
-
- it('mid-rewrite height drop: walks back to the true fork point', async function(){
- // The source is observed mid-rewrite: the tip dropped from 10 to 8, but the rewrite
- // actually forked at 5, so blocks 5-8 are already replacements. A height-only check
- // would broadcast reorg@9 (too shallow), leaving followers on stale blocks 5-8 until a
- // later poll caught the deeper rewrite, so the walk-back must resolve fork=5 in this poll.
- poller.lastPolledBlock = 10;
- db.getLastBlock.resolves(8);
- // Recorded broadcast hashes: 4 matches the live source ('l'), 5..10 were broadcast pre-reorg with a different content hash.
- poller.recentBroadcastHashes.set(4, 'l');
- for(let bi = 5; bi <= 10; bi++) poller.recentBroadcastHashes.set(bi, 'pre-reorg');
- await poller.poll();
- let event = broadcaster.broadcast.firstCall.args[2];
- assert.strictEqual(event.type, 'reorg');
- assert.strictEqual(event.block_index, 5);
- assert.strictEqual(poller.lastPolledBlock, 4);
- // Guard re-seeded from the recorded (still matching) hash at the fork parent.
- assert.strictEqual(poller.lastPolledBlockHash, 'l');
- assert.strictEqual(poller.transparencyLog.pruneFrom.calledOnceWithExactly(5), true);
- });
-
- it('currentBlock = null (all blocks deleted): early return', async function(){
- poller.lastPolledBlock = 10;
- db.getLastBlock.resolves(null);
- await poller.poll();
- assert.strictEqual(broadcaster.broadcast.called, false);
- assert.strictEqual(poller.lastPolledBlock, 10); // unchanged
- });
-
- it('first poll (lastPolledBlock = null): initializes without processing', async function(){
- poller.lastPolledBlock = null;
- db.getLastBlock.resolves(50);
- await poller.poll();
- assert.strictEqual(poller.lastPolledBlock, 50);
- assert.strictEqual(broadcaster.broadcast.called, false); // no block broadcasts
- assert.strictEqual(broadcaster.updateStatus.calledOnce, true);
- });
-
- it('first poll with empty DB: remains null', async function(){
- poller.lastPolledBlock = null;
- db.getLastBlock.resolves(null);
- await poller.poll();
- assert.strictEqual(poller.lastPolledBlock, null);
- assert.strictEqual(broadcaster.broadcast.called, false);
- });
-
- it('same-height non-detection: no event when data changes at same block', async function(){
- poller.lastPolledBlock = 10;
- db.getLastBlock.resolves(10);
- // Even if underlying data changed at block 10, poller does not detect it
- await poller.poll();
- assert.strictEqual(broadcaster.broadcast.called, false);
- });
});
diff --git a/test/unit/boundaries/reorg_detection.test/02_mid_rewrite.test.js b/test/unit/boundaries/reorg_detection.test/02_mid_rewrite.test.js
new file mode 100644
index 00000000..187abde4
--- /dev/null
+++ b/test/unit/boundaries/reorg_detection.test/02_mid_rewrite.test.js
@@ -0,0 +1,38 @@
+// 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.
+
+// Covers mid-rewrite fork discovery. One part of reorg_detection.test.js.
+const assert = require('assert');
+const { registerHooks } = require('./helpers/reorg_detection_suite');
+
+describe('Boundary: Reorg Detection', function(){
+ let poller, db, broadcaster;
+ registerHooks(function(context){ ({ poller, db, broadcaster } = context); });
+
+ it('mid-rewrite height drop: walks back to the true fork point', async function(){
+ // The source is observed mid-rewrite: the tip dropped from 10 to 8, but the rewrite
+ // actually forked at 5, so blocks 5-8 are already replacements. A height-only check
+ // would broadcast reorg@9 (too shallow), leaving followers on stale blocks 5-8 until a
+ // later poll caught the deeper rewrite, so the walk-back must resolve fork=5 in this poll.
+ poller.lastPolledBlock = 10;
+ db.getLastBlock.resolves(8);
+ // Recorded broadcast hashes: 4 matches the live source ('l'), 5..10 were broadcast pre-reorg with a different content hash.
+ poller.recentBroadcastHashes.set(4, 'l');
+ for(let bi = 5; bi <= 10; bi++) poller.recentBroadcastHashes.set(bi, 'pre-reorg');
+ await poller.poll();
+ let event = broadcaster.broadcast.firstCall.args[2];
+ assert.strictEqual(event.type, 'reorg');
+ assert.strictEqual(event.block_index, 5);
+ assert.strictEqual(poller.lastPolledBlock, 4);
+ // Guard re-seeded from the recorded (still matching) hash at the fork parent.
+ assert.strictEqual(poller.lastPolledBlockHash, 'l');
+ assert.strictEqual(poller.transparencyLog.pruneFrom.calledOnceWithExactly(5), true);
+ });
+});
diff --git a/test/unit/boundaries/reorg_detection.test/03_initialization.test.js b/test/unit/boundaries/reorg_detection.test/03_initialization.test.js
new file mode 100644
index 00000000..0ac719f2
--- /dev/null
+++ b/test/unit/boundaries/reorg_detection.test/03_initialization.test.js
@@ -0,0 +1,51 @@
+// 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.
+
+// Covers empty and initial poll states. One part of reorg_detection.test.js.
+const assert = require('assert');
+const { registerHooks } = require('./helpers/reorg_detection_suite');
+
+describe('Boundary: Reorg Detection', function(){
+ let poller, db, broadcaster;
+ registerHooks(function(context){ ({ poller, db, broadcaster } = context); });
+
+ it('currentBlock = null (all blocks deleted): early return', async function(){
+ poller.lastPolledBlock = 10;
+ db.getLastBlock.resolves(null);
+ await poller.poll();
+ assert.strictEqual(broadcaster.broadcast.called, false);
+ assert.strictEqual(poller.lastPolledBlock, 10); // unchanged
+ });
+
+ it('first poll (lastPolledBlock = null): initializes without processing', async function(){
+ poller.lastPolledBlock = null;
+ db.getLastBlock.resolves(50);
+ await poller.poll();
+ assert.strictEqual(poller.lastPolledBlock, 50);
+ assert.strictEqual(broadcaster.broadcast.called, false); // no block broadcasts
+ assert.strictEqual(broadcaster.updateStatus.calledOnce, true);
+ });
+
+ it('first poll with empty DB: remains null', async function(){
+ poller.lastPolledBlock = null;
+ db.getLastBlock.resolves(null);
+ await poller.poll();
+ assert.strictEqual(poller.lastPolledBlock, null);
+ assert.strictEqual(broadcaster.broadcast.called, false);
+ });
+
+ it('same-height non-detection: no event when data changes at same block', async function(){
+ poller.lastPolledBlock = 10;
+ db.getLastBlock.resolves(10);
+ // Even if underlying data changed at block 10, poller does not detect it
+ await poller.poll();
+ assert.strictEqual(broadcaster.broadcast.called, false);
+ });
+});
diff --git a/test/unit/boundaries/reorg_detection.test/helpers/reorg_detection_suite.js b/test/unit/boundaries/reorg_detection.test/helpers/reorg_detection_suite.js
new file mode 100644
index 00000000..9524c33d
--- /dev/null
+++ b/test/unit/boundaries/reorg_detection.test/helpers/reorg_detection_suite.js
@@ -0,0 +1,55 @@
+// 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.
+
+// Covers reorg fixtures and hooks. One part of reorg_detection.test.js.
+const sinon = require('sinon');
+const ServerPoller = require('../../../../../src/server/poller');
+const Utility = require('../../../../../src/util');
+const { withDbMixins } = require('../../../../helpers/db_mixins.js');
+
+function createMockDb(){
+ // Queries read through named Database methods. The real ones are installed for
+ // any this fake does not stub, so they still reach doQuery below and every
+ // doQuery call count these suites assert keeps counting them.
+ return withDbMixins({
+ getLastBlock: sinon.stub().resolves(null),
+ getBlockHashRow: sinon.stub().resolves(null),
+ getBlockScopedRows: sinon.stub().resolves([]),
+ getTxScopedRows: sinon.stub().resolves([]),
+ getActionScopedRows: sinon.stub().resolves([]),
+ getEmissionRowsForBlock: sinon.stub().resolves([]),
+ getTransactions: sinon.stub().resolves([]),
+ getActions: sinon.stub().resolves([]),
+ getStatusId: sinon.stub().resolves(null),
+ doQuery: sinon.stub().resolves([]),
+ beginReadSnapshot: sinon.stub().resolves({ mockSnapshotConn: true }),
+ commitReadSnapshot: sinon.stub().resolves(),
+ rollbackReadSnapshot: sinon.stub().resolves()
+ });
+}
+
+function registerHooks(setContext){
+ beforeEach(function(){
+ let db = createMockDb();
+ let broadcaster = { broadcast: sinon.stub(), updateStatus: sinon.stub(), getSubscribers: sinon.stub().returns([]), getSubscriberCount: sinon.stub().returns(0) };
+ let log = { recordBlock: sinon.stub().resolves(), pruneFrom: sinon.stub().resolves() };
+ let poller = new ServerPoller('bitcoin', 'mainnet', db, broadcaster, log, { BLOCK_POLL_INTERVAL: 100 }, new Utility());
+ db.getBlockHashRow.callsFake(async (idx) => ({
+ block_index: idx, block_time: idx * 10,
+ ledger_hash: 'l', actions_hash: 'a', contract_hash: 'c'
+ }));
+ sinon.stub(console, 'log');
+ sinon.stub(console, 'error');
+ setContext({ poller, db, broadcaster });
+ });
+ afterEach(function(){ sinon.restore(); });
+}
+
+module.exports = { registerHooks };
diff --git a/test/unit/boundaries/transparency_page.test.js b/test/unit/boundaries/transparency_page.test.js
index 7b9eb222..89285d79 100644
--- a/test/unit/boundaries/transparency_page.test.js
+++ b/test/unit/boundaries/transparency_page.test.js
@@ -9,24 +9,11 @@
// contact legal@dankest.llc.
const assert = require('assert');
-const sinon = require('sinon');
const TransparencyLog = require('../../../src/server/transparency_log');
-const { withDbMixins } = require('../../helpers/db_mixins.js');
-
-function createMockDb(total){
- return withDbMixins({
- doQuery: sinon.stub().callsFake(async (query, args) => {
- if(query.includes('COUNT'))
- return [{ total: total || 0 }];
- return [];
- })
- });
-}
+const { createMockDb, registerHooks } = require('./transparency_page.test/helpers/transparency_page_suite');
describe('Boundary: Transparency Log Pagination', function(){
-
- afterEach(function(){ sinon.restore(); });
-
+ registerHooks();
describe('page parameter', function(){
it('page=0: offset is 0', async function(){
let log = new TransparencyLog(createMockDb(100));
@@ -80,82 +67,4 @@ describe('Boundary: Transparency Log Pagination', function(){
assert.strictEqual(result.page, 999999);
});
});
-
- describe('limit parameter', function(){
- it('limit=1: minimum valid', async function(){
- let log = new TransparencyLog(createMockDb(100));
- let result = await log.getPage(0, 1);
- assert.strictEqual(result.limit, 1);
- });
-
- it('limit=0: falls back to 100 (0 is falsy)', async function(){
- let log = new TransparencyLog(createMockDb(100));
- let result = await log.getPage(0, 0);
- assert.strictEqual(result.limit, 100);
- });
-
- it('limit=-1: clamped to 1', async function(){
- let log = new TransparencyLog(createMockDb(100));
- let result = await log.getPage(0, -1);
- assert.strictEqual(result.limit, 1);
- });
-
- it('limit=100: default value', async function(){
- let log = new TransparencyLog(createMockDb(100));
- let result = await log.getPage(0, 100);
- assert.strictEqual(result.limit, 100);
- });
-
- it('limit=999: just below max', async function(){
- let log = new TransparencyLog(createMockDb(100));
- let result = await log.getPage(0, 999);
- assert.strictEqual(result.limit, 999);
- });
-
- it('limit=1000: at max', async function(){
- let log = new TransparencyLog(createMockDb(100));
- let result = await log.getPage(0, 1000);
- assert.strictEqual(result.limit, 1000);
- });
-
- it('limit=1001: clamped to 1000', async function(){
- let log = new TransparencyLog(createMockDb(100));
- let result = await log.getPage(0, 1001);
- assert.strictEqual(result.limit, 1000);
- });
-
- it('limit=999999: clamped to 1000', async function(){
- let log = new TransparencyLog(createMockDb(100));
- let result = await log.getPage(0, 999999);
- assert.strictEqual(result.limit, 1000);
- });
-
- it('limit="abc": defaults to 100', async function(){
- let log = new TransparencyLog(createMockDb(100));
- let result = await log.getPage(0, 'abc');
- assert.strictEqual(result.limit, 100);
- });
-
- it('limit=undefined: defaults to 100', async function(){
- let log = new TransparencyLog(createMockDb(100));
- let result = await log.getPage(0);
- assert.strictEqual(result.limit, 100);
- });
- });
-
- describe('offset calculation', function(){
- it('page=0, limit=10: offset=0', async function(){
- let log = new TransparencyLog(createMockDb(100));
- await log.getPage(0, 10);
- let selectCall = log.db.doQuery.getCalls().find(c => c.args[0].includes('LIMIT'));
- assert.strictEqual(selectCall.args[1][1], 0);
- });
-
- it('page=5, limit=20: offset=100', async function(){
- let log = new TransparencyLog(createMockDb(1000));
- await log.getPage(5, 20);
- let selectCall = log.db.doQuery.getCalls().find(c => c.args[0].includes('LIMIT'));
- assert.strictEqual(selectCall.args[1][1], 100);
- });
- });
});
diff --git a/test/unit/boundaries/transparency_page.test/02_limit_lower.test.js b/test/unit/boundaries/transparency_page.test/02_limit_lower.test.js
new file mode 100644
index 00000000..5f1376aa
--- /dev/null
+++ b/test/unit/boundaries/transparency_page.test/02_limit_lower.test.js
@@ -0,0 +1,49 @@
+// 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.
+
+// Covers lower page limits. One part of transparency_page.test.js.
+const assert = require('assert');
+const TransparencyLog = require('../../../../src/server/transparency_log');
+const { createMockDb, registerHooks } = require('./helpers/transparency_page_suite');
+
+describe('Boundary: Transparency Log Pagination', function(){
+ registerHooks();
+ describe('limit parameter', function(){
+ it('limit=1: minimum valid', async function(){
+ let log = new TransparencyLog(createMockDb(100));
+ let result = await log.getPage(0, 1);
+ assert.strictEqual(result.limit, 1);
+ });
+
+ it('limit=0: falls back to 100 (0 is falsy)', async function(){
+ let log = new TransparencyLog(createMockDb(100));
+ let result = await log.getPage(0, 0);
+ assert.strictEqual(result.limit, 100);
+ });
+
+ it('limit=-1: clamped to 1', async function(){
+ let log = new TransparencyLog(createMockDb(100));
+ let result = await log.getPage(0, -1);
+ assert.strictEqual(result.limit, 1);
+ });
+
+ it('limit=100: default value', async function(){
+ let log = new TransparencyLog(createMockDb(100));
+ let result = await log.getPage(0, 100);
+ assert.strictEqual(result.limit, 100);
+ });
+
+ it('limit=999: just below max', async function(){
+ let log = new TransparencyLog(createMockDb(100));
+ let result = await log.getPage(0, 999);
+ assert.strictEqual(result.limit, 999);
+ });
+ });
+});
diff --git a/test/unit/boundaries/transparency_page.test/03_limit_upper.test.js b/test/unit/boundaries/transparency_page.test/03_limit_upper.test.js
new file mode 100644
index 00000000..e233528f
--- /dev/null
+++ b/test/unit/boundaries/transparency_page.test/03_limit_upper.test.js
@@ -0,0 +1,49 @@
+// 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.
+
+// Covers upper page limits. One part of transparency_page.test.js.
+const assert = require('assert');
+const TransparencyLog = require('../../../../src/server/transparency_log');
+const { createMockDb, registerHooks } = require('./helpers/transparency_page_suite');
+
+describe('Boundary: Transparency Log Pagination', function(){
+ registerHooks();
+ describe('limit parameter', function(){
+ it('limit=1000: at max', async function(){
+ let log = new TransparencyLog(createMockDb(100));
+ let result = await log.getPage(0, 1000);
+ assert.strictEqual(result.limit, 1000);
+ });
+
+ it('limit=1001: clamped to 1000', async function(){
+ let log = new TransparencyLog(createMockDb(100));
+ let result = await log.getPage(0, 1001);
+ assert.strictEqual(result.limit, 1000);
+ });
+
+ it('limit=999999: clamped to 1000', async function(){
+ let log = new TransparencyLog(createMockDb(100));
+ let result = await log.getPage(0, 999999);
+ assert.strictEqual(result.limit, 1000);
+ });
+
+ it('limit="abc": defaults to 100', async function(){
+ let log = new TransparencyLog(createMockDb(100));
+ let result = await log.getPage(0, 'abc');
+ assert.strictEqual(result.limit, 100);
+ });
+
+ it('limit=undefined: defaults to 100', async function(){
+ let log = new TransparencyLog(createMockDb(100));
+ let result = await log.getPage(0);
+ assert.strictEqual(result.limit, 100);
+ });
+ });
+});
diff --git a/test/unit/boundaries/transparency_page.test/04_offset.test.js b/test/unit/boundaries/transparency_page.test/04_offset.test.js
new file mode 100644
index 00000000..4d44e382
--- /dev/null
+++ b/test/unit/boundaries/transparency_page.test/04_offset.test.js
@@ -0,0 +1,33 @@
+// 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.
+
+// Covers offset calculations. One part of transparency_page.test.js.
+const assert = require('assert');
+const TransparencyLog = require('../../../../src/server/transparency_log');
+const { createMockDb, registerHooks } = require('./helpers/transparency_page_suite');
+
+describe('Boundary: Transparency Log Pagination', function(){
+ registerHooks();
+ describe('offset calculation', function(){
+ it('page=0, limit=10: offset=0', async function(){
+ let log = new TransparencyLog(createMockDb(100));
+ await log.getPage(0, 10);
+ let selectCall = log.db.doQuery.getCalls().find(c => c.args[0].includes('LIMIT'));
+ assert.strictEqual(selectCall.args[1][1], 0);
+ });
+
+ it('page=5, limit=20: offset=100', async function(){
+ let log = new TransparencyLog(createMockDb(1000));
+ await log.getPage(5, 20);
+ let selectCall = log.db.doQuery.getCalls().find(c => c.args[0].includes('LIMIT'));
+ assert.strictEqual(selectCall.args[1][1], 100);
+ });
+ });
+});
diff --git a/test/unit/boundaries/transparency_page.test/helpers/transparency_page_suite.js b/test/unit/boundaries/transparency_page.test/helpers/transparency_page_suite.js
new file mode 100644
index 00000000..cb9913c3
--- /dev/null
+++ b/test/unit/boundaries/transparency_page.test/helpers/transparency_page_suite.js
@@ -0,0 +1,29 @@
+// 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.
+
+// Covers pagination fixtures and hooks. One part of transparency_page.test.js.
+const sinon = require('sinon');
+const { withDbMixins } = require('../../../../helpers/db_mixins.js');
+
+function createMockDb(total){
+ return withDbMixins({
+ doQuery: sinon.stub().callsFake(async (query, args) => {
+ if(query.includes('COUNT'))
+ return [{ total: total || 0 }];
+ return [];
+ })
+ });
+}
+
+function registerHooks(){
+ afterEach(function(){ sinon.restore(); });
+}
+
+module.exports = { createMockDb, registerHooks };
diff --git a/test/unit/boundaries/websocket_limits.test.js b/test/unit/boundaries/websocket_limits.test.js
index 84ab3aa3..b593ce0a 100644
--- a/test/unit/boundaries/websocket_limits.test.js
+++ b/test/unit/boundaries/websocket_limits.test.js
@@ -10,28 +10,12 @@
const assert = require('assert');
const sinon = require('sinon');
-const WebSocket = require('ws');
const BlockBroadcaster = require('../../../src/server/block_broadcaster');
-
-function mockWs(){
- return {
- readyState: WebSocket.OPEN,
- bufferedAmount: 0,
- _syncBuffered: 0,
- _syncChain: null, _syncNetwork: null, _syncIp: null,
- send: sinon.stub(), close: sinon.stub(), on: sinon.stub()
- };
-}
-
-function mockReq(ip){
- return { headers: {}, socket: { remoteAddress: ip || '127.0.0.1' } };
-}
+const { mockWs, mockReq, registerHooks } = require('./websocket_limits.test/helpers/websocket_limits_suite');
describe('Boundary: WebSocket Limits', function(){
-
let broadcaster;
-
- afterEach(function(){ sinon.restore(); });
+ registerHooks();
describe('per-IP connection limit', function(){
beforeEach(function(){
@@ -83,102 +67,4 @@ describe('Boundary: WebSocket Limits', function(){
assert.strictEqual(broadcaster.getSubscriberCount('bitcoin', 'mainnet'), 6);
});
});
-
- describe('per-IP limit = 1', function(){
- beforeEach(function(){
- broadcaster = new BlockBroadcaster({ WS_MAX_PER_IP: 1, WS_BACKPRESSURE_LIMIT: 50 });
- sinon.stub(console, 'log');
- });
-
- it('accepts first, rejects second', function(){
- let ip = '6.6.6.6';
- assert.strictEqual(broadcaster.addSubscription(mockWs(), mockReq(ip), 'b', 'm'), true);
- let ws2 = mockWs();
- assert.strictEqual(broadcaster.addSubscription(ws2, mockReq(ip), 'b', 'm'), false);
- assert.strictEqual(ws2.close.calledOnce, true);
- });
- });
-
- describe('backpressure (item 5410: drop only genuinely stalled peers)', function(){
- const MAX_BYTES = 1000;
- const STALL_MS = 30000;
- beforeEach(function(){
- broadcaster = new BlockBroadcaster({ WS_MAX_PER_IP: 10, WS_BACKPRESSURE_MAX_BYTES: MAX_BYTES, WS_BACKPRESSURE_STALL_MS: STALL_MS });
- sinon.stub(console, 'log');
- });
-
- it('slow-but-draining peer is NEVER dropped, across many buffered sends (the 5410 regression)', function(){
- let ws = mockWs();
- ws._syncIp = 'test';
- // Buffer trends DOWN each send (peer is draining) but stays > 0: must survive.
- for(let b of [900, 800, 700, 600, 500, 400, 300, 200, 100, 50]){
- ws.bufferedAmount = b;
- broadcaster.send(ws, 'msg');
- }
- assert.strictEqual(ws.close.called, false);
- assert.strictEqual(ws._syncBackpressureSince, null); // downward progress kept resetting it
- assert.strictEqual(ws.send.callCount, 10);
- });
-
- it('drops a peer whose buffer exceeds the byte ceiling', function(){
- let ws = mockWs();
- ws._syncIp = 'test';
- ws.bufferedAmount = MAX_BYTES + 1;
- broadcaster.send(ws, 'msg');
- assert.strictEqual(ws.close.calledOnce, true);
- assert.strictEqual(ws.close.firstCall.args[0], 1008);
- assert.strictEqual(ws.send.called, false);
- });
-
- it('drops a peer whose buffer is non-draining past the stall window', function(){
- let ws = mockWs();
- ws._syncIp = 'test';
- ws.bufferedAmount = 100; // below ceiling, non-empty
- ws._syncLastBuffered = 100; // flat: no downward progress
- ws._syncBackpressureSince = Date.now() - (STALL_MS + 1); // window already elapsed
- broadcaster.send(ws, 'msg');
- assert.strictEqual(ws.close.calledOnce, true);
- assert.strictEqual(ws.close.firstCall.args[0], 1008);
- });
-
- it('does NOT drop a stalled-then-recovered peer (drain resets the window)', function(){
- let ws = mockWs();
- ws._syncIp = 'test';
- ws.bufferedAmount = 100;
- ws._syncLastBuffered = 200; // drained 200 -> 100: progress
- ws._syncBackpressureSince = Date.now() - (STALL_MS + 1); // stale window, must be cleared
- broadcaster.send(ws, 'msg');
- assert.strictEqual(ws.close.called, false);
- assert.strictEqual(ws._syncBackpressureSince, null);
- assert.strictEqual(ws.send.calledOnce, true);
- });
-
- it('a fully-drained buffer keeps the peer healthy and clears any stall window', function(){
- let ws = mockWs();
- ws._syncIp = 'test';
- ws.bufferedAmount = 0;
- ws._syncBackpressureSince = Date.now() - (STALL_MS + 1);
- broadcaster.send(ws, 'msg');
- assert.strictEqual(ws.close.called, false);
- assert.strictEqual(ws._syncBackpressureSince, null);
- assert.strictEqual(ws.send.calledOnce, true);
- });
-
- it('arms but does not trip the stall window on the first non-draining send', function(){
- let ws = mockWs();
- ws._syncIp = 'test';
- ws.bufferedAmount = 100;
- broadcaster.send(ws, 'msg');
- assert.strictEqual(ws.close.called, false);
- assert.notStrictEqual(ws._syncBackpressureSince, null); // window armed for next time
- assert.strictEqual(ws.send.calledOnce, true);
- });
-
- it('skips closed WebSocket', function(){
- let ws = mockWs();
- ws.readyState = WebSocket.CLOSED;
- broadcaster.send(ws, 'msg');
- assert.strictEqual(ws.send.called, false);
- });
- });
});
diff --git a/test/unit/boundaries/websocket_limits.test/02_single_connection.test.js b/test/unit/boundaries/websocket_limits.test/02_single_connection.test.js
new file mode 100644
index 00000000..cfbd2f86
--- /dev/null
+++ b/test/unit/boundaries/websocket_limits.test/02_single_connection.test.js
@@ -0,0 +1,34 @@
+// 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.
+
+// Covers a single allowed connection. One part of websocket_limits.test.js.
+const assert = require('assert');
+const sinon = require('sinon');
+const BlockBroadcaster = require('../../../../src/server/block_broadcaster');
+const { mockWs, mockReq, registerHooks } = require('./helpers/websocket_limits_suite');
+
+describe('Boundary: WebSocket Limits', function(){
+ let broadcaster;
+ registerHooks();
+ describe('per-IP limit = 1', function(){
+ beforeEach(function(){
+ broadcaster = new BlockBroadcaster({ WS_MAX_PER_IP: 1, WS_BACKPRESSURE_LIMIT: 50 });
+ sinon.stub(console, 'log');
+ });
+
+ it('accepts first, rejects second', function(){
+ let ip = '6.6.6.6';
+ assert.strictEqual(broadcaster.addSubscription(mockWs(), mockReq(ip), 'b', 'm'), true);
+ let ws2 = mockWs();
+ assert.strictEqual(broadcaster.addSubscription(ws2, mockReq(ip), 'b', 'm'), false);
+ assert.strictEqual(ws2.close.calledOnce, true);
+ });
+ });
+});
diff --git a/test/unit/boundaries/websocket_limits.test/03_backpressure_drop.test.js b/test/unit/boundaries/websocket_limits.test/03_backpressure_drop.test.js
new file mode 100644
index 00000000..135db100
--- /dev/null
+++ b/test/unit/boundaries/websocket_limits.test/03_backpressure_drop.test.js
@@ -0,0 +1,62 @@
+// 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.
+
+// Covers backpressure drop boundaries. One part of websocket_limits.test.js.
+const assert = require('assert');
+const sinon = require('sinon');
+const BlockBroadcaster = require('../../../../src/server/block_broadcaster');
+const { mockWs, registerHooks } = require('./helpers/websocket_limits_suite');
+
+describe('Boundary: WebSocket Limits', function(){
+ let broadcaster;
+ registerHooks();
+ describe('backpressure (item 5410: drop only genuinely stalled peers)', function(){
+ const MAX_BYTES = 1000;
+ const STALL_MS = 30000;
+ beforeEach(function(){
+ broadcaster = new BlockBroadcaster({ WS_MAX_PER_IP: 10, WS_BACKPRESSURE_MAX_BYTES: MAX_BYTES, WS_BACKPRESSURE_STALL_MS: STALL_MS });
+ sinon.stub(console, 'log');
+ });
+
+ it('slow-but-draining peer is NEVER dropped, across many buffered sends (the 5410 regression)', function(){
+ let ws = mockWs();
+ ws._syncIp = 'test';
+ // Buffer trends DOWN each send (peer is draining) but stays > 0: must survive.
+ for(let b of [900, 800, 700, 600, 500, 400, 300, 200, 100, 50]){
+ ws.bufferedAmount = b;
+ broadcaster.send(ws, 'msg');
+ }
+ assert.strictEqual(ws.close.called, false);
+ assert.strictEqual(ws._syncBackpressureSince, null); // downward progress kept resetting it
+ assert.strictEqual(ws.send.callCount, 10);
+ });
+
+ it('drops a peer whose buffer exceeds the byte ceiling', function(){
+ let ws = mockWs();
+ ws._syncIp = 'test';
+ ws.bufferedAmount = MAX_BYTES + 1;
+ broadcaster.send(ws, 'msg');
+ assert.strictEqual(ws.close.calledOnce, true);
+ assert.strictEqual(ws.close.firstCall.args[0], 1008);
+ assert.strictEqual(ws.send.called, false);
+ });
+
+ it('drops a peer whose buffer is non-draining past the stall window', function(){
+ let ws = mockWs();
+ ws._syncIp = 'test';
+ ws.bufferedAmount = 100; // below ceiling, non-empty
+ ws._syncLastBuffered = 100; // flat: no downward progress
+ ws._syncBackpressureSince = Date.now() - (STALL_MS + 1); // window already elapsed
+ broadcaster.send(ws, 'msg');
+ assert.strictEqual(ws.close.calledOnce, true);
+ assert.strictEqual(ws.close.firstCall.args[0], 1008);
+ });
+ });
+});
diff --git a/test/unit/boundaries/websocket_limits.test/04_backpressure_recovery.test.js b/test/unit/boundaries/websocket_limits.test/04_backpressure_recovery.test.js
new file mode 100644
index 00000000..39702b5f
--- /dev/null
+++ b/test/unit/boundaries/websocket_limits.test/04_backpressure_recovery.test.js
@@ -0,0 +1,68 @@
+// 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.
+
+// Covers backpressure recovery. One part of websocket_limits.test.js.
+const assert = require('assert');
+const sinon = require('sinon');
+const BlockBroadcaster = require('../../../../src/server/block_broadcaster');
+const { WebSocket, mockWs, registerHooks } = require('./helpers/websocket_limits_suite');
+
+describe('Boundary: WebSocket Limits', function(){
+ let broadcaster;
+ registerHooks();
+ describe('backpressure (item 5410: drop only genuinely stalled peers)', function(){
+ const MAX_BYTES = 1000;
+ const STALL_MS = 30000;
+ beforeEach(function(){
+ broadcaster = new BlockBroadcaster({ WS_MAX_PER_IP: 10, WS_BACKPRESSURE_MAX_BYTES: MAX_BYTES, WS_BACKPRESSURE_STALL_MS: STALL_MS });
+ sinon.stub(console, 'log');
+ });
+
+ it('does NOT drop a stalled-then-recovered peer (drain resets the window)', function(){
+ let ws = mockWs();
+ ws._syncIp = 'test';
+ ws.bufferedAmount = 100;
+ ws._syncLastBuffered = 200; // drained 200 -> 100: progress
+ ws._syncBackpressureSince = Date.now() - (STALL_MS + 1); // stale window, must be cleared
+ broadcaster.send(ws, 'msg');
+ assert.strictEqual(ws.close.called, false);
+ assert.strictEqual(ws._syncBackpressureSince, null);
+ assert.strictEqual(ws.send.calledOnce, true);
+ });
+
+ it('a fully-drained buffer keeps the peer healthy and clears any stall window', function(){
+ let ws = mockWs();
+ ws._syncIp = 'test';
+ ws.bufferedAmount = 0;
+ ws._syncBackpressureSince = Date.now() - (STALL_MS + 1);
+ broadcaster.send(ws, 'msg');
+ assert.strictEqual(ws.close.called, false);
+ assert.strictEqual(ws._syncBackpressureSince, null);
+ assert.strictEqual(ws.send.calledOnce, true);
+ });
+
+ it('arms but does not trip the stall window on the first non-draining send', function(){
+ let ws = mockWs();
+ ws._syncIp = 'test';
+ ws.bufferedAmount = 100;
+ broadcaster.send(ws, 'msg');
+ assert.strictEqual(ws.close.called, false);
+ assert.notStrictEqual(ws._syncBackpressureSince, null); // window armed for next time
+ assert.strictEqual(ws.send.calledOnce, true);
+ });
+
+ it('skips closed WebSocket', function(){
+ let ws = mockWs();
+ ws.readyState = WebSocket.CLOSED;
+ broadcaster.send(ws, 'msg');
+ assert.strictEqual(ws.send.called, false);
+ });
+ });
+});
diff --git a/test/unit/boundaries/websocket_limits.test/helpers/websocket_limits_suite.js b/test/unit/boundaries/websocket_limits.test/helpers/websocket_limits_suite.js
new file mode 100644
index 00000000..7b3c6681
--- /dev/null
+++ b/test/unit/boundaries/websocket_limits.test/helpers/websocket_limits_suite.js
@@ -0,0 +1,33 @@
+// 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.
+
+// Covers socket fixtures and hooks. One part of websocket_limits.test.js.
+const sinon = require('sinon');
+const WebSocket = require('ws');
+
+function mockWs(){
+ return {
+ readyState: WebSocket.OPEN,
+ bufferedAmount: 0,
+ _syncBuffered: 0,
+ _syncChain: null, _syncNetwork: null, _syncIp: null,
+ send: sinon.stub(), close: sinon.stub(), on: sinon.stub()
+ };
+}
+
+function mockReq(ip){
+ return { headers: {}, socket: { remoteAddress: ip || '127.0.0.1' } };
+}
+
+function registerHooks(){
+ afterEach(function(){ sinon.restore(); });
+}
+
+module.exports = { WebSocket, mockWs, mockReq, registerHooks };
diff --git a/test/unit/client_applier.test/06_insert_rows_upsert_error_tag.test.js b/test/unit/client_applier.test/06_insert_rows_upsert_error_tag.test.js
new file mode 100644
index 00000000..e7d1e5b3
--- /dev/null
+++ b/test/unit/client_applier.test/06_insert_rows_upsert_error_tag.test.js
@@ -0,0 +1,49 @@
+// 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.
+
+// insertRows names the upsert full-dump table on a failing statement's error, which
+// is what lets ClientSync halt on a repeating markets duplicate key and on nothing else.
+
+const assert = require('assert');
+const sinon = require('sinon');
+const ClientApplier = require('../../../src/client/applier');
+const Utility = require('../../../src/util');
+const { withDbMixins } = require('../../helpers/db_mixins.js');
+
+function dupKeyError(){
+ return Object.assign(new Error("Duplicate entry '42' for key 'PRIMARY'"), { errno: 1062 });
+}
+
+describe('ClientApplier.insertRows upsert error tag', function(){
+ let applier, db;
+ beforeEach(function(){
+ db = withDbMixins({ doQuery: sinon.stub().rejects(dupKeyError()) });
+ applier = new ClientApplier(db, new Utility());
+ sinon.stub(console, 'log');
+ sinon.stub(console, 'error');
+ });
+ afterEach(function(){ sinon.restore(); });
+
+ it('tags a failing markets upsert with its table and keeps the source id', async function(){
+ let err = await applier.insertRows('markets', [{ id: 42, tick1_id: 1, tick2_id: 2 }]).then(() => null, e => e);
+ assert.ok(err, 'the duplicate key propagates');
+ assert.strictEqual(err.errno, 1062);
+ assert.strictEqual(err.upsertTable, 'markets');
+ let sql = db.doQuery.firstCall.args[0];
+ assert.ok(sql.includes('ON DUPLICATE KEY UPDATE'), 'markets upserts');
+ assert.ok(sql.includes('`id` = VALUES(`id`)'), 'markets carries the source id');
+ });
+
+ it('leaves a plain INSERT table untagged', async function(){
+ let err = await applier.insertRows('credits', [{ id: 1, block_index: 5 }]).then(() => null, e => e);
+ assert.ok(err);
+ assert.strictEqual(err.upsertTable, undefined);
+ });
+});
diff --git a/test/unit/client_rollback.test/07_token_refold.test.js b/test/unit/client_rollback.test/07_token_refold.test.js
new file mode 100644
index 00000000..82d358e3
--- /dev/null
+++ b/test/unit/client_rollback.test/07_token_refold.test.js
@@ -0,0 +1,278 @@
+// 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.
+
+// The reverse leg of updated_rows class 7: a reorg that orphans an ISSUE edit must leave
+// the replica's tokens row equal to the source's refold from the surviving issues. Each
+// scenario runs ClientRollback against an in-memory replica, checks the row against
+// hand-written expectations, and, when the xchain-indexer sibling is present, against
+// the indexer's OWN getTokenInfo + createToken run over the same surviving issues.
+
+'use strict';
+
+const assert = require('assert');
+const path = require('path');
+const fs = require('fs');
+const sinon = require('sinon');
+const ClientRollback = require('../../../src/client/rollback');
+const BlockHasher = require('../../../src/client/block_hasher');
+const Utility = require('../../../src/util');
+const tokenRefold = require('../../../src/db/token_refold');
+const { siblingCheckout, skipOrFail } = require('../../helpers/sibling_checkout.js');
+
+const ADDRESSES = { 1: 'addrAlice', 2: 'addrBob', 3: 'addrCarol' };
+const TICKERS = { 10: 'TOKA', 11: 'CBTICK' };
+const TICK = 10;
+
+function issue(action_index, fields){
+ return Object.assign({
+ tick_id: TICK, action_index, status: 'valid', source_addr_id: 1, transfer_addr_id: null,
+ callback_tick_ref: null, max_supply: '', max_mint: '', decimals: '', description: '',
+ lock_max_supply: '', lock_mint_supply: '', lock_mint: '', lock_max_mint: '',
+ lock_description: '', lock_sleep: '', lock_callback: '', callback_block: '',
+ callback_amount: '', mint_address_max: '', mint_start_block: '', mint_stop_block: '',
+ allow_list: null, block_list: null, bridge_chains: '', min_depth: '', lock_bridge: '',
+ }, fields);
+}
+
+const GENESIS = issue(100, { decimals: '8', max_supply: '1000', max_mint: '10', description: 'first',
+ lock_max_supply: '0', lock_mint: '0', lock_description: '0', mint_start_block: '50',
+ mint_stop_block: '900', bridge_chains: 'LTC', min_depth: '6', lock_bridge: '0' });
+const SURVIVING_EDIT = issue(300, { description: 'second', callback_tick_ref: 11,
+ callback_block: '700', callback_amount: '1.5', allow_list: 42 });
+
+// The replica row as the orphaned edit left it (forward class 7 carried it).
+function editedRow(over){
+ return Object.assign({ tick_id: TICK, action_index: 100, supply: '5', bridged: 0, escrow_action_index: null,
+ max_supply: '1000.00000000', max_mint: '10.00000000', decimals: 8, description: 'second',
+ lock_max_supply: 0, lock_mint: 0, lock_mint_supply: 0, lock_max_mint: 0, lock_description: 0,
+ lock_sleep: 0, lock_callback: 0, callback_block: '700', callback_tick_id: 11, callback_amount: '1.5',
+ allow_list: 42, block_list: null, mint_address_max: '0.00000000', mint_start_block: '50',
+ mint_stop_block: '900', bridge_chains: 'LTC', min_depth: 6, lock_bridge: 0, owner_id: 1,
+ last_action_index: 500 }, over);
+}
+
+// What the source's refold writes after the reorg drops the edit at 500.
+const EXPECTED = editedRow({ last_action_index: 300 });
+
+// An in-memory replica answering exactly the statements the refold path issues.
+function replica(issues, tokenRow, firstActionIndex){
+ let world = { issues: issues.slice(), token: Object.assign({}, tokenRow), updates: 0 };
+ let doQuery = sinon.stub().callsFake(async (sql, args) => {
+ if(/^SELECT DISTINCT tick_id FROM issues WHERE action_index >= \?/.test(sql))
+ return [...new Set(world.issues.filter(i => i.action_index >= args[0]).map(i => i.tick_id))].map(t => ({ tick_id: t }));
+ if(sql === 'DELETE FROM `issues` WHERE action_index >= ?'){
+ world.issues = world.issues.filter(i => i.action_index < args[0]);
+ return { affectedRows: 1 };
+ }
+ if(/FROM issues i INNER JOIN actions a1/.test(sql))
+ return world.issues.filter(i => i.status === 'valid' && args.map(Number).includes(i.tick_id))
+ .sort((a, b) => (a.tick_id - b.tick_id) || (a.action_index - b.action_index));
+ if(/^SELECT tick_id, action_index FROM tokens WHERE tick_id IN/.test(sql))
+ return args.map(Number).includes(world.token.tick_id) ? [{ tick_id: world.token.tick_id, action_index: world.token.action_index }] : [];
+ if(/^UPDATE tokens SET max_supply=\?/.test(sql)){
+ world.updates++;
+ if(Number(args[args.length - 1]) === world.token.tick_id)
+ tokenRefold.FOLD_COLUMNS.forEach((c, k) => { world.token[c] = args[k]; });
+ return { affectedRows: 1 };
+ }
+ return [];
+ });
+ let db = { doQuery, getFirstActionIndex: sinon.stub().resolves(firstActionIndex),
+ getStatusId: sinon.stub().resolves(null), beginTransaction: sinon.stub().resolves(),
+ commitTransaction: sinon.stub().resolves(), rollbackTransaction: sinon.stub().resolves() };
+ return { db, world };
+}
+
+// Storage view of a bound value: every fold column is VARCHAR or an integer column, so
+// String() is what the row holds, and undefined binds as NULL.
+function stored(row){
+ let o = {};
+ for(let c of tokenRefold.FOLD_COLUMNS) o[c] = (row[c] === null || row[c] === undefined) ? null : String(row[c]);
+ return o;
+}
+
+// ── The indexer's own fold, loaded from the sibling checkout ──────────────────
+function indexerRoot(){
+ if(process.env.XCHAIN_INDEXER_SQL_PATH) return { usable: true, path: path.resolve(process.env.XCHAIN_INDEXER_SQL_PATH, '..', '..'), reason: null };
+ return siblingCheckout(__dirname, '../../../../xchain-indexer');
+}
+
+// Runs getTokenInfo + createToken exactly as updateTokenInfo does, over a stub whose
+// doQuery answers the replay SELECT with the surviving issues in the source's row shape,
+// and returns the UPDATE's bound values keyed by FOLD_COLUMNS.
+async function sourceFold(root, issues){
+ // Runtime path: the sibling root is resolved per run (env override or sibling checkout).
+ let req = (rel) => require(path.join(root, rel));
+ let config = {};
+ req('src/config/wire_fields.js').applyWireFields(config);
+ req('src/config/token_limits.js').applyTokenSupplyLimits(config);
+ let util = Object.assign({ safeToString: (v) => (v === null || v === undefined) ? null : String(v) },
+ req('src/utility/value_checks.js'), req('src/utility/bcmath.js'));
+ let byName = (map, v) => { let k = Object.keys(map).find(id => map[id] === v); return k === undefined ? null : Number(k); };
+ let captured = null;
+ let db = Object.assign({ util, config }, req('src/db/issues/token_info.js'),
+ req('src/db/tokens/token_writer.js'), req('src/db/database/normalize.js'));
+ db.createTicker = async (t) => util.isNull(t) ? null : byName(TICKERS, t);
+ db.createAddress = async (a) => util.isNull(a) ? null : byName(ADDRESSES, a);
+ db.getTokenSupply = async () => '5';
+ db.doQuery = async (sql, args) => {
+ if(/FROM\s+issues i/.test(sql)) return issues.filter(i => i.status === 'valid').map(i => Object.assign({}, i, {
+ tick: TICKERS[i.tick_id], callback_tick: i.callback_tick_ref ? TICKERS[i.callback_tick_ref] : null,
+ owner: ADDRESSES[i.source_addr_id], transfer: i.transfer_addr_id ? ADDRESSES[i.transfer_addr_id] : null,
+ bridged: 0, block_index: 1 }));
+ if(/SELECT id FROM tokens/.test(sql)) return [{ id: 1 }];
+ if(/^\s*UPDATE\s+tokens/.test(sql)){ captured = args; return {}; }
+ return [];
+ };
+ let data = await db.getTokenInfo(TICKERS[TICK]);
+ await db.createToken(data);
+ // updateArgs order: the 22 fold columns, supply, owner_id, last_action_index, tick_id.
+ let cols = tokenRefold.FOLD_COLUMNS.filter(c => c !== 'owner_id' && c !== 'last_action_index');
+ let out = {};
+ cols.forEach((c, k) => { out[c] = captured[k]; });
+ out.owner_id = captured[23];
+ out.last_action_index = captured[24];
+ return out;
+}
+
+async function reorg(edit, rowAfterEdit){
+ let { db, world } = replica([GENESIS, SURVIVING_EDIT, edit], rowAfterEdit, 500);
+ await new ClientRollback(db, new Utility(), 'BTC', 'regtest').rollback(1000);
+ return world;
+}
+
+function indexerOrSkip(ctx){
+ let root = indexerRoot();
+ let ok = root.usable && fs.existsSync(path.join(root.path, 'src/db/issues/token_info.js'));
+ if(!skipOrFail(ctx, ok ? root : { usable: false, reason: root.reason || 'token_info.js absent' }, 'the indexer fold parity guard')) return null;
+ return root.path;
+}
+
+describe('ClientRollback token refold (reverse leg of updated_rows class 7)', function(){
+
+ beforeEach(function(){ sinon.stub(console, 'log'); sinon.stub(console, 'error'); });
+ afterEach(function(){ sinon.restore(); });
+
+ const SCENARIOS = {
+ 'ownership transfer': [issue(500, { transfer_addr_id: 2 }), editedRow({ owner_id: 2 })],
+ 'lock edit': [issue(500, { lock_mint: '1', lock_description: '1', lock_callback: '1' }),
+ editedRow({ lock_mint: 1, lock_description: 1, lock_callback: 1 })],
+ 'bridge-policy edit': [issue(500, { bridge_chains: '-', min_depth: '12', lock_bridge: '1' }),
+ editedRow({ bridge_chains: null, min_depth: 12, lock_bridge: 1 })],
+ 'callback, list and mint-window edit': [
+ issue(500, { callback_block: '999', callback_amount: '2', block_list: 7, mint_stop_block: '5000', max_mint: '20' }),
+ editedRow({ callback_block: '999', callback_amount: '2', block_list: 7, mint_stop_block: '5000', max_mint: '20.00000000' })],
+ };
+
+ for(let name of Object.keys(SCENARIOS)){
+ it('a reorg of the ' + name + ' leaves the replica row equal to the refold of the surviving issues', async function(){
+ let [edit, row] = SCENARIOS[name];
+ let world = await reorg(edit, row);
+ assert.strictEqual(world.updates, 1);
+ assert.deepStrictEqual(stored(world.token), stored(EXPECTED));
+ // Columns the fold does not own are left alone (supply is recomputed separately).
+ assert.strictEqual(world.token.supply, '5');
+ assert.strictEqual(world.token.action_index, 100);
+ assert.strictEqual(world.token.bridged, 0);
+ });
+
+ it('a reorg of the ' + name + ' matches the xchain-indexer fold byte for byte', async function(){
+ let root = indexerOrSkip(this);
+ if(!root) return;
+ let [edit, row] = SCENARIOS[name];
+ let world = await reorg(edit, row);
+ let source = await sourceFold(root, [GENESIS, SURVIVING_EDIT]);
+ assert.deepStrictEqual(stored(world.token), stored(source));
+ });
+ }
+});
+
+describe('ClientRollback token refold: no-op and failure paths', function(){
+
+ beforeEach(function(){ sinon.stub(console, 'log'); sinon.stub(console, 'error'); });
+ afterEach(function(){ sinon.restore(); });
+
+ it('a rollback touching no ISSUE issues no tokens UPDATE and changes nothing', async function(){
+ let row = editedRow({ last_action_index: 300 });
+ let { db, world } = replica([GENESIS, SURVIVING_EDIT], row, 500);
+ await new ClientRollback(db, new Utility(), 'BTC', 'regtest').rollback(1000);
+ assert.strictEqual(world.updates, 0);
+ assert.deepStrictEqual(world.token, row);
+ assert.ok(!db.doQuery.getCalls().some(c => /FROM issues i INNER JOIN/.test(c.args[0])));
+ });
+
+ it('a tick whose only issues were orphaned is left to the generic delete (no UPDATE)', async function(){
+ let { db, world } = replica([issue(600, { decimals: '0' })], editedRow({}), 500);
+ await new ClientRollback(db, new Utility(), 'BTC', 'regtest').rollback(1000);
+ assert.strictEqual(world.updates, 0);
+ });
+
+ it('a replica without the tick\'s first issuance (truncated) skips the refold instead of folding a tail', async function(){
+ let row = editedRow({ owner_id: 2 });
+ let { db, world } = replica([SURVIVING_EDIT, issue(500, { transfer_addr_id: 2 })], row, 500);
+ await new ClientRollback(db, new Utility(), 'BTC', 'regtest').rollback(1000);
+ assert.strictEqual(world.updates, 0);
+ assert.deepStrictEqual(world.token, row);
+ });
+
+ it('a failed refold aborts the rollback transaction', async function(){
+ let { db } = replica([GENESIS, issue(500, { transfer_addr_id: 2 })], editedRow({}), 500);
+ let inner = db.doQuery;
+ db.doQuery = sinon.stub().callsFake(async (sql, args) => {
+ if(/^UPDATE tokens SET max_supply=\?/.test(sql)){ let e = new Error('lock wait'); e.errno = 1205; throw e; }
+ return inner(sql, args);
+ });
+ await assert.rejects(new ClientRollback(db, new Utility(), 'BTC', 'regtest').rollback(1000), /lock wait/);
+ assert.ok(db.rollbackTransaction.calledOnce);
+ assert.ok(db.commitTransaction.notCalled);
+ });
+});
+
+describe('foldIssueRows', function(){
+ it('keeps a set lock, never lowers decimals, and inherits empty fields', function(){
+ let out = tokenRefold.foldIssueRows([
+ issue(1, { decimals: '8', lock_mint: '1', description: 'a', max_supply: '5' }),
+ issue(2, { decimals: '2', lock_mint: '0', description: '' }),
+ ]).get(String(TICK));
+ assert.strictEqual(out.lock_mint, 1);
+ assert.strictEqual(out.decimals, 8);
+ assert.strictEqual(out.description, 'a');
+ assert.strictEqual(out.max_supply, '5.00000000');
+ assert.strictEqual(out.last_action_index, 2);
+ });
+});
+
+describe('BlockHasher.computeTokenFoldChecksum (advisory token fold parity)', function(){
+ function hasherOver(rows, maxAction){
+ let db = { doQuery: sinon.stub().callsFake(async (sql) => {
+ if(/MAX\(action_index\)/.test(sql)) return [{ m: maxAction }];
+ if(/FROM tokens ORDER BY tick_id/.test(sql)) return rows.map(r => Object.assign({}, r));
+ return [];
+ }) };
+ return new BlockHasher(db, new Utility());
+ }
+ const A = Object.assign(editedRow({ last_action_index: 300 }), { tick_id: 10 });
+ const B = Object.assign(editedRow({ last_action_index: 800 }), { tick_id: 12 });
+
+ it('agrees when the follower holds the source rows, leaving out ticks the source edited past the height', async function(){
+ let source = await hasherOver([A, B], 500).computeTokenFoldChecksum(1000);
+ assert.deepStrictEqual(source.ahead, ['12']);
+ let follower = await hasherOver([A, Object.assign({}, B, { last_action_index: 400, owner_id: 3 })], 999)
+ .computeTokenFoldChecksum(1000, { exclude: source.ahead });
+ assert.strictEqual(follower.h, source.h);
+ });
+
+ it('detects a replica that kept an orphaned edit', async function(){
+ let source = await hasherOver([A], 500).computeTokenFoldChecksum(1000);
+ let follower = await hasherOver([editedRow({ owner_id: 2, last_action_index: 500 })], 500)
+ .computeTokenFoldChecksum(1000, { exclude: source.ahead });
+ assert.notStrictEqual(follower.h, source.h);
+ });
+});
diff --git a/test/unit/client_sync.test/04_start.test.js b/test/unit/client_sync.test/04_start.test.js
index 771e94bd..630ed134 100644
--- a/test/unit/client_sync.test/04_start.test.js
+++ b/test/unit/client_sync.test/04_start.test.js
@@ -92,8 +92,38 @@ function registerStartGroup2Tests(){
});
}
+// A full-snapshot bootstrap never reconciles dispensers, so live-follow is the only
+// point the status tick's wall-clock bound can measure from on such a replica.
+function registerStartDispenserClockTests(){
+ describe('start', function(){
+ it('starts the dispensers wall clock when a snapshot-bootstrapped decoder enters live-follow', async function(){
+ sync.dbType = 'decoder';
+ db.getLastBlock.resolves(null);
+ sinon.stub(sync, 'bootstrapFromSnapshot').callsFake(async () => { sync.lastAppliedBlock = 10; });
+ sinon.stub(sync, 'connectWebSockets').callsFake(() => { sync.running = false; });
+ let before = Date.now();
+
+ await sync.start();
+
+ assert.strictEqual(sync._lastDispenserReconcileAt, undefined, 'the snapshot path stamps no reconcile');
+ assert.ok(sync._dispenserClockArmedAt >= before, 'the clock must be armed at live-follow');
+ });
+
+ it('leaves the dispensers clock alone on an indexer replica', async function(){
+ db.getLastBlock.resolves(null);
+ sinon.stub(sync, 'bootstrapFromSnapshot').callsFake(async () => { sync.lastAppliedBlock = 10; });
+ sinon.stub(sync, 'connectWebSockets').callsFake(() => { sync.running = false; });
+
+ await sync.start();
+
+ assert.strictEqual(sync._dispenserClockArmedAt, undefined);
+ });
+ });
+}
+
describe('ClientSync', function(){
registerClientSyncHooks(assignState);
registerStartGroup1Tests();
registerStartGroup2Tests();
+ registerStartDispenserClockTests();
});
diff --git a/test/unit/client_sync.test/19_run_incremental_catch_up_upsert_duplicate_key_halt.test.js b/test/unit/client_sync.test/19_run_incremental_catch_up_upsert_duplicate_key_halt.test.js
new file mode 100644
index 00000000..cc6fa2e0
--- /dev/null
+++ b/test/unit/client_sync.test/19_run_incremental_catch_up_upsert_duplicate_key_halt.test.js
@@ -0,0 +1,133 @@
+// 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.
+
+// An upsert full-dump (markets) that keeps failing on the same duplicate key must
+// end in a durable, visible halt rather than a catch-up that re-fails forever while
+// the replica reports halted:false.
+
+const {
+ assert, sinon, axios, registerClientSyncHooks
+} = require('./support');
+
+let sync, db, applier;
+
+function assignState(state){
+ ({ sync, db, applier } = state);
+}
+
+function dupKey(entry, table){
+ let msg = "Duplicate entry '" + entry + "' for key 'PRIMARY'";
+ return Object.assign(new Error(msg), { errno: 1062, sqlMessage: msg, upsertTable: table || 'markets' });
+}
+
+function serveSnapshot(){
+ db.getLastBlock.resolves(5);
+ db.recordHalt = sinon.stub().resolves({ block_index: 5 });
+ sinon.stub(console, 'warn');
+ let gz = require('zlib').gzipSync(JSON.stringify({ schema_version: 'x', block_height: 9, tables: {} }));
+ sinon.stub(axios, 'get').resolves({ data: gz });
+}
+
+function registerHaltCases(){
+ it('halts durably when the same upsert duplicate key fails twice in a row', async function(){
+ serveSnapshot();
+ applier.applyIncrementalSnapshot.rejects(dupKey('42'));
+
+ await sync.runIncrementalCatchUp();
+ assert.strictEqual(sync.isHalted(), false, 'the first hit retries');
+ await sync.runIncrementalCatchUp();
+
+ assert.strictEqual(sync.isHalted(), true);
+ let info = sync.getHaltInfo();
+ assert.strictEqual(info.reason, 'apply-duplicate-key');
+ assert.deepStrictEqual(info.mismatches, [{ table: 'markets', entry: '42', key: 'PRIMARY' }]);
+ assert.strictEqual(db.recordHalt.callCount, 1);
+ assert.strictEqual(db.recordHalt.firstCall.args[2], 'apply-duplicate-key');
+ });
+
+ it('refuses further catch-ups once halted', async function(){
+ serveSnapshot();
+ applier.applyIncrementalSnapshot.rejects(dupKey('42'));
+ await sync.runIncrementalCatchUp();
+ await sync.runIncrementalCatchUp();
+
+ await sync.incrementalCatchUp(6);
+ assert.strictEqual(applier.applyIncrementalSnapshot.callCount, 2);
+ });
+
+ it('stays halted in memory when persisting the halt fails', async function(){
+ serveSnapshot();
+ db.recordHalt.rejects(new Error('db down'));
+ applier.applyIncrementalSnapshot.rejects(dupKey('42'));
+ await sync.runIncrementalCatchUp();
+ await sync.runIncrementalCatchUp();
+ assert.strictEqual(sync.getHaltInfo().reason, 'apply-duplicate-key');
+ });
+}
+
+function registerNoHaltCases(){
+ it('does not halt on a single duplicate key', async function(){
+ serveSnapshot();
+ applier.applyIncrementalSnapshot.rejects(dupKey('42'));
+ await sync.runIncrementalCatchUp();
+ assert.strictEqual(sync.isHalted(), false);
+ assert.strictEqual(db.recordHalt.called, false);
+ });
+
+ it('does not halt when two failures name different keys', async function(){
+ serveSnapshot();
+ applier.applyIncrementalSnapshot
+ .onFirstCall().rejects(dupKey('42'))
+ .onSecondCall().rejects(dupKey('43'));
+ await sync.runIncrementalCatchUp();
+ await sync.runIncrementalCatchUp();
+ assert.strictEqual(sync.isHalted(), false);
+ });
+
+ it('resets on a successful apply between two identical failures', async function(){
+ serveSnapshot();
+ applier.applyIncrementalSnapshot
+ .onFirstCall().rejects(dupKey('42'))
+ .onSecondCall().resolves()
+ .onThirdCall().rejects(dupKey('42'));
+ await sync.runIncrementalCatchUp();
+ await sync.runIncrementalCatchUp();
+ await sync.runIncrementalCatchUp();
+ assert.strictEqual(sync.isHalted(), false);
+ });
+
+ it('does not halt on a repeated duplicate key from a non-upsert insert', async function(){
+ serveSnapshot();
+ let plain = Object.assign(new Error("Duplicate entry '7' for key 'PRIMARY'"), { errno: 1062 });
+ applier.applyIncrementalSnapshot.rejects(plain);
+ await sync.runIncrementalCatchUp();
+ await sync.runIncrementalCatchUp();
+ assert.strictEqual(sync.isHalted(), false);
+ });
+
+ it('still routes a schema gap to the heal path', async function(){
+ serveSnapshot();
+ applier.applyIncrementalSnapshot
+ .onFirstCall().rejects(Object.assign(new Error('no table'), { errno: 1146 }))
+ .onSecondCall().resolves();
+ let heal = sinon.stub(sync, 'fetchAndApplySchema').resolves();
+ await sync.runIncrementalCatchUp();
+ assert.strictEqual(heal.calledOnce, true);
+ assert.strictEqual(sync.isHalted(), false);
+ });
+}
+
+describe('ClientSync', function(){
+ registerClientSyncHooks(assignState);
+ describe('runIncrementalCatchUp upsert duplicate-key halt', function(){
+ registerHaltCases();
+ registerNoHaltCases();
+ });
+});
diff --git a/test/unit/client_sync.test/20_reconcile_dispensers_schema_version_gate.test.js b/test/unit/client_sync.test/20_reconcile_dispensers_schema_version_gate.test.js
new file mode 100644
index 00000000..92fee348
--- /dev/null
+++ b/test/unit/client_sync.test/20_reconcile_dispensers_schema_version_gate.test.js
@@ -0,0 +1,81 @@
+// 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.
+
+const zlib = require('zlib');
+const { assert, sinon, axios, ClientSync } = require('./support');
+const { SCHEMA_VERSION } = require('../../../src/schema/version');
+
+const SOURCE = 'http://source1:3006';
+
+// Gzipped page body as the /snapshot-dispensers route serves it.
+function pageBuffer(page){
+ return zlib.gzipSync(Buffer.from(JSON.stringify(page)));
+}
+
+function decoderCtx(){
+ return {
+ dbType: 'decoder', chain: 'bitcoin', network: 'mainnet', config: {},
+ _lastDispenserReconcileAt: null,
+ upstreamHeaders: () => ({}),
+ withApplyLock: (fn) => fn(),
+ applier: { applyDispensersReplace: sinon.stub().resolves() },
+ };
+}
+
+function stubPages(pages){
+ let get = sinon.stub(axios, 'get');
+ pages.forEach((p, i) => get.onCall(i).resolves({ data: pageBuffer(p) }));
+ return get;
+}
+
+function reconcile(ctx){
+ return ClientSync.prototype.reconcileDispensers.call(ctx, SOURCE);
+}
+
+describe('ClientSync.reconcileDispensers (schema_version gate)', function(){
+ afterEach(function(){ sinon.restore(); });
+
+ it('replaces the table from a page carrying the client schema version', async function(){
+ let ctx = decoderCtx();
+ stubPages([{ schema_version: SCHEMA_VERSION.decoder, has_more: false, rows: [{ tx_index: 1 }] }]);
+ await reconcile(ctx);
+ assert.strictEqual(ctx.applier.applyDispensersReplace.calledOnce, true);
+ assert.deepStrictEqual(ctx.applier.applyDispensersReplace.firstCall.args[0], [{ tx_index: 1 }]);
+ assert.ok(ctx._lastDispenserReconcileAt > 0);
+ });
+
+ it('leaves the table intact when the page schema version differs', async function(){
+ let ctx = decoderCtx();
+ stubPages([{ schema_version: SCHEMA_VERSION.decoder + 1, has_more: false, rows: [{ tx_index: 1 }] }]);
+ await reconcile(ctx);
+ assert.strictEqual(ctx.applier.applyDispensersReplace.called, false);
+ assert.strictEqual(ctx._lastDispenserReconcileAt, null);
+ });
+
+ it('fails closed on a page with no schema_version field', async function(){
+ let ctx = decoderCtx();
+ stubPages([{ has_more: false, rows: [{ tx_index: 1 }] }]);
+ await reconcile(ctx);
+ assert.strictEqual(ctx.applier.applyDispensersReplace.called, false);
+ assert.strictEqual(ctx._lastDispenserReconcileAt, null);
+ });
+
+ it('applies nothing when a later page mismatches after an accepted first page', async function(){
+ let ctx = decoderCtx();
+ let get = stubPages([
+ { schema_version: SCHEMA_VERSION.decoder, has_more: true, max_tx: 5, max_addr: 'a', rows: [{ tx_index: 1 }] },
+ { schema_version: SCHEMA_VERSION.decoder - 1, has_more: false, rows: [{ tx_index: 6 }] },
+ ]);
+ await reconcile(ctx);
+ assert.strictEqual(get.callCount, 2);
+ assert.strictEqual(ctx.applier.applyDispensersReplace.called, false);
+ assert.strictEqual(ctx._lastDispenserReconcileAt, null);
+ });
+});
diff --git a/test/unit/client_sync_dispenser_reconcile_tick.test.js b/test/unit/client_sync_dispenser_reconcile_tick.test.js
index 9f8352c6..51d2c27f 100644
--- a/test/unit/client_sync_dispenser_reconcile_tick.test.js
+++ b/test/unit/client_sync_dispenser_reconcile_tick.test.js
@@ -27,6 +27,7 @@
const assert = require('assert');
const sinon = require('sinon');
const ClientSync = require('../../src/client/sync');
+const { SCHEMA_VERSION } = require('../../src/schema/version');
describe('ClientSync.dispenserReconcileIntervalDue (wall-clock term)', function(){
function due(ctx, now){
@@ -59,8 +60,7 @@ describe('ClientSync.dispenserReconcileIntervalDue (wall-clock term)', function(
});
it('is not due before any reconcile has stamped a time', function(){
- // firstResume belongs to the catch-up path: firing it from a recurring tick
- // would retry a failing re-dump on every tick instead of once per interval.
+ // A bare context carries no success, attempt or live-follow time to measure from.
let ctx = { config: { DISPENSERS_RECONCILE_MAX_INTERVAL_MS: '60000' },
_lastDispenserReconcileAt: null };
assert.strictEqual(due(ctx, 99999999), false);
@@ -174,3 +174,112 @@ describe('ClientSync status tick fires the stale dispensers reconcile', function
assert.strictEqual(ctx.reconcileDispensers.calledOnce, true);
});
});
+
+// A replica that bootstrapped from a full snapshot never reconciles, so the bound
+// measures from live-follow, and a failed attempt defers the retry one interval.
+describe('ClientSync.dispenserReconcileIntervalDue without a successful reconcile', function(){
+ function due(over, now){
+ let ctx = Object.assign({ config: { DISPENSERS_RECONCILE_MAX_INTERVAL_MS: '60000' },
+ _lastDispenserReconcileAt: null }, over);
+ return ClientSync.prototype.dispenserReconcileIntervalDue.call(ctx, now);
+ }
+
+ it('is due once live-follow began longer than the interval ago', function(){
+ assert.strictEqual(due({ _dispenserClockArmedAt: 1000 }, 1000 + 60000), true);
+ });
+
+ it('is not due inside the interval from live-follow', function(){
+ assert.strictEqual(due({ _dispenserClockArmedAt: 1000 }, 1000 + 59999), false);
+ });
+
+ it('waits a full interval after a failed attempt, then is due again', function(){
+ let over = { _dispenserClockArmedAt: 1000, _lastDispenserReconcileAttemptAt: 70000 };
+ assert.strictEqual(due(over, 70000 + 59999), false);
+ assert.strictEqual(due(over, 70000 + 60000), true);
+ });
+
+ it('does not retry a failing re-dump every tick after an old success', function(){
+ let over = { _lastDispenserReconcileAt: 1000, _lastDispenserReconcileAttemptAt: 90000 };
+ assert.strictEqual(due(over, 90001), false);
+ });
+
+ it('measures from a success newer than both fallbacks', function(){
+ let over = { _lastDispenserReconcileAt: 100000, _lastDispenserReconcileAttemptAt: 99000,
+ _dispenserClockArmedAt: 1000 };
+ assert.strictEqual(due(over, 100000 + 59999), false);
+ });
+
+ it('stays disabled by 0 whatever the fallbacks say', function(){
+ let over = { config: { DISPENSERS_RECONCILE_MAX_INTERVAL_MS: '0' }, _dispenserClockArmedAt: 1 };
+ assert.strictEqual(due(over, 99999999), false);
+ });
+
+ it('fires the status tick on a snapshot-bootstrapped replica', async function(){
+ let ctx = {
+ dbType: 'decoder', config: { DISPENSERS_RECONCILE_MAX_INTERVAL_MS: '60000' },
+ sources: ['http://source1:3006'], lastKnownServerBlock: 500, lastAppliedBlock: 500,
+ _halted: null, _lastDispenserReconcileAt: null, _dispenserClockArmedAt: 1000,
+ _dispenserReconcileInFlight: false,
+ recordUpstreamStatus: sinon.stub(), logGap: sinon.stub(),
+ incrementalCatchUp: sinon.stub().resolves(), maybeVerifyCompleteness: sinon.stub().resolves(),
+ reconcileDispensers: sinon.stub().resolves(),
+ dispenserReconcileIntervalDue: ClientSync.prototype.dispenserReconcileIntervalDue
+ };
+ let logStub = sinon.stub(console, 'log');
+ let clock = sinon.useFakeTimers({ now: 1000 + 60000, toFake: ['Date'] });
+ try {
+ await ClientSync.prototype.handleEvent.call(ctx, { type: 'status', block_height: 500 }, 0);
+ } finally {
+ clock.restore();
+ logStub.restore();
+ }
+ assert.strictEqual(ctx.reconcileDispensers.calledOnce, true);
+ });
+});
+
+describe('ClientSync.reconcileDispensers attempt stamp and request', function(){
+ const axios = require('axios');
+
+ function reconcileCtx(){
+ return {
+ dbType: 'decoder', chain: 'bitcoin', network: 'mainnet',
+ config: { SNAPSHOT_MAX_CONTENT: 1024 * 1024 },
+ upstreamHeaders: () => ({}),
+ withApplyLock: (fn) => fn(),
+ applier: { applyDispensersReplace: sinon.stub().resolves() }
+ };
+ }
+
+ beforeEach(function(){ sinon.stub(console, 'log'); sinon.stub(console, 'error'); });
+ afterEach(function(){ sinon.restore(); });
+
+ it('stamps the attempt but not the success when the re-dump fails', async function(){
+ sinon.stub(axios, 'get').rejects(new Error('ECONNRESET'));
+ let ctx = reconcileCtx();
+ let clock = sinon.useFakeTimers({ now: 42000, toFake: ['Date'] });
+ try { await ClientSync.prototype.reconcileDispensers.call(ctx, 'http://source1:3006'); }
+ finally { clock.restore(); }
+ assert.strictEqual(ctx._lastDispenserReconcileAttemptAt, 42000);
+ assert.strictEqual(ctx._lastDispenserReconcileAt, undefined);
+ assert.strictEqual(ctx.applier.applyDispensersReplace.called, false, 'local table left intact');
+ });
+
+ it('requests the whole table with no page-size parameter and stamps the success', async function(){
+ let body = JSON.stringify({ schema_version: SCHEMA_VERSION.decoder, has_more: false,
+ rows: [{ tx_index: 1, address_id: 2 }] });
+ let get = sinon.stub(axios, 'get').resolves({ data: Buffer.from(body) });
+ let ctx = reconcileCtx();
+ await ClientSync.prototype.reconcileDispensers.call(ctx, 'http://source1:3006');
+ assert.strictEqual(get.firstCall.args[0], 'http://source1:3006/snapshot-dispensers/decoder/bitcoin/mainnet');
+ assert.strictEqual(ctx.applier.applyDispensersReplace.firstCall.args[0].length, 1);
+ assert.ok(ctx._lastDispenserReconcileAt >= ctx._lastDispenserReconcileAttemptAt);
+ });
+});
+
+describe('ClientSync.shouldReconcileDispensers ignores the tick-only fallbacks', function(){
+ it('still treats a replica with no successful reconcile as the first resume', function(){
+ let ctx = { config: { DISPENSERS_RECONCILE_EVERY: '1000' }, _lastDispenserReconcileAt: null,
+ _dispenserClockArmedAt: 5000, _lastDispenserReconcileAttemptAt: 5000, _catchUpCount: 0 };
+ assert.strictEqual(ClientSync.prototype.shouldReconcileDispensers.call(ctx, 5001), true);
+ });
+});
diff --git a/test/unit/client_sync_halt.test/01_independent_recompute_halt.test.js b/test/unit/client_sync_halt.test/01_independent_recompute_halt.test.js
index 867b3618..35ba7a58 100644
--- a/test/unit/client_sync_halt.test/01_independent_recompute_halt.test.js
+++ b/test/unit/client_sync_halt.test/01_independent_recompute_halt.test.js
@@ -27,16 +27,18 @@ const Utility = require('../../../src/util');
const HashVerifier = require('../../../src/client/hash_verifier');
const vectors = require('../../fixtures/block-hash-vectors.json');
-// db.doQuery feeds BlockHasher the canned golden rows IN CALL ORDER (one
-// sequence per computeBlockHashes pass), so the recompute yields the
+// db.doQuery / doQueryStrict feed BlockHasher the canned golden rows IN CALL
+// ORDER (one sequence per computeBlockHashes pass), so the recompute yields the
// indexer-authentic committed hashes from vectors.expected.
function seqDb(results){
let i = 0;
+ const next = async () => results[i++];
return {
dbName: 'test_db', dbType: 'indexer',
getLastBlock: sinon.stub().resolves(null),
getBlockHashRow: sinon.stub().resolves(null),
- doQuery: sinon.stub().callsFake(async () => results[i++]),
+ doQuery: sinon.stub().callsFake(next),
+ doQueryStrict: sinon.stub().callsFake(next),
recordHalt: sinon.stub().resolves({ block_index: 0 }),
getActiveHalt: sinon.stub().resolves(null),
clearHalt: sinon.stub().resolves(1)
@@ -116,11 +118,15 @@ describe('ClientSync: independent recompute halt @regression', function(){
await sync.applyBlockEvent(event);
assert.strictEqual(sync.isHalted(), false, 'no recompute, no halt when opted out');
assert.strictEqual(db.doQuery.called, false, 'recompute queries must not run when disabled');
+ assert.strictEqual(db.doQueryStrict.called, false, 'recompute queries must not run when disabled');
assert.strictEqual(sync.lastAppliedBlock, vectors.block_index);
});
it('a recompute DB error is logged but does NOT halt (no self-inflicted fork on infra faults)', async function(){
- db.doQuery = sinon.stub().rejects(new Error('transient DB error'));
+ // Model the production reader: doQuery swallows the error into [], doQueryStrict throws.
+ // A preimage gathered fail-soft would hash the empty rows and halt on a false divergence.
+ db.doQuery = sinon.stub().resolves([]);
+ db.doQueryStrict = sinon.stub().rejects(new Error('transient DB error'));
const event = { block_index: vectors.block_index, block_time: 123, ledger_hash: 'x', actions_hash: 'y', contract_hash: 'z' };
await sync.applyBlockEvent(event);
assert.strictEqual(sync.isHalted(), false, 'an infra error must not halt the validator');
diff --git a/test/unit/client_sync_io.test.js b/test/unit/client_sync_io.test.js
index 401a9c21..84793fd0 100644
--- a/test/unit/client_sync_io.test.js
+++ b/test/unit/client_sync_io.test.js
@@ -168,6 +168,20 @@ describe('ClientSync: fetchAndApplySchema', function(){
assert.ok(errorCalls.some(m => m && m.indexOf('Rejected DDL') !== -1),
'should log rejected DDL');
});
+
+ it('records validated source table names for replica-gap checks', async function(){
+ sinon.stub(axios, 'get').resolves({ data: {
+ tables: {
+ goodtable: 'CREATE TABLE `goodtable` (id int)',
+ emptytable: '',
+ 'bad-name': 'CREATE TABLE `bad-name` (id int)'
+ }
+ }});
+ db.doQuery.resolves([]);
+ await sync.fetchAndApplySchema('http://src1:3006');
+
+ assert.deepStrictEqual(sync._sourceTables, new Set(['goodtable', 'emptytable']));
+ });
});
describe('ClientSync: fetchAndApplySchema', function(){
@@ -207,6 +221,7 @@ describe('ClientSync: fetchAndApplySchema', function(){
let errorCalls = console.error.getCalls().map(c => c.args[0]);
assert.ok(errorCalls.some(m => m && m.indexOf('Failed to fetch schema') !== -1),
'outer catch must log "Failed to fetch schema"');
+ assert.strictEqual(sync._sourceTables, null, 'failed fetch must leave source schema unknown');
});
});
diff --git a/test/unit/client_sync_io.test/19_small_branches.test.js b/test/unit/client_sync_io.test/19_small_branches.test.js
index 4735e827..2f07b803 100644
--- a/test/unit/client_sync_io.test/19_small_branches.test.js
+++ b/test/unit/client_sync_io.test/19_small_branches.test.js
@@ -29,7 +29,7 @@ function createMockApplier(){
applyIncrementalSnapshot: sinon.stub().resolves() };
}
function createMockRollback(){ return { rollback: sinon.stub().resolves() }; }
-function makeSync(configOverrides, dbOverrides){
+function makeSync(configOverrides, dbOverrides, identity){
let db = createMockDb(dbOverrides), applier = createMockApplier(), rb = createMockRollback();
let hv = new HashVerifier(), util = new Utility();
let config = Object.assign({
@@ -37,7 +37,8 @@ function makeSync(configOverrides, dbOverrides){
HASH_CONFIRM_TIMEOUT: 5000, SNAPSHOT_MAX_CONTENT: 200 * 1024 * 1024,
WS_MAX_PAYLOAD: 50 * 1024 * 1024, MAX_ROLLBACK_DEPTH: 10, GAP_LOG_INTERVAL_MS: 30000
}, configOverrides || {});
- let sync = new ClientSync('bitcoin', 'mainnet', withDbMixins(db), applier, rb, hv, config, util);
+ identity = identity || { chain: 'bitcoin', network: 'mainnet' };
+ let sync = new ClientSync(identity.chain, identity.network, withDbMixins(db), applier, rb, hv, config, util);
return { sync, db, applier, rb, hv, util, config };
}
@@ -200,6 +201,31 @@ describe('ClientSync: small branches', function(){
assert.strictEqual(sync.getHaltInfo().reason, 'max-rollback-depth-exceeded');
assert.strictEqual(db.recordHalt.firstCall.args[0], 'decoder');
});
+});
+
+describe('ClientSync: litecoin testnet reorg depth', function(){
+ beforeEach(setupSmallBranchTest);
+ afterEach(function(){ sinon.restore(); });
+
+ it('rolls back a 134-block litecoin testnet reorg under the network default', async function(){
+ let rb;
+ ({ sync, rb } = makeSync(
+ { MAX_ROLLBACK_DEPTH: 100, MAX_ROLLBACK_DEPTH_EXPLICIT: false },
+ { dbType: 'decoder' },
+ { chain: 'litecoin', network: 'testnet' }));
+ sync.lastAppliedBlock = 1000;
+
+ await sync.handleReorg({ type: 'reorg', block_index: 867 });
+
+ assert.strictEqual(sync.maxRollbackDepth, 5000);
+ assert.strictEqual(rb.rollback.calledOnceWith(867), true);
+ assert.strictEqual(sync.isHalted(), false);
+ });
+});
+
+describe('ClientSync: small branches', function(){
+ beforeEach(setupSmallBranchTest);
+ afterEach(function(){ sinon.restore(); });
// Stress-sweep 2026-07-08: a reorg ABOVE the tip must be a no-op, never a cursor
// advance. depth = tip - block_index + 1 goes <= 0 above the tip, so it never trips
diff --git a/test/unit/consensus_primitive_conformance.test.js b/test/unit/consensus_primitive_conformance.test.js
index 8f523994..3c8c25bf 100644
--- a/test/unit/consensus_primitive_conformance.test.js
+++ b/test/unit/consensus_primitive_conformance.test.js
@@ -18,11 +18,13 @@
// predicate and the equivocation-header builder are CONSENSUS-CRITICAL and
// vendored byte-identically into five services (xchain-hub, xchain-indexer,
// xchain-explorer, xchain-sdk, xchain-sync); a divergence in their logic forks
-// the chain. The canonical source of record lives in xchain-documentation
-// (protocol/reference-impl + protocol/test-vectors). This guard runs in every
+// the chain. Their canonical source of record is xchain-indexer/src/consensus,
+// which reconcile-twins.sh vendors into every other copy, including
+// xchain-documentation/protocol/reference-impl/consensus; the canonical vectors
+// live in xchain-documentation/protocol/test-vectors. This guard runs in every
// repo and asserts BOTH:
// 1. BEHAVIOR - the local copy matches the canonical vectors.
-// 2. IDENTITY - the local copy is byte-identical to the canonical source.
+// 2. IDENTITY - the local copy is byte-identical to the xchain-documentation copy.
// (1) catches a logic change that happens to pass the local unit suite; (2)
// catches ANY edit to one copy that was not propagated to the others. When the
// sibling xchain-documentation repo is not checked out (standalone deploy), skip
@@ -117,7 +119,7 @@ describe('consensus-primitive conformance: byte-identity to canonical source @re
const canon = fs.readFileSync(path.join(CANON_DIR, 'consensus', f), 'utf8');
assert.strictEqual(local, canon,
'this repo\'s consensus/' + f + ' has drifted from the canonical source; ' +
- 'edit xchain-documentation/protocol/reference-impl/consensus/' + f + ' and re-vendor all five copies.');
+ 'edit xchain-indexer/src/consensus/' + f + ' and re-run reconcile-twins.sh to re-vendor every copy.');
});
});
});
diff --git a/test/unit/db_stake_weight_collation.test.js b/test/unit/db_stake_weight_collation.test.js
index f96d9177..cd129e22 100644
--- a/test/unit/db_stake_weight_collation.test.js
+++ b/test/unit/db_stake_weight_collation.test.js
@@ -9,7 +9,7 @@
// Replica half of the stake-weight ordering-collation flag-day (see
// src/stake_weight_collation_activation.js, a byte-identical twin of the
// xchain-indexer copy). The follower rebuilds stakes_root from
-// _cappedStakeWeightsSql, whose window caps truncate on an ORDER over
+// cappedStakeWeightsSql, whose window caps truncate on an ORDER over
// index_addresses.address / index_pubkeys.pubkey. Those columns are declared
// utf8_general_ci (folding), so the collation decides WHICH sources and keys
// survive the cap - and pinning it on ONE side of the seam is itself the fork
diff --git a/test/unit/db_stake_weights_as_of.test.js b/test/unit/db_stake_weights_as_of.test.js
index 2b6a58ed..eaafecfe 100644
--- a/test/unit/db_stake_weights_as_of.test.js
+++ b/test/unit/db_stake_weights_as_of.test.js
@@ -133,10 +133,10 @@ describe(STAKE_WEIGHTS_AS_OF_TITLE, function(){
/denominator S/);
});
- it('does not reference _stakeWeightsSql (keeps the drift-guarded twin untouched)', async function(){
- // The reconstruction is self-contained; _stakeWeightsSql must remain the
+ it('does not reference stakeWeightsSql (keeps the drift-guarded twin untouched)', async function(){
+ // The reconstruction is self-contained; stakeWeightsSql must remain the
// byte-identical consensus twin used only by the live, in-order callers.
- let spy = sinon.spy(db, '_stakeWeightsSql');
+ let spy = sinon.spy(db, 'stakeWeightsSql');
await db.getStakeWeightsByCapabilityAsOf('oracle_publish', 106, '500', 1000);
assert.strictEqual(spy.called, false);
});
diff --git a/test/unit/decoder_table_classification.test.js b/test/unit/decoder_table_classification.test.js
index c031f8de..2af583d4 100644
--- a/test/unit/decoder_table_classification.test.js
+++ b/test/unit/decoder_table_classification.test.js
@@ -42,7 +42,7 @@ const fs = require('fs');
const path = require('path');
const { getReplicatedTables } = require('../../src/schema/replicated_tables');
-const { OPERATOR_LOCAL_TABLES } = require('../../src/server/snapshot_builder');
+const { OPERATOR_LOCAL_TABLES, decoderIncrementalSets } = require('../../src/server/snapshot_builder');
// Resolved exactly as the other two decoder-schema readers resolve it
// (generatedColumns.test.js, replicatedDatetimeColumns.test.js), so this guard
@@ -64,8 +64,8 @@ function requireSibling(ctx){
// Decoder tables deliberately NOT replicated. Each entry is a decision, not an
// oversight, and the same decision is repeated by hand in SnapshotBuilder
-// (OPERATOR_LOCAL_TABLES, and the function-local decoderSkip inside
-// streamIncrementalSnapshot), which is why the last case below cross-checks it.
+// (OPERATOR_LOCAL_TABLES, and the skip set of decoderIncrementalSets), which is
+// why the cases below cross-check it.
//
// mempool_transactions: node-local, non-deterministic observation state; its own
// schema comment forbids sharing raw values across nodes.
@@ -75,7 +75,7 @@ const ADD_INSTRUCTIONS =
'\n\nEither add it to TOPOLOGY.decoder in src/schema/replicated_tables.js (and update the ' +
'by-value pin in test/unit/replicated_tables.test.js), or add it to DECODER_EXCLUDED ' +
'here with a written reason. Either way check SnapshotBuilder OPERATOR_LOCAL_TABLES / ' +
- 'decoderSkip and ClientRollback decoderBlockTables / decoderTxScopedTables.';
+ 'decoderIncrementalSets and ClientRollback decoderBlockTables / decoderTxScopedTables.';
function decoderSqlTables(){
return fs.readdirSync(DECODER_SQL_DIR)
@@ -141,3 +141,25 @@ describe('decoder table classification (schema exhaustiveness) @regression', fun
table + ' is in both TOPOLOGY.decoder and DECODER_EXCLUDED - pick one');
});
});
+
+describe('decoder incremental-snapshot buckets (topology exhaustiveness) @regression', function(){
+
+ it('places every replicated decoder table in exactly one incremental-snapshot bucket', function(){
+ // No sibling needed. A table no bucket names falls to the bare `continue` in
+ // streamIncrementalSnapshot, so derivation alone cannot cover special/actionScoped.
+ const buckets = Object.values(decoderIncrementalSets());
+ const replicated = getReplicatedTables('decoder');
+ assert.ok(replicated.length >= 8,
+ 'decoder topology enumeration looks broken: ' + replicated.join(','));
+
+ const misplaced = replicated.filter(t => buckets.filter(b => b.has(t)).length !== 1);
+ assert.deepStrictEqual(misplaced, [],
+ misplaced.length
+ ? 'decoder table(s) replicated but not in exactly one SnapshotBuilder ' +
+ 'decoderIncrementalSets bucket: ' + misplaced.join(', ') + '. An ' +
+ 'unclassified table rides no incremental snapshot, so every incrementally-' +
+ 'caught-up replica freezes it at bootstrap height and shows a permanent ' +
+ '/status count shortfall until a full re-snapshot. Classify it there.'
+ : undefined);
+ });
+});
diff --git a/test/unit/fixture_ports.test.js b/test/unit/fixture_ports.test.js
new file mode 100644
index 00000000..30dc4687
--- /dev/null
+++ b/test/unit/fixture_ports.test.js
@@ -0,0 +1,27 @@
+'use strict';
+
+const assert = require('assert');
+const ports = require('../../bin/fixture-ports.js');
+
+describe('fixture ports', function () {
+ it('keeps base ports when CI_PORT_OFFSET is absent or zero', function () {
+ assert.strictEqual(ports.port('E2E_DB_PORT', {}), 23306);
+ assert.strictEqual(ports.port('TOXIPROXY_PORT', { CI_PORT_OFFSET: '0' }), 8474);
+ });
+
+ it('adds CI_PORT_OFFSET to host ports', function () {
+ const env = { CI_PORT_OFFSET: '10700' };
+ assert.strictEqual(ports.port('E2E_DB_PORT', env), 34006);
+ assert.strictEqual(ports.port('SOURCE_DIRECT_PORT', env), 43765);
+ });
+
+ it('does not add the offset to an explicit final port override', function () {
+ const env = { CI_PORT_OFFSET: '10700', E2E_DB_PORT: '41000' };
+ assert.strictEqual(ports.port('E2E_DB_PORT', env), 41000);
+ });
+
+ it('rejects invalid offsets and out-of-range results', function () {
+ assert.throws(() => ports.port('E2E_DB_PORT', { CI_PORT_OFFSET: '-1' }), /non-negative integer/);
+ assert.throws(() => ports.port('SOURCE_DIRECT_PORT', { CI_PORT_OFFSET: '33000' }), /TCP port range/);
+ });
+});
diff --git a/test/unit/hub_client.test/01_get_indexer_configs.test.js b/test/unit/hub_client.test/01_get_indexer_configs.test.js
index 451a26e8..d624ba3f 100644
--- a/test/unit/hub_client.test/01_get_indexer_configs.test.js
+++ b/test/unit/hub_client.test/01_get_indexer_configs.test.js
@@ -32,7 +32,7 @@ describe('HubClient', function(){
bitcoin: {
mainnet: {
'xchain-indexer': {
- db_host: 'db1', db_port: '3307', name: 'btc_main', user: 'u', pass: 'p'
+ db_host: 'db1', db_port: '3307', name: 'btc_main', user: 'fixture-user', pass: 'fixture-pass'
}
}
}
@@ -50,7 +50,7 @@ describe('HubClient', function(){
sinon.stub(axios, 'post').resolves({ data: { result: {
litecoin: {
testnet: {
- 'xchain-indexer': { host: 'fallback_host', port: '3308', name: 'ltc', user: 'u', pass: 'p' }
+ 'xchain-indexer': { host: 'fallback_host', port: '3308', name: 'ltc', user: 'fixture-user', pass: 'fixture-pass' }
}
}
}}});
@@ -61,7 +61,7 @@ describe('HubClient', function(){
it('defaults db_host to 127.0.0.1 when neither present', async function(){
sinon.stub(axios, 'post').resolves({ data: { result: {
- doge: { regtest: { 'xchain-indexer': { name: 'd', user: 'u', pass: 'p' } } }
+ doge: { regtest: { 'xchain-indexer': { name: 'd', user: 'fixture-user', pass: 'fixture-pass' } } }
}}});
let configs = await hub.getIndexerConfigs();
assert.strictEqual(configs[0].db_host, '127.0.0.1');
@@ -102,7 +102,7 @@ describe('HubClient', function(){
it('skips empty coin keys', async function(){
sinon.stub(axios, 'post').resolves({ data: { result: {
- '': { mainnet: { 'xchain-indexer': { name: 'x', user: 'u', pass: 'p' } } }
+ '': { mainnet: { 'xchain-indexer': { name: 'x', user: 'fixture-user', pass: 'fixture-pass' } } }
}}});
let configs = await hub.getIndexerConfigs();
assert.strictEqual(configs.length, 0);
@@ -110,8 +110,8 @@ describe('HubClient', function(){
it('handles multiple chains', async function(){
sinon.stub(axios, 'post').resolves({ data: { result: {
- bitcoin: { mainnet: { 'xchain-indexer': { name: 'b', user: 'u', pass: 'p' } } },
- litecoin: { mainnet: { 'xchain-indexer': { name: 'l', user: 'u', pass: 'p' } } }
+ bitcoin: { mainnet: { 'xchain-indexer': { name: 'b', user: 'fixture-user', pass: 'fixture-pass' } } },
+ litecoin: { mainnet: { 'xchain-indexer': { name: 'l', user: 'fixture-user', pass: 'fixture-pass' } } }
}}});
let configs = await hub.getIndexerConfigs();
assert.strictEqual(configs.length, 2);
diff --git a/test/unit/hub_client.test/02_get_decoder_configs.test.js b/test/unit/hub_client.test/02_get_decoder_configs.test.js
index f85a3325..dcf5a240 100644
--- a/test/unit/hub_client.test/02_get_decoder_configs.test.js
+++ b/test/unit/hub_client.test/02_get_decoder_configs.test.js
@@ -29,7 +29,7 @@ describe('HubClient', function(){
describe('getDecoderConfigs', function(){
it('extracts xchain-decoder entries', async function(){
sinon.stub(axios, 'post').resolves({ data: { result: {
- bitcoin: { mainnet: { 'xchain-decoder': { db_host: 'dh', db_port: '3309', name: 'dec', user: 'u', pass: 'p' } } }
+ bitcoin: { mainnet: { 'xchain-decoder': { db_host: 'dh', db_port: '3309', name: 'dec', user: 'fixture-user', pass: 'fixture-pass' } } }
}}});
let configs = await hub.getDecoderConfigs();
assert.strictEqual(configs.length, 1);
diff --git a/test/unit/missing_tables.test.js b/test/unit/missing_tables.test.js
index 160b3fc0..bbb723d0 100644
--- a/test/unit/missing_tables.test.js
+++ b/test/unit/missing_tables.test.js
@@ -31,6 +31,7 @@ const { getReplicatedTables, missingReplicatedTables } = require('../../src/sche
// whose source was already writing them.
const BET_TABLES = ['bets', 'bet_statuses', 'bet_resolves', 'bet_feeds',
'bet_feed_statuses', 'bet_cancels'];
+const BRIDGE_TABLES = ['bridge_settlements', 'xbridges'];
// A schema listing that holds every replicated table except `absent`.
function presentExcept(dbType, absent){
@@ -74,6 +75,16 @@ describe('missingReplicatedTables', function(){
assert.strictEqual(missingReplicatedTables(undefined, 'indexer'), null);
assert.strictEqual(missingReplicatedTables(['bets'], 'indexer'), null);
});
+
+ it('checks only tables present in an explicitly supplied source schema', function(){
+ let source = presentExcept('indexer', BRIDGE_TABLES);
+ let local = new Set(source);
+ assert.deepStrictEqual(missingReplicatedTables(local, 'indexer', source), []);
+
+ source.add('bridge_settlements');
+ assert.deepStrictEqual(missingReplicatedTables(local, 'indexer', source), ['bridge_settlements']);
+ assert.strictEqual(missingReplicatedTables(local, 'indexer', null), null);
+ });
});
function makeSync(dbOverrides, dbType){
@@ -101,6 +112,7 @@ function makeSync(dbOverrides, dbType){
};
let sync = new ClientSync('bitcoin', 'mainnet', db, applier, rb,
new HashVerifier(), config, new Utility());
+ sync._sourceTables = new Set(getReplicatedTables(dbType || 'indexer'));
return { sync, db };
}
@@ -146,6 +158,20 @@ describe('ClientSync.warnMissingTables', function(){
await sync.warnMissingTables();
assert.deepStrictEqual(sync.getMissingTables(), ['bets']);
});
+
+ it('stays silent for replicated tables absent from both source and replica', async function(){
+ let sourceTables = presentExcept('indexer', BRIDGE_TABLES);
+ let { sync } = makeSync({
+ listExistingTables: sinon.stub().resolves(new Set(sourceTables))
+ });
+ sync._sourceTables = sourceTables;
+
+ await sync.warnMissingTables();
+
+ let calls = warnStub.getCalls().filter(c => String(c.args[0]).indexOf('MISSING_REPLICATED_TABLES') === 0);
+ assert.strictEqual(calls.length, 0);
+ assert.deepStrictEqual(sync.getMissingTables(), []);
+ });
});
describe('ClientSync.warnMissingTables', function(){
@@ -220,8 +246,9 @@ function mockDb(present, dbType){
// A client-mode SyncService that reports the exact shape the live incident
// showed: caught up, not halted, nothing wrong.
-function mockClientSyncService(){
+function mockClientSyncService(missingTables){
return {
+ getClientSync: () => ({ getMissingTables: () => missingTables }),
getClientSyncState: () => ({
lastKnownServerBlock: 100, sourceHeightStale: false, halted: false, haltInfo: null,
truncated: false, bootstrapBase: null, sourceQuorum: 1, sourcesConfigured: 1,
@@ -236,7 +263,8 @@ describe('/status missing_tables', function(){
it('names the missing tables on a replica that otherwise looks perfectly healthy', async function(){
let { buildStatusRow } = loadApi('client');
- let row = await buildStatusRow(mockClientSyncService(),
+ let expected = BET_TABLES.filter(t => getReplicatedTables('indexer').includes(t)).sort();
+ let row = await buildStatusRow(mockClientSyncService(expected),
mockDb(presentExcept('indexer', BET_TABLES)),
'indexer', 'bitcoin', 'mainnet');
@@ -246,13 +274,12 @@ describe('/status missing_tables', function(){
// table_counts cannot show the gap: an absent table is simply not a key.
for(let t of BET_TABLES) assert.ok(!(t in row.table_counts));
// missing_tables can, and does.
- let expected = BET_TABLES.filter(t => getReplicatedTables('indexer').includes(t)).sort();
assert.deepStrictEqual(row.missing_tables, expected);
});
it('is an empty array on a complete replica', async function(){
let { buildStatusRow } = loadApi('client');
- let row = await buildStatusRow(mockClientSyncService(),
+ let row = await buildStatusRow(mockClientSyncService([]),
mockDb(new Set(getReplicatedTables('indexer'))),
'indexer', 'bitcoin', 'mainnet');
assert.deepStrictEqual(row.missing_tables, []);
@@ -260,7 +287,7 @@ describe('/status missing_tables', function(){
it('is null, not [], when the table listing fails', async function(){
let { buildStatusRow } = loadApi('client');
- let row = await buildStatusRow(mockClientSyncService(),
+ let row = await buildStatusRow(mockClientSyncService(null),
mockDb(new Error('information_schema unavailable')),
'indexer', 'bitcoin', 'mainnet');
assert.strictEqual(row.missing_tables, null);
@@ -273,7 +300,7 @@ describe('/status missing_tables', function(){
it('scopes to the decoder replicated set on a decoder replica', async function(){
let { buildStatusRow } = loadApi('client');
- let row = await buildStatusRow(mockClientSyncService(),
+ let row = await buildStatusRow(mockClientSyncService(['pubkeys']),
mockDb(presentExcept('decoder', ['pubkeys']), 'decoder'),
'decoder', 'bitcoin', 'mainnet');
assert.deepStrictEqual(row.missing_tables, ['pubkeys']);
diff --git a/test/unit/observability.test.js b/test/unit/observability.test.js
index accfde0c..b08350dd 100644
--- a/test/unit/observability.test.js
+++ b/test/unit/observability.test.js
@@ -39,6 +39,13 @@ const {
installObservability, readObservabilityEnv, routeLabel
} = require('../../src/observability/index.js');
+const { registerMetricsTests } = require('./observability.test/01_metrics.test.js');
+const {
+ registerLogShipperTests, registerTextFormatTests, registerMessageRedactionTests
+} = require('./observability.test/02_log_shipper.test.js');
+const { registerRouteTests } = require('./observability.test/03_routes.test.js');
+const { registerFlushAndHealthTests } = require('./observability.test/04_flush_and_health.test.js');
+
// A console-shaped sink so tests never write to the mocha output.
function fakeConsole() {
const lines = { log: [], warn: [], error: [] };
@@ -61,364 +68,20 @@ async function listen(app) {
};
}
-describe('observability/metrics: exposition format', function () {
-
- it('renders a counter with HELP, TYPE and labelled samples', function () {
- const reg = new Registry();
- const c = reg.counter({ name: 'test_requests_total', help: 'Requests', labelNames: ['route'] });
- c.inc({ route: '/a' }, 2);
- c.inc({ route: '/a' });
- c.inc({ route: '/b' });
-
- const out = reg.render();
- expect(out).to.include('# HELP test_requests_total Requests');
- expect(out).to.include('# TYPE test_requests_total counter');
- expect(out).to.include('test_requests_total{route="/a"} 3');
- expect(out).to.include('test_requests_total{route="/b"} 1');
- expect(out.endsWith('\n')).to.equal(true);
- });
-
- it('rejects invalid metric and label names at declaration', function () {
- const reg = new Registry();
- expect(() => reg.counter({ name: '9bad', help: 'x' })).to.throw(/invalid metric name/);
- expect(() => reg.counter({ name: 'ok_total', help: 'x', labelNames: ['bad-label'] })).to.throw(/invalid label name/);
- expect(() => reg.counter({ name: 'ok2_total', help: 'x', labelNames: ['__name__'] })).to.throw(/reserved/);
- });
-
- it('hands back the same metric when an identical declaration repeats', function () {
- // Modules register their counters wherever they are required, and the
- // registry is now process-wide, so an identical re-declaration is a
- // normal event rather than a conflict.
- const reg = new Registry();
- const first = reg.gauge({ name: 'dup_gauge', help: 'x' });
- expect(reg.gauge({ name: 'dup_gauge', help: 'y' })).to.equal(first);
- });
-
- it('still refuses a duplicate name declared with a different shape', function () {
- const reg = new Registry();
- reg.gauge({ name: 'shape_clash', help: 'x' });
- expect(() => reg.counter({ name: 'shape_clash', help: 'x' })).to.throw(/different shape/);
- reg.gauge({ name: 'label_clash', help: 'x', labelNames: ['a'] });
- expect(() => reg.gauge({ name: 'label_clash', help: 'x', labelNames: ['b'] })).to.throw(/different shape/);
- });
-
- it('rejects a negative counter increment and an unknown label', function () {
- const reg = new Registry();
- const c = reg.counter({ name: 'neg_total', help: 'x', labelNames: ['a'] });
- expect(() => c.inc({ a: '1' }, -1)).to.throw(/non-negative/);
- expect(() => c.inc({ b: '1' }, 1)).to.throw(/unknown label/);
- });
-
- it('escapes backslash, quote and newline in label values and help', function () {
- const reg = new Registry();
- const c = reg.counter({ name: 'esc_total', help: 'line1\nline2 \\ end', labelNames: ['v'] });
- c.inc({ v: 'a"b\\c\nd' });
- const out = reg.render();
- expect(out).to.include('# HELP esc_total line1\\nline2 \\\\ end');
- expect(out).to.include('esc_total{v="a\\"b\\\\c\\nd"} 1');
- // No raw newline may appear inside a sample line.
- for (const line of out.trim().split('\n')) expect(line).to.not.equal('');
- });
-
- it('treats label order as declared order, not caller order', function () {
- const reg = new Registry();
- const c = reg.counter({ name: 'order_total', help: 'x', labelNames: ['a', 'b'] });
- c.inc({ a: '1', b: '2' });
- c.inc({ b: '2', a: '1' });
- expect(c.get({ a: '1', b: '2' })).to.equal(2);
- expect(reg.render().split('\n').filter((l) => l.startsWith('order_total{')).length).to.equal(1);
- });
-
- it('emits cumulative histogram buckets with +Inf equal to _count', function () {
- const reg = new Registry();
- const h = reg.histogram({ name: 'lat_seconds', help: 'x', labelNames: ['route'], buckets: [0.1, 0.5, 1] });
- h.observe({ route: '/a' }, 0.05);
- h.observe({ route: '/a' }, 0.3);
- h.observe({ route: '/a' }, 2);
+const testContext = {
+ expect, express, http,
+ Registry, Counter, Gauge, Histogram, collectDefaultMetrics,
+ createLogShipper, readLogEnv, redactFields, scrubMessage, REDACTED,
+ installObservability, readObservabilityEnv, routeLabel,
+ fakeConsole, listen
+};
- const out = reg.render();
- expect(out).to.include('# TYPE lat_seconds histogram');
- expect(out).to.include('lat_seconds_bucket{route="/a",le="0.1"} 1');
- expect(out).to.include('lat_seconds_bucket{route="/a",le="0.5"} 2');
- expect(out).to.include('lat_seconds_bucket{route="/a",le="1"} 2');
- expect(out).to.include('lat_seconds_bucket{route="/a",le="+Inf"} 3');
- expect(out).to.include('lat_seconds_count{route="/a"} 3');
- expect(out).to.include('lat_seconds_sum{route="/a"} 2.35');
- });
-
- it('ignores a non-finite histogram observation instead of poisoning the sum', function () {
- const reg = new Registry();
- const h = reg.histogram({ name: 'nan_seconds', help: 'x', buckets: [1] });
- h.observe({}, Number.NaN);
- h.observe({}, Infinity);
- h.observe({}, 0.5);
- expect(h.get({}).count).to.equal(1);
- expect(h.get({}).sum).to.equal(0.5);
- });
-
- it('reserves le for histogram buckets', function () {
- const reg = new Registry();
- expect(() => reg.histogram({ name: 'le_seconds', help: 'x', labelNames: ['le'] })).to.throw(/reserved/);
- });
-
- it('caps series per metric and counts the drops instead of growing', function () {
- const reg = new Registry({ maxSeries: 3 });
- const c = reg.counter({ name: 'card_total', help: 'x', labelNames: ['id'] });
- for (let i = 0; i < 10; i++) c.inc({ id: `id-${i}` });
- expect(c.series.size).to.equal(3);
- expect(reg.get('xchain_metrics_series_dropped_total').get({ metric: 'card_total' })).to.equal(7);
- expect(reg.render()).to.include('xchain_metrics_series_dropped_total{metric="card_total"} 7');
- });
-
- it('gauge set/inc/dec track a value and reject a non-finite set', function () {
- const reg = new Registry();
- const g = reg.gauge({ name: 'depth', help: 'x' });
- g.set({}, 5);
- g.inc({}, 2);
- g.dec({}, 3);
- expect(g.get({})).to.equal(4);
- expect(() => g.set({}, Number.NaN)).to.throw(/finite/);
- });
-
- it('setMonotonic never lets a collector-driven counter go backwards', function () {
- const reg = new Registry();
- const c = reg.counter({ name: 'cpu_total', help: 'x' });
- c.setMonotonic({}, 10);
- c.setMonotonic({}, 4);
- expect(c.get({})).to.equal(10);
- });
-
- it('survives a throwing collector and still renders the rest', function () {
- const reg = new Registry();
- reg.counter({ name: 'ok_total', help: 'x' }).inc({});
- reg.addCollector(() => { throw new Error('boom'); });
- expect(reg.render()).to.include('ok_total 1');
- });
-
- it('collectDefaultMetrics exposes process and service identity', function () {
- const reg = new Registry();
- collectDefaultMetrics(reg, { service: 'xchain-sync', version: '1.2.3', coin: 'BTC', network: 'regtest' });
- const out = reg.render();
- expect(out).to.include('xchain_service_info{service="xchain-sync",version="1.2.3",coin="BTC",network="regtest"');
- expect(out).to.match(/process_resident_memory_bytes \d+/);
- expect(out).to.match(/process_cpu_user_seconds_total [\d.]+/);
- expect(out).to.include('# TYPE process_cpu_user_seconds_total counter');
- expect(out).to.match(/nodejs_heap_size_used_bytes \d+/);
- });
-
- it('exports the Prometheus content type', function () {
- expect(new Registry().contentType()).to.equal('text/plain; version=0.0.4; charset=utf-8');
- });
-
- it('metric classes are usable standalone', function () {
- expect(new Counter({ name: 'a_total', help: 'x' }).inc({}, 2)).to.equal(2);
- const g = new Gauge({ name: 'b', help: 'x' }); g.set({}, 1); expect(g.get({})).to.equal(1);
- const h = new Histogram({ name: 'c', help: 'x' }); h.observe({}, 1); expect(h.get({}).count).to.equal(1);
- });
+describe('observability/metrics: exposition format', function () {
+ registerMetricsTests(testContext);
});
describe('observability/logShipper', function () {
-
- it('is inert by default: text output, no buffering, no shipping', function () {
- const sink = fakeConsole();
- const log = createLogShipper({ service: 'svc', env: {}, console: sink });
- log.info('hello world');
- expect(log.config.shipEnabled).to.equal(false);
- expect(log.buffer.length).to.equal(0);
- expect(log.timer).to.equal(null);
- expect(sink.lines.log).to.have.lengthOf(1);
- expect(sink.lines.log[0]).to.match(/^\S+Z info \[svc\] hello world$/);
- });
-
- it('emits NDJSON with the envelope keys when LOG_FORMAT=json', function () {
- const sink = fakeConsole();
- const log = createLogShipper({ service: 'xchain-sync', version: '9.9.9', env: { LOG_FORMAT: 'json' }, console: sink });
- log.warn('block stalled', { height: 42 });
- const rec = JSON.parse(sink.lines.warn[0]);
- expect(rec.level).to.equal('warn');
- expect(rec.service).to.equal('xchain-sync');
- expect(rec.msg).to.equal('block stalled');
- expect(rec.height).to.equal(42);
- expect(rec.version).to.equal('9.9.9');
- expect(new Date(rec.ts).toISOString()).to.equal(rec.ts);
- });
-
- it('honours LOG_LEVEL and drops quieter levels', function () {
- const sink = fakeConsole();
- const log = createLogShipper({ service: 'svc', env: { LOG_LEVEL: 'warn' }, console: sink });
- expect(log.info('quiet')).to.equal(null);
- expect(log.error('loud')).to.not.equal(null);
- expect(sink.lines.log.length).to.equal(0);
- });
-
- it('redacts credential-shaped field keys and inline key=value pairs', function () {
- const sink = fakeConsole();
- const log = createLogShipper({ service: 'svc', env: { LOG_FORMAT: 'json' }, console: sink });
- const rec = log.info('connect password=hunter2 then api_key: abc123', {
- db: { user: 'app', password: 'hunter2' },
- HUB_API_KEY: 'zzz',
- height: 7
- });
- expect(rec.db.password).to.equal(REDACTED);
- expect(rec.db.user).to.equal('app');
- expect(rec.HUB_API_KEY).to.equal(REDACTED);
- expect(rec.height).to.equal(7);
- expect(rec.msg).to.not.include('hunter2');
- expect(rec.msg).to.not.include('abc123');
- expect(JSON.stringify(rec)).to.not.include('hunter2');
- });
-
- it('never lets a caller field forge the record envelope', function () {
- const log = createLogShipper({ service: 'real-svc', env: {}, console: fakeConsole() });
- const rec = log.info('m', { service: 'spoofed', level: 'debug', msg: 'spoofed' });
- expect(rec.service).to.equal('real-svc');
- expect(rec.level).to.equal('info');
- expect(rec.msg).to.equal('m');
- });
-
- it('handles cyclic and deep field graphs without throwing', function () {
- const a = { name: 'a' };
- a.self = a;
- expect(redactFields(a).self).to.equal('[circular]');
- expect(redactFields({ a: { b: { c: { d: { e: 1 } } } } }).a.b.c.d).to.equal('[truncated]');
- expect(scrubMessage('token=abc')).to.equal(`token=${REDACTED}`);
- });
-
- it('serializes an Error field with a scrubbed message', function () {
- const out = redactFields({ err: new Error('login failed for password=hunter2') });
- expect(out.err.message).to.include(REDACTED);
- expect(out.err.message).to.not.include('hunter2');
- });
-
- it('requires BOTH the flag and a valid URL before shipping', function () {
- expect(readLogEnv({ LOG_SHIP_ENABLED: '1' }).shipEnabled).to.equal(false);
- expect(readLogEnv({ LOG_SHIP_URL: 'https://c/logs' }).shipEnabled).to.equal(false);
- expect(readLogEnv({ LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'ftp://c/logs' }).shipEnabled).to.equal(false);
- expect(readLogEnv({ LOG_SHIP_ENABLED: 'true', LOG_SHIP_URL: 'https://c/logs' }).shipEnabled).to.equal(true);
- });
-
- it('batches NDJSON to the transport once the batch size is reached', async function () {
- const bodies = [];
- const log = createLogShipper({
- service: 'svc',
- env: { LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'https://collector.invalid/logs', LOG_SHIP_BATCH_SIZE: '2' },
- console: fakeConsole(),
- transport: (body) => { bodies.push(body); return Promise.resolve(); }
- });
- log.info('one');
- log.info('two');
- await new Promise((r) => setImmediate(r));
- await log.stop();
-
- expect(bodies.length).to.equal(1);
- const lines = bodies[0].trim().split('\n').map((l) => JSON.parse(l));
- expect(lines.map((l) => l.msg)).to.deep.equal(['one', 'two']);
- expect(log.stats.shipped).to.equal(2);
- });
-
- it('drops the oldest lines when the buffer is full and counts the loss', function () {
- const log = createLogShipper({
- service: 'svc',
- env: {
- LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'https://collector.invalid/logs',
- LOG_SHIP_BATCH_SIZE: '1000', LOG_SHIP_MAX_BUFFER: '3'
- },
- console: fakeConsole(),
- transport: () => new Promise(() => {}) // never settles: buffer fills
- });
- for (let i = 0; i < 6; i++) log.info(`line-${i}`);
- expect(log.buffer.length).to.equal(3);
- expect(log.stats.dropped).to.equal(3);
- expect(log.buffer.map((r) => r.msg)).to.deep.equal(['line-3', 'line-4', 'line-5']);
- });
-
- it('survives a failing collector, re-queues the batch and rate-limits the stderr note', async function () {
- const sink = fakeConsole();
- const log = createLogShipper({
- service: 'svc',
- env: { LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'https://collector.invalid/logs', LOG_SHIP_BATCH_SIZE: '1' },
- console: sink,
- transport: () => Promise.reject(new Error('ECONNREFUSED'))
- });
- log.info('a');
- await log.flush();
- log.info('b');
- await log.flush();
-
- expect(log.stats.failures).to.be.greaterThan(0);
- expect(log.stats.shipped).to.equal(0);
- expect(log.buffer.length).to.be.greaterThan(0);
- // One note per minute, so the second failure adds no line.
- expect(sink.lines.error.filter((l) => l.includes('[log-ship]')).length).to.equal(1);
- await log.stop();
- });
-
- it('exposes shipper counters on a registry when one is supplied', function () {
- const reg = new Registry();
- const log = createLogShipper({ service: 'svc', env: {}, console: fakeConsole(), registry: reg });
- log.info('x');
- log.error('y');
- const out = reg.render();
- expect(out).to.include('log_lines_emitted_total{level="info"} 1');
- expect(out).to.include('log_lines_emitted_total{level="error"} 1');
- expect(out).to.include('log_ship_buffer_lines 0');
- });
-
- it('stop() clears the flush timer so the process can exit', async function () {
- const log = createLogShipper({
- service: 'svc',
- env: { LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'https://collector.invalid/logs' },
- console: fakeConsole(),
- transport: () => Promise.resolve()
- });
- expect(log.timer).to.not.equal(null);
- await log.stop();
- expect(log.timer).to.equal(null);
- });
-
- // Exercises the real _post/fetch path. Every other test here injects a
- // transport, which is why the unreleased response body below went unseen.
- it('releases the response body so a stalled collector cannot pin the socket', async function () {
- this.timeout(5000);
- let closed = false;
- const sockets = new Set();
- const server = http.createServer((req, res) => {
- req.resume();
- // Answer with headers and a first chunk, then never end the body.
- req.on('end', () => { res.writeHead(200); res.write('ack'); });
- res.socket.on('close', () => { closed = true; });
- });
- server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
- await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
- const { port } = server.address();
-
- const log = createLogShipper({
- service: 'svc',
- env: {
- LOG_SHIP_ENABLED: '1',
- LOG_SHIP_URL: `http://127.0.0.1:${port}/logs`,
- LOG_SHIP_BATCH_SIZE: '1',
- LOG_SHIP_TIMEOUT_MS: '400'
- },
- console: fakeConsole()
- });
-
- try {
- log.info('one');
- await log.flush();
- // fetch() resolves on headers and the abort timer is cleared with it,
- // so an unreleased body leaves nothing that will ever close this
- // socket. Measured: released in under 2ms, unreleased still open at 3s.
- for (let i = 0; i < 100 && !closed; i++) {
- await new Promise((resolve) => setTimeout(resolve, 10));
- }
- expect(closed).to.equal(true);
- } finally {
- await log.stop();
- for (const s of sockets) s.destroy();
- await new Promise((resolve) => server.close(resolve));
- }
- });
+ registerLogShipperTests(testContext);
});
describe('observability/installObservability', function () {
@@ -427,159 +90,7 @@ describe('observability/installObservability', function () {
// service), so a suite that installs many times has to drop them between
// cases or it reads the previous case's service label and HTTP series.
afterEach(function () { require('../../src/observability/index.js')._resetObservability(); });
-
- it('reads a default-off config from an empty env', function () {
- const cfg = readObservabilityEnv({});
- expect(cfg.metricsEnabled).to.equal(false);
- expect(cfg.httpMetrics).to.equal(false);
- expect(cfg.metricsPath).to.equal('/metrics');
- expect(cfg.log.shipEnabled).to.equal(false);
- });
-
- it('normalizes a METRICS_PATH given without a leading slash', function () {
- expect(readObservabilityEnv({ METRICS_ENABLED: '1', METRICS_PATH: 'internal/metrics' }).metricsPath)
- .to.equal('/internal/metrics');
- });
-
- it('registers NO route when the flag is unset, but still hands back a registry', async function () {
- const app = express();
- app.get('/health', (req, res) => res.json({ ok: true }));
- const obs = installObservability(app, { service: 'xchain-sync', env: {}, console: fakeConsole() });
- expect(obs.enabled).to.equal(false);
- // The registry is deliberately NOT gated: a counter a consensus module
- // registers has to exist on the default fleet, or it can never record.
- // Only the endpoint is an operator decision.
- expect(obs.registry).to.not.equal(null);
- expect(typeof obs.registry.counter).to.equal('function');
-
- const srv = await listen(app);
- try {
- const res = await fetch(srv.url('/metrics'));
- expect(res.status).to.equal(404);
- } finally {
- await obs.shutdown();
- await srv.close();
- }
- });
-
- it('serves the exposition text and instruments requests when enabled', async function () {
- const app = express();
- app.get('/health', (req, res) => res.json({ ok: true }));
- const obs = installObservability(app, {
- service: 'xchain-sync', version: '1.0.0', coin: 'BTC', network: 'regtest',
- env: { METRICS_ENABLED: '1' }, console: fakeConsole()
- });
- expect(obs.enabled).to.equal(true);
-
- const srv = await listen(app);
- try {
- await fetch(srv.url('/health'));
- await fetch(srv.url('/health'));
- await fetch(srv.url('/nope'));
-
- const res = await fetch(srv.url('/metrics'));
- expect(res.status).to.equal(200);
- expect(res.headers.get('content-type')).to.include('version=0.0.4');
- expect(res.headers.get('cache-control')).to.equal('no-store');
-
- const body = await res.text();
- expect(body).to.include('http_requests_total{method="GET",route="/health",status="200"} 2');
- expect(body).to.include('http_request_duration_seconds_count{method="GET",route="/health"} 2');
- expect(body).to.include('http_requests_in_flight 0');
- expect(body).to.include('xchain_service_info{service="xchain-sync",version="1.0.0",coin="BTC",network="regtest"');
- // The scrape itself is never counted.
- expect(body).to.not.include('route="/metrics"');
- } finally {
- await obs.shutdown();
- await srv.close();
- }
- });
-
- it('buckets an unmatched path by first segment so URLs cannot explode cardinality', async function () {
- const app = express();
- const obs = installObservability(app, { service: 'svc', env: { METRICS_ENABLED: '1' }, console: fakeConsole() });
- const srv = await listen(app);
- try {
- await fetch(srv.url('/block/000000001'));
- await fetch(srv.url('/block/000000002'));
- const body = await (await fetch(srv.url('/metrics'))).text();
- expect(body).to.include('http_requests_total{method="GET",route="/block",status="404"} 2');
- } finally {
- await obs.shutdown();
- await srv.close();
- }
- });
-
- it('uses the express route pattern, not the concrete path, as the route label', function () {
- expect(routeLabel({ route: { path: '/snapshot/:table' }, baseUrl: '/hub-db' })).to.equal('/hub-db/snapshot/:table');
- expect(routeLabel({ originalUrl: '/telemetry/summary?x=1' })).to.equal('/telemetry');
- expect(routeLabel({ url: '/' })).to.equal('/');
- });
-
- it('gates the endpoint behind METRICS_TOKEN when one is configured', async function () {
- const app = express();
- const obs = installObservability(app, {
- service: 'svc', env: { METRICS_ENABLED: '1', METRICS_TOKEN: 'sekret-scrape' }, console: fakeConsole()
- });
- const srv = await listen(app);
- try {
- expect((await fetch(srv.url('/metrics'))).status).to.equal(401);
- expect((await fetch(srv.url('/metrics'), { headers: { Authorization: 'Bearer wrong' } })).status).to.equal(401);
- const ok = await fetch(srv.url('/metrics'), { headers: { Authorization: 'Bearer sekret-scrape' } });
- expect(ok.status).to.equal(200);
- expect(await ok.text()).to.include('# TYPE');
- } finally {
- await obs.shutdown();
- await srv.close();
- }
- });
-
- it('honours a custom METRICS_PATH and can skip HTTP instrumentation', async function () {
- const app = express();
- app.get('/health', (req, res) => res.json({ ok: true }));
- const obs = installObservability(app, {
- service: 'svc', env: { METRICS_ENABLED: '1', METRICS_PATH: '/internal/metrics', METRICS_HTTP: '0' },
- console: fakeConsole()
- });
- const srv = await listen(app);
- try {
- await fetch(srv.url('/health'));
- expect((await fetch(srv.url('/metrics'))).status).to.equal(404);
- const body = await (await fetch(srv.url('/internal/metrics'))).text();
- expect(body).to.include('xchain_service_info');
- expect(body).to.not.include('http_requests_total{');
- } finally {
- await obs.shutdown();
- await srv.close();
- }
- });
-
- it('instruments routes registered BEFORE the install call (layer is hoisted)', async function () {
- // The six services wire this at different points in their api.js; Express
- // dispatches in registration order, so without the hoist an install that
- // lands after the routes would export zero HTTP metrics.
- const app = express();
- app.get('/early', (req, res) => res.send('ok'));
- const obs = installObservability(app, { service: 'svc', env: { METRICS_ENABLED: '1' }, console: fakeConsole() });
- const srv = await listen(app);
- try {
- await fetch(srv.url('/early'));
- const body = await (await fetch(srv.url('/metrics'))).text();
- expect(body).to.include('http_requests_total{method="GET",route="/early",status="200"} 1');
- } finally {
- await obs.shutdown();
- await srv.close();
- }
- });
-
- it('returns a usable logger even with no app to mount on', function () {
- const sink = fakeConsole();
- const obs = installObservability(null, { service: 'worker', env: {}, console: sink });
- expect(obs.enabled).to.equal(false);
- obs.logger.info('tick');
- expect(sink.lines.log).to.have.lengthOf(1);
- expect(sink.lines.log[0]).to.match(/^\S+Z info \[worker\] tick$/);
- });
+ registerRouteTests(testContext);
});
// The fleet runs text mode, so text mode is where the structured record has to
@@ -587,120 +98,11 @@ describe('observability/installObservability', function () {
// threw the whole record away: LOG_LEVEL and LOG_FORMAT changed nothing an
// operator could see on any box.
describe('observability/logShipper: text-with-fields format', function () {
- it('renders ts, lowercase level, service tag, message, then key=value', function () {
- const sink = fakeConsole();
- const log = createLogShipper({ service: 'xchain-sync', env: {}, console: sink });
- log.warn('PBFT_DROP', { reason: 'digest_mismatch', phase: 'prepare', round: 42 });
- expect(sink.lines.warn).to.have.lengthOf(1);
- expect(sink.lines.warn[0]).to.match(
- /^\d{4}-\d{2}-\d{2}T[\d:.]+Z warn \[xchain-sync\] PBFT_DROP reason=digest_mismatch phase=prepare round=42$/
- );
- });
-
- it('keeps the level token lowercase so the server-monitor ERROR|FATAL grep does not match it', function () {
- const sink = fakeConsole();
- const log = createLogShipper({ service: 'svc', env: {}, console: sink });
- log.error('boom');
- // collect-snapshot.sh counts `grep -cE 'ERROR|FATAL'`. An uppercase
- // token would make every console.error line count and trip the crit
- // threshold fleet-wide on first deploy.
- expect(sink.lines.error[0]).to.not.match(/ERROR|FATAL/);
- expect(sink.lines.error[0]).to.include(' error [svc] boom');
- });
-
- it('puts the message immediately after the service tag so existing substring greps still match', function () {
- const sink = fakeConsole();
- const log = createLogShipper({ service: 'xchain-sync', env: {}, console: sink });
- log.info('Oracle: Round 12 finalized');
- expect(sink.lines.log[0]).to.include('Oracle: Round 12 finalized');
- });
-
- it('quotes a value carrying whitespace, = or a quote, and leaves plain tokens bare', function () {
- const sink = fakeConsole();
- const log = createLogShipper({ service: 'svc', env: {}, console: sink });
- log.info('m', { plain: 'abc', spaced: 'a b', eq: 'k=v', num: 3, flag: true, nil: null });
- const line = sink.lines.log[0];
- expect(line).to.include('plain=abc');
- expect(line).to.include('spaced="a b"');
- expect(line).to.include('eq="k=v"');
- expect(line).to.include('num=3');
- expect(line).to.include('flag=true');
- expect(line).to.include('nil=null');
- });
-
- it('redacts a credential-shaped field and an inline credential in the message', function () {
- const sink = fakeConsole();
- const log = createLogShipper({ service: 'svc', env: {}, console: sink });
- log.warn('connect failed password=hunter2', { db_password: 'hunter2', host: 'db1' });
- const line = sink.lines.warn[0];
- expect(line).to.not.include('hunter2');
- expect(line).to.include(REDACTED);
- expect(line).to.include('host=db1');
- });
-
- it('emits one NDJSON record per line under LOG_FORMAT=json', function () {
- const sink = fakeConsole();
- const log = createLogShipper({ service: 'svc', env: { LOG_FORMAT: 'json' }, console: sink });
- log.info('hello', { a: 1 });
- const parsed = JSON.parse(sink.lines.log[0]);
- expect(parsed).to.include({ level: 'info', service: 'svc', msg: 'hello', a: 1 });
- expect(parsed.ts).to.be.a('string');
- });
-
- it('silences info under LOG_LEVEL=warn while still emitting warn', function () {
- const sink = fakeConsole();
- const log = createLogShipper({ service: 'svc', env: { LOG_LEVEL: 'warn' }, console: sink });
- log.info('quiet');
- log.warn('loud');
- expect(sink.lines.log).to.have.lengthOf(0);
- expect(sink.lines.warn).to.have.lengthOf(1);
- });
+ registerTextFormatTests(testContext);
});
describe('observability/logShipper: message redaction', function () {
- // An env-validation failure prints the variable NAME and its value, and the
- // names the services use are all prefixed (HUB_DB_SECRET, INDEXER_DB_PASS,
- // db_password). A `\b`-anchored key never matches those, because `_` is a
- // word character and `\b` does not fire between two word characters. With
- // LOG_SHIP_* configured, an unscrubbed line goes off-box in the clear.
- const leaky = [
- ['prefixed env secret', 'Missing required environment variable: HUB_DB_SECRET=hunter2swordfish'],
- ['prefixed db pass', 'connect failed db_password=hunter2swordfish'],
- ['screaming env pass', 'INDEXER_DB_PASS=hunter2swordfish'],
- ['api key', 'HUB_API_KEY=hunter2swordfish'],
- ['keyed bearer', 'Authorization: Bearer eyJhbGciOi.SECRETPAYLOAD.sig'],
- ['bare bearer', 'sending Bearer eyJhbGciOi.SECRETPAYLOAD.sig upstream'],
- ['quoted mnemonic', 'mnemonic="correct horse battery staple"'],
- ];
- for (const [name, line] of leaky) {
- it(`scrubs a ${name}`, function () {
- const out = scrubMessage(line);
- expect(out).to.not.match(/hunter2swordfish|SECRETPAYLOAD|correct horse/);
- expect(out).to.include(REDACTED);
- });
- }
-
- it('redacts the token, not the word Bearer', function () {
- // The value group would otherwise capture "Bearer" and stop, leaving the
- // token itself in the clear immediately after a [redacted] marker that
- // makes the line look handled.
- const out = scrubMessage('Authorization: Bearer eyJhbGciOi.SECRETPAYLOAD.sig');
- expect(out).to.not.include('SECRETPAYLOAD');
- });
-
- it('leaves real operational lines untouched, hex identifiers included', function () {
- // Hub and indexer lines are full of legitimate 64-char hex (txids, block
- // hashes, state roots). A hex sweep here would gut the logs this work
- // exists to make readable.
- const keep = [
- 'Oracle: Round 12 finalized with 4 of 5 votes',
- 'StateAnchorPublisher: anchored bundle regtest @ 100 (txid a3f9bc21de)',
- 'P2P: Invalid signature from xc1qexampleaddr; dropping message',
- 'seed block=5 imported',
- 'PBFT_DROP reason=digest_mismatch phase=prepare round=42'
- ];
- for (const line of keep) expect(scrubMessage(line)).to.equal(line);
- });
+ registerMessageRedactionTests(testContext);
});
describe('observability/patchConsole', function () {
@@ -708,149 +110,7 @@ describe('observability/patchConsole', function () {
require('../../src/observability/index.js');
afterEach(function () { _resetObservability(); });
-
- it('routes console.* through the shim, mapping log to info', function () {
- const handle = patchConsole({ service: 'xchain-sync', env: {} });
- const seen = [];
- // Read the shipper's own output by swapping its sink after the patch,
- // which is the only place the bound originals are reachable from.
- handle.logger.console = { log: (m) => seen.push(m), warn: (m) => seen.push(m), error: (m) => seen.push(m) };
- console.log('plain');
- console.warn('careful');
- console.error('bad');
- unpatchConsole();
- expect(seen[0]).to.match(/ info \[xchain-sync\] plain$/);
- expect(seen[1]).to.match(/ warn \[xchain-sync\] careful$/);
- expect(seen[2]).to.match(/ error \[xchain-sync\] bad$/);
- });
-
- it('resolves printf format strings and keeps an Error stack, via util.format', function () {
- const handle = patchConsole({ service: 'svc', env: {} });
- const seen = [];
- handle.logger.console = { log: (m) => seen.push(m), warn: (m) => seen.push(m), error: (m) => seen.push(m) };
- console.log('round %d of %s', 7, 'oracle');
- console.error('crashed:', new Error('kaboom'));
- unpatchConsole();
- expect(seen[0]).to.include('round 7 of oracle');
- expect(seen[1]).to.include('kaboom');
- expect(seen[1]).to.include('Error');
- });
-
- it('does not recurse: the sink holds bound originals captured BEFORE the patch', function () {
- // `const orig = console` would hand the logger the very object about to
- // be replaced, so every line would re-enter the wrapper forever. The
- // proof is simply that a line completes and arrives once.
- const realLog = console.log;
- let depth = 0;
- let maxDepth = 0;
- console.log = (...a) => { depth += 1; maxDepth = Math.max(maxDepth, depth); depth -= 1; return realLog.apply(console, a); };
- const captured = console.log;
- try {
- patchConsole({ service: 'svc', env: {} });
- expect(console.log).to.not.equal(captured);
- console.log('one line');
- unpatchConsole();
- } finally {
- console.log = realLog;
- }
- expect(maxDepth).to.equal(1);
- });
-
- it('no-ops under XCHAIN_LOG_PATCH=0 so test bootstraps see stock console', function () {
- const before = console.log;
- const handle = patchConsole({ service: 'svc', env: { XCHAIN_LOG_PATCH: '0' } });
- expect(handle.patched).to.equal(false);
- expect(console.log).to.equal(before);
- });
-
- it('is idempotent and restores the exact original functions on unpatch', function () {
- const before = { log: console.log, warn: console.warn, error: console.error };
- const first = patchConsole({ service: 'svc', env: {} });
- const second = patchConsole({ service: 'other', env: {} });
- expect(second).to.equal(first);
- unpatchConsole();
- expect(console.log).to.equal(before.log);
- expect(console.warn).to.equal(before.warn);
- expect(console.error).to.equal(before.error);
- });
-
- it('getLogger works before any install and reaches the real shipper after', function () {
- // A module that logs while being required must not be able to crash the
- // process just because it loaded before the wiring.
- const log = getLogger();
- expect(() => log.info('early', { a: 1 })).to.not.throw();
- const handle = patchConsole({ service: 'svc', env: {} });
- const seen = [];
- handle.logger.console = { log: (m) => seen.push(m), warn: (m) => seen.push(m), error: (m) => seen.push(m) };
- log.warn('LATE_EVENT', { reason: 'x' });
- unpatchConsole();
- expect(seen[0]).to.match(/ warn \[svc\] LATE_EVENT reason=x$/);
- });
-
- // A trailing Error argument expands across lines under util.inspect, and only
- // the first line carries the prefix. Measured on the live fleet as orphaned
- // fragments like " fatal: true," from a pretty-printed mariadb SqlError:
- // no operation, no error, no coin, and unparseable by anything keying on the
- // prefix. One console call must be one line.
- it('renders a multi-line message as ONE line with the breaks escaped', () => {
- const sink = fakeConsole();
- const log = createLogShipper({ service: 'xchain-sync', env: {}, console: sink });
- log.error('DB write failed: SqlError: connect ECONNREFUSED\n fatal: true,\n errno: -111');
- expect(sink.lines.error).to.have.lengthOf(1);
- expect(sink.lines.error[0]).to.not.match(/\n/);
- expect(sink.lines.error[0]).to.include('\\n fatal: true,');
- expect(sink.lines.error[0]).to.match(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z error \[xchain-sync\] DB write failed:/);
- });
-
- it('escapes a bare carriage return too, so a progress writer cannot split a record', () => {
- const sink = fakeConsole();
- const log = createLogShipper({ service: 'xchain-sync', env: {}, console: sink });
- log.warn('rewriting\rline');
- expect(sink.lines.warn).to.have.lengthOf(1);
- expect(sink.lines.warn[0]).to.include('rewriting\\nline');
- });
-
- // JSON mode needs no escaping of its own: JSON.stringify already emits one
- // physical line and keeps the true characters, which is better fidelity for a
- // machine reader than a lossy substitution would be.
- it('JSON mode keeps the real newlines and still emits one physical line', () => {
- const sink = fakeConsole();
- const log = createLogShipper({ service: 'xchain-sync', env: { LOG_FORMAT: 'json' }, console: sink });
- log.error('line one\nline two');
- expect(sink.lines.error).to.have.lengthOf(1);
- expect(sink.lines.error[0]).to.not.match(/\n/);
- expect(JSON.parse(sink.lines.error[0]).msg).to.equal('line one\nline two');
- });
-
- it('does not double-format: a shipper built AFTER the patch writes to the pre-patch sink', function () {
- // The shim's default sink is the global console by reference. A shipper
- // taking that default once console is patched emits its formatted line
- // INTO the wrapper and gets it formatted again, so the line reads
- // ` warn [svc] warn [svc] msg`. Caught by driving the real hub
- // suite, not by reading the diff.
- const seen = [];
- const realWarn = console.warn;
- console.warn = (m) => seen.push(m);
- try {
- patchConsole({ service: 'svc', env: {} });
- // A custom transport means this handle does NOT adopt the process
- // shipper, so it builds a second one: the path where the global
- // console would otherwise be taken as the default sink.
- const second = installObservability(null, { service: 'svc', env: {}, logTransport: () => Promise.resolve() });
- second.logger.warn('once only');
- } finally {
- unpatchConsole();
- console.warn = realWarn;
- }
- expect(seen).to.have.lengthOf(1);
- expect(seen[0]).to.match(/^\S+Z warn \[svc\] once only$/);
- });
-
- it('hands out one registry, always constructed, before any install call', function () {
- const reg = getRegistry({ service: 'svc' });
- expect(reg).to.equal(getRegistry());
- const c = reg.counter({ name: 'xchain_probe_total', help: 'probe' });
- c.inc({}, 1);
- expect(reg.render()).to.include('xchain_probe_total');
+ registerFlushAndHealthTests({
+ ...testContext, patchConsole, unpatchConsole, getLogger, getRegistry
});
});
diff --git a/test/unit/observability.test/01_metrics.test.js b/test/unit/observability.test/01_metrics.test.js
new file mode 100644
index 00000000..1f30feba
--- /dev/null
+++ b/test/unit/observability.test/01_metrics.test.js
@@ -0,0 +1,182 @@
+'use strict';
+
+// 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.
+
+'use strict';
+
+function registerMetricsTestsPart1({ expect, Registry }) {
+ it('renders a counter with HELP, TYPE and labelled samples', function () {
+ const reg = new Registry();
+ const c = reg.counter({ name: 'test_requests_total', help: 'Requests', labelNames: ['route'] });
+ c.inc({ route: '/a' }, 2);
+ c.inc({ route: '/a' });
+ c.inc({ route: '/b' });
+
+ const out = reg.render();
+ expect(out).to.include('# HELP test_requests_total Requests');
+ expect(out).to.include('# TYPE test_requests_total counter');
+ expect(out).to.include('test_requests_total{route="/a"} 3');
+ expect(out).to.include('test_requests_total{route="/b"} 1');
+ expect(out.endsWith('\n')).to.equal(true);
+ });
+
+ it('rejects invalid metric and label names at declaration', function () {
+ const reg = new Registry();
+ expect(() => reg.counter({ name: '9bad', help: 'x' })).to.throw(/invalid metric name/);
+ expect(() => reg.counter({ name: 'ok_total', help: 'x', labelNames: ['bad-label'] })).to.throw(/invalid label name/);
+ expect(() => reg.counter({ name: 'ok2_total', help: 'x', labelNames: ['__name__'] })).to.throw(/reserved/);
+ });
+
+ it('hands back the same metric when an identical declaration repeats', function () {
+ // Modules register their counters wherever they are required, and the
+ // registry is now process-wide, so an identical re-declaration is a
+ // normal event rather than a conflict.
+ const reg = new Registry();
+ const first = reg.gauge({ name: 'dup_gauge', help: 'x' });
+ expect(reg.gauge({ name: 'dup_gauge', help: 'y' })).to.equal(first);
+ });
+
+ it('still refuses a duplicate name declared with a different shape', function () {
+ const reg = new Registry();
+ reg.gauge({ name: 'shape_clash', help: 'x' });
+ expect(() => reg.counter({ name: 'shape_clash', help: 'x' })).to.throw(/different shape/);
+ reg.gauge({ name: 'label_clash', help: 'x', labelNames: ['a'] });
+ expect(() => reg.gauge({ name: 'label_clash', help: 'x', labelNames: ['b'] })).to.throw(/different shape/);
+ });
+
+ it('rejects a negative counter increment and an unknown label', function () {
+ const reg = new Registry();
+ const c = reg.counter({ name: 'neg_total', help: 'x', labelNames: ['a'] });
+ expect(() => c.inc({ a: '1' }, -1)).to.throw(/non-negative/);
+ expect(() => c.inc({ b: '1' }, 1)).to.throw(/unknown label/);
+ });
+
+ it('escapes backslash, quote and newline in label values and help', function () {
+ const reg = new Registry();
+ const c = reg.counter({ name: 'esc_total', help: 'line1\nline2 \\ end', labelNames: ['v'] });
+ c.inc({ v: 'a"b\\c\nd' });
+ const out = reg.render();
+ expect(out).to.include('# HELP esc_total line1\\nline2 \\\\ end');
+ expect(out).to.include('esc_total{v="a\\"b\\\\c\\nd"} 1');
+ // No raw newline may appear inside a sample line.
+ for (const line of out.trim().split('\n')) expect(line).to.not.equal('');
+ });
+
+}
+
+function registerMetricsTestsPart2({ expect, Registry }) {
+ it('treats label order as declared order, not caller order', function () {
+ const reg = new Registry();
+ const c = reg.counter({ name: 'order_total', help: 'x', labelNames: ['a', 'b'] });
+ c.inc({ a: '1', b: '2' });
+ c.inc({ b: '2', a: '1' });
+ expect(c.get({ a: '1', b: '2' })).to.equal(2);
+ expect(reg.render().split('\n').filter((l) => l.startsWith('order_total{')).length).to.equal(1);
+ });
+
+ it('emits cumulative histogram buckets with +Inf equal to _count', function () {
+ const reg = new Registry();
+ const h = reg.histogram({ name: 'lat_seconds', help: 'x', labelNames: ['route'], buckets: [0.1, 0.5, 1] });
+ h.observe({ route: '/a' }, 0.05);
+ h.observe({ route: '/a' }, 0.3);
+ h.observe({ route: '/a' }, 2);
+
+ const out = reg.render();
+ expect(out).to.include('# TYPE lat_seconds histogram');
+ expect(out).to.include('lat_seconds_bucket{route="/a",le="0.1"} 1');
+ expect(out).to.include('lat_seconds_bucket{route="/a",le="0.5"} 2');
+ expect(out).to.include('lat_seconds_bucket{route="/a",le="1"} 2');
+ expect(out).to.include('lat_seconds_bucket{route="/a",le="+Inf"} 3');
+ expect(out).to.include('lat_seconds_count{route="/a"} 3');
+ expect(out).to.include('lat_seconds_sum{route="/a"} 2.35');
+ });
+
+ it('ignores a non-finite histogram observation instead of poisoning the sum', function () {
+ const reg = new Registry();
+ const h = reg.histogram({ name: 'nan_seconds', help: 'x', buckets: [1] });
+ h.observe({}, Number.NaN);
+ h.observe({}, Infinity);
+ h.observe({}, 0.5);
+ expect(h.get({}).count).to.equal(1);
+ expect(h.get({}).sum).to.equal(0.5);
+ });
+
+ it('reserves le for histogram buckets', function () {
+ const reg = new Registry();
+ expect(() => reg.histogram({ name: 'le_seconds', help: 'x', labelNames: ['le'] })).to.throw(/reserved/);
+ });
+
+ it('caps series per metric and counts the drops instead of growing', function () {
+ const reg = new Registry({ maxSeries: 3 });
+ const c = reg.counter({ name: 'card_total', help: 'x', labelNames: ['id'] });
+ for (let i = 0; i < 10; i++) c.inc({ id: `id-${i}` });
+ expect(c.series.size).to.equal(3);
+ expect(reg.get('xchain_metrics_series_dropped_total').get({ metric: 'card_total' })).to.equal(7);
+ expect(reg.render()).to.include('xchain_metrics_series_dropped_total{metric="card_total"} 7');
+ });
+
+}
+
+function registerMetricsTestsPart3({ expect, Registry, Counter, Gauge, Histogram, collectDefaultMetrics }) {
+ it('gauge set/inc/dec track a value and reject a non-finite set', function () {
+ const reg = new Registry();
+ const g = reg.gauge({ name: 'depth', help: 'x' });
+ g.set({}, 5);
+ g.inc({}, 2);
+ g.dec({}, 3);
+ expect(g.get({})).to.equal(4);
+ expect(() => g.set({}, Number.NaN)).to.throw(/finite/);
+ });
+
+ it('setMonotonic never lets a collector-driven counter go backwards', function () {
+ const reg = new Registry();
+ const c = reg.counter({ name: 'cpu_total', help: 'x' });
+ c.setMonotonic({}, 10);
+ c.setMonotonic({}, 4);
+ expect(c.get({})).to.equal(10);
+ });
+
+ it('survives a throwing collector and still renders the rest', function () {
+ const reg = new Registry();
+ reg.counter({ name: 'ok_total', help: 'x' }).inc({});
+ reg.addCollector(() => { throw new Error('boom'); });
+ expect(reg.render()).to.include('ok_total 1');
+ });
+
+ it('collectDefaultMetrics exposes process and service identity', function () {
+ const reg = new Registry();
+ collectDefaultMetrics(reg, { service: 'xchain-sync', version: '1.2.3', coin: 'BTC', network: 'regtest' });
+ const out = reg.render();
+ expect(out).to.include('xchain_service_info{service="xchain-sync",version="1.2.3",coin="BTC",network="regtest"');
+ expect(out).to.match(/process_resident_memory_bytes \d+/);
+ expect(out).to.match(/process_cpu_user_seconds_total [\d.]+/);
+ expect(out).to.include('# TYPE process_cpu_user_seconds_total counter');
+ expect(out).to.match(/nodejs_heap_size_used_bytes \d+/);
+ });
+
+ it('exports the Prometheus content type', function () {
+ expect(new Registry().contentType()).to.equal('text/plain; version=0.0.4; charset=utf-8');
+ });
+
+ it('metric classes are usable standalone', function () {
+ expect(new Counter({ name: 'a_total', help: 'x' }).inc({}, 2)).to.equal(2);
+ const g = new Gauge({ name: 'b', help: 'x' }); g.set({}, 1); expect(g.get({})).to.equal(1);
+ const h = new Histogram({ name: 'c', help: 'x' }); h.observe({}, 1); expect(h.get({}).count).to.equal(1);
+ });
+}
+
+function registerMetricsTests(context) {
+ registerMetricsTestsPart1(context);
+ registerMetricsTestsPart2(context);
+ registerMetricsTestsPart3(context);
+}
+
+module.exports = { registerMetricsTests };
diff --git a/test/unit/observability.test/02_log_shipper.test.js b/test/unit/observability.test/02_log_shipper.test.js
new file mode 100644
index 00000000..185f24a0
--- /dev/null
+++ b/test/unit/observability.test/02_log_shipper.test.js
@@ -0,0 +1,365 @@
+'use strict';
+
+// 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.
+
+'use strict';
+
+function registerLogShipperTestsPart1({ expect, createLogShipper, readLogEnv, redactFields, scrubMessage, REDACTED, fakeConsole }) {
+ it('is inert by default: text output, no buffering, no shipping', function () {
+ const sink = fakeConsole();
+ const log = createLogShipper({ service: 'svc', env: {}, console: sink });
+ log.info('hello world');
+ expect(log.config.shipEnabled).to.equal(false);
+ expect(log.buffer.length).to.equal(0);
+ expect(log.timer).to.equal(null);
+ expect(sink.lines.log).to.have.lengthOf(1);
+ expect(sink.lines.log[0]).to.match(/^\S+Z info \[svc\] hello world$/);
+ });
+
+ it('emits NDJSON with the envelope keys when LOG_FORMAT=json', function () {
+ const sink = fakeConsole();
+ const log = createLogShipper({ service: 'xchain-sync', version: '9.9.9', env: { LOG_FORMAT: 'json' }, console: sink });
+ log.warn('block stalled', { height: 42 });
+ const rec = JSON.parse(sink.lines.warn[0]);
+ expect(rec.level).to.equal('warn');
+ expect(rec.service).to.equal('xchain-sync');
+ expect(rec.msg).to.equal('block stalled');
+ expect(rec.height).to.equal(42);
+ expect(rec.version).to.equal('9.9.9');
+ expect(new Date(rec.ts).toISOString()).to.equal(rec.ts);
+ });
+
+ it('honours LOG_LEVEL and drops quieter levels', function () {
+ const sink = fakeConsole();
+ const log = createLogShipper({ service: 'svc', env: { LOG_LEVEL: 'warn' }, console: sink });
+ expect(log.info('quiet')).to.equal(null);
+ expect(log.error('loud')).to.not.equal(null);
+ expect(sink.lines.log.length).to.equal(0);
+ });
+
+ it('redacts credential-shaped field keys and inline key=value pairs', function () {
+ const sink = fakeConsole();
+ const log = createLogShipper({ service: 'svc', env: { LOG_FORMAT: 'json' }, console: sink });
+ const rec = log.info('connect password=hunter2 then api_key: abc123', {
+ db: { user: 'app', password: 'hunter2' },
+ HUB_API_KEY: 'zzz',
+ height: 7
+ });
+ expect(rec.db.password).to.equal(REDACTED);
+ expect(rec.db.user).to.equal('app');
+ expect(rec.HUB_API_KEY).to.equal(REDACTED);
+ expect(rec.height).to.equal(7);
+ expect(rec.msg).to.not.include('hunter2');
+ expect(rec.msg).to.not.include('abc123');
+ expect(JSON.stringify(rec)).to.not.include('hunter2');
+ });
+
+}
+
+function registerLogShipperTestsPart2({
+ expect, createLogShipper, readLogEnv, redactFields, scrubMessage, REDACTED, fakeConsole
+}) {
+ it('never lets a caller field forge the record envelope', function () {
+ const log = createLogShipper({ service: 'real-svc', env: {}, console: fakeConsole() });
+ const rec = log.info('m', { service: 'spoofed', level: 'debug', msg: 'spoofed' });
+ expect(rec.service).to.equal('real-svc');
+ expect(rec.level).to.equal('info');
+ expect(rec.msg).to.equal('m');
+ });
+
+ it('handles cyclic and deep field graphs without throwing', function () {
+ const a = { name: 'a' };
+ a.self = a;
+ expect(redactFields(a).self).to.equal('[circular]');
+ expect(redactFields({ a: { b: { c: { d: { e: 1 } } } } }).a.b.c.d).to.equal('[truncated]');
+ expect(scrubMessage('token=abc')).to.equal(`token=${REDACTED}`);
+ });
+
+ it('serializes an Error field with a scrubbed message', function () {
+ const out = redactFields({ err: new Error('login failed for password=hunter2') });
+ expect(out.err.message).to.include(REDACTED);
+ expect(out.err.message).to.not.include('hunter2');
+ });
+
+ it('requires BOTH the flag and a valid URL before shipping', function () {
+ expect(readLogEnv({ LOG_SHIP_ENABLED: '1' }).shipEnabled).to.equal(false);
+ expect(readLogEnv({ LOG_SHIP_URL: 'https://c/logs' }).shipEnabled).to.equal(false);
+ expect(readLogEnv({ LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'ftp://c/logs' }).shipEnabled).to.equal(false);
+ expect(readLogEnv({ LOG_SHIP_ENABLED: 'true', LOG_SHIP_URL: 'https://c/logs' }).shipEnabled).to.equal(true);
+ });
+
+ it('batches NDJSON to the transport once the batch size is reached', async function () {
+ const bodies = [];
+ const log = createLogShipper({
+ service: 'svc',
+ env: { LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'https://collector.invalid/logs', LOG_SHIP_BATCH_SIZE: '2' },
+ console: fakeConsole(),
+ transport: (body) => { bodies.push(body); return Promise.resolve(); }
+ });
+ log.info('one');
+ log.info('two');
+ await new Promise((r) => setImmediate(r));
+ await log.stop();
+
+ expect(bodies.length).to.equal(1);
+ const lines = bodies[0].trim().split('\n').map((l) => JSON.parse(l));
+ expect(lines.map((l) => l.msg)).to.deep.equal(['one', 'two']);
+ expect(log.stats.shipped).to.equal(2);
+ });
+
+}
+
+function registerLogShipperTestsPart3({ expect, Registry, createLogShipper, fakeConsole }) {
+ it('drops the oldest lines when the buffer is full and counts the loss', function () {
+ const log = createLogShipper({
+ service: 'svc',
+ env: {
+ LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'https://collector.invalid/logs',
+ LOG_SHIP_BATCH_SIZE: '1000', LOG_SHIP_MAX_BUFFER: '3'
+ },
+ console: fakeConsole(),
+ transport: () => new Promise(() => {}) // never settles: buffer fills
+ });
+ for (let i = 0; i < 6; i++) log.info(`line-${i}`);
+ expect(log.buffer.length).to.equal(3);
+ expect(log.stats.dropped).to.equal(3);
+ expect(log.buffer.map((r) => r.msg)).to.deep.equal(['line-3', 'line-4', 'line-5']);
+ });
+
+ it('survives a failing collector, re-queues the batch and rate-limits the stderr note', async function () {
+ const sink = fakeConsole();
+ const log = createLogShipper({
+ service: 'svc',
+ env: { LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'https://collector.invalid/logs', LOG_SHIP_BATCH_SIZE: '1' },
+ console: sink,
+ transport: () => Promise.reject(new Error('ECONNREFUSED'))
+ });
+ log.info('a');
+ await log.flush();
+ log.info('b');
+ await log.flush();
+
+ expect(log.stats.failures).to.be.greaterThan(0);
+ expect(log.stats.shipped).to.equal(0);
+ expect(log.buffer.length).to.be.greaterThan(0);
+ // One note per minute, so the second failure adds no line.
+ expect(sink.lines.error.filter((l) => l.includes('[log-ship]')).length).to.equal(1);
+ await log.stop();
+ });
+
+ it('exposes shipper counters on a registry when one is supplied', function () {
+ const reg = new Registry();
+ const log = createLogShipper({ service: 'svc', env: {}, console: fakeConsole(), registry: reg });
+ log.info('x');
+ log.error('y');
+ const out = reg.render();
+ expect(out).to.include('log_lines_emitted_total{level="info"} 1');
+ expect(out).to.include('log_lines_emitted_total{level="error"} 1');
+ expect(out).to.include('log_ship_buffer_lines 0');
+ });
+
+}
+
+function registerLogShipperTestsPart4({ expect, http, createLogShipper, fakeConsole }) {
+ it('stop() clears the flush timer so the process can exit', async function () {
+ const log = createLogShipper({
+ service: 'svc',
+ env: { LOG_SHIP_ENABLED: '1', LOG_SHIP_URL: 'https://collector.invalid/logs' },
+ console: fakeConsole(),
+ transport: () => Promise.resolve()
+ });
+ expect(log.timer).to.not.equal(null);
+ await log.stop();
+ expect(log.timer).to.equal(null);
+ });
+
+ // Exercises the real postBatch/fetch path. Every other test here injects a
+ // transport, which is why the unreleased response body below went unseen.
+ it('releases the response body so a stalled collector cannot pin the socket', async function () {
+ this.timeout(5000);
+ let closed = false;
+ const sockets = new Set();
+ const server = http.createServer((req, res) => {
+ req.resume();
+ // Answer with headers and a first chunk, then never end the body.
+ req.on('end', () => { res.writeHead(200); res.write('ack'); });
+ res.socket.on('close', () => { closed = true; });
+ });
+ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ const { port } = server.address();
+
+ const log = createLogShipper({
+ service: 'svc',
+ env: {
+ LOG_SHIP_ENABLED: '1',
+ LOG_SHIP_URL: `http://127.0.0.1:${port}/logs`,
+ LOG_SHIP_BATCH_SIZE: '1',
+ LOG_SHIP_TIMEOUT_MS: '400'
+ },
+ console: fakeConsole()
+ });
+
+ try {
+ log.info('one');
+ await log.flush();
+ // fetch() resolves on headers and the abort timer is cleared with it,
+ // so an unreleased body leaves nothing that will ever close this
+ // socket. Measured: released in under 2ms, unreleased still open at 3s.
+ for (let i = 0; i < 100 && !closed; i++) {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ expect(closed).to.equal(true);
+ } finally {
+ await log.stop();
+ for (const s of sockets) s.destroy();
+ await new Promise((resolve) => server.close(resolve));
+ }
+ });
+}
+
+function registerLogShipperTests(context) {
+ registerLogShipperTestsPart1(context);
+ registerLogShipperTestsPart2(context);
+ registerLogShipperTestsPart3(context);
+ registerLogShipperTestsPart4(context);
+}
+
+function registerTextFormatTestsPart1({ expect, createLogShipper, REDACTED, fakeConsole }) {
+ it('renders ts, lowercase level, service tag, message, then key=value', function () {
+ const sink = fakeConsole();
+ const log = createLogShipper({ service: 'xchain-sync', env: {}, console: sink });
+ log.warn('PBFT_DROP', { reason: 'digest_mismatch', phase: 'prepare', round: 42 });
+ expect(sink.lines.warn).to.have.lengthOf(1);
+ expect(sink.lines.warn[0]).to.match(
+ /^\d{4}-\d{2}-\d{2}T[\d:.]+Z warn \[xchain-sync\] PBFT_DROP reason=digest_mismatch phase=prepare round=42$/
+ );
+ });
+
+ it('keeps the level token lowercase so the server-monitor ERROR|FATAL grep does not match it', function () {
+ const sink = fakeConsole();
+ const log = createLogShipper({ service: 'svc', env: {}, console: sink });
+ log.error('boom');
+ // collect-snapshot.sh counts `grep -cE 'ERROR|FATAL'`. An uppercase
+ // token would make every console.error line count and trip the crit
+ // threshold fleet-wide on first deploy.
+ expect(sink.lines.error[0]).to.not.match(/ERROR|FATAL/);
+ expect(sink.lines.error[0]).to.include(' error [svc] boom');
+ });
+
+ it('puts the message immediately after the service tag so existing substring greps still match', function () {
+ const sink = fakeConsole();
+ const log = createLogShipper({ service: 'xchain-sync', env: {}, console: sink });
+ log.info('Oracle: Round 12 finalized');
+ expect(sink.lines.log[0]).to.include('Oracle: Round 12 finalized');
+ });
+
+ it('quotes a value carrying whitespace, = or a quote, and leaves plain tokens bare', function () {
+ const sink = fakeConsole();
+ const log = createLogShipper({ service: 'svc', env: {}, console: sink });
+ log.info('m', { plain: 'abc', spaced: 'a b', eq: 'k=v', num: 3, flag: true, nil: null });
+ const line = sink.lines.log[0];
+ expect(line).to.include('plain=abc');
+ expect(line).to.include('spaced="a b"');
+ expect(line).to.include('eq="k=v"');
+ expect(line).to.include('num=3');
+ expect(line).to.include('flag=true');
+ expect(line).to.include('nil=null');
+ });
+
+}
+
+function registerTextFormatTestsPart2({ expect, createLogShipper, REDACTED, fakeConsole }) {
+ it('redacts a credential-shaped field and an inline credential in the message', function () {
+ const sink = fakeConsole();
+ const log = createLogShipper({ service: 'svc', env: {}, console: sink });
+ log.warn('connect failed password=hunter2', { db_password: 'hunter2', host: 'db1' });
+ const line = sink.lines.warn[0];
+ expect(line).to.not.include('hunter2');
+ expect(line).to.include(REDACTED);
+ expect(line).to.include('host=db1');
+ });
+
+ it('emits one NDJSON record per line under LOG_FORMAT=json', function () {
+ const sink = fakeConsole();
+ const log = createLogShipper({ service: 'svc', env: { LOG_FORMAT: 'json' }, console: sink });
+ log.info('hello', { a: 1 });
+ const parsed = JSON.parse(sink.lines.log[0]);
+ expect(parsed).to.include({ level: 'info', service: 'svc', msg: 'hello', a: 1 });
+ expect(parsed.ts).to.be.a('string');
+ });
+
+ it('silences info under LOG_LEVEL=warn while still emitting warn', function () {
+ const sink = fakeConsole();
+ const log = createLogShipper({ service: 'svc', env: { LOG_LEVEL: 'warn' }, console: sink });
+ log.info('quiet');
+ log.warn('loud');
+ expect(sink.lines.log).to.have.lengthOf(0);
+ expect(sink.lines.warn).to.have.lengthOf(1);
+ });
+}
+
+function registerTextFormatTests(context) {
+ registerTextFormatTestsPart1(context);
+ registerTextFormatTestsPart2(context);
+}
+
+function registerMessageRedactionTests({ expect, scrubMessage, REDACTED }) {
+ // An env-validation failure prints the variable NAME and its value, and the
+ // names the services use are all prefixed (HUB_DB_SECRET, INDEXER_DB_PASS,
+ // db_password). A `\b`-anchored key never matches those, because `_` is a
+ // word character and `\b` does not fire between two word characters. With
+ // LOG_SHIP_* configured, an unscrubbed line goes off-box in the clear.
+ const leaky = [
+ ['prefixed env secret', 'Missing required environment variable: HUB_DB_SECRET=hunter2swordfish'],
+ ['prefixed db pass', 'connect failed db_password=hunter2swordfish'],
+ ['screaming env pass', 'INDEXER_DB_PASS=hunter2swordfish'],
+ ['api key', 'HUB_API_KEY=hunter2swordfish'],
+ ['keyed bearer', 'Authorization: Bearer eyJhbGciOi.SECRETPAYLOAD.sig'],
+ ['bare bearer', 'sending Bearer eyJhbGciOi.SECRETPAYLOAD.sig upstream'],
+ ['quoted mnemonic', 'mnemonic="correct horse battery staple"'],
+ ];
+ for (const [name, line] of leaky) {
+ it(`scrubs a ${name}`, function () {
+ const out = scrubMessage(line);
+ expect(out).to.not.match(/hunter2swordfish|SECRETPAYLOAD|correct horse/);
+ expect(out).to.include(REDACTED);
+ });
+ }
+
+ it('redacts the token, not the word Bearer', function () {
+ // The value group would otherwise capture "Bearer" and stop, leaving the
+ // token itself in the clear immediately after a [redacted] marker that
+ // makes the line look handled.
+ const out = scrubMessage('Authorization: Bearer eyJhbGciOi.SECRETPAYLOAD.sig');
+ expect(out).to.not.include('SECRETPAYLOAD');
+ });
+
+ it('leaves real operational lines untouched, hex identifiers included', function () {
+ // Hub and indexer lines are full of legitimate 64-char hex (txids, block
+ // hashes, state roots). A hex sweep here would gut the logs this work
+ // exists to make readable.
+ const keep = [
+ 'Oracle: Round 12 finalized with 4 of 5 votes',
+ 'StateAnchorPublisher: anchored bundle regtest @ 100 (txid a3f9bc21de)',
+ 'P2P: Invalid signature from xc1qexampleaddr; dropping message',
+ 'seed block=5 imported',
+ 'PBFT_DROP reason=digest_mismatch phase=prepare round=42'
+ ];
+ for (const line of keep) expect(scrubMessage(line)).to.equal(line);
+ });
+}
+
+module.exports = {
+ registerLogShipperTests,
+ registerTextFormatTests,
+ registerMessageRedactionTests
+};
diff --git a/test/unit/observability.test/03_routes.test.js b/test/unit/observability.test/03_routes.test.js
new file mode 100644
index 00000000..e9e867ef
--- /dev/null
+++ b/test/unit/observability.test/03_routes.test.js
@@ -0,0 +1,186 @@
+'use strict';
+
+// 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.
+
+'use strict';
+
+function registerRouteTestsPart1({ expect, express, installObservability, readObservabilityEnv, fakeConsole, listen }) {
+ it('reads a default-off config from an empty env', function () {
+ const cfg = readObservabilityEnv({});
+ expect(cfg.metricsEnabled).to.equal(false);
+ expect(cfg.httpMetrics).to.equal(false);
+ expect(cfg.metricsPath).to.equal('/metrics');
+ expect(cfg.log.shipEnabled).to.equal(false);
+ });
+
+ it('normalizes a METRICS_PATH given without a leading slash', function () {
+ expect(readObservabilityEnv({ METRICS_ENABLED: '1', METRICS_PATH: 'internal/metrics' }).metricsPath)
+ .to.equal('/internal/metrics');
+ });
+
+ it('registers NO route when the flag is unset, but still hands back a registry', async function () {
+ const app = express();
+ app.get('/health', (req, res) => res.json({ ok: true }));
+ const obs = installObservability(app, { service: 'xchain-sync', env: {}, console: fakeConsole() });
+ expect(obs.enabled).to.equal(false);
+ // The registry is deliberately NOT gated: a counter a consensus module
+ // registers has to exist on the default fleet, or it can never record.
+ // Only the endpoint is an operator decision.
+ expect(obs.registry).to.not.equal(null);
+ expect(typeof obs.registry.counter).to.equal('function');
+
+ const srv = await listen(app);
+ try {
+ const res = await fetch(srv.url('/metrics'));
+ expect(res.status).to.equal(404);
+ } finally {
+ await obs.shutdown();
+ await srv.close();
+ }
+ });
+
+}
+
+function registerRouteTestsPart2({ expect, express, installObservability, routeLabel, fakeConsole, listen }) {
+ it('serves the exposition text and instruments requests when enabled', async function () {
+ const app = express();
+ app.get('/health', (req, res) => res.json({ ok: true }));
+ const obs = installObservability(app, {
+ service: 'xchain-sync', version: '1.0.0', coin: 'BTC', network: 'regtest',
+ env: { METRICS_ENABLED: '1' }, console: fakeConsole()
+ });
+ expect(obs.enabled).to.equal(true);
+
+ const srv = await listen(app);
+ try {
+ await fetch(srv.url('/health'));
+ await fetch(srv.url('/health'));
+ await fetch(srv.url('/nope'));
+
+ const res = await fetch(srv.url('/metrics'));
+ expect(res.status).to.equal(200);
+ expect(res.headers.get('content-type')).to.include('version=0.0.4');
+ expect(res.headers.get('cache-control')).to.equal('no-store');
+
+ const body = await res.text();
+ expect(body).to.include('http_requests_total{method="GET",route="/health",status="200"} 2');
+ expect(body).to.include('http_request_duration_seconds_count{method="GET",route="/health"} 2');
+ expect(body).to.include('http_requests_in_flight 0');
+ expect(body).to.include('xchain_service_info{service="xchain-sync",version="1.0.0",coin="BTC",network="regtest"');
+ // The scrape itself is never counted.
+ expect(body).to.not.include('route="/metrics"');
+ } finally {
+ await obs.shutdown();
+ await srv.close();
+ }
+ });
+
+ it('buckets an unmatched path by first segment so URLs cannot explode cardinality', async function () {
+ const app = express();
+ const obs = installObservability(app, { service: 'svc', env: { METRICS_ENABLED: '1' }, console: fakeConsole() });
+ const srv = await listen(app);
+ try {
+ await fetch(srv.url('/block/000000001'));
+ await fetch(srv.url('/block/000000002'));
+ const body = await (await fetch(srv.url('/metrics'))).text();
+ expect(body).to.include('http_requests_total{method="GET",route="/block",status="404"} 2');
+ } finally {
+ await obs.shutdown();
+ await srv.close();
+ }
+ });
+
+ it('uses the express route pattern, not the concrete path, as the route label', function () {
+ expect(routeLabel({ route: { path: '/snapshot/:table' }, baseUrl: '/hub-db' })).to.equal('/hub-db/snapshot/:table');
+ expect(routeLabel({ originalUrl: '/telemetry/summary?x=1' })).to.equal('/telemetry');
+ expect(routeLabel({ url: '/' })).to.equal('/');
+ });
+
+}
+
+function registerRouteTestsPart3({ expect, express, installObservability, fakeConsole, listen }) {
+ it('gates the endpoint behind METRICS_TOKEN when one is configured', async function () {
+ const app = express();
+ const obs = installObservability(app, {
+ service: 'svc', env: { METRICS_ENABLED: '1', METRICS_TOKEN: 'sekret-scrape' }, console: fakeConsole()
+ });
+ const srv = await listen(app);
+ try {
+ expect((await fetch(srv.url('/metrics'))).status).to.equal(401);
+ expect((await fetch(srv.url('/metrics'), { headers: { Authorization: 'Bearer wrong' } })).status).to.equal(401);
+ const ok = await fetch(srv.url('/metrics'), { headers: { Authorization: 'Bearer sekret-scrape' } });
+ expect(ok.status).to.equal(200);
+ expect(await ok.text()).to.include('# TYPE');
+ } finally {
+ await obs.shutdown();
+ await srv.close();
+ }
+ });
+
+ it('honours a custom METRICS_PATH and can skip HTTP instrumentation', async function () {
+ const app = express();
+ app.get('/health', (req, res) => res.json({ ok: true }));
+ const obs = installObservability(app, {
+ service: 'svc', env: { METRICS_ENABLED: '1', METRICS_PATH: '/internal/metrics', METRICS_HTTP: '0' },
+ console: fakeConsole()
+ });
+ const srv = await listen(app);
+ try {
+ await fetch(srv.url('/health'));
+ expect((await fetch(srv.url('/metrics'))).status).to.equal(404);
+ const body = await (await fetch(srv.url('/internal/metrics'))).text();
+ expect(body).to.include('xchain_service_info');
+ expect(body).to.not.include('http_requests_total{');
+ } finally {
+ await obs.shutdown();
+ await srv.close();
+ }
+ });
+
+}
+
+function registerRouteTestsPart4({ expect, express, installObservability, fakeConsole, listen }) {
+ it('instruments routes registered BEFORE the install call (layer is hoisted)', async function () {
+ // The six services wire this at different points in their api.js; Express
+ // dispatches in registration order, so without the hoist an install that
+ // lands after the routes would export zero HTTP metrics.
+ const app = express();
+ app.get('/early', (req, res) => res.send('ok'));
+ const obs = installObservability(app, { service: 'svc', env: { METRICS_ENABLED: '1' }, console: fakeConsole() });
+ const srv = await listen(app);
+ try {
+ await fetch(srv.url('/early'));
+ const body = await (await fetch(srv.url('/metrics'))).text();
+ expect(body).to.include('http_requests_total{method="GET",route="/early",status="200"} 1');
+ } finally {
+ await obs.shutdown();
+ await srv.close();
+ }
+ });
+
+ it('returns a usable logger even with no app to mount on', function () {
+ const sink = fakeConsole();
+ const obs = installObservability(null, { service: 'worker', env: {}, console: sink });
+ expect(obs.enabled).to.equal(false);
+ obs.logger.info('tick');
+ expect(sink.lines.log).to.have.lengthOf(1);
+ expect(sink.lines.log[0]).to.match(/^\S+Z info \[worker\] tick$/);
+ });
+}
+
+function registerRouteTests(context) {
+ registerRouteTestsPart1(context);
+ registerRouteTestsPart2(context);
+ registerRouteTestsPart3(context);
+ registerRouteTestsPart4(context);
+}
+
+module.exports = { registerRouteTests };
diff --git a/test/unit/observability.test/04_flush_and_health.test.js b/test/unit/observability.test/04_flush_and_health.test.js
new file mode 100644
index 00000000..009c5242
--- /dev/null
+++ b/test/unit/observability.test/04_flush_and_health.test.js
@@ -0,0 +1,174 @@
+'use strict';
+
+// 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.
+
+'use strict';
+
+function registerFlushAndHealthTestsPart1({ expect, patchConsole, unpatchConsole }) {
+ it('routes console.* through the shim, mapping log to info', function () {
+ const handle = patchConsole({ service: 'xchain-sync', env: {} });
+ const seen = [];
+ // Read the shipper's own output by swapping its sink after the patch,
+ // which is the only place the bound originals are reachable from.
+ handle.logger.console = { log: (m) => seen.push(m), warn: (m) => seen.push(m), error: (m) => seen.push(m) };
+ console.log('plain');
+ console.warn('careful');
+ console.error('bad');
+ unpatchConsole();
+ expect(seen[0]).to.match(/ info \[xchain-sync\] plain$/);
+ expect(seen[1]).to.match(/ warn \[xchain-sync\] careful$/);
+ expect(seen[2]).to.match(/ error \[xchain-sync\] bad$/);
+ });
+
+ it('resolves printf format strings and keeps an Error stack, via util.format', function () {
+ const handle = patchConsole({ service: 'svc', env: {} });
+ const seen = [];
+ handle.logger.console = { log: (m) => seen.push(m), warn: (m) => seen.push(m), error: (m) => seen.push(m) };
+ console.log('round %d of %s', 7, 'oracle');
+ console.error('crashed:', new Error('kaboom'));
+ unpatchConsole();
+ expect(seen[0]).to.include('round 7 of oracle');
+ expect(seen[1]).to.include('kaboom');
+ expect(seen[1]).to.include('Error');
+ });
+
+ it('does not recurse: the sink holds bound originals captured BEFORE the patch', function () {
+ // `const orig = console` would hand the logger the very object about to
+ // be replaced, so every line would re-enter the wrapper forever. The
+ // proof is simply that a line completes and arrives once.
+ const realLog = console.log;
+ let depth = 0;
+ let maxDepth = 0;
+ console.log = (...a) => { depth += 1; maxDepth = Math.max(maxDepth, depth); depth -= 1; return realLog.apply(console, a); };
+ const captured = console.log;
+ try {
+ patchConsole({ service: 'svc', env: {} });
+ expect(console.log).to.not.equal(captured);
+ console.log('one line');
+ unpatchConsole();
+ } finally {
+ console.log = realLog;
+ }
+ expect(maxDepth).to.equal(1);
+ });
+
+}
+
+function registerFlushAndHealthTestsPart2({ expect, patchConsole, unpatchConsole, getLogger, createLogShipper, fakeConsole }) {
+ it('no-ops under XCHAIN_LOG_PATCH=0 so test bootstraps see stock console', function () {
+ const before = console.log;
+ const handle = patchConsole({ service: 'svc', env: { XCHAIN_LOG_PATCH: '0' } });
+ expect(handle.patched).to.equal(false);
+ expect(console.log).to.equal(before);
+ });
+
+ it('is idempotent and restores the exact original functions on unpatch', function () {
+ const before = { log: console.log, warn: console.warn, error: console.error };
+ const first = patchConsole({ service: 'svc', env: {} });
+ const second = patchConsole({ service: 'other', env: {} });
+ expect(second).to.equal(first);
+ unpatchConsole();
+ expect(console.log).to.equal(before.log);
+ expect(console.warn).to.equal(before.warn);
+ expect(console.error).to.equal(before.error);
+ });
+
+ it('getLogger works before any install and reaches the real shipper after', function () {
+ // A module that logs while being required must not be able to crash the
+ // process just because it loaded before the wiring.
+ const log = getLogger();
+ expect(() => log.info('early', { a: 1 })).to.not.throw();
+ const handle = patchConsole({ service: 'svc', env: {} });
+ const seen = [];
+ handle.logger.console = { log: (m) => seen.push(m), warn: (m) => seen.push(m), error: (m) => seen.push(m) };
+ log.warn('LATE_EVENT', { reason: 'x' });
+ unpatchConsole();
+ expect(seen[0]).to.match(/ warn \[svc\] LATE_EVENT reason=x$/);
+ });
+
+ // A trailing Error argument expands across lines under util.inspect, and only
+ // the first line carries the prefix. Measured on the live fleet as orphaned
+ // fragments like " fatal: true," from a pretty-printed mariadb SqlError:
+ // no operation, no error, no coin, and unparseable by anything keying on the
+ // prefix. One console call must be one line.
+ it('renders a multi-line message as ONE line with the breaks escaped', () => {
+ const sink = fakeConsole();
+ const log = createLogShipper({ service: 'xchain-sync', env: {}, console: sink });
+ log.error('DB write failed: SqlError: connect ECONNREFUSED\n fatal: true,\n errno: -111');
+ expect(sink.lines.error).to.have.lengthOf(1);
+ expect(sink.lines.error[0]).to.not.match(/\n/);
+ expect(sink.lines.error[0]).to.include('\\n fatal: true,');
+ expect(sink.lines.error[0]).to.match(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z error \[xchain-sync\] DB write failed:/);
+ });
+
+}
+
+function registerFlushAndHealthTestsPart3({ expect, patchConsole, unpatchConsole, getRegistry, createLogShipper, installObservability, fakeConsole }) {
+ it('escapes a bare carriage return too, so a progress writer cannot split a record', () => {
+ const sink = fakeConsole();
+ const log = createLogShipper({ service: 'xchain-sync', env: {}, console: sink });
+ log.warn('rewriting\rline');
+ expect(sink.lines.warn).to.have.lengthOf(1);
+ expect(sink.lines.warn[0]).to.include('rewriting\\nline');
+ });
+
+ // JSON mode needs no escaping of its own: JSON.stringify already emits one
+ // physical line and keeps the true characters, which is better fidelity for a
+ // machine reader than a lossy substitution would be.
+ it('JSON mode keeps the real newlines and still emits one physical line', () => {
+ const sink = fakeConsole();
+ const log = createLogShipper({ service: 'xchain-sync', env: { LOG_FORMAT: 'json' }, console: sink });
+ log.error('line one\nline two');
+ expect(sink.lines.error).to.have.lengthOf(1);
+ expect(sink.lines.error[0]).to.not.match(/\n/);
+ expect(JSON.parse(sink.lines.error[0]).msg).to.equal('line one\nline two');
+ });
+
+ it('does not double-format: a shipper built AFTER the patch writes to the pre-patch sink', function () {
+ // The shim's default sink is the global console by reference. A shipper
+ // taking that default once console is patched emits its formatted line
+ // INTO the wrapper and gets it formatted again, so the line reads
+ // ` warn [svc] warn [svc] msg`. Caught by driving the real hub
+ // suite, not by reading the diff.
+ const seen = [];
+ const realWarn = console.warn;
+ console.warn = (m) => seen.push(m);
+ try {
+ patchConsole({ service: 'svc', env: {} });
+ // A custom transport means this handle does NOT adopt the process
+ // shipper, so it builds a second one: the path where the global
+ // console would otherwise be taken as the default sink.
+ const second = installObservability(null, { service: 'svc', env: {}, logTransport: () => Promise.resolve() });
+ second.logger.warn('once only');
+ } finally {
+ unpatchConsole();
+ console.warn = realWarn;
+ }
+ expect(seen).to.have.lengthOf(1);
+ expect(seen[0]).to.match(/^\S+Z warn \[svc\] once only$/);
+ });
+
+ it('hands out one registry, always constructed, before any install call', function () {
+ const reg = getRegistry({ service: 'svc' });
+ expect(reg).to.equal(getRegistry());
+ const c = reg.counter({ name: 'xchain_probe_total', help: 'probe' });
+ c.inc({}, 1);
+ expect(reg.render()).to.include('xchain_probe_total');
+ });
+}
+
+function registerFlushAndHealthTests(context) {
+ registerFlushAndHealthTestsPart1(context);
+ registerFlushAndHealthTestsPart2(context);
+ registerFlushAndHealthTestsPart3(context);
+}
+
+module.exports = { registerFlushAndHealthTests };
diff --git a/test/unit/replica_freshness.test.js b/test/unit/replica_freshness.test.js
index 04fa618b..ef4c4db8 100644
--- a/test/unit/replica_freshness.test.js
+++ b/test/unit/replica_freshness.test.js
@@ -93,6 +93,19 @@ describe('/status replication freshness', function(){
});
});
+describe('per-chain rollback depth', function(){
+ it('raises only litecoin testnet to the public-testnet rollback window', function(){
+ assert.strictEqual(config.resolveMaxRollbackDepth('litecoin', 'testnet', 100, false), 5000);
+ assert.strictEqual(config.resolveMaxRollbackDepth('LTC', 'mainnet', 100, false), 100);
+ assert.strictEqual(config.resolveMaxRollbackDepth('LTC', 'regtest', 100, false), 100);
+ assert.strictEqual(config.resolveMaxRollbackDepth('bitcoin', 'testnet', 100, false), 100);
+ });
+
+ it('preserves an explicit operator override', function(){
+ assert.strictEqual(config.resolveMaxRollbackDepth('litecoin', 'testnet', 250, true), 250);
+ });
+});
+
describe('/status replication freshness', function(){
// A follower's own lag_blocks is computed against a height its SOURCE published.
@@ -128,3 +141,29 @@ describe('/status replication freshness', function(){
});
});
});
+
+describe('/status server row carries a protocol-client halt', function(){
+ afterEach(function(){ sinon.restore(); });
+
+ it('marks a durable halt stale even when native SQL replication is fresh', async function(){
+ let prior = process.env.SYNC_MODE;
+ process.env.SYNC_MODE = 'server';
+ let { buildStatusRow } = proxyquire('../../src/api', {});
+ if(prior === undefined) delete process.env.SYNC_MODE; else process.env.SYNC_MODE = prior;
+ let db = mockDb();
+ db.getActiveHalt = sinon.stub().resolves({ block_index: 99, reason: 'rollback-depth-exceeded' });
+ let service = {
+ getBroadcaster: () => ({
+ getStatus: () => ({ block_height: 100, source_block_height: 100,
+ replica_stale: false, replica_seconds_behind: 0 }),
+ getSubscribers: () => []
+ }),
+ getSnapshotBuilder: () => null
+ };
+
+ let row = await buildStatusRow(service, db, 'decoder', 'litecoin', 'testnet');
+ assert.strictEqual(row.replica_halted, true);
+ assert.strictEqual(row.replica_stale, true);
+ assert.strictEqual(row.lag_blocks, null);
+ });
+});
diff --git a/test/unit/repo_guards/carrier_logic_pin.test.js b/test/unit/repo_guards/carrier_logic_pin.test.js
index cbcfd93b..87f95eba 100644
--- a/test/unit/repo_guards/carrier_logic_pin.test.js
+++ b/test/unit/repo_guards/carrier_logic_pin.test.js
@@ -42,9 +42,13 @@ function siblingWith(repo, rel) {
return fs.existsSync(path.join(dir, rel)) ? dir : null;
}
-/** Skip or fail on a missing sibling, by the environment's rule. */
-function missingSibling(test, repo) {
- if (REQUIRE_SIBLINGS) assert.fail(`${repo} is not checked out beside this repo and XCHAIN_REQUIRE_SIBLINGS=1`);
+/** Skip or fail once for every missing sibling, by the environment's rule; call it after the assertions (skip throws). */
+function missingSiblings(test, repos) {
+ const names = [...new Set(repos)].sort();
+ if (!names.length) return;
+ if (REQUIRE_SIBLINGS) {
+ assert.fail(`${names.join(', ')} ${names.length > 1 ? 'are' : 'is'} not checked out beside this repo and XCHAIN_REQUIRE_SIBLINGS=1`);
+ }
test.skip();
}
@@ -70,15 +74,17 @@ describe('bin/pins/carrier-logic.json: the carrier logic pin', function () {
it('(c) every twin id hashes the same in the sibling pin', function () {
const mismatches = [];
+ const missing = [];
for (const id of Object.keys(pin.entries)) {
for (const repo of pin.entries[id].twins || []) {
const dir = siblingWith(repo, pinModule.PIN_REL);
- if (!dir) { missingSibling(this, repo); continue; }
+ if (!dir) { missing.push(repo); continue; }
const theirs = (pinModule.readPin(dir).entries[id] || {}).hash;
if (theirs !== pin.entries[id].hash) mismatches.push(`${id}: ${repo} pins ${theirs}, this repo pins ${pin.entries[id].hash}`);
}
}
assert.deepStrictEqual(mismatches, [], 'a logic change to a twin re-pins every copy in one change set');
+ missingSiblings(this, missing);
});
});
@@ -105,10 +111,11 @@ describe('bin/pins/carrier-logic.json: the carrier logic pin', function () {
it('(e) every twin file\'s bytes equal every sibling copy', function () {
assert.ok(pinModule.MODULE_TWIN_FILES.length >= 2, 'the module and its ops half are both twins');
+ const missing = [];
for (const repo of pinModule.MODULE_TWINS) {
if (repo === pinModule.repoName(REPO_ROOT)) continue;
const dir = siblingWith(repo, pinModule.MODULE_REL);
- if (!dir) { missingSibling(this, repo); continue; }
+ if (!dir) { missing.push(repo); continue; }
for (const rel of pinModule.MODULE_TWIN_FILES) {
const theirs = path.join(dir, rel);
assert.ok(fs.existsSync(theirs), `${rel} is missing from the ${repo} copy: cp it there`);
@@ -116,6 +123,7 @@ describe('bin/pins/carrier-logic.json: the carrier logic pin', function () {
`${rel} differs from the ${repo} copy: edit one and cp it to the others`);
}
}
+ missingSiblings(this, missing);
});
it('(g) the membership rule and the pin name the same files, both ways', () => {
diff --git a/test/unit/repo_guards/config_key_coverage.test.js b/test/unit/repo_guards/config_key_coverage.test.js
new file mode 100644
index 00000000..b3471d79
--- /dev/null
+++ b/test/unit/repo_guards/config_key_coverage.test.js
@@ -0,0 +1,107 @@
+/*********************************************************************
+ *
+ * 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.
+ *
+ **********************************************************************
+ * Every service config key read under src/ is declared by getConfig().
+ *
+ * The live config object is exactly what getConfig() builds, so a key the
+ * code reads but the builder never assigns is always undefined in production
+ * and its env var is silently inert. Unit tests hand-build partial config
+ * objects, so nothing else in the suite can see that gap.
+ ********************************************************************/
+
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const acorn = require('acorn');
+const config = require('../../../src/config');
+
+const SRC_DIR = path.resolve(__dirname, '..', '..', '..', 'src');
+
+// Names the service config object is held under at its read sites.
+const CONFIG_NAMES = new Set(['config', 'cfg']);
+
+// Keys read but deliberately not declared yet. Each must still be read and still
+// be undeclared, so this list can only shrink.
+const PENDING = {
+ // Selects which release manifest the train-activation gate enforces; its env
+ // wiring is decided together with the indexer's train gate, not here alone.
+ RELEASE_MANIFEST_PATH: 'src/client/sync.js'
+};
+
+// List every .js file under a directory, recursively.
+function jsFiles(dir, out){
+ for(const entry of fs.readdirSync(dir, { withFileTypes: true })){
+ const full = path.join(dir, entry.name);
+ if(entry.isDirectory()) jsFiles(full, out);
+ else if(entry.name.endsWith('.js')) out.push(full);
+ }
+ return out;
+}
+
+// Collect `config['KEY']` / `cfg['KEY']` reads from code tokens (comments excluded).
+function collectReads(file, reads){
+ const source = fs.readFileSync(file, 'utf8');
+ const tokens = [...acorn.tokenizer(source, { ecmaVersion: 'latest', allowHashBang: true, locations: true })];
+ for(let i = 0; i + 3 < tokens.length; i++){
+ const [name, open, key, close] = tokens.slice(i, i + 4);
+ if(name.type.label !== 'name' || !CONFIG_NAMES.has(name.value)) continue;
+ if(open.type.label !== '[' || close.type.label !== ']') continue;
+ if(key.type.label !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(key.value)) continue;
+ const site = path.relative(path.dirname(SRC_DIR), file) + ':' + key.loc.start.line;
+ if(!reads.has(key.value)) reads.set(key.value, []);
+ reads.get(key.value).push(site);
+ }
+ return reads;
+}
+
+function allReads(){
+ const reads = new Map();
+ for(const file of jsFiles(SRC_DIR, [])) collectReads(file, reads);
+ return reads;
+}
+
+describe('config key coverage: every config key src/ reads is declared by getConfig()', function(){
+ let reads;
+ let declared;
+ before(function(){
+ reads = allReads();
+ declared = new Set(Object.keys(config.getConfig()));
+ });
+
+ it('finds the known reads, so an empty scan cannot pass', function(){
+ for(const key of ['SNAPSHOT_MAX_CONTENT', 'DISPENSERS_RECONCILE_EVERY', 'DISPENSERS_RECONCILE_MAX_INTERVAL_MS']){
+ assert.ok(reads.has(key), 'the scan no longer sees the read of ' + key);
+ }
+ });
+
+ it('declares every key that is read', function(){
+ const missing = [];
+ for(const [key, sites] of reads){
+ if(declared.has(key) || Object.prototype.hasOwnProperty.call(PENDING, key)) continue;
+ missing.push(key + ' (read at ' + sites.join(', ') + ')');
+ }
+ assert.deepStrictEqual(missing, [],
+ 'read off the service config but never assigned in getConfig(), so the env var is inert:\n ' +
+ missing.join('\n '));
+ });
+
+ it('keeps every pending key both read and undeclared', function(){
+ for(const [key, file] of Object.entries(PENDING)){
+ assert.ok((reads.get(key) || []).some(site => site.startsWith(file + ':')),
+ key + ' is no longer read in ' + file + '; drop it from PENDING');
+ assert.ok(!declared.has(key), key + ' is now declared; drop it from PENDING');
+ }
+ });
+});
diff --git a/test/unit/repo_guards/markets_id_parity_tool.test.js b/test/unit/repo_guards/markets_id_parity_tool.test.js
new file mode 100644
index 00000000..c2ced8c7
--- /dev/null
+++ b/test/unit/repo_guards/markets_id_parity_tool.test.js
@@ -0,0 +1,92 @@
+// 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.
+
+// bin/markets-id-parity.js compares markets.id by pair NAME across sync sources.
+
+const assert = require('assert');
+const sinon = require('sinon');
+const zlib = require('zlib');
+const axios = require('axios');
+const tool = require('../../../bin/markets-id-parity.js');
+
+function source(tickers, markets){
+ return tool.idsByPair(markets, new Map(Object.entries(tickers)));
+}
+
+function gz(obj){ return { data: zlib.gzipSync(JSON.stringify(obj)) }; }
+
+describe('bin/markets-id-parity fetch', function(){
+ afterEach(function(){ sinon.restore(); });
+ const opts = { chain: 'BTC', network: 'mainnet', headers: {} };
+
+ it('reads markets at the tip and pages every ticker', async function(){
+ let get = sinon.stub(axios, 'get');
+ get.withArgs('http://a/status/indexer/BTC/mainnet').resolves({ data: Buffer.from('{"block_height":90}') });
+ get.withArgs('http://a/snapshot/indexer/BTC/mainnet/since/90?skip_lookups=1')
+ .resolves(gz({ tables: { markets: [{ id: 1, tick1_id: 1, tick2_id: 2 }] } }));
+ get.withArgs('http://a/snapshot-rows/indexer/BTC/mainnet/index_tickers?after_id=0&limit=50000')
+ .resolves(gz({ has_more: true, max_id: 1, rows: [{ id: 1, tick: 'XCP' }] }));
+ get.withArgs('http://a/snapshot-rows/indexer/BTC/mainnet/index_tickers?after_id=1&limit=50000')
+ .resolves(gz({ has_more: false, max_id: 2, rows: [{ id: 2, tick: 'PEPE' }] }));
+ let got = await tool.fetchSource('http://a', opts);
+ assert.strictEqual(got.tip, 90);
+ assert.deepStrictEqual([...tool.idsByPair(got.markets, got.tickers)], [['XCP/PEPE', '1']]);
+ });
+
+ it('refuses a source with no polled height instead of pulling since/0', async function(){
+ let get = sinon.stub(axios, 'get').resolves({ data: { block_height: null } });
+ await assert.rejects(() => tool.fetchSource('http://a', opts), /no block_height/);
+ assert.strictEqual(get.callCount, 1, 'no snapshot is requested');
+ });
+});
+
+describe('bin/markets-id-parity', function(){
+ it('keys pairs by ticker name so differing ticker ids still match', function(){
+ let a = source({ 1: 'XCP', 2: 'PEPE' }, [{ id: 5, tick1_id: 1, tick2_id: 2 }]);
+ let b = source({ 7: 'XCP', 9: 'PEPE' }, [{ id: 5, tick1_id: '7', tick2_id: '9' }]);
+ let r = tool.compareSources({ a, b });
+ assert.strictEqual(r.pairs, 1);
+ assert.deepStrictEqual(r.differing, []);
+ assert.deepStrictEqual(r.missing, []);
+ assert.deepStrictEqual(r.collisions, []);
+ });
+
+ it('names a native-coin side by its coin id', function(){
+ let a = source({ 1: 'XCP' }, [{ id: 3, tick1_id: 1, tick2_id: 0, coin2_id: 4 }]);
+ assert.deepStrictEqual([...a.keys()], ['XCP/coin#4']);
+ });
+
+ it('reports differing ids and the id that names two pairs', function(){
+ let t = { 1: 'XCP', 2: 'PEPE', 3: 'RARE' };
+ let a = source(t, [{ id: 1, tick1_id: 1, tick2_id: 2 }, { id: 2, tick1_id: 1, tick2_id: 3 }]);
+ let b = source(t, [{ id: 2, tick1_id: 1, tick2_id: 2 }, { id: 1, tick1_id: 1, tick2_id: 3 }]);
+ let r = tool.compareSources({ a, b });
+ assert.strictEqual(r.differing.length, 2);
+ assert.deepStrictEqual(r.collisions.map(c => c.id), ['1', '2']);
+ let report = tool.renderReport({ chain: 'BTC', network: 'mainnet', sources: ['a', 'b'] },
+ { a: { tip: 9, markets: [1, 2] }, b: { tip: 9, markets: [1, 2] } }, r);
+ assert.strictEqual(report.agree, false);
+ assert.match(report.text, /NOT reproducible/);
+ });
+
+ it('reports a pair only one source holds', function(){
+ let a = source({ 1: 'XCP', 2: 'PEPE' }, [{ id: 1, tick1_id: 1, tick2_id: 2 }]);
+ let b = source({ 1: 'XCP', 2: 'PEPE' }, []);
+ let r = tool.compareSources({ a, b });
+ assert.deepStrictEqual(r.missing, [{ pair: 'XCP/PEPE', ids: { a: '1', b: null } }]);
+ });
+
+ it('refuses fewer than two sources', function(){
+ assert.throws(() => tool.parseArgs(['--chain', 'BTC', '--network', 'mainnet'], { SYNC_SOURCES: 'http://a' }),
+ /two sources/);
+ let o = tool.parseArgs(['--chain', 'BTC', '--network', 'mainnet'], { SYNC_SOURCES: 'http://a/, http://b' });
+ assert.deepStrictEqual(o.sources, ['http://a', 'http://b']);
+ });
+});
diff --git a/test/unit/rollback_coverage.test.js b/test/unit/rollback_coverage.test.js
index a483b07e..b1860164 100644
--- a/test/unit/rollback_coverage.test.js
+++ b/test/unit/rollback_coverage.test.js
@@ -22,10 +22,12 @@
* diverges from the source (the exact failure xchain-indexer's rollback guard
* prevents on the source side).
*
- * The risk here is sharper than on the source: ClientRollback's table lists are
- * a hand-maintained mirror of xchain-indexer/src/rollback.js (see the header
- * comment there). They have drifted before: `prices` was added to the indexer's
- * rollback set but not here, so reorged price rows lingered on every replica.
+ * The risk here is sharper than on the source: ClientRollback's generic lists
+ * derive from the table-lifecycle registry and the decoder topology (see the
+ * header of src/client/rollback.js), but its bespoke resets and restores are a
+ * hand-maintained mirror of xchain-indexer/src/rollback/. Hand-copied lists
+ * drift: `prices` joined the indexer's rollback set but not the replica's, so
+ * reorged price rows lingered on every replica.
* This test fails when a table ServerPoller replicates is not handled by
* ClientRollback for that dbType, catching the drift at CI time.
*
@@ -94,13 +96,14 @@ const isLookupTable = (t) => t.startsWith('index_') || t === 'pubkeys';
// Coverage that lives outside ClientRollback's table arrays: the replica-side
// rollback buckets, derived from the table-lifecycle registry twin
-// (src/tableLifecycle.js, byte-identical to the xchain-indexer copy; asserted
+// (src/table_lifecycle.js, byte-identical to the xchain-indexer copy; asserted
// below). Per-table rationale lives with each registry entry.
const lifecycleTwin = require('../../src/table_lifecycle');
const pathMod = require('path');
const fs = require('fs');
const assertLocal = require('assert');
const sh = require('../../src/consensus/state_hash');
+const { withDbMixins } = require('../helpers/db_mixins.js');
const widenSet = require('../../src/schema/utf8mb4_columns');
const { RECOMPUTED, SPECIAL_CASE, ROLLBACK_EXEMPT, INDEXER_LOCAL } = lifecycleTwin.replicaRollbackBuckets();
@@ -239,7 +242,7 @@ describe('Rollback coverage guard @regression', function(){
const IndexerRollback = require(rbPath);
// Rollback's constructor only assigns config aliases + the static table
// arrays (no DB/network work), so a bare stub yields the lists we need.
- const indexer = new IndexerRollback({});
+ const indexer = new IndexerRollback({ util: { resetLists () {} } });
const indexerRollback = new Set([...indexer.dataTables, ...indexer.blockTables]);
// INDEXER_LOCAL (tables the indexer rolls back that sync intentionally
@@ -289,7 +292,7 @@ describe('Rollback coverage guard @regression', function(){
const rbPath = indexerFile(INDEXER_ROLLBACK_ENTRY);
if(!requireSibling(this, rbPath)) return;
const IndexerRollback = require(rbPath);
- const indexer = new IndexerRollback({});
+ const indexer = new IndexerRollback({ util: { resetLists () {} } });
assert.deepStrictEqual(
[...(indexer.indexTables || [])].sort(),
[...rollback.indexTables].sort(),
@@ -453,30 +456,27 @@ describe('Rollback coverage guard @regression', function(){
});
// Cross-repo drift guard for the light-client stakes_root query (SPV spec sec.4.1).
- // The follower rebuilds the BTC stakes_root from db._stakeWeightsSql; it MUST stay
- // byte-identical to the xchain-indexer _stakeWeightsSql, or the follower's
+ // The follower rebuilds the BTC stakes_root from db.stakeWeightsSql; it MUST stay
+ // byte-identical to the xchain-indexer stakeWeightsSql, or the follower's
// stakes_root (hence state_root) diverges from the source and the state-commitment
// check false-halts. Both files carry the method verbatim; this extracts the body
// and asserts whitespace-normalised equality. If you edit one, edit the other.
- it('_stakeWeightsSql is identical across xchain-indexer and xchain-sync (cross-repo drift guard)', function(){
+ it('stakeWeightsSql is identical across xchain-indexer and xchain-sync (cross-repo drift guard)', function(){
function stakeSql(p){
const src = fs.readFileSync(p, 'utf8');
- // The indexer spells the method stakeWeightsSql (its code-structure pass dropped
- // the underscore prefix); this repo still spells it _stakeWeightsSql. The optional
- // prefix accepts both, and the body comparison below is unchanged.
- const m = src.match(/_?stakeWeightsSql\(valid_id, blockIndex, minStake\)\{([\s\S]*?)return \{ sql, args \};/);
- assert.ok(m, `_stakeWeightsSql not found in ${p}`);
+ const m = src.match(/(? fs.readFileSync(pathMod.resolve(__dirname, f), 'utf8')).join('\n')
.replace(/[`"']/g, ' ').replace(/\s+\+\s+/g, ' ').replace(/\s+/g, ' ');
assert.ok(/WHERE cooldown_end_block BETWEEN \? AND \?/.test(src),
'updatedRows.js must select the cooldown status flip by cooldown_end_block (the maturity-block key the reverse reset and the forward credit select share)');
@@ -720,7 +721,10 @@ describe('Rollback coverage guard @regression', function(){
assert.ok(/collectDerivedAnchorRewards\s*\(/.test(src),
`${f} does not call collectDerivedAnchorRewards; its replication channel drops derived anchor/archive rewards`);
}
- const applier = norm(fs.readFileSync(pathMod.resolve(__dirname, '../../src/client/applier.js'), 'utf8'));
+ // The reconcile DELETE lives in the validator_rewards mixin and ClientApplier
+ // calls it by name, so the guard reads both.
+ const applier = norm(['../../src/client/applier.js', '../../src/db/validator_rewards.js']
+ .map((f) => fs.readFileSync(pathMod.resolve(__dirname, f), 'utf8')).join('\n'));
assert.ok(/DELETE vr FROM validator_rewards vr JOIN anchor_reward_reconcile_log d ON d\.source_id = vr\.source_id AND d\.signing_pubkey_id = vr\.signing_pubkey_id AND d\.reward_type = vr\.reward_type AND d\.round_reference <=> vr\.round_reference AND d\.round_qualifier = vr\.round_qualifier/.test(applier),
'ClientApplier.js must mirror the reconcile DELETE from the replicated pre-image log (forward twin of the RB-ANCHOR restore) on the FULL five-column reward identity; without round_qualifier the keyed delete also reaches the other archive snapshot\'s surviving reward');
// RB-ANCHOR restore parity on that same identity. The source twin
@@ -908,7 +912,9 @@ describe('Rollback coverage guard @regression', function(){
'ARCHIVE_HEAD_VERSIONS_SQL must render as IN (1)');
const fs = require('fs'), pathMod = require('path');
const norm = s => s.replace(/[`"']/g, ' ').replace(/\s+\+\s+/g, ' ').replace(/\s+/g, ' ');
- const ur = norm(fs.readFileSync(pathMod.resolve(__dirname, '../../src/server/updated_rows.js'), 'utf8'));
+ // The anchor SELECT lives in the tables mixin and the collector calls it by name.
+ const ur = norm(['../../src/server/updated_rows.js', '../../src/db/tables.js']
+ .map((f) => fs.readFileSync(pathMod.resolve(__dirname, f), 'utf8')).join('\n'));
assertLocal.ok(/WHERE p\.version ARCHIVE_HEAD_VERSIONS_SQL AND ARCHIVE_CHUNK_HEIGHT_COL BETWEEN \? AND \?/.test(ur),
'updatedRows.js anchor class must select archive-head parents via ARCHIVE_HEAD_VERSIONS_SQL, ' +
'scoped by the shared ARCHIVE_CHUNK_HEIGHT_COL');
@@ -965,7 +971,9 @@ describe('Rollback coverage guard @regression', function(){
assert.deepStrictEqual(POLL_FINALIZE_TABLES, ['polls'],
'updated_rows must track the poll finalization flip on polls');
const fs = require('fs'), pathMod = require('path');
- const src = fs.readFileSync(pathMod.resolve(__dirname, '../../src/server/updated_rows.js'), 'utf8')
+ // The SELECT lives in the tables mixin and the collector calls it by name.
+ const src = ['../../src/server/updated_rows.js', '../../src/db/tables.js']
+ .map((f) => fs.readFileSync(pathMod.resolve(__dirname, f), 'utf8')).join('\n')
.replace(/[`"']/g, ' ').replace(/\s+\+\s+/g, ' ').replace(/\s+/g, ' ');
assert.ok(/WHERE resolved_block BETWEEN \? AND \?/.test(src),
'updatedRows.js must select the poll finalization flip by resolved_block (the same key the reverse re-open resets)');
@@ -986,7 +994,9 @@ describe('Rollback coverage guard @regression', function(){
assert.deepStrictEqual(ROTATION_TABLES, ['contract_stakes', 'contract_unstakes'],
'updated_rows must track the rotation rewrite on both contract stake tables');
const fs = require('fs'), pathMod = require('path');
- const src = fs.readFileSync(pathMod.resolve(__dirname, '../../src/server/updated_rows.js'), 'utf8')
+ // The SELECT lives in the tables mixin and the collector calls it by name.
+ const src = ['../../src/server/updated_rows.js', '../../src/db/tables.js']
+ .map((f) => fs.readFileSync(pathMod.resolve(__dirname, f), 'utf8')).join('\n')
.replace(/[`"']/g, ' ').replace(/\s+\+\s+/g, ' ').replace(/\s+/g, ' ');
assert.ok(/JOIN contract_delegation_rotations r ON r\.stake_action_index = t\.action_index WHERE r\.target_table = \? AND r\.block_index BETWEEN \? AND \?/.test(src),
'updatedRows.js must select rotated stake rows through the contract_delegation_rotations journal keyed by target_table and block_index window (the same journal ClientRollback restores from)');
@@ -1289,13 +1299,13 @@ describe('Rollback coverage guard @regression', function(){
const { collectUpdatedRows } = require('../../src/server/updated_rows');
// Fake DB: the anchor self-join query returns a v1 parent stamped invalid_archive.
let anchorParent = { action_index: 301, version: 1, status_id: 99, match_batch_seq: 7 };
- let db = {
+ let db = withDbMixins({
dbType: 'indexer',
doQuery: async (sql) => {
if(sql.indexOf('anchor_actions p') !== -1) return [anchorParent];
return [];
}
- };
+ });
let out = await collectUpdatedRows(db, 300, 300, null);
assert.ok(Array.isArray(out.anchor_actions) && out.anchor_actions.length === 1,
'collectUpdatedRows must return the anchor_actions parent row for the CRC-failure window');
diff --git a/test/unit/schema_generated_columns_prototype_keys.test.js b/test/unit/schema_generated_columns_prototype_keys.test.js
new file mode 100644
index 00000000..78f2a5c7
--- /dev/null
+++ b/test/unit/schema_generated_columns_prototype_keys.test.js
@@ -0,0 +1,44 @@
+/********************************************************************
+ *
+ * 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.
+ *
+ ********************************************************************
+ * test/unit/schema_generated_columns_prototype_keys.test.js
+ *
+ * generatedColumns(table) used a plain `GENERATED_COLUMNS[table]` lookup, so a
+ * prototype key such as `constructor` resolved through Object.prototype to a
+ * function, and `new Set(function)` throws instead of yielding an empty Set. An
+ * unknown table name must always answer an empty Set, prototype keys included.
+ */
+
+'use strict';
+
+const assert = require('assert');
+
+const { generatedColumns } = require('../../src/schema/generated_columns');
+
+describe('generatedColumns: prototype-key table names @regression', function(){
+
+ it('returns an empty Set for constructor, __proto__ and toString', function(){
+ assert.strictEqual(generatedColumns('constructor').size, 0);
+ assert.strictEqual(generatedColumns('__proto__').size, 0);
+ assert.strictEqual(generatedColumns('toString').size, 0);
+ });
+
+ it('still returns contract_state\'s own generated column', function(){
+ assert.deepStrictEqual([...generatedColumns('contract_state')], ['state_key_bin']);
+ });
+
+ it('caches the same Set across calls for a prototype-key table', function(){
+ const first = generatedColumns('constructor');
+ assert.strictEqual(generatedColumns('constructor'), first);
+ });
+});
diff --git a/test/unit/shutdown.test.js b/test/unit/shutdown.test.js
index 99f56784..2548c110 100644
--- a/test/unit/shutdown.test.js
+++ b/test/unit/shutdown.test.js
@@ -135,6 +135,8 @@ describe('graceful shutdown', function(){
});
it('stays under Docker\'s 10s default stop grace', function(){
+ // xchain-node stamps a 30 s budget; docker's ten seconds is the bound
+ // left on a container created before that budget existed.
assert.ok(DEFAULT_SHUTDOWN_TIMEOUT_MS < 10000);
});
});
diff --git a/test/unit/snapshot_builder.test/04_stream_dispensers.test.js b/test/unit/snapshot_builder.test/04_stream_dispensers.test.js
index eec891f8..27c067f9 100644
--- a/test/unit/snapshot_builder.test/04_stream_dispensers.test.js
+++ b/test/unit/snapshot_builder.test/04_stream_dispensers.test.js
@@ -45,7 +45,7 @@ function streamDispensersPagingTests(){
it('rejects a non-decoder dbType with 400', async function(){
let db = createMockDb(); // dbType defaults to indexer-shaped (undefined)
let res = createMockRes();
- await builder.streamDispensers(db, NaN, NaN, 50000, res);
+ await builder.streamDispensers(db, NaN, NaN, res);
assert.ok(res.status.calledWith(400), 'dispensers reconcile is decoder-only');
});
@@ -59,7 +59,7 @@ function streamDispensersPagingTests(){
res.setHeader = sinon.stub();
await new Promise((resolve) => {
res.on('finish', resolve);
- builder.streamDispensers(db, NaN, NaN, 50000, res);
+ builder.streamDispensers(db, NaN, NaN, res);
});
let parsed = JSON.parse(zlib.gunzipSync(Buffer.concat(chunks)).toString());
assert.strictEqual(parsed.rows.length, 2);
@@ -84,14 +84,14 @@ function streamDispensersPagingTests(){
res.setHeader = sinon.stub();
await new Promise((resolve) => {
res.on('finish', resolve);
- builder.streamDispensers(db, 8, 1, 3, res);
+ builder.streamDispensers(db, 8, 1, res);
});
let parsed = JSON.parse(zlib.gunzipSync(Buffer.concat(chunks)).toString());
assert.strictEqual(parsed.has_more, false,
'has_more is always false so an old paging client completes in one round trip');
let q = db.doQueryStrict.firstCall.args[0];
assert.ok(/WHERE \(tx_index > \? OR \(tx_index = \? AND address_id > \?\)\)/.test(q), 'composite keyset predicate');
- assert.ok(!/LIMIT/.test(q), 'legacy limit arg is ignored: no paging');
+ assert.ok(!/LIMIT/.test(q), 'no paging within the cursor branch either');
assert.deepStrictEqual(db.doQueryStrict.firstCall.args[1], [8, 8, 1]);
});
}
@@ -113,7 +113,7 @@ function streamDispensersFailureTests(){
res.setHeader = sinon.stub();
await assert.rejects(
- () => builder.streamDispensers(db, NaN, NaN, 50000, res),
+ () => builder.streamDispensers(db, NaN, NaN, res),
/errno 1205/,
'the read error must reach the route handler, which answers 500');
assert.strictEqual(res.setHeader.called, false, 'no response headers before the read succeeds');
@@ -129,7 +129,7 @@ function streamDispensersFailureTests(){
res.setHeader = sinon.stub();
await assert.rejects(
- () => builder.streamDispensers(db, 8, 1, 3, res),
+ () => builder.streamDispensers(db, 8, 1, res),
/errno 1205/);
});
}
diff --git a/test/unit/snapshot_builder.test/07_paging_stream_client_abort.test.js b/test/unit/snapshot_builder.test/07_paging_stream_client_abort.test.js
index 9488cb5c..786361f3 100644
--- a/test/unit/snapshot_builder.test/07_paging_stream_client_abort.test.js
+++ b/test/unit/snapshot_builder.test/07_paging_stream_client_abort.test.js
@@ -72,7 +72,7 @@ function pagingStreamAbortTests(){
sinon.stub(zlib, 'createGzip').returns(fake);
let res = new PassThrough(); res.setHeader = sinon.stub(); res.on('data', () => {});
- let p = builder.streamDispensers(db, NaN, NaN, 50000, res);
+ let p = builder.streamDispensers(db, NaN, NaN, res);
await new Promise(r => setImmediate(r));
res.emit('close');
await p;
diff --git a/test/unit/snapshot_builder.test/11_stream_incremental_snapshot_snapshot_replication_tables.test.js b/test/unit/snapshot_builder.test/11_stream_incremental_snapshot_snapshot_replication_tables.test.js
index 78bd89d3..5e427ae8 100644
--- a/test/unit/snapshot_builder.test/11_stream_incremental_snapshot_snapshot_replication_tables.test.js
+++ b/test/unit/snapshot_builder.test/11_stream_incremental_snapshot_snapshot_replication_tables.test.js
@@ -41,40 +41,66 @@ function restoreSnapshotBuilder(){
// pubkeys rides NO incremental snapshot and freezes at bootstrap
// height on every incrementally-caught-up follower, invisibly (it is excluded
// from the /status count check and is not consensus-hashed).
+//
+// It is full-dumped instead, paged by its address_id PRIMARY KEY inside the one
+// read view: a bare SELECT * would materialize the whole table per catch-up.
+const PUBKEYS = [{ address_id: 7, pubkey: 'pk7' }, { address_id: 9, pubkey: 'pk9' }, { address_id: 12, pubkey: 'pk12' }];
+
+// Serve pubkeys by address_id page, 1054 on any action-scoped read, and log every query.
+function pubkeysDb(queries){
+ let db = createMockDb();
+ db.dbType = 'indexer';
+ db.getLastBlock.resolves(100);
+ db.getBlockHashRow.resolves({ ledger_hash: 'l', actions_hash: 'a', contract_hash: 'c' });
+ // Non-null: the action_index fallback is reachable precisely here.
+ db.getFirstActionIndex.resolves(500);
+ db.doQuery.callsFake(async (query, args) => {
+ if(query.includes('information_schema')) return [{ table_name: 'pubkeys' }];
+ queries.push(query);
+ if(/SELECT \* FROM `pubkeys` WHERE `address_id` > \? ORDER BY `address_id` ASC LIMIT \?/.test(query))
+ return PUBKEYS.filter(r => r.address_id > args[0]).slice(0, args[1]);
+ if(query.includes('`pubkeys`') && query.includes('action_index')){
+ let e = new Error("Unknown column 'action_index' in 'where clause'");
+ e.errno = 1054;
+ throw e;
+ }
+ return [];
+ });
+ return db;
+}
+
+// Stream one incremental snapshot and return its parsed body.
+async function streamParsed(db, opts){
+ let res = new PassThrough();
+ let chunks = [];
+ res.on('data', c => chunks.push(c));
+ res.setHeader = sinon.stub();
+ await new Promise((resolve) => { res.on('finish', resolve); builder.streamIncrementalSnapshot(db, 80, res, undefined, opts); });
+ return JSON.parse(zlib.gunzipSync(Buffer.concat(chunks)).toString());
+}
+
function streamIncrementalSnapshotReplicationTests(){
it('full-dumps indexer pubkeys instead of action-scoping it into errno 1054', async function(){
- let db = createMockDb();
- db.dbType = 'indexer';
- db.getLastBlock.resolves(100);
- db.getBlockHashRow.resolves({ ledger_hash: 'l', actions_hash: 'a', contract_hash: 'c' });
- // Non-null: the action_index fallback is reachable precisely here.
- db.getFirstActionIndex.resolves(500);
let queries = [];
- db.doQuery.callsFake(async (query) => {
- if(query.includes('information_schema')) return [{ table_name: 'pubkeys' }];
- queries.push(query);
- if(/SELECT \* FROM `pubkeys`\s*$/.test(query.trim())){
- return [{ address_id: 7, pubkey: 'pk7' }, { address_id: 9, pubkey: 'pk9' }];
- }
- if(query.includes('`pubkeys`') && query.includes('action_index')){
- let e = new Error("Unknown column 'action_index' in 'where clause'");
- e.errno = 1054;
- throw e;
- }
- return [];
- });
-
- let res = new PassThrough();
- let chunks = [];
- res.on('data', c => chunks.push(c));
- res.setHeader = sinon.stub();
- await new Promise((resolve) => { res.on('finish', resolve); builder.streamIncrementalSnapshot(db, 80, res); });
-
- let parsed = JSON.parse(zlib.gunzipSync(Buffer.concat(chunks)).toString());
- assert.deepStrictEqual(parsed.tables.pubkeys.map(r => r.address_id), [7, 9], 'pubkeys rides the incremental payload');
+ let parsed = await streamParsed(pubkeysDb(queries));
+ assert.deepStrictEqual(parsed.tables.pubkeys.map(r => r.address_id), [7, 9, 12], 'pubkeys rides the incremental payload');
assert.ok(!queries.some(q => q.includes('`pubkeys`') && q.includes('action_index')),
'pubkeys is never action-scoped (that query 1054s and is swallowed)');
});
+
+ it('pages indexer pubkeys by address_id rather than one unbounded SELECT *', async function(){
+ builder.pageSize = 2;
+ let queries = [];
+ let parsed = await streamParsed(pubkeysDb(queries));
+ assert.deepStrictEqual(parsed.tables.pubkeys.map(r => r.address_id), [7, 9, 12], 'every page lands, none twice');
+ assert.ok(!queries.some(q => /SELECT \* FROM `pubkeys`\s*$/.test(q.trim())), 'no unbounded read of pubkeys');
+ assert.strictEqual(queries.filter(q => q.includes('`address_id` >')).length, 2, 'two pages of at most 2 rows');
+ });
+
+ it('still streams indexer pubkeys under skip_lookups, which has no out-of-band route for it', async function(){
+ let parsed = await streamParsed(pubkeysDb([]), { skipLookups: true });
+ assert.deepStrictEqual(parsed.tables.pubkeys.map(r => r.address_id), [7, 9, 12]);
+ });
}
function snapshotBuilderTests(){
diff --git a/test/unit/stakes_validator_set_parity.test.js b/test/unit/stakes_validator_set_parity.test.js
index 42ea420a..85b6a0a9 100644
--- a/test/unit/stakes_validator_set_parity.test.js
+++ b/test/unit/stakes_validator_set_parity.test.js
@@ -17,7 +17,7 @@
// In the active light-client class (STATE_COMMITMENT regtest/testnet = block 0),
// ClientApplier.computeFollowerRoots recomputes stakes_root every block via
// gatherStakeEntries, and ClientSync halts on any state_root mismatch. The leaf
-// encoders (merkle.js) and _stakeWeightsSql are byte-guarded, but the *inputs*
+// encoders (merkle.js) and stakeWeightsSql are byte-guarded, but the *inputs*
// to gatherStakeEntries were not: BTC_STAKE_CAPABILITIES (the capability set and
// per-cap MIN_STAKE floors) and VALIDATOR_QUERY_LIMIT are hand-mirrored from the
// indexer's src/coins/BTC.js. A future indexer edit (a sixth capability, a
diff --git a/test/unit/sync_service.test.js b/test/unit/sync_service.test.js
index 69dbfb63..b4aad5d1 100644
--- a/test/unit/sync_service.test.js
+++ b/test/unit/sync_service.test.js
@@ -33,13 +33,6 @@ const ServerPoller = require('../../src/server/poller');
const fs = require('fs');
const path = require('path');
-function indexerCfg(over){
- return Object.assign({
- coin: 'bitcoin', network: 'mainnet', dbType: 'indexer',
- db_host: 'srchost', db_port: 3306, db_name: 'btc_idx', db_user: 'u', db_pass: 'p'
- }, over || {});
-}
-
let service, config;
function registerHooks(){
diff --git a/test/unit/table_content_parity.test/05_operational_log_exclusion.test.js b/test/unit/table_content_parity.test/05_operational_log_exclusion.test.js
new file mode 100644
index 00000000..8a7638e3
--- /dev/null
+++ b/test/unit/table_content_parity.test/05_operational_log_exclusion.test.js
@@ -0,0 +1,102 @@
+// 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.
+//
+// The operational-log boundary: a table the row-count check declares unconvergent
+// must not be content-compared either, or an id-offset replica reports it as
+// "content diverged at equal row count".
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const BlockHasher = require('../../../src/client/block_hasher');
+const HashVerifier = require('../../../src/client/hash_verifier');
+const Utility = require('../../../src/util');
+const replicatedTables = require('../../../src/schema/replicated_tables');
+
+// Serve canned decoder rows by id window; every listed table exists.
+function decoderDb(tables){
+ return {
+ dbType: 'decoder',
+ async listExistingTables(){ return new Set(Object.keys(tables)); },
+ async getContentWindowRows(table){ return (tables[table] || []).slice(); },
+ async getMaxRowId(table){
+ let rows = tables[table] || [];
+ return rows.length ? Math.max(...rows.map(r => Number(r.id))) : null;
+ },
+ async getContentIdWindowRows(table, fromId, toId){
+ return (tables[table] || []).filter(r => Number(r.id) > fromId && Number(r.id) <= toId);
+ }
+ };
+}
+
+// Return the source and follower checksum maps, the follower reusing the source ceilings.
+async function sourceAndFollower(sourceTables, followerTables){
+ let src = await new BlockHasher(decoderDb(sourceTables), new Utility()).computeTableContentChecksums(9, { window: 5 });
+ let idBounds = {};
+ for(let [table, entry] of Object.entries(src.tables)) if(entry.id_max !== undefined) idBounds[table] = entry.id_max;
+ let local = await new BlockHasher(decoderDb(followerTables), new Utility())
+ .computeTableContentChecksums(9, { window: 5, idBounds: idBounds });
+ return { src, local };
+}
+
+describe('Advisory table-content parity', function(){
+
+ describe('operational-log exclusion', function(){
+
+ it('no content-parity plan compares a table the count check declares unconvergent', function(){
+ assert.ok(replicatedTables.OPERATIONAL_LOG_TABLES.includes('events'));
+ for(const dbType of ['indexer', 'decoder']){
+ let planned = new Set(replicatedTables.contentParityPlan(dbType).map(p => p.table));
+ let excluded = replicatedTables.contentParityExclusions(dbType);
+ let replicated = new Set(replicatedTables.getReplicatedTables(dbType));
+ for(const table of replicatedTables.OPERATIONAL_LOG_TABLES){
+ assert.ok(!planned.has(table), dbType + ' plan must not content-compare ' + table);
+ if(replicated.has(table))
+ assert.match(excluded[table] || '', /^operational log: /, dbType + ' ' + table + ' needs a declared reason');
+ }
+ }
+ });
+
+ it('stays scoped to the operational logs: the decoder lookups are still compared', function(){
+ let decoder = {};
+ for(let p of replicatedTables.contentParityPlan('decoder')) decoder[p.table] = p.bound;
+ for(const table of ['index_addresses', 'index_transactions', 'pubkeys'])
+ assert.strictEqual(decoder[table], 'id', table + ' must stay in the decoder plan');
+ });
+
+ it('the count check reads the same declaration rather than a hand list', function(){
+ let src = fs.readFileSync(path.join(__dirname, '../../../src/client/sync.js'), 'utf8');
+ assert.ok(/new Set\(replicatedTables\.OPERATIONAL_LOG_TABLES\)/.test(src),
+ 'ClientSync must build its count exclusion from replicated_tables.js');
+ assert.ok(!/new Set\(\['events'\]\)/.test(src), 'a second hand-listed copy can drift from the plan');
+ });
+
+ it('an id-offset replica with equal window counts raises no content mismatch', async function(){
+ let lookups = [{ id: 1, address: 'a' }, { id: 2, address: 'b' }];
+ let { src, local } = await sourceAndFollower(
+ { index_addresses: lookups, events: [{ id: 7, code: 'REORG', data: '[9]' }] },
+ { index_addresses: lookups, events: [{ id: 7, code: 'LOCAL', data: 'own row' }] });
+ assert.ok(!('events' in src.tables) && !('events' in local.tables), 'events must not be published or recomputed');
+
+ let res = new HashVerifier().compareTableContent(9, local, src);
+ assert.deepStrictEqual(res.mismatches, []);
+ assert.strictEqual(res.compared, 1, 'the lookup beside it is still compared');
+ });
+
+ it('a real lookup divergence on the same replica is still reported', async function(){
+ let { src, local } = await sourceAndFollower(
+ { index_addresses: [{ id: 1, address: 'a' }] },
+ { index_addresses: [{ id: 1, address: 'forged' }] });
+ let res = new HashVerifier().compareTableContent(9, local, src);
+ assert.deepStrictEqual(res.mismatches.map(m => m.table), ['index_addresses']);
+ });
+ });
+});
diff --git a/test/unit/updated_rows.test.js b/test/unit/updated_rows.test.js
index c732104d..cd1629e7 100644
--- a/test/unit/updated_rows.test.js
+++ b/test/unit/updated_rows.test.js
@@ -52,11 +52,11 @@ describe('updatedRows.collectUpdatedRows', function(){
let hitDeactivation = db.calls.some(c => c.sql.indexOf('deactivation_block') !== -1);
assert.strictEqual(hitDeactivation, false);
// The slash + delegation-rotation + request_status + poll-finalize + cooldown-status
- // + bet-status + anchor_invalid + attest-batch-head + tokens-supply classes still run,
- // none of which depend on the activation delay (4 slash + 2 rotation + 2 request
- // + 1 poll + 2 cooldown-status + 2 bet-status + 1 anchor + 1 attest batch head
- // + 1 tokens = 16).
- assert.strictEqual(db.calls.length, 16);
+ // + bet-status + anchor_invalid + attest-batch-head + tokens-supply + tokens-edit
+ // classes still run, none of which depend on the activation delay (4 slash
+ // + 2 rotation + 2 request + 1 poll + 2 cooldown-status + 2 bet-status + 1 anchor
+ // + 1 attest batch head + 1 tokens supply + 1 tokens edit = 17).
+ assert.strictEqual(db.calls.length, 17);
// And the cooldown status flip is keyed by cooldown_end_block, not the delay.
let hitCooldown = db.calls.some(c => c.sql.indexOf('cooldown_end_block') !== -1);
assert.strictEqual(hitCooldown, true);
@@ -172,6 +172,50 @@ describe('updatedRows.collectUpdatedRows', function(){
});
});
+describe('updatedRows.collectUpdatedRows', function(){
+
+ afterEach(() => sinon.restore());
+
+ it('refreshes a surviving tokens row edited by an ISSUE that moved no balance (#39)', async function(){
+ // The reported shape: an ownership transfer (ISSUE|0|||||||) rewrites
+ // tokens.owner_id in place but writes no credit / debit / escrow row in its own tick,
+ // so the ledger-keyed supply class above sees nothing and the action-scoped stream
+ // carries only the new `issues` row (tokens.action_index stays at the first issuance).
+ // Every follower served the PRE-TRANSFER owner until some later action on the tick
+ // happened to move a balance. Routed on the issues subquery alone, with NO route for
+ // the supply class, so this asserts the edit rides its own class.
+ let db = fakeDb([
+ { match: 'SELECT i.tick_id FROM issues i', rows: [{ id: 5, tick_id: 42, action_index: 100, last_action_index: 100, owner_id: 77 }] }
+ ]);
+ let out = await collectUpdatedRows(db, 300, 300, 6);
+ let eq = db.calls.find(c => c.sql.indexOf('SELECT i.tick_id FROM issues i') !== -1);
+ assert.ok(eq, 'expected the tokens-edit refresh query');
+ assert.deepStrictEqual(eq.args, [300, 300]);
+ // Keyed on the ISSUE action's block, and only a VALID ISSUE re-derives the row.
+ assert.ok(eq.sql.indexOf('JOIN actions a ON a.action_index = i.action_index') !== -1);
+ assert.match(eq.sql, /JOIN index_statuses s ON s\.id = i\.status_id AND s\.status = 'valid'/);
+ assert.ok(eq.sql.indexOf('a.block_index BETWEEN ? AND ?') !== -1);
+ // SELECT t.* carries the source `id` so the follower's upsert lands on the PK.
+ assert.ok(eq.sql.indexOf('SELECT t.*') !== -1);
+ assert.ok(out.tokens && out.tokens.length === 1);
+ assert.strictEqual(out.tokens[0].owner_id, 77);
+ });
+
+ it('emits one tokens row when the edit and supply classes both reach the same tick', async function(){
+ // A MINT-bearing re-issuance moves a balance AND rewrites the row, so both token
+ // classes return it. add() dedups by the row's UNIQUE action_index (pinned at the
+ // first issuance), exactly as it does for a stake that was both deactivated and
+ // slashed, so the payload carries the tick once.
+ let db = fakeDb([
+ { match: 'SELECT i.tick_id FROM issues i', rows: [{ id: 5, tick_id: 42, action_index: 100, owner_id: 77, supply: '1000' }] },
+ { match: 'FROM `tokens` t WHERE t.tick_id IN', rows: [{ id: 5, tick_id: 42, action_index: 100, owner_id: 77, supply: '1000' }] }
+ ]);
+ let out = await collectUpdatedRows(db, 300, 300, 6);
+ assert.strictEqual(out.tokens.length, 1);
+ assert.strictEqual(out.tokens[0].action_index, 100);
+ });
+});
+
describe('updatedRows.collectUpdatedRows', function(){
afterEach(() => sinon.restore());