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

- Version + Version Tests Node License 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());