diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..1f374249 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "attribution": false, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "git config user.name 'J-Dog' && git config user.email 'j-dog@j-dog.net' || true", + "timeout": 10, + "statusMessage": "Setting the repo git identity" + } + ] + } + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 24811403..f83cd92c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.23.0] - 2026-10-11 + +### Added +- Added dispenser address collapse, purge grace and expiration extension undo, each behind an activation. +- Enforced newest-first protocol time windows and the protocol time history bound. + +### Changed +- Required unsigned BIGINT transaction id columns and asserted them at startup. +- Pinned the BIGINT migration checksum rebaseline so the widened ids migration heals in place. + +### Fixed +- Preserved stale mempool read metadata and kept a known reorg halt on a database ping failure. +- Validated block hashes from the node and carried JSON-RPC error codes on HTTP 200 errors. + ## [0.21.0] - 2026-09-29 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cfdad8e5..3a415bc8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,6 +125,7 @@ CI runs the no-external-services gate, the docker tiers, the cross-repo drift gu 2. Update `CHANGELOG.md` with a terse entry for your change. 3. Make sure `git status` is clean apart from intended changes (no `node_modules/`, no editor leftovers, no `.env`). 4. Open the PR with a clear title and a description of what changed and why. +5. Agree to the Contributor License Agreement when the CLA Assistant bot links it on your first PR. Its `license/cla` check must pass before merge, so a PR from a contributor who has not signed cannot be merged. One signature covers every XChain Platform repository. For non-security bugs, open an issue at . For security bugs, see [`SECURITY.md`](./SECURITY.md). diff --git a/Dockerfile b/Dockerfile index dbc56620..5df2b448 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,14 @@ -FROM node:25.9.0 - -RUN mkdir /XChainDecoder/ +FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS dependencies +WORKDIR /XChainDecoder COPY ./package.json /XChainDecoder/package.json COPY ./package-lock.json /XChainDecoder/package-lock.json -WORKDIR /XChainDecoder RUN npm ci --omit=dev +FROM node:25.9.0@sha256:78839ac448c23517f8eab2e8f7943d9b4f73979eb7f8bed2c73dbf72ff869e7b AS decoder +WORKDIR /XChainDecoder +COPY ./package.json /XChainDecoder/package.json +COPY ./package-lock.json /XChainDecoder/package-lock.json +COPY --from=dependencies /XChainDecoder/node_modules /XChainDecoder/node_modules COPY ./src /XChainDecoder/src # Patch bitcoinjs-lib's bufferutils with a BigInt-aware 64-bit reader. The stock # readUInt64 throws "RangeError: value out of range" for output values above @@ -24,4 +27,4 @@ COPY ./src/chain/bufferutils.js /XChainDecoder/node_modules/bitcoinjs-lib/src/bu # xchain-encoder/Dockerfile). This image registers real drain work on SIGTERM # (src/api.js: flip decoderRunning, then decoder.stop()), which only runs when # node is PID 1 and receives the signal itself. -CMD ["node", "./src/api.js"] \ No newline at end of file +CMD ["node", "./src/api.js"] diff --git a/NOTICE.md b/NOTICE.md index ed913df8..bbf40b84 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -21,8 +21,10 @@ For commercial licensing, contact **legal@dankest.llc**. ## Attribution -Any redistribution or modification of this software must retain the following -attribution, per the AGPL and the project's trademark policy: +As an additional term under section 7(b) of the GNU Affero General Public +License v3.0, any redistribution or modification of this software must preserve +the following author attribution in its source and in the Appropriate Legal +Notices displayed by any work containing it: > Based on XChain Platform by Dankest, LLC - https://dankest.llc diff --git a/README.md b/README.md index 3a551da4..6a25ed57 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Transaction extraction service for the XChain Platform. Polls the coin nodes of - **ACTION validation**: 34-name allowlist plus 5 short-form aliases (e.g. TRANSFER -> SEND) expanded before database writes - **Parse-failure quarantine**: tx-level decode failures retry up to 3 times, then are quarantined as PARSE_ERROR events rather than halting the block - **Source pubkey capture**: records the source address pubkey per transaction in a dedicated table for downstream use by the indexer -- **Native-coin fee tracking**: when FEE_DESTINATION is set, outputs paying that address are persisted to transaction_outputs for indexer fee validation +- **Native-coin fee tracking**: outputs paying the coin registry's FEE_DESTINATION are persisted to transaction_outputs for indexer fee validation; on regtest only, `XCHAIN_FEE_DESTINATION__REGTEST` redirects it for the decoder and indexer alike, and a bare `FEE_DESTINATION` env is ignored with a warning - **Graceful shutdown**: SIGTERM/SIGINT handlers complete in-flight work - **Node RPC failover**: rotates through `NODE_URL_FALLBACK` endpoints after `NODE_FAILOVER_THRESHOLD` consecutive connection failures, round-robin, so a recovered primary is retried again if the fallback also dies - **1300+ tests**: unit, integration, e2e, security, fuzz, chaos, mutation, regression, benchmarks, smoke diff --git a/bin/ci-full.sh b/bin/ci-full.sh index 13b98936..7049b653 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -89,6 +89,7 @@ ci_tier_deferred() { # >>> ci-tier timer (generated block; re-run the tier wirer to update) >>> run_tier() { ci_tier_deferred "$1" && return 0 # ci-tier guard (generated) + if [ "${CI_TIER:-full}" = "fast" ] && [ -n "${FAILED:-}" ]; then echo; echo "ci:full ===== $1 NOT RUN (a push stops at its first red tier, its verdict already red; the full sweep runs it) ====="; return 0; fi # ci-tier stop (generated) local name="$1"; shift local __ci_tier_t0=$SECONDS echo; echo "ci:full ===== $name =====" diff --git a/bin/lib/migration_residue_pin.js b/bin/lib/migration_residue_pin.js new file mode 100644 index 00000000..ea7781e8 --- /dev/null +++ b/bin/lib/migration_residue_pin.js @@ -0,0 +1,140 @@ +#!/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. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * The migration immutability pin: two sha256 values per committed migration in + * test/fixtures/migration-executable-residue.json, so an in-place edit to an + * applied file fails in CI instead of surfacing on a fleet as `content CHANGED`. + * Ported from the indexer's pin of the same name; the decoder's runner hashes + * migrations the same way (whole-file sha256 in schema_migrations). + * + * residue the file with every `--` line and blank line dropped (the basis the + * MIGRATION_CHECKSUM_REBASELINES entries are verified on). A change + * here is an executable edit and always fails. + * sha256 the whole file, exactly what schema_migrations records. A change here + * passes only when a reviewed rebaseline heals the pinned hash to the + * current one, since that is the only case a deployed DB survives. + * + * A file the pin does not hold yet passes when its name sorts after the newest pinned + * migration, so a new migration needs no fixture edit; `--add` pins it. A file that + * sorts before the newest pinned migration fails because it was missed or backdated. + * An existing entry moves only through `--accept `, which is legitimate solely + * for a file no database has applied. + * + * USAGE (from the repo root) + * node bin/lib/migration_residue_pin.js check; exit 1 on a violation + * node bin/lib/migration_residue_pin.js --add pin every unpinned file + * node bin/lib/migration_residue_pin.js --accept [--accept ] + * + ********************************************************************/ + +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +// Loads the rebaseline table only; requiring src/db opens no connection and reads no env. +const { MIGRATION_CHECKSUM_REBASELINES } = require('../../src/db'); + +const ROOT = path.join(__dirname, '..', '..'); +const MIG_DIR = path.join(ROOT, 'src', 'sql', 'migrations'); +const FIXTURE = path.join(ROOT, 'test', 'fixtures', 'migration-executable-residue.json'); + +const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex'); + +function executableResidue(raw){ + return String(raw).split('\n').filter((l) => !/^\s*--/.test(l) && l.trim() !== '').join('\n'); +} + +function pinOf(raw){ + return { residue: sha256(executableResidue(raw)), sha256: sha256(raw) }; +} + +// Every violation of the pin over `files` (name -> raw text), as { file, kind, message }. +function findViolations({ files, fixture, rebaselines }){ + const out = []; + const newest = Object.keys(fixture).sort().pop() || ''; + for(const file of Object.keys(files).filter((f) => !Object.hasOwn(fixture, f) && f < newest).sort()){ + out.push({ file, kind: 'unpinned', message: file + ' sorts before the newest pinned migration ' + newest + + ' but is not pinned: run node bin/lib/migration_residue_pin.js --add and commit the fixture.' }); + } + for(const file of Object.keys(fixture).sort()){ + const pinned = fixture[file]; + // Deleting or renaming an applied migration is forbidden: the decoder has no ledger rename map. + if(!Object.hasOwn(files, file)){ + out.push({ file, kind: 'missing', message: file + ' is pinned but gone: deleting or renaming an applied ' + + 'migration breaks every ledger that recorded it.' }); + continue; + } + const now = pinOf(files[file]); + // A changed statement is never healable; it needs a new dated migration. + if(now.residue !== pinned.residue){ + out.push({ file, kind: 'residue', message: file + ': the executable statements of a pinned migration ' + + 'changed. Revert and write a new dated migration; MIGRATION_CHECKSUM_REBASELINES heals the ledger only.' }); + continue; + } + // A comment-only change passes only under a rebaseline healing exactly the pinned hash to the current one. + const heal = rebaselines && rebaselines[file]; + const healed = heal && heal.to === now.sha256 && [].concat(heal.from).includes(pinned.sha256); + if(now.sha256 !== pinned.sha256 && !healed){ + out.push({ file, kind: 'content', message: file + ': content changed with no rebaseline from ' + + pinned.sha256.slice(0, 12) + ' to ' + now.sha256.slice(0, 12) + ', so every DB that applied it logs ' + + '`content CHANGED` and migrate.js fails closed there. Revert, or add a reviewed comment-only rebaseline.' }); + } + } + return out; +} + +// Every committed migration (`.sql` only; the README beside them is not one), name -> raw text. +function readTree(){ + const files = {}; + for(const f of fs.readdirSync(MIG_DIR).filter((n) => n.endsWith('.sql'))) files[f] = fs.readFileSync(path.join(MIG_DIR, f), 'utf8'); + return files; +} + +function writeFixture(fixture){ + const sorted = {}; + for(const k of Object.keys(fixture).sort()) sorted[k] = fixture[k]; + fs.writeFileSync(FIXTURE, JSON.stringify(sorted, null, 2) + '\n'); +} + +function main(argv){ + const files = readTree(); + const fixture = JSON.parse(fs.readFileSync(FIXTURE, 'utf8')); + const accept = argv.flatMap((a, i) => (a === '--accept' ? [argv[i + 1]] : [])); + if(argv.includes('--add') || accept.length){ + const added = Object.keys(files).filter((f) => !fixture[f]); + if(argv.includes('--add')) for(const f of added) fixture[f] = pinOf(files[f]); + for(const f of accept){ + if(!files[f]) throw new Error('--accept ' + f + ': no such migration'); + console.warn('ACCEPTED a new pin for ' + f + ': legitimate only if no database has applied it.'); + fixture[f] = pinOf(files[f]); + } + writeFixture(fixture); + console.log('pinned ' + (argv.includes('--add') ? added.length : 0) + ' new, accepted ' + accept.length + + '. Next: node bin/lib/migration_residue_pin.js'); + return 0; + } + const bad = findViolations({ files, fixture, rebaselines: MIGRATION_CHECKSUM_REBASELINES }); + for(const v of bad) console.log(v.kind + ': ' + v.message); + const unpinned = Object.keys(files).filter((f) => !fixture[f]).length; + console.log(bad.length + ' violation(s), ' + unpinned + ' unpinned file(s).' + + (unpinned ? ' Next: node bin/lib/migration_residue_pin.js --add' : '')); + return bad.length ? 1 : 0; +} + +module.exports = { executableResidue, pinOf, findViolations, readTree, FIXTURE }; + +if(require.main === module) process.exitCode = main(process.argv.slice(2)); diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index dacce5cc..e9270361 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -6,11 +6,14 @@ "E2E: ACTION Decoding ACTION payload edge cases A4.3:should handle ACTION with special characters in memo", "E2E: ACTION Decoding ACTION payload edge cases A4.4:should handle minimum-length ACTION" ], - "01650d3ce79e1914": [ - "XChainDecoder parse-loop quarantine quarantines a poison transaction after exhausting block retries", - "XChainDecoder parse-loop quarantine retries the whole block when parseTransaction throws transiently (no quarantine)", - "XChainDecoder parse-loop quarantine skips just the failing tx during a mempool update instead of aborting the cycle", - "XChainDecoder parse-loop quarantine survives a blockFromHex throw and retries the block instead of dying" + "033b7f903629733d": [ + "DISPENSER_PURGE_GRACE_ACTIVATION conformance is value-identical to the canonical map in xchain-documentation", + "DISPENSER_PURGE_GRACE_ACTIVATION conformance keeps mainnet and testnet unarmed while regtest is genesis-active" + ], + "0406fb865bdaa059": [ + "Database.destructiveAutoStatement() storage engine @regression flags a CREATE TABLE that names any engine other than InnoDB", + "Database.destructiveAutoStatement() storage engine @regression flags every ALTER TABLE that names an ENGINE, InnoDB included", + "Database.destructiveAutoStatement() storage engine @regression keeps InnoDB and engine-less CREATE TABLE and unrelated ALTERs auto-eligible" ], "0421fc1806b28494": [ "Database#parseExpectedIndexes() ignores CREATE INDEX text inside -- line comments", @@ -61,36 +64,6 @@ "07a542a6a7c8c758": [ "BATCH dispenser registration registration rides the SAME flag-day as payment-output capture both are off one second below the instant and on AT it" ], - "094df283a0e43bcb": [ - "src/sql schema parse-coverage @regression blocks.sql every parsed column has a name and a definition", - "src/sql schema parse-coverage @regression blocks.sql is parseable by the drift reconciler (non-null)", - "src/sql schema parse-coverage @regression blocks.sql yields at least one column", - "src/sql schema parse-coverage @regression dispensers.sql every parsed column has a name and a definition", - "src/sql schema parse-coverage @regression dispensers.sql is parseable by the drift reconciler (non-null)", - "src/sql schema parse-coverage @regression dispensers.sql yields at least one column", - "src/sql schema parse-coverage @regression events.sql every parsed column has a name and a definition", - "src/sql schema parse-coverage @regression events.sql is parseable by the drift reconciler (non-null)", - "src/sql schema parse-coverage @regression events.sql yields at least one column", - "src/sql schema parse-coverage @regression finds at least one table source (sanity)", - "src/sql schema parse-coverage @regression index_addresses.sql every parsed column has a name and a definition", - "src/sql schema parse-coverage @regression index_addresses.sql is parseable by the drift reconciler (non-null)", - "src/sql schema parse-coverage @regression index_addresses.sql yields at least one column", - "src/sql schema parse-coverage @regression index_transactions.sql every parsed column has a name and a definition", - "src/sql schema parse-coverage @regression index_transactions.sql is parseable by the drift reconciler (non-null)", - "src/sql schema parse-coverage @regression index_transactions.sql yields at least one column", - "src/sql schema parse-coverage @regression mempool_transactions.sql every parsed column has a name and a definition", - "src/sql schema parse-coverage @regression mempool_transactions.sql is parseable by the drift reconciler (non-null)", - "src/sql schema parse-coverage @regression mempool_transactions.sql yields at least one column", - "src/sql schema parse-coverage @regression pubkeys.sql every parsed column has a name and a definition", - "src/sql schema parse-coverage @regression pubkeys.sql is parseable by the drift reconciler (non-null)", - "src/sql schema parse-coverage @regression pubkeys.sql yields at least one column", - "src/sql schema parse-coverage @regression transaction_outputs.sql every parsed column has a name and a definition", - "src/sql schema parse-coverage @regression transaction_outputs.sql is parseable by the drift reconciler (non-null)", - "src/sql schema parse-coverage @regression transaction_outputs.sql yields at least one column", - "src/sql schema parse-coverage @regression transactions.sql every parsed column has a name and a definition", - "src/sql schema parse-coverage @regression transactions.sql is parseable by the drift reconciler (non-null)", - "src/sql schema parse-coverage @regression transactions.sql yields at least one column" - ], "09e84783d9b0b7ef": [ "Fuzz: DISPENSER parsing BATCH-like strings with embedded DISPENSER should handle batch: \"DISPENSER|0|A||||||B|||||3600;DISPENSER|0|C||||||D|||||7200...\"", "Fuzz: DISPENSER parsing BATCH-like strings with embedded DISPENSER should handle batch: \"DISPENSER|0|A||||||B|||||3600;SEND|0|XCHAIN|1000...\"", @@ -244,38 +217,39 @@ "Fuzz: removeObfuscation random data, valid txid should handle 100 random buffers", "Fuzz: removeObfuscation random txid + random data should handle 100 fully random inputs" ], + "0c587a78e35ab1a8": [ + "rpcResult errors attaches a non-enumerable rpcCode and rpcMessage to an HTTP-200 RPC error", + "rpcResult errors leaves rpcMessage undefined for a non-string message and rpcCode undefined for no code", + "rpcResult errors returns a positive result", + "rpcResult errors returns a zero result", + "rpcResult errors serializes an RPC error without a string message", + "rpcResult errors throws only the label for a null response", + "rpcResult errors throws only the label for a null result", + "rpcResult errors throws only the label for an absent result", + "rpcResult errors throws the RPC code and message", + "sanitizeRpcError returns a string unchanged", + "sanitizeRpcError returns null as a string", + "sanitizeRpcError returns the message of an error without a response", + "sanitizeRpcError scrubs transport details and preserves RPC details" + ], "0c5f9fce59434a2d": [ "Taproot envelope source pubkey capture records nothing when the commit input exposes no key (key-path spend)", "Taproot envelope source pubkey capture records the commit funder key, never the 65-byte tapscript, for a 20-byte payload" ], + "0c8454135e113a90": [ + "XChainDecoder fee destination @unit keeps a real fee destination address", + "XChainDecoder fee destination @unit treats a missing fee destination as null", + "XChainDecoder fee destination @unit treats the unset placeholder as null (capture disabled)", + "resolveFeeDestination @unit defaults to the registry pin for every coin and network when no env override is set", + "resolveFeeDestination @unit falls back to env-only when the network is unrecognized", + "resolveFeeDestination @unit follows the registry XCHAIN_FEE_DESTINATION__REGTEST override on regtest", + "resolveFeeDestination @unit ignores a bare env override on regtest too, so capture and indexer validation resolve one address", + "resolveFeeDestination @unit ignores an env override on mainnet AND testnet and returns the pin (armed-federation fork guard)" + ], "0d5d819b92bebe35": [ "Boundary: Multisig Zero-Trim Edge Cases should keep all bytes when no trailing zeros exist", "Boundary: Multisig Zero-Trim Edge Cases should remove single trailing zero from multisig data" ], - "0da18e1ca9928fd3": [ - "Database schema-contract guards @regression accepts a pubkeys.pubkey wide enough for an uncompressed key", - "Database schema-contract guards @regression accepts both action-text columns already at utf8mb4", - "Database schema-contract guards @regression accepts dispensers.expiration at BIGINT UNSIGNED", - "Database schema-contract guards @regression accepts the stock MariaDB strict mode and STRICT_ALL_TABLES", - "Database schema-contract guards @regression distinguishes a dropped column (drift, throws) from an absent table (skip)", - "Database schema-contract guards @regression is a no-op when the pubkeys table does not exist yet", - "Database schema-contract guards @regression is a no-op when the tables do not exist yet", - "Database schema-contract guards @regression never points a drifted INTEGER column at the DATETIME converter migration", - "Database schema-contract guards @regression rejects NO_BACKSLASH_ESCAPES even under a strict mode", - "Database schema-contract guards @regression rejects a SIGNED bigint, which the old DATA_TYPE-only guard let through", - "Database schema-contract guards @regression rejects a half-migrated pair where only the mempool column lagged", - "Database schema-contract guards @regression rejects a lax mode, naming the quarantine divergence it causes", - "Database schema-contract guards @regression rejects a narrower INT UNSIGNED, naming the truncation against the indexer", - "Database schema-contract guards @regression rejects a narrower signed INT too", - "Database schema-contract guards @regression rejects a transactions.data still at utf8mb3, naming the quarantine it causes", - "Database schema-contract guards @regression rejects the pre-migration DATETIME and names the migration that converts it", - "Database schema-contract guards @regression rejects the pre-widen VARCHAR(66), naming the seam field it would corrupt", - "Database schema-contract guards @regression releases the pooled connection on both the pass and the throw path", - "Database schema-contract guards @regression releases the pooled connection on the expiration pass and throw paths", - "Database schema-contract guards @regression releases the pooled connection on the sql_mode pass and throw paths", - "Database schema-contract guards @regression releases the pooled connection on the utf8mb4 pass and throw paths", - "Database schema-contract guards @regression runs the pubkey guard on every runMigrations exit path, lock-skip included" - ], "0eb0aadd100cef95": [ "XChainDecoder#removeObfuscation() [REGRESSION P0] R-DEC-001: should decrypt a known XCHN payload correctly", "XChainDecoder#removeObfuscation() [REGRESSION P0] R-DEC-003: should decrypt non-XCHN data without error", @@ -396,18 +370,6 @@ "SQL DATETIME completeness pins direct and pooled database connections to UTC", "SQL DATETIME completeness sets UTC before converting mempool first_seen to DATETIME" ], - "155fe1323df6de21": [ - "compiled-push-size arbiter conformance OP_RETURN_PUSH_OVERHEAD is name-keyed and value-identical equals the vendored canonical protocol constant", - "compiled-push-size arbiter conformance OP_RETURN_PUSH_OVERHEAD is name-keyed and value-identical is exactly what compiledPushSize adds above the OP_PUSHDATA1 ceiling", - "compiled-push-size arbiter conformance dual-push summation matches the real compiled two-push script", - "compiled-push-size arbiter conformance matches bitcoin.script.compile byte-for-byte across the prefix boundaries", - "compiled-push-size arbiter conformance parity with the encoder compiledPushSize MAX constants stay equal across the two services", - "compiled-push-size arbiter conformance parity with the encoder compiledPushSize agrees with the decoder helper for every length up to the ceiling", - "compiled-push-size arbiter conformance parity with the encoder compiledPushSize envelope push band (0xffff .. ENVELOPE_MAX_PAYLOAD) envelopePushSize equals the real compiled push length across the band", - "compiled-push-size arbiter conformance parity with the encoder compiledPushSize envelope push band (0xffff .. ENVELOPE_MAX_PAYLOAD) re-measuring at the ceiling would under-count, which is why the decoder does not", - "compiled-push-size arbiter conformance parity with the encoder compiledPushSize envelope push band (0xffff .. ENVELOPE_MAX_PAYLOAD) the decoder helper under-counts by exactly 2 above 0xffff, and not below", - "compiled-push-size arbiter conformance the 8192 accept/drop boundary is where the encoder and the compiled script agree" - ], "15a40e4df3b0929a": [ "coverage ratchet floors documents the Docker-shipped non-owned bufferutils exclusion", "coverage ratchet floors enforces every declared floor, at the declared value", @@ -427,6 +389,25 @@ "Boundary: Combinatorial DISPENSER Scenarios BATCH with DISPENSER as second command: decoder does not parse it", "Boundary: Combinatorial DISPENSER Scenarios DISPENSER payload but getSourceFromOutput returns null: tx skipped" ], + "1924e1a0eff49f01": [ + "/live gates on the poll-loop heartbeat answers 200 while the loop is iterating, caught up and quiet", + "/live gates on the poll-loop heartbeat answers 503 the moment the running flag drops, ahead of the silence window", + "/live gates on the poll-loop heartbeat answers 503 when the DB ping fails, which the heartbeat must not mask", + "/live gates on the poll-loop heartbeat answers 503 when the loop stops iterating, with nothing else having changed", + "/live gates on the poll-loop heartbeat is not silent before the loop has iterated once, so a booting decoder stays 200", + "/live gates on the poll-loop heartbeat reports a latent REORG_HALT marker on the surface that is actually polled", + "/live gates on the poll-loop heartbeat reports the halt as a stable false, not an absent key, when there is no marker", + "/live gates on the poll-loop heartbeat stays 200 through a node-tip outage, so containers are not restarted", + "/live gates on the poll-loop heartbeat still answers 200 while halted, so autoheal cannot restart-loop a resync case", + "/live keeps a halt whose marker write failed past the probe TTL still reports reorg_halted with its reason after the re-probe reads no row", + "/live says whether the halt marker was ever read publishes a null reorg_halt_checked_at when no marker read has completed", + "/live says whether the halt marker was ever read publishes the read time once a marker read has completed", + "/live says whether the halt marker was ever read publishes who cleared a halt, so a cleared decoder is not read as never halted", + "a failed DB ping keeps the halt the decoder already knows /live reports the cached halt when the ping answers false rather than throwing", + "a failed DB ping keeps the halt the decoder already knows /live still reports the halt and the park while the ping throws", + "a failed DB ping keeps the halt the decoder already knows invents no halt: a clean decoder with a dead DB still reads not-halted and never-looked", + "a failed DB ping keeps the halt the decoder already knows the JSON-RPC health probe reports the cached halt while the ping throws" + ], "19e73caaa9b7c73f": [ "committed migrations declare intent @regression 2026-05-28-unique-index-tables.sql: carries a runner-visible `-- xchain:migration mode=auto|manual` tag", "committed migrations declare intent @regression 2026-05-28-unique-index-tables.sql: if tagged mode=auto, contains no destructive DDL", @@ -511,15 +492,6 @@ "dispenser-open gate: coin parity with the indexer [REGRESSION P1] R-DSP-002 rejects both-empty (the pre-fix OR gate also rejected this)", "dispenser-open gate: coin parity with the indexer derives the local coin ticker from the network key" ], - "1eae452ecbbf5562": [ - "Database#deleteBlockByIndex() [REGRESSION M-12] rolls back the block delete AND its marker together on failure", - "Database#deleteBlockByIndex() [REGRESSION M-12] writes the per-block REORG marker in the SAME transaction, before commit", - "Database#deleteBlockByIndex() a PARSE_ERROR audit row survives the rollback of its block (append-only events contract)", - "Database#deleteBlockByIndex() executes 4 DELETE queries and returns true on success", - "Database#deleteBlockByIndex() passes blockIndex to DELETE queries", - "Database#deleteBlockByIndex() throws on query error (propagates after rolling back)", - "Database#deleteBlockByIndex() writes NO event when called without a block hash (non-reorg delete stays a plain delete)" - ], "1f849fc3813cbd69": [ "CryptoNetworks #getBitcoinJsNetwork() [REGRESSION P2] R-NET-001: should return Dogecoin mainnet config with correct pubKeyHash", "CryptoNetworks #getBitcoinJsNetwork() [REGRESSION P2] R-NET-001: should return Litecoin mainnet config with bech32 prefix \"ltc\"", @@ -543,13 +515,51 @@ "CryptoNetworks #getFirstBlock() should return 4862500 for litecoin-testnet", "CryptoNetworks #getFirstBlock() should return 67847500 for dogecoin-testnet" ], - "1fea0088e53b0230": [ - "BATCH dispenser registration a top-level DISPENSER is untouched on both sides of the gate defaults an omitted EXPIRATION from the block time on both sides", - "BATCH dispenser registration a top-level DISPENSER is untouched on both sides of the gate registers a delegated create on GET_ADDRESS and records the create SOURCE", - "BATCH dispenser registration a top-level DISPENSER is untouched on both sides of the gate registers a top-level create above the gate", - "BATCH dispenser registration a top-level DISPENSER is untouched on both sides of the gate registers a top-level create below the gate, byte-identically", - "BATCH dispenser registration a top-level DISPENSER is untouched on both sides of the gate skips a top-level create whose coins name another chain, on both sides", - "BATCH dispenser registration a top-level DISPENSER is untouched on both sides of the gate still extends on a top-level v2 edit, and registers no create row" + "1fe4c0f9111bdecb": [ + "Database.backdatedFrontierViolation() @regression accepts a Map keys() iterator, which is what the apply loop passes", + "Database.backdatedFrontierViolation() @regression compares against the MAXIMUM applied name, not the last one seen", + "Database.backdatedFrontierViolation() @regression does not trip a resumed partial run over the shipped auto files", + "Database.backdatedFrontierViolation() @regression ignores an undated legacy ledger row when computing the frontier", + "Database.backdatedFrontierViolation() @regression never trips on a fresh install (empty ledger)", + "Database.backdatedFrontierViolation() @regression reports the frontier when a pending file is dated before an applied one", + "Database.backdatedFrontierViolation() @regression stays silent for a pending file dated after everything applied", + "Database.backdatedFrontierViolation() @regression still reports a real violation when an undated legacy row is present", + "Database.backdatedFrontierViolation() @regression treats an equal name as applied, not backdated", + "Database.destructiveAutoStatement() @regression does NOT flag the allow-listed session SETs (SET NAMES / a single UTC time_zone)", + "Database.destructiveAutoStatement() @regression does not flag a `#` inside a quoted literal or a block comment", + "Database.destructiveAutoStatement() @regression does not flag additive / widening statements", + "Database.destructiveAutoStatement() @regression does not flag an ordinary column whose name merely contains \"partition\"", + "Database.destructiveAutoStatement() @regression does not flag metadata-only drops (INDEX/KEY/FOREIGN KEY/CONSTRAINT/PRIMARY KEY)", + "Database.destructiveAutoStatement() @regression does not let a destructive keyword inside a block comment trigger a hit", + "Database.destructiveAutoStatement() @regression flags ALTER TABLE ... DROP COLUMN and a bare column drop", + "Database.destructiveAutoStatement() @regression flags ALTER TABLE ... RENAME (TO / COLUMN) and CHANGE", + "Database.destructiveAutoStatement() @regression flags ALTER TABLE partition and tablespace clauses", + "Database.destructiveAutoStatement() @regression flags CREATE OR REPLACE TABLE (atomic DROP+CREATE wipes rows) but not plain/IF NOT EXISTS", + "Database.destructiveAutoStatement() @regression flags DELETE FROM", + "Database.destructiveAutoStatement() @regression flags DROP DATABASE / DROP SCHEMA", + "Database.destructiveAutoStatement() @regression flags DROP TABLE", + "Database.destructiveAutoStatement() @regression flags INSERT ... ON DUPLICATE KEY UPDATE but not a plain INSERT", + "Database.destructiveAutoStatement() @regression flags LOAD DATA (rows come from a file the classifier cannot read)", + "Database.destructiveAutoStatement() @regression flags MODIFY ... NOT NULL narrowing (but not the AUTO_INCREMENT repair)", + "Database.destructiveAutoStatement() @regression flags RENAME TABLE", + "Database.destructiveAutoStatement() @regression flags REPLACE INTO (atomic DELETE+INSERT), matching the DELETE guard", + "Database.destructiveAutoStatement() @regression flags TRUNCATE", + "Database.destructiveAutoStatement() @regression flags UPDATE bypasses that smuggle past the id-repair carve-out", + "Database.destructiveAutoStatement() @regression flags a DROP hidden behind a `#` line comment (the server honours `#`)", + "Database.destructiveAutoStatement() @regression flags a NOT NULL-narrowing clause even when a sibling clause is AUTO_INCREMENT", + "Database.destructiveAutoStatement() @regression flags a bare UPDATE but not the committed AUTO_INCREMENT id=0 repair", + "Database.destructiveAutoStatement() @regression flags a destructive statement hidden after a safe one (scans all statements)", + "Database.destructiveAutoStatement() @regression flags a statement still carrying a `#` line comment (strip-regression guard)", + "Database.destructiveAutoStatement() @regression flags dynamic-SQL / stored-routine indirection (PREPARE/EXECUTE/CALL/SET @)", + "Database.destructiveAutoStatement() @regression flags non-canonical DELETE forms that omit an immediate FROM", + "Database.destructiveAutoStatement() @regression flags the SET @/PREPARE/EXECUTE dynamic-SQL bypass as a whole", + "Database.migrationMode() @regression a non-auto/manual value falls through to manual", + "Database.migrationMode() @regression defaults to manual when no tag is present (never auto-runs unknown DDL)", + "Database.migrationMode() @regression does not let a tag below the first SQL statement arm auto-apply (prologue window only)", + "Database.migrationMode() @regression is case-insensitive and tolerant of spacing", + "Database.migrationMode() @regression reads mode=auto from the header tag", + "Database.migrationMode() @regression reads mode=manual from the header tag", + "Database.migrationMode() @regression reads the tag past a multi-line comment banner (banner does not push it out of view)" ], "20a02f889b2e9582": [ "XChainDecoder RPC-lookup + rollback-signal hardening block loop RPC-failure classification retries the block past TX_PARSE_MAX_RETRIES on tagged RPC failures, never quarantining", @@ -583,6 +593,39 @@ "BATCH limit vendoring and cross-repo conformance tier 2: the post-flag rule set is the only one this decoder can ever see registers it at or below the indexer compiled consensus version", "BATCH limit vendoring and cross-repo conformance tier 2: the post-flag rule set is the only one this decoder can ever see testnet and regtest have no such window: capture and weighting both arm at genesis" ], + "2442ae2aad9cf6c2": [ + "src/sql schema parse-coverage @regression blocks.sql every parsed column has a name and a definition", + "src/sql schema parse-coverage @regression blocks.sql is parseable by the drift reconciler (non-null)", + "src/sql schema parse-coverage @regression blocks.sql yields at least one column", + "src/sql schema parse-coverage @regression dispenser_extension_undo.sql every parsed column has a name and a definition", + "src/sql schema parse-coverage @regression dispenser_extension_undo.sql is parseable by the drift reconciler (non-null)", + "src/sql schema parse-coverage @regression dispenser_extension_undo.sql yields at least one column", + "src/sql schema parse-coverage @regression dispensers.sql every parsed column has a name and a definition", + "src/sql schema parse-coverage @regression dispensers.sql is parseable by the drift reconciler (non-null)", + "src/sql schema parse-coverage @regression dispensers.sql yields at least one column", + "src/sql schema parse-coverage @regression events.sql every parsed column has a name and a definition", + "src/sql schema parse-coverage @regression events.sql is parseable by the drift reconciler (non-null)", + "src/sql schema parse-coverage @regression events.sql yields at least one column", + "src/sql schema parse-coverage @regression finds at least one table source (sanity)", + "src/sql schema parse-coverage @regression index_addresses.sql every parsed column has a name and a definition", + "src/sql schema parse-coverage @regression index_addresses.sql is parseable by the drift reconciler (non-null)", + "src/sql schema parse-coverage @regression index_addresses.sql yields at least one column", + "src/sql schema parse-coverage @regression index_transactions.sql every parsed column has a name and a definition", + "src/sql schema parse-coverage @regression index_transactions.sql is parseable by the drift reconciler (non-null)", + "src/sql schema parse-coverage @regression index_transactions.sql yields at least one column", + "src/sql schema parse-coverage @regression mempool_transactions.sql every parsed column has a name and a definition", + "src/sql schema parse-coverage @regression mempool_transactions.sql is parseable by the drift reconciler (non-null)", + "src/sql schema parse-coverage @regression mempool_transactions.sql yields at least one column", + "src/sql schema parse-coverage @regression pubkeys.sql every parsed column has a name and a definition", + "src/sql schema parse-coverage @regression pubkeys.sql is parseable by the drift reconciler (non-null)", + "src/sql schema parse-coverage @regression pubkeys.sql yields at least one column", + "src/sql schema parse-coverage @regression transaction_outputs.sql every parsed column has a name and a definition", + "src/sql schema parse-coverage @regression transaction_outputs.sql is parseable by the drift reconciler (non-null)", + "src/sql schema parse-coverage @regression transaction_outputs.sql yields at least one column", + "src/sql schema parse-coverage @regression transactions.sql every parsed column has a name and a definition", + "src/sql schema parse-coverage @regression transactions.sql is parseable by the drift reconciler (non-null)", + "src/sql schema parse-coverage @regression transactions.sql yields at least one column" + ], "248c2e7fdd4b8d28": [ "Fuzz: parseTransaction dispenser detection with various output types should handle 500 txs with dispenser-matching addresses" ], @@ -802,6 +845,11 @@ "ACTION-name alias round-trip parseRawTransaction decodes MSG payload to raw bytes", "ACTION-name alias round-trip parseRawTransaction decodes TRANSFER payload to raw bytes" ], + "3219370f321e36fa": [ + "post-commit dispenser purge failure count counts a purge that returns false and still finishes the batch", + "post-commit dispenser purge failure count exports the count as a Prometheus counter", + "post-commit dispenser purge failure count leaves the count at zero when the purge succeeds" + ], "358a7cbf07feb7ad": [ "Database#getBlockByIndex() [REGRESSION P0] throws (never returns the missing-row sentinel) after retries on persistent query error" ], @@ -876,6 +924,17 @@ "Fuzz: DISPENSER parsing near-miss DISPENSER prefixes should not trigger insert for prefix=\"dispenser\"", "Fuzz: DISPENSER parsing random DISPENSER strings should handle 100 random DISPENSER strings" ], + "376df582d23998da": [ + "db/migration_query_timeout activates once, restores the runtime timeout, and releases the lock", + "db/migration_query_timeout exports a one-hour default migration timeout", + "db/migration_query_timeout reads MIGRATE_QUERY_TIMEOUT=\"\"", + "db/migration_query_timeout reads MIGRATE_QUERY_TIMEOUT=\"-1\"", + "db/migration_query_timeout reads MIGRATE_QUERY_TIMEOUT=\"0\"", + "db/migration_query_timeout reads MIGRATE_QUERY_TIMEOUT=\"5000\"", + "db/migration_query_timeout reads MIGRATE_QUERY_TIMEOUT=\"abc\"", + "db/migration_query_timeout releases an untouched session without changing its timeout", + "db/migration_query_timeout releases and destroys the connection when timeout restoration fails" + ], "3894c155a8954b2b": [ "XChainDecoder#parseTransaction() [REGRESSION P0] R-SCR-001: should decode a DISPENSER payload", "XChainDecoder#parseTransaction() [REGRESSION P0] R-SCR-001: should decode a dynamically built OP_RETURN transaction", @@ -907,50 +966,32 @@ "E2E: Multi-Block Processing sequential block processing C1.2: blocks table should have no gaps across sequential blocks", "E2E: Multi-Block Processing sequential block processing C1.3: getDecoderBlockData returns correct data per block" ], - "3d54004868a9ce33": [ - "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression baselines when both columns already carry utf8mb4", - "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression does NOT baseline a half-converted pair (one column still lagging)", - "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression does NOT baseline at the pre-migration shape (both still utf8mb3)", - "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression does NOT baseline when a charset is unreadable (NULL)", - "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression does NOT baseline when either column is absent", - "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression baselines at the post-migration shape (tx_hash, no tx_hash_id)", - "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression does NOT baseline an ambiguous shape (both columns present)", - "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression does NOT baseline at the pre-migration shape (tx_hash_id still present)", - "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression does NOT baseline when a column name is unreadable (NULL)", - "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression does NOT baseline when the table or columns are absent", - "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression reads the two column names out of information_schema for this database", - "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression baselines when the column already holds an uncompressed key (130 chars)", - "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression baselines when the column is wider than required", - "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression does NOT baseline at the pre-migration shape (narrow VARCHAR(66))", - "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression does NOT baseline when the column is absent", - "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression does NOT baseline when the length is unreadable (NULL)", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: carries the deploy-precondition header tag", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: is mode=manual (an auto migration cannot be a missing precondition)", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: names a real assertion method on Database", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: the registered migration exists on disk", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: carries the deploy-precondition header tag", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: is mode=manual (an auto migration cannot be a missing precondition)", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: names a real assertion method on Database", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: the registered migration exists on disk", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: carries the deploy-precondition header tag", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: is mode=manual (an auto migration cannot be a missing precondition)", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: names a real assertion method on Database", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: the registered migration exists on disk", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 every tagged migration file is registered (no tag without an assertion behind it)", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 no mode=auto migration carries the tag", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 registers exactly the three migrations this tree asserts at startup", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 startupAssertedMigrationFile() resolves each registered assertion to its migration filename", - "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 startupAssertedMigrationFile() throws on an unregistered assertion rather than yielding undefined", - "Database.migrationDeclaresDeployPrecondition @regression @tier1 ignores the token on a comment line that is not the xchain:migration directive", - "Database.migrationDeclaresDeployPrecondition @regression @tier1 ignores the token once the SQL body has started, so prose or a data literal cannot arm it", - "Database.migrationDeclaresDeployPrecondition @regression @tier1 is false for an ordinary tagged migration", - "Database.migrationDeclaresDeployPrecondition @regression @tier1 is false for an untagged file and for empty input", - "Database.migrationDeclaresDeployPrecondition @regression @tier1 reads the tag off the xchain:migration directive line", - "Database.migrationDeclaresDeployPrecondition @regression @tier1 sees the tag through a long license banner (the prologue is unbounded)", - "Database.migrationDeclaresDeployPrecondition @regression @tier1 tolerates spacing around the token", - "startup assertion error text names the registered file @regression @tier1 assertActionDataIsUtf8mb4 names the exact migration file", - "startup assertion error text names the registered file @regression @tier1 assertDispenserExpirationIsBigintUnsigned names the exact migration file", - "startup assertion error text names the registered file @regression @tier1 assertPubkeyColumnIsUncompressedWide names the exact migration file" + "3e1e7599837cbf70": [ + "ACTION manifest conformance: decoder wireDecoded set @regression ACTION_ALIASES exactly equals the manifest aliases map", + "ACTION manifest conformance: decoder wireDecoded set @regression VALID_ACTION_NAMES exactly equals the manifest wireDecoded slice", + "ACTION manifest conformance: decoder wireDecoded set @regression byte-identity to canonical manifest vendored test/fixtures/action-manifest.json is byte-identical to canonical", + "ACTION manifest conformance: decoder wireDecoded set @regression settlement anchors remain explorer-only and outside the wire decoder" + ], + "3e9a24b03ca94279": [ + "Database#getMempoolTransactionCount() returns the count as a Number (BigInt-safe) and 0 on an empty result", + "Database#getMempoolTransactions() reads the raw-string columns + first_seen in tx_hash order with a clamped limit", + "Database#getMempoolTransactions() releases the connection even when the query throws", + "Database#getMempoolTransactions() returns first_seen as plain unix seconds whatever the host time zone", + "api.js getmempool method (source pin) exposes getmempool on the JSON-RPC controller", + "api.js getmempool method (source pin) maps the node-mempool observation snapshot into the response", + "api.js getmempool method (source pin) reads rows via the bounded DB helpers and clamps the per-request limit to 500", + "api.js getmempool method (source pin) serves first_seen as the SQL-read unix seconds, never a driver Date", + "api.js getmempool method (source pin) serves from a TTL cache so an unauthenticated burst cannot amplify into DB reads", + "api.js getmempool stale fallback marks a failed refresh stale and preserves the last successful read", + "node-mempool observation snapshot a failed getrawmempool leaves the previous snapshot standing", + "node-mempool observation snapshot starts unknown: -1 count / null timestamp until the first poll", + "node-mempool observation snapshot updateMempool records the DEDUPED node mempool size and a timestamp" + ], + "3f8698af9d3d592f": [ + "BlockchainConnector retry exhaustion keeps the cause identity getRawTransaction exhaustion carries the node rpcCode from an HTTP-500 body", + "BlockchainConnector retry exhaustion keeps the cause identity getRawTransaction exhaustion carries the rpcCode of an HTTP-200 JSON-RPC error", + "BlockchainConnector retry exhaustion keeps the cause identity getRawTransaction exhaustion carries the transport code of the last attempt", + "BlockchainConnector retry exhaustion keeps the cause identity rpcCallWithTimeoutRetry exhaustion carries ECONNABORTED and keeps its message" ], "4044805d7869c790": [ "runMigrations() migration preconditions @regression a file with no precondition entry is never baselined", @@ -966,6 +1007,16 @@ "Security: Connection Handling Transaction lock mechanism [REGRESSION P0] R-SEC-003: should acquire lock on first call", "Security: Connection Handling getConnection retry bound [REGRESSION P0] R-SEC-003: should cap connection retries with a maxAttempts bound" ], + "411f596aab8ee136": [ + "DISPENSER exact integer EXPIRATION activation is public and UNARMED on every network", + "DISPENSER exact integer EXPIRATION activation tests integer value in exact decimal space", + "DISPENSER exact integer EXPIRATION activation uses inclusive block-time activation semantics", + "DISPENSER exact integer EXPIRATION decoding does not mirror a precision-rounded fractional edit above the gate", + "DISPENSER exact integer EXPIRATION decoding preserves precision-rounded create and edit behavior below the gate", + "DISPENSER exact integer EXPIRATION decoding rejects a precision-rounded fractional create above the gate", + "DISPENSER exact integer EXPIRATION valid spellings keeps an omitted create EXPIRATION on the default path above the gate", + "DISPENSER exact integer EXPIRATION valid spellings keeps exactly integral decimal and exponent spellings valid above the gate" + ], "418aa8572442df90": [ "api.js GET /status publishes a lag field computes syncStatus before the res.json() call, not after", "api.js GET /status publishes a lag field publishes lag on the JSON body, matching one of the keys BootstrapHealthGate checks", @@ -1014,6 +1065,21 @@ "verifyReorg: an above-tip gap the window cannot absorb is refused before the first delete counts what a previous process already rolled back toward the refusal", "verifyReorg: an above-tip gap the window cannot absorb is refused before the first delete deletes nothing and writes no halt when the known depth alone exceeds the ceiling" ], + "47c0720002969b72": [ + "assertTransactionIdsAreBigint metadata @regression @tier1 rejects a missing id column", + "assertTransactionIdsAreBigint metadata @regression @tier1 skips unrelated fixture rows that did not come from its schema query", + "assertTransactionIdsAreBigint metadata @regression @tier1 skips when the transactions table does not exist yet", + "assertTransactionIdsAreBigint types @regression @tier1 accepts the complete transactions id set at BIGINT UNSIGNED", + "assertTransactionIdsAreBigint types @regression @tier1 names the exact migration file when a column is still INT", + "assertTransactionIdsAreBigint types @regression @tier1 rejects a signed BIGINT id column", + "runMigrations BIGINT id startup contract @regression @tier1 fails closed when the required assertion method is unavailable" + ], + "48102ba3278bff5c": [ + "VALID_ACTION_NAMES flag-day pin @regression contains only upper-case names with no duplicates in the pin", + "VALID_ACTION_NAMES flag-day pin @regression falsification: a changed membership is detected", + "VALID_ACTION_NAMES flag-day pin @regression holds exactly the pinned names", + "VALID_ACTION_NAMES flag-day pin @regression pins a stable digest of the sorted membership" + ], "4890a1159c369a21": [ "Fuzz: parseTransaction P2WSH with missing/corrupt witness should handle 100 P2WSH txs with corrupt witness" ], @@ -1084,6 +1150,13 @@ "DISPENSER Integration DISPENSER v0 with valid fields should store DISPENSER data in transactions table", "DISPENSER Integration dispenser output via indexer contract query should include dispenser output fields in getDecoderBlockData" ], + "4edb863dbbb574cc": [ + "Database.destructiveAutoStatement() quoted comment markers @regression flags a destructive clause hidden between a /* literal and a later */ literal", + "Database.destructiveAutoStatement() quoted comment markers @regression flags the same trick through backtick identifiers, one ending in a backslash", + "Database.destructiveAutoStatement() quoted comment markers @regression still reads a real comment as a comment and a quoted marker as data", + "decoder live-schema guard quoted comment markers @regression finds a MODIFY between quoted markers or after a backslash-ended identifier", + "decoder live-schema guard quoted comment markers @regression strips block comments with the splitter quote model" + ], "4edd63db5d637faa": [ "Fuzz: parseTransaction H2: multisig with tiny/empty pubkeys should handle multisig with 0-byte pubkeys", "Fuzz: parseTransaction H2: multisig with tiny/empty pubkeys should handle multisig with 1-byte pubkeys", @@ -1137,27 +1210,14 @@ "E2E: ACTION Decoding encoding types A2.2:should decode ACTION via 1-of-3 multisig", "E2E: ACTION Decoding encoding types A2.3:multisig should strip trailing zeros from short payload" ], - "51c14bbdc7db5a0e": [ - "Database.getReorgHaltMarker parses the JSON payload of the newest marker row", - "Database.getReorgHaltMarker returns halted:false when no marker row exists", - "Database.getReorgHaltMarker still reports halted when the payload is unreadable", - "Database.markReorgHalted reports the row, not the ack returns false rather than throwing when the read-back itself fails", - "Database.markReorgHalted reports the row, not the ack returns false when insertEvent swallowed the write error", - "Database.markReorgHalted reports the row, not the ack returns false when the insert claims success but no row is readable", - "Database.markReorgHalted reports the row, not the ack returns true when the row is readable after the insert", - "Database.markReorgHalted reports the row, not the ack stays idempotent: an existing marker is true without a second write", - "XChainDecoder latent REORG_HALT reporting a probe fault keeps the last known halt rather than clearing it", - "XChainDecoder latent REORG_HALT reporting caches within the TTL so a monitoring burst is not one DB query per request", - "XChainDecoder latent REORG_HALT reporting concurrent probes collapse onto one in-flight query", - "XChainDecoder latent REORG_HALT reporting declares the clear-history cache fields up front, so the shape is null and not undefined", - "XChainDecoder latent REORG_HALT reporting falls back to the boolean isReorgHalted probe when the detailed reader is absent", - "XChainDecoder latent REORG_HALT reporting force bypasses the TTL", - "XChainDecoder latent REORG_HALT reporting leaves checked_at null until the first probe, so \"never looked\" is distinguishable", - "XChainDecoder latent REORG_HALT reporting reports a dormant marker, with its reason, from the cached probe", - "XChainDecoder latent REORG_HALT reporting reports not-halted (and does not throw) before the db handle exists", - "XChainDecoder latent REORG_HALT reporting reports not-halted on a clean decoder", - "XChainDecoder latent REORG_HALT reporting verifyReorg entry guard mirrors a pre-existing durable marker into health state", - "XChainDecoder latent REORG_HALT reporting verifyReorg marks the decoder halted in memory even when the durable write fails" + "5199ac844e30531f": [ + "mempool admission of an ACTION with no resolvable source blanks an unattributable ACTION to an empty string and keeps the row", + "mempool admission of an ACTION with no resolvable source keeps the ACTION text for a null source when a dispense output makes the tx storable", + "mempool admission of an ACTION with no resolvable source keeps the ACTION text when the source resolves", + "mempool admission of an ACTION with no resolvable source loads the open-dispenser set with the cancelGraceFloor for the network", + "mempool admission of an ACTION with no resolvable source parses against the getAllOpenDispenserAddresses set, so an unattributed ACTION paying an open dispenser keeps its text", + "mempool admission of an ACTION with no resolvable source still runs the cycle against an empty set when getAllOpenDispenserAddresses fails", + "mempool admission of an ACTION with no resolvable source still writes a blanked row for an actionless tx with no source" ], "51fbc60e4c509ae1": [ "Database#hasDispenserTransactions() probes BOTH the top-level and the batch-carried shape", @@ -1173,6 +1233,59 @@ "BATCH limit vendoring and cross-repo conformance tier 1: the vendored tables have not drifted from the sibling keeps every weight an integer >= 1, which is what makes the count cap a sound pre-filter", "BATCH limit vendoring and cross-repo conformance tier 1: the vendored tables have not drifted from the sibling keeps the ungated and gated caps in the tables they came from" ], + "54639638d1536735": [ + "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression baselines when both columns already carry utf8mb4", + "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression does NOT baseline a half-converted pair (one column still lagging)", + "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression does NOT baseline at the pre-migration shape (both still utf8mb3)", + "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression does NOT baseline when a charset is unreadable (NULL)", + "Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression does NOT baseline when either column is absent", + "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression baselines at the post-migration shape (tx_hash, no tx_hash_id)", + "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression does NOT baseline an ambiguous shape (both columns present)", + "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression does NOT baseline at the pre-migration shape (tx_hash_id still present)", + "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression does NOT baseline when a column name is unreadable (NULL)", + "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression does NOT baseline when the table or columns are absent", + "Database.MIGRATION_PRECONDITIONS: mempool raw-strings rebuild predicate @regression reads the two column names out of information_schema for this database", + "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression baselines when the column already holds an uncompressed key (130 chars)", + "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression baselines when the column is wider than required", + "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression does NOT baseline at the pre-migration shape (narrow VARCHAR(66))", + "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression does NOT baseline when the column is absent", + "Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression does NOT baseline when the length is unreadable (NULL)", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-02-widen-ids-to-bigint.sql: carries the deploy-precondition header tag", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-02-widen-ids-to-bigint.sql: is mode=manual (an auto migration cannot be a missing precondition)", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-02-widen-ids-to-bigint.sql: names a real assertion method on Database", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-02-widen-ids-to-bigint.sql: the registered migration exists on disk", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: carries the deploy-precondition header tag", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: is mode=manual (an auto migration cannot be a missing precondition)", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: names a real assertion method on Database", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-06-13-dispensers-expiration-bigint.sql: the registered migration exists on disk", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: carries the deploy-precondition header tag", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: is mode=manual (an auto migration cannot be a missing precondition)", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: names a real assertion method on Database", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-07-24-pubkeys-widen-uncompressed.sql: the registered migration exists on disk", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: carries the deploy-precondition header tag", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: is mode=manual (an auto migration cannot be a missing precondition)", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: names a real assertion method on Database", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 2026-08-10-action-data-utf8mb4.sql: the registered migration exists on disk", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 every tagged migration file is registered (no tag without an assertion behind it)", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 no mode=auto migration carries the tag", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 registers exactly the four migrations this tree asserts at startup", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 startupAssertedMigrationFile() resolves each registered assertion to its migration filename", + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 startupAssertedMigrationFile() throws on an unregistered assertion rather than yielding undefined", + "Database.migrationDeclaresDeployPrecondition @regression @tier1 ignores the token on a comment line that is not the xchain:migration directive", + "Database.migrationDeclaresDeployPrecondition @regression @tier1 ignores the token once the SQL body has started, so prose or a data literal cannot arm it", + "Database.migrationDeclaresDeployPrecondition @regression @tier1 is false for an ordinary tagged migration", + "Database.migrationDeclaresDeployPrecondition @regression @tier1 is false for an untagged file and for empty input", + "Database.migrationDeclaresDeployPrecondition @regression @tier1 reads the tag off the xchain:migration directive line", + "Database.migrationDeclaresDeployPrecondition @regression @tier1 sees the tag through a long license banner (the prologue is unbounded)", + "Database.migrationDeclaresDeployPrecondition @regression @tier1 tolerates spacing around the token", + "startup assertion error text names the registered file @regression @tier1 assertActionDataIsUtf8mb4 names the exact migration file", + "startup assertion error text names the registered file @regression @tier1 assertDispenserExpirationIsBigintUnsigned names the exact migration file", + "startup assertion error text names the registered file @regression @tier1 assertPubkeyColumnIsUncompressedWide names the exact migration file" + ], + "5547b4e45f6dd005": [ + "XChainDecoder unattributed XChain payload skip counts a payload with no resolvable source toward parse_errors and stores nothing", + "XChainDecoder unattributed XChain payload skip leaves parse_errors untouched for a tx with no payload and no source" + ], "55b018a2603be435": [ "BlockchainConnector#getRawTransactions (bounded concurrency) bounds in-flight requests to DECODER_RPC_CONCURRENCY and preserves order", "BlockchainConnector#getRawTransactions (bounded concurrency) never exceeds the 50-request default and handles an empty list", @@ -1211,6 +1324,33 @@ "Database.ping() health probe isolation draws a pooled connection even while a block transaction is open", "Database.ping() health probe isolation releases its pooled connection and rethrows when the probe query fails" ], + "571065a79b6fdaba": [ + "Database.getReorgHaltMarker parses the JSON payload of the newest marker row", + "Database.getReorgHaltMarker returns halted:false when no marker row exists", + "Database.getReorgHaltMarker still reports halted when the payload is unreadable", + "Database.markReorgHalted reports the row, not the ack returns false rather than throwing when the read-back itself fails", + "Database.markReorgHalted reports the row, not the ack returns false when insertEvent swallowed the write error", + "Database.markReorgHalted reports the row, not the ack returns false when the insert claims success but no row is readable", + "Database.markReorgHalted reports the row, not the ack returns true when the row is readable after the insert", + "Database.markReorgHalted reports the row, not the ack stays idempotent: an existing marker is true without a second write", + "XChainDecoder keeps an unrecorded halt across the TTL re-probe adopts the row when the write reported failure but the read finds the halt", + "XChainDecoder keeps an unrecorded halt across the TTL re-probe keeps the halt when db.markReorgHalted does not exist", + "XChainDecoder keeps an unrecorded halt across the TTL re-probe keeps the halt when the newest row is an older operator clear", + "XChainDecoder keeps an unrecorded halt across the TTL re-probe keeps the halt when the write returned false and the read finds no row", + "XChainDecoder keeps an unrecorded halt across the TTL re-probe still clears a recorded halt once an operator clear is read", + "XChainDecoder latent REORG_HALT reporting a probe fault keeps the last known halt rather than clearing it", + "XChainDecoder latent REORG_HALT reporting caches within the TTL so a monitoring burst is not one DB query per request", + "XChainDecoder latent REORG_HALT reporting concurrent probes collapse onto one in-flight query", + "XChainDecoder latent REORG_HALT reporting declares the clear-history cache fields up front, so the shape is null and not undefined", + "XChainDecoder latent REORG_HALT reporting falls back to the boolean isReorgHalted probe when the detailed reader is absent", + "XChainDecoder latent REORG_HALT reporting force bypasses the TTL", + "XChainDecoder latent REORG_HALT reporting leaves checked_at null until the first probe, so \"never looked\" is distinguishable", + "XChainDecoder latent REORG_HALT reporting reports a dormant marker, with its reason, from the cached probe", + "XChainDecoder latent REORG_HALT reporting reports not-halted (and does not throw) before the db handle exists", + "XChainDecoder latent REORG_HALT reporting reports not-halted on a clean decoder", + "XChainDecoder latent REORG_HALT reporting verifyReorg entry guard mirrors a pre-existing durable marker into health state", + "XChainDecoder latent REORG_HALT reporting verifyReorg marks the decoder halted in memory even when the durable write fails" + ], "5777938ee30b46ca": [ "Database constructor DB_QUERY_TIMEOUT handling should default queryTimeout to 30000 when unset", "Database constructor DB_QUERY_TIMEOUT handling should disable the timeout when DB_QUERY_TIMEOUT=0", @@ -1343,6 +1483,18 @@ "Security: remediated dependency advisories @regression @tier4 ADV-4: brace-expansion survives the CVE-2026-14257 unbounded-length input", "Security: remediated dependency advisories @regression @tier4 ADV-5: the installed axios reports a patched runtime version" ], + "5ea20e09507c7a24": [ + "Taproot envelope recognition constants conformance parity with the canonical xchain-documentation copy ENVELOPE_MAX_PAYLOAD and both activation maps are byte-equal to the canonical copy", + "Taproot envelope recognition constants conformance parity with the canonical xchain-documentation copy every adversarial entry is either executed above or mapped to a local test that still exists", + "Taproot envelope recognition constants conformance parity with the canonical xchain-documentation copy every adversarial entry that carries bytes runs against the decoder and meets its expect", + "Taproot envelope recognition constants conformance parity with the canonical xchain-documentation copy the envelope_not_input_0 witness IS an action at input 0, so its refusal comes from the index alone", + "Taproot envelope recognition constants conformance parity with the canonical xchain-documentation copy the golden tapleaf hash reproduces from the frozen script bytes", + "Taproot envelope recognition constants conformance parity with the canonical xchain-documentation copy the inlined golden bytes match the frozen vector file", + "Taproot envelope recognition constants conformance parity with the encoder validator ENVELOPE_MAX_PAYLOAD stays equal across the two services", + "Taproot envelope recognition constants conformance the carrier-recognition map is exactly the shipped shape: mainnet unarmed, genesis-active test networks, DOGE never", + "Taproot envelope recognition constants conformance the decoder exports the vendored constants unchanged", + "Taproot envelope recognition constants conformance the recognition map is exactly the \u00a77 shape: BTC/LTC armed mainnet cohorts, genesis-active test networks, DOGE never" + ], "5ecf90a69618e860": [ "XChainBlockDecoder litecoin blockFromBuffer should parse a litecoin block with one standard (non-HogEx) transaction", "XChainBlockDecoder litecoin blockFromBuffer should parse a litecoin block with two transactions where last has no HogEx flag", @@ -1445,26 +1597,6 @@ "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files LTC/testnet vendored pin matches the vendored consensusHash", "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files verifyConsensusPin passes for every network on the vendored bundle" ], - "640847acd3068328": [ - "Database#countReorgDeletesAboveTip() THROWS on a marker payload that is not the expected array", - "Database#countReorgDeletesAboveTip() THROWS on a non-numeric block_index", - "Database#countReorgDeletesAboveTip() THROWS on an unparseable marker payload", - "Database#countReorgDeletesAboveTip() bounds the scan, and refuses a nonsense bound rather than emitting it as SQL", - "Database#countReorgDeletesAboveTip() counts a height once even when it was deleted, re-synced and deleted again", - "Database#countReorgDeletesAboveTip() counts only the marked heights above the current tip", - "Database#countReorgDeletesAboveTip() handles the pre-M-12 multi-entry payload shape", - "Database#countReorgDeletesAboveTip() propagates a tip read that could not be answered, rather than counting against a guess", - "Database#countReorgDeletesAboveTip() returns zero on a database with no REORG markers at all", - "Database#countReorgDeletesAboveTip() scans beyond the 5006-block litecoin testnet ceiling by default", - "verifyReorg: a re-deleted height does not shrink the carried-over depth counts every distinct height above the tip when repeat markers fill the newest rows", - "verifyReorg: the safe-depth ceiling survives a lost halt marker deletes nothing at all when the prior depth cannot be read", - "verifyReorg: the safe-depth ceiling survives a lost halt marker names both halves of the depth in the abort message, so the operator sees the resume", - "verifyReorg: the safe-depth ceiling survives a lost halt marker recovers when the read fault is transient", - "verifyReorg: the safe-depth ceiling survives a lost halt marker refuses the FIRST delete when the committed REORG markers already reach the ceiling", - "verifyReorg: the safe-depth ceiling survives a lost halt marker spends only the depth that is left, then aborts", - "verifyReorg: the safe-depth ceiling survives a lost halt marker still refuses on the halt marker when one DID survive, before counting anything", - "verifyReorg: the safe-depth ceiling survives a lost halt marker without the durable count, a restart spends a whole fresh budget" - ], "646499266a236557": [ "Database#reconcileTableIndexes() adds a declared index that is missing live", "Database#reconcileTableIndexes() is non-fatal when the SQL source cannot be read", @@ -1482,6 +1614,40 @@ "roundtrip conformance fixture: stored-record invariants lets an alias expansion push the stored record PAST the compiled wire ceiling", "roundtrip conformance fixture: stored-record invariants stores the CANONICAL action name, never the on-wire alias" ], + "6579d4ea0926bf01": [ + "dispenserAddressKey drops trailing spaces", + "dispenserAddressKey folds ASCII letters to lower case", + "dispenserAddressKey keeps distinct addresses distinct", + "dispenserAddressKey keeps leading spaces and other characters" + ], + "65e3e5f5a8f53eb0": [ + "Database#alterTableForDrift() NOT NULL relax keeps live attributes reads every attribute a bare MODIFY could drop from the live column", + "Database#alterTableForDrift() NOT NULL relax keeps live attributes restates the live collation when it relaxes a clean column", + "Database#alterTableForDrift() NOT NULL relax keeps live attributes skips the relax when the live column carries a DEFAULT, COMMENT, ON UPDATE or generation expression", + "Database#alterTableForDrift() NOT NULL relax keeps live attributes still never relaxes a primary-key or AUTO_INCREMENT column" + ], + "67e3c12ff1c288e7": [ + "malformed-AuxPoW block reassembly fallback BlockchainConnector.getBlockReassembled fails loudly when an in-block tx cannot be fetched", + "malformed-AuxPoW block reassembly fallback BlockchainConnector.getBlockReassembled fetches txs through the bounded batch helper, not serially", + "malformed-AuxPoW block reassembly fallback BlockchainConnector.getBlockReassembled preserves error.code and the original error as cause on a transport fault", + "malformed-AuxPoW block reassembly fallback BlockchainConnector.getBlockReassembled rebuilds header + tx-count varint + raw txs, parseable as a block", + "malformed-AuxPoW block reassembly fallback BlockchainConnector.getBlockReassembled uses only the first 80 header bytes when getblockheader appends AuxPoW bytes", + "malformed-AuxPoW block reassembly fallback BlockchainConnector.getBlockReassembled wraps a content fault with a cause and no invented code", + "malformed-AuxPoW block reassembly fallback BlockchainConnector.probeTxIndex returns false when getrawtransaction finds nothing (txindex missing)", + "malformed-AuxPoW block reassembly fallback BlockchainConnector.probeTxIndex returns null (never throws) when the probe RPCs fail", + "malformed-AuxPoW block reassembly fallback BlockchainConnector.probeTxIndex returns null on an empty chain (genesis coinbase is never indexed)", + "malformed-AuxPoW block reassembly fallback BlockchainConnector.probeTxIndex returns true when the tip coinbase is retrievable without a blockhash", + "malformed-AuxPoW block reassembly fallback XChainDecoder.fetchBlockHex does not reassemble when transport faults, not content faults, drove the count", + "malformed-AuxPoW block reassembly fallback XChainDecoder.fetchBlockHex falls back to getBlockReassembled at the failure threshold", + "malformed-AuxPoW block reassembly fallback XChainDecoder.fetchBlockHex never reassembles on a non-AuxPoW chain", + "malformed-AuxPoW block reassembly fallback XChainDecoder.fetchBlockHex uses getBlockWithoutAuxPow below the failure threshold", + "malformed-AuxPoW block reassembly fallback encodeVarintHex encodes each varint width and refuses >2^32-1", + "malformed-AuxPoW block reassembly fallback getBlockReassembled carries rpcCode/rpcMessage through the wrap, untagged", + "malformed-AuxPoW block reassembly fallback getBlockReassembled keeps error.code when the real getRawTransaction ladder exhausts", + "malformed-AuxPoW block reassembly fallback getBlockWithoutAuxPow fault classification propagates a getBlock transport fault unwrapped too", + "malformed-AuxPoW block reassembly fallback getBlockWithoutAuxPow fault classification propagates an RPC transport fault unwrapped, with error.code intact", + "malformed-AuxPoW block reassembly fallback getBlockWithoutAuxPow fault classification tags an untraversable AuxPoW section as a content fault" + ], "69a2734ef775181c": [ "Database#insertMempoolTransaction() binds raw_data as the 7th param, null when absent", "Database#insertMempoolTransaction() does not allocate index ids and stores raw strings", @@ -1556,15 +1722,6 @@ "Boundary: AES-128-CTR Deobfuscation (D-1 through D-7) [REGRESSION P0] R-DEC-005 D-6: should decrypt with all-zero txid (valid AES key/IV)", "Boundary: AES-128-CTR Deobfuscation (D-1 through D-7) [REGRESSION P0] R-DEC-005 D-7: should decrypt with all-f txid (valid AES key/IV)" ], - "7198625d14f3da9d": [ - "XChainDecoder fee destination @unit keeps a real fee destination address", - "XChainDecoder fee destination @unit treats a missing fee destination as null", - "XChainDecoder fee destination @unit treats the unset placeholder as null (capture disabled)", - "resolveFeeDestination @unit defaults to the registry pin for every coin and network when no env override is set", - "resolveFeeDestination @unit falls back to env-only when the network is unrecognized", - "resolveFeeDestination @unit honors an env override on regtest ONLY", - "resolveFeeDestination @unit ignores an env override on mainnet AND testnet and returns the pin (armed-federation fork guard)" - ], "72a971c9a8d36046": [ "BATCH dispenser registration the money-bearing end: payments to a batch-created dispenser a top-level create captures the same way, on both sides of the gate", "BATCH dispenser registration the money-bearing end: payments to a batch-created dispenser are NOT captured below the gate (the defect: coin spent, nothing dispensed)", @@ -1627,6 +1784,17 @@ "alias expansion at the MAX_ACTION_DATA_LENGTH boundary an already-canonical name at the cap is returned unchanged", "alias expansion at the MAX_ACTION_DATA_LENGTH boundary every alias expansion delta is what the constant comment claims" ], + "7ab2b61a3082956b": [ + "XChainDecoder parse-loop quarantine quarantines a poison transaction after exhausting block retries", + "XChainDecoder parse-loop quarantine retries the whole block when parseTransaction throws transiently (no quarantine)", + "XChainDecoder parse-loop quarantine skips just the failing tx during a mempool update instead of aborting the cycle", + "XChainDecoder parse-loop quarantine survives a blockFromHex throw and retries the block instead of dying", + "quarantine retry state is per tx position and per block a one-off INSERT rejection on a second tx after a quarantine in the same block is still retried", + "quarantine retry state is per tx position and per block a sibling block at the same height does not inherit an INSERT quarantine", + "quarantine retry state is per tx position and per block a sibling block at the same height does not inherit the parse retry count", + "quarantine retry state is per tx position and per block a transient parse throw on a second tx after a quarantine in the same block is still retried", + "quarantine retry state is per tx position and per block two poison txs in one block each get their own parse retries and the block commits" + ], "7b91e388506f917f": [ "Boundary: ACTION String Parsing (A-1 through A-12) [REGRESSION P1] R-DSP-001 A-5: DISPENSER v0 with all 15 fields \u2192 complete parse", "Boundary: DISPENSER Field Extraction Logic (A-4, A-7 through A-11) [REGRESSION P1] DISPENSER with a compacted ^ GET_ADDRESS: fail-loud, not registered", @@ -1667,6 +1835,11 @@ "DISPENSER PRICE v1 oracle-fee output capture set-membership capture over a source's open Mode B dispensers keeps the legacy single-pick below the gate: the older row captures nothing", "DISPENSER PRICE v1 oracle-fee output capture set-membership capture over a source's open Mode B dispensers keeps the legacy single-pick below the gate: the top-ranked row still captures" ], + "7ee2d8b6ab3965a7": [ + "/status reports the stale tip without gating on it answers 200 on a stale tip but flags its zero lag as untrustworthy", + "/status reports the stale tip without gating on it keeps 503 on a stopped decoder whatever the tip says", + "/status reports the stale tip without gating on it reports the flag as a stable boolean, not an absent key, when the tip is fresh" + ], "7f339cce97ef3d87": [ "mempool vs confirmed ACTION payload representation conformance both decode sites resolve the same on-wire ACTION to a byte-identical string", "mempool vs confirmed ACTION payload representation conformance stored form is the readable UTF-8 ACTION string, never hex", @@ -1738,16 +1911,13 @@ "migrate.js operator CLI argv refusal @regression an unknown flag applies nothing and exits 2", "migrate.js operator CLI argv refusal @regression the refusal prints both modes so an operator can tell them apart" ], - "82f18e9dded2dd48": [ - "Taproot envelope recognition constants conformance parity with the canonical xchain-documentation copy ENVELOPE_MAX_PAYLOAD and both activation maps are byte-equal to the canonical copy", - "Taproot envelope recognition constants conformance parity with the canonical xchain-documentation copy every adversarial entry is either executed above or mapped to a local test that still exists", - "Taproot envelope recognition constants conformance parity with the canonical xchain-documentation copy every adversarial entry that carries bytes runs against the decoder and meets its expect", - "Taproot envelope recognition constants conformance parity with the canonical xchain-documentation copy the envelope_not_input_0 witness IS an action at input 0, so its refusal comes from the index alone", - "Taproot envelope recognition constants conformance parity with the canonical xchain-documentation copy the golden tapleaf hash reproduces from the frozen script bytes", - "Taproot envelope recognition constants conformance parity with the canonical xchain-documentation copy the inlined golden bytes match the frozen vector file", - "Taproot envelope recognition constants conformance parity with the encoder validator ENVELOPE_MAX_PAYLOAD stays equal across the two services", - "Taproot envelope recognition constants conformance the decoder exports the vendored constants unchanged", - "Taproot envelope recognition constants conformance the recognition map is exactly the \u00a77 shape: BTC/LTC armed mainnet cohorts, genesis-active test networks, DOGE never" + "8383b3fc6d715636": [ + "protocol time does not replace an unusable window entry with an older entry", + "protocol time falls back to the raw clock at the history boundary", + "protocol time never resolves protocol time later than the block stamp", + "protocol time returns an immutable context without changing replay inputs", + "protocol time takes the first 11 height-descending entries before sorting by timestamp", + "protocol time uses previous-block MTP only on the armed network" ], "83cb7e9cd2340bd1": [ "BlockchainConnector (extra coverage) #getBlock() ECONNABORTED handling should pass hexFormat=false as verbose=true to the RPC", @@ -1840,37 +2010,15 @@ "DISPENSER lifecycle mirror: advisory open-view format 2 edit with a past EXPIRATION is skipped (indexer rejects EXPIRATION <= BLOCK_TIME)", "DISPENSER lifecycle mirror: advisory open-view format 2 edit with an empty EXPIRATION is a no-op (only a present EXPIRATION moves the view)" ], - "8999f436cb35bdfa": [ - "BlockchainConnector #getBlock() should request raw hex format by default (hexFormat=true means verbose=false)", - "BlockchainConnector #getBlock() should return block hex on success", - "BlockchainConnector #getBlock() should throw when response has no result", - "BlockchainConnector #getBlockHash() should coerce a BigInt block index to a Number param (serializable JSON-RPC body)", - "BlockchainConnector #getBlockHash() should pass the block index as params", - "BlockchainConnector #getBlockHash() should propagate network errors", - "BlockchainConnector #getBlockHash() should return the block hash on success", - "BlockchainConnector #getBlockHash() should throw when response has no result", - "BlockchainConnector #getBlockHeader() [REGRESSION P2] R-RPC-001: should retry on timeout (ECONNABORTED)", - "BlockchainConnector #getBlockHeader() [REGRESSION P2] R-RPC-001: should throw after exhausting all 10 timeout retries", - "BlockchainConnector #getBlockHeader() should return block header on success", - "BlockchainConnector #getBlockHeader() should throw immediately on non-timeout errors", - "BlockchainConnector #getBlockchainInfo() [REGRESSION P2] R-RPC-001: should return the result on success", - "BlockchainConnector #getBlockchainInfo() should send correct JSON-RPC method", - "BlockchainConnector #getBlockchainInfo() should throw when response has no result", - "BlockchainConnector #getBlockchainInfo() should use auth credentials", - "BlockchainConnector #getNetworkInfo() should return the result on success", - "BlockchainConnector #getNetworkInfo() should throw when response has no result", - "BlockchainConnector #getRawMempool() should return mempool txids on success", - "BlockchainConnector #getRawMempool() should throw when response has no result", - "BlockchainConnector #getRawTransaction() [REGRESSION P2] R-RPC-002: should back off longer on -429 work queue depth exceeded", - "BlockchainConnector #getRawTransaction() should resolve null when response has no result (tx mined/evicted)", - "BlockchainConnector #getRawTransaction() should resolve on success after retries", - "BlockchainConnector #getRawTransaction() should retry on network failure up to 10 times", - "BlockchainConnector #getRawTransaction() should return raw tx hex on success", - "BlockchainConnector #getRawTransactions() should batch multiple getRawTransaction calls", - "BlockchainConnector #getRawTransactions() should not fail the whole batch when one tx is mined/evicted (resolves null)", - "BlockchainConnector #getRawTransactions() should return empty array for empty input", - "BlockchainConnector constructor should construct the URL from host and port", - "BlockchainConnector constructor should store rpc credentials" + "8a5d5b0d051bc1ea": [ + "decoder migration live-schema MODIFY guard: omitted charset and collation @regression passes when the column already carries the table default or the MODIFY names its charset", + "decoder migration live-schema MODIFY guard: omitted charset and collation @regression refuses a charset-less text MODIFY that drops a column collation", + "decoder migration live-schema MODIFY guard: omitted charset and collation @regression refuses a charset-less text MODIFY that falls back to a different table charset", + "decoder migration live-schema MODIFY guard: type changes @regression names the live and restated types when a decimal score would be rounded to an integer", + "decoder migration live-schema MODIFY guard: type changes @regression passes the timestamp to datetime move the committed auto migrations make", + "decoder migration live-schema MODIFY guard: type changes @regression passes widenings and alias restatements", + "decoder migration live-schema MODIFY guard: type changes @regression refuses a same-name restatement that drops precision, fractional seconds or width", + "decoder migration live-schema MODIFY guard: type changes @regression refuses a type change it has no widening rule for" ], "8a87ff6abaa5f1be": [ "installCrashHandlers installs one handler per event with the required log and exit behavior", @@ -1878,6 +2026,11 @@ "reportStartFailure logs an Error with reorg halt context and exits once", "reportStartFailure normalizes a non-Error without a stack or halt context" ], + "8b1bac8a4cab2cbd": [ + "Database.destructiveAutoStatement() session SETs @regression flags every other SET: sql_mode, check flags, GLOBAL, multi-assignment, other zones, SET STATEMENT", + "Database.destructiveAutoStatement() session SETs @regression flags the relax-then-narrow file end to end, and keeps a UTC zone before an additive ALTER auto", + "Database.destructiveAutoStatement() session SETs @regression keeps the allow-listed spellings auto-eligible" + ], "8b70a95f87f4b7d8": [ "Fuzz: parseTransaction P2WSH with missing/corrupt witness should handle 500 P2WSH txs with corrupt witness" ], @@ -1908,6 +2061,13 @@ "Database#getAllOpenDispenserAddresses() grace floor still returns null on a query fault, with or without a floor", "Database#getAllOpenDispenserAddresses() grace floor treats a null or non-finite floor as no grace at all" ], + "8d1ac41b3c8d3fc3": [ + "decoder reorg table coverage deleteBlockByIndex deletes exactly the rollback tables", + "decoder reorg table coverage every exemption carries a written reason", + "decoder reorg table coverage every policy entry names a real src/sql table", + "decoder reorg table coverage every src/sql table has a recorded reorg decision", + "decoder reorg table coverage reads a non-trivial table list from src/sql" + ], "8d9e5d5d21b31cbc": [ "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] encodeVarintHex is byte-identical in both repos", "AuxPoW strip parity with xchain-utxo-tracker @regression cross-repo byte identity [REGRESSION P1] every shared function carries a Keep-in-sync comment on both sides", @@ -1937,6 +2097,10 @@ "Security: BlockchainConnector Protocol handling should not double-prefix when http:// is already present", "Security: BlockchainConnector Protocol handling should not double-prefix when https:// is already present" ], + "914b98b3994edc4c": [ + "Taproot envelope published chunk vectors recognizes and reassembles the published chunk_rebalance vector", + "Taproot envelope published chunk vectors recognizes and reassembles the published envelope_chunking vector" + ], "9287c127d042af8f": [ "Security: Connection Handling Transaction lock mechanism [REGRESSION P0] R-SEC-003: should acquire lock on first call", "Security: Connection Handling Transaction lock mechanism should initialize lock state correctly", @@ -1987,6 +2151,15 @@ "Smoke: Multisig Transaction Parsing should return empty dispenseOutputs", "Smoke: Multisig Transaction Parsing should return non-empty data from a multisig tx" ], + "94d278f823552144": [ + "Database.destructiveAutoStatement() DROP target @regression flags a DROP of a backtick-quoted column named like a metadata keyword", + "Database.destructiveAutoStatement() DROP target @regression flags a DROP whose target only starts with a keyword or cannot be read", + "Database.destructiveAutoStatement() DROP target @regression keeps bare metadata-only DROP forms auto-eligible" + ], + "951b85192ced37b0": [ + "getmempool stale reads reports a stale empty fallback when the first read fails without a prior good timestamp", + "getmempool stale reads serves the last good snapshot as stale and retries immediately after a failed refresh" + ], "95b9a69144b6d40d": [ "migration operator hints [REGRESSION P1] points every source hint at the existing migration entrypoint" ], @@ -2012,25 +2185,28 @@ "decoder stability fixes @regression zero-input tx is skipped cleanly instead of throwing parseTransaction returns null for a tx with no inputs", "decoder stability fixes @regression zero-input tx is skipped cleanly instead of throwing parseTransaction returns null when ins is absent" ], - "99319118c2a13885": [ - "malformed-AuxPoW block reassembly fallback BlockchainConnector.getBlockReassembled fails loudly when an in-block tx cannot be fetched", - "malformed-AuxPoW block reassembly fallback BlockchainConnector.getBlockReassembled fetches txs through the bounded batch helper, not serially", - "malformed-AuxPoW block reassembly fallback BlockchainConnector.getBlockReassembled preserves error.code and the original error as cause on a transport fault", - "malformed-AuxPoW block reassembly fallback BlockchainConnector.getBlockReassembled rebuilds header + tx-count varint + raw txs, parseable as a block", - "malformed-AuxPoW block reassembly fallback BlockchainConnector.getBlockReassembled uses only the first 80 header bytes when getblockheader appends AuxPoW bytes", - "malformed-AuxPoW block reassembly fallback BlockchainConnector.getBlockReassembled wraps a content fault with a cause and no invented code", - "malformed-AuxPoW block reassembly fallback BlockchainConnector.probeTxIndex returns false when getrawtransaction finds nothing (txindex missing)", - "malformed-AuxPoW block reassembly fallback BlockchainConnector.probeTxIndex returns null (never throws) when the probe RPCs fail", - "malformed-AuxPoW block reassembly fallback BlockchainConnector.probeTxIndex returns null on an empty chain (genesis coinbase is never indexed)", - "malformed-AuxPoW block reassembly fallback BlockchainConnector.probeTxIndex returns true when the tip coinbase is retrievable without a blockhash", - "malformed-AuxPoW block reassembly fallback XChainDecoder.fetchBlockHex does not reassemble when transport faults, not content faults, drove the count", - "malformed-AuxPoW block reassembly fallback XChainDecoder.fetchBlockHex falls back to getBlockReassembled at the failure threshold", - "malformed-AuxPoW block reassembly fallback XChainDecoder.fetchBlockHex never reassembles on a non-AuxPoW chain", - "malformed-AuxPoW block reassembly fallback XChainDecoder.fetchBlockHex uses getBlockWithoutAuxPow below the failure threshold", - "malformed-AuxPoW block reassembly fallback encodeVarintHex encodes each varint width and refuses >2^32-1", - "malformed-AuxPoW block reassembly fallback getBlockWithoutAuxPow fault classification propagates a getBlock transport fault unwrapped too", - "malformed-AuxPoW block reassembly fallback getBlockWithoutAuxPow fault classification propagates an RPC transport fault unwrapped, with error.code intact", - "malformed-AuxPoW block reassembly fallback getBlockWithoutAuxPow fault classification tags an untraversable AuxPoW section as a content fault" + "97b9a29072a0f010": [ + "runMigrations() migration connection retirement @regression closes the connection when a file fails partway through its statements", + "runMigrations() migration connection retirement @regression closes, never pools, a connection that applied a migration file, after unlocking", + "runMigrations() migration connection retirement @regression returns the connection to the pool when no file ran", + "runMigrations() migration-scoped statement timeout @regression never touches the session when the lock is not acquired", + "runMigrations() migration-scoped statement timeout @regression raises the limit after GET_LOCK and restores it before RELEASE_LOCK", + "runMigrations() migration-scoped statement timeout @regression reads MIGRATE_QUERY_TIMEOUT=-5 as session values 3600, 30", + "runMigrations() migration-scoped statement timeout @regression reads MIGRATE_QUERY_TIMEOUT=0 as session values 0, 30", + "runMigrations() migration-scoped statement timeout @regression reads MIGRATE_QUERY_TIMEOUT=900000 as session values 900, 30", + "runMigrations() migration-scoped statement timeout @regression reads MIGRATE_QUERY_TIMEOUT=abc as session values 3600, 30", + "runMigrations() migration-scoped statement timeout @regression restores the limit before RELEASE_LOCK when a statement fails", + "runMigrations() migration-scoped statement timeout on scoped runs @regression destroys instead of pooling the connection when the restore fails", + "runMigrations() migration-scoped statement timeout on scoped runs @regression leaves the session alone for a scoped run with nothing to apply", + "runMigrations() migration-scoped statement timeout on scoped runs @regression raises and restores the limit for a scoped run that applies its file" + ], + "98dd8c6fd3e3decd": [ + "Database#purgeExpiredDispensers() grace predicate keeps the legacy height-only delete when no purge floor is active", + "Database#purgeExpiredDispensers() grace predicate requires both the mark time and expiration to be older than the grace floor", + "block store purge grace plumbing passes a grace floor derived from the committed block protocol time", + "block store purge grace plumbing passes no floor below the purge gate", + "dispenser purge grace decision activates at the purge gate boundary and rejects invalid times", + "dispenser purge grace decision uses a gate independent from capture grace" ], "99ba2e139fbb3406": [ "Fuzz: removeObfuscation H1: short/malformed txid should not crash with txid=\"\"", @@ -2103,11 +2279,22 @@ "roundtrip conformance fixture: every case reaches the stored record reassembles the envelope payload as the encoder compiled it, chunk boundaries included", "roundtrip conformance fixture: every case reaches the stored record recognizes each envelope as the carrier, with the envelope ceiling" ], + "9c3f37e4f6fe7093": [ + "Database.destructiveAutoStatement() CONVERT TO and MODIFY id @regression flags ALTER TABLE ... CONVERT TO in every spelling", + "Database.destructiveAutoStatement() CONVERT TO and MODIFY id @regression flags a MODIFY of id that drops AUTO_INCREMENT", + "Database.destructiveAutoStatement() CONVERT TO and MODIFY id @regression keeps the AUTO_INCREMENT id repair and unrelated MODIFYs auto-eligible", + "Database.destructiveAutoStatement() CONVERT TO and MODIFY id @regression refuses a mode=auto file that converts the charset, from raw file text" + ], "9c60729c4afc15d8": [ "BATCH sub-command ACTION-name gate and alias expansion the indexer side of the argument, driven not asserted enables names this decoder does not know, which is why the gate stops at the empty one", "BATCH sub-command ACTION-name gate and alias expansion the indexer side of the argument, driven not asserted really does reject the EMPTY ACTION name, which is what suppression rests on", "BATCH sub-command ACTION-name gate and alias expansion the indexer side of the argument, driven not asserted rejects an ALIAS name, so expansion must never run below BATCH_SUBACTION_NORMALIZATION" ], + "9cdf9bd6784320ad": [ + "DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION conformance is byte-equal to the canonical declaration in xchain-documentation", + "DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION conformance is re-exported by the public constants module", + "DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION conformance keeps mainnet and testnet inert while regtest is genesis-active" + ], "9d0527ed17a1692f": [ "BATCH dispenser registration two creates on the SAME operating address (the PRIMARY KEY collision) collapses three same-address creates to one row", "BATCH dispenser registration two creates on the SAME operating address (the PRIMARY KEY collision) collapses to ONE row carrying the LATER expiration", @@ -2129,6 +2316,14 @@ "XChainDecoder#removeObfuscation() [REGRESSION P0] R-DEC-005: should decrypt correctly with different txids (different key/iv)", "XChainDecoder#removeObfuscation() [REGRESSION P0] R-DEC-005: should produce wrong plaintext when decrypted with wrong txid" ], + "a194c2b2855f5460": [ + "decoder migration live-schema MODIFY guard @regression finds each MODIFY clause, ignoring FIRST/AFTER and other clauses", + "decoder migration live-schema MODIFY guard @regression passes a MODIFY that restates every attribute or only widens, and an absent column", + "decoder migration live-schema MODIFY guard @regression refuses a MODIFY that strips AUTO_INCREMENT, a DEFAULT, ON UPDATE, COMMENT or a generation expression", + "decoder migration live-schema MODIFY guard @regression refuses narrowing of type, length and charset", + "decoder migration live-schema MODIFY guard @regression sees a MODIFY behind a block comment and ignores a keyword inside one", + "decoder migration runner applies the live-schema guard to auto files @regression refuses an auto MODIFY that would strip a live DEFAULT before running any statement" + ], "a1f7b506ab91b517": [ "Malformed Data Integration blocks with mixed valid and invalid transactions should store only the valid XCHN transaction from a block", "Malformed Data Integration decoder stability after invalid data should continue processing valid blocks after encountering invalid data", @@ -2136,6 +2331,15 @@ "Malformed Data Integration non-XCHN transactions should not store a transaction with a text OP_RETURN (like \"HELLO WORLD\")", "Malformed Data Integration non-XCHN transactions should not store a transaction with non-XCHN OP_RETURN data" ], + "a30a253f8b183809": [ + "getRawTransaction accepts only a hex string passes a hex answer through unchanged", + "getRawTransaction accepts only a hex string rejects after the retry budget, counting one RPC error and naming the txid", + "getRawTransaction accepts only a hex string retries a non-hex string and counts no RPC error when the next answer is whole", + "getRawTransaction accepts only a hex string retries a number and counts no RPC error when the next answer is whole", + "getRawTransaction accepts only a hex string retries an object and counts no RPC error when the next answer is whole", + "getRawTransaction accepts only a hex string retries an odd-length hex string and counts no RPC error when the next answer is whole", + "getRawTransaction accepts only a hex string still resolves null on an empty answer" + ], "a3fabf061091f85f": [ "BATCH sub-command split a nested BATCH sub-command is returned as-is (the indexer rejects the whole batch)", "BATCH sub-command split does not let a LATER BATCH|0| occurrence pass off as the stripped head", @@ -2230,23 +2434,49 @@ "Security: DISPENSER Field Validation Version parsing safety [REGRESSION P1] R-DSP-002: should verify parseInt uses radix 10 in source code", "Security: DISPENSER Field Validation Version parsing safety should verify Number() is used for expiration (not parseInt)" ], - "a712626ef67e3e03": [ - "protocol time falls back to the raw clock at the history boundary", - "protocol time never resolves protocol time later than the block stamp", - "protocol time returns an immutable context without changing replay inputs", - "protocol time uses previous-block MTP only on the armed network" + "a6b2950c08ad3319": [ + "BlockchainConnector #getBlock() should request raw hex format by default (hexFormat=true means verbose=false)", + "BlockchainConnector #getBlock() should return block hex on success", + "BlockchainConnector #getBlock() should throw when response has no result", + "BlockchainConnector #getBlockHash() result shape rejects a malformed result and counts it as an RPC error", + "BlockchainConnector #getBlockHash() result shape returns an uppercase hash lowercased so a case change cannot fake a fork", + "BlockchainConnector #getBlockHash() should coerce a BigInt block index to a Number param (serializable JSON-RPC body)", + "BlockchainConnector #getBlockHash() should pass the block index as params", + "BlockchainConnector #getBlockHash() should propagate network errors", + "BlockchainConnector #getBlockHash() should return the block hash on success", + "BlockchainConnector #getBlockHash() should throw when response has no result", + "BlockchainConnector #getBlockHeader() [REGRESSION P2] R-RPC-001: should retry on timeout (ECONNABORTED)", + "BlockchainConnector #getBlockHeader() [REGRESSION P2] R-RPC-001: should throw after exhausting all 10 timeout retries", + "BlockchainConnector #getBlockHeader() should return block header on success", + "BlockchainConnector #getBlockHeader() should throw immediately on non-timeout errors", + "BlockchainConnector #getBlockchainInfo() [REGRESSION P2] R-RPC-001: should return the result on success", + "BlockchainConnector #getBlockchainInfo() should send correct JSON-RPC method", + "BlockchainConnector #getBlockchainInfo() should throw when response has no result", + "BlockchainConnector #getBlockchainInfo() should use auth credentials", + "BlockchainConnector #getNetworkInfo() should return the result on success", + "BlockchainConnector #getNetworkInfo() should throw when response has no result", + "BlockchainConnector #getRawMempool() should return mempool txids on success", + "BlockchainConnector #getRawMempool() should throw when response has no result", + "BlockchainConnector #getRawTransaction() [REGRESSION P2] R-RPC-002: should back off longer on -429 work queue depth exceeded", + "BlockchainConnector #getRawTransaction() should resolve null when response has no result (tx mined/evicted)", + "BlockchainConnector #getRawTransaction() should resolve on success after retries", + "BlockchainConnector #getRawTransaction() should retry on network failure up to 10 times", + "BlockchainConnector #getRawTransaction() should return raw tx hex on success", + "BlockchainConnector #getRawTransactions() should batch multiple getRawTransaction calls", + "BlockchainConnector #getRawTransactions() should not fail the whole batch when one tx is mined/evicted (resolves null)", + "BlockchainConnector #getRawTransactions() should return empty array for empty input", + "BlockchainConnector constructor should construct the URL from host and port", + "BlockchainConnector constructor should store rpc credentials", + "BlockchainConnector getBlockHash rethrows an HTTP-200 JSON-RPC error carrying rpcCode/rpcMessage" ], - "a7c7a6651942ddef": [ - "Database#getMempoolTransactionCount() returns the count as a Number (BigInt-safe) and 0 on an empty result", - "Database#getMempoolTransactions() reads the raw-string columns + first_seen in tx_hash order with a clamped limit", - "Database#getMempoolTransactions() releases the connection even when the query throws", - "api.js getmempool method (source pin) exposes getmempool on the JSON-RPC controller", - "api.js getmempool method (source pin) maps the node-mempool observation snapshot into the response", - "api.js getmempool method (source pin) reads rows via the bounded DB helpers and clamps the per-request limit to 500", - "api.js getmempool method (source pin) serves from a TTL cache so an unauthenticated burst cannot amplify into DB reads", - "node-mempool observation snapshot a failed getrawmempool leaves the previous snapshot standing", - "node-mempool observation snapshot starts unknown: -1 count / null timestamp until the first poll", - "node-mempool observation snapshot updateMempool records the DEDUPED node mempool size and a timestamp" + "a87567a9516739f2": [ + "BATCH dispenser registration a top-level DISPENSER is untouched on both sides of the gate defaults an omitted EXPIRATION from the block time on both sides", + "BATCH dispenser registration a top-level DISPENSER is untouched on both sides of the gate registers a delegated create on GET_ADDRESS and records the create SOURCE", + "BATCH dispenser registration a top-level DISPENSER is untouched on both sides of the gate registers a top-level create above the gate", + "BATCH dispenser registration a top-level DISPENSER is untouched on both sides of the gate registers a top-level create below the gate, byte-identically", + "BATCH dispenser registration a top-level DISPENSER is untouched on both sides of the gate skips a top-level create whose coins name another chain, on both sides", + "BATCH dispenser registration a top-level DISPENSER is untouched on both sides of the gate still extends on a top-level v2 edit, and registers no create row", + "BATCH dispenser registration address-id collapse activation collapses table-equivalent address variants through the block loop" ], "a8fd96d3c6db8dcc": [ "Database#getAllOpenDispenserAddresses() returns a Set of every open-dispenser address from a single query", @@ -2261,6 +2491,12 @@ "Smoke: OP_RETURN Transaction Parsing should return non-empty data from a valid OP_RETURN tx", "Smoke: OP_RETURN Transaction Parsing should return the expected result structure" ], + "aa473d78908111d8": [ + "reorg entry paths log at warn @unit block_ingest.js logs \"A reorg has been detected at block\" through logWarn", + "reorg entry paths log at warn @unit reorg_verification.js logs \"reorg: rolled back\" through logWarn", + "reorg entry paths log at warn @unit sync_loop.js logs \"Equal-height tip replacement detected\" through logWarn", + "reorg entry paths log at warn @unit tip_refresh.js logs \"Reconciling orphan blocks...\" through logWarn" + ], "aaf7aa8a7e0a3dd3": [ "XChainDecoder#isFutureSegwitScript() should return false for OP_0 (v0 segwit, handled by bitcoinjs)", "XChainDecoder#isFutureSegwitScript() should return false for OP_1 (v1 taproot, handled by bitcoinjs)", @@ -2273,6 +2509,16 @@ "XChainDecoder#isFutureSegwitScript() should return true for OP_16 (v16 future segwit) with valid push length", "XChainDecoder#isFutureSegwitScript() should return true for OP_2 (v2 future segwit) with valid push length" ], + "ab8fb229aacfa93f": [ + "dispenser expiration extend undo keeps an earlier block close in place when restoring", + "dispenser expiration extend undo records the pre-extend row with the block-start value kept on a second extend", + "dispenser expiration extend undo restores expiration and an orphaned-block expiry mark through deleteBlockByIndex", + "dispenser extension undo purge and failure purges undo rows at the reorg-safe height with the expired dispensers", + "dispenser extension undo purge and failure record returns false on a query error", + "dispenser_extension_undo schema is a src/sql table the startup reconciler creates and drift-checks, with nullable pre-images", + "extendEditedDispenser records before it extends", + "extendEditedDispenser rolls back without extending when the record fails" + ], "abdf4cc518953c22": [ "CE-03: Database Connection Pool Exhaustion beginTransaction should release connection on beginTransaction failure", "CE-03: Database Connection Pool Exhaustion commitTransaction should rollback and release on commit failure", @@ -2362,15 +2608,18 @@ "graceful shutdown resolveTimeoutMs stays under the 120 s budget xchain-node gives a decoder", "graceful shutdown resolveTimeoutMs stays under the stop budget in xchain-node's own table when that checkout is beside this one" ], - "b890eff5e5d00764": [ - "roundtrip conformance fixture: byte-identity to encoder original vendored test/fixtures/roundtrip-conformance.json is byte-identical to the encoder original" - ], - "b8f6917eb3bf48ea": [ + "b7c9218357c1b1f2": [ + "XChainDecoder.verifyReorg mid-walk tip refresh from a catching-up node accepts the refreshed tip once the node has finished catching up", + "XChainDecoder.verifyReorg mid-walk tip refresh from a catching-up node ignores a refreshed tip while the node reports initial block download", + "XChainDecoder.verifyReorg mid-walk tip refresh from a catching-up node ignores a refreshed tip while verification progress is below the parse threshold", "XChainDecoder.verifyReorg mid-walk tip regression keeps retrying (does not crash) when the node is fully unreachable", "XChainDecoder.verifyReorg mid-walk tip regression refreshes nodeTip on out-of-range so a regressed tip self-heals instead of wedging", "XChainDecoder.verifyReorg mid-walk tip regression refuses a same-tier foreign endpoint tip refresh (genesis pin mismatch) and deletes no local blocks", "XChainDecoder.verifyReorg mid-walk tip regression still accepts the refreshed tip when block 0 agrees, so the self-heal happy path is unchanged" ], + "b890eff5e5d00764": [ + "roundtrip conformance fixture: byte-identity to encoder original vendored test/fixtures/roundtrip-conformance.json is byte-identical to the encoder original" + ], "b97f08cbf07b98ec": [ "CE-09: Unhandled Promise Rejection api.js catch handler should track decoder crash state", "CE-09: Unhandled Promise Rejection decoder.start() should throw when database creation fails", @@ -2406,6 +2655,26 @@ "Security: BlockchainConnector Credential handling [REGRESSION P0] R-SEC-001: should not include credentials in the URL", "Security: BlockchainConnector Protocol handling [REGRESSION P0] R-SEC-001: should preserve https:// when specified" ], + "be256411308fe4e8": [ + "compiled-push-size arbiter conformance OP_RETURN_PUSH_OVERHEAD is name-keyed and value-identical equals the vendored canonical protocol constant", + "compiled-push-size arbiter conformance OP_RETURN_PUSH_OVERHEAD is name-keyed and value-identical is exactly what compiledPushSize adds above the OP_PUSHDATA1 ceiling", + "compiled-push-size arbiter conformance dual-push summation matches the real compiled two-push script", + "compiled-push-size arbiter conformance matches bitcoin.script.compile byte-for-byte across the prefix boundaries", + "compiled-push-size arbiter conformance parity with the encoder compiledPushSize MAX constants stay equal across the two services", + "compiled-push-size arbiter conformance parity with the encoder compiledPushSize agrees with the decoder helper for every length up to the ceiling", + "compiled-push-size arbiter conformance parity with the encoder compiledPushSize envelope push band (0xffff .. ENVELOPE_MAX_PAYLOAD) envelopePushSize equals the real compiled push length across the band", + "compiled-push-size arbiter conformance parity with the encoder compiledPushSize envelope push band (0xffff .. ENVELOPE_MAX_PAYLOAD) re-measuring at the ceiling would under-count, which is why the decoder does not", + "compiled-push-size arbiter conformance parity with the encoder compiledPushSize envelope push band (0xffff .. ENVELOPE_MAX_PAYLOAD) the decoder helper under-counts by exactly 2 above 0xffff, and not below", + "compiled-push-size arbiter conformance the 8192 accept/drop boundary is where the encoder and the compiled script agree", + "payload_helpers pure functions bigIntBufferutilsActive fails safe for a missing reader or a throwing reader", + "payload_helpers pure functions bigIntBufferutilsActive reports the installed bufferutils as patched", + "payload_helpers pure functions canonicalizeActionPayload expands an alias and preserves bytes after the first pipe", + "payload_helpers pure functions canonicalizeActionPayload returns the original buffer for a canonical or unknown name", + "payload_helpers pure functions canonicalizeActionPayload treats a pipe-free payload as all name", + "payload_helpers pure functions compiledPushSize adds one byte through 75, two through 255, three from 256", + "payload_helpers pure functions compiledPushSize keeps the three-byte overhead across 65535 and 65536", + "payload_helpers pure functions nodeStillCatchingUp is true only for a strict true initialblockdownload" + ], "bea80fd3971653d5": [ "protocol time parity with indexer @regression keeps medianTimePast behavior identical across value vectors", "protocol time parity with indexer @regression keeps protocolTime behavior identical across value vectors", @@ -2447,36 +2716,6 @@ "XChainDecoder#parseTransaction() [REGRESSION P0] R-SCR-001: should return null for a coinbase transaction", "XChainDecoder#parseTransaction() [REGRESSION P0] R-SCR-004: should decode a 1-of-3 multisig transaction" ], - "c7bb56e0e47bab3f": [ - "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides a clear newer than the halt reads as not halted, and says when and why it was cleared", - "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides a halt row with an unreadable code or payload still counts as live (fail-closed)", - "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides a halt with no clear is live", - "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides a later halt after a clear is live again", - "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides asks for the newest of BOTH codes in one query", - "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt clears when the pinned halt is still the live one", - "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt is a no-op on a database that is not halted", - "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt refuses a missing or trivial reason before touching the database", - "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt refuses a pinned clear when the live halt id is unreadable (fail-closed)", - "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt refuses an unpinned clear when the live halt id is unreadable (fail-closed)", - "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt refuses when the live halt is not the one the checks were taken against", - "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt reports not-cleared when the write does not land", - "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt writes a REORG_HALT_CLEARED row that supersedes the halt and confirms by read-back", - "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clears when the driver returns the halt row id as a BigInt", - "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides getReorgHaltMarker surfaces the live halt id the clear pins to", - "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides no row at all is not halted", - "clear-reorg-halt CLI --dry-run reports the verdict and writes nothing", - "clear-reorg-halt CLI --dry-run without --reason prints the verdict, writes nothing and exits 0", - "clear-reorg-halt CLI clears a clean database and records the checks", - "clear-reorg-halt CLI is a no-op when no halt is live", - "clear-reorg-halt CLI parses --reason, --force and --dry-run", - "clear-reorg-halt CLI pins the halt its checks were measured against", - "clear-reorg-halt CLI refuses a database that has held dispenser state unless forced, and records the force", - "clear-reorg-halt CLI refuses a live halt whose events id is unreadable, dry run included, and never clears", - "clear-reorg-halt CLI refuses when the decoder halted again while the checks ran, and says to re-run", - "clear-reorg-halt CLI refuses without a substantive reason and writes nothing", - "clear-reorg-halt CLI refuses, and cannot be forced, while rolled-back blocks are still missing above the tip", - "clear-reorg-halt CLI reports failure when the clear row does not land" - ], "c907290d04418767": [ "Boundary: bigIntSatoshiToDecimalsString (DB-6 through DB-8) -1 \u2192 \"-0.00000001\"", "Boundary: bigIntSatoshiToDecimalsString (DB-6 through DB-8) -100000000 \u2192 \"-1.00000000\"", @@ -2504,6 +2743,38 @@ "Smoke: XChainBlockDecoder should throw on empty hex", "Smoke: XChainBlockDecoder should throw on truncated hex (< 80 bytes)" ], + "ca3928e5b27e5791": [ + "DISPENSER wide expiration clamps every decimal token in the u64 gap without Number rounding", + "DISPENSER wide expiration keeps every safe unsigned integer exact on both sides of the gate", + "DISPENSER wide expiration pins the activation map and public constants export", + "DISPENSER wide expiration registration does not extend for a wide edit below the gate or an overflowing edit", + "DISPENSER wide expiration registration extends for a wide edit using the same clamp", + "DISPENSER wide expiration registration registers a wide create at the clamped advisory expiration", + "DISPENSER wide expiration registration rejects values the indexer u64 column cannot represent", + "DISPENSER wide expiration registration retains the historical rejection below the gate", + "DISPENSER wide expiration uses inclusive activation semantics and fails closed" + ], + "cb25ed0b62ac6428": [ + "Database#countReorgDeletesAboveTip() THROWS on a marker payload that is not the expected array", + "Database#countReorgDeletesAboveTip() THROWS on a non-numeric block_index", + "Database#countReorgDeletesAboveTip() THROWS on an unparseable marker payload", + "Database#countReorgDeletesAboveTip() bounds the scan, and refuses a nonsense bound rather than emitting it as SQL", + "Database#countReorgDeletesAboveTip() counts a height once even when it was deleted, re-synced and deleted again", + "Database#countReorgDeletesAboveTip() counts only the marked heights above the current tip", + "Database#countReorgDeletesAboveTip() handles the pre-M-12 multi-entry payload shape", + "Database#countReorgDeletesAboveTip() propagates a tip read that could not be answered, rather than counting against a guess", + "Database#countReorgDeletesAboveTip() returns zero on a database with no REORG markers at all", + "Database#countReorgDeletesAboveTip() scans beyond the 5006-block litecoin testnet ceiling by default", + "verifyReorg: a re-deleted height does not shrink the carried-over depth counts every distinct height above the tip when repeat markers fill the newest rows", + "verifyReorg: the safe-depth ceiling survives a lost halt marker deletes nothing at all when the prior depth cannot be read", + "verifyReorg: the safe-depth ceiling survives a lost halt marker names both halves of the depth in the abort message, so the operator sees the resume", + "verifyReorg: the safe-depth ceiling survives a lost halt marker recovers when the read fault is transient", + "verifyReorg: the safe-depth ceiling survives a lost halt marker refuses the FIRST delete when the committed REORG markers already reach the ceiling", + "verifyReorg: the safe-depth ceiling survives a lost halt marker spends only the depth that is left, then aborts", + "verifyReorg: the safe-depth ceiling survives a lost halt marker still refuses on the halt marker when one DID survive, before counting anything", + "verifyReorg: the safe-depth ceiling survives a lost halt marker treats a prior depth that is not a non-negative integer as a read fault, deleting nothing", + "verifyReorg: the safe-depth ceiling survives a lost halt marker without the durable count, a restart spends a whole fresh budget" + ], "cb98f7659ab544d2": [ "CE-07: Concurrent Instance Behavior deleteBlockByIndex should use its own transaction scope", "CE-07: Concurrent Instance Behavior deleteBlockByIndex writes the REORG marker inside the same transaction", @@ -2519,6 +2790,15 @@ "BATCH dispenser registration batched v2 refill / v1 cancel reaches a create placed AFTER it in the same batch too (hold-open-longer)", "BATCH dispenser registration batched v2 refill / v1 cancel resolves against a dispenser created in the SAME batch" ], + "cd8e0e210f80e0cb": [ + "migration executable-residue pin @regression every pinned migration on disk still matches its pin", + "migration executable-residue pin @regression pins at least the migrations the rebaseline table names", + "migration executable-residue pin @regression pins each rebaselined file at the hash its rebaseline heals the ledger to", + "migration executable-residue pin: the guard bites @regression a comment-only edit is a content violation unless a rebaseline heals exactly that pair", + "migration executable-residue pin: the guard bites @regression a removed pinned file is missing, and a new unpinned file is free", + "migration executable-residue pin: the guard bites @regression an added statement is a residue violation, even with a rebaseline", + "migration executable-residue pin: the guard bites @regression an untouched file passes" + ], "cdab1aa16d30bd48": [ "/live reports the stale tip without gating on it answers 200 on a stale tip but says so in the body", "/live reports the stale tip without gating on it is wired into the real /live handler with the healthy gate untouched", @@ -2577,6 +2857,14 @@ "Database#getConnection() succeeds on first pool.getConnection call", "Database#getConnection() throws after maxAttempts (30) consecutive failures" ], + "d104a881d7ea316d": [ + "bufferutils patch twin parity with xchain-utxo-tracker @regression src/chain/apply_bufferutils_patch.js is byte-identical in both repos", + "bufferutils patch twin parity with xchain-utxo-tracker @regression src/chain/bufferutils.js is byte-identical in both repos" + ], + "d18469488f659a45": [ + "protocol time newest-first caller contract fetchPreviousBlockTimes returns a height-ordered window bounded by the median span", + "protocol time newest-first caller contract returns the available newest-first prefix near genesis" + ], "d19fabd24013167c": [ "node_catching_up rides the health payloads /live publishes null when no wait is running, never an omitted key", "node_catching_up rides the health payloads /live publishes the wait verbatim (the real registrar, not a copy of it)", @@ -2593,12 +2881,30 @@ "DISPENSER PRICE v1 oracle-fee output capture captures a v2 refill oracle-fee output using the stored dispenser oracle address", "DISPENSER PRICE v1 oracle-fee output capture captures the oracle-fee output of a v0 Mode B create" ], + "d365682fdae6dbbc": [ + "envInt trims valid integers and honors a custom minimum", + "envInt uses the fallback for missing, empty, and invalid values", + "envInt warns for an empty value but not an undefined value", + "nodeReachabilityFrom dates a later outage from the last successful attempt", + "nodeReachabilityFrom dates an initial outage from connector startup", + "nodeReachabilityFrom reports an unknown state before any node attempt", + "nodeReachabilityFrom reports the last success when the latest attempt succeeded", + "normalizeEndpoint adds missing protocols and ports while preserving complete URLs", + "normalizeEndpoint rejects malformed endpoints" + ], "d39e2dbb1562d76b": [ "Database#insertTransactionOutput() converts BigInt amount to decimal string via bigIntSatoshiToDecimalsString", "Database#insertTransactionOutput() returns DUPLICATED_TRANSACTION on errno 1062", "Database#insertTransactionOutput() returns false on generic error", "Database#insertTransactionOutput() returns true on success" ], + "d41d5e457c323cd7": [ + "collapseDispenserRegistrations address-id collapse keeps table-equivalent variants apart below the gate", + "collapseDispenserRegistrations address-id collapse merges table-equivalent variants once active", + "collapseDispenserRegistrations address-id collapse merges trailing-space variants and tolerates non-lists once active", + "collapseDispenserRegistrations address-id collapse stays inactive everywhere while the constants define no gate", + "collapseDispenserRegistrations address-id collapse uses numeric activation boundaries and rejects invalid inputs" + ], "d42bb5a9f837d16a": [ "Security: ACTION Data Validation Invalid ACTION names [REGRESSION P0] R-ACT-002: should still decode raw data even with unknown ACTION name", "Security: ACTION Data Validation Multisig safety [REGRESSION P0] R-ACT-005: should skip multisig output when pubkey elements are not Buffers", @@ -2652,11 +2958,6 @@ "BlockchainConnector RPC error accounting and reporting the shared result extractor reads PRESENCE, not truthiness still prefers the node error object over the result member", "BlockchainConnector RPC error accounting and reporting the shared result extractor reads PRESENCE, not truthiness still throws the per-method label when the result is absent" ], - "d8cd8319c6020126": [ - "ACTION manifest conformance: decoder wireDecoded set @regression ACTION_ALIASES exactly equals the manifest aliases map", - "ACTION manifest conformance: decoder wireDecoded set @regression VALID_ACTION_NAMES exactly equals the manifest wireDecoded slice", - "ACTION manifest conformance: decoder wireDecoded set @regression byte-identity to canonical manifest vendored test/fixtures/action-manifest.json is byte-identical to canonical" - ], "d8d1e52267ea606f": [ "cross-repo sibling coverage (what this run could NOT verify) declares every sibling it lists in .ci-siblings, so the venue ships them", "cross-repo sibling coverage (what this run could NOT verify) reports every sibling it looked for, so the list itself cannot rot silently", @@ -2693,23 +2994,38 @@ "Boundary: Script Type Detection (S-1 through S-7) [REGRESSION P1] R-SCR-005 S-1c: empty leading push with a trailing rawData push is reported, not silently blanked", "Boundary: Script Type Detection (S-1 through S-7) [REGRESSION P1] R-SCR-006 S-1d: a lone OP_0 payload stays silent (no false parse error)" ], - "da7078876aa77bf9": [ - "/live gates on the poll-loop heartbeat answers 200 while the loop is iterating, caught up and quiet", - "/live gates on the poll-loop heartbeat answers 503 the moment the running flag drops, ahead of the silence window", - "/live gates on the poll-loop heartbeat answers 503 when the DB ping fails, which the heartbeat must not mask", - "/live gates on the poll-loop heartbeat answers 503 when the loop stops iterating, with nothing else having changed", - "/live gates on the poll-loop heartbeat is not silent before the loop has iterated once, so a booting decoder stays 200", - "/live gates on the poll-loop heartbeat reports a latent REORG_HALT marker on the surface that is actually polled", - "/live gates on the poll-loop heartbeat reports the halt as a stable false, not an absent key, when there is no marker", - "/live gates on the poll-loop heartbeat stays 200 through a node-tip outage, so containers are not restarted", - "/live gates on the poll-loop heartbeat still answers 200 while halted, so autoheal cannot restart-loop a resync case", - "/live says whether the halt marker was ever read publishes a null reorg_halt_checked_at when no marker read has completed", - "/live says whether the halt marker was ever read publishes the read time once a marker read has completed", - "/live says whether the halt marker was ever read publishes who cleared a halt, so a cleared decoder is not read as never halted", - "a failed DB ping keeps the halt the decoder already knows /live reports the cached halt when the ping answers false rather than throwing", - "a failed DB ping keeps the halt the decoder already knows /live still reports the halt and the park while the ping throws", - "a failed DB ping keeps the halt the decoder already knows invents no halt: a clean decoder with a dead DB still reads not-halted and never-looked", - "a failed DB ping keeps the halt the decoder already knows the JSON-RPC health probe reports the cached halt while the ping throws" + "dbef882bb2e58f54": [ + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides a clear newer than the halt reads as not halted, and says when and why it was cleared", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides a halt row with an unreadable code or payload still counts as live (fail-closed)", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides a halt with no clear is live", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides a later halt after a clear is live again", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides asks for the newest of BOTH codes in one query", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt clears when the pinned halt is still the live one", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt is a no-op on a database that is not halted", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt refuses a missing or trivial reason before touching the database", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt refuses a pinned clear when the live halt id is unreadable (fail-closed)", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt refuses an unpinned clear when the live halt id is unreadable (fail-closed)", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt refuses when the live halt is not the one the checks were taken against", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt reports not-cleared when the write does not land", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clearReorgHalt writes a REORG_HALT_CLEARED row that supersedes the halt and confirms by read-back", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides clears when the driver returns the halt row id as a BigInt", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides getReorgHaltMarker surfaces the live halt id the clear pins to", + "Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides no row at all is not halted", + "clear-reorg-halt CLI --dry-run reports the verdict and writes nothing", + "clear-reorg-halt CLI --dry-run without --reason prints the verdict, writes nothing and exits 0", + "clear-reorg-halt CLI clears a clean database and records the checks", + "clear-reorg-halt CLI is a no-op when no halt is live", + "clear-reorg-halt CLI parses --reason, --force and --dry-run", + "clear-reorg-halt CLI pins the halt its checks were measured against", + "clear-reorg-halt CLI refuses a database that has held dispenser state unless forced, and records the force", + "clear-reorg-halt CLI refuses a lax or NO_BACKSLASH_ESCAPES session, dry run included, and never clears", + "clear-reorg-halt CLI refuses a live halt whose events id is unreadable, dry run included, and never clears", + "clear-reorg-halt CLI refuses when the decoder halted again while the checks ran, and says to re-run", + "clear-reorg-halt CLI refuses without a substantive reason and writes nothing", + "clear-reorg-halt CLI refuses, and cannot be forced, when the above-tip count is not a non-negative integer", + "clear-reorg-halt CLI refuses, and cannot be forced, while rolled-back blocks are still missing above the tip", + "clear-reorg-halt CLI reports failure when the clear row does not land", + "clear-reorg-halt CLI still clears on the stock strict session" ], "dc2a6bdefc1386bc": [ "Database#deleteAndCompareTxsNotInList() deletes every stored row when the node mempool is empty", @@ -2725,57 +3041,36 @@ "fixture ports keeps base ports when CI_PORT_OFFSET is absent or zero", "fixture ports rejects invalid offsets and out-of-range results" ], - "dc9720d3655e4785": [ - "Database.backdatedFrontierViolation() @regression accepts a Map keys() iterator, which is what the apply loop passes", - "Database.backdatedFrontierViolation() @regression compares against the MAXIMUM applied name, not the last one seen", - "Database.backdatedFrontierViolation() @regression does not trip a resumed partial run over the shipped auto files", - "Database.backdatedFrontierViolation() @regression ignores an undated legacy ledger row when computing the frontier", - "Database.backdatedFrontierViolation() @regression never trips on a fresh install (empty ledger)", - "Database.backdatedFrontierViolation() @regression reports the frontier when a pending file is dated before an applied one", - "Database.backdatedFrontierViolation() @regression stays silent for a pending file dated after everything applied", - "Database.backdatedFrontierViolation() @regression still reports a real violation when an undated legacy row is present", - "Database.backdatedFrontierViolation() @regression treats an equal name as applied, not backdated", - "Database.destructiveAutoStatement() @regression does NOT flag benign system-variable SETs (SET NAMES / SET sql_mode / SET @@)", - "Database.destructiveAutoStatement() @regression does not flag a `#` inside a quoted literal or a block comment", - "Database.destructiveAutoStatement() @regression does not flag additive / widening statements", - "Database.destructiveAutoStatement() @regression does not flag an ordinary column whose name merely contains \"partition\"", - "Database.destructiveAutoStatement() @regression does not flag metadata-only drops (INDEX/KEY/FOREIGN KEY/CONSTRAINT/PRIMARY KEY)", - "Database.destructiveAutoStatement() @regression does not let a destructive keyword inside a block comment trigger a hit", - "Database.destructiveAutoStatement() @regression flags ALTER TABLE ... DROP COLUMN and a bare column drop", - "Database.destructiveAutoStatement() @regression flags ALTER TABLE ... RENAME (TO / COLUMN) and CHANGE", - "Database.destructiveAutoStatement() @regression flags ALTER TABLE partition and tablespace clauses", - "Database.destructiveAutoStatement() @regression flags CREATE OR REPLACE TABLE (atomic DROP+CREATE wipes rows) but not plain/IF NOT EXISTS", - "Database.destructiveAutoStatement() @regression flags DELETE FROM", - "Database.destructiveAutoStatement() @regression flags DROP DATABASE / DROP SCHEMA", - "Database.destructiveAutoStatement() @regression flags DROP TABLE", - "Database.destructiveAutoStatement() @regression flags INSERT ... ON DUPLICATE KEY UPDATE but not a plain INSERT", - "Database.destructiveAutoStatement() @regression flags LOAD DATA (rows come from a file the classifier cannot read)", - "Database.destructiveAutoStatement() @regression flags MODIFY ... NOT NULL narrowing (but not the AUTO_INCREMENT repair)", - "Database.destructiveAutoStatement() @regression flags RENAME TABLE", - "Database.destructiveAutoStatement() @regression flags REPLACE INTO (atomic DELETE+INSERT), matching the DELETE guard", - "Database.destructiveAutoStatement() @regression flags TRUNCATE", - "Database.destructiveAutoStatement() @regression flags UPDATE bypasses that smuggle past the id-repair carve-out", - "Database.destructiveAutoStatement() @regression flags a DROP hidden behind a `#` line comment (the server honours `#`)", - "Database.destructiveAutoStatement() @regression flags a NOT NULL-narrowing clause even when a sibling clause is AUTO_INCREMENT", - "Database.destructiveAutoStatement() @regression flags a bare UPDATE but not the committed AUTO_INCREMENT id=0 repair", - "Database.destructiveAutoStatement() @regression flags a destructive statement hidden after a safe one (scans all statements)", - "Database.destructiveAutoStatement() @regression flags a statement still carrying a `#` line comment (strip-regression guard)", - "Database.destructiveAutoStatement() @regression flags dynamic-SQL / stored-routine indirection (PREPARE/EXECUTE/CALL/SET @)", - "Database.destructiveAutoStatement() @regression flags non-canonical DELETE forms that omit an immediate FROM", - "Database.destructiveAutoStatement() @regression flags the SET @/PREPARE/EXECUTE dynamic-SQL bypass as a whole", - "Database.migrationMode() @regression a non-auto/manual value falls through to manual", - "Database.migrationMode() @regression defaults to manual when no tag is present (never auto-runs unknown DDL)", - "Database.migrationMode() @regression does not let a tag below the first SQL statement arm auto-apply (prologue window only)", - "Database.migrationMode() @regression is case-insensitive and tolerant of spacing", - "Database.migrationMode() @regression reads mode=auto from the header tag", - "Database.migrationMode() @regression reads mode=manual from the header tag", - "Database.migrationMode() @regression reads the tag past a multi-line comment banner (banner does not push it out of view)" - ], "dde536d960f1a1a5": [ "mempool first_seen DATETIME migration @regression baselines only when first_seen is already DATETIME", "mempool first_seen DATETIME migration @regression declares no TIMESTAMP column and declares first_seen DATETIME NOT NULL", "mempool first_seen DATETIME migration @regression is manual without a deploy precondition and orders the conversion statements" ], + "de71dddc3a70f32b": [ + "Database schema-contract guards @regression accepts a pubkeys.pubkey wide enough for an uncompressed key", + "Database schema-contract guards @regression accepts both action-text columns already at utf8mb4", + "Database schema-contract guards @regression accepts dispensers.expiration at BIGINT UNSIGNED", + "Database schema-contract guards @regression accepts the stock MariaDB strict mode and STRICT_ALL_TABLES", + "Database schema-contract guards @regression distinguishes a dropped column (drift, throws) from an absent table (skip)", + "Database schema-contract guards @regression is a no-op when the pubkeys table does not exist yet", + "Database schema-contract guards @regression is a no-op when the tables do not exist yet", + "Database schema-contract guards @regression never points a drifted INTEGER column at the DATETIME converter migration", + "Database schema-contract guards @regression refuses a lax session before the migration body runs a single statement", + "Database schema-contract guards @regression rejects NO_BACKSLASH_ESCAPES even under a strict mode", + "Database schema-contract guards @regression rejects a SIGNED bigint, which the old DATA_TYPE-only guard let through", + "Database schema-contract guards @regression rejects a half-migrated pair where only the mempool column lagged", + "Database schema-contract guards @regression rejects a lax mode, naming the quarantine divergence it causes", + "Database schema-contract guards @regression rejects a narrower INT UNSIGNED, naming the truncation against the indexer", + "Database schema-contract guards @regression rejects a narrower signed INT too", + "Database schema-contract guards @regression rejects a transactions.data still at utf8mb3, naming the quarantine it causes", + "Database schema-contract guards @regression rejects the pre-migration DATETIME and names the migration that converts it", + "Database schema-contract guards @regression rejects the pre-widen VARCHAR(66), naming the seam field it would corrupt", + "Database schema-contract guards @regression releases the pooled connection on both the pass and the throw path", + "Database schema-contract guards @regression releases the pooled connection on the expiration pass and throw paths", + "Database schema-contract guards @regression releases the pooled connection on the sql_mode pass and throw paths", + "Database schema-contract guards @regression releases the pooled connection on the utf8mb4 pass and throw paths", + "Database schema-contract guards @regression runs the pubkey guard on every runMigrations exit path, lock-skip included" + ], "df1a7438428d2073": [ "BlockchainConnector NODE_URL_FALLBACK failover endpoint parsing has a single endpoint when NODE_URL_FALLBACK is unset", "BlockchainConnector NODE_URL_FALLBACK failover endpoint parsing ignores an empty NODE_URL_FALLBACK value", @@ -3004,6 +3299,15 @@ "Database#getTransactionId() returns null (swallows) on query error", "Database#getTransactionId() returns null when no rows found" ], + "f3933fa79957c8e8": [ + "collapseRegistrationsByKey collapses selected keys while preserving first-seen values and merge rules", + "collapseRegistrationsByKey falls back to the raw address when the selected key is null", + "collapseRegistrationsByKey keeps distinct selected addresses separate", + "collapseRegistrationsByKey matches the existing raw-address collapse when no key is provided", + "collapseRegistrationsByKey returns an empty array for non-array input", + "collapseRegistrationsByKey returns objects that do not alias input candidates", + "collapseRegistrationsByKey skips missing candidates and candidates without an address" + ], "f45010ff7d50d33a": [ "runMigrations() checksum re-bless path @regression 2026-05-28-unique-index-tables.sql historical revisions heals a ledger recording the guarded revision (50a5e83)", "runMigrations() checksum re-bless path @regression 2026-05-28-unique-index-tables.sql historical revisions heals a ledger recording the original shipped revision (8151979)", @@ -3013,6 +3317,15 @@ "runMigrations() checksum re-bless path @regression is a no-op when the recorded checksum already matches the file", "runMigrations() checksum re-bless path @regression still fails closed on an unpinned recorded checksum (immutability guard intact)" ], + "f461947d31adfc1a": [ + "Database#deleteBlockByIndex() [REGRESSION M-12] rolls back the block delete AND its marker together on failure", + "Database#deleteBlockByIndex() [REGRESSION M-12] writes the per-block REORG marker in the SAME transaction, before commit", + "Database#deleteBlockByIndex() a PARSE_ERROR audit row survives the rollback of its block (append-only events contract)", + "Database#deleteBlockByIndex() executes 5 DELETE queries and returns true on success", + "Database#deleteBlockByIndex() passes blockIndex to DELETE queries", + "Database#deleteBlockByIndex() throws on query error (propagates after rolling back)", + "Database#deleteBlockByIndex() writes NO event when called without a block hash (non-reorg delete stays a plain delete)" + ], "f505ba8dce326503": [ "CE-02: RPC Timeout Storm getBlockHash should propagate errors without retry", "CE-02: RPC Timeout Storm getBlockHeader should NOT retry on non-timeout errors", @@ -3022,18 +3335,6 @@ "CE-02: RPC Timeout Storm getRawTransaction should retry up to 10 times on timeout", "CE-02: RPC Timeout Storm getRawTransactions should fail entire batch if one tx fails" ], - "f557b21579db13fd": [ - "runMigrations() migration-scoped statement timeout @regression never touches the session when the lock is not acquired", - "runMigrations() migration-scoped statement timeout @regression raises the limit after GET_LOCK and restores it before RELEASE_LOCK", - "runMigrations() migration-scoped statement timeout @regression reads MIGRATE_QUERY_TIMEOUT=-5 as session values 3600, 30", - "runMigrations() migration-scoped statement timeout @regression reads MIGRATE_QUERY_TIMEOUT=0 as session values 0, 30", - "runMigrations() migration-scoped statement timeout @regression reads MIGRATE_QUERY_TIMEOUT=900000 as session values 900, 30", - "runMigrations() migration-scoped statement timeout @regression reads MIGRATE_QUERY_TIMEOUT=abc as session values 3600, 30", - "runMigrations() migration-scoped statement timeout @regression restores the limit before RELEASE_LOCK when a statement fails", - "runMigrations() migration-scoped statement timeout on scoped runs @regression destroys instead of pooling the connection when the restore fails", - "runMigrations() migration-scoped statement timeout on scoped runs @regression leaves the session alone for a scoped run with nothing to apply", - "runMigrations() migration-scoped statement timeout on scoped runs @regression raises and restores the limit for a scoped run that applies its file" - ], "f56486fd8ec9ad87": [ "DISPENSER lifecycle mirror: advisory open-view a creator-issued lengthening edit still reaches the delegated dispenser", "DISPENSER lifecycle mirror: advisory open-view a delegated dispenser is NOT closed by a cancel from its original creator", @@ -3102,15 +3403,17 @@ }, "scripts": { "test": { - "fileCount": 188, - "titleCount": 1759, + "fileCount": 223, + "titleCount": 1990, "files": { - "test/unit/action_manifest_conformance.test.js": "d8cd8319c6020126", + "test/unit/action_manifest_conformance.test.js": "3e1e7599837cbf70", "test/unit/alias_expansion_boundary.test.js": "7a5cec648390c3a0", "test/unit/apply_bufferutils_patch.test.js": "d002ffc05ec60ec9", - "test/unit/auxpow_reassembly.test.js": "99319118c2a13885", + "test/unit/apply_bufferutils_patch_twin_parity.test.js": "d104a881d7ea316d", + "test/unit/auxpow_reassembly.test.js": "67e3c12ff1c288e7", "test/unit/auxpow_strip_parity.test.js": "8d9e5d5d21b31cbc", - "test/unit/batch_dispenser_registration.test.js": "1fea0088e53b0230", + "test/unit/batch_dispenser_address_id_collapse.test.js": "d41d5e457c323cd7", + "test/unit/batch_dispenser_registration.test.js": "a87567a9516739f2", "test/unit/batch_dispenser_registration.test/01_a_dispenser_created_inside_a_batch.test.js": "05ef33730e0f85a6", "test/unit/batch_dispenser_registration.test/02_several_dispenser_sub_commands_in_one_batch.test.js": "7d5dc72476b4d580", "test/unit/batch_dispenser_registration.test/03_two_creates_on_the_same_operating_address_the_primary_key_collision.test.js": "9d0527ed17a1692f", @@ -3123,6 +3426,7 @@ "test/unit/batch_dispenser_registration.test/10_the_dispenser_prefix_carries_its_delimiter_above_the_gate_inside_a_batch_continued.test.js": "752d986da139a295", "test/unit/batch_dispenser_registration.test/11_the_dispenser_prefix_carries_its_delimiter_above_the_gate_at_the_top_level.test.js": "b254adfe1875935b", "test/unit/batch_dispenser_registration.test/12_collapse_dispenser_registrations.test.js": "075b788357e290ab", + "test/unit/batch_dispenser_registration.test/13_collapse_registrations_by_key.test.js": "f3933fa79957c8e8", "test/unit/batch_limits_vendoring.test.js": "537ab7708498cb2c", "test/unit/batch_limits_vendoring.test/tier_2_the_post_flag_rule_set_is_the_only_one_this_decoder_can_ever_see.test.js": "2267962a8018027e", "test/unit/batch_limits_vendoring.test/tier_3_driven_against_the_real_indexer_batch_handler.test.js": "f2206c18335eb69f", @@ -3138,7 +3442,7 @@ "test/unit/batch_whole_batch_rejection.test/01_the_real_on_chain_corpus.test.js": "ed774fc2974f593b", "test/unit/bet_action_gate.test.js": "5aaa005bd6c99496", "test/unit/block_prev_hash_byte_order.test.js": "2cc5bf1987da3a39", - "test/unit/blockchain_connector.test.js": "8999f436cb35bdfa", + "test/unit/blockchain_connector.test.js": "a6b2950c08ad3319", "test/unit/blockchain_connector.test/01_get_block_without_aux_pow.test.js": "f036a06a88e895c5", "test/unit/blockchain_connector.test/02_blockchain_connector_get_raw_transactions_bounded_concurrency.test.js": "55b018a2603be435", "test/unit/blockchain_connector_extra.test.js": "83cb7e9cd2340bd1", @@ -3152,11 +3456,13 @@ "test/unit/boundary/script_types.test/01_boundary_multisig_zero_trim_edge_cases.test.js": "0d5d819b92bebe35", "test/unit/boundary/script_types.test/02_boundary_magic_prefix_encoding_type_detection.test.js": "cec1808fc0db7487", "test/unit/boundary/script_types.test/03_boundary_is_future_segwit_script_additional_edge_cases.test.js": "4fad52825aa3c998", + "test/unit/chain/blockchain_connector/rpc_helpers_config.test.js": "d365682fdae6dbbc", + "test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js": "0c587a78e35ab1a8", "test/unit/chain_genesis_pin.test.js": "e697767e491d29b9", "test/unit/chain_identity_gate.test.js": "4b21d3e40dadf41e", "test/unit/chunk_lane_commit_fetch.test.js": "492934dde4710b5e", "test/unit/coins_conformance.test.js": "63016c2f7cde007d", - "test/unit/compiled_push_size_conformance.test.js": "155fe1323df6de21", + "test/unit/compiled_push_size_conformance.test.js": "be256411308fe4e8", "test/unit/consensus_pin_boot.test.js": "b3bae8599c655a50", "test/unit/coverage_thresholds_sync.test.js": "15a40e4df3b0929a", "test/unit/crash_reporting.test.js": "8a87ff6abaa5f1be", @@ -3168,13 +3474,15 @@ "test/unit/db.test/04_database_transaction_lock_queue.test.js": "e7f696b18c93ae6d", "test/unit/db.test/05_database_parse_expected_indexes.test.js": "0421fc1806b28494", "test/unit/db.test/06_database_reconcile_table_indexes.test.js": "646499266a236557", + "test/unit/db.test/07_database_alter_table_for_drift_relax.test.js": "65e3e5f5a8f53eb0", + "test/unit/db/migration_query_timeout.test.js": "376df582d23998da", "test/unit/db_connection_release.test.js": "142291e578f9a313", "test/unit/db_ping_probe.test.js": "56fc18cc6fda9009", "test/unit/db_queries.test.js": "f2b4a0f1e0a013b6", "test/unit/db_queries.test/01_has_pubkey.test.js": "254ae1e75c67c363", "test/unit/db_queries.test/02_insert_pubkey.test.js": "4505554e7c82e75b", "test/unit/db_queries.test/03_insert_event.test.js": "29f2c0cb6cb80f50", - "test/unit/db_queries.test/04_delete_block_by_index.test.js": "1eae452ecbbf5562", + "test/unit/db_queries.test/04_delete_block_by_index.test.js": "f461947d31adfc1a", "test/unit/db_queries.test/05_insert_block.test.js": "067466a5e0dc8208", "test/unit/db_queries.test/06_insert_transaction.test.js": "dfb32b76fb12ebb3", "test/unit/db_queries.test/07_insert_mempool_transaction.test.js": "69a2734ef775181c", @@ -3194,15 +3502,19 @@ "test/unit/db_query_helpers.test.js": "6fa21ab5be3abe7a", "test/unit/db_session_timezone_utc.test.js": "4ef43ab7b728601d", "test/unit/decoder_halt_diagnostics.test.js": "a623c806af89bc2c", - "test/unit/decoder_live_heartbeat.test.js": "da7078876aa77bf9", + "test/unit/decoder_live_heartbeat.test.js": "1924e1a0eff49f01", "test/unit/decoder_stress_sweep.test.js": "973dda09a83b0b1a", "test/unit/decoder_tip_stale_surface.test.js": "cdab1aa16d30bd48", + "test/unit/dispenser_address_id_collapse_activation.test.js": "9cdf9bd6784320ad", + "test/unit/dispenser_address_key.test.js": "6579d4ea0926bf01", "test/unit/dispenser_cancel_edit_db.test.js": "80d233d9d6bb2811", "test/unit/dispenser_cancel_grace.test.js": "86ef365e34f8bfd3", "test/unit/dispenser_cancel_grace.test/01_database_get_all_open_dispenser_addresses_grace_floor.test.js": "8c9bfd7c64e31c4f", "test/unit/dispenser_cancel_grace_activation.test.js": "88ab629ba53398a7", + "test/unit/dispenser_exact_integer_expiration.test.js": "411f596aab8ee136", "test/unit/dispenser_expiry_realign.test.js": "df84d924c9729396", "test/unit/dispenser_expiry_realign_activation.test.js": "820204db57b071e9", + "test/unit/dispenser_extension_undo_reorg.test.js": "ab8fb229aacfa93f", "test/unit/dispenser_field_offsets.test.js": "2d38404d478d45b6", "test/unit/dispenser_gate.test.js": "1e1f291db477634c", "test/unit/dispenser_lifecycle_mirror.test.js": "ba423415512227b8", @@ -3214,34 +3526,49 @@ "test/unit/dispenser_oracle_fee_output.test/02_activation_gate.test.js": "f068ea30d6afb5a5", "test/unit/dispenser_oracle_fee_output.test/03_field_extraction.test.js": "df497991073677fa", "test/unit/dispenser_protocol_time.test.js": "25603dbc161ae5b3", + "test/unit/dispenser_purge_failure_count.test.js": "3219370f321e36fa", + "test/unit/dispenser_purge_grace.test.js": "98dd8c6fd3e3decd", + "test/unit/dispenser_purge_grace_activation.test.js": "033b7f903629733d", "test/unit/dispenser_safe_depth.test.js": "48dee2bb9f8949a5", - "test/unit/fee_destination.test.js": "7198625d14f3da9d", + "test/unit/dispenser_wide_expiration.test.js": "ca3928e5b27e5791", + "test/unit/fee_destination.test.js": "0c8454135e113a90", + "test/unit/getmempool_stale_read.test.js": "951b85192ced37b0", "test/unit/jsonrpc_body_guard.test.js": "58706b2a54fa0008", "test/unit/litecoin_block.test.js": "5ecf90a69618e860", - "test/unit/mempool_api_surface.test.js": "a7c7a6651942ddef", + "test/unit/mempool_api_surface.test.js": "3e9a24b03ca94279", "test/unit/mempool_first_seen_datetime.test.js": "dde536d960f1a1a5", "test/unit/mempool_isolation.test.js": "6207eebcc7c69c98", "test/unit/mempool_payload_representation.test.js": "7f339cce97ef3d87", + "test/unit/mempool_unattributable_action.test.js": "5199ac844e30531f", "test/unit/migrate.test.js": "826dacd11ea07f42", "test/unit/migrate_status.test.js": "4fd0a29c075465f3", - "test/unit/migration_preconditions.test.js": "3d54004868a9ce33", - "test/unit/migration_runner.test.js": "dc9720d3655e4785", + "test/unit/migration_preconditions.test.js": "54639638d1536735", + "test/unit/migration_preconditions_bigint.test.js": "47c0720002969b72", + "test/unit/migration_runner.test.js": "1fe4c0f9111bdecb", "test/unit/migration_runner.test/01_committed_migrations_declare_intent.test.js": "19e73caaa9b7c73f", "test/unit/migration_runner.test/02_database_migration_checksum_rebaselines.test.js": "35cbf8a92b2e7c36", "test/unit/migration_runner.test/03_run_migrations_checksum_re_bless_path.test.js": "f45010ff7d50d33a", "test/unit/migration_runner.test/04_run_migrations_file_opts_only_scoping.test.js": "9427161b3e973b0e", "test/unit/migration_runner.test/05_run_migrations_migration_preconditions.test.js": "4044805d7869c790", "test/unit/migration_runner.test/06_database_split_sql_statements.test.js": "45857eb89566bdb3", - "test/unit/migration_runner.test/07_database_schema_contract_guards.test.js": "0da18e1ca9928fd3", + "test/unit/migration_runner.test/07_database_schema_contract_guards.test.js": "de71dddc3a70f32b", "test/unit/migration_runner.test/08_destructive_guard_prefix_forms.test.js": "ace818c6415f144e", - "test/unit/migration_runner.test/09_migration_query_timeout.test.js": "f557b21579db13fd", + "test/unit/migration_runner.test/09_migration_query_timeout.test.js": "97b9a29072a0f010", + "test/unit/migration_runner.test/10_migration_executable_residue_pin.test.js": "cd8e0e210f80e0cb", + "test/unit/migration_runner.test/11_destructive_guard_session_set.test.js": "8b1bac8a4cab2cbd", + "test/unit/migration_runner.test/12_destructive_guard_modify_convert.test.js": "9c3f37e4f6fe7093", + "test/unit/migration_runner.test/13_live_schema_guard.test.js": "a194c2b2855f5460", + "test/unit/migration_runner.test/14_destructive_guard_engine.test.js": "0406fb865bdaa059", + "test/unit/migration_runner.test/15_destructive_guard_drop_target.test.js": "94d278f823552144", + "test/unit/migration_runner.test/16_destructive_guard_quoted_comments.test.js": "4edb863dbbb574cc", + "test/unit/migration_runner.test/17_live_schema_guard_types.test.js": "8a5d5b0d051bc1ea", "test/unit/migrations_ledger_datetime.test.js": "2a6097257b0a2986", "test/unit/node_catch_up_wait.test.js": "4763aa60db951190", "test/unit/node_catching_up_status.test.js": "d19fabd24013167c", "test/unit/node_reachability_status.test.js": "09f6b21d4c9c4493", "test/unit/node_url_failover.test.js": "df1a7438428d2073", "test/unit/oracle_fee_output_activation_conformance.test.js": "f7b308fd7d4c8015", - "test/unit/parse_loop_quarantine.test.js": "01650d3ce79e1914", + "test/unit/parse_loop_quarantine.test.js": "7ab2b61a3082956b", "test/unit/parse_transaction.test.js": "7775a7711b003332", "test/unit/parse_transaction.test/01_parse_transaction_multisig_and_extraction.test.js": "e2dc1e87963a2b20", "test/unit/parse_transaction.test/02_parse_transaction_outputs_and_defaults.test.js": "d9b3257c562c64ff", @@ -3249,13 +3576,16 @@ "test/unit/parse_transaction.test/04_get_source_from_output.test.js": "5bfafba200cdf61c", "test/unit/parse_transaction.test/05_parse_transaction_p2wsh_per_chain_segwit_gate.test.js": "6a95ee16117e8e38", "test/unit/protocol_constants.test.js": "13584cc3d862a5a2", - "test/unit/protocol_time.test.js": "a712626ef67e3e03", + "test/unit/protocol_time.test.js": "8383b3fc6d715636", + "test/unit/protocol_time_caller_order.test.js": "d18469488f659a45", "test/unit/protocol_time_parity.test.js": "bea80fd3971653d5", + "test/unit/raw_transaction_shape_guard.test.js": "a30a253f8b183809", "test/unit/remove_obfuscation.test.js": "0eb0aadd100cef95", - "test/unit/reorg_depth_across_restart.test.js": "640847acd3068328", - "test/unit/reorg_halt_clear.test.js": "c7bb56e0e47bab3f", + "test/unit/reorg_depth_across_restart.test.js": "cb25ed0b62ac6428", + "test/unit/reorg_halt_clear.test.js": "dbef882bb2e58f54", "test/unit/reorg_halt_park.test.js": "b6be0fd71f5537fb", - "test/unit/reorg_halt_surface.test.js": "51c14bbdc7db5a0e", + "test/unit/reorg_halt_surface.test.js": "571065a79b6fdaba", + "test/unit/reorg_table_coverage.test.js": "8d1ac41b3c8d3fc3", "test/unit/repo/fixture_ports.test.js": "dc378898866ff01a", "test/unit/roundtrip.test.js": "319d38eebd8ba22a", "test/unit/roundtrip_conformance.test.js": "99f5849afe3a05b0", @@ -3265,27 +3595,33 @@ "test/unit/rpc_lookup_failure.test/01_block_loop_rollback_signal_handling.test.js": "8f6c7971e72c3673", "test/unit/rpc_lookup_failure.test/02_wire_decode_faults_escape_untagged.test.js": "85b2c4212474b7ca", "test/unit/rpc_lookup_failure.test/03_start_refuses_a_dogecoin_decoder_with_an_inactive_bigint_reader.test.js": "4488beb4be8efd3a", + "test/unit/rpc_retry_exhaustion_identity.test.js": "3f8698af9d3d592f", "test/unit/security/configuration/dependency_advisories.test.js": "5cc2b8143bdd5808", "test/unit/shutdown.test.js": "b7c40222c7ba54aa", "test/unit/sibling_coverage.test.js": "d8d1e52267ea606f", "test/unit/sql_no_timestamp_columns.test.js": "1554a102f8ef603f", "test/unit/sql_quote_backslash_escapes.test.js": "b72d4e6d05233677", - "test/unit/sql_schema_parse_coverage.test.js": "094df283a0e43bcb", + "test/unit/sql_schema_parse_coverage.test.js": "2442ae2aad9cf6c2", "test/unit/status_lag_field.test.js": "418aa8572442df90", + "test/unit/status_stale_tip_field.test.js": "7ee2d8b6ab3965a7", "test/unit/taproot_envelope.test.js": "bf2c76cad3b8a7fc", "test/unit/taproot_envelope.test/01_envelope_recognition_height_envelope_active_at.test.js": "2c50360ec142d262", "test/unit/taproot_envelope.test/02_parse_transaction_golden_envelope_reveal.test.js": "222d1bde1d8da99f", "test/unit/taproot_envelope.test/03_per_encoding_4_ceiling.test.js": "0ab9816c47311b1c", "test/unit/taproot_envelope.test/04_carrier_arbitration_3_8_height_gated.test.js": "4ba2e505b3f4a9a0", - "test/unit/taproot_envelope.test/05_constants_conformance.test.js": "82f18e9dded2dd48", + "test/unit/taproot_envelope.test/05_constants_conformance.test.js": "5ea20e09507c7a24", "test/unit/taproot_envelope.test/06_wire_fidelity_with_the_shipped_encoder_sibling_gated.test.js": "41c7af9fde59dd5a", "test/unit/taproot_envelope.test/07_envelope_source_pubkey_capture.test.js": "0c5f9fce59434a2d", + "test/unit/taproot_envelope_published_vectors.test.js": "914b98b3994edc4c", "test/unit/tier_manifest.test.js": "2a3c25e6d0fc5dac", + "test/unit/unattributed_action_skip.test.js": "5547b4e45f6dd005", "test/unit/util.test.js": "81be8bb6cf1dea2f", "test/unit/util_extra.test.js": "3a87e525ae152e17", + "test/unit/valid_action_names_flag_day.test.js": "48102ba3278bff5c", "test/unit/verify_reorg_retry.test.js": "fd57b09815088f6a", - "test/unit/verify_reorg_retry.test/01_xchain_decoder_verify_reorg_mid_walk_tip_regression.test.js": "b8f6917eb3bf48ea", + "test/unit/verify_reorg_retry.test/01_xchain_decoder_verify_reorg_mid_walk_tip_regression.test.js": "b7c9218357c1b1f2", "test/unit/verify_reorg_retry.test/02_xchain_decoder_verify_reorg_does_not_mistake_a_failed_db_read_for_an_exhausted_table.test.js": "5aede94bd4bed74a", + "test/unit/verify_reorg_retry.test/03_reorg_entry_paths_log_at_warn.test.js": "aa473d78908111d8", "test/unit/verify_tables_skips_nonsql.test.js": "3abae033a7d98a61", "test/unit/xchain_block_decoder.test.js": "560dcfa0adadeb89", "test/unit/xchain_decoder.test.js": "13e00a456eac7fe0", @@ -3584,15 +3920,17 @@ } }, "test:unit": { - "fileCount": 188, - "titleCount": 1759, + "fileCount": 223, + "titleCount": 1990, "files": { - "test/unit/action_manifest_conformance.test.js": "d8cd8319c6020126", + "test/unit/action_manifest_conformance.test.js": "3e1e7599837cbf70", "test/unit/alias_expansion_boundary.test.js": "7a5cec648390c3a0", "test/unit/apply_bufferutils_patch.test.js": "d002ffc05ec60ec9", - "test/unit/auxpow_reassembly.test.js": "99319118c2a13885", + "test/unit/apply_bufferutils_patch_twin_parity.test.js": "d104a881d7ea316d", + "test/unit/auxpow_reassembly.test.js": "67e3c12ff1c288e7", "test/unit/auxpow_strip_parity.test.js": "8d9e5d5d21b31cbc", - "test/unit/batch_dispenser_registration.test.js": "1fea0088e53b0230", + "test/unit/batch_dispenser_address_id_collapse.test.js": "d41d5e457c323cd7", + "test/unit/batch_dispenser_registration.test.js": "a87567a9516739f2", "test/unit/batch_dispenser_registration.test/01_a_dispenser_created_inside_a_batch.test.js": "05ef33730e0f85a6", "test/unit/batch_dispenser_registration.test/02_several_dispenser_sub_commands_in_one_batch.test.js": "7d5dc72476b4d580", "test/unit/batch_dispenser_registration.test/03_two_creates_on_the_same_operating_address_the_primary_key_collision.test.js": "9d0527ed17a1692f", @@ -3605,6 +3943,7 @@ "test/unit/batch_dispenser_registration.test/10_the_dispenser_prefix_carries_its_delimiter_above_the_gate_inside_a_batch_continued.test.js": "752d986da139a295", "test/unit/batch_dispenser_registration.test/11_the_dispenser_prefix_carries_its_delimiter_above_the_gate_at_the_top_level.test.js": "b254adfe1875935b", "test/unit/batch_dispenser_registration.test/12_collapse_dispenser_registrations.test.js": "075b788357e290ab", + "test/unit/batch_dispenser_registration.test/13_collapse_registrations_by_key.test.js": "f3933fa79957c8e8", "test/unit/batch_limits_vendoring.test.js": "537ab7708498cb2c", "test/unit/batch_limits_vendoring.test/tier_2_the_post_flag_rule_set_is_the_only_one_this_decoder_can_ever_see.test.js": "2267962a8018027e", "test/unit/batch_limits_vendoring.test/tier_3_driven_against_the_real_indexer_batch_handler.test.js": "f2206c18335eb69f", @@ -3620,7 +3959,7 @@ "test/unit/batch_whole_batch_rejection.test/01_the_real_on_chain_corpus.test.js": "ed774fc2974f593b", "test/unit/bet_action_gate.test.js": "5aaa005bd6c99496", "test/unit/block_prev_hash_byte_order.test.js": "2cc5bf1987da3a39", - "test/unit/blockchain_connector.test.js": "8999f436cb35bdfa", + "test/unit/blockchain_connector.test.js": "a6b2950c08ad3319", "test/unit/blockchain_connector.test/01_get_block_without_aux_pow.test.js": "f036a06a88e895c5", "test/unit/blockchain_connector.test/02_blockchain_connector_get_raw_transactions_bounded_concurrency.test.js": "55b018a2603be435", "test/unit/blockchain_connector_extra.test.js": "83cb7e9cd2340bd1", @@ -3634,11 +3973,13 @@ "test/unit/boundary/script_types.test/01_boundary_multisig_zero_trim_edge_cases.test.js": "0d5d819b92bebe35", "test/unit/boundary/script_types.test/02_boundary_magic_prefix_encoding_type_detection.test.js": "cec1808fc0db7487", "test/unit/boundary/script_types.test/03_boundary_is_future_segwit_script_additional_edge_cases.test.js": "4fad52825aa3c998", + "test/unit/chain/blockchain_connector/rpc_helpers_config.test.js": "d365682fdae6dbbc", + "test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js": "0c587a78e35ab1a8", "test/unit/chain_genesis_pin.test.js": "e697767e491d29b9", "test/unit/chain_identity_gate.test.js": "4b21d3e40dadf41e", "test/unit/chunk_lane_commit_fetch.test.js": "492934dde4710b5e", "test/unit/coins_conformance.test.js": "63016c2f7cde007d", - "test/unit/compiled_push_size_conformance.test.js": "155fe1323df6de21", + "test/unit/compiled_push_size_conformance.test.js": "be256411308fe4e8", "test/unit/consensus_pin_boot.test.js": "b3bae8599c655a50", "test/unit/coverage_thresholds_sync.test.js": "15a40e4df3b0929a", "test/unit/crash_reporting.test.js": "8a87ff6abaa5f1be", @@ -3650,13 +3991,15 @@ "test/unit/db.test/04_database_transaction_lock_queue.test.js": "e7f696b18c93ae6d", "test/unit/db.test/05_database_parse_expected_indexes.test.js": "0421fc1806b28494", "test/unit/db.test/06_database_reconcile_table_indexes.test.js": "646499266a236557", + "test/unit/db.test/07_database_alter_table_for_drift_relax.test.js": "65e3e5f5a8f53eb0", + "test/unit/db/migration_query_timeout.test.js": "376df582d23998da", "test/unit/db_connection_release.test.js": "142291e578f9a313", "test/unit/db_ping_probe.test.js": "56fc18cc6fda9009", "test/unit/db_queries.test.js": "f2b4a0f1e0a013b6", "test/unit/db_queries.test/01_has_pubkey.test.js": "254ae1e75c67c363", "test/unit/db_queries.test/02_insert_pubkey.test.js": "4505554e7c82e75b", "test/unit/db_queries.test/03_insert_event.test.js": "29f2c0cb6cb80f50", - "test/unit/db_queries.test/04_delete_block_by_index.test.js": "1eae452ecbbf5562", + "test/unit/db_queries.test/04_delete_block_by_index.test.js": "f461947d31adfc1a", "test/unit/db_queries.test/05_insert_block.test.js": "067466a5e0dc8208", "test/unit/db_queries.test/06_insert_transaction.test.js": "dfb32b76fb12ebb3", "test/unit/db_queries.test/07_insert_mempool_transaction.test.js": "69a2734ef775181c", @@ -3676,15 +4019,19 @@ "test/unit/db_query_helpers.test.js": "6fa21ab5be3abe7a", "test/unit/db_session_timezone_utc.test.js": "4ef43ab7b728601d", "test/unit/decoder_halt_diagnostics.test.js": "a623c806af89bc2c", - "test/unit/decoder_live_heartbeat.test.js": "da7078876aa77bf9", + "test/unit/decoder_live_heartbeat.test.js": "1924e1a0eff49f01", "test/unit/decoder_stress_sweep.test.js": "973dda09a83b0b1a", "test/unit/decoder_tip_stale_surface.test.js": "cdab1aa16d30bd48", + "test/unit/dispenser_address_id_collapse_activation.test.js": "9cdf9bd6784320ad", + "test/unit/dispenser_address_key.test.js": "6579d4ea0926bf01", "test/unit/dispenser_cancel_edit_db.test.js": "80d233d9d6bb2811", "test/unit/dispenser_cancel_grace.test.js": "86ef365e34f8bfd3", "test/unit/dispenser_cancel_grace.test/01_database_get_all_open_dispenser_addresses_grace_floor.test.js": "8c9bfd7c64e31c4f", "test/unit/dispenser_cancel_grace_activation.test.js": "88ab629ba53398a7", + "test/unit/dispenser_exact_integer_expiration.test.js": "411f596aab8ee136", "test/unit/dispenser_expiry_realign.test.js": "df84d924c9729396", "test/unit/dispenser_expiry_realign_activation.test.js": "820204db57b071e9", + "test/unit/dispenser_extension_undo_reorg.test.js": "ab8fb229aacfa93f", "test/unit/dispenser_field_offsets.test.js": "2d38404d478d45b6", "test/unit/dispenser_gate.test.js": "1e1f291db477634c", "test/unit/dispenser_lifecycle_mirror.test.js": "ba423415512227b8", @@ -3696,34 +4043,49 @@ "test/unit/dispenser_oracle_fee_output.test/02_activation_gate.test.js": "f068ea30d6afb5a5", "test/unit/dispenser_oracle_fee_output.test/03_field_extraction.test.js": "df497991073677fa", "test/unit/dispenser_protocol_time.test.js": "25603dbc161ae5b3", + "test/unit/dispenser_purge_failure_count.test.js": "3219370f321e36fa", + "test/unit/dispenser_purge_grace.test.js": "98dd8c6fd3e3decd", + "test/unit/dispenser_purge_grace_activation.test.js": "033b7f903629733d", "test/unit/dispenser_safe_depth.test.js": "48dee2bb9f8949a5", - "test/unit/fee_destination.test.js": "7198625d14f3da9d", + "test/unit/dispenser_wide_expiration.test.js": "ca3928e5b27e5791", + "test/unit/fee_destination.test.js": "0c8454135e113a90", + "test/unit/getmempool_stale_read.test.js": "951b85192ced37b0", "test/unit/jsonrpc_body_guard.test.js": "58706b2a54fa0008", "test/unit/litecoin_block.test.js": "5ecf90a69618e860", - "test/unit/mempool_api_surface.test.js": "a7c7a6651942ddef", + "test/unit/mempool_api_surface.test.js": "3e9a24b03ca94279", "test/unit/mempool_first_seen_datetime.test.js": "dde536d960f1a1a5", "test/unit/mempool_isolation.test.js": "6207eebcc7c69c98", "test/unit/mempool_payload_representation.test.js": "7f339cce97ef3d87", + "test/unit/mempool_unattributable_action.test.js": "5199ac844e30531f", "test/unit/migrate.test.js": "826dacd11ea07f42", "test/unit/migrate_status.test.js": "4fd0a29c075465f3", - "test/unit/migration_preconditions.test.js": "3d54004868a9ce33", - "test/unit/migration_runner.test.js": "dc9720d3655e4785", + "test/unit/migration_preconditions.test.js": "54639638d1536735", + "test/unit/migration_preconditions_bigint.test.js": "47c0720002969b72", + "test/unit/migration_runner.test.js": "1fe4c0f9111bdecb", "test/unit/migration_runner.test/01_committed_migrations_declare_intent.test.js": "19e73caaa9b7c73f", "test/unit/migration_runner.test/02_database_migration_checksum_rebaselines.test.js": "35cbf8a92b2e7c36", "test/unit/migration_runner.test/03_run_migrations_checksum_re_bless_path.test.js": "f45010ff7d50d33a", "test/unit/migration_runner.test/04_run_migrations_file_opts_only_scoping.test.js": "9427161b3e973b0e", "test/unit/migration_runner.test/05_run_migrations_migration_preconditions.test.js": "4044805d7869c790", "test/unit/migration_runner.test/06_database_split_sql_statements.test.js": "45857eb89566bdb3", - "test/unit/migration_runner.test/07_database_schema_contract_guards.test.js": "0da18e1ca9928fd3", + "test/unit/migration_runner.test/07_database_schema_contract_guards.test.js": "de71dddc3a70f32b", "test/unit/migration_runner.test/08_destructive_guard_prefix_forms.test.js": "ace818c6415f144e", - "test/unit/migration_runner.test/09_migration_query_timeout.test.js": "f557b21579db13fd", + "test/unit/migration_runner.test/09_migration_query_timeout.test.js": "97b9a29072a0f010", + "test/unit/migration_runner.test/10_migration_executable_residue_pin.test.js": "cd8e0e210f80e0cb", + "test/unit/migration_runner.test/11_destructive_guard_session_set.test.js": "8b1bac8a4cab2cbd", + "test/unit/migration_runner.test/12_destructive_guard_modify_convert.test.js": "9c3f37e4f6fe7093", + "test/unit/migration_runner.test/13_live_schema_guard.test.js": "a194c2b2855f5460", + "test/unit/migration_runner.test/14_destructive_guard_engine.test.js": "0406fb865bdaa059", + "test/unit/migration_runner.test/15_destructive_guard_drop_target.test.js": "94d278f823552144", + "test/unit/migration_runner.test/16_destructive_guard_quoted_comments.test.js": "4edb863dbbb574cc", + "test/unit/migration_runner.test/17_live_schema_guard_types.test.js": "8a5d5b0d051bc1ea", "test/unit/migrations_ledger_datetime.test.js": "2a6097257b0a2986", "test/unit/node_catch_up_wait.test.js": "4763aa60db951190", "test/unit/node_catching_up_status.test.js": "d19fabd24013167c", "test/unit/node_reachability_status.test.js": "09f6b21d4c9c4493", "test/unit/node_url_failover.test.js": "df1a7438428d2073", "test/unit/oracle_fee_output_activation_conformance.test.js": "f7b308fd7d4c8015", - "test/unit/parse_loop_quarantine.test.js": "01650d3ce79e1914", + "test/unit/parse_loop_quarantine.test.js": "7ab2b61a3082956b", "test/unit/parse_transaction.test.js": "7775a7711b003332", "test/unit/parse_transaction.test/01_parse_transaction_multisig_and_extraction.test.js": "e2dc1e87963a2b20", "test/unit/parse_transaction.test/02_parse_transaction_outputs_and_defaults.test.js": "d9b3257c562c64ff", @@ -3731,13 +4093,16 @@ "test/unit/parse_transaction.test/04_get_source_from_output.test.js": "5bfafba200cdf61c", "test/unit/parse_transaction.test/05_parse_transaction_p2wsh_per_chain_segwit_gate.test.js": "6a95ee16117e8e38", "test/unit/protocol_constants.test.js": "13584cc3d862a5a2", - "test/unit/protocol_time.test.js": "a712626ef67e3e03", + "test/unit/protocol_time.test.js": "8383b3fc6d715636", + "test/unit/protocol_time_caller_order.test.js": "d18469488f659a45", "test/unit/protocol_time_parity.test.js": "bea80fd3971653d5", + "test/unit/raw_transaction_shape_guard.test.js": "a30a253f8b183809", "test/unit/remove_obfuscation.test.js": "0eb0aadd100cef95", - "test/unit/reorg_depth_across_restart.test.js": "640847acd3068328", - "test/unit/reorg_halt_clear.test.js": "c7bb56e0e47bab3f", + "test/unit/reorg_depth_across_restart.test.js": "cb25ed0b62ac6428", + "test/unit/reorg_halt_clear.test.js": "dbef882bb2e58f54", "test/unit/reorg_halt_park.test.js": "b6be0fd71f5537fb", - "test/unit/reorg_halt_surface.test.js": "51c14bbdc7db5a0e", + "test/unit/reorg_halt_surface.test.js": "571065a79b6fdaba", + "test/unit/reorg_table_coverage.test.js": "8d1ac41b3c8d3fc3", "test/unit/repo/fixture_ports.test.js": "dc378898866ff01a", "test/unit/roundtrip.test.js": "319d38eebd8ba22a", "test/unit/roundtrip_conformance.test.js": "99f5849afe3a05b0", @@ -3747,27 +4112,33 @@ "test/unit/rpc_lookup_failure.test/01_block_loop_rollback_signal_handling.test.js": "8f6c7971e72c3673", "test/unit/rpc_lookup_failure.test/02_wire_decode_faults_escape_untagged.test.js": "85b2c4212474b7ca", "test/unit/rpc_lookup_failure.test/03_start_refuses_a_dogecoin_decoder_with_an_inactive_bigint_reader.test.js": "4488beb4be8efd3a", + "test/unit/rpc_retry_exhaustion_identity.test.js": "3f8698af9d3d592f", "test/unit/security/configuration/dependency_advisories.test.js": "5cc2b8143bdd5808", "test/unit/shutdown.test.js": "b7c40222c7ba54aa", "test/unit/sibling_coverage.test.js": "d8d1e52267ea606f", "test/unit/sql_no_timestamp_columns.test.js": "1554a102f8ef603f", "test/unit/sql_quote_backslash_escapes.test.js": "b72d4e6d05233677", - "test/unit/sql_schema_parse_coverage.test.js": "094df283a0e43bcb", + "test/unit/sql_schema_parse_coverage.test.js": "2442ae2aad9cf6c2", "test/unit/status_lag_field.test.js": "418aa8572442df90", + "test/unit/status_stale_tip_field.test.js": "7ee2d8b6ab3965a7", "test/unit/taproot_envelope.test.js": "bf2c76cad3b8a7fc", "test/unit/taproot_envelope.test/01_envelope_recognition_height_envelope_active_at.test.js": "2c50360ec142d262", "test/unit/taproot_envelope.test/02_parse_transaction_golden_envelope_reveal.test.js": "222d1bde1d8da99f", "test/unit/taproot_envelope.test/03_per_encoding_4_ceiling.test.js": "0ab9816c47311b1c", "test/unit/taproot_envelope.test/04_carrier_arbitration_3_8_height_gated.test.js": "4ba2e505b3f4a9a0", - "test/unit/taproot_envelope.test/05_constants_conformance.test.js": "82f18e9dded2dd48", + "test/unit/taproot_envelope.test/05_constants_conformance.test.js": "5ea20e09507c7a24", "test/unit/taproot_envelope.test/06_wire_fidelity_with_the_shipped_encoder_sibling_gated.test.js": "41c7af9fde59dd5a", "test/unit/taproot_envelope.test/07_envelope_source_pubkey_capture.test.js": "0c5f9fce59434a2d", + "test/unit/taproot_envelope_published_vectors.test.js": "914b98b3994edc4c", "test/unit/tier_manifest.test.js": "2a3c25e6d0fc5dac", + "test/unit/unattributed_action_skip.test.js": "5547b4e45f6dd005", "test/unit/util.test.js": "81be8bb6cf1dea2f", "test/unit/util_extra.test.js": "3a87e525ae152e17", + "test/unit/valid_action_names_flag_day.test.js": "48102ba3278bff5c", "test/unit/verify_reorg_retry.test.js": "fd57b09815088f6a", - "test/unit/verify_reorg_retry.test/01_xchain_decoder_verify_reorg_mid_walk_tip_regression.test.js": "b8f6917eb3bf48ea", + "test/unit/verify_reorg_retry.test/01_xchain_decoder_verify_reorg_mid_walk_tip_regression.test.js": "b7c9218357c1b1f2", "test/unit/verify_reorg_retry.test/02_xchain_decoder_verify_reorg_does_not_mistake_a_failed_db_read_for_an_exhausted_table.test.js": "5aede94bd4bed74a", + "test/unit/verify_reorg_retry.test/03_reorg_entry_paths_log_at_warn.test.js": "aa473d78908111d8", "test/unit/verify_tables_skips_nonsql.test.js": "3abae033a7d98a61", "test/unit/xchain_block_decoder.test.js": "560dcfa0adadeb89", "test/unit/xchain_decoder.test.js": "13e00a456eac7fe0", diff --git a/bin/pins/identity.json b/bin/pins/identity.json index 41a039df..6308fb20 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -2,14 +2,14 @@ "repo": "xchain-decoder", "what": "sha256 of every file this repo vendors from a canonical it does not own", "coins": { - "src/coins/BTC.js": "92f1e7d143f844886700f40deb53cb02bb7c6c270ab4bb837488f5d8535629b6", - "src/coins/DOGE.js": "3b1aac3d95ca96fd6a7f53c4af169e4e845effb77d00058473bda4d4786e397b", - "src/coins/LTC.js": "d7cf410caea44a18c3c5cac07ab7f47507af8680ec3cbbec4f54ab8cd09fc0b9", + "src/coins/BTC.js": "182baf5173e3846b94a5edc1ae15913a54fb266addfc45a3cf9a013bd9ec16c6", + "src/coins/DOGE.js": "4659b45fb7a5c959ce2777ab9d5e7c193f3515f03cdd8cd23bb6b9c4d012fc11", + "src/coins/LTC.js": "6155195bfa5fdfeab77dec3802ce67b5cc47a77695e81a4a5545d980e4c421dd", "src/coins/consensus_pin.js": "342bf7af224a19ea0a98ff9cfb2c9a70b3fd5be75de7ecef2db05936f82baa63", "src/coins/index.js": "08b678d934fa647d3c7d14dc5ccdaeb86722ee32a3662de69e4a31cd7b09234c" }, "twinFixtures": { - "test/fixtures/action-manifest.json": "e4b386bf166203f65d0cd8a246bc0820a2518ed570ab2112367169dd8a5c1e69", + "test/fixtures/action-manifest.json": "2be99c8a8adbcfbeb9d545e8dc42663323cdb15351a39373ccd38fabf48e58ff", "test/fixtures/roundtrip-conformance.json": "d9963ac7c35bdcfab99595fec040d7791d73119cb62fef20db136f8ea56236ed" } } diff --git a/bin/pins/suite-title-renames.json b/bin/pins/suite-title-renames.json index 12eb8b96..3f5e7484 100644 --- a/bin/pins/suite-title-renames.json +++ b/bin/pins/suite-title-renames.json @@ -1,5 +1,18 @@ { "note": "Declared renames for bin/suite-title-map.js --compare against bin/pins/at1-suite-titles.json. paths maps each test file renamed after the pin to its new path; titles maps a test file, by its new path, to the full titles whose wording changed with the rename. Empty since the 2026-09-28 pin regeneration: that pin was taken directly off the current tree, so every path and title it holds is already the renamed one and no bridge is needed until the next rename.", "paths": {}, - "titles": {} + "titles": { + "test/unit/db_queries.test/04_delete_block_by_index.test.js": { + "Database#deleteBlockByIndex() executes 4 DELETE queries and returns true on success": "Database#deleteBlockByIndex() executes 5 DELETE queries and returns true on success" + }, + "test/unit/fee_destination.test.js": { + "resolveFeeDestination @unit honors an env override on regtest ONLY": "resolveFeeDestination @unit ignores a bare env override on regtest too, so capture and indexer validation resolve one address" + }, + "test/unit/migration_preconditions.test.js": { + "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 registers exactly the three migrations this tree asserts at startup": "Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1 registers exactly the four migrations this tree asserts at startup" + }, + "test/unit/migration_runner.test.js": { + "Database.destructiveAutoStatement() @regression does NOT flag benign system-variable SETs (SET NAMES / SET sql_mode / SET @@)": "Database.destructiveAutoStatement() @regression does NOT flag the allow-listed session SETs (SET NAMES / a single UTC time_zone)" + } + } } diff --git a/bin/suite-title-map.js b/bin/suite-title-map.js index 3e5e3a8c..e1191caa 100644 --- a/bin/suite-title-map.js +++ b/bin/suite-title-map.js @@ -46,7 +46,7 @@ * node bin/suite-title-map.js --out write the map as JSON * node bin/suite-title-map.js --script test one script only * node bin/suite-title-map.js --compare diff the tree against a pin, - * exit 1 on any difference + * exit 1 on non-additive change * node bin/suite-title-map.js --compare --rename-map * the same, with the declared * renames (flat {old: new} @@ -70,6 +70,7 @@ const { loadSplits, compareWithSplits } = require('./suite_title_map/split_map.j const REPO_ROOT = path.resolve(__dirname, '..'); const MOCHA_BIN = path.join(REPO_ROOT, 'node_modules', '.bin', 'mocha'); +const GROWTH_KINDS = new Set(['file_added', 'title_added']); /** * A shell-ish split that keeps quoted globs whole. The scripts are plain @@ -268,6 +269,32 @@ function compare(pin, fresh, renames, only) { return differences; } +function isGrowth(difference) { + return GROWTH_KINDS.has(difference.kind); +} + +function compareAgainst(opts, map) { + const pin = JSON.parse(fs.readFileSync(opts.compare, 'utf8')); + const renames = opts.renameMap ? JSON.parse(fs.readFileSync(opts.renameMap, 'utf8')) : {}; + const splits = opts.splitMap ? loadSplits(opts.splitMap) : {}; + const differences = compareWithSplits({ pin, fresh: map, renames, splits, only: opts.script, compare }); + if (!differences.length) { + console.log(`suite identity holds against ${path.relative(REPO_ROOT, opts.compare)}` + + `${opts.renameMap ? ' through the declared rename map' : ''}` + + `${opts.splitMap ? ' through the declared split map' : ''}`); + return; + } + const blocking = differences.filter((difference) => !isGrowth(difference)); + const growthOnly = blocking.length === 0 ? ' (additive growth only)' : ''; + console.log(`${differences.length} difference(s) against ${path.relative(REPO_ROOT, opts.compare)}${growthOnly}:`); + for (const d of differences.slice(0, 200)) { + const growth = isGrowth(d) ? '[growth] ' : ''; + console.log(` ${growth}[${d.script}] ${d.kind} ${d.file || ''} ${d.title ? `:: ${d.title}` : d.detail || ''}`); + } + if (differences.length > 200) console.log(` ... and ${differences.length - 200} more`); + if (blocking.length) process.exitCode = 1; +} + function parseArgs(argv) { const opts = { json: false }; for (let i = 0; i < argv.length; i += 1) { @@ -291,22 +318,7 @@ function main() { const map = buildMap(opts.script); if (opts.compare) { - const pin = JSON.parse(fs.readFileSync(opts.compare, 'utf8')); - const renames = opts.renameMap ? JSON.parse(fs.readFileSync(opts.renameMap, 'utf8')) : {}; - const splits = opts.splitMap ? loadSplits(opts.splitMap) : {}; - const differences = compareWithSplits({ pin, fresh: map, renames, splits, only: opts.script, compare }); - if (!differences.length) { - console.log(`suite identity holds against ${path.relative(REPO_ROOT, opts.compare)}` - + `${opts.renameMap ? ' through the declared rename map' : ''}` - + `${opts.splitMap ? ' through the declared split map' : ''}`); - return; - } - console.log(`${differences.length} difference(s) against ${path.relative(REPO_ROOT, opts.compare)}:`); - for (const d of differences.slice(0, 200)) { - console.log(` [${d.script}] ${d.kind} ${d.file || ''} ${d.title ? `:: ${d.title}` : d.detail || ''}`); - } - if (differences.length > 200) console.log(` ... and ${differences.length - 200} more`); - process.exitCode = 1; + compareAgainst(opts, map); return; } diff --git a/package-lock.json b/package-lock.json index c6b6937d..b20b5e06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-decoder", - "version": "0.21.0", + "version": "0.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-decoder", - "version": "0.21.0", + "version": "0.23.0", "license": "AGPL-3.0-or-later", "dependencies": { "axios": "^1.18.1", diff --git a/package.json b/package.json index 59ac17e4..7a68c9ae 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xchain-decoder", "description": "xchain-decoder decodes XChain platform transactions from a given blockchain and populates a database with the decoded data.", - "version": "0.21.0", + "version": "0.23.0", "license": "AGPL-3.0-or-later", "repository": { "type": "git", diff --git a/src/XChainDecoder.js b/src/XChainDecoder.js index 8c498403..bbfd7761 100644 --- a/src/XChainDecoder.js +++ b/src/XChainDecoder.js @@ -37,6 +37,7 @@ const transactionParsingMethods = require('./XChainDecoder/transaction_parsing.j const reorgVerificationMethods = require('./XChainDecoder/reorg_verification.js') const startupMethods = require('./XChainDecoder/startup.js') const mempoolRefreshMethods = require('./XChainDecoder/mempool_refresh.js') +const { DISPENSER_WIDE_EXPIRATION_ACTIVATION } = require('./protocol/constants.js') //We need to init the ecc to parse taproot addresses from output scripts bitcoin.initEccLib(ecc); @@ -209,6 +210,7 @@ function initializeDecoderMode(decoder) { decoder.rpcErrors = 0 decoder.parseErrors = 0 + decoder.dispenserPurgeFailures = 0 } function initializeDecoderReorg(decoder) { @@ -337,6 +339,7 @@ Object.assign(XChainDecoder, { // (encoder/docs copies must stay byte-equal). ENVELOPE_MAX_PAYLOAD, ENVELOPE_RECOGNITION_ACTIVATION, + DISPENSER_WIDE_EXPIRATION_ACTIVATION, }); module.exports = XChainDecoder diff --git a/src/XChainDecoder/block_store.js b/src/XChainDecoder/block_store.js index 4d15b180..43212adf 100644 --- a/src/XChainDecoder/block_store.js +++ b/src/XChainDecoder/block_store.js @@ -14,10 +14,10 @@ const { format: formatLogLine } = require('node:util') const { isDispenserExpiryRealignActive } = require('../protocol/dispenser_expiry_realign') -const { cancelGraceFloor } = require('../protocol/dispenser_cancel_grace') +const { cancelGraceFloor, purgeGraceFloor } = require('../protocol/dispenser_cancel_grace') const protocolTime = require('../protocol/protocol_time') const { logger, SYNCED_THRESHOLD, DB_TRANSACTION_BLOCKS_QUANTITY, LOG_BLOCK_INTERVAL, DISPENSER_EXPIRE_SAFE_DEPTH } = require('./constants.js') -const { ingestTransaction } = require('./transaction_ingest.js') +const { ingestTransaction, clearQuarantineState } = require('./transaction_ingest.js') async function loadOpenDispenserAddresses(block, nextBlockHeight){ // Load the set once per block after any legacy expiry and before the @@ -49,7 +49,19 @@ async function retryFailedCommit(loop, nextBlockHeight){ return 'continue' } -async function commitBlockBatch(loop, nextBlockHeight, nextBlockHash){ +// Purge after commit at a deterministic, reorg-safe canonical height. A failed purge only keeps +// rows longer and retries at the next commit, so count it for monitoring and never throw. +async function purgeExpiredAfterCommit(nextBlockHeight, blockTime){ + const safeDepth = this.dispenserExpireSafeDepth || DISPENSER_EXPIRE_SAFE_DEPTH + const safeHeight = nextBlockHeight - safeDepth + const purged = await this.db.purgeExpiredDispensers(safeHeight, purgeGraceFloor(this.consensusNetwork, blockTime)) + if (purged !== false) return + this.dispenserPurgeFailures = (this.dispenserPurgeFailures || 0) + 1 + logger.warn(`Expired-dispenser purge at safe height ${safeHeight} failed ` + + `(${this.dispenserPurgeFailures} since start); the rows stay until a later purge succeeds`) +} + +async function commitBlockBatch(loop, nextBlockHeight, nextBlockHash, blockTime){ if ((nextBlockHeight % LOG_BLOCK_INTERVAL === 0) || ((this.blockchainInfoLastBlock - nextBlockHeight) <= SYNCED_THRESHOLD)) { this.log("Parsing block "+(nextBlockHeight)+"("+nextBlockHash+") Txs ("+loop.transactionsCount+") Outputs ("+loop.outputCount+")") this.log("Inserting data Blocks ("+loop.blocksCount+") Valid Transactions ("+loop.validTransactionsCount+")") @@ -59,12 +71,10 @@ async function commitBlockBatch(loop, nextBlockHeight, nextBlockHash){ return await retryFailedCommit.call(this, loop, nextBlockHeight) } - // Drop poison-tx positions only after their PARSE_ERROR rows commit. - if (loop.insertQuarantine.size > 0) loop.insertQuarantine.clear() + // Drop poison-tx positions and retry counts only after their PARSE_ERROR rows commit. + clearQuarantineState(loop) - // Purge after commit at a deterministic, reorg-safe canonical height. - const safeDepth = this.dispenserExpireSafeDepth || DISPENSER_EXPIRE_SAFE_DEPTH - await this.db.purgeExpiredDispensers(nextBlockHeight - safeDepth) + await purgeExpiredAfterCommit.call(this, nextBlockHeight, blockTime) loop.blocksCount = 0 loop.transactionsCount = 0 @@ -91,7 +101,7 @@ async function finishBlock(loop, block, nextBlockHeight, nextBlockHash, openDisp for (let txIndex=0;txIndex < transactions.length;txIndex++){ let nextTransaction = transactions[txIndex] - const directive = await ingestTransaction.call(this, loop, block, nextBlockHeight, openDispenserAddresses, nextTransaction, txIndex) + const directive = await ingestTransaction.call(this, loop, block, nextBlockHeight, openDispenserAddresses, nextTransaction, txIndex, nextBlockHash) if (directive === 'rollback') return 'rollback' if (directive === 'continue') continue @@ -110,7 +120,8 @@ async function finishBlock(loop, block, nextBlockHeight, nextBlockHash, openDisp // Commit a full batch, or commit immediately when this block reaches tip. if ((loop.blocksQuantity == DB_TRANSACTION_BLOCKS_QUANTITY-1) || (nextBlockHeight == this.blockchainInfoLastBlock)){ - if ((await commitBlockBatch.call(this, loop, nextBlockHeight, nextBlockHash)) === 'continue') return 'continue' + if ((await commitBlockBatch.call( + this, loop, nextBlockHeight, nextBlockHash, block.timestamp)) === 'continue') return 'continue' } loop.blocksQuantity = loop.blocksQuantity + 1 @@ -119,9 +130,12 @@ async function finishBlock(loop, block, nextBlockHeight, nextBlockHash, openDisp } async function fetchPreviousBlockTimes(nextBlockHeight, span){ - // Walk strictly backward so a failed lookup never mixes reorg states. + // Walk strictly backward so the result is newest first by block height and + // never wider than the median span, even when asked for more. A failed + // lookup returns no partial window, avoiding mixed reorg states. + const boundedSpan = Math.min(span, protocolTime.MEDIAN_TIME_SPAN) let previousBlockTimes = [] - for (let height = nextBlockHeight - 1; height >= 0 && previousBlockTimes.length < span; height--){ + for (let height = nextBlockHeight - 1; height >= 0 && previousBlockTimes.length < boundedSpan; height--){ let previousBlock try { previousBlock = await this.db.getBlockByIndex(height) @@ -195,4 +209,4 @@ async function storeBlock(loop, block, nextBlockHeight, nextBlockHash, previousB this, loop, block, nextBlockHeight, nextBlockHash, openAddresses, expireAtEnd) } -module.exports = { storeBlock } +module.exports = { storeBlock, fetchPreviousBlockTimes } diff --git a/src/XChainDecoder/chain_integrity.js b/src/XChainDecoder/chain_integrity.js index ecb26cde..6cc0aaa4 100644 --- a/src/XChainDecoder/chain_integrity.js +++ b/src/XChainDecoder/chain_integrity.js @@ -21,6 +21,22 @@ const { chainGenesisMismatch, chainGenesisUnpinned } = require('../protocol/chain_identity') const { logger, REORG_HALT_PROBE_INTERVAL_MS, AUXPOW_REASSEMBLE_AFTER } = require('./constants.js') +// Read the durable REORG_HALT marker through whichever probe the db exposes, or +// null when it exposes none (or there is no db) and the cached state must stand. +async function readReorgHaltMarker(db){ + if (!db) return null + if (typeof db.getReorgHaltMarker === 'function'){ + return (await db.getReorgHaltMarker()) || { halted: false, at: null, reason: null } + } + if (typeof db.isReorgHalted === 'function'){ + // Older/minimal db shapes (and the mocks in the verifyReorg suites) + // expose only the boolean probe. + const halted = await db.isReorgHalted() + return { halted: !!(halted && halted.halted !== undefined ? halted.halted : halted), at: null, reason: null } + } + return null +} + module.exports = { // Probe the durable REORG_HALT marker and cache the answer, on a TTL, so every // operator-facing surface can report a LATENT halt. The marker is written by @@ -41,16 +57,13 @@ module.exports = { if (this._reorgHaltProbeInFlight) return this._reorgHaltProbeInFlight this._reorgHaltProbeInFlight = (async () => { try { - if (!this.db) return this.getReorgHaltStatus() - let marker - if (typeof this.db.getReorgHaltMarker === 'function'){ - marker = await this.db.getReorgHaltMarker() - } else if (typeof this.db.isReorgHalted === 'function'){ - // Older/minimal db shapes (and the mocks in the verifyReorg suites) - // expose only the boolean probe. - const halted = await this.db.isReorgHalted() - marker = { halted: !!(halted && halted.halted !== undefined ? halted.halted : halted), at: null, reason: null } - } else { + const marker = await readReorgHaltMarker(this.db) + if (marker === null) return this.getReorgHaltStatus() + // Keep a halt this process raised but could not record. A failed marker + // write leaves no row, so a no-halt read here is that absence, not a + // clear; adopting it would publish reorg_halted=false until restart. + if (this.reorgHalted && this.reorgHaltMarkerPersisted === false && !marker.halted){ + this.reorgHaltCheckedAt = now return this.getReorgHaltStatus() } const wasHalted = this.reorgHalted diff --git a/src/XChainDecoder/constants.js b/src/XChainDecoder/constants.js index ea2fed01..17f3a0a8 100644 --- a/src/XChainDecoder/constants.js +++ b/src/XChainDecoder/constants.js @@ -121,6 +121,8 @@ const TAPROOT_LEAF_VERSION = 0xc0 // script-path elements. An annex-bearing reveal is never recognized (§3.8). const TAPROOT_ANNEX_MARKER = 0x50 +// Consensus surface: a name missing here drops every on-chain instance of that action, +// so any membership change is a flag-day, pinned by test/unit/valid_action_names_flag_day.test.js. const VALID_ACTION_NAMES = new Set([ 'ADDRESS', 'AIRDROP', 'ANCHOR', 'ATTEST', 'BATCH', 'BET', 'BROADCAST', 'CALLBACK', 'COINPAY', 'COLLECT', diff --git a/src/XChainDecoder/dispenser_registration.js b/src/XChainDecoder/dispenser_registration.js index 36127401..77f671e8 100644 --- a/src/XChainDecoder/dispenser_registration.js +++ b/src/XChainDecoder/dispenser_registration.js @@ -21,6 +21,12 @@ const { logger } = require('./constants.js') const { oracleAddressFromCreate, V0_GIVE_COIN_INDEX, V0_GET_COIN_INDEX, V0_GET_ADDRESS_INDEX, V0_EXPIRATION_INDEX, V2_EXPIRATION_INDEX } = require('../protocol/oracle_fee_output') const { isBatchSubCommandCaptureActive } = require('../protocol/batch_sub_command_capture') +const { + hasValidExpirationToken, + isExactIntegerExpirationActive, + isExactIntegerToken, + normalizeDispenserExpiration, +} = require('./dispenser_wide_expiration.js') //Catch any dispenser message to add it to //the list of possible dispenses. @@ -199,34 +205,20 @@ function pushV0DispenserCreate(decodedDataSplit, dispenserCreateCandidates, pars expiration = this.getDefaultExpiration(block.timestamp) } else { expiration = Number(expirationToken) + expiration = normalizeDispenserExpiration( + expirationToken, this.consensusNetwork, block.timestamp, expiration) } - // Require an INTEGER, matching the indexer, which rejects any - // non-integer EXPIRATION outright (isInteger, see - // xchain-indexer/src/actions/dispenser/validate_format.js). dispensers.expiration - // is BIGINT UNSIGNED, so a fractional value like 1700000000.5 - // either fails the write under a strict sql_mode - wedging the - // block loop, which then retries the same deterministic tx - // forever - or truncates under a lax one, leaving the decoder - // holding a dispenser the indexer never registered. - // Number.isSafeInteger already excludes NaN and Infinity, so it - // subsumes the isNaN test it replaces; the default expiration is - // integral by construction (block timestamp + whole days). + // Require an integer: dispensers.expiration is BIGINT UNSIGNED, so a fractional + // value wedges the block loop or leaves a dispenser the indexer never registered. + // At/above EXACT_INTEGER_EXPIRATION_ACTIVATION the wire token is checked in exact + // decimal space as the indexer does; below it the Number-based decision is kept + // for historical replay. The default expiration is integral by construction. // - // SAFE integer, not merely integer, and no u32 ceiling. The old - // `expiration > 4294967295` reject was recognition drift: the - // indexer escrows any non-negative integer EXPIRATION into its own - // BIGINT UNSIGNED column, so a dispenser opened past year 2106 (or - // spelled 9999999999 for "never") stayed open and escrowed there - // while the decoder skipped registration, and a later coin payment - // to it was never flagged as a dispense. Number.isSafeInteger is - // the bound that actually holds: at or below it Number() round-trips - // the payload token exactly, so the decoder stores the same value - // the indexer does, and it stays far inside BIGINT UNSIGNED. - // Dropping the ceiling outright would NOT be safe - Number.isInteger - // is true for 1e300, which overflows the column and wedges the block - // loop on the same deterministic tx forever. - if (!Number.isSafeInteger(expiration) || expiration < 0) { + // Above the wide-expiration gate, exact unsigned u64 values beyond Number's safe + // range clamp to its largest exact integer, keeping the advisory row open without + // rounding the wire value. Overflow and invalid spellings remain rejected. + if (!hasValidExpirationToken(expirationToken, expiration, this.consensusNetwork, block.timestamp)) { this.parseErrors++ logger.error(`Skipping dispenser in tx ${nextTransactionHash}: invalid expiration value '${decodedDataSplit[V0_EXPIRATION_INDEX]}'`) } else if (this.dispenserOpensForThisChain(giveCoin, getCoin)){ @@ -330,17 +322,11 @@ function dispenserEditExtension(dispenserCommand, dispenserCommandPrefix, parseR const editExpirationToken = decodedDataSplit[V2_EXPIRATION_INDEX] if (editSource && editSource.length > 0 && editExpirationToken !== undefined && editExpirationToken !== ""){ - const newExpiration = Number(editExpirationToken) - // Same integer contract as the create guard above: the edit - // path writes through extendOpenDispenserExpirationBySource - // into the same BIGINT UNSIGNED column, and the indexer - // rejects a fractional edit EXPIRATION with the identical - // isInteger test, and the same SAFE-integer ceiling rather than - // a u32 one (see the create guard: a u32 reject here would - // silently decline to mirror an extend the indexer accepted, - // closing the decoder's row early on a dispenser that is still - // open and escrowed). - if (Number.isSafeInteger(newExpiration) && newExpiration >= 0 && + const convertedExpiration = Number(editExpirationToken) + const newExpiration = normalizeDispenserExpiration( + editExpirationToken, this.consensusNetwork, block.timestamp, convertedExpiration) + // Same gated exact-or-clamped contract as the create guard above. + if (hasValidExpirationToken(editExpirationToken, newExpiration, this.consensusNetwork, block.timestamp) && newExpiration > block.timestamp){ return { editSource, newExpiration } } @@ -379,10 +365,14 @@ async function extendEditedDispenser(extension, nextBlockHeight){ // The clear below stays for the legacy era it was written // for, where it is still the only thing ending the // PERSISTENT divergence. + // Pre-image first, so a reorg of this block can restore what the extend overwrites. + if ((await this.db.recordDispenserExtensionUndo(editSource, nextBlockHeight)) === false){ + return 'rollback' + } if ((await this.db.extendOpenDispenserExpirationBySource(editSource, newExpiration, nextBlockHeight)) === false){ // extendOpenDispenserExpirationBySource's error path already rolled the block back. return 'rollback' } } -module.exports = { dispenserCommandPrefixFor, collectDispenserCreates, registerDispenser, dispenserEditExtension, extendEditedDispenser } +module.exports = { dispenserCommandPrefixFor, collectDispenserCreates, registerDispenser, dispenserEditExtension, extendEditedDispenser, isExactIntegerExpirationActive, isExactIntegerToken } diff --git a/src/XChainDecoder/dispenser_wide_expiration.js b/src/XChainDecoder/dispenser_wide_expiration.js new file mode 100644 index 00000000..14581bd1 --- /dev/null +++ b/src/XChainDecoder/dispenser_wide_expiration.js @@ -0,0 +1,112 @@ +/********************************************************************* + * + * 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'; + +const { + DISPENSER_WIDE_EXPIRATION_ACTIVATION, + EXACT_INTEGER_EXPIRATION_ACTIVATION, +} = require('../protocol/constants.js') + +const MAX_SAFE_DISPENSER_EXPIRATION = Number.MAX_SAFE_INTEGER +const U64_MAX = 18446744073709551615n + +function isDispenserWideExpirationActive(consensusNetwork, blockTime){ + const activation = DISPENSER_WIDE_EXPIRATION_ACTIVATION[consensusNetwork] + if (typeof activation !== 'number') return false + const time = Number(blockTime) + return Number.isFinite(time) && time >= activation +} + +function isExactIntegerExpirationActive(consensusNetwork, blockTime){ + const activation = EXACT_INTEGER_EXPIRATION_ACTIVATION[consensusNetwork] + return typeof activation === 'number' && Number.isFinite(Number(blockTime)) && Number(blockTime) >= activation +} + +function isExactIntegerToken(value){ + const match = String(value).trim().match(/^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/) + if (!match) return false + const fraction = match[3] || match[4] || '' + const digits = (match[2] || '') + fraction + const exponent = Number(match[5] || 0) + if (!Number.isSafeInteger(exponent)) return false + const fractionalDigits = fraction.length - exponent + if (fractionalDigits <= 0) return true + if (fractionalDigits >= digits.length) return /^0*$/.test(digits) + return /^0*$/.test(digits.slice(digits.length - fractionalDigits)) +} + +function hasValidExpirationToken(expirationToken, expiration, consensusNetwork, blockTime){ + if (!Number.isSafeInteger(expiration) || expiration < 0) return false + if (expirationToken === undefined || expirationToken === '') return true + return !isExactIntegerExpirationActive(consensusNetwork, blockTime) || isExactIntegerToken(expirationToken) +} + +function exactUnsignedDecimal(raw){ + const token = typeof raw === 'string' ? raw.trim() : String(raw) + const match = token.match(/^\+?([0-9]+)(?:\.([0-9]*))?(?:e([+-]?[0-9]+))?$/i) + if (!match) return null + + const fraction = match[2] || '' + const exponentToken = match[3] || '0' + if (exponentToken.replace(/^[+-]?0*/, '').length > 6) return null + const exponent = Number(exponentToken) + let digits = match[1] + fraction + const fractionalPlaces = fraction.length - exponent + + if (fractionalPlaces > 0){ + if (fractionalPlaces >= digits.length) return /^0+$/.test(digits) ? 0n : null + if (!/^0+$/.test(digits.slice(-fractionalPlaces))) return null + digits = digits.slice(0, -fractionalPlaces) + } else if (fractionalPlaces < 0){ + const zeroCount = -fractionalPlaces + if (!/^0+$/.test(digits) && digits.replace(/^0+/, '').length + zeroCount > 20) + return null + digits += '0'.repeat(zeroCount) + } + + return BigInt(digits || '0') +} + +// The decoder's dispenser set is advisory and may safely remain open longer +// than the indexer's authoritative row. A u64 expiration above the exact Number +// range is therefore represented by the largest exact Number, never by a rounded +// conversion of the wire token. Both values are far beyond the Unix timestamps +// this decoder can process, so neither side can expire first in reachable history. +function normalizeDispenserExpiration(raw, consensusNetwork, blockTime, asNumber = Number(raw)){ + if (raw === null || raw === undefined) return null + if (typeof raw === 'string' && raw.trim() === '') return null + if (Number.isSafeInteger(asNumber) && asNumber >= 0) + return asNumber + + if (!isDispenserWideExpirationActive(consensusNetwork, blockTime)) + return null + + const exact = exactUnsignedDecimal(raw) + if (exact === null) return null + if (exact <= BigInt(MAX_SAFE_DISPENSER_EXPIRATION) || exact > U64_MAX) + return null + return MAX_SAFE_DISPENSER_EXPIRATION +} + +module.exports = { + DISPENSER_WIDE_EXPIRATION_ACTIVATION, + MAX_SAFE_DISPENSER_EXPIRATION, + U64_MAX, + hasValidExpirationToken, + isDispenserWideExpirationActive, + isExactIntegerExpirationActive, + isExactIntegerToken, + normalizeDispenserExpiration, +} diff --git a/src/XChainDecoder/mempool_refresh.js b/src/XChainDecoder/mempool_refresh.js index fb6a101b..1e7c196a 100644 --- a/src/XChainDecoder/mempool_refresh.js +++ b/src/XChainDecoder/mempool_refresh.js @@ -19,6 +19,7 @@ ********************************************************************/ const { format: formatLogLine } = require('node:util') +const { cancelGraceFloor } = require('../protocol/dispenser_cancel_grace') const { logger, MEMPOOL_BATCH_SIZE } = require('./constants.js') function dedupNodeMempool(rawMempoolUnordered){ @@ -64,7 +65,30 @@ function decodeMempoolTransaction(nextTxHex, nextTxHexIndex){ return nextTx } -function* parseMempoolTransaction(nextTx, nextTransactionHash){ +// The open-dispenser set a pending tx is classified against, loaded once per cycle on +// mempoolDb with the same query and grace floor the block loop uses +// (loadOpenDispenserAddresses in block_store.js). A pending tx confirms at the earliest +// into the next block, whose header time is close to now, so wall-clock seconds stand +// in for that block's timestamp. A failed read is logged and the cycle parses against +// an empty set: the mempool view is non-consensus, so a pending row that blanks an +// unattributed ACTION for one poll is corrected by the block path on confirm, and the +// rest of the cycle (pruning, new arrivals) still runs. +function* loadMempoolOpenDispenserAddresses(){ + const floor = cancelGraceFloor(this.consensusNetwork, Math.floor(Date.now() / 1000)) + let openDispenserAddresses = null + try { + openDispenserAddresses = yield this.mempoolDb.getAllOpenDispenserAddresses(floor) + } catch (err) { + logger.error(formatLogLine("Mempool: failed to load open dispenser addresses:", err)) + } + if (openDispenserAddresses == null) { + logger.error("Mempool: open dispenser addresses unavailable; classifying this cycle without them") + return new Set() + } + return openDispenserAddresses +} + +function* parseMempoolTransaction(nextTx, nextTransactionHash, openDispenserAddresses){ let parseResult = null try { // Pass mempoolDb so the pubkey-capture writes inside parseTransaction also @@ -75,7 +99,10 @@ function* parseMempoolTransaction(nextTx, nextTransactionHash){ // mempool view is per-instance and non-consensus, so a // briefly-lagging instance near the flag boundary is // acceptable where a forked BLOCK parse would not be. - parseResult = yield this.parseTransaction(nextTx, undefined, this.mempoolDb, this.lastProcessedBlockIndex + 1) + // The open-dispenser set makes a payment to an open dispenser a dispense + // output here exactly as on the block path, so hasStorableContent agrees + // between the two for an ACTION whose source cannot be resolved. + parseResult = yield this.parseTransaction(nextTx, openDispenserAddresses, this.mempoolDb, this.lastProcessedBlockIndex + 1) } catch (err) { // The surrounding try has no catch (only a finally for the busy // flag), so a single undecodable mempool tx would abort the whole @@ -88,7 +115,22 @@ function* parseMempoolTransaction(nextTx, nextTransactionHash){ return parseResult } -function* storeMempoolTransaction(nextTxHex, nextTxHexIndex){ +// Apply the block path's two admission gates: an ACTION needs a resolved source +// (hasStorableContent), then the shared payload gate (buildStoredActionRecord). +// An unattributable ACTION is blanked, not skipped: the row is the seen-set entry. +// An unattributed ACTION that pays an open dispenser carries dispense outputs, so it +// passes hasStorableContent and keeps its text, matching the row stored on confirm. +function admitMempoolAction(parseResult, nextTransactionHash){ + const hasAction = (parseResult["data"] != null) && (parseResult["data"].length > 0) + if (hasAction && !this.hasStorableContent(parseResult)){ + this.parseErrors++ + logger.error(`Mempool: tx ${nextTransactionHash}: XChain data found but source address could not be resolved`) + return { skip: false, data: "", rawData: null, unattributed: true } + } + return this.buildStoredActionRecord(parseResult, nextTransactionHash, true) +} + +function* storeMempoolTransaction(nextTxHex, nextTxHexIndex, openDispenserAddresses){ let nextTx = decodeMempoolTransaction.call(this, nextTxHex, nextTxHexIndex) if (nextTx == null) { return false @@ -96,12 +138,12 @@ function* storeMempoolTransaction(nextTxHex, nextTxHexIndex){ let nextTransactionHash = nextTx.getId() - let parseResult = yield* parseMempoolTransaction.call(this, nextTx, nextTransactionHash) + let parseResult = yield* parseMempoolTransaction.call(this, nextTx, nextTransactionHash, openDispenserAddresses) if (parseResult == null) { return false } - // Same storage gate as the confirmed-block path, by construction: + // Same admission gates as the confirmed-block path (admitMempoolAction): // buildStoredActionRecord owns the ceiling, the alias expansion, the // UTF-8 decode and the VALID_ACTION_NAMES check, so a pending tx can // never show one thing and then silently vanish on confirm. It stores @@ -111,7 +153,7 @@ function* storeMempoolTransaction(nextTxHex, nextTxHexIndex){ // two encodings and content-correlation between a pending row and its // confirmed twin silently mismatches (uuid:26220713). A rejected ACTION // on a money-bearing tx blanks to '' (never SQL NULL) for the same reason. - let stored = this.buildStoredActionRecord(parseResult, nextTransactionHash, true) + const stored = admitMempoolAction.call(this, parseResult, nextTransactionHash) if (stored.skip) return false if (!(yield this.mempoolDb.insertMempoolTransaction({ @@ -127,11 +169,11 @@ function* storeMempoolTransaction(nextTxHex, nextTxHexIndex){ yield this.sleep(3000) return false } else { - return (parseResult["data"] != null) && (parseResult["data"].length > 0) + return !stored.unattributed && (parseResult["data"] != null) && (parseResult["data"].length > 0) } } -function* storeMempoolBatches(rawMempool){ +function* storeMempoolBatches(rawMempool, openDispenserAddresses){ let validTransactionsCount = 0 let i = 0 while (i < rawMempool.length) { @@ -156,7 +198,7 @@ function* storeMempoolBatches(rawMempool){ continue } - if (yield* storeMempoolTransaction.call(this, nextTxHex, nextTxHexIndex)) { + if (yield* storeMempoolTransaction.call(this, nextTxHex, nextTxHexIndex, openDispenserAddresses)) { validTransactionsCount = validTransactionsCount + 1 } } @@ -169,6 +211,9 @@ function* storeMempoolBatches(rawMempool){ function* refreshMempoolRows(rawMempool, nodeMempoolCount, mempoolStartTime){ let validTransactionsCount = 0 + // One load per cycle, shared by every tx the cycle parses. + const openDispenserAddresses = yield* loadMempoolOpenDispenserAddresses.call(this) + // All mempool DB work runs on this.mempoolDb, never this.db, so it stays outside the // block loop's open transaction. Deletes txs no longer in the node mempool and // drops txs already stored, leaving rawMempool holding only the new arrivals. @@ -179,7 +224,7 @@ function* refreshMempoolRows(rawMempool, nodeMempoolCount, mempoolStartTime){ // the call above truncated rawMempool down to the txids this node has not stored. let newArrivalsCount = rawMempool.length - validTransactionsCount = yield* storeMempoolBatches.call(this, rawMempool) + validTransactionsCount = yield* storeMempoolBatches.call(this, rawMempool, openDispenserAddresses) let mempoolEndTime = Date.now() let timeString = this.millisecondsToTimeString(mempoolEndTime - mempoolStartTime) diff --git a/src/XChainDecoder/reorg_halt.js b/src/XChainDecoder/reorg_halt.js index 7679b577..bd60dcde 100644 --- a/src/XChainDecoder/reorg_halt.js +++ b/src/XChainDecoder/reorg_halt.js @@ -37,12 +37,12 @@ function announceReorgHalt(reason, blocksDeleted, canPersist){ // REORG_HALT_MARKER record below carries the real answer. marker_write: canPersist ? 'attempting' : 'unavailable', // Spelled out rather than left for the reader to infer from the - // field: this is the one halt that /status and /live cannot - // report, because the marker they read is never written. + // field: this is the one halt /status and /live can report only + // from this process's memory, because no marker is ever written. detail: canPersist ? undefined : 'db.markReorgHalted is unavailable: the durable halt marker cannot be persisted, ' - + 'so GET /status, GET /live and the JSON-RPC health method will NOT report this halt. ' - + 'This log line is the only record of it.' + + 'so GET /status, GET /live and the JSON-RPC health method report this halt only from ' + + 'this process\'s memory and lose it on restart. This log line is the only lasting record of it.' }) } catch (_) { /* a diagnostic must never mask the abort it describes */ } } diff --git a/src/XChainDecoder/reorg_verification.js b/src/XChainDecoder/reorg_verification.js index 880bb9e8..ad83f63a 100644 --- a/src/XChainDecoder/reorg_verification.js +++ b/src/XChainDecoder/reorg_verification.js @@ -20,8 +20,9 @@ const { format: formatLogLine } = require('node:util') const { chainTierMismatch } = require('../protocol/chain_identity') -const { logger, DISPENSER_EXPIRE_SAFE_DEPTH } = require('./constants.js') +const { logger, DISPENSER_EXPIRE_SAFE_DEPTH, MIN_VERIFICATION_PROGRESS_TO_PARSE } = require('./constants.js') const { haltReorg } = require('./reorg_halt.js') +const { nodeStillCatchingUp } = require('./payload_helpers.js') function safeDepthFor(decoder){ return decoder.dispenserExpireSafeDepth || DISPENSER_EXPIRE_SAFE_DEPTH @@ -54,7 +55,12 @@ async function readPriorRollbackDepth(){ try { // Default scan bound, never the height ceiling: the bound counts ROWS and the // method returns distinct HEIGHTS, so re-deleted heights would crowd out older ones. - priorDepth = await this.db.countReorgDeletesAboveTip() + const depth = await this.db.countReorgDeletesAboveTip() + // A non-count is a read fault, never a depth: NaN fails both ceiling comparisons open. + if (!Number.isInteger(depth) || depth < 0){ + throw new Error('countReorgDeletesAboveTip returned a non-count depth (' + typeof depth + ' ' + String(depth) + ')') + } + priorDepth = depth seedErr = null break } catch (err){ @@ -75,8 +81,8 @@ async function readPriorRollbackDepth(){ // Fail-closed reorg-depth ceiling, parity with xchain-utxo-tracker's // UNDO_BLOCKS guard (XChainUtxoTracker.js verifyReorg). Soft-expired -// dispensers are hard-purged once DISPENSER_EXPIRE_SAFE_DEPTH blocks -// deep (purgeExpiredDispensers), and deleteBlockByIndex can only +// dispensers become eligible for hard purge once DISPENSER_EXPIRE_SAFE_DEPTH +// blocks deep (purgeExpiredDispensers), and deleteBlockByIndex can only // resurrect a dispenser whose expired_block_index row still exists, so // rolling back past that window would silently and permanently lose // money-bearing dispenser state vs a from-scratch sync. A loud abort is @@ -97,7 +103,7 @@ async function assertWithinSafeDepth(lastBlockIndex, priorDepth, blocksDeleted){ + (priorDepth + blocksDeleted.length) + " blocks (" + blocksDeleted.length + " in this run, resumed from " + priorDepth + " already deleted above the tip); " + "soft-expired dispenser rows for block height " - + lastBlockIndex + " and below have already been hard-purged, so continuing would " + + lastBlockIndex + " and below may already have been hard-purged, so continuing would " + "silently lose money-bearing dispenser state. Aborting. Recovery: a full resync from " + "a known-good snapshot, or once the rolled-back range is re-parsed and the database " + "is verified intact, `xchain-node clear-reorg-halt --reason \"...\"`." @@ -161,6 +167,13 @@ async function deleteAboveTipBlock(lastBlockIndex, lastBlock, nodeTip, priorDept return retryCount } +// Return true while the node is in initial block download or below the parse threshold, +// the block loop's own two gates: the mid-walk refresh keeps its tip then, because a +// catching-up node is waited on, never rolled back (its low tip deletes valid blocks). +function nodeTipStillCatchingUp(info){ + return nodeStillCatchingUp(info) || info["verificationprogress"] < MIN_VERIFICATION_PROGRESS_TO_PARSE +} + async function refreshReorgTip(nodeTip){ try { const info = await this.connector.getBlockchainInfo() @@ -185,6 +198,8 @@ async function refreshReorgTip(nodeTip){ const reorgGenesisMismatch = await this.verifyChainGenesis() if (reorgGenesisMismatch){ this.logError('reorg: ignoring a tip refresh from a foreign endpoint: ' + reorgGenesisMismatch) + } else if (nodeTipStillCatchingUp(info)) { + this.logWarn('reorg: ignoring a tip refresh from a node still catching up') } else { nodeTip = info.blocks } @@ -290,7 +305,8 @@ async function walkReorg(nodeTip, priorDepth, blocksDeleted){ continue } - if (lastBlock["block_hash"] != blockHashFromNode){ + // Strict compare: a loose one could coerce a non-string into a false fork that deletes this block. + if (lastBlock["block_hash"] !== blockHashFromNode){ retryCount = await deleteForkedBlock.call(this, lastBlockIndex, lastBlock, priorDepth, blocksDeleted, retryCount) } else { thereAreDifferences = false @@ -345,7 +361,7 @@ module.exports = { // Each rolled-back block already persisted its own REORG marker atomically with its // delete (deleteBlockByIndex), so there is no separate end-of-run event to write. // This is only an ops summary of the completed reorg. - this.log(`reorg: rolled back ${blocksDeleted.length} block(s): ` + JSON.stringify(blocksDeleted.map(b => b.block_index))) + this.logWarn(`reorg: rolled back ${blocksDeleted.length} block(s): ` + JSON.stringify(blocksDeleted.map(b => b.block_index))) // Once per RUN, never per deleted block: a per-block increment would report a // single depth-5 reorg as five reorgs and destroy the frequency signal. this.reorgCount++ diff --git a/src/XChainDecoder/startup.js b/src/XChainDecoder/startup.js index 3e3e23ba..f89a7d39 100644 --- a/src/XChainDecoder/startup.js +++ b/src/XChainDecoder/startup.js @@ -194,24 +194,23 @@ function initialLoopLatches(){ // it every poll, so a latch here would be a per-transition line that never toggles. let chainFieldMissingLogged = false - // Transaction-level parse-failure tracking for the block currently being - // retried (see TX_PARSE_MAX_RETRIES). - let txParseRetryHeight = -1 - let txParseRetryCount = 0 + // Transaction-level parse-failure attempts per tx position of the block being + // retried (see TX_PARSE_MAX_RETRIES), keyed like insertQuarantine below. + const txParseRetryCounts = new Map() // Deterministic-INSERT-failure tracking. A row the DB rejects deterministically // (Database.POISON_ROW: an errno in DETERMINISTIC_WRITE_ERRNOS, e.g. 1406 data too // long) can never insert as-is, so retrying the block would wedge it forever. // After TX_PARSE_MAX_RETRIES the tx position is added to insertQuarantine and the // re-parse skips it (PARSE_ERROR + no insert), mirroring the parse-throw quarantine. - // Keyed ":"; cleared on block commit so it stays bounded - // and cannot leak across a height whose content changed under a reorg. Only + // Counts and entries are keyed "::", so every tx + // gets its own retries and a block replaced at the same height (sibling or reorg) + // never inherits them; all are cleared on block commit so they stay bounded. Only // DETERMINISTIC failures quarantine; transient ones (false) still retry forever, so // no instance ever skips a tx a healthy instance accepts (cross-instance parity). - let insertQuarantineHeight = -1 - let insertQuarantineCount = 0 + const insertQuarantineCounts = new Map() const insertQuarantine = new Set() - return { nodeSyncedProblem, nodeCatchingUpProblem, tipBelowStoredTipRefused, wrongChainProblem, wrongGenesisProblem, chainFieldMissingLogged, txParseRetryHeight, txParseRetryCount, insertQuarantineHeight, insertQuarantineCount, insertQuarantine } + return { nodeSyncedProblem, nodeCatchingUpProblem, tipBelowStoredTipRefused, wrongChainProblem, wrongGenesisProblem, chainFieldMissingLogged, txParseRetryCounts, insertQuarantineCounts, insertQuarantine } } async function bootDecoder(){ diff --git a/src/XChainDecoder/sync_loop.js b/src/XChainDecoder/sync_loop.js index 0bd4f8ba..64336f22 100644 --- a/src/XChainDecoder/sync_loop.js +++ b/src/XChainDecoder/sync_loop.js @@ -76,7 +76,7 @@ async function reconcileEqualHeightTip(loop){ // true. Its own catch classifies rather than swallows: a REORG_HALT // refusal parks the loop (nothing a restart can fix), every other abort // still propagates out of start() and halts loudly. - this.log("Equal-height tip replacement detected at height " + loop.lastProcessedBlockIndex + ". Reconciling...") + this.logWarn("Equal-height tip replacement detected at height " + loop.lastProcessedBlockIndex + ". Reconciling...") await this.db.endTransaction() try { await this.verifyReorg(this.blockchainInfoLastBlock) diff --git a/src/XChainDecoder/sync_status.js b/src/XChainDecoder/sync_status.js index b833d128..eb089c2a 100644 --- a/src/XChainDecoder/sync_status.js +++ b/src/XChainDecoder/sync_status.js @@ -178,11 +178,13 @@ module.exports = { // iteration counter independent of the chain covers all three. // // Fail-quiet in the same style as isStalled(), because the consumer restarts the - // container: lastPollAt 0 (loop has not iterated yet, e.g. a long initial sync) - // is never silent. + // container: lastPollAt 0 means pre-loop boot (database prepare and migrations, + // txindex probe, cursor read), never initial sync, since every pass stamps it. + // That window is bounded by DB_QUERY_TIMEOUT and MIGRATE_QUERY_TIMEOUT (default + // 1 h per migration statement): a timeout fails start() and /live turns 503. Until + // then /live stays 200 on purpose, so autoheal never restarts a long migration. isPollSilent() { - // The loop has not completed a single pass yet, which a long initial sync - // does legitimately, so there is no silence to report. + // The loop has not started yet (pre-loop boot), so there is no silence to report. if (!this.lastPollAt) return false return (Date.now() - this.lastPollAt) > POLL_SILENT_MS }, diff --git a/src/XChainDecoder/tip_refresh.js b/src/XChainDecoder/tip_refresh.js index fdb6866d..eb8bb0b4 100644 --- a/src/XChainDecoder/tip_refresh.js +++ b/src/XChainDecoder/tip_refresh.js @@ -220,7 +220,7 @@ async function reconcileOrphanBlocks(loop, lastProcessedBlockIndex){ // the fork point. blockchainInfoLastBlock was just refreshed above, so // the tip is current. if (!loop.tipBelowStoredTipRefused){ - this.log("The last processed block height ("+lastProcessedBlockIndex+") is greater than the last block from the node ("+this.blockchainInfoLastBlock+"). Reconciling orphan blocks...") + this.logWarn("The last processed block height ("+lastProcessedBlockIndex+") is greater than the last block from the node ("+this.blockchainInfoLastBlock+"). Reconciling orphan blocks...") } await this.db.endTransaction() try { diff --git a/src/XChainDecoder/transaction_ingest.js b/src/XChainDecoder/transaction_ingest.js index 6baacba6..2475e3ff 100644 --- a/src/XChainDecoder/transaction_ingest.js +++ b/src/XChainDecoder/transaction_ingest.js @@ -23,6 +23,28 @@ const { captureCommands, collapseDispenserRegistrations } = require('../protocol const { logger, TX_PARSE_MAX_RETRIES } = require('./constants.js') const { dispenserCommandPrefixFor, collectDispenserCreates, registerDispenser, dispenserEditExtension, extendEditedDispenser } = require('./dispenser_registration.js') +// Key retry and quarantine state by height, block hash and tx position, so a +// second tx in the block, or a sibling block that replaced this one at the same +// height, never inherits another position's retries or quarantine. +function quarantineKey(nextBlockHeight, nextBlockHash, txIndex){ + return nextBlockHeight + ':' + nextBlockHash + ':' + txIndex +} + +// Record one more failed attempt for this key and return the running total. +function countAttempt(attempts, key){ + const attempt = (attempts.get(key) || 0) + 1 + attempts.set(key, attempt) + return attempt +} + +// Drop every retry count and quarantined position once the block that owned +// them has committed (its PARSE_ERROR rows are durable), keeping the state bounded. +function clearQuarantineState(loop){ + loop.txParseRetryCounts.clear() + loop.insertQuarantineCounts.clear() + loop.insertQuarantine.clear() +} + async function skipQuarantinedTransaction(block, nextBlockHeight, nextTransaction, txIndex){ this.parseErrors++ let quarantinedHash = null @@ -40,7 +62,7 @@ async function skipQuarantinedTransaction(block, nextBlockHeight, nextTransactio return 'continue' } -async function handleParseFailure(loop, e, block, nextBlockHeight, nextTransactionHash, txIndex){ +async function handleParseFailure(loop, e, block, nextBlockHeight, nextTransactionHash, txIndex, retryKey){ if (e && e.rpcLookupFailure){ // A prevout/fee-output RPC lookup failed even after the // connector's internal retries. That is node/infrastructure @@ -54,16 +76,11 @@ async function handleParseFailure(loop, e, block, nextBlockHeight, nextTransacti return 'rollback' } - if (loop.txParseRetryHeight != nextBlockHeight){ - loop.txParseRetryHeight = nextBlockHeight - loop.txParseRetryCount = 0 - } - loop.txParseRetryCount++ - - if (loop.txParseRetryCount <= TX_PARSE_MAX_RETRIES){ + const attempt = countAttempt(loop.txParseRetryCounts, retryKey) + if (attempt <= TX_PARSE_MAX_RETRIES){ // Could be transient (DB hiccup inside parseTransaction): // roll the block back and re-parse it from scratch. - logger.error(formatLogLine(`parseTransaction failed in block ${nextBlockHeight} (tx position ${txIndex}, attempt ${loop.txParseRetryCount}/${TX_PARSE_MAX_RETRIES}), retrying block:`, e)) + logger.error(formatLogLine(`parseTransaction failed in block ${nextBlockHeight} (tx position ${txIndex}, attempt ${attempt}/${TX_PARSE_MAX_RETRIES}), retrying block:`, e)) await this.db.endTransaction() return 'rollback' } @@ -86,19 +103,19 @@ async function handleParseFailure(loop, e, block, nextBlockHeight, nextTransacti return 'continue' } -async function parseBlockTransaction(loop, block, nextBlockHeight, openDispenserAddresses, nextTransaction, txIndex){ +async function parseBlockTransaction(loop, block, nextBlockHeight, openDispenserAddresses, nextTransaction, txIndex, retryKey){ let nextTransactionHash = null let parseResult = null try { nextTransactionHash = nextTransaction.getId() parseResult = await this.parseTransaction(nextTransaction, openDispenserAddresses, undefined, nextBlockHeight) } catch (e){ - return await handleParseFailure.call(this, loop, e, block, nextBlockHeight, nextTransactionHash, txIndex) + return await handleParseFailure.call(this, loop, e, block, nextBlockHeight, nextTransactionHash, txIndex, retryKey) } return { nextTransactionHash, parseResult } } -async function insertTransactionRow(loop, parseResult, nextTransactionHash, nextBlockHeight, stored, txIndex){ +async function insertTransactionRow(loop, parseResult, nextTransactionHash, nextBlockHeight, stored, txIndex, retryKey){ let insertResult = await this.db.insertTransaction({ index: loop.lastProcessedTxIndex, hash: nextTransactionHash, @@ -120,16 +137,12 @@ async function insertTransactionRow(loop, parseResult, nextTransactionHash, next // retry margin guards against a misclassified transient error; // the errno set is conservative, so this normally quarantines // on the first exceedance.) - if (loop.insertQuarantineHeight != nextBlockHeight){ - loop.insertQuarantineHeight = nextBlockHeight - loop.insertQuarantineCount = 0 - } - loop.insertQuarantineCount++ - if (loop.insertQuarantineCount > TX_PARSE_MAX_RETRIES){ - loop.insertQuarantine.add(nextBlockHeight + ':' + txIndex) + const attempt = countAttempt(loop.insertQuarantineCounts, retryKey) + if (attempt > TX_PARSE_MAX_RETRIES){ + loop.insertQuarantine.add(retryKey) logger.error(`Quarantining tx with deterministic INSERT failure in block ${nextBlockHeight} (tx position ${txIndex}, hash ${nextTransactionHash}) after ${TX_PARSE_MAX_RETRIES} block retries`) } else { - logger.error(`insertTransaction deterministic failure in block ${nextBlockHeight} (tx position ${txIndex}, attempt ${loop.insertQuarantineCount}/${TX_PARSE_MAX_RETRIES}), retrying block`) + logger.error(`insertTransaction deterministic failure in block ${nextBlockHeight} (tx position ${txIndex}, attempt ${attempt}/${TX_PARSE_MAX_RETRIES}), retrying block`) } return 'rollback' } else if (insertResult === false){ @@ -225,8 +238,8 @@ async function capturePaymentOutputs(loop, block, parseResult, nextTransactionHa return commands } -async function persistTransaction(loop, block, nextBlockHeight, openDispenserAddresses, parseResult, nextTransactionHash, dispenseOutputs, stored, decodedData, txIndex){ - if ((await insertTransactionRow.call(this, loop, parseResult, nextTransactionHash, nextBlockHeight, stored, txIndex)) === 'rollback') return 'rollback' +async function persistTransaction(loop, block, nextBlockHeight, openDispenserAddresses, parseResult, nextTransactionHash, dispenseOutputs, stored, decodedData, txIndex, retryKey){ + if ((await insertTransactionRow.call(this, loop, parseResult, nextTransactionHash, nextBlockHeight, stored, txIndex, retryKey)) === 'rollback') return 'rollback' //Store dispenses outputs. false means the INSERT failed and //the block transaction was already rolled back: stop writing //(anything further would land outside a transaction) and @@ -245,7 +258,7 @@ async function persistTransaction(loop, block, nextBlockHeight, openDispenserAdd // A transaction carrying a single create (every non-BATCH transaction, // and every transaction below the gate) collapses to that create // unchanged, so this insert is byte-identical to the one it replaces. - for (let nextRegistration of collapseDispenserRegistrations(dispenserCreateCandidates)){ + for (let nextRegistration of collapseDispenserRegistrations(dispenserCreateCandidates, this.consensusNetwork, block.timestamp)){ if ((await registerDispenser.call(this, loop, nextRegistration, openDispenserAddresses)) === 'rollback') return 'rollback' } @@ -260,9 +273,10 @@ async function persistTransaction(loop, block, nextBlockHeight, openDispenserAdd } } -async function ingestTransaction(loop, block, nextBlockHeight, openDispenserAddresses, nextTransaction, txIndex){ +async function ingestTransaction(loop, block, nextBlockHeight, openDispenserAddresses, nextTransaction, txIndex, nextBlockHash){ let nextTransactionHash = null let parseResult = null + const retryKey = quarantineKey(nextBlockHeight, nextBlockHash, txIndex) // Insert-quarantine skip: this tx position deterministically failed to // INSERT on a prior pass of this block. Skip it exactly like a quarantined @@ -270,11 +284,11 @@ async function ingestTransaction(loop, block, nextBlockHeight, openDispenserAddr // poison row cannot wedge the block. The block transaction is open here // (beginTransaction ran when blocksQuantity hit 0), so the event commits // with the block. Deterministic across instances, so parity holds. - if (loop.insertQuarantine.has(nextBlockHeight + ':' + txIndex)){ + if (loop.insertQuarantine.has(retryKey)){ return await skipQuarantinedTransaction.call(this, block, nextBlockHeight, nextTransaction, txIndex) } - const parsed = await parseBlockTransaction.call(this, loop, block, nextBlockHeight, openDispenserAddresses, nextTransaction, txIndex) + const parsed = await parseBlockTransaction.call(this, loop, block, nextBlockHeight, openDispenserAddresses, nextTransaction, txIndex, retryKey) if (typeof parsed === 'string') return parsed ;({ nextTransactionHash, parseResult } = parsed) @@ -297,16 +311,19 @@ async function ingestTransaction(loop, block, nextBlockHeight, openDispenserAddr // The canonical ACTION string as stored; the dispenser and // COINPAY handling below reads the same value the row holds. let decodedData = stored.data - return await persistTransaction.call(this, loop, block, nextBlockHeight, openDispenserAddresses, parseResult, nextTransactionHash, dispenseOutputs, stored, decodedData, txIndex) + return await persistTransaction.call(this, loop, block, nextBlockHeight, openDispenserAddresses, parseResult, nextTransactionHash, dispenseOutputs, stored, decodedData, txIndex, retryKey) } else { // Verify a payload that says something has an author. A // record with no resolvable source address cannot be // attributed to anyone, so it is skipped rather than stored. if ((parseResult["data"].length > 0) && (parseResult["source"] == null)){ + // Count the skip toward parse_errors (monitoring only, nothing stored) + // so a source-resolution regression shows in health and metrics. + this.parseErrors++ logger.error(`Skipping tx ${nextTransactionHash}: XChain data found but source address could not be resolved`) } } } } -module.exports = { ingestTransaction } +module.exports = { ingestTransaction, clearQuarantineState } diff --git a/src/api.js b/src/api.js index 293a8784..4fc8dd3c 100644 --- a/src/api.js +++ b/src/api.js @@ -25,12 +25,13 @@ dotenv.config() // DECODER_API_PORT check is exactly the line an operator needs levelled and // timestamped, and installObservability does not run until ~200 lines further // down. +const config = require('./config'); const { patchConsole } = require('./observability'); patchConsole({ service: 'xchain-decoder', version: require('../package.json').version, - coin: process.env.COIN || '', - network: process.env.NETWORK || '' + coin: config.COIN || '', + network: config.NETWORK || '' }); const express = require('express'); @@ -56,22 +57,22 @@ const { reportStartFailure, installCrashHandlers } = require('./api/crash_report const { getHealthProbeState } = require('./api/health_probe'); const { installDecoderObservability } = require('./api/observability_wiring'); -const NETWORK = process.env.NETWORK -const NODE_URL = process.env.NODE_URL -const NODE_PORT = process.env.NODE_PORT -const NODE_USER = process.env.NODE_USER -const NODE_PASSWORD = process.env.NODE_PASSWORD -const DB_URL = process.env.DECODER_DB_HOST -const DB_PORT = process.env.DECODER_DB_PORT -const DECODER_DB_NAME = process.env.DECODER_DB_NAME -const DECODER_DB_USER = process.env.DECODER_DB_USER -const DB_PASSWORD = process.env.DECODER_DB_PASS -const DECODER_API_PORT = parseInt(process.env.DECODER_API_PORT, 10) -const AUX_POW = process.env.AUX_POW === 'true' || process.env.AUX_POW === '1' +const NETWORK = config.NETWORK +const NODE_URL = config.NODE_URL +const NODE_PORT = config.NODE_PORT +const NODE_USER = config.NODE_USER +const NODE_PASSWORD = config.NODE_PASSWORD +const DB_URL = config.DECODER_DB_HOST +const DB_PORT = config.DECODER_DB_PORT +const DECODER_DB_NAME = config.DECODER_DB_NAME +const DECODER_DB_USER = config.DECODER_DB_USER +const DB_PASSWORD = config.DECODER_DB_PASS +const DECODER_API_PORT = parseInt(config.DECODER_API_PORT, 10) +const AUX_POW = config.AUX_POW === 'true' || config.AUX_POW === '1' // Native-coin protocol fee destination for this coin+network: registry-pinned default with a // regtest-only env override (see src/protocol/fee_destination.js). When resolved, the decoder persists // outputs paying it to transaction_outputs so the indexer can validate native-coin fee payments. -const FEE_DESTINATION = resolveFeeDestination(NETWORK, process.env.FEE_DESTINATION || null) +const FEE_DESTINATION = resolveFeeDestination(NETWORK, config.FEE_DESTINATION || null) // The JSON-RPC health payload, built from getHealthProbeState's result. The caller @@ -118,6 +119,7 @@ function buildHealthResult(decoder, state, decoderRunning, decoderError){ : null, rpc_errors: decoder.rpcErrors + decoder.connector.rpcErrors, parse_errors: decoder.parseErrors, + dispenser_purge_failures: decoder.dispenserPurgeFailures || 0, error: decoderError ? decoderError.message : null } } @@ -151,6 +153,9 @@ function registerStatusRoute(app, decoder, isDecoderRunning){ db: dbOk, running: decoderRunning, lag: syncStatus.lag, + // Ships beside lag: a frozen tip reads lag 0, and the bootstrap gate refuses on + // this flag. Reported, not gated on, as on /live. + node_height_stale: syncStatus.node_height_stale === true, reorg_halted: reorgHalt.halted, reorg_halt_reason: reorgHalt.reason, reorg_halted_at: reorgHalt.at, @@ -234,38 +239,40 @@ function createMempoolMethods(decoder){ // per-request, so differing limits share one read. A poll-cycle-stale // snapshot is fine: updateMempool itself only rewrites every 60s. async getmempool(params) { - const ttl = parseInt(process.env.GETMEMPOOL_CACHE_MS, 10) || 5000; + const ttl = parseInt(config.GETMEMPOOL_CACHE_MS, 10) || 5000; const now = Date.now(); const db = decoder.mempoolDb || decoder.db; + let stale = false; + let snapshot = getmempoolCache; if (!getmempoolCache || (now - getmempoolCache.t) >= ttl) { - let rows = [], total = 0; if (db) { try { - rows = await db.getMempoolTransactions(500); - total = await db.getMempoolTransactionCount(); + const rows = await db.getMempoolTransactions(500); + const total = await db.getMempoolTransactionCount(); + getmempoolCache = { t: now, rows, total, read_ok_at: Date.now() }; } catch (err) { // Serve the stale snapshot if we have one; a mempool read // must never surface as an API error to remote explorers. console.error('getmempool: mempool read failed:', err); - rows = getmempoolCache ? getmempoolCache.rows : []; - total = getmempoolCache ? getmempoolCache.total : 0; + stale = true; } + } else { + getmempoolCache = { t: now, rows: [], total: 0, read_ok_at: null }; } - getmempoolCache = { t: now, rows, total }; + snapshot = getmempoolCache || { rows: [], total: 0, read_ok_at: null }; } const limit = Math.max(1, Math.min(parseInt(params && params.limit, 10) || 500, 500)); return { node_tx_count: decoder.nodeMempoolTxCount, node_updated_at: decoder.nodeMempoolUpdatedAt, - total: getmempoolCache.total, - rows: getmempoolCache.rows.slice(0, limit).map(r => ({ - tx_hash: r.tx_hash, - source: r.source, - // TEXT can come back as a Buffer depending on driver options; - // normalize so the JSON body always carries the UTF-8 string. - data: Buffer.isBuffer(r.data) ? r.data.toString('utf8') : r.data, - first_seen: (r.first_seen instanceof Date) ? Math.floor(r.first_seen.getTime() / 1000) - : (r.first_seen != null ? r.first_seen : null) + stale, + read_ok_at: snapshot.read_ok_at, + total: snapshot.total, + rows: snapshot.rows.slice(0, limit).map(r => ({ + tx_hash: r.tx_hash, + source: r.source, + data: Buffer.isBuffer(r.data) ? r.data.toString('utf8') : r.data, + first_seen: Number.isFinite(r.first_seen) ? r.first_seen : null })) }; } @@ -280,7 +287,7 @@ function installAppMiddleware(app, decoder){ // Rate limiting (requests per minute per IP; override with DECODER_RATE_LIMIT_RPM) app.use(rateLimit({ windowMs: 60 * 1000, - limit: parseInt(process.env.DECODER_RATE_LIMIT_RPM, 10) || 100, + limit: parseInt(config.DECODER_RATE_LIMIT_RPM, 10) || 100, standardHeaders: true, legacyHeaders: false })); @@ -290,7 +297,7 @@ function installAppMiddleware(app, decoder){ // there is nothing a cross-origin caller can reach that a direct one cannot. app.use(cors()); - installDecoderObservability(app, decoder, { COIN: process.env.COIN, NETWORK }) + installDecoderObservability(app, decoder, { COIN: config.COIN, NETWORK }) } // Routes in registration order: GET /status, GET /live, the batch guard, the @@ -301,7 +308,7 @@ function registerRoutes(app, decoder, isDecoderRunning, getDecoderError){ // Bound JSON-RPC batch size (see makeRpcBatchGuard). Must run after bodyParser // (req.body parsed) and before the router (dispatch). - app.use(makeRpcBatchGuard(parseInt(process.env.DECODER_RPC_MAX_BATCH, 10) || 20)) + app.use(makeRpcBatchGuard(parseInt(config.DECODER_RPC_MAX_BATCH, 10) || 20)) // Express 5 / body-parser 2.x leaves req.body undefined when a request carries // no JSON body (a GET, or a POST without application/json), whereas body-parser @@ -318,7 +325,7 @@ async function startApi(){ // bind a random OS-assigned port, making the container appear healthy while every // downstream caller gets connection-refused. Checked here (not at module load) so the // module can be required by tests without a valid port set. - if (!process.env.DECODER_API_PORT || isNaN(DECODER_API_PORT) || DECODER_API_PORT < 1 || DECODER_API_PORT > 65535) { + if (!config.DECODER_API_PORT || isNaN(DECODER_API_PORT) || DECODER_API_PORT < 1 || DECODER_API_PORT > 65535) { console.error('DECODER_API_PORT is not set or invalid. Set a valid port (1-65535) in the environment.') process.exit(1) } @@ -379,4 +386,4 @@ if (require.main === module) startApi() // startApi is exported so the crash handlers it installs can be driven for real // rather than asserted against the source text; the require.main guard above // still keeps a plain require from opening a port or a DB connection. -module.exports = { makeRpcBatchGuard, registerLiveRoute, startApi, noteProbeFailure, nodeReachabilityFields, resetProbeLogState, ageProbeLogState, PROBE_LOG_WINDOW_MS } \ No newline at end of file +module.exports = { makeRpcBatchGuard, registerLiveRoute, registerStatusRoute, startApi, createMempoolMethods, noteProbeFailure, nodeReachabilityFields, resetProbeLogState, ageProbeLogState, PROBE_LOG_WINDOW_MS } diff --git a/src/chain/blockchain_connector/auxpow_codec.js b/src/chain/blockchain_connector/auxpow_codec.js index 3a4615d0..00a15c6c 100644 --- a/src/chain/blockchain_connector/auxpow_codec.js +++ b/src/chain/blockchain_connector/auxpow_codec.js @@ -14,7 +14,7 @@ // Decode a Bitcoin-style varint from `buf` at `offset`. // Returns { value, bytes } where `bytes` is the number of bytes consumed. -// Keep in sync with xchain-utxo-tracker/src/chain/blockchain_connector.js readVarint. +// Keep in sync with xchain-utxo-tracker/src/chain/blockchain_connector/auxpow_codec.js readVarint. function readVarint(buf, offset) { const first = buf[offset] if (first < 0xFD) return { value: first, bytes: 1 } @@ -27,7 +27,7 @@ function readVarint(buf, offset) { } // Encode a Bitcoin-style varint as lowercase hex (inverse of readVarint). -// Keep in sync with xchain-utxo-tracker/src/chain/blockchain_connector.js encodeVarintHex. +// Keep in sync with xchain-utxo-tracker/src/chain/blockchain_connector/auxpow_codec.js encodeVarintHex. function encodeVarintHex(value) { if (value < 0xFD) { return value.toString(16).padStart(2, '0') @@ -109,7 +109,7 @@ function skipCoinbaseTransaction(buf, start) { // chain merge-mining branch (same layout) | // parent block header (80 B) // Throws if the buffer is too short or structurally invalid. -// Keep in sync with xchain-utxo-tracker/src/chain/blockchain_connector.js skipAuxPow. +// Keep in sync with xchain-utxo-tracker/src/chain/blockchain_connector/auxpow_codec.js skipAuxPow. function skipAuxPow(buf, start) { let offset = skipCoinbaseTransaction(buf, start) @@ -142,7 +142,7 @@ function skipAuxPow(buf, start) { // header/block length delta), and Dogecoin Core 1.14 whose getblockheader always // returns exactly 160 chars, requiring the AuxPoW size to be parsed structurally from // the block hex (skipAuxPow). Non-AuxPoW blocks pass through unchanged. -// Keep in sync with xchain-utxo-tracker/src/chain/blockchain_connector.js stripAuxPowFromBlockHex. +// Keep in sync with xchain-utxo-tracker/src/chain/blockchain_connector/auxpow_codec.js stripAuxPowFromBlockHex. // test/unit/auxpow_strip_parity.test.js asserts byte identity of the two function bodies, // so a strip correction cannot land in one repo alone. function stripAuxPowFromBlockHex(headerHex, blockHex) { diff --git a/src/chain/blockchain_connector/block_queries.js b/src/chain/blockchain_connector/block_queries.js index 01389482..743fee0f 100644 --- a/src/chain/blockchain_connector/block_queries.js +++ b/src/chain/blockchain_connector/block_queries.js @@ -13,6 +13,10 @@ ********************************************************************/ const { encodeVarintHex, stripAuxPowFromBlockHex } = require('./auxpow_codec.js') +const { attachRpcFields } = require('./rpc_helpers.js') + +// A block hash as every supported node prints it: 32 bytes of hex, either case. +const BLOCK_HASH_HEX = /^[0-9a-fA-F]{64}$/ module.exports = { async getNetworkInfo(){ @@ -37,12 +41,22 @@ module.exports = { // "Do not know how to serialize a BigInt". Coerce defensively at the RPC boundary. blockindex = Number(blockindex) - return await this.rpcCallWithTimeoutRetry({ + const result = await this.rpcCallWithTimeoutRetry({ jsonrpc: '2.0', method: 'getblockhash', params: [blockindex], id: 1, }, 'block hash') + // Require a 64-hex block hash: the reorg walk deletes stored blocks on a hash + // mismatch, so a malformed answer (a proxy's object, number or short string) + // must fail closed into the caller's retry, never read as a fork. + if (typeof result !== 'string' || !BLOCK_HASH_HEX.test(result)) { + this.rpcErrors++ + throw new Error('Error getting block hash: malformed result (' + typeof result + ' ' + + String(JSON.stringify(result)).slice(0, 80) + ')') + } + // Lowercase it so a case-changing hop cannot make a valid hash compare unequal. + return result.toLowerCase() }, async getBlockHeader(blockhash, hexFormat = true) { @@ -96,6 +110,7 @@ module.exports = { // the decoder already depends on (Dogecoin 1.14 has no verbosity-2 // getblock, so per-txid fetches are the portable route). Deterministic // across instances: the output depends only on chain content. + // Keep in sync with xchain-utxo-tracker/src/chain/blockchain_connector/block_queries.js getBlockReassembled. async getBlockReassembled(blockhash) { try { // Older daemons append the AuxPoW bytes to getblockheader; the pure @@ -121,9 +136,11 @@ module.exports = { // sit INSIDE this try, so a transport fault (an ECONNRESET from a saturated // Dogecoin 1.14 RPC queue, an ECONNABORTED timeout, a node restart) lands here // beside a genuine content fault, and only error.code and the rpcCode/rpcMessage - // sanitizeRpcError attaches separate the two. _auxPowParseErrorCount never - // decays, so once a height has escalated to this path every later failure at - // that height arrives through this catch, which is precisely where an operator + // the RPC helpers attach (to an HTTP-500 and an HTTP-200 JSON-RPC error alike) + // separate the two, so all three are copied onto the wrapper below. + // _auxPowParseErrorCount never decays, so once a height has escalated to this + // path every later failure at that height arrives through this catch, which is + // precisely where an operator // has to tell an unreachable node from a block whose bytes are unusable. // Mirrors the cause attachment getBlockWithoutAuxPow makes above. // @@ -136,6 +153,7 @@ module.exports = { const reassembleErr = new Error("There were problems reassembling a block without auxpow. " + err.message) reassembleErr.cause = err if (err && err.code !== undefined) reassembleErr.code = err.code + if (err) attachRpcFields(reassembleErr, err.rpcCode, err.rpcMessage) throw reassembleErr } }, diff --git a/src/chain/blockchain_connector/rpc_helpers.js b/src/chain/blockchain_connector/rpc_helpers.js index 4f57f3a6..99f36ad7 100644 --- a/src/chain/blockchain_connector/rpc_helpers.js +++ b/src/chain/blockchain_connector/rpc_helpers.js @@ -36,6 +36,17 @@ function envInt(raw, fallback, name, min = 1) { return n } +// Attach the node's JSON-RPC error code and message to an error, non-enumerable so +// JSON serialization of the error is unchanged. The one definition both transports +// use: sanitizeRpcError for an HTTP-500 body, rpcResult for an HTTP-200 one. +function attachRpcFields(error, rpcCode, rpcMessage){ + if (!error || (rpcCode === undefined && rpcMessage === undefined)) return + try { + Object.defineProperty(error, 'rpcCode', { value: rpcCode, enumerable: false, configurable: true }) + Object.defineProperty(error, 'rpcMessage', { value: rpcMessage, enumerable: false, configurable: true }) + } catch (_) { /* attaching diagnostics must never mask the original failure */ } +} + // Sanitize an axios error before it is logged or re-thrown. Every RPC call passes // `auth: { username: rpcUser, password: rpcPassword }`, and axios attaches the request // config to the thrown error, so `logger.error(formatLogLine(msg, error))` serializes NODE_USER / @@ -69,11 +80,8 @@ function sanitizeRpcError(error){ } error.response = (status !== undefined) ? { status: status } : undefined } - if (error && (rpcCode !== undefined || rpcMessage !== undefined)) { - // Non-enumerable so this does not alter JSON serialization of the error. - Object.defineProperty(error, 'rpcCode', { value: rpcCode, enumerable: false, configurable: true }) - Object.defineProperty(error, 'rpcMessage', { value: rpcMessage, enumerable: false, configurable: true }) - } + // Non-enumerable so this does not alter JSON serialization of the error. + attachRpcFields(error, rpcCode, rpcMessage) } catch (_) { /* sanitization must never mask the original failure */ } const base = (error && error.message) ? error.message : String(error) if (rpcCode !== undefined || rpcMessage !== undefined) { @@ -101,7 +109,12 @@ function rpcResult(response, label) { if (rpcError) { const code = (rpcError.code !== undefined) ? rpcError.code : 'unknown' const message = (typeof rpcError.message === 'string') ? rpcError.message : JSON.stringify(rpcError) - throw new Error(`${label}: RPC error ${code}: ${message}`) + const err = new Error(`${label}: RPC error ${code}: ${message}`) + // Carry the code and message as fields too, exactly as an HTTP-500 error does, so + // a caller branching on rpcCode behaves the same on a JSON-RPC 2.0 node. + attachRpcFields(err, rpcError.code, + (typeof rpcError.message === 'string') ? rpcError.message : undefined) + throw err } if (!response || !response.data) throw new Error(label) const result = response.data.result @@ -148,8 +161,19 @@ function nodeReachabilityFrom(startedAt, lastNodeOkAt, lastNodeFailAt, now = Dat } } +// Copy a cause's identity (error.code plus rpcCode/rpcMessage) onto an error built in its +// place, so a retry ladder's exhaustion error still tells a transport fault from a node answer. +function carryErrorIdentity(target, cause){ + if (!target || !cause) return target + if (cause.code !== undefined) target.code = cause.code + attachRpcFields(target, cause.rpcCode, cause.rpcMessage) + return target +} + module.exports = { envInt, + attachRpcFields, + carryErrorIdentity, sanitizeRpcError, rpcResult, normalizeEndpoint, diff --git a/src/chain/blockchain_connector/rpc_transport.js b/src/chain/blockchain_connector/rpc_transport.js index a210e6fe..d4a0126b 100644 --- a/src/chain/blockchain_connector/rpc_transport.js +++ b/src/chain/blockchain_connector/rpc_transport.js @@ -16,7 +16,7 @@ const axios = require('axios'); const config = require('../../config'); const { format: formatLogLine } = require('node:util'); const { CONNECTION_ERROR_CODES, logger } = require('./constants.js') -const { envInt, nodeReachabilityFrom, rpcResult, sanitizeRpcError } = require('./rpc_helpers.js') +const { carryErrorIdentity, envInt, nodeReachabilityFrom, rpcResult, sanitizeRpcError } = require('./rpc_helpers.js') module.exports = { // Node reachability as the health surfaces publish it. Cheap and never throws, @@ -108,6 +108,7 @@ module.exports = { async rpcCallWithTimeoutRetry(data, label, { resultLabel, exhausted } = {}){ let tries = 10 let lastErrorSummary = null + let lastError = null while (tries > 0) { try { @@ -119,6 +120,7 @@ module.exports = { tries = tries - 1 logger.info(`Getting timeout trying to get ${label}, trying again...`) lastErrorSummary = sanitizeRpcError(error) + lastError = error await this.backoffOnTimeout() } else { this.rpcErrors++ @@ -130,6 +132,6 @@ module.exports = { this.rpcErrors++ const message = exhausted || `There were problems getting ${label}.` - throw new Error(lastErrorSummary ? `${message} ${lastErrorSummary}` : message) + throw carryErrorIdentity(new Error(lastErrorSummary ? `${message} ${lastErrorSummary}` : message), lastError) }, } diff --git a/src/chain/blockchain_connector/transaction_queries.js b/src/chain/blockchain_connector/transaction_queries.js index a52ce53b..4b76fdc6 100644 --- a/src/chain/blockchain_connector/transaction_queries.js +++ b/src/chain/blockchain_connector/transaction_queries.js @@ -14,7 +14,15 @@ const config = require('../../config'); const { logger } = require('./constants.js') -const { envInt, sanitizeRpcError } = require('./rpc_helpers.js') +const { carryErrorIdentity, envInt, sanitizeRpcError } = require('./rpc_helpers.js') + +// Return a truthy result only if it is a whole hex string; throw into the retry loop otherwise. +// (Callers decode outside their tagged try, so a non-hex answer would quarantine as content.) +function wholeHexResult(result, txid) { + if (typeof result === 'string' && result.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(result)) return result + const shape = typeof result === 'string' ? `a ${result.length}-char string` : `a ${typeof result}` + throw new Error(`getRawTransaction: malformed result for txid ${txid}: expected whole hex, got ${shape}`) +} function rawTransactionResponse(response, txid) { // A JSON-RPC 2.0 node (Bitcoin Core >= v28) answers an RPC error with @@ -40,7 +48,7 @@ function rawTransactionResponse(response, txid) { // Return (not break) so // a success on the final attempt cannot fall through to the failure // guard below and inflate rpcErrors on a recovered fetch. - if (response.data.result) return response.data.result + if (response.data.result) return wholeHexResult(response.data.result, txid) // Tx no longer retrievable (mined/evicted between getRawMempool and this // call, or an empty RPC result): resolve null so a single missing tx does @@ -101,7 +109,7 @@ async function handleRawTransactionFailure(connector, error, txid, tries, maxTri logger.error(`getRawTransaction: attempt ${tries}/${maxTries} for txid ${txid} failed: HTTP ${details.httpStatus !== undefined ? details.httpStatus : 'n/a'} rpcCode ${details.rpcCode !== undefined ? details.rpcCode : 'n/a'}: ${details.lastErrorSummary}`) } await connector.sleep(details.isQueueFull ? 5000 : 500) - return { resolved: false, lastErrorSummary: details.lastErrorSummary } + return { resolved: false, lastErrorSummary: details.lastErrorSummary, lastError: error } } async function runRawTransactionRetries(connector, txid, resolve, reject) { @@ -111,6 +119,7 @@ async function runRawTransactionRetries(connector, txid, resolve, reject) { // deterministic misconfiguration (401/404/DNS) is diagnosable instead of // surfacing as a bare "failed after 10 attempts" line. let lastErrorSummary = null + let lastError = null while (tries < maxTries){ tries++ try { @@ -131,12 +140,14 @@ async function runRawTransactionRetries(connector, txid, resolve, reject) { return } lastErrorSummary = outcome.lastErrorSummary + lastError = outcome.lastError } } if (tries >= maxTries){ connector.rpcErrors++ - reject(new Error(`getRawTransaction failed after ${maxTries} attempts for txid ${txid}${lastErrorSummary ? ': ' + lastErrorSummary : ''}`)) + // Keep the last cause's code/rpcCode so getBlockReassembled can tell an unreachable node from a bad block. + reject(carryErrorIdentity(new Error(`getRawTransaction failed after ${maxTries} attempts for txid ${txid}${lastErrorSummary ? ': ' + lastErrorSummary : ''}`), lastError)) } } diff --git a/src/clear_reorg_halt.js b/src/clear_reorg_halt.js index 57eeaafd..49e1427b 100644 --- a/src/clear_reorg_halt.js +++ b/src/clear_reorg_halt.js @@ -37,8 +37,10 @@ * action, so the purge cannot have lost anything. --force overrides this one * for an operator who has compared the dispensers table against a known-good * replica; the clear row records that it was forced. + * 3. the session passes the decoder's strict sql_mode check. Cannot be forced. * - * Reads DECODER_DB_* from the service environment (.env), like migrate.js. + * Reads DECODER_DB_* from the service environment (.env) through src/config.js, + * like migrate.js. * ********************************************************************/ @@ -50,6 +52,7 @@ if (require.main === module) require('dotenv').config() const Database = require('./db.js') +const config = require('./config.js') const EXIT = { OK: 0, @@ -87,12 +90,27 @@ function parseArgs(argv){ return out } +// Render a non-count for the refusal text without throwing on any input. +function describeCount(value){ + if (typeof value === 'string') return 'the string ' + JSON.stringify(value) + if (typeof value === 'number' || typeof value === 'bigint' || value == null) return String(value) + return 'a value of type ' + typeof value +} + async function checkClearPreconditions(db, args, error){ // Check 1: the rollback has been re-synced. Not forceable: a halt with blocks // still missing above the tip is a rollback in progress, and clearing it lets // the next verifyReorg resume past the window. const deletesAboveTip = await db.countReorgDeletesAboveTip() - if (deletesAboveTip > 0){ + // Only a proven zero passes, like the dispenser check below: a count that is not + // a non-negative integer cannot prove the range was re-parsed. + if (!Number.isInteger(deletesAboveTip) || deletesAboveTip < 0){ + error('clear-reorg-halt: REFUSED. The count of rolled-back blocks above the tip could not be read as a ' + + 'block count (got ' + describeCount(deletesAboveTip) + '), so this command cannot confirm the rollback ' + + 'was re-parsed. This check cannot be forced. Nothing was cleared.') + return { exitCode: EXIT.NOT_RESYNCED } + } + if (deletesAboveTip !== 0){ error('clear-reorg-halt: REFUSED. ' + deletesAboveTip + ' block(s) rolled back above the current tip have not been re-parsed yet. ' + 'This check cannot be forced. If the decoder is parked on this halt (reorg_halt_parked: true on health, ' + '/status or /live), it parses nothing and will never re-parse this range: recover with a full resync from ' @@ -126,6 +144,21 @@ function refuseUnpinnableHalt(marker, error){ return true } +// Refuse a session whose sql_mode the decoder itself would refuse at startup. This +// CLI builds its own pool and never runs runMigrations, so without this check the row +// that decides halt state could be written on a lax session with no warning. +async function refuseLaxSqlMode(db, error){ + try { + await db.assertStrictSqlMode() + return false + } catch (err){ + error('clear-reorg-halt: REFUSED. This session\'s SQL mode failed the strict-mode check, so the ' + + 'REORG_HALT_CLEARED row would be written with no strict-mode guarantee. Nothing was cleared. ' + + ((err && err.message) || err)) + return true + } +} + // The whole decision, with the database and the output injected so it can be // exercised without MariaDB. Returns the process exit code. async function run({ db, argv = [], log = console.log, error = console.error }){ @@ -153,6 +186,8 @@ async function run({ db, argv = [], log = console.log, error = console.error }){ const preconditions = await checkClearPreconditions(db, args, error) if (preconditions.exitCode !== undefined) return preconditions.exitCode const { checks, dispenserClean, dispensers, dispenserTxs } = preconditions + // Before the dry-run branch, so a dry run predicts the same refusal a real run hits. + if (await refuseLaxSqlMode(db, error)) return EXIT.FAILED const verdict = 'checks: rolled-back blocks above tip = 0; dispensers = ' + dispensers + '; DISPENSER actions decoded = ' + dispenserTxs + (dispenserClean ? ' (clean)' : ' (FORCED by the operator)') @@ -188,11 +223,11 @@ async function run({ db, argv = [], log = console.log, error = console.error }){ } async function main(){ - const host = process.env.DECODER_DB_HOST - const port = process.env.DECODER_DB_PORT - const name = process.env.DECODER_DB_NAME - const user = process.env.DECODER_DB_USER - const pass = process.env.DECODER_DB_PASS + const host = config.DECODER_DB_HOST + const port = config.DECODER_DB_PORT + const name = config.DECODER_DB_NAME + const user = config.DECODER_DB_USER + const pass = config.DECODER_DB_PASS if (!host || !name || !user){ console.error('clear-reorg-halt: DECODER_DB_HOST / DECODER_DB_NAME / DECODER_DB_USER must be set (load the service .env).') process.exit(EXIT.USAGE) diff --git a/src/coins/BTC.js b/src/coins/BTC.js index 31f7e010..b50f3d4a 100644 --- a/src/coins/BTC.js +++ b/src/coins/BTC.js @@ -301,7 +301,7 @@ module.exports = { GAS_PRICE: '0.00001', // XCHAIN per gas unit UNIFIED_EXPIRATION_FEE_FREE_DAYS: 90, - FEE_PAYMENT_MODE: 'xchain', // informational only; not read at runtime (see detectFeePaymentMode) + FEE_PAYMENT_MODE: 'xchain', // declared mode; the indexer classifies by coin (BTC xchain) and a registry test pins the two together FEE_TOLERANCE_MIN: '0.95', FEE_TOLERANCE_MAX: '1.10', ORACLE_MAX_PRICE_AGE_SECONDS: 1800, diff --git a/src/coins/DOGE.js b/src/coins/DOGE.js index 3cd041c6..787327f3 100644 --- a/src/coins/DOGE.js +++ b/src/coins/DOGE.js @@ -218,7 +218,7 @@ module.exports = { GAS_PRICE: '0.00001', UNIFIED_EXPIRATION_FEE_FREE_DAYS: 90, - FEE_PAYMENT_MODE: 'native', // DOGE: native-only; informational, not read at runtime + FEE_PAYMENT_MODE: 'native', // DOGE: native-only; declared mode, classified by coin in the indexer and pinned by a registry test FEE_TOLERANCE_MIN: '0.95', FEE_TOLERANCE_MAX: '1.10', ORACLE_MAX_PRICE_AGE_SECONDS: 1800, diff --git a/src/coins/LTC.js b/src/coins/LTC.js index 1dcbab46..2715fc6a 100644 --- a/src/coins/LTC.js +++ b/src/coins/LTC.js @@ -212,7 +212,7 @@ module.exports = { GAS_PRICE: '0.00001', UNIFIED_EXPIRATION_FEE_FREE_DAYS: 90, - FEE_PAYMENT_MODE: 'native', // LTC: native-only; informational, not read at runtime + FEE_PAYMENT_MODE: 'native', // LTC: native-only; declared mode, classified by coin in the indexer and pinned by a registry test FEE_TOLERANCE_MIN: '0.95', FEE_TOLERANCE_MAX: '1.10', ORACLE_MAX_PRICE_AGE_SECONDS: 1800, diff --git a/src/config.js b/src/config.js index bed4b5a1..0feda82c 100644 --- a/src/config.js +++ b/src/config.js @@ -21,8 +21,7 @@ * name with different fallbacks disagree with each other silently. One home * makes the whole surface one file long. * - * WHAT LIVES HERE, AND WHAT DOES NOT. Every name a module outside the entry - * points reads lives here, as the raw string the environment holds (or + * WHAT LIVES HERE, AND WHAT DOES NOT. Every name any module reads lives here, as the raw string the environment holds (or * undefined). Coercion does NOT: a read site * that parses a number, applies a floor or derives a fallback from another * setting keeps that code where it is and only takes the raw value from here. @@ -39,10 +38,10 @@ * boot-time values and only a test that changes one mid-run would notice. * So the exported object is accessors over the block below, not a copy of it. * - * The three process entry points (api.js, migrate.js, clear_reorg_halt.js) - * read the environment directly and are exempt: they validate and report on - * their configuration before anything else is loaded, which is the one job - * that cannot go through a module that has already resolved it. + * The two process entry points (migrate.js, clear_reorg_halt.js) read their + * DECODER_DB_* settings here too. They load the service .env into the + * environment first and only then read, and since every read below is live, + * a value dotenv sets after this module was required is still the value they get. * ********************************************************************/ @@ -58,6 +57,23 @@ function currentEnvironment() { return { // codemod:env-entries + COIN: process.env.COIN, + NETWORK: process.env.NETWORK, + NODE_URL: process.env.NODE_URL, + NODE_PORT: process.env.NODE_PORT, + NODE_USER: process.env.NODE_USER, + NODE_PASSWORD: process.env.NODE_PASSWORD, + DECODER_DB_HOST: process.env.DECODER_DB_HOST, + DECODER_DB_PORT: process.env.DECODER_DB_PORT, + DECODER_DB_NAME: process.env.DECODER_DB_NAME, + DECODER_DB_USER: process.env.DECODER_DB_USER, + DECODER_DB_PASS: process.env.DECODER_DB_PASS, + DECODER_API_PORT: process.env.DECODER_API_PORT, + AUX_POW: process.env.AUX_POW, + FEE_DESTINATION: process.env.FEE_DESTINATION, + GETMEMPOOL_CACHE_MS: process.env.GETMEMPOOL_CACHE_MS, + DECODER_RATE_LIMIT_RPM: process.env.DECODER_RATE_LIMIT_RPM, + DECODER_RPC_MAX_BATCH: process.env.DECODER_RPC_MAX_BATCH, DB_QUERY_TIMEOUT: process.env.DB_QUERY_TIMEOUT, DECODER_POLL_SILENT_MS: process.env.DECODER_POLL_SILENT_MS, DECODER_RPC_CONCURRENCY: process.env.DECODER_RPC_CONCURRENCY, diff --git a/src/db.js b/src/db.js index ab932092..ac6e33f5 100644 --- a/src/db.js +++ b/src/db.js @@ -53,8 +53,9 @@ class Database { password: this.pass, database: this.dbName, port: this.port, - // mariadb 3.5.3 issues SET time_zone='+00:00' on every connection and - // encodes/decodes Dates as UTC, aligning SQL timestamps and first_seen. + // Issue SET time_zone='+00:00' on every connection. The driver still decodes + // DATETIME/TIMESTAMP text as host-local Dates, so a read that needs an instant + // converts in SQL (UNIX_TIMESTAMP) rather than trusting a returned Date. timezone: 'Z', connectionLimit: 10, insertIdAsNumber: true, diff --git a/src/db/blocks.js b/src/db/blocks.js index cf610bb5..cb0cdf7c 100644 --- a/src/db/blocks.js +++ b/src/db/blocks.js @@ -16,6 +16,7 @@ const { format: formatLogLine } = require('node:util'); const { SATOSHIS_DECIMALS, logger } = require('./constants.js') async function deleteBlockRows(connection, blockIndex){ + // Classify every src/sql table in test/unit/reorg_table_coverage.test.js (deleted here or exempted there). // Resurrect any dispenser that THIS (now-orphaned) block soft-expired: // clear the expiry mark so it is open again. Must run before the // dispenser row-delete below (a dispenser both OPENED and expired in @@ -113,6 +114,7 @@ module.exports = { let connection = await this.getConnection() try { + await this.restoreDispenserExtensions(connection, blockIndex) await deleteBlockRows(connection, blockIndex) await insertReorgEvent(connection, blockIndex, reorgBlockHash) diff --git a/src/db/database_setup.js b/src/db/database_setup.js index 3a9bd87c..d0205c34 100644 --- a/src/db/database_setup.js +++ b/src/db/database_setup.js @@ -204,12 +204,16 @@ module.exports = { // fail-closed guards a mode=manual migration owns fire even when the body early-returns // (no migrations dir, empty dir, lock contention). A throwing body is already failing // loudly, so the assertions are skipped there. + // + // Check sql_mode BEFORE the body: it reads session state, not schema, and a lax or + // NO_BACKSLASH_ESCAPES session would otherwise commit truncated backfills first. async runMigrations(opts = {}){ + await this.assertStrictSqlMode(); const result = await this.runMigrationsInner(opts); await this.assertDispenserExpirationIsBigintUnsigned(); await this.assertPubkeyColumnIsUncompressedWide(); + await this.assertTransactionIdsAreBigint(); await this.assertActionDataIsUtf8mb4(); - await this.assertStrictSqlMode(); return result; }, @@ -285,6 +289,50 @@ module.exports = { } }, + async assertTransactionIdsAreBigint(){ + const expected = ['tx_index', 'tx_hash_id', 'block_index', 'source_id', 'destination_id']; + let conn; + try { + conn = await this.getConnection(); + const rows = await conn.query( + "SELECT 1 AS tableExists, c.COLUMN_NAME AS col, c.DATA_TYPE AS dataType, c.COLUMN_TYPE AS columnType " + + "FROM information_schema.tables t " + + "LEFT JOIN information_schema.columns c " + + " ON c.table_schema = t.table_schema AND c.table_name = t.table_name " + + " AND c.column_name IN ('tx_index', 'tx_hash_id', 'block_index', 'source_id', 'destination_id') " + + "WHERE t.table_schema = ? AND t.table_name = 'transactions'", + [this.dbName] + ); + if(!rows.length || !rows.some(row => row.tableExists != null)) return; + const types = new Map(); + for(const row of rows){ + if(row.col != null) types.set(String(row.col).toLowerCase(), { + dataType: String(row.dataType || '').toLowerCase(), + columnType: String(row.columnType || '').toLowerCase() + }); + } + const invalid = expected.filter(col => { + const type = types.get(col); + return !type || type.dataType !== 'bigint' || !/\bunsigned\b/.test(type.columnType); + }); + if(invalid.length){ + const found = invalid.map(col => { + const type = types.get(col); + return col + '=' + (type ? (type.columnType || type.dataType || 'unknown') : 'missing'); + }).join(', '); + throw new Error( + 'transactions id columns must use BIGINT UNSIGNED, but found ' + found + '. Run the pending migration: ' + + 'node src/db/migrate.js --file ' + + Database.startupAssertedMigrationFile('assertTransactionIdsAreBigint') + ); + } + } finally { + if(conn && this.transactionConnection == null){ + try { await conn.release(); } catch(_){} + } + } + }, + //This is only used in tests async dropDatabase(){ logger.info("Droping database") diff --git a/src/db/dispenser_queries.js b/src/db/dispenser_queries.js index 9e6d1266..ad2232c6 100644 --- a/src/db/dispenser_queries.js +++ b/src/db/dispenser_queries.js @@ -57,9 +57,10 @@ module.exports = { // has already stamped whose expiration is no older than it, which is how the decoder keeps // capturing payments to a dispenser the indexer holds fillable through its cancellation // grace period. It widens THIS query and nothing else: the expiry mark, the extend mirror, - // the oracle-address resolution and the hard purge keep their timing, so the divergence - // stays in the over-capture direction the advisory contract above calls safe. Rationale and - // the reason the MARK must not move instead: src/protocol/dispenser_cancel_grace.js. + // the oracle-address resolution keep their timing. The hard purge changes only under its + // independent activation gate, so the divergence stays in the over-capture direction. + // Rationale and the reason the MARK must not move instead: + // src/protocol/dispenser_cancel_grace.js. // // THE FLOOR IS MEASURED AGAINST THE MARK BLOCK, NOT THE EXPIRATION. The indexer runs a // block's transactions BEFORE its expiration pass (xchain-indexer XChainIndexer.js, the @@ -70,7 +71,8 @@ module.exports = { // retention on E alone ends capture at E + grace and loses the buyer's coin in the window // between the two. The block that stamps expired_block_index is exactly the last block in // which a cancel can be accepted, so its header time plus the same grace covers every - // settleable fill by construction, with no slack constant. The join reads that header time + // settleable fill by construction, with no slack constant (on testnet only once the indexer + // gates its close delay on protocol time; see dispenser_cancel_grace.js). The join reads that header time // from this decoder's own blocks table rather than duplicating it on the dispenser row, so // the reorg clear at deleteBlockByIndex and the this-block restore in // extendOpenDispenserExpirationBySource keep the pair consistent by clearing one column. @@ -117,6 +119,57 @@ module.exports = { return addresses; }, + // Pre-image capture for a format-2 expiration extend. Called before the extend runs, on + // the block transaction, it records the expiration and expiry mark of every row the extend + // will touch (same two-key source match and mark filter), with INSERT IGNORE so a second + // extend of the same row in the block keeps the value the row held when the block began. + // Same false/true contract as the extend: false means the query failed and the block + // transaction was rolled back. + async recordDispenserExtensionUndo(sourceAddress, blockIndex) { + const query = ` + INSERT IGNORE INTO dispenser_extension_undo + (block_index, tx_index, address_id, prior_expiration, prior_expired_block_index) + SELECT ?, tx_index, address_id, expiration, expired_block_index + FROM dispensers + WHERE (address_id = (SELECT id FROM index_addresses WHERE address = ? LIMIT 1) + OR source_address_id = (SELECT id FROM index_addresses WHERE address = ? LIMIT 1)) + AND (expired_block_index IS NULL OR expired_block_index = ?); + `; + let connection = await this.getConnection() + const ownLease = (this.transactionConnection == null) + try { + await connection.query(query, [blockIndex, sourceAddress, sourceAddress, blockIndex]) + return true + } catch (err) { + logger.error(formatLogLine('Error recording dispenser extension undo:', err)); + if (this.transactionConnection){ + await this.endTransaction() + } + return false + } finally { + if (ownLease){ + await connection.release() + } + } + }, + + // Reorg half of recordDispenserExtensionUndo: put back the expiration and expiry mark the + // orphaned block's extends overwrote, then drop that block's undo rows. A prior mark equal + // to the orphaned block becomes NULL, the clear the reorg applies to every stamp of that + // height. Runs on the caller's reorg connection and transaction; throws on a query fault so + // deleteBlockByIndex rolls back as it does for its other statements. + async restoreDispenserExtensions(connection, blockIndex) { + await connection.query(` + UPDATE dispensers d + INNER JOIN dispenser_extension_undo u + ON u.tx_index = d.tx_index AND u.address_id <=> d.address_id + SET d.expiration = u.prior_expiration, + d.expired_block_index = CASE WHEN u.prior_expired_block_index = u.block_index + THEN NULL ELSE u.prior_expired_block_index END + WHERE u.block_index = ?;`, [blockIndex]) + await connection.query(`DELETE FROM dispenser_extension_undo WHERE block_index = ?;`, [blockIndex]) + }, + async deleteOpenDispensers(blockIndex, minExpiration) { // SOFT-EXPIRE, don't hard-delete. minExpiration is the block's protocol // unix timestamp; expiration is a unix BIGINT, so compare @@ -164,12 +217,24 @@ module.exports = { // Hard-delete dispensers that were soft-expired at or before a reorg-safe // depth. Run OUTSIDE the per-block transaction (a transient failure here must // never roll back committed block data. At worst soft-expired rows linger a - // little longer). Deterministic across nodes: keyed off canonical block height, - // never wall clock. Bounds dispensers table growth (the reason streamed - // dispenser replication was disabled, see xchain-sync src/schema/replicated_tables.js). - async purgeExpiredDispensers(safeHeight) { + // little longer). Deterministic across nodes: keyed off canonical block height + // and a block-derived grace floor, never wall clock. Bounds dispensers table growth + // (the reason streamed dispenser replication was disabled, see xchain-sync + // src/schema/replicated_tables.js). + async purgeExpiredDispensers(safeHeight, graceFloor) { if (safeHeight == null || safeHeight < 0) return true // nothing reorg-safe yet (initial sync) - const query = ` + const floor = graceFloor + const graceActive = (typeof floor === 'number') && Number.isFinite(floor) + const query = graceActive + ? ` + DELETE d FROM dispensers d + LEFT JOIN blocks eb ON eb.block_index = d.expired_block_index + WHERE d.expired_block_index IS NOT NULL + AND d.expired_block_index <= ? + AND d.expiration < ? + AND (eb.block_time IS NULL OR eb.block_time < ?); + ` + : ` DELETE FROM dispensers WHERE expired_block_index IS NOT NULL AND expired_block_index <= ?; @@ -177,7 +242,11 @@ module.exports = { let connection = await this.getConnection() const ownLease = (this.transactionConnection == null) try { - await connection.query(query, [safeHeight]) + await connection.query(query, graceActive + ? [safeHeight, floor, floor] + : [safeHeight]) + // Undo rows are only useful inside the window a reorg can reach, the same bound. + await connection.query(`DELETE FROM dispenser_extension_undo WHERE block_index <= ?;`, [safeHeight]) return true } catch (err) { logger.error(formatLogLine('Error purging expired dispensers:', err)); diff --git a/src/db/mempool.js b/src/db/mempool.js index a56fd08f..2c028d66 100644 --- a/src/db/mempool.js +++ b/src/db/mempool.js @@ -15,6 +15,14 @@ const { format: formatLogLine } = require('node:util'); const { logger } = require('./constants.js') +// Convert first_seen to plain unix seconds, null when absent. The SQL reads it with +// UNIX_TIMESTAMP on the +00:00 session, because the driver decodes a DATETIME as +// host-local time; the BIGINT it returns arrives as a BigInt, which JSON cannot carry. +function firstSeenAsSeconds(row){ + const n = (row.first_seen == null) ? NaN : Number(row.first_seen) + return Object.assign({}, row, { first_seen: Number.isFinite(n) ? n : null }) +} + module.exports = { async insertMempoolTransaction(tx) { const query = ` @@ -83,7 +91,8 @@ module.exports = { // ACTION-CARRYING ROWS ONLY. This table holds a row for EVERY mempool tx the // decoder observed, not just XChain ones: buildStoredActionRecord blanks // `data` to '' (never NULL) for a money-bearing tx whose ACTION was invalid - // or unknown, which on a public chain is nearly all of them (measured on BTC + // or unknown (and the mempool path blanks an ACTION whose source could not + // be resolved), which on a public chain is nearly all of them (measured on BTC // testnet 2026-08-22: 32 of 32 rows). An unfiltered window is useless to the // consumer, because on a busy chain all 500 slots fill with actionless rows // and the feed renders empty while real pending actions sit deeper in the @@ -92,7 +101,7 @@ module.exports = { async getMempoolTransactions(limit) { const max = Math.max(1, Math.min(Number(limit) || 200, 500)) const query = ` - SELECT tx_hash, source, data, first_seen + SELECT tx_hash, source, data, UNIX_TIMESTAMP(first_seen) AS first_seen FROM mempool_transactions WHERE data IS NOT NULL AND data != '' ORDER BY tx_hash @@ -102,7 +111,7 @@ module.exports = { const ownLease = (this.transactionConnection == null) try { const rows = await connection.query(query) - return rows || [] + return (rows || []).map(firstSeenAsSeconds) } finally { if (ownLease) { await connection.release() diff --git a/src/db/migrate.js b/src/db/migrate.js index 812d50e2..49330086 100644 --- a/src/db/migrate.js +++ b/src/db/migrate.js @@ -47,7 +47,7 @@ * ignored token (a typo, `--dry-run`, `--help`) would silently apply every * pending manual migration the operator was only asking about. * - * Reads DECODER_DB_* from the service environment (.env). + * Reads DECODER_DB_* from the service environment (.env), through src/config.js. * ********************************************************************/ @@ -56,6 +56,7 @@ const dotenv = require('dotenv'); dotenv.config(); const Database = require('../db.js'); +const config = require('../config.js'); // Spelled out for an operator reading it mid-incident: the difference between a // blanket run and a scoped one is the whole risk of this command, so each mode @@ -195,11 +196,11 @@ async function main(){ if(parsed === null) return; const only = parsed.only; - const host = process.env.DECODER_DB_HOST; - const port = process.env.DECODER_DB_PORT; - const name = process.env.DECODER_DB_NAME; - const user = process.env.DECODER_DB_USER; - const pass = process.env.DECODER_DB_PASS; + const host = config.DECODER_DB_HOST; + const port = config.DECODER_DB_PORT; + const name = config.DECODER_DB_NAME; + const user = config.DECODER_DB_USER; + const pass = config.DECODER_DB_PASS; if(!host || !name || !user){ console.error('migrate: DECODER_DB_HOST / DECODER_DB_NAME / DECODER_DB_USER must be set (load the service .env).'); process.exit(2); diff --git a/src/db/migration_checksum_rebaselines.js b/src/db/migration_checksum_rebaselines.js index 1ca8374d..848bc266 100644 --- a/src/db/migration_checksum_rebaselines.js +++ b/src/db/migration_checksum_rebaselines.js @@ -86,16 +86,18 @@ Database.MIGRATION_CHECKSUM_REBASELINES = { from: '027a643d3ff0be087b38889f947fdde2b4d8c696682c3b3642f288553f419068', to: '0b3b2fefb780da1fb96a0d5518967b67b215676cc1ac02efc08ec1672d9091b2', // deploy-precondition tag (HEAD) }, - // Comment-only edit: the header prose was tidied and a stale operator note - // dropped. The executable statements are unchanged since a0f826b, which is - // the earliest revision that can be blessed here: 6869813 and older carry a - // different statement residue and must still fail the immutability check. + // Comment-only edits: the header prose was tidied and a stale operator note + // dropped, then the `deploy-precondition=required` tag was added for the + // startup assertion. The executable statements are unchanged since a0f826b, + // which is the earliest revision that can be blessed here: 6869813 and older + // carry a different statement residue and must still fail the immutability check. '2026-06-02-widen-ids-to-bigint.sql': { from: [ 'e508ea3bcc4ea4f8f6fd241d93c678245a0ddcb9e582094fe4ddbb636b66d6d7', // a0f826b '82865499dd2ccc48c0a0a016535409a9201b415395f49c70b41c73a3aeda8847', // ec36bd4 (license header) + 'b03b41b6fcabef9c959851ede9b75cc9089cef7c015bdd69cfcea74ad5acea7a', // comment tidy ], - to: 'b03b41b6fcabef9c959851ede9b75cc9089cef7c015bdd69cfcea74ad5acea7a', // comment tidy (HEAD) + to: '33423de08d0869d7a81829301668383ee3cb47e7226bff587fcfcbf9fa0c7b1c', // deploy-precondition tag (HEAD) }, // TWO revisions are pinned here and they are blessed for DIFFERENT reasons, so both are // stated rather than filed together under the blanket sentence above. diff --git a/src/db/migration_ledger.js b/src/db/migration_ledger.js new file mode 100644 index 00000000..0b78f4e0 --- /dev/null +++ b/src/db/migration_ledger.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. + * + **********************************************************************/ + +// Convert a legacy TIMESTAMP applied_at column on the migration ledger to DATETIME, in place. +async function ensureLedgerAppliedAtDatetime(conn){ + const rows = await conn.query( + 'SELECT DATA_TYPE AS dataType FROM information_schema.COLUMNS ' + + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'schema_migrations' " + + "AND COLUMN_NAME = 'applied_at'" + ); + if(String(rows[0]?.dataType || '').toLowerCase() !== 'timestamp') return; + await conn.query( + 'ALTER TABLE schema_migrations MODIFY applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP' + ); +} + +module.exports = { + ensureLedgerAppliedAtDatetime, +} diff --git a/src/db/migration_live_schema_guard.js b/src/db/migration_live_schema_guard.js new file mode 100644 index 00000000..3d7159e0 --- /dev/null +++ b/src/db/migration_live_schema_guard.js @@ -0,0 +1,280 @@ +/********************************************************************* + * + * 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 Decoder - migration live-schema guard + * + * A MODIFY restates the whole column definition, so a restatement that omits an attribute + * the live column carries silently strips it, and one that shrinks the type truncates or + * rejects stored rows. The destructive-DDL scan cannot see either: it reads statement text, + * not the column the statement lands on. This guard reads the live column from + * information_schema for every MODIFY in an auto file and names each loss. A type change + * it has no widening rule for counts as a loss, and a text MODIFY that names no charset + * or collation is compared against the table default the server would fall back to. + * + ********************************************************************/ + +'use strict'; + +const { opensBackslashEscape } = require('./query_helpers.js'); + +const INT_RANK = { tinyint: 1, smallint: 2, mediumint: 3, int: 4, integer: 4, bigint: 5 }; +const TEXT_RANK = { tinytext: 1, text: 2, mediumtext: 3, longtext: 4 }; +const BLOB_RANK = { tinyblob: 1, blob: 2, mediumblob: 3, longblob: 4 }; +const CHARSET_RANK = { ascii: 1, latin1: 1, utf8: 3, utf8mb3: 3, utf8mb4: 4 }; +const SIZED = new Set(['varchar', 'char', 'varbinary', 'binary']); +// Decimal digits each integer type needs to hold its whole range, signed then unsigned. +const INT_DIGITS = { tinyint: [3, 3], smallint: [5, 5], mediumint: [7, 8], int: [10, 10], integer: [10, 10], bigint: [19, 20] }; +// Spellings the server stores under another name (JSON is reported as longtext). +const TYPE_ALIAS = { integer: 'int', numeric: 'decimal', dec: 'decimal', fixed: 'decimal', real: 'double', + bool: 'tinyint', boolean: 'tinyint', json: 'longtext' }; +const FSP_TYPES = new Set(['datetime', 'timestamp', 'time']); +const TEXTUAL = new Set(['char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'enum', 'set']); + +// Blank every quoted literal so a keyword inside a DEFAULT or COMMENT string never counts. +function blankLiterals(text){ + return text.replace(/'(?:[^'\\]|\\.|'')*'|"(?:[^"\\]|\\.|"")*"|`[^`]*`/g, m => m[0] + m[0]); +} + +// Split on commas outside parentheses and quotes. The quote model is splitSqlStatements' +// (doubled quotes, no backslash escape inside backticks), so both read the same spans. +function splitTopLevel(text){ + const out = []; + let depth = 0, quote = null, cur = ''; + for(let i = 0; i < text.length; i++){ + const ch = text[i]; + if(quote){ + cur += ch; + if(opensBackslashEscape(text, i, quote)){ cur += text[++i]; } + else if(ch === quote){ + if(text[i + 1] === quote){ cur += text[++i]; } + else { quote = null; } + } + continue; + } + if(ch === "'" || ch === '"' || ch === '`'){ quote = ch; cur += ch; continue; } + if(ch === '(') depth++; + else if(ch === ')') depth--; + if(ch === ',' && depth === 0){ out.push(cur); cur = ''; continue; } + cur += ch; + } + if(cur.trim()) out.push(cur); + return out; +} + +const unquote = (id) => id.replace(/^`|`$/g, ''); + +// Replace each block comment outside a quoted literal with a space, so a comment can neither +// hide a clause nor stand in for a restated attribute. The quote model is splitSqlStatements' +// (a regex strip would delete real SQL between a '/*' literal and a later '*/' literal). +// Executable `/*!` and `/*M!` comments stay: the server runs them, and the destructive-DDL +// scan already refuses them. An unterminated `/*` is kept as written, as the splitter does. +function stripBlockComments(text){ + text = String(text); + let out = '', quote = null; + for(let i = 0; i < text.length; i++){ + const ch = text[i]; + if(quote){ + out += ch; + if(opensBackslashEscape(text, i, quote)){ out += text[++i]; } + else if(ch === quote){ + if(text[i + 1] === quote){ out += text[++i]; } + else { quote = null; } + } + continue; + } + if(ch === "'" || ch === '"' || ch === '`'){ quote = ch; out += ch; continue; } + if(ch === '/' && text[i + 1] === '*' && !/^(?:!|M!)/i.test(text.slice(i + 2, i + 4))){ + const end = text.indexOf('*/', i + 2); + if(end === -1){ out += text.slice(i); break; } + out += ' '; + i = end + 1; + continue; + } + out += ch; + } + return out; +} + +// Every `ALTER TABLE t ... MODIFY [COLUMN] c ` clause in a statement list. +function modifyClauses(statements){ + const found = []; + for(const stmt of statements){ + const head = /^ALTER\s+(?:ONLINE\s+)?(?:IGNORE\s+)?TABLE\s+(?:IF\s+EXISTS\s+)?((?:`[^`]+`|\w+)(?:\s*\.\s*(?:`[^`]+`|\w+))?)\s+([\s\S]*)$/i.exec(stripBlockComments(stmt).trim()); + if(!head) continue; + const table = unquote(head[1].split('.').pop().trim()); + for(const clause of splitTopLevel(head[2])){ + const m = /^\s*MODIFY\s+(?:COLUMN\s+)?(?:IF\s+EXISTS\s+)?(`[^`]+`|\w+)\s+([\s\S]+)$/i.exec(clause); + if(!m) continue; + const definition = m[2].replace(/\s+(?:FIRST|AFTER\s+(?:`[^`]+`|\w+))\s*$/i, '').trim(); + found.push({ table, column: unquote(m[1]), definition }); + } + } + return found; +} + +// The data type, its arguments and unsignedness as the definition states them. +function parseType(definition){ + const m = /^\s*(\w+)\s*(?:\(([^)]*)\))?\s*(unsigned)?/i.exec(definition); + if(!m) return null; + return { name: m[1].toLowerCase(), args: m[2] == null ? null : m[2].trim(), unsigned: !!m[3] }; +} + +// The same shape from the live COLUMN_TYPE (`bigint(20) unsigned`, `enum('a','b')`). +function parseLiveType(columnType){ + return parseType(String(columnType || '')); +} + +const enumValues = (args) => (args == null ? [] : splitTopLevel(args).map(s => s.trim().replace(/^'|'$/g, ''))); + +function declaredCharset(definition){ + const cs = /\b(?:CHARACTER\s+SET|CHARSET)\s+(\w+)/i.exec(definition); + if(cs) return cs[1].toLowerCase(); + const co = /\bCOLLATE\s+(\w+)/i.exec(definition); + return co ? co[1].toLowerCase().split('_')[0] : null; +} + +// The type under the name the server stores it as. +const canonicalType = (t) => Object.assign({}, t, { name: TYPE_ALIAS[t.name] || t.name }); + +// DECIMAL(p,s) with the server's defaults filled in: bare DECIMAL is (10,0), DECIMAL(p) is (p,0). +function decimalArgs(args){ + if(args == null) return [10, 0]; + const [p, s = 0] = String(args).split(',').map(Number); + return [p, s]; +} + +// Fractional-second digits of a datetime, timestamp or time type; absent means 0. +const fsp = (args) => (args == null ? 0 : Number(args)); + +const shownType = (t) => t.name + (t.args == null ? '' : '(' + t.args + ')') + (t.unsigned ? ' unsigned' : ''); + +// A change of type name the server applies without losing a stored value. Everything else +// counts as a loss, so a type this table does not know fails closed instead of passing. +function widensAcross(live, next){ + if(live.name in INT_DIGITS && next.name === 'decimal'){ + const [p, s] = decimalArgs(next.args); + return p - s >= INT_DIGITS[live.name][live.unsigned ? 1 : 0] && !(next.unsigned && !live.unsigned); + } + if(SIZED.has(live.name)) return TEXT_RANK[next.name] > 1 || BLOB_RANK[next.name] > 1; + if(live.name === 'timestamp' && next.name === 'datetime') return fsp(next.args) >= fsp(live.args); + if(live.name === 'date' && next.name === 'datetime') return true; + return live.name === 'float' && next.name === 'double' && next.args == null && !next.unsigned; +} + +// The loss when a type keeps its name but its precision, fractional seconds or width shrink. +function narrowedPrecision(live, next){ + if(live.name === 'decimal'){ + const [lp, ls] = decimalArgs(live.args), [np, ns] = decimalArgs(next.args); + const lost = np - ns < lp - ls || ns < ls || (next.unsigned && !live.unsigned); + return lost ? 'decimal(' + lp + ',' + ls + ') -> decimal(' + np + ',' + ns + ')' + (next.unsigned ? ' unsigned' : '') : null; + } + if(FSP_TYPES.has(live.name)) return fsp(next.args) < fsp(live.args) ? shownType(live) + ' -> ' + shownType(next) : null; + if(live.name === 'bit') return Number(next.args || 1) < Number(live.args || 1) ? shownType(live) + ' -> ' + shownType(next) : null; + if(live.name === 'float' || live.name === 'double'){ + const sign = next.unsigned && !live.unsigned; + if(next.args == null) return sign ? shownType(live) + ' -> ' + shownType(next) : null; + if(live.args == null) return shownType(live) + ' -> ' + shownType(next); + const [lm, ld = 0] = String(live.args).split(',').map(Number), [nm, nd = 0] = String(next.args).split(',').map(Number); + return sign || nm - nd < lm - ld || nd < ld ? shownType(live) + ' -> ' + shownType(next) : null; + } + return null; +} + +function narrowedType(rawLive, rawNext){ + if(!rawLive || !rawNext) return null; + const live = canonicalType(rawLive), next = canonicalType(rawNext); + if(live.name in INT_RANK && next.name in INT_RANK){ + const l = INT_RANK[live.name], n = INT_RANK[next.name]; + if(n < l) return live.name + ' -> ' + next.name; + if(n === l && live.unsigned !== next.unsigned) return live.name + (live.unsigned ? ' unsigned' : '') + ' -> ' + next.name + (next.unsigned ? ' unsigned' : ''); + return null; + } + if(live.name in TEXT_RANK && next.name in TEXT_RANK) return TEXT_RANK[next.name] < TEXT_RANK[live.name] ? live.name + ' -> ' + next.name : null; + if(live.name in BLOB_RANK && next.name in BLOB_RANK) return BLOB_RANK[next.name] < BLOB_RANK[live.name] ? live.name + ' -> ' + next.name : null; + if(SIZED.has(live.name) && next.name === live.name){ + const l = Number(live.args), n = Number(next.args); + return Number.isFinite(l) && Number.isFinite(n) && n < l ? live.name + '(' + l + ') -> ' + next.name + '(' + n + ')' : null; + } + if((live.name === 'enum' || live.name === 'set') && next.name === live.name){ + const kept = new Set(enumValues(next.args)); + const lost = enumValues(live.args).filter(v => !kept.has(v)); + return lost.length ? live.name + ' drops ' + lost.join(',') : null; + } + if(live.name === next.name) return narrowedPrecision(live, next); + if(widensAcross(live, next)) return null; + const familiar = live.name in INT_RANK || live.name in TEXT_RANK || live.name in BLOB_RANK || SIZED.has(live.name); + return familiar ? live.name + ' -> ' + next.name : shownType(live) + ' -> ' + shownType(next); +} + +// A collation name as the server compares it: MariaDB 10.6+ reports utf8 as utf8mb3. +const collationKey = (name) => (name == null || name === '' ? null : String(name).toLowerCase().replace(/^utf8_/, 'utf8mb3_')); + +// Every loss one MODIFY would cause against the live column row; empty when it is safe. +function losses(live, definition){ + const out = []; + const bare = blankLiterals(definition); + const extra = String(live.EXTRA || '').toLowerCase(); + + if(/\bauto_increment\b/.test(extra) && !/\bAUTO_INCREMENT\b/i.test(bare)) out.push('strips AUTO_INCREMENT'); + const generated = String(live.GENERATION_EXPRESSION || '') !== '' || /\b(virtual|stored|persistent) generated\b/.test(extra); + const liveDefault = live.COLUMN_DEFAULT; + if(!generated && liveDefault != null && String(liveDefault).toUpperCase() !== 'NULL' && !/\bDEFAULT\b/i.test(bare)) out.push('strips DEFAULT ' + liveDefault); + if(/\bon update\b/.test(extra) && !/\bON\s+UPDATE\b/i.test(bare)) out.push('strips ON UPDATE'); + if(String(live.COLUMN_COMMENT || '') !== '' && !/\bCOMMENT\b/i.test(bare)) out.push('strips COMMENT'); + if(generated && !/\bGENERATED\b|\bAS\s*\(/i.test(bare)) out.push('strips the generation expression'); + + const narrowed = narrowedType(parseLiveType(live.COLUMN_TYPE), parseType(definition)); + if(narrowed) out.push('narrows the type (' + narrowed + ')'); + + const liveSet = live.CHARACTER_SET_NAME ? String(live.CHARACTER_SET_NAME).toLowerCase() : null; + const nextSet = declaredCharset(bare); + if(liveSet && nextSet && nextSet !== liveSet){ + const l = CHARSET_RANK[liveSet], n = CHARSET_RANK[nextSet]; + if(l == null || n == null || n < l) out.push('changes the charset (' + liveSet + ' -> ' + nextSet + ')'); + } + // A text MODIFY naming neither CHARACTER SET nor COLLATE takes the table's default collation, + // so a column widened apart from its table (utf8mb4 in a utf8mb3 table) silently reverts. + const nextType = parseType(definition); + const liveCollation = collationKey(live.COLLATION_NAME), tableCollation = collationKey(live.TABLE_COLLATION); + if(liveSet && !nextSet && nextType && TEXTUAL.has(nextType.name) && liveCollation && tableCollation && + liveCollation !== tableCollation){ + const what = liveCollation.split('_')[0] === tableCollation.split('_')[0] ? 'omits COLLATE' : 'omits CHARACTER SET'; + out.push(what + ' (' + liveCollation + ' -> table default ' + tableCollation + ')'); + } + return out; +} + +// Throw when an auto file's MODIFY would strip or narrow a live column. An absent live +// column (a fresh install, or one the file itself adds) is not drift and passes through. +async function assertNoLiveColumnLoss(conn, file, statements){ + for(const { table, column, definition } of modifyClauses(statements)){ + const rows = await conn.query( + 'SELECT COLUMN_TYPE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT, GENERATION_EXPRESSION, CHARACTER_SET_NAME, ' + + 'COLLATION_NAME, TABLE_COLLATION FROM information_schema.columns c JOIN information_schema.tables t ' + + 'ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME ' + + 'WHERE c.TABLE_SCHEMA = DATABASE() AND t.TABLE_SCHEMA = DATABASE() AND c.TABLE_NAME = ? AND c.COLUMN_NAME = ?', + [table, column] + ); + const live = Array.isArray(rows) ? rows.find(r => r && r.COLUMN_TYPE != null) : null; + if(!live) continue; + const found = losses(live, definition); + if(found.length){ + throw new Error('runMigrations: ' + file + ' is tagged mode=auto but its MODIFY of ' + table + '.' + column + + ' would damage the live column: ' + found.join('; ') + '. Restate every live attribute in the MODIFY, ' + + 'or re-tag the file `-- xchain:migration mode=manual` and apply it deliberately via `node src/db/migrate.js`.'); + } + } +} + +module.exports = { assertNoLiveColumnLoss, modifyClauses, losses, stripBlockComments }; diff --git a/src/db/migration_preconditions.js b/src/db/migration_preconditions.js index 112fd7a5..a286f72c 100644 --- a/src/db/migration_preconditions.js +++ b/src/db/migration_preconditions.js @@ -201,15 +201,18 @@ Database.backdatedFrontierViolation = function(pendingName, appliedNames){ // startup that sees it, so it can never be the missing precondition. Database.DEPLOY_PRECONDITION_TAG = 'deploy-precondition=required'; +const BIGINT_ID_MIGRATION = '2026-06-02-widen-ids-to-bigint.sql'; + // Migrations this tree ASSERTS at startup: the service refuses to run when the // target database has not applied them. // // WHY THIS LIST EXISTS // -------------------- // A v0.10.0 fleet deploy put five of nine decoders into Restarting(1) crash-loops. -// The three startup assertions above (assertDispenserExpirationIsBigintUnsigned, -// assertPubkeyColumnIsUncompressedWide, assertActionDataIsUtf8mb4) each require a -// mode=manual migration, and none of the three migration files carried a header the +// The four startup assertions above (assertTransactionIdsAreBigint, +// assertDispenserExpirationIsBigintUnsigned, assertPubkeyColumnIsUncompressedWide, +// assertActionDataIsUtf8mb4) each require a +// mode=manual migration, and none of the four migration files carried a header the // deploy tool could read, so nothing checked the precondition at deploy time and the // crash-loop itself was the only thing that surfaced the requirement. // @@ -225,6 +228,11 @@ Database.DEPLOY_PRECONDITION_TAG = 'deploy-precondition=required'; // assertion with no migration behind it (assertStrictSqlMode) is not registered: the // pool pins what it checks, so it holds by construction and cannot crash-loop a deploy. Database.STARTUP_ASSERTED_MIGRATIONS = [ + { + file: BIGINT_ID_MIGRATION, + assertion: 'assertTransactionIdsAreBigint', + symptom: 'Fatal decoder error: transactions id columns must use BIGINT UNSIGNED' + }, { file: '2026-06-13-dispensers-expiration-bigint.sql', assertion: 'assertDispenserExpirationIsBigintUnsigned', diff --git a/src/db/migration_query_timeout.js b/src/db/migration_query_timeout.js index e3cca0b8..6e059300 100644 --- a/src/db/migration_query_timeout.js +++ b/src/db/migration_query_timeout.js @@ -66,8 +66,18 @@ function migrationSession(conn, lockName, runtimeTimeoutMs){ } } +// A migration file may SET any session variable (sql_mode, foreign_key_checks, ...), and the +// pool re-applies its pinned initSql only to a NEW connection, never on release. So a +// connection that ran file statements is closed rather than handed back to the pool. +async function retireMigrationConnection(conn, sessionDirty){ + if(sessionDirty && typeof conn.destroy === 'function'){ + try { await conn.destroy() } catch(_){} + } else try { await conn.release() } catch(_){} +} + module.exports = { DEFAULT_MIGRATE_QUERY_TIMEOUT_MS, migrationQueryTimeoutMs, migrationSession, + retireMigrationConnection, } diff --git a/src/db/migration_statements.js b/src/db/migration_statements.js index c5c42d9c..5ca5cd2d 100644 --- a/src/db/migration_statements.js +++ b/src/db/migration_statements.js @@ -12,7 +12,9 @@ * **********************************************************************/ -const { opensBackslashEscape } = require('./query_helpers.js') +const { opensBackslashEscape, createNamesNonInnoDb, dropTargetDestroys } = require('./query_helpers.js') +const { ensureLedgerAppliedAtDatetime } = require('./migration_ledger.js') +const { stripBlockComments } = require('./migration_live_schema_guard.js') // True when a `#` sits outside every quoted span - a line comment // stripSqlLineComments should already have removed. Quote-aware so a `#` @@ -37,6 +39,14 @@ function hasUnquotedHash(s){ return false; } +// True for the only session SETs an auto file may carry: SET NAMES and a single UTC time_zone, +// the zone the pool is configured for (timezone 'Z'), so neither can relax a strict-mode check. +// Kept in step with the xchain-indexer classifier. +function autoEligibleSet(stmt){ + if(/^SET\s+NAMES\s+['"]?\w+['"]?(?:\s+COLLATE\s+['"]?\w+['"]?)?$/i.test(stmt)) return true; + return /^SET\s+(?:SESSION\s+|LOCAL\s+|@@SESSION\.|@@LOCAL\.|@@)?time_zone\s*=\s*'\+00:00'$/i.test(stmt); +} + function isSimpleDestructiveStatement(stmt){ // Server-side indirection escapes a statement-prefix classifier: a mode=auto // file can smuggle destructive SQL past every keyword check below via dynamic @@ -44,12 +54,17 @@ function isSimpleDestructiveStatement(stmt){ // or a `CALL proc()` whose body the scanner cannot see. None of these are used // by any committed auto migration, so treat them as non-auto-eligible. SET of a // user variable (`SET @s = ...`) exists to stage dynamic SQL for PREPARE, so - // flag it too - but NOT system-variable SETs (`SET NAMES ...`, `SET sql_mode - // = ...`, `SET @@session...`), which are benign and stay auto-eligible. + // flag it too. if(/^PREPARE\b/i.test(stmt)) return true; if(/^EXECUTE\b/i.test(stmt)) return true; if(/^CALL\b/i.test(stmt)) return true; if(/^SET\s+@(?!@)/i.test(stmt)) return true; + // Any other SET is an allow-list, not a deny-list: only SET NAMES and a single + // UTC time_zone stay auto-eligible. A SET sql_mode (or foreign_key_checks, ...) turns + // off the strict-mode backstop, so a later narrowing MODIFY in the same file truncates + // silently instead of failing; and SET STATEMENT v=x FOR hides a statement + // behind the SET keyword from every check below. + if(/^SET\b/i.test(stmt) && !autoEligibleSet(stmt)) return true; if(/^DROP\s+(TABLE|DATABASE|SCHEMA)\b/i.test(stmt)) return true; // CREATE OR REPLACE TABLE is an atomic DROP TABLE IF EXISTS + CREATE: it destroys // every existing row. Plain CREATE TABLE / CREATE TABLE IF NOT EXISTS are additive @@ -81,13 +96,13 @@ function isSimpleDestructiveStatement(stmt){ return /^LOAD\s+DATA\b/i.test(stmt); } -// True for any CREATE other than a table or an index. A trigger, event, routine or view -// carries SQL this classifier never reads, and the server runs it later (a trigger on every -// row write), the same blind spot as CALL above. +// True for any CREATE other than an InnoDB table or an index. A trigger, event, routine or +// view carries SQL this classifier never reads, and the server runs it later (a trigger on +// every row write), the same blind spot as CALL above. function createOutsideAllowList(stmt){ if(!/^CREATE\b/i.test(stmt)) return false; // Allow the additive forms committed auto migrations use: [TEMPORARY] TABLE and INDEX. - if(/^CREATE\s+(?:TEMPORARY\s+)?TABLE\b/i.test(stmt)) return false; + if(/^CREATE\s+(?:TEMPORARY\s+)?TABLE\b/i.test(stmt)) return createNamesNonInnoDb(stmt); return !/^CREATE\s+(?:(?:UNIQUE|FULLTEXT|SPATIAL)\s+)?INDEX\b/i.test(stmt); } @@ -101,18 +116,19 @@ function isDestructiveAlter(stmt, safeAlterDrop){ // so the whole class is non-auto-eligible - re-tag mode=manual to run one. if(/\bPARTITION(?:ING)?\b/i.test(stmt)) return true; if(/\bTABLESPACE\b/i.test(stmt)) return true; + // Any ENGINE option rebuilds the whole table by copy, and a non-InnoDB engine loses rows on + // restart (MEMORY) or escapes the reorg rollback (MyISAM, Aria), so it runs manual only. + if(/\bENGINE\b/i.test(stmt)) return true; // Every DROP inside the ALTER must target a safe (metadata-only) object. - let m; - const dropRe = /\bDROP\s+([A-Za-z_]+|`[^`]+`)/gi; - while((m = dropRe.exec(stmt)) !== null){ - const target = m[1].replace(/`/g, '').toUpperCase(); - if(!safeAlterDrop.has(target)) return true; - } + if(dropTargetDestroys(stmt, safeAlterDrop)) return true; // RENAME TO / RENAME COLUMN / bare RENAME lose the old name; only // RENAME INDEX/KEY is a metadata-only rename. if(/\bRENAME\b(?!\s+(INDEX|KEY)\b)/i.test(stmt)) return true; // CHANGE [COLUMN] renames and retypes in one clause - manual only. if(/\bCHANGE\b/i.test(stmt)) return true; + // CONVERT TO CHARACTER SET rewrites every text column of the table and can truncate or + // re-encode row data. + if(/\bCONVERT\s+TO\b/i.test(stmt)) return true; // MODIFY that adds NOT NULL narrows the column domain - except an // AUTO_INCREMENT attribute repair: an AUTO_INCREMENT column is // definitionally NOT NULL, so no domain is narrowed (see the @@ -133,22 +149,14 @@ function isDestructiveAlter(stmt, safeAlterDrop){ for(const clause of mClauses){ if(/\bMODIFY\b[\s\S]*\bNOT\s+NULL\b/i.test(clause) && !/\bAUTO_INCREMENT\b/i.test(clause)) return true; + // A MODIFY restates the whole column, so restating id without AUTO_INCREMENT strips + // the attribute and every later insert loses its generated key. + if(/^\s*(?:ALTER\s+(?:ONLINE\s+)?TABLE\s+(?:`[^`]+`|[A-Za-z0-9_$.]+)\s+)?MODIFY\s+(?:COLUMN\s+)?(?:IF\s+EXISTS\s+)?`?id`?(?=[\s,]|$)/i.test(clause) && + !/\bAUTO_INCREMENT\b/i.test(clause)) return true; } return false; } -async function ensureLedgerAppliedAtDatetime(conn){ - const rows = await conn.query( - 'SELECT DATA_TYPE AS dataType FROM information_schema.COLUMNS ' + - "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'schema_migrations' " + - "AND COLUMN_NAME = 'applied_at'" - ); - if(String(rows[0]?.dataType || '').toLowerCase() !== 'timestamp') return; - await conn.query( - 'ALTER TABLE schema_migrations MODIFY applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP' - ); -} - module.exports = { // Read a migration file's `-- xchain:migration mode=auto|manual` header tag. // Defaults to 'manual' when absent (conservative: unknown DDL never auto-runs). @@ -185,20 +193,22 @@ module.exports = { // committed AUTO_INCREMENT id=0 repair), // ALTER TABLE ... DROP , // ALTER TABLE ... RENAME (except RENAME INDEX/KEY), ALTER TABLE ... CHANGE - // (rename+retype), MODIFY ... NOT NULL (the statically detectable - // narrowing; a width reduction cannot be seen without the live schema and - // stays covered by the manual-tag convention), any ALTER TABLE PARTITION or - // TABLESPACE clause, ALTER IGNORE TABLE (deletes duplicate-key rows), any other - // ALTER than ALTER [ONLINE] TABLE, and any CREATE other than [TEMPORARY] TABLE and + // (rename+retype), CONVERT TO (rewrites every text column), MODIFY ... NOT NULL + // (the statically detectable narrowing; the runner's live-schema guard catches a + // width cut or a dropped attribute), MODIFY id without AUTO_INCREMENT (strips the + // generated key), any ALTER TABLE PARTITION, TABLESPACE or ENGINE clause, ALTER IGNORE + // TABLE (deletes duplicate-key rows), any other ALTER than ALTER [ONLINE] TABLE, a CREATE + // TABLE naming an engine other than InnoDB, and any CREATE other than [TEMPORARY] TABLE and // [UNIQUE|FULLTEXT|SPATIAL] INDEX (triggers, events, routines and views run SQL - // the scanner cannot read). + // the scanner cannot read), and any SET other than SET NAMES or a single UTC + // time_zone (session variables relax strict mode; SET STATEMENT ... FOR hides a statement). // // Deliberately NOT flagged (legitimate existing auto patterns): DROP INDEX/KEY, - // DROP FOREIGN KEY/CONSTRAINT/CHECK/DEFAULT/PRIMARY KEY (structural, no row - // data lost), ADD ..., plain CREATE TABLE / CREATE TABLE IF NOT EXISTS (additive; - // but CREATE OR REPLACE TABLE IS flagged - it is an atomic DROP+CREATE), CREATE - // [UNIQUE] INDEX, MODIFY that widens/nullables a column, and ALTER ONLINE TABLE - // under the same clause rules as plain ALTER TABLE. + // DROP FOREIGN KEY/CONSTRAINT/CHECK/DEFAULT/PRIMARY KEY with the keyword bare and + // unquoted (structural, no row data lost), ADD ..., plain InnoDB or engine-less CREATE + // TABLE / CREATE TABLE IF NOT EXISTS (additive; but CREATE OR REPLACE TABLE IS flagged - + // it is an atomic DROP+CREATE), CREATE [UNIQUE] INDEX, MODIFY that widens/nullables a + // column, and ALTER ONLINE TABLE under the same clause rules as plain ALTER TABLE. destructiveAutoStatement(statements){ // Drops that remove metadata only; anything else after DROP inside an // ALTER (COLUMN, PARTITION, or a bare column identifier) loses data. @@ -213,9 +223,9 @@ module.exports = { // something a prefix classifier cannot see - and no committed auto migration // uses one, so treat any statement carrying one as non-auto-eligible. if(/\/\*(?:!|M!)/i.test(String(raw))) return raw; - // Belt-and-braces: strip /* */ block comments (line comments are already - // gone) so a keyword inside comment prose never triggers or hides a hit. - const stmt = String(raw).replace(/\/\*[\s\S]*?\*\//g, ' ').trim(); + // Strip /* */ comments (line comments are gone) so comment prose never triggers or hides + // a hit; quote-aware, so a '/*' literal and a later '*/' literal hide no real clause. + const stmt = stripBlockComments(raw).trim(); if(!stmt) continue; // Second layer behind stripSqlLineComments: MariaDB/MySQL honour `#` to // end-of-line as a comment, so `# note\nDROP TABLE balances` is a DROP every diff --git a/src/db/migrations.js b/src/db/migrations.js index c1aec364..f98a22b7 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -17,7 +17,8 @@ const crypto = require('crypto'); const config = require('../config'); const Database = require('../db.js') const { logger } = require('./constants.js') -const { migrationSession } = require('./migration_query_timeout.js') +const { migrationSession, retireMigrationConnection } = require('./migration_query_timeout.js') +const { assertNoLiveColumnLoss } = require('./migration_live_schema_guard.js') function validateMigrationTargets(files, only, dir){ // Targeted rollout: a name that matches no committed migration is almost @@ -171,9 +172,13 @@ async function applyMigrationFile(database, context, file, raw, checksum, mode){ offender.slice(0, 160) + (offender.length > 160 ? '...' : '') + '". ' + 'Re-tag the file `-- xchain:migration mode=manual` and apply it deliberately via `node src/db/migrate.js`.'); } + // The text scan cannot see what a MODIFY drops from the live column; this check can. + await assertNoLiveColumnLoss(context.conn, file, statements); } // Lift the pool's 30s statement limit so a table rebuild is not killed half-applied. await context.activateQueryTimeout(); + // Marked before the first statement, so a file that fails partway still retires the connection. + if(context.markSessionDirty) context.markSessionDirty(); logger.info('runMigrations: applying ' + file + ' (mode=' + mode + ', ' + statements.length + ' statement(s))...'); try { for(const stmt of statements){ await context.conn.query(stmt); } @@ -269,6 +274,7 @@ module.exports = { const lockName = 'xchain_migrate_' + this.dbName; let conn = await this.getConnection(); let returnToPool = true; + let sessionDirty = false; try { const got = await conn.query('SELECT GET_LOCK(?, 30) AS l', [lockName]); if(!got || !got[0] || String(got[0].l) !== '1'){ @@ -288,6 +294,7 @@ module.exports = { const appliedRows = await conn.query('SELECT name, checksum FROM schema_migrations'); const context = { includeManual, only, dir, result, conn, activateQueryTimeout: session.activate, + markSessionDirty: () => { sessionDirty = true; }, appliedByName: new Map(appliedRows.map(r => [r.name, r.checksum])), }; for(const file of files) await processMigration(this, context, file); @@ -295,7 +302,7 @@ module.exports = { returnToPool = await session.release(); } } finally { - if(returnToPool) try { await conn.release(); } catch(_){} + if(returnToPool) await retireMigrationConnection(conn, sessionDirty); } if(result.applied.length) logger.info('runMigrations: ' + result.applied.length + ' migration(s) applied to ' + this.dbName + '.'); if(result.pending.length) logger.info('runMigrations: ' + result.pending.length + ' manual migration(s) pending for ' + this.dbName + '; run `node src/db/migrate.js` to apply.'); diff --git a/src/db/query_helpers.js b/src/db/query_helpers.js index 8529e220..0e5e57d6 100644 --- a/src/db/query_helpers.js +++ b/src/db/query_helpers.js @@ -61,8 +61,34 @@ function opensBackslashEscape(str, i, quote){ return str[i] === '\\' && quote !== '`' && i + 1 < str.length; } +// destructiveAutoStatement, the CREATE TABLE engine check: true when any ENGINE option names +// an engine other than InnoDB. MEMORY loses every row on restart, and MyISAM or Aria sit +// outside the transaction a reorg rollback undoes. +function createNamesNonInnoDb(stmt){ + const engineRe = /\bENGINE\b\s*=?\s*[`'"]?(\w*)/gi; + let m; + while((m = engineRe.exec(stmt)) !== null){ + if(m[1].toLowerCase() !== 'innodb') return true; + } + return false; +} + +// destructiveAutoStatement, the ALTER TABLE DROP check: true unless every DROP is followed by a +// bare, unquoted metadata keyword. A quoted name is always a column to the server, a word that +// only starts with a keyword (key1) is a column, and a token the scan cannot read is not safe. +function dropTargetDestroys(stmt, safeDrop){ + const dropRe = /\bDROP\b/gi; + while(dropRe.exec(stmt) !== null){ + const next = /^\s+([A-Za-z_]+)(?=[\s,;()`]|$)/.exec(stmt.slice(dropRe.lastIndex)); + if(!next || !safeDrop.has(next[1].toUpperCase())) return true; + } + return false; +} + module.exports = { resolveQueryTimeout, jsonBigIntSafe, opensBackslashEscape, + createNamesNonInnoDb, + dropTargetDestroys, } diff --git a/src/db/table_drift.js b/src/db/table_drift.js index 770a7879..e67ffa5c 100644 --- a/src/db/table_drift.js +++ b/src/db/table_drift.js @@ -16,6 +16,38 @@ const fs = require('fs'); const util = require('../util') const { logger } = require('./constants.js') +// alterTableForDrift, a column live as NOT NULL that the source declares nullable: relaxed +// only when the bare MODIFY that does it would lose nothing. +async function relaxNullability(db, table, exp, cur){ + // NEVER relax a primary-key or auto-increment column: a PK can't be + // NULL anyway, and a bare `MODIFY NULL` silently strips the + // AUTO_INCREMENT attribute (mirror-cursor corruption). parseExpectedColumns + // already treats such sources as NOT NULL; this guards against any parse gap. + const isPk = String(cur.COLUMN_KEY || '').toUpperCase() === 'PRI'; + const isAutoInc = /auto_increment/i.test(String(cur.EXTRA || '')); + if(isPk || isAutoInc){ + logger.info('Schema drift on ' + table + '.' + exp.name + ': live=NOT NULL, source=NULL - SKIPPING relax (' + (isPk ? 'PRIMARY KEY' : 'AUTO_INCREMENT') + ' column; a bare MODIFY would strip attributes).'); + return; + } + // A MODIFY restates the whole column, so a live DEFAULT, COMMENT, ON UPDATE or generation + // expression the statement omits is dropped. Rebuilding those clauses from + // information_schema has its own quoting traps, so relax only when nothing would be lost. + const lossy = []; + if(cur.COLUMN_DEFAULT !== null && cur.COLUMN_DEFAULT !== undefined) lossy.push('DEFAULT'); + if(String(cur.COLUMN_COMMENT || '') !== '') lossy.push('COMMENT'); + if(String(cur.GENERATION_EXPRESSION || '') !== '') lossy.push('generation expression'); + if(/on update/i.test(String(cur.EXTRA || ''))) lossy.push('ON UPDATE'); + if(lossy.length){ + logger.warn('Schema drift on ' + table + '.' + exp.name + ': live=NOT NULL, source=NULL - SKIPPING relax (a bare MODIFY would drop ' + lossy.join(', ') + '). Relax it in a dated migration that restates the full column instead.'); + return; + } + // Restate the live collation: it is a bare identifier (no quoting hazard) and + // omitting it re-collates an explicitly-collated column to the table default. + const collate = /^[A-Za-z0-9_]+$/.test(String(cur.COLLATION_NAME || '')) ? ' COLLATE ' + cur.COLLATION_NAME : ''; + logger.info('Schema drift on ' + table + '.' + exp.name + ': live=NOT NULL, source=NULL. Relaxing constraint.'); + await db.query('ALTER TABLE `' + table + '` MODIFY `' + exp.name + '` ' + cur.COLUMN_TYPE + collate + ' NULL'); +} + module.exports = { // Parse a CREATE TABLE statement to extract expected columns. Conservative: // only used for drift detection, not full schema management. Returns array of @@ -67,7 +99,7 @@ module.exports = { // with a loud warning rather than aborting startup.) // 2. Nullability: only relaxes NOT NULL -> NULL (the safe direction; never // strengthens to NOT NULL since live rows might hold NULLs that would - // block the ALTER). + // block the ALTER), and skips the relax when it would drop a live attribute. // Doesn't touch types, defaults of existing columns, or indexes. Each applied // ALTER is loudly logged. Reuses the caller's connection (`db`). async alterTableForDrift(file, db){ @@ -84,7 +116,8 @@ module.exports = { return; } const live = await db.query( - "SELECT COLUMN_NAME, IS_NULLABLE, COLUMN_TYPE, COLUMN_KEY, EXTRA FROM information_schema.columns WHERE table_schema = ? AND table_name = ?", + "SELECT COLUMN_NAME, IS_NULLABLE, COLUMN_TYPE, COLUMN_KEY, EXTRA, COLUMN_DEFAULT, COLUMN_COMMENT, GENERATION_EXPRESSION, COLLATION_NAME " + + "FROM information_schema.columns WHERE table_schema = ? AND table_name = ?", [this.dbName, table] ); const liveByName = new Map(live.map(c => [c.COLUMN_NAME.toLowerCase(), c])); @@ -99,21 +132,7 @@ module.exports = { await db.query('ALTER TABLE `' + table + '` ADD COLUMN ' + exp.definition); continue; } - const liveIsNullable = cur.IS_NULLABLE === 'YES'; - if(!liveIsNullable && exp.nullable){ - // NEVER relax a primary-key or auto-increment column: a PK can't be - // NULL anyway, and a bare `MODIFY NULL` silently strips the - // AUTO_INCREMENT attribute (mirror-cursor corruption). parseExpectedColumns - // already treats such sources as NOT NULL; this guards against any parse gap. - const isPk = String(cur.COLUMN_KEY || '').toUpperCase() === 'PRI'; - const isAutoInc = /auto_increment/i.test(String(cur.EXTRA || '')); - if(isPk || isAutoInc){ - logger.info('Schema drift on ' + table + '.' + exp.name + ': live=NOT NULL, source=NULL - SKIPPING relax (' + (isPk ? 'PRIMARY KEY' : 'AUTO_INCREMENT') + ' column; a bare MODIFY would strip attributes).'); - continue; - } - logger.info('Schema drift on ' + table + '.' + exp.name + ': live=NOT NULL, source=NULL. Relaxing constraint.'); - await db.query('ALTER TABLE `' + table + '` MODIFY `' + exp.name + '` ' + cur.COLUMN_TYPE + ' NULL'); - } + if(cur.IS_NULLABLE !== 'YES' && exp.nullable) await relaxNullability(db, table, exp, cur); } }, diff --git a/src/metrics/decoder_metrics.js b/src/metrics/decoder_metrics.js index 1cd36180..fbccf8a3 100644 --- a/src/metrics/decoder_metrics.js +++ b/src/metrics/decoder_metrics.js @@ -46,7 +46,8 @@ const DECODER_GAUGES = [ const DECODER_COUNTERS = [ ['parse_errors_total', 'Transactions the decoder failed to parse since process start'], ['rpc_errors_total', 'Node RPC errors seen since process start'], - ['reorgs_total', 'Reorgs this decoder has rolled back since process start'] + ['reorgs_total', 'Reorgs this decoder has rolled back since process start'], + ['dispenser_purge_failures_total', 'Post-commit expired-dispenser purges that failed since process start'] ]; function collectDecoderMetrics(decoder, gauges, counters) { @@ -89,6 +90,7 @@ function collectDecoderMetrics(decoder, gauges, counters) { const rpcErrors = (decoder.rpcErrors || 0) + ((decoder.connector && decoder.connector.rpcErrors) || 0); counters.rpc_errors_total.setMonotonic({}, rpcErrors); counters.parse_errors_total.setMonotonic({}, decoder.parseErrors || 0); + counters.dispenser_purge_failures_total.setMonotonic({}, decoder.dispenserPurgeFailures || 0); // Reorg churn. The durable REORG rows and the indexer's reorgsProcessed cover // the completed handshake, but neither is scrapeable when only Prometheus is diff --git a/src/protocol/batch_sub_command_capture.js b/src/protocol/batch_sub_command_capture.js index 8eedc3ca..350f8873 100644 --- a/src/protocol/batch_sub_command_capture.js +++ b/src/protocol/batch_sub_command_capture.js @@ -46,7 +46,10 @@ 'use strict'; -const { BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION } = require('./constants.js') +const constants = require('./constants.js') +const { BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION } = constants +const { dispenserAddressKey } = require('./batch_sub_command_capture/dispenser_address_key.js') +const { collapseRegistrationsByKey } = require('./dispenser_registration_collapse.js') const ACTION_ALIASES = require('./action_aliases.js') const { COMMAND_LIMIT, ACTION_LIMITS, @@ -110,6 +113,14 @@ function captureCommands(decodedData, consensusNetwork, blockTime){ return subCommands.map(command => expandSubCommandAlias(command, ACTION_ALIASES)) } +function isDispenserAddressIdCollapseActive(consensusNetwork, blockTime){ + const gate = constants.DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION + const activation = gate ? gate[consensusNetwork] : undefined + if (typeof activation !== 'number') return false + const t = Number(blockTime) + return Number.isFinite(t) && t >= activation +} + // Collapse the v0 DISPENSER creates of ONE transaction to at most one registration per // OPERATING ADDRESS, keeping the LATEST expiration and the first oracle address named. // Input order is sub-command position order; output order is first-appearance order. @@ -145,7 +156,13 @@ function captureCommands(decodedData, consensusNetwork, blockTime){ // trade-off, live oracle-fee under-capture against migration plus flag-day, stays OPEN. // Registering ONE of the two is strictly better than the pre-gate behaviour, where a // batch registers NEITHER. -function collapseDispenserRegistrations(candidates){ +// +// Above the address-id collapse gate, use the same collation key as index_addresses so +// spellings that resolve to one address_id cannot attempt two inserts for the same primary +// key. Raw spellings remain distinct below the gate for historical replay compatibility. +function collapseDispenserRegistrations(candidates, consensusNetwork, blockTime){ + if (isDispenserAddressIdCollapseActive(consensusNetwork, blockTime)) + return collapseRegistrationsByKey(candidates, dispenserAddressKey) const collapsed = new Map() if (!Array.isArray(candidates)) return [] for (const candidate of candidates){ @@ -186,6 +203,7 @@ module.exports = { maxIdenticalMintTicks, captureCommands, collapseDispenserRegistrations, + isDispenserAddressIdCollapseActive, COMMAND_LIMIT, ACTION_LIMITS, GATED_ACTION_LIMITS, diff --git a/src/protocol/batch_sub_command_capture/dispenser_address_key.js b/src/protocol/batch_sub_command_capture/dispenser_address_key.js new file mode 100644 index 00000000..5604d537 --- /dev/null +++ b/src/protocol/batch_sub_command_capture/dispenser_address_key.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. + * + **********************************************************************/ + +'use strict' + +function dispenserAddressKey(address){ + return address.replace(/[A-Z]/g, letter => letter.toLowerCase()).replace(/ +$/, '') +} + +module.exports = { dispenserAddressKey } diff --git a/src/protocol/batch_sub_command_capture/sub_commands.js b/src/protocol/batch_sub_command_capture/sub_commands.js index 8aaf2795..56c322f2 100644 --- a/src/protocol/batch_sub_command_capture/sub_commands.js +++ b/src/protocol/batch_sub_command_capture/sub_commands.js @@ -110,42 +110,50 @@ function subCommandActionName(command){ // PROVABLY rejects, taking the whole batch down with it? // // WHY CAPTURE HAS TO CARE. The indexer's activation scan (batch/validate.js activationError) -// runs, before any dispatch: +// runs before any dispatch. In outline, it applies these admission checks to every name: // // for(let command of commands){ // let action = String(command).split('|')[0]; // if(normalize) action = this.normalizeSubAction(action); -// if(!error && await this.protocolChanges.isEnabled(action, ...) == false) +// if(!error && dispatchedOnly && !DISPATCHED_ACTIONS.has(action)) +// error = 'invalid: ACTION (unknown)'; +// else if(!error && await this.protocolChanges.isEnabled(action, ...) == false) // error = 'invalid: ACTION (unknown)'; // } // -// and `isEnabled` returns FALSE for any name absent from its registry. One rejected name -// invalidates the WHOLE batch as a single record, so NO sub-command runs - not even the -// well-formed ones beside it. Capture that keeps reading those siblings persists outputs -// for actions the indexer never executes: the same over-capture the DISPENSER prefix -// tightening closes, reached through a sibling command instead of through the DISPENSER -// command's own name. `BATCH|0|DISPENSER|0|...;` (one trailing semicolon) registers a -// dispenser here and none there, and payments to that address are then read as dispenses -// no indexer will ever settle. +// `isEnabled` returns FALSE for any name absent from its registry; `dispatchedOnly` is the +// gate described below. One rejected name invalidates the WHOLE batch as a single record, +// so NO sub-command runs - not even the well-formed ones beside it. Capture that keeps +// reading those siblings persists outputs for actions the indexer never executes: the same +// over-capture the DISPENSER prefix tightening closes, reached through a sibling command +// instead of through the DISPENSER command's own name. `BATCH|0|DISPENSER|0|...;` (one +// trailing semicolon) registers a dispenser here and none there, and payments to that +// address are then read as dispenses no indexer will ever settle. // // WHY ONLY THE EMPTY NAME, when the scan rejects far more than that. Suppression is the // UNDER-capture direction, the money-bearing one: refuse capture for a batch the indexer // actually runs and a real settlement output is never persisted. So this may only fire on -// names it can PROVE are unregistered, and the decoder holds no copy of that registry. -// Measured against the sibling indexer at this commit, 53 names are enabled there and -// absent from VALID_ACTION_NAMES here (DISPENSE, XCALL, ORDER_MATCH and every non-action -// feature-gate flag: UNIFIED_FEES, ISSUANCE_FEE, FIX_OUTPUT_FANOUT, ...), so a gate keyed -// on the decoder's own known-name set would suppress capture for batches the indexer -// dispatches normally. The EMPTY name is different in kind rather than in degree: '' is -// not an ACTION and not a feature-gate flag, no addChange can name it, and it is the one -// verdict this file can reach on its own evidence. +// names it can PROVE the indexer rejects, and the decoder holds neither the indexer's +// protocol-change registry nor its dispatch set. +// +// ACTION_ADMISSION_DISPATCHED_ONLY tightens the indexer's scan at its flag day: an enabled +// registry name must also name a handler in DISPATCHED_ACTIONS. That closes the post-flag +// path where a non-action feature-gate name could pass admission, but it does not make a +// decoder-local allowlist sound. Historical replay below the flag still uses registry-only +// admission, while a newly added dispatched action above it can predate the decoder's +// vendored list. Even the current decoder set omits dispatched names such as DISPENSE, +// XCALL and ORDER_MATCH. Treating any such name as a whole-batch rejection would suppress +// capture for a sibling command the indexer really runs. The EMPTY name is different in +// kind rather than in degree: '' names neither a registry entry nor a dispatch handler, so +// it is the one verdict this file can reach on its own evidence in every admission era. // // The rest of the class is now closed as far as it is provable, in hasProvablyRejectedBatch // below: the nested BATCH, the per-ACTION caps, the 250-command cap and the // BATCH_COST_WEIGHTING weight budget, against the indexer's tables vendored canonically in -// src/protocol/indexer_batch_limits.js. The UNKNOWN NAME is still the one cause left open, and -// deliberately, for the reason this paragraph gives: a vendored name LIST is not closed under -// registry growth, so a stale one under-captures. +// src/protocol/indexer_batch_limits.js. An UNKNOWN OR UNDISPATCHED NAME is still the one +// cause left open, and deliberately, for the reason this paragraph gives: neither a vendored +// registry list nor a vendored dispatch list is closed under indexer growth, so a stale one +// under-captures. // // A '' name is reachable two ways and both are covered, because both are what // `split('|')[0]` yields: an EMPTY element (a trailing ';', a ';;', or the whole command diff --git a/src/protocol/constants.js b/src/protocol/constants.js index dc1613e5..64e6271f 100644 --- a/src/protocol/constants.js +++ b/src/protocol/constants.js @@ -29,613 +29,11 @@ * ********************************************************************/ -// Maximum *compiled* on-chain ACTION push, in bytes. -// -// This is measured against the reassembled script push as it appears on -// chain (i.e. the OP_PUSHDATA-prefixed buffer, BEFORE bitcoin.script.decompile -// strips the push prefix. The indexing decoder is the protocol arbiter: it -// drops any transaction whose compiled ACTION push exceeds this value, so the -// encoder must enforce the identical compiled-size ceiling. A transaction the -// encoder produces above this size would be silently dropped by every node. -const MAX_ACTION_DATA_LENGTH = 8192; - -// Bytes added by the OP_PUSHDATA2 push prefix (1-byte opcode + 2-byte -// little-endian length) when a 256..65535-byte payload is compiled into the -// on-chain script. For a single such push the compiled length is therefore -// (decoded payload bytes + OP_RETURN_PUSH_OVERHEAD); smaller payloads use a -// 1- or 2-byte prefix, and multi-segment encodings add one prefix per segment. -// This is why the authoritative cap is enforced on the *compiled* length, not -// on the decoded character count. -const OP_RETURN_PUSH_OVERHEAD = 3; - -// Maximum smart-contract source code size, in bytes (64 KiB). Enforced by the -// SDK (pre-flight validation), the indexer (DEPLOY processing) and the VM -// (isolate limit). These were each declared independently and are kept in -// lockstep by the same regression suite. -const MAX_CODE_SIZE = 65536; - -// Cross-contract calls (emit.execute). Maximum call depth: a user-submitted -// EXECUTE runs at depth 0; each emit.execute hop adds 1. Enforced by the VM at -// emit time and re-validated by the indexer when it processes the emission. -const VM_MAX_CALL_DEPTH = 4; - -// Minimum caller-funded gas reservation per emit.execute call. Bounds call-tree -// fan-out: every call costs at least (VM_EMISSION + VM_MIN_CALL_GAS) out of the -// caller's own gas budget. Enforced by the VM and the indexer in lockstep. -const VM_MIN_CALL_GAS = 5000; - -// Cross-CHAIN contract calls (emit.crossExecute / XCALL). -// Enforced by the VM at emit time and re-validated host-side by the indexer -// (processEmission + actions/xcall.js); the target chain re-validates the -// signed dispatch row before injecting. See protocol/Cross_Chain_Calls.md. - -// Target-side gas ceiling bounds. The injected execution is fee-less on the -// target chain (the caller pre-paid on the source chain), so the per-call cap -// is much tighter than the same-chain 1M execution ceiling. The minimum equals -// VM_MIN_CALL_GAS. -const XCALL_MIN_GAS = 5000; -const XCALL_MAX_GAS = 200000; - -// Cross-chain hop budget: a user-originated call is hop 1; a call emitted from -// a cross-chain-injected execution (or from a result callback) is hop 2; more -// requires a fresh user transaction. Bounds X→Y→X ping-pong, which is -// otherwise free after the first hop (injected executions have no fee payer). -const XCALL_MAX_HOPS = 2; - -// Source-chain deadline window (blocks). Must comfortably exceed both chains' -// relay confirmation depths plus federation rounds; expiry past deadline_block -// is synthesized deterministically by every source-chain indexer. -const XCALL_MIN_DEADLINE_BLOCKS = 10; -const XCALL_MAX_DEADLINE_BLOCKS = 4000; - -// Return payload cap, bytes (pre-base64). The payload is mirrored to every -// indexer and ANCHOR-archived on DOGE; an oversize return becomes status -// 'payload_too_large' with an EMPTY payload (deterministic. Never truncated). -const XCALL_MAX_RETURN_BYTES = 1024; - -// Deterministic per-block injection cap on each target chain. Overflow carries -// forward to the next block in (snapshot_block, call_id) order. Never dropped. -const XCALL_MAX_CALLS_PER_BLOCK = 25; - -// ATTEST expiry sweep. -// Deterministic per-block cap on the ATTEST v0 deadline-expiry sweep. -// Each expired request synthesizes an ATTEST v2 action that flips the request to -// 'expired' and fires its callback, so an unbounded sweep lets a single block -// inherit an arbitrary backlog: one block's processing time (and its actions -// rows) becomes a function of how many requests happened to expire at once, -// which an attacker controls by batching requests with a common deadline. -// -// Overflow carries forward to the next block rather than being dropped: the -// selection is ordered (deadline_block ASC, action_index ASC), a TOTAL order -// because action_index is unique, so the same requests expire in the same order -// on every node, just spread across more blocks. Mirrors the XCALL sibling cap -// above in both value and carry-forward semantics. -// -// CONSENSUS-VISIBLE: the cap decides which block an expiry lands in, which moves -// actions rows, the contract hash and the checkpoint preimage. It ships ungated -// because the pre-launch fleet-wide replay recomputes all of it. -const ATTEST_MAX_EXPIRIES_PER_BLOCK = 25; - -// Token-gated content. -// Fixed fractional scale for comparing FILE.GATE_MIN_AMOUNT thresholds against a -// holder's balance. The wallet scales both sides to this many fractional digits -// as BigInt (packages/core THRESHOLD_SCALE); the indexer compares with mathjs -// bignumber. A threshold carrying MORE decimal places than this is -// unrepresentable on the wallet side, so the two implementations would disagree -// on the last digit for values neither considers malformed. The indexer therefore -// bounds a threshold's decimal places at min(gate tick divisibility, -// THRESHOLD_SCALE) rather than at divisibility alone. -// -// Cross-repo twin: xchain-wallet packages/core THRESHOLD_SCALE. These two must -// move together or the disagreement returns. -const THRESHOLD_SCALE = 18; - -// Chunked DEPLOY (DEPLOY v4 carriers + DEPLOY v2/v3 assemble). -// A contract whose base64(code) exceeds the single-tx budget is split across -// ordered DEPLOY v4 carrier actions and reassembled by a DEPLOY v2/v3 keyed on -// the CODE_HASH. Enforced by the indexer (deploy_chunk + deploy assembly) and -// the SDK (chunkHelper splitter) in lockstep. - -// Maximum number of chunks one DEPLOY may assemble. base64(MAX_CODE_SIZE) is -// ~87.4 KB; at the conservative per-chunk part budget below that is ~12 chunks, -// so 16 leaves headroom while bounding assembler work + chunk-table DoS. -const MAX_DEPLOY_CHUNKS = 16; - -// Maximum bytes of base64 code carried by a single DEPLOY v4 carrier's CODE_PART. -// Sized so the compiled v4 carrier action (action prefix + 64-char CODE_HASH + -// indices + the part) stays comfortably under MAX_ACTION_DATA_LENGTH including -// the OP_PUSHDATA2 prefix. The SDK splits at this size; the indexer rejects a -// larger part (belt-and-suspenders; the decoder already drops oversize pushes). -const MAX_DEPLOYCHUNK_PART_BYTES = 7800; - -// Stake-weighted quorum (STAKE_WEIGHTED_QUORUM). -// Consensus-critical activation: at/above this BTC-anchored snapshot_block the -// federation quorum becomes stake-WEIGHTED (signers' summed source stake must -// exceed 2/3 of total active snapshot stake) instead of count-based (2f+1 of the -// pubkey COUNT). -// -// Keyed on the BTC `snapshot_block` carried by every settlement/checkpoint -// canonical (NOT each chain's local processing height) so the hub and the BTC, -// LTC and DOGE indexers all flip on the SAME anchor. A per-chain local-height -// gate would fork: one snapshot_block lands at different local heights per chain. -// The `network` is also taken from the row, so the gate is env-independent. -// -// Enforced IDENTICALLY by the hub (every PBFT tally engine), the indexer -// (every settlement-signature gate + recovery), and the sdk/explorer/sync -// verifiers. All five keep a local copy of this map; the cross-service -// regression suite asserts they equal these values, so the activation height -// can never silently diverge (a divergence forks the chain). -// -// mainnet is ARMED (2026-07-07) to a concrete near-term height: 961000, the -// BTC-anchored flag-day at which mainnet flips from the count-based quorum -// rule to stake-weighted. BTC anchor ~2026-08-04; hub + ALL indexers (+ -// sdk/explorer/sync copies) MUST deploy before this height. testnet/regtest -// activate at genesis so the e2e / regtest stack exercises stake-weighting -// from block 0. -const STAKE_WEIGHTED_QUORUM_ACTIVATION = { - mainnet: 961000, // ARMED 2026-07-07: BTC anchor ~2026-08-04; deploy hub + ALL indexers (+ sdk/explorer/sync copies) before this height - testnet: 0, - regtest: 0, -}; - -// EQUIV_HEADER_ACTIVATION: the BTC-anchored flag-day at/above which every -// consensus canonical is prefixed with a uniform signed header -// `EQUIV|||||`. This is consensus-breaking (it changes the -// signed preimage of every settlement/checkpoint/price/attestation signature + the config-change -// PBFT canonical), so it is gated, kept byte-identical to the local copies in -// xchain-{hub,indexer,sdk,explorer,sync}/src/equivocation_header.js by the -// cross-service regression suite, and must deploy hub + ALL indexers atomically. Its sole -// consumer is the SLASH v0 equivocation-slashing action, which is only constructible from -// post-flag-day (header-carrying) messages. Same ARMED height and deploy-by convention as -// STAKE_WEIGHTED_QUORUM_ACTIVATION: mainnet is armed to 961000 (2026-07-07; BTC anchor -// ~2026-08-04), not a disabled placeholder. -const EQUIV_HEADER_ACTIVATION = { - mainnet: 961000, // ARMED 2026-07-07: BTC anchor ~2026-08-04; deploy hub + ALL indexers (+ sdk/explorer/sync copies) before this height - testnet: 0, - regtest: 0, -}; - -// STATE_COMMITMENT_ACTIVATION (light-client SPV, spec §6.4): the flag-day at/above which -// each indexer computes + commits the additive per-block `state_root` (balances+stakes SMT) -// and `block_merkle_root`. ADDITIVE (the three consensus block hashes + BLOCK_HASH_VERSION are -// untouched), so it is not consensus-breaking by itself; it only adds new committed roots that -// the xchain-sync follower recomputes and HALTS on if they diverge. UNLIKE the two maps above, -// this gates on the chain's OWN local block_index (each chain starts committing its own per-block -// root at its own height); the Phase 2 checkpoint/ANCHOR extension that SIGNS these roots gates on -// snapshot_block. Kept byte-identical to the local copies in xchain-indexer/src/ -// state_commitment_activation.js + xchain-sync/src/state_commitment_activation.js (and xchain-hub -// at Phase 2) by the cross-service regression suite. ARMED MID-CHAIN 2026-07-07 with per-chain -// ':' keys (one shared height cannot fit BTC ~957k and DOGE ~6.28M at once; bare -// network key remains for regtest; coin-less mainnet/testnet lookups stay inert). Same heights -// as the two state-hash gate maps, so ONE deploy-by date governs all Cohort-C flips; each height -// precedes the Cohort-B BTC anchor (961000) as the checkpoint-commitment ordering requires. -const STATE_COMMITMENT_ACTIVATION = { - 'BTC:mainnet': 958500, // ARMED 2026-07-07 at tip 957062; ~10 days of margin - 'LTC:mainnet': 3143000, // ARMED 2026-07-07 at tip 3138154; ~8 days - 'DOGE:mainnet': 6291000, // ARMED 2026-07-07 at tip 6280094; ~7.5 days - 'BTC:testnet': 145000, // ARMED 2026-07-07 at tip 143299 - 'LTC:testnet': 4805000, // ARMED 2026-07-07 at tip 4797675 - 'DOGE:testnet': 67000000, // ARMED 2026-07-07 at tip 66498605 (fast chain, wide margin) - regtest: 0, // armed from genesis: fresh regtest stacks exercise the roots end to end -}; - -// CHECKPOINT_COMMITMENT_ACTIVATION (light-client SPV, spec §6.1/§6.3, Phase 2): the flag-day at/above -// which the quorum-signed checkpoint canonical (and the on-chain ANCHOR) COMMIT the additive -// `state_root` + `block_merkle_root` (with their version bytes) that STATE_COMMITMENT_ACTIVATION made -// the indexer compute in Phase 1. Post-flag-day the checkpoint canonical string gains -// `|STATE_ROOT|STATE_ROOT_VERSION|BLOCK_MERKLE_ROOT|BLOCK_MERKLE_VERSION` and a new ANCHOR v3 carries -// the roots on DOGE; pre-flag-day both keep their old shape and the roots are absent. Consensus-relevant -// for signature verification (the signed preimage changes), so it must deploy hub + ALL indexers + the -// SDK/explorer verifiers atomically. -// -// UNLIKE STATE_COMMITMENT_ACTIVATION (which gates on each chain's OWN local block_index, since each chain -// computes its own per-block root), this gates on the BTC-anchored `snapshot_block` carried by every -// checkpoint canonical, exactly like STAKE_WEIGHTED_QUORUM_ACTIVATION / EQUIV_HEADER_ACTIVATION, so the -// hub and the BTC/LTC/DOGE indexers all flip the SIGNED shape on the same anchor. The operator MUST pick -// a snapshot_block at/after which every checkpointed chain is already past its own STATE_COMMITMENT -// flag-day (else the engine would have no roots to sign). Kept byte-identical to the local copies in -// xchain-{hub,indexer,sdk,explorer,sync}/src/checkpoint_commitment_activation.js (sync consumes it at -// checkpoint.js to decide whether to expect the roots) by the cross-service regression suite. Same -// ARMED height and deploy-by convention as the maps above: mainnet is armed to 961000 -// (2026-07-07; BTC anchor ~2026-08-04), not a disabled placeholder. -const CHECKPOINT_COMMITMENT_ACTIVATION = { - mainnet: 961000, // ARMED 2026-07-07: BTC anchor ~2026-08-04; deploy hub + ALL indexers (+ sdk/explorer/sync copies) before this height - testnet: 146000, // ARMED 2026-07-22: first BTC-testnet anchor past all three STATE_COMMITMENT testnet thresholds; was 0, which forced the SPV root suffix from testnet genesis before the indexer computes roots, so the hub refused to sign every testnet checkpoint - regtest: 0, -}; - -// ANCHOR_REWARD_ACTIVATION (anchor-reward re-derivation): the flag-day at/above which the validator -// anchor reward stops being TRUSTED from the hub's `pushvalidatorrewards` JSON-RPC and is instead -// DERIVED by every indexer from the on-chain ANCHOR bytes. Post-flag-day the hub emits a publisher- -// bearing ANCHOR (v4 rootless / v5 root-bearing) carrying the elected publisher pubkey plus a 2f+1 -// `oracle_publish` attestation (XANCPUB) over the reward tuple; the indexer verifies that quorum and -// credits the publisher with ANCHOR_REWARD_AMOUNT (a frozen consensus constant, NEVER from the wire). -// Below the flag-day the old push path stands and v4/v5 anchors are rejected. Consensus-relevant (the -// credited reward becomes a COLLECT-spendable per-block ledger row), so it must deploy hub + ALL -// indexers atomically. Like CHECKPOINT_COMMITMENT_ACTIVATION / STAKE_WEIGHTED_QUORUM_ACTIVATION it gates -// on the BTC-anchored `snapshot_block` carried by every ANCHOR canonical. Kept byte-identical to the -// local copies in xchain-{hub,indexer}/src/anchor_reward_activation.js by the cross-service regression -// suite. Same ARMED height and deploy-by convention as the maps above: mainnet is armed to 961000 -// (2026-07-07; BTC anchor ~2026-08-04), not a disabled placeholder. -const ANCHOR_REWARD_ACTIVATION = { - mainnet: 961000, // ARMED 2026-07-07: BTC anchor ~2026-08-04; deploy hub + ALL indexers (+ sdk/explorer/sync copies) before this height - testnet: 0, - regtest: 0, -}; - -// ANCHOR_REWARD_AMOUNT: the frozen validator anchor-publish reward, signed into the XANCPUB attestation -// by the hub and re-derived by the indexer (never from the wire). Changing it is itself a flag-day. -const ANCHOR_REWARD_AMOUNT = '10.00000000'; - -// ARCHIVE_REWARD_ACTIVATION (archive-reward re-derivation): the flag-day at/above which the -// anchor_archive reward stops riding the key-authenticated `pushvalidatorrewards` rail and is instead -// DERIVED by every indexer from the on-chain ANCHOR v6 bytes (the v1 archive anchor plus the same -// PUBLISHER + 2f+1 XANCPUB attestation tail as v4/v5, attested over an 'anchor_archive' canonical -// keyed on MATCH_BATCH_SEQ). This retires the last insider-with-key reward-forge surface the -// per-chain ANCHOR_REWARD flag-day left open. Below the flag-day the legacy v1 + push path stands -// and v6 anchors are rejected. Consensus-relevant, same deploy rules and snapshot_block gating as -// ANCHOR_REWARD_ACTIVATION; kept byte-identical to the local copies in -// xchain-{hub,indexer}/src/anchor_reward_activation.js by the cross-service regression suite. -const ARCHIVE_REWARD_ACTIVATION = { - mainnet: 963000, // ARMED 2026-07-16, RE-PINNED 2026-08-12 off 969500 onto the pre-launch-freeze train boundary (tip 959,853 on 07-27 at ~144 blocks/day + 21d); deploy every consumer before this era - testnet: 0, - regtest: 0, -}; - -// ARCHIVE_REWARD_AMOUNT: the frozen archive-publish reward, signed into the archive XANCPUB -// attestation by the hub and re-derived by the indexer (never from the wire). Kept equal to the -// hub's historical default (ANCHOR_REWARD_PER_PUBLISH). Changing it is itself a flag-day. -const ARCHIVE_REWARD_AMOUNT = '10.00000000'; - -// CROSS_CHAIN_ROYALTY_ACTIVATION (cross-chain royalty match-canonical): the flag-day at/above which -// the validator-signed XMATCH canonical carries the matched orders' royalty payout legs -// (a_payout_legs / b_payout_legs), so a colluding hub cannot strip a royalty from a cross-chain -// match; below it the canonical stays byte-identical to the legacy format, so pre-existing -// signatures keep verifying. Consensus-relevant (the signed preimage changes), so it must deploy -// hub + ALL indexers atomically. Like CHECKPOINT_COMMITMENT_ACTIVATION / ANCHOR_REWARD_ACTIVATION -// it gates on the BTC-anchored `snapshot_block` carried by every XMATCH canonical. The CREATE-side -// acceptance rule (deny a royalty-bearing cross-chain listing while enforcement is impossible) is -// gated separately by the CROSS_CHAIN_ROYALTY entry in the indexer's protocol_changes.js; the -// operator MUST flip this canonical gate first or together with it, NEVER create-side first -// (create-side ON with canonical OFF would put the legs in unsigned mirror fields, the exact -// tamper hole the legs-in-canonical design closes). Kept byte-identical to the local copies in -// xchain-{hub,indexer}/src/cross_chain_royalty_activation.js by the cross-service regression -// suite. Same ARMED height and deploy-by convention as the maps above: mainnet is armed to -// 961000 (2026-07-07; BTC anchor ~2026-08-04), not a disabled placeholder. -const CROSS_CHAIN_ROYALTY_ACTIVATION = { - mainnet: 961000, // ARMED 2026-07-07: BTC anchor ~2026-08-04; deploy hub + ALL indexers before this height - testnet: 0, - regtest: 0, -}; - -// ORACLE_FEE_OUTPUT_ACTIVATION (PRICE v1 oracle usage fee): the flag-day -// at/above which the DECODER persists a native-coin output paying a DISPENSER's -// ORACLE_ADDRESS into transaction_outputs, so the indexer's validateOracleFee can see the -// fee that was actually paid. Keyed on BLOCK TIME (not height) because dispensers settle on -// BTC, LTC and DOGE, whose heights diverge; compared with the same >= semantics the indexer's -// protocol_changes gates use. -// -// The mainnet value is NOT free to choose: capturing a second output on a data-bearing -// transaction makes it fan out to two rows in getDecoderBlockData, and BELOW the indexer's -// FIX_OUTPUT_FANOUT flag-day (protocol_changes.js, mainnet block_time 1786060800) such a -// transaction is a consensus-critical fault that HALTS the block. This gate must therefore -// never precede FIX_OUTPUT_FANOUT; it is armed to exactly the same instant so capture begins -// in the same block the collapse does. Below it a Mode B create against a fee-bearing oracle -// is rejected with 'missing oracle fee output' whether or not the payer paid, which is the -// fail-closed direction and costs nothing while no mainnet chain holds a dispenser. -// testnet/regtest are genesis-on, matching FIX_OUTPUT_FANOUT there. -// -// Vendored byte-equal into xchain-decoder/src/protocol/constants.js; the parity suites keep -// the two copies and the indexer's FIX_OUTPUT_FANOUT timestamp in lockstep. -const ORACLE_FEE_OUTPUT_ACTIVATION = { - mainnet: 1786060800, // 2026-08-07 00:00:00 UTC, the contract-era flag-day FIX_OUTPUT_FANOUT rides - testnet: 0, - regtest: 0, -}; - -// ORACLE_FEE_SET_CAPTURE_ACTIVATION (PRICE v1 oracle usage fee, set-membership capture): the -// flag-day at/above which the DECODER captures a DISPENSER v2 (edit/refill) oracle-fee output -// by SET MEMBERSHIP over EVERY open Mode B dispenser of the paying SOURCE, instead of the one -// top-ranked row the legacy lookup picks. Keyed on BLOCK TIME with the same >= semantics as -// ORACLE_FEE_OUTPUT_ACTIVATION, which it never precedes: set capture only widens a capture -// that gate has already switched on. -// -// WHY IT EXISTS: a v2 payload names its target by DISPENSER_ACTION_INDEX, an id in the -// INDEXER's action space the decoder does not maintain, so the decoder resolves the oracle by -// SOURCE address. Capture is an address EQUALITY test, so when one source holds several open -// Mode B dispensers with DIFFERENT oracle addresses, a refill of any row but the top-ranked -// one resolves the wrong oracle, NOTHING is captured, and the indexer (which resolves the -// exact DISPENSER_ACTION_INDEX target) rejects a valid refill after the payer's native payment -// is already spent. Testing membership over the whole set captures the right output for every -// row; the extra outputs a multi-oracle source's refill may also capture are ones the indexer -// ignores, which is the over-capture direction the decoder's advisory open-view calls safe. -// -// CONSENSUS-AFFECTING: it changes the set of outputs persisted to transaction_outputs, so an -// ungated widening breaks from-genesis byte-identity and forks validators. The legacy -// single-pick therefore stays live BELOW the gate, and a re-decode of pre-flag-day history -// reproduces exactly what the fleet wrote live. -// -// mainnet is ARMED at the base gate's own instant by the 2026-09-09 ruling, the earliest the -// ordering above permits: the indexed mainnet history holds 0 dispensers (measured -// 2026-09-09), so set capture persists exactly the output set the legacy single-pick did and -// the arm rewrites no agreed history. A from-genesis OLD-vs-ON replay witness per chain is the -// proof. regtest holds no agreed history (its chains are recreated per run), so it is -// genesis-on and exercises the set path in the regtest venues. -// -// DEPLOY DEADLINE, once an instant is armed: EVERY decoder on that network MUST be running the -// armed value before the instant, or the fleet splits on the first refill of a source holding -// more than one open Mode B dispenser. -// -// Vendored byte-equal into xchain-decoder/src/protocol/constants.js; the conformance suite -// keeps the two copies in lockstep and refuses a value that precedes -// ORACLE_FEE_OUTPUT_ACTIVATION. -const ORACLE_FEE_SET_CAPTURE_ACTIVATION = { - mainnet: 1786060800, // ARMED by the 2026-09-09 ruling at its base gate's own instant, the earliest the ordering above permits; identity on the indexed mainnet history (0 dispensers, measured 2026-09-09) - // ARMED AT GENESIS (instant 0 = always in force), operator-ratified 2026-08-18 under the - // pre-launch ruling that every feature must be ACTIVE on testnet. This gate fixes a defect - // that spends a payer native coin and gives nothing back, so a public testnet WILL hit it. - // Safe at 0 because testnet decoder/indexer state is REBUILT from the chain before launch. - testnet: 0, - regtest: 0, -}; - -// DISPENSER_EXPIRY_REALIGN_ACTIVATION (dispenser soft-expire measurement point): the flag-day -// at/above which the DECODER soft-expires open dispensers AFTER the block's transaction loop -// instead of before it, putting its measurement point where the INDEXER's already is. Keyed on -// BLOCK TIME with the same >= semantics as ORACLE_FEE_OUTPUT_ACTIVATION, because dispensers -// settle on BTC, LTC and DOGE, whose heights diverge. -// -// WHY IT EXISTS: the two services expire the same dispenser at opposite ends of the same block. -// The decoder runs db.deleteOpenDispensers BEFORE its transaction loop and then loads the -// open-dispenser address set the loop tests every output against, so on the FIRST block whose -// header time passes an expiration the dispenser is already out of that set. The indexer runs -// utility.processExpirations AFTER its transaction loop (XChainIndexer.js, next to -// processBetPasses), so for every transaction in that same block it still treats the dispenser -// as open. The indexer only ever sees outputs the decoder persisted, so a native payment to -// that dispenser on the boundary block is dropped by the decoder and no DISPENSE ever reaches -// the indexer: the payer's coin is spent and nothing is dispensed for it. That is money-bearing -// and unreachable by any in-memory re-seed, because transactions preceding an edit in the block -// are already past. -// -// At/above the gate the soft-expire moves to the end of the block loop, inside the same block -// transaction, so both services measure expiry at the identical point and a boundary block -// yields the same DISPENSE set on both sides. -// -// CONSENSUS-AFFECTING: it changes the set of outputs persisted to transaction_outputs on -// boundary blocks, so an ungated move breaks from-genesis byte-identity and forks validators. -// The legacy block-start soft-expire therefore stays live BELOW the gate, and a re-decode of -// pre-flag-day history reproduces exactly what the fleet wrote live. -// -// mainnet is ARMED at genesis (instant 0) by the 2026-09-09 ruling: the indexed mainnet history -// holds 0 dispensers and 0 dispenses (measured 2026-09-09), so no block ever carried an expiry -// boundary the realigned soft-expire could move and the arm rewrites no agreed history. A -// from-genesis OLD-vs-ON replay witness per chain is the proof. regtest holds no agreed history -// (its chains are recreated per run), so it is genesis-on and exercises the realigned path in -// the regtest venues. -// -// DEPLOY DEADLINE, once an instant is armed: EVERY decoder on that network MUST be running the -// armed value before the instant, or the fleet splits on the first block whose header time -// passes an open dispenser's expiration. -// -// Vendored byte-equal into xchain-decoder/src/protocol/constants.js; the conformance suite -// keeps the two copies in lockstep. -const DISPENSER_EXPIRY_REALIGN_ACTIVATION = { - mainnet: 0, // ARMED at genesis by the 2026-09-09 ruling: identity on the indexed mainnet history (0 dispensers, 0 dispenses, measured 2026-09-09) - // ARMED AT GENESIS (instant 0 = always in force), operator-ratified 2026-08-18 under the - // pre-launch ruling that every feature must be ACTIVE on testnet. This gate fixes a defect - // that spends a payer native coin and gives nothing back, so a public testnet WILL hit it. - // Safe at 0 because testnet decoder/indexer state is REBUILT from the chain before launch. - testnet: 0, - regtest: 0, -}; - -// DISPENSER_CANCEL_GRACE_ACTIVATION (dispenser cancellation grace capture): the flag-day -// at/above which the DECODER keeps a just-expired dispenser in the block loop's payment -// CAPTURE SET for a grace window past its expiration. Keyed on BLOCK TIME with the same >= -// semantics as DISPENSER_EXPIRY_REALIGN_ACTIVATION, because dispensers settle on BTC, LTC -// and DOGE, whose heights diverge. -// -// WHY IT EXISTS: the indexer keeps a CANCELLED dispenser fillable past its own expiration. -// It excludes `cancelling` rows from its expiration pass (xchain-indexer/src/db/index_tables.js -// getExpiredItems, `s2.status='open'`), keeps them matchable through -// `status IN ('open','cancelling')` in findMatchingDispensers, and closes only at the -// cancel's block time plus DISPENSER_CLOSE_DELAY (3600s). The decoder mirrors no cancel at -// all, by design, so it soft-expires that dispenser at its raw expiration and drops the -// address from the capture set. Cancel a funded dispenser shortly before its expiration and -// a window opens: the indexer still settles fills, the decoder captures no output, and the -// buyer's native coin reaches the seller with no DISPENSE record and no inventory release. -// -// At/above the gate the CAPTURE SET alone widens: a row whose expiration is no older than -// the grace window stays an eligible payment destination even once the soft-expire has -// stamped it. The soft-expire itself, the expiry MARK, the extend mirror, the oracle-address -// resolution and the hard purge all keep their current timing, which confines the change to -// the over-capture direction the decoder's advisory contract (xchain-decoder/src/db.js, -// above extendOpenDispenserExpirationBySource) calls safe. Delaying the MARK instead reaches -// the legacy single-pick oracle resolution, whose ORDER BY ... LIMIT 1 then ranks a dead row -// first and captures nothing at all: the under-capture direction, a second money-bearing -// defect rather than a fix. Widen the capture set, never the mark. -// -// CONSENSUS-AFFECTING: it changes the set of outputs persisted to transaction_outputs, so an -// ungated widening breaks from-genesis byte-identity and forks validators. The unwidened -// capture set therefore stays live BELOW the gate, and a re-decode of pre-flag-day history -// reproduces exactly what the fleet wrote. -// -// mainnet is ARMED at genesis (instant 0) by the 2026-09-09 ruling: the indexed mainnet -// history holds 0 dispensers and 0 dispenses (measured 2026-09-09), so the widened capture -// set admits no output the unwidened one missed and the arm rewrites no agreed history. A -// from-genesis OLD-vs-ON replay witness per chain is the proof. -// -// DEPLOY DEADLINE, once an instant is armed: EVERY decoder on that network MUST be running -// the armed value before the instant, or the fleet splits on the first block whose header -// time passes a cancelled dispenser's expiration. -// -// Vendored byte-equal into xchain-decoder/src/protocol/constants.js; the conformance suite -// keeps the two copies in lockstep. -const DISPENSER_CANCEL_GRACE_ACTIVATION = { - mainnet: 0, // ARMED at genesis by the 2026-09-09 ruling: identity on the indexed mainnet history (0 dispensers, 0 dispenses, measured 2026-09-09) - // ARMED AT GENESIS (instant 0 = always in force), matching the sibling - // DISPENSER_EXPIRY_REALIGN_ACTIVATION under the pre-launch ruling that every feature must - // be ACTIVE on testnet. This gate closes a defect that spends a payer's native coin and - // gives nothing back, so a public testnet WILL hit it. Safe at 0 because testnet - // decoder/indexer state is REBUILT from the chain before launch. - testnet: 0, - regtest: 0, -}; - -// BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION (output capture AND open-dispenser registration -// through a BATCH): the flag-day at/above which the DECODER reads a BATCH's SUB-COMMANDS -// instead of only its top-level ACTION name, both when deciding which native-coin outputs to -// persist and when deciding which dispensers to register. Keyed on BLOCK TIME with the same >= -// semantics as ORACLE_FEE_OUTPUT_ACTIVATION, because the affected settlement flows run on BTC, -// LTC and DOGE, whose heights diverge by millions of blocks. -// -// WHY IT EXISTS: capture reads the top-level action string. `decodedData.startsWith("COINPAY|")` -// is FALSE for `BATCH|0|COINPAY|0|x;COINPAY|0|y`, and resolveOracleFeeAddresses' matching -// `startsWith("DISPENSER|")` is false for a batched DISPENSER, so a BATCH carrying either action -// persists NO settlement output and NO oracle-fee output. The indexer only ever sees outputs the -// decoder persisted, so a batched COINPAY reaches it with an EMPTY COIN_DESTINATION and settles -// nothing ("COINPAY (skip): destination mismatch tx= payee=", witnessed on regtest), and -// a batched Mode B DISPENSER is rejected for a missing oracle fee whether or not the payer paid. -// Both are money-bearing: the payer's coin is spent and nothing settles. At/above the gate the -// capture decision runs over the batch's sub-command list, split exactly as the indexer's -// xchain-indexer/src/actions/batch/validate.js readCommands splits it, so a batched COINPAY -// captures the same outputs a top-level COINPAY does. -// -// THE OPEN-DISPENSER REGISTRY RIDES THE SAME INSTANT, deliberately, because it is the same -// blindness and the same decision. `decodedData.startsWith("DISPENSER")` is false for -// `BATCH|0|DISPENSER|0|...`, so a dispenser CREATED inside a batch never entered -// getAllOpenDispenserAddresses: payments to it were never classified as dispense outputs and no -// DISPENSE ever fired, while the INDEXER, which dispatches the sub-command, registered it. A -// user could open a dispenser in a batch, fund it, and it would never dispense. That registry IS -// the address set the dispense half of output capture tests against, so splitting the two across -// two flag-days would leave the decoder half-batch-aware for a stretch of chain with nothing -// gained. One instant arms both; xchain-decoder/test/unit/batch_dispenser_registration.test.js -// drives the coupling rather than asserting it in prose. -// -// CONSENSUS-AFFECTING: it changes the set of rows written to transaction_outputs, which changes -// indexer verdicts, which changes the ledger. An ungated flip makes a from-genesis re-decode -// capture outputs the live fleet never captured, so the legacy top-level-only view stays live -// BELOW the gate and pre-flag-day history re-decodes byte-identically. -// -// NEVER ARM IT BELOW two sibling instants, both asserted in -// test/unit/batch_sub_command_output_capture_activation.test.js: -// * the indexer's FIX_OUTPUT_FANOUT. A BATCH is a data-bearing, non-COINPAY row, so the extra -// captured outputs fan it out to several rows, and BELOW that flag-day -// output_fanout.collapseOutputFanout treats that as a consensus-critical fault and HALTS the -// block. Arming this gate earlier does not merely change a verdict, it stops the chain. -// * the indexer's BATCH_ISSUANCE_LIMITS, which carries the batch-cumulative settlement ledger. -// Capture without that ledger lets N COINPAY sub-commands settle N obligations from ONE -// payment, which is the defect this spec's R5 closes; arming capture first would open it. -// -// null means DISARMED (never active), the fail-closed default: a network keeps the legacy -// top-level-only view until the operator ratifies an instant, chosen with the fleet's upgrade -// state in hand, because arming it too early forks the chain and arming it in the past rewrites -// agreed history. Mainnet is ARMED (below). testnet and regtest are genesis-on, matching BOTH -// sibling gates there (FIX_OUTPUT_FANOUT and BATCH_ISSUANCE_LIMITS are all-zeros off mainnet), -// so the venues exercise the sub-command path from block 0. -// -// DEPLOY DEADLINE, once an instant is armed: EVERY decoder on that network MUST be running the -// armed value before the instant, or the fleet splits on the first BATCH carrying a COINPAY or a -// Mode B DISPENSER. -// -// Vendored into xchain-decoder/src/protocol/constants.js AHEAD of the canonical copy in -// xchain-documentation/protocol/constants.js; the conformance suite requires that mirror to exist -// before mainnet may be armed. -const BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION = { - mainnet: 1786838400, // ARMED 2026-08-14 (operator, pre-launch): 2026-08-16T00:00:00Z, the - // SAME instant as BATCH_ISSUANCE_LIMITS carries in the indexer. One - // decision, one boundary: arming the issuance rework WITHOUT this one - // ships a mainnet where a batched COINPAY spends the coin and settles - // nothing, and a batched DISPENSER create never dispenses, because - // capture would still read only the top-level ACTION name. DEPLOY - // DEADLINE: every decoder on mainnet must run this value BEFORE the - // instant, or the fleet splits on the first BATCH carrying a COINPAY - // or a Mode B DISPENSER. - testnet: 0, - regtest: 0, -}; - -// Taproot envelope. - -// ENVELOPE_MAX_PAYLOAD: the Taproot-envelope payload ceiling, PER-ENCODING by -// design (legacy lanes keep MAX_ACTION_DATA_LENGTH; a global raise would -// multiply the chunk-lane abuse ceiling ~50x). Measures the REASSEMBLED -// envelope payload byte length after concatenation of the payload pushes, -// before parse; the envelope's own 520-byte push framing is NOT counted (the -// legacy constant, by contrast, is framing-inclusive of the single on-chain -// push). -// DERIVED FROM WEIGHT: the binding limit is Bitcoin Core's -// MAX_STANDARD_TX_WEIGHT of 400,000 WU, not a round byte count. 400,000 payload -// bytes build a 402,789 WU reveal, which is non-standard and unrelayable; the -// true maxima are 397,228 (P2WPKH change) and 397,009 (P2TR change + floor pad). -// 390,000 sits 7,050 WU under the limit in the worst reveal shape. Re-derive it -// from the weight limit if it ever changes; do not pick a number. -// Enforced identically in the decoder's block and mempool paths, mirrored by -// the encoder validator. Canonical source: xchain-documentation/protocol/ -// constants.js (ENVELOPE_MAX_PAYLOAD). -const ENVELOPE_MAX_PAYLOAD = 390000; - -// ENVELOPE_RECOGNITION_ACTIVATION (Taproot-envelope spec §7): the LOCAL block height -// at/above which the decoder recognizes Taproot-envelope reveals as -// action-bearing transactions, per host chain and network. Recognition changes -// what counts as an action (and §3.8's mixed-carrier/multi-envelope rejections -// activate at the same height), so it is fleet-deterministic: every decoder -// instance for a given chain+network MUST flip at the same height or the fleet -// forks on the first envelope (or the first mixed-carrier tx). Keyed on each -// chain's OWN local block height (like STATE_COMMITMENT_ACTIVATION), because -// recognition happens while parsing that chain's blocks; DOGE has no segwit, -// hence no Taproot and no envelope, so its entry is null (never active) and -// must stay null. Below the height the decoder behaves EXACTLY as shipped: a -// pre-flag tx containing an envelope plus an OP_RETURN action replays as the -// OP_RETURN action, exactly as the fleet indexed it live. -// -// The mainnet heights were pinned 2026-08-02 against a MEASURED tip (BTC 960812, -// LTC 3153356) with ~6 hours of margin over a redeploy train that takes about an -// hour. Re-pinning an already-deployed, already-armed cohort is done by moving the -// constant, never by rebasing the code. testnet/regtest stay genesis-active: this -// gate only ever applied to mainnet. -// -// DEPLOY DEADLINE: EVERY decoder on BTC and LTC mainnet MUST be running this -// constant before its height or the fleet forks on the first envelope (or the first -// mixed-carrier tx, which the §3.8 rejections start refusing at exactly this -// height). Rollout order within any venue: decoder before encoder, per the standing -// coupling rule. Verify the fleet by reading the armed map out of each RUNNING -// container rather than out of this file. -const ENVELOPE_RECOGNITION_ACTIVATION = { - BTC: { mainnet: 960850, testnet: 0, regtest: 0 }, - LTC: { mainnet: 3153500, testnet: 0, regtest: 0 }, - DOGE: { mainnet: null, testnet: null, regtest: null }, -}; - -// ENVELOPE_CARRIER_RECOGNITION_ACTIVATION (Taproot-envelope spec §3.8): the LOCAL block -// height at/above which the decoder counts a RECOGNIZED but payload-free carrier as a -// mixed carrier. Below it, arbitration infers carrier presence from accumulated payload -// bytes, so an OP_RETURN that deobfuscates to exactly the XCHN magic and nothing else -// contributes zero bytes and the envelope is still accepted as an action - while §3.8 -// says an envelope mixed with any other carrier is not an action. That is a divergence -// against any implementation written from the published rule. -// -// Its own height, separate from ENVELOPE_RECOGNITION_ACTIVATION, because that gate is -// already ARMED on BTC and LTC mainnet: §3.8 arbitration has been live consensus since -// 2026-08-02, so changing what it refuses is a second recognition change and every -// decoder must flip at the same height or the fleet forks. Below the height the decoder -// behaves EXACTLY as shipped, so replay of indexed history is byte-identical. -// -// The mainnet entries are deliberately UNPINNED (null = never active). Pinning them -// against a measured tip, with the redeploy train's margin, is an operator decision and -// a deploy-train act, not a code edit made in passing. testnet/regtest are genesis-active, -// matching the sibling gate above: recognition itself has been genesis-active there, so -// the refusal rule the spec states applies to those chains from genesis too. -// -// DEPLOY DEADLINE (once pinned): EVERY decoder on that chain+network MUST be running the -// pinned height before it, or the fleet forks on the first envelope carrying a -// marker-only XCHN OP_RETURN. Verify the fleet by reading the armed map out of each -// RUNNING container rather than out of this file. -const ENVELOPE_CARRIER_RECOGNITION_ACTIVATION = { - BTC: { mainnet: null, testnet: 0, regtest: 0 }, - LTC: { mainnet: null, testnet: 0, regtest: 0 }, - DOGE: { mainnet: null, testnet: null, regtest: null }, -}; +const actionSize = require('./constants/action_size.js'); +const vmCrossCall = require('./constants/vm_cross_call.js'); +const batchLimits = require('./constants/batch_limits.js'); +const activation = require('./constants/activation.js'); +const dispenserWideExpiration = require('./constants/dispenser_wide_expiration.js'); // VALID_FIAT_CODES: the accepted FIAT_CODE allow-list for PRICE actions. The indexer's // config['FIATS'] keys (xchain-indexer/src/config.js) are the on-chain arbiter; this list @@ -676,39 +74,43 @@ const PRICE_MAX = 10_000_000_000; const ORACLE_DEVIATION_THRESHOLD = 0.05; module.exports = { - MAX_ACTION_DATA_LENGTH, - OP_RETURN_PUSH_OVERHEAD, - MAX_CODE_SIZE, - MAX_DEPLOY_CHUNKS, - MAX_DEPLOYCHUNK_PART_BYTES, - VM_MAX_CALL_DEPTH, - VM_MIN_CALL_GAS, - XCALL_MIN_GAS, - XCALL_MAX_GAS, - XCALL_MAX_HOPS, - XCALL_MIN_DEADLINE_BLOCKS, - XCALL_MAX_DEADLINE_BLOCKS, - XCALL_MAX_RETURN_BYTES, - XCALL_MAX_CALLS_PER_BLOCK, - ATTEST_MAX_EXPIRIES_PER_BLOCK, - THRESHOLD_SCALE, - STAKE_WEIGHTED_QUORUM_ACTIVATION, - EQUIV_HEADER_ACTIVATION, - STATE_COMMITMENT_ACTIVATION, - CHECKPOINT_COMMITMENT_ACTIVATION, - ANCHOR_REWARD_ACTIVATION, - ANCHOR_REWARD_AMOUNT, - ARCHIVE_REWARD_ACTIVATION, - ARCHIVE_REWARD_AMOUNT, - CROSS_CHAIN_ROYALTY_ACTIVATION, - ORACLE_FEE_OUTPUT_ACTIVATION, - ORACLE_FEE_SET_CAPTURE_ACTIVATION, - DISPENSER_EXPIRY_REALIGN_ACTIVATION, - DISPENSER_CANCEL_GRACE_ACTIVATION, - BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION, - ENVELOPE_MAX_PAYLOAD, - ENVELOPE_RECOGNITION_ACTIVATION, - ENVELOPE_CARRIER_RECOGNITION_ACTIVATION, + MAX_ACTION_DATA_LENGTH: actionSize.MAX_ACTION_DATA_LENGTH, + OP_RETURN_PUSH_OVERHEAD: actionSize.OP_RETURN_PUSH_OVERHEAD, + MAX_CODE_SIZE: actionSize.MAX_CODE_SIZE, + MAX_DEPLOY_CHUNKS: batchLimits.MAX_DEPLOY_CHUNKS, + MAX_DEPLOYCHUNK_PART_BYTES: batchLimits.MAX_DEPLOYCHUNK_PART_BYTES, + VM_MAX_CALL_DEPTH: vmCrossCall.VM_MAX_CALL_DEPTH, + VM_MIN_CALL_GAS: vmCrossCall.VM_MIN_CALL_GAS, + XCALL_MIN_GAS: vmCrossCall.XCALL_MIN_GAS, + XCALL_MAX_GAS: vmCrossCall.XCALL_MAX_GAS, + XCALL_MAX_HOPS: vmCrossCall.XCALL_MAX_HOPS, + XCALL_MIN_DEADLINE_BLOCKS: vmCrossCall.XCALL_MIN_DEADLINE_BLOCKS, + XCALL_MAX_DEADLINE_BLOCKS: vmCrossCall.XCALL_MAX_DEADLINE_BLOCKS, + XCALL_MAX_RETURN_BYTES: vmCrossCall.XCALL_MAX_RETURN_BYTES, + XCALL_MAX_CALLS_PER_BLOCK: vmCrossCall.XCALL_MAX_CALLS_PER_BLOCK, + ATTEST_MAX_EXPIRIES_PER_BLOCK: batchLimits.ATTEST_MAX_EXPIRIES_PER_BLOCK, + THRESHOLD_SCALE: batchLimits.THRESHOLD_SCALE, + STAKE_WEIGHTED_QUORUM_ACTIVATION: activation.STAKE_WEIGHTED_QUORUM_ACTIVATION, + EQUIV_HEADER_ACTIVATION: activation.EQUIV_HEADER_ACTIVATION, + STATE_COMMITMENT_ACTIVATION: activation.STATE_COMMITMENT_ACTIVATION, + CHECKPOINT_COMMITMENT_ACTIVATION: activation.CHECKPOINT_COMMITMENT_ACTIVATION, + ANCHOR_REWARD_ACTIVATION: activation.ANCHOR_REWARD_ACTIVATION, + ANCHOR_REWARD_AMOUNT: activation.ANCHOR_REWARD_AMOUNT, + ARCHIVE_REWARD_ACTIVATION: activation.ARCHIVE_REWARD_ACTIVATION, + ARCHIVE_REWARD_AMOUNT: activation.ARCHIVE_REWARD_AMOUNT, + CROSS_CHAIN_ROYALTY_ACTIVATION: activation.CROSS_CHAIN_ROYALTY_ACTIVATION, + ORACLE_FEE_OUTPUT_ACTIVATION: activation.ORACLE_FEE_OUTPUT_ACTIVATION, + ORACLE_FEE_SET_CAPTURE_ACTIVATION: activation.ORACLE_FEE_SET_CAPTURE_ACTIVATION, + DISPENSER_EXPIRY_REALIGN_ACTIVATION: activation.DISPENSER_EXPIRY_REALIGN_ACTIVATION, + DISPENSER_CANCEL_GRACE_ACTIVATION: activation.DISPENSER_CANCEL_GRACE_ACTIVATION, + DISPENSER_PURGE_GRACE_ACTIVATION: activation.DISPENSER_PURGE_GRACE_ACTIVATION, + DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION: activation.DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION, + DISPENSER_WIDE_EXPIRATION_ACTIVATION: dispenserWideExpiration.DISPENSER_WIDE_EXPIRATION_GATES, + EXACT_INTEGER_EXPIRATION_ACTIVATION: activation.EXACT_INTEGER_EXPIRATION_ACTIVATION, + BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION: activation.BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION, + ENVELOPE_MAX_PAYLOAD: actionSize.ENVELOPE_MAX_PAYLOAD, + ENVELOPE_RECOGNITION_ACTIVATION: activation.ENVELOPE_RECOGNITION_ACTIVATION, + ENVELOPE_CARRIER_RECOGNITION_ACTIVATION: activation.ENVELOPE_CARRIER_RECOGNITION_ACTIVATION, VALID_FIAT_CODES, GAS_TICK, PRICE_MAX, diff --git a/src/protocol/constants/action_size.js b/src/protocol/constants/action_size.js new file mode 100644 index 00000000..899b9854 --- /dev/null +++ b/src/protocol/constants/action_size.js @@ -0,0 +1,63 @@ +/********************************************************************* + * + * 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. + * + **********************************************************************/ + +// Maximum *compiled* on-chain ACTION push, in bytes. +// +// This is measured against the reassembled script push as it appears on +// chain (i.e. the OP_PUSHDATA-prefixed buffer, BEFORE bitcoin.script.decompile +// strips the push prefix. The indexing decoder is the protocol arbiter: it +// drops any transaction whose compiled ACTION push exceeds this value, so the +// encoder must enforce the identical compiled-size ceiling. A transaction the +// encoder produces above this size would be silently dropped by every node. +const MAX_ACTION_DATA_LENGTH = 8192; + +// Bytes added by the OP_PUSHDATA2 push prefix (1-byte opcode + 2-byte +// little-endian length) when a 256..65535-byte payload is compiled into the +// on-chain script. For a single such push the compiled length is therefore +// (decoded payload bytes + OP_RETURN_PUSH_OVERHEAD); smaller payloads use a +// 1- or 2-byte prefix, and multi-segment encodings add one prefix per segment. +// This is why the authoritative cap is enforced on the *compiled* length, not +// on the decoded character count. +const OP_RETURN_PUSH_OVERHEAD = 3; + +// Maximum smart-contract source code size, in bytes (64 KiB). Enforced by the +// SDK (pre-flight validation), the indexer (DEPLOY processing) and the VM +// (isolate limit). These were each declared independently and are kept in +// lockstep by the same regression suite. +const MAX_CODE_SIZE = 65536; + +// ENVELOPE_MAX_PAYLOAD: the Taproot-envelope payload ceiling, PER-ENCODING by +// design (legacy lanes keep MAX_ACTION_DATA_LENGTH; a global raise would +// multiply the chunk-lane abuse ceiling ~50x). Measures the REASSEMBLED +// envelope payload byte length after concatenation of the payload pushes, +// before parse; the envelope's own 520-byte push framing is NOT counted (the +// legacy constant, by contrast, is framing-inclusive of the single on-chain +// push). +// DERIVED FROM WEIGHT: the binding limit is Bitcoin Core's +// MAX_STANDARD_TX_WEIGHT of 400,000 WU, not a round byte count. 400,000 payload +// bytes build a 402,789 WU reveal, which is non-standard and unrelayable; the +// true maxima are 397,228 (P2WPKH change) and 397,009 (P2TR change + floor pad). +// 390,000 sits 7,050 WU under the limit in the worst reveal shape. Re-derive it +// from the weight limit if it ever changes; do not pick a number. +// Enforced identically in the decoder's block and mempool paths, mirrored by +// the encoder validator. Canonical source: xchain-documentation/protocol/ +// constants.js (ENVELOPE_MAX_PAYLOAD). +const ENVELOPE_MAX_PAYLOAD = 390000; + +module.exports = { + MAX_ACTION_DATA_LENGTH, + OP_RETURN_PUSH_OVERHEAD, + MAX_CODE_SIZE, + ENVELOPE_MAX_PAYLOAD, +}; diff --git a/src/protocol/constants/activation.js b/src/protocol/constants/activation.js new file mode 100644 index 00000000..d41157a0 --- /dev/null +++ b/src/protocol/constants/activation.js @@ -0,0 +1,339 @@ +/********************************************************************* + * + * 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. + * + **********************************************************************/ + +// Stake-weighted quorum (STAKE_WEIGHTED_QUORUM). +// Consensus-critical activation: at/above this BTC-anchored snapshot_block the +// federation quorum becomes stake-WEIGHTED (signers' summed source stake must +// exceed 2/3 of total active snapshot stake) instead of count-based (2f+1 of the +// pubkey COUNT). +// +// Keyed on the BTC `snapshot_block` carried by every settlement/checkpoint +// canonical (NOT each chain's local processing height) so the hub and the BTC, +// LTC and DOGE indexers all flip on the SAME anchor. A per-chain local-height +// gate would fork: one snapshot_block lands at different local heights per chain. +// The `network` is also taken from the row, so the gate is env-independent. +// +// Enforced IDENTICALLY by the hub (every PBFT tally engine), the indexer +// (every settlement-signature gate + recovery), and the sdk/explorer/sync +// verifiers. All five keep a local copy of this map; the cross-service +// regression suite asserts they equal these values, so the activation height +// can never silently diverge (a divergence forks the chain). +// +// mainnet is ARMED (2026-07-07) to a concrete near-term height: 961000, the +// BTC-anchored flag-day at which mainnet flips from the count-based quorum +// rule to stake-weighted. BTC anchor ~2026-08-04; hub + ALL indexers (+ +// sdk/explorer/sync copies) MUST deploy before this height. testnet/regtest +// activate at genesis so the e2e / regtest stack exercises stake-weighting +// from block 0. +const STAKE_WEIGHTED_QUORUM_ACTIVATION = { + mainnet: 961000, // ARMED 2026-07-07: BTC anchor ~2026-08-04; deploy hub + ALL indexers (+ sdk/explorer/sync copies) before this height + testnet: 0, + regtest: 0, +}; + +// EQUIV_HEADER_ACTIVATION: the BTC-anchored flag-day at/above which every +// consensus canonical is prefixed with a uniform signed header +// `EQUIV|||||`. This is consensus-breaking (it changes the +// signed preimage of every settlement/checkpoint/price/attestation signature + the config-change +// PBFT canonical), so it is gated, kept byte-identical to the local copies in +// xchain-{hub,indexer,sdk,explorer,sync}/src/equivocation_header.js by the +// cross-service regression suite, and must deploy hub + ALL indexers atomically. Its sole +// consumer is the SLASH v0 equivocation-slashing action, which is only constructible from +// post-flag-day (header-carrying) messages. Same ARMED height and deploy-by convention as +// STAKE_WEIGHTED_QUORUM_ACTIVATION: mainnet is armed to 961000 (2026-07-07; BTC anchor +// ~2026-08-04), not a disabled placeholder. +const EQUIV_HEADER_ACTIVATION = { + mainnet: 961000, // ARMED 2026-07-07: BTC anchor ~2026-08-04; deploy hub + ALL indexers (+ sdk/explorer/sync copies) before this height + testnet: 0, + regtest: 0, +}; + +// STATE_COMMITMENT_ACTIVATION (light-client SPV, spec §6.4): the flag-day at/above which +// each indexer computes + commits the additive per-block `state_root` (balances+stakes SMT) +// and `block_merkle_root`. ADDITIVE (the three consensus block hashes + BLOCK_HASH_VERSION are +// untouched), so it is not consensus-breaking by itself; it only adds new committed roots that +// the xchain-sync follower recomputes and HALTS on if they diverge. UNLIKE the two maps above, +// this gates on the chain's OWN local block_index (each chain starts committing its own per-block +// root at its own height); the Phase 2 checkpoint/ANCHOR extension that SIGNS these roots gates on +// snapshot_block. Kept byte-identical to the local copies in xchain-indexer/src/ +// state_commitment_activation.js + xchain-sync/src/state_commitment_activation.js (and xchain-hub +// at Phase 2) by the cross-service regression suite. ARMED MID-CHAIN 2026-07-07 with per-chain +// ':' keys (one shared height cannot fit BTC ~957k and DOGE ~6.28M at once; bare +// network key remains for regtest; coin-less mainnet/testnet lookups stay inert). Same heights +// as the two state-hash gate maps, so ONE deploy-by date governs all Cohort-C flips; each height +// precedes the Cohort-B BTC anchor (961000) as the checkpoint-commitment ordering requires. +const STATE_COMMITMENT_ACTIVATION = { + 'BTC:mainnet': 958500, // ARMED 2026-07-07 at tip 957062; ~10 days of margin + 'LTC:mainnet': 3143000, // ARMED 2026-07-07 at tip 3138154; ~8 days + 'DOGE:mainnet': 6291000, // ARMED 2026-07-07 at tip 6280094; ~7.5 days + 'BTC:testnet': 145000, // ARMED 2026-07-07 at tip 143299 + 'LTC:testnet': 4805000, // ARMED 2026-07-07 at tip 4797675 + 'DOGE:testnet': 67000000, // ARMED 2026-07-07 at tip 66498605 (fast chain, wide margin) + regtest: 0, // armed from genesis: fresh regtest stacks exercise the roots end to end +}; + +// CHECKPOINT_COMMITMENT_ACTIVATION (light-client SPV, spec §6.1/§6.3, Phase 2): the flag-day at/above +// which the quorum-signed checkpoint canonical (and the on-chain ANCHOR) COMMIT the additive +// `state_root` + `block_merkle_root` (with their version bytes) that STATE_COMMITMENT_ACTIVATION made +// the indexer compute in Phase 1. Post-flag-day the checkpoint canonical string gains +// `|STATE_ROOT|STATE_ROOT_VERSION|BLOCK_MERKLE_ROOT|BLOCK_MERKLE_VERSION` and a new ANCHOR v3 carries +// the roots on DOGE; pre-flag-day both keep their old shape and the roots are absent. Consensus-relevant +// for signature verification (the signed preimage changes), so it must deploy hub + ALL indexers + the +// SDK/explorer verifiers atomically. +// +// UNLIKE STATE_COMMITMENT_ACTIVATION (which gates on each chain's OWN local block_index, since each chain +// computes its own per-block root), this gates on the BTC-anchored `snapshot_block` carried by every +// checkpoint canonical, exactly like STAKE_WEIGHTED_QUORUM_ACTIVATION / EQUIV_HEADER_ACTIVATION, so the +// hub and the BTC/LTC/DOGE indexers all flip the SIGNED shape on the same anchor. The operator MUST pick +// a snapshot_block at/after which every checkpointed chain is already past its own STATE_COMMITMENT +// flag-day (else the engine would have no roots to sign). Kept byte-identical to the local copies in +// xchain-{hub,indexer,sdk,explorer,sync}/src/checkpoint_commitment_activation.js (sync consumes it at +// checkpoint.js to decide whether to expect the roots) by the cross-service regression suite. Same +// ARMED height and deploy-by convention as the maps above: mainnet is armed to 961000 +// (2026-07-07; BTC anchor ~2026-08-04), not a disabled placeholder. +const CHECKPOINT_COMMITMENT_ACTIVATION = { + mainnet: 961000, // ARMED 2026-07-07: BTC anchor ~2026-08-04; deploy hub + ALL indexers (+ sdk/explorer/sync copies) before this height + testnet: 146000, // ARMED 2026-07-22: first BTC-testnet anchor past all three STATE_COMMITMENT testnet thresholds; was 0, which forced the SPV root suffix from testnet genesis before the indexer computes roots, so the hub refused to sign every testnet checkpoint + regtest: 0, +}; + +// ANCHOR_REWARD_ACTIVATION (anchor-reward re-derivation): the flag-day at/above which the validator +// anchor reward stops being TRUSTED from the hub's `pushvalidatorrewards` JSON-RPC and is instead +// DERIVED by every indexer from the on-chain ANCHOR bytes. Post-flag-day the hub emits a publisher- +// bearing ANCHOR (v4 rootless / v5 root-bearing) carrying the elected publisher pubkey plus a 2f+1 +// `oracle_publish` attestation (XANCPUB) over the reward tuple; the indexer verifies that quorum and +// credits the publisher with ANCHOR_REWARD_AMOUNT (a frozen consensus constant, NEVER from the wire). +// Below the flag-day the old push path stands and v4/v5 anchors are rejected. Consensus-relevant (the +// credited reward becomes a COLLECT-spendable per-block ledger row), so it must deploy hub + ALL +// indexers atomically. Like CHECKPOINT_COMMITMENT_ACTIVATION / STAKE_WEIGHTED_QUORUM_ACTIVATION it gates +// on the BTC-anchored `snapshot_block` carried by every ANCHOR canonical. Kept byte-identical to the +// local copies in xchain-{hub,indexer}/src/anchor_reward_activation.js by the cross-service regression +// suite. Same ARMED height and deploy-by convention as the maps above: mainnet is armed to 961000 +// (2026-07-07; BTC anchor ~2026-08-04), not a disabled placeholder. +const ANCHOR_REWARD_ACTIVATION = { + mainnet: 961000, // ARMED 2026-07-07: BTC anchor ~2026-08-04; deploy hub + ALL indexers (+ sdk/explorer/sync copies) before this height + testnet: 0, + regtest: 0, +}; + +// ANCHOR_REWARD_AMOUNT: the frozen validator anchor-publish reward, signed into the XANCPUB attestation +// by the hub and re-derived by the indexer (never from the wire). Changing it is itself a flag-day. +const ANCHOR_REWARD_AMOUNT = '10.00000000'; + +// ARCHIVE_REWARD_ACTIVATION (archive-reward re-derivation): the flag-day at/above which the +// anchor_archive reward stops riding the key-authenticated `pushvalidatorrewards` rail and is instead +// DERIVED by every indexer from the on-chain ANCHOR v6 bytes (the v1 archive anchor plus the same +// PUBLISHER + 2f+1 XANCPUB attestation tail as v4/v5, attested over an 'anchor_archive' canonical +// keyed on MATCH_BATCH_SEQ). This retires the last insider-with-key reward-forge surface the +// per-chain ANCHOR_REWARD flag-day left open. Below the flag-day the legacy v1 + push path stands +// and v6 anchors are rejected. Consensus-relevant, same deploy rules and snapshot_block gating as +// ANCHOR_REWARD_ACTIVATION; kept byte-identical to the local copies in +// xchain-{hub,indexer}/src/anchor_reward_activation.js by the cross-service regression suite. +const ARCHIVE_REWARD_ACTIVATION = { + mainnet: 963000, // ARMED 2026-07-16, RE-PINNED 2026-08-12 off 969500 onto the pre-launch-freeze train boundary (tip 959,853 on 07-27 at ~144 blocks/day + 21d); deploy every consumer before this era + testnet: 0, + regtest: 0, +}; + +// ARCHIVE_REWARD_AMOUNT: the frozen archive-publish reward, signed into the archive XANCPUB +// attestation by the hub and re-derived by the indexer (never from the wire). Kept equal to the +// hub's historical default (ANCHOR_REWARD_PER_PUBLISH). Changing it is itself a flag-day. +const ARCHIVE_REWARD_AMOUNT = '10.00000000'; + +// CROSS_CHAIN_ROYALTY_ACTIVATION (cross-chain royalty match-canonical): the flag-day at/above which +// the validator-signed XMATCH canonical carries the matched orders' royalty payout legs +// (a_payout_legs / b_payout_legs), so a colluding hub cannot strip a royalty from a cross-chain +// match; below it the canonical stays byte-identical to the legacy format, so pre-existing +// signatures keep verifying. Consensus-relevant (the signed preimage changes), so it must deploy +// hub + ALL indexers atomically. Like CHECKPOINT_COMMITMENT_ACTIVATION / ANCHOR_REWARD_ACTIVATION +// it gates on the BTC-anchored `snapshot_block` carried by every XMATCH canonical. The CREATE-side +// acceptance rule (deny a royalty-bearing cross-chain listing while enforcement is impossible) is +// gated separately by the CROSS_CHAIN_ROYALTY entry in the indexer's protocol_changes.js; the +// operator MUST flip this canonical gate first or together with it, NEVER create-side first +// (create-side ON with canonical OFF would put the legs in unsigned mirror fields, the exact +// tamper hole the legs-in-canonical design closes). Kept byte-identical to the local copies in +// xchain-{hub,indexer}/src/cross_chain_royalty_activation.js by the cross-service regression +// suite. Same ARMED height and deploy-by convention as the maps above: mainnet is armed to +// 961000 (2026-07-07; BTC anchor ~2026-08-04), not a disabled placeholder. +const CROSS_CHAIN_ROYALTY_ACTIVATION = { + mainnet: 961000, // ARMED 2026-07-07: BTC anchor ~2026-08-04; deploy hub + ALL indexers before this height + testnet: 0, + regtest: 0, +}; + +// ORACLE_FEE_OUTPUT_ACTIVATION (PRICE v1 oracle usage fee): the flag-day +// at/above which the DECODER persists a native-coin output paying a DISPENSER's +// ORACLE_ADDRESS into transaction_outputs, so the indexer's validateOracleFee can see the +// fee that was actually paid. Keyed on BLOCK TIME (not height) because dispensers settle on +// BTC, LTC and DOGE, whose heights diverge; compared with the same >= semantics the indexer's +// protocol_changes gates use. +// +// The mainnet value is NOT free to choose: capturing a second output on a data-bearing +// transaction makes it fan out to two rows in getDecoderBlockData, and BELOW the indexer's +// FIX_OUTPUT_FANOUT flag-day (protocol_changes.js, mainnet block_time 1786060800) such a +// transaction is a consensus-critical fault that HALTS the block. This gate must therefore +// never precede FIX_OUTPUT_FANOUT; it is armed to exactly the same instant so capture begins +// in the same block the collapse does. Below it a Mode B create against a fee-bearing oracle +// is rejected with 'missing oracle fee output' whether or not the payer paid, which is the +// fail-closed direction and costs nothing while no mainnet chain holds a dispenser. +// testnet/regtest are genesis-on, matching FIX_OUTPUT_FANOUT there. +// +// Vendored byte-equal into xchain-decoder/src/protocol/constants.js; the parity suites keep +// the two copies and the indexer's FIX_OUTPUT_FANOUT timestamp in lockstep. +const ORACLE_FEE_OUTPUT_ACTIVATION = { + mainnet: 1786060800, // 2026-08-07 00:00:00 UTC, the contract-era flag-day FIX_OUTPUT_FANOUT rides + testnet: 0, + regtest: 0, +}; + +// Enables set-membership oracle-fee capture for all open Mode B dispensers owned by +// the source. The legacy single-row lookup can miss a refill's oracle when the source +// owns multiple dispensers. This block-time gate must never precede +// ORACLE_FEE_OUTPUT_ACTIVATION because it changes persisted outputs. Mainnet is armed +// at that base gate after a zero-dispenser history audit; testnet and regtest start at +// genesis. Every decoder must deploy an armed value before its activation instant. +const ORACLE_FEE_SET_CAPTURE_ACTIVATION = { + mainnet: 1786060800, // ARMED by the 2026-09-09 ruling at its base gate's own instant, the earliest the ordering above permits; identity on the indexed mainnet history (0 dispensers, measured 2026-09-09) + // ARMED AT GENESIS (instant 0 = always in force), operator-ratified 2026-08-18 under the + // pre-launch ruling that every feature must be ACTIVE on testnet. This gate fixes a defect + // that spends a payer native coin and gives nothing back, so a public testnet WILL hit it. + // Safe at 0 because testnet decoder/indexer state is REBUILT from the chain before launch. + testnet: 0, + regtest: 0, +}; + +// Moves decoder soft-expiry from block start to after the transaction loop, matching +// the indexer's measurement point so boundary-block payments are retained. This +// block-time gate changes persisted outputs. Genesis activation was approved after a +// zero-dispenser and zero-dispense history audit; every decoder must deploy an armed +// value before its activation instant. +const DISPENSER_EXPIRY_REALIGN_ACTIVATION = { + mainnet: 0, // ARMED at genesis by the 2026-09-09 ruling: identity on the indexed mainnet history (0 dispensers, 0 dispenses, measured 2026-09-09) + // ARMED AT GENESIS (instant 0 = always in force), operator-ratified 2026-08-18 under the + // pre-launch ruling that every feature must be ACTIVE on testnet. This gate fixes a defect + // that spends a payer native coin and gives nothing back, so a public testnet WILL hit it. + // Safe at 0 because testnet decoder/indexer state is REBUILT from the chain before launch. + testnet: 0, + regtest: 0, +}; + +// Keeps just-expired cancelling dispensers in the payment capture set for the +// indexer's cancellation grace window. Only capture eligibility widens; the expiry +// mark, edit mirror, oracle lookup, and purge timing stay unchanged. This block-time +// gate changes persisted outputs. Genesis activation was approved after a +// zero-dispenser and zero-dispense history audit; every decoder must deploy an armed +// value before its activation instant. +const DISPENSER_CANCEL_GRACE_ACTIVATION = { + mainnet: 0, // ARMED at genesis by the 2026-09-09 ruling: identity on the indexed mainnet history (0 dispensers, 0 dispenses, measured 2026-09-09) + // ARMED AT GENESIS (instant 0 = always in force), matching the sibling + // DISPENSER_EXPIRY_REALIGN_ACTIVATION under the pre-launch ruling that every feature must + // be ACTIVE on testnet. This gate closes a defect that spends a payer's native coin and + // gives nothing back, so a public testnet WILL hit it. Safe at 0 because testnet + // decoder/indexer state is REBUILT from the chain before launch. + testnet: 0, + regtest: 0, +}; + +// DISPENSER_PURGE_GRACE_ACTIVATION: the block-time boundary at/above which the +// decoder keeps a soft-expired dispenser until its cancellation grace period has +// passed instead of hard-purging it at the raw expiration time. It is a separate +// gate from DISPENSER_CANCEL_GRACE_ACTIVATION because capture eligibility and row +// retention change independently. +// +// Mainnet and testnet remain unarmed so existing history is not reinterpreted. +// Regtest is genesis-active so the grace-aware purge path is exercised there. +const DISPENSER_PURGE_GRACE_ACTIVATION = { + mainnet: 9999999999, + testnet: 9999999999, + regtest: 0, +}; + +const DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION = { + mainnet: 9999999999, + testnet: 9999999999, + regtest: 0, +}; + +// EXACT_INTEGER_EXPIRATION_ACTIVATION: the block-time boundary at/above which the +// decoder tests a DISPENSER create or edit EXPIRATION wire spelling in exact decimal +// space as well as checking its Number conversion. The legacy +// Number.isSafeInteger(Number(token)) guard can +// accept a fractional wire value when its fractional tail is below Number precision, +// while the indexer's exact integer rule rejects the same action. Registering or +// extending that dispenser here would then make the decoder capture payments for an +// action the indexer did not accept. +// +// This recognition change is consensus-affecting because it can remove an address from +// the decoder's payment-output capture set. Every network therefore remains UNARMED +// until an operator audit establishes a safe flag-day and the matching indexer gate is +// armed. null means never active; unknown networks and invalid block times fail closed. +const EXACT_INTEGER_EXPIRATION_ACTIVATION = { + mainnet: null, + testnet: null, + regtest: null, +}; + +// Enables output capture and open-dispenser registration from BATCH subcommands. +// Both must switch together because the registry supplies the dispenser capture set. +// This block-time gate changes persisted rows and must never precede either the +// indexer's FIX_OUTPUT_FANOUT or BATCH_ISSUANCE_LIMITS activation. Mainnet is armed +// below; testnet and regtest start at genesis. Every decoder must deploy the armed +// value before the first affected BATCH. +const BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION = { + mainnet: 1786838400, // ARMED 2026-08-14 for 2026-08-16T00:00:00Z, matching BATCH_ISSUANCE_LIMITS + testnet: 0, + regtest: 0, +}; + +// Enables Taproot-envelope action recognition at each chain's local height. It also +// activates mixed-carrier and multi-envelope rejection. DOGE remains disabled because +// it has no Taproot. Mainnet heights were pinned 2026-08-02; testnet and regtest start +// at genesis. Deploy every decoder before an armed height, decoder before encoder. +const ENVELOPE_RECOGNITION_ACTIVATION = { + BTC: { mainnet: 960850, testnet: 0, regtest: 0 }, + LTC: { mainnet: 3153500, testnet: 0, regtest: 0 }, + DOGE: { mainnet: null, testnet: null, regtest: null }, +}; + +// Makes a recognized payload-free carrier count in mixed-carrier arbitration. This is +// separate because envelope recognition is already armed on BTC and LTC mainnet. +// Mainnet stays unpinned pending an operator decision; testnet and regtest start at +// genesis. Deploy every decoder before pinning a mainnet height. +const ENVELOPE_CARRIER_RECOGNITION_ACTIVATION = { + BTC: { mainnet: null, testnet: 0, regtest: 0 }, + LTC: { mainnet: null, testnet: 0, regtest: 0 }, + DOGE: { mainnet: null, testnet: null, regtest: null }, +}; + +module.exports = { + STAKE_WEIGHTED_QUORUM_ACTIVATION, + EQUIV_HEADER_ACTIVATION, + STATE_COMMITMENT_ACTIVATION, + CHECKPOINT_COMMITMENT_ACTIVATION, + ANCHOR_REWARD_ACTIVATION, + ANCHOR_REWARD_AMOUNT, + ARCHIVE_REWARD_ACTIVATION, + ARCHIVE_REWARD_AMOUNT, + CROSS_CHAIN_ROYALTY_ACTIVATION, + ORACLE_FEE_OUTPUT_ACTIVATION, + ORACLE_FEE_SET_CAPTURE_ACTIVATION, + DISPENSER_EXPIRY_REALIGN_ACTIVATION, + DISPENSER_CANCEL_GRACE_ACTIVATION, + DISPENSER_PURGE_GRACE_ACTIVATION, + DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION, + EXACT_INTEGER_EXPIRATION_ACTIVATION, + BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION, + ENVELOPE_RECOGNITION_ACTIVATION, + ENVELOPE_CARRIER_RECOGNITION_ACTIVATION, +}; diff --git a/src/protocol/constants/batch_limits.js b/src/protocol/constants/batch_limits.js new file mode 100644 index 00000000..4c758a50 --- /dev/null +++ b/src/protocol/constants/batch_limits.js @@ -0,0 +1,71 @@ +/********************************************************************* + * + * 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. + * + **********************************************************************/ + +// ATTEST expiry sweep. +// Deterministic per-block cap on the ATTEST v0 deadline-expiry sweep. +// Each expired request synthesizes an ATTEST v2 action that flips the request to +// 'expired' and fires its callback, so an unbounded sweep lets a single block +// inherit an arbitrary backlog: one block's processing time (and its actions +// rows) becomes a function of how many requests happened to expire at once, +// which an attacker controls by batching requests with a common deadline. +// +// Overflow carries forward to the next block rather than being dropped: the +// selection is ordered (deadline_block ASC, action_index ASC), a TOTAL order +// because action_index is unique, so the same requests expire in the same order +// on every node, just spread across more blocks. Mirrors the XCALL sibling cap +// above in both value and carry-forward semantics. +// +// CONSENSUS-VISIBLE: the cap decides which block an expiry lands in, which moves +// actions rows, the contract hash and the checkpoint preimage. It ships ungated +// because the pre-launch fleet-wide replay recomputes all of it. +const ATTEST_MAX_EXPIRIES_PER_BLOCK = 25; + +// Token-gated content. +// Fixed fractional scale for comparing FILE.GATE_MIN_AMOUNT thresholds against a +// holder's balance. The wallet scales both sides to this many fractional digits +// as BigInt (packages/core THRESHOLD_SCALE); the indexer compares with mathjs +// bignumber. A threshold carrying MORE decimal places than this is +// unrepresentable on the wallet side, so the two implementations would disagree +// on the last digit for values neither considers malformed. The indexer therefore +// bounds a threshold's decimal places at min(gate tick divisibility, +// THRESHOLD_SCALE) rather than at divisibility alone. +// +// Cross-repo twin: xchain-wallet packages/core THRESHOLD_SCALE. These two must +// move together or the disagreement returns. +const THRESHOLD_SCALE = 18; + +// Chunked DEPLOY (DEPLOY v4 carriers + DEPLOY v2/v3 assemble). +// A contract whose base64(code) exceeds the single-tx budget is split across +// ordered DEPLOY v4 carrier actions and reassembled by a DEPLOY v2/v3 keyed on +// the CODE_HASH. Enforced by the indexer (deploy_chunk + deploy assembly) and +// the SDK (chunkHelper splitter) in lockstep. + +// Maximum number of chunks one DEPLOY may assemble. base64(MAX_CODE_SIZE) is +// ~87.4 KB; at the conservative per-chunk part budget below that is ~12 chunks, +// so 16 leaves headroom while bounding assembler work + chunk-table DoS. +const MAX_DEPLOY_CHUNKS = 16; + +// Maximum bytes of base64 code carried by a single DEPLOY v4 carrier's CODE_PART. +// Sized so the compiled v4 carrier action (action prefix + 64-char CODE_HASH + +// indices + the part) stays comfortably under MAX_ACTION_DATA_LENGTH including +// the OP_PUSHDATA2 prefix. The SDK splits at this size; the indexer rejects a +// larger part (belt-and-suspenders; the decoder already drops oversize pushes). +const MAX_DEPLOYCHUNK_PART_BYTES = 7800; + +module.exports = { + ATTEST_MAX_EXPIRIES_PER_BLOCK, + THRESHOLD_SCALE, + MAX_DEPLOY_CHUNKS, + MAX_DEPLOYCHUNK_PART_BYTES, +}; diff --git a/src/protocol/constants/dispenser_wide_expiration.js b/src/protocol/constants/dispenser_wide_expiration.js new file mode 100644 index 00000000..25b64713 --- /dev/null +++ b/src/protocol/constants/dispenser_wide_expiration.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. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + **********************************************************************/ + +'use strict'; + +// Block-time gate for mapping an indexer-valid unsigned 64-bit DISPENSER +// EXPIRATION onto the decoder's exact Number range. Mainnet and testnet keep +// the historical rejection until a fleet flag day is selected. Regtest is +// genesis-active so the widened recognition path is continuously exercised. +const DISPENSER_WIDE_EXPIRATION_GATES = { + mainnet: 9999999999, + testnet: 9999999999, + regtest: 0, +}; + +module.exports = { DISPENSER_WIDE_EXPIRATION_GATES }; diff --git a/src/protocol/constants/vm_cross_call.js b/src/protocol/constants/vm_cross_call.js new file mode 100644 index 00000000..8107433d --- /dev/null +++ b/src/protocol/constants/vm_cross_call.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. + * + **********************************************************************/ + +// Cross-contract calls (emit.execute). Maximum call depth: a user-submitted +// EXECUTE runs at depth 0; each emit.execute hop adds 1. Enforced by the VM at +// emit time and re-validated by the indexer when it processes the emission. +const VM_MAX_CALL_DEPTH = 4; + +// Minimum caller-funded gas reservation per emit.execute call. Bounds call-tree +// fan-out: every call costs at least (VM_EMISSION + VM_MIN_CALL_GAS) out of the +// caller's own gas budget. Enforced by the VM and the indexer in lockstep. +const VM_MIN_CALL_GAS = 5000; + +// Cross-CHAIN contract calls (emit.crossExecute / XCALL). +// Enforced by the VM at emit time and re-validated host-side by the indexer +// (processEmission + actions/xcall.js); the target chain re-validates the +// signed dispatch row before injecting. See protocol/Cross_Chain_Calls.md. + +// Target-side gas ceiling bounds. The injected execution is fee-less on the +// target chain (the caller pre-paid on the source chain), so the per-call cap +// is much tighter than the same-chain 1M execution ceiling. The minimum equals +// VM_MIN_CALL_GAS. +const XCALL_MIN_GAS = 5000; +const XCALL_MAX_GAS = 200000; + +// Cross-chain hop budget: a user-originated call is hop 1; a call emitted from +// a cross-chain-injected execution (or from a result callback) is hop 2; more +// requires a fresh user transaction. Bounds X→Y→X ping-pong, which is +// otherwise free after the first hop (injected executions have no fee payer). +const XCALL_MAX_HOPS = 2; + +// Source-chain deadline window (blocks). Must comfortably exceed both chains' +// relay confirmation depths plus federation rounds; expiry past deadline_block +// is synthesized deterministically by every source-chain indexer. +const XCALL_MIN_DEADLINE_BLOCKS = 10; +const XCALL_MAX_DEADLINE_BLOCKS = 4000; + +// Return payload cap, bytes (pre-base64). The payload is mirrored to every +// indexer and ANCHOR-archived on DOGE; an oversize return becomes status +// 'payload_too_large' with an EMPTY payload (deterministic. Never truncated). +const XCALL_MAX_RETURN_BYTES = 1024; + +// Deterministic per-block injection cap on each target chain. Overflow carries +// forward to the next block in (snapshot_block, call_id) order. Never dropped. +const XCALL_MAX_CALLS_PER_BLOCK = 25; + +module.exports = { + VM_MAX_CALL_DEPTH, + VM_MIN_CALL_GAS, + XCALL_MIN_GAS, + XCALL_MAX_GAS, + XCALL_MAX_HOPS, + XCALL_MIN_DEADLINE_BLOCKS, + XCALL_MAX_DEADLINE_BLOCKS, + XCALL_MAX_RETURN_BYTES, + XCALL_MAX_CALLS_PER_BLOCK, +}; diff --git a/src/protocol/dispenser_cancel_grace.js b/src/protocol/dispenser_cancel_grace.js index ec97c539..98beaac6 100644 --- a/src/protocol/dispenser_cancel_grace.js +++ b/src/protocol/dispenser_cancel_grace.js @@ -39,12 +39,19 @@ * contract in db.js retired). getAllOpenDispenserAddresses reads that mark time by joining * this decoder's own blocks table on expired_block_index. * + * THE BOUND NEEDS THE INDEXER'S CLOSE-DELAY CLOCK. Where protocol time is the raw header time + * (mainnet, regtest) the cancel block's header is no later than the mark's, so the bound holds + * as stated. On testnet protocol time is median-time-past, a cancel accepted before the mark + * can sit in a future-stamped block, and the bound holds only once the indexer measures its + * close delay on the cancel block's protocol time (its DISPENSER_DELAY_PROTOCOL_TIME gate), + * because protocol time never decreases and never exceeds the mark block's header time. + * * WHAT THE GRACE MOVES, AND WHAT IT MUST NOT. The widening applies to the CAPTURE SET only: * getAllOpenDispenserAddresses admits a row whose expiration is no older than the floor this * module computes. The soft-expire predicate, the expiry MARK, the extend mirror, the - * oracle-address resolution and the hard purge keep their current timing. That confines the - * change to the over-capture direction the advisory contract calls safe, because capture is a - * Set membership test that the indexer arbitrates afterwards. Delaying the MARK instead would + * oracle-address resolution keep their current timing. The hard purge changes only under its + * independent activation gate. That confines the change to the over-capture direction, because + * capture is a Set membership test that the indexer arbitrates afterwards. Delaying the MARK instead would * reach getOpenDispenserOracleAddressBySource, whose below-gate `ORDER BY ... LIMIT 1` would * then rank a dead row first; oracle-fee capture is a single-address EQUALITY test, so a wrong * pick captures NOTHING. That is the under-capture direction, a second funds-loss path rather @@ -57,7 +64,10 @@ 'use strict'; -const { DISPENSER_CANCEL_GRACE_ACTIVATION } = require('./constants.js') +const { + DISPENSER_CANCEL_GRACE_ACTIVATION, + DISPENSER_PURGE_GRACE_ACTIVATION, +} = require('./constants.js') // Seconds a soft-expired dispenser stays an eligible payment destination at/above the gate. // @@ -100,9 +110,25 @@ function cancelGraceFloor(consensusNetwork, blockTime){ return Number(blockTime) - DISPENSER_CANCEL_GRACE_SECONDS } +function isDispenserPurgeGraceActive(consensusNetwork, blockTime){ + const activation = DISPENSER_PURGE_GRACE_ACTIVATION[consensusNetwork] + if (typeof activation !== 'number') return false + const t = Number(blockTime) + if (!Number.isFinite(t)) return false + return t >= activation +} + +function purgeGraceFloor(consensusNetwork, blockTime){ + if (!isDispenserPurgeGraceActive(consensusNetwork, blockTime)) return null + return Number(blockTime) - DISPENSER_CANCEL_GRACE_SECONDS +} + module.exports = { DISPENSER_CANCEL_GRACE_ACTIVATION, + DISPENSER_PURGE_GRACE_ACTIVATION, DISPENSER_CANCEL_GRACE_SECONDS, isDispenserCancelGraceActive, cancelGraceFloor, + isDispenserPurgeGraceActive, + purgeGraceFloor, } diff --git a/src/protocol/dispenser_registration_collapse.js b/src/protocol/dispenser_registration_collapse.js new file mode 100644 index 00000000..c89bb006 --- /dev/null +++ b/src/protocol/dispenser_registration_collapse.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. + * + ********************************************************************** + * + * Collapse dispenser registrations by an optional caller-provided key. + * + ********************************************************************/ + +'use strict'; + +function collapseRegistrationsByKey(candidates, keyOf){ + if (!Array.isArray(candidates)) return [] + const collapsed = new Map() + for (const candidate of candidates){ + if (!candidate || !candidate.address) continue + const selectedKey = typeof keyOf === 'function' ? keyOf(candidate.address) : null + const key = typeof selectedKey === 'string' && selectedKey ? selectedKey : candidate.address + const existing = collapsed.get(key) + if (existing === undefined){ + collapsed.set(key, { + address: candidate.address, + sourceAddress: candidate.sourceAddress, + oracleAddress: candidate.oracleAddress || null, + expiration: candidate.expiration, + }) + continue + } + if (candidate.expiration > existing.expiration) + existing.expiration = candidate.expiration + if (!existing.oracleAddress && candidate.oracleAddress) + existing.oracleAddress = candidate.oracleAddress + } + return [...collapsed.values()] +} + +module.exports = { collapseRegistrationsByKey } diff --git a/src/protocol/fee_destination.js b/src/protocol/fee_destination.js index dff04101..0b08dc17 100644 --- a/src/protocol/fee_destination.js +++ b/src/protocol/fee_destination.js @@ -19,11 +19,13 @@ * The vendored coin registry (src/coins) supplies the consensus-pinned default, * so a stock deployment captures fee outputs with no operator env (previously * env-only: default installs captured nothing and LTC/DOGE native-fee - * validation failed closed downstream). A FEE_DESTINATION env override is - * honored on regtest ONLY; on mainnet AND testnet it is ignored with a warning, - * because fee-output capture feeds consensus-relevant fee acceptance and must - * not depend on operator env (same rule as the registry's per-coin override, - * which is likewise regtest-only). Testnet is an armed multi-operator federation + * validation failed closed downstream). The bare FEE_DESTINATION env is ignored + * with a warning on every recognized coin/network: the only override is the + * registry's regtest-only XCHAIN_FEE_DESTINATION__REGTEST, the same + * variable the indexer reads, so the capture side and the validation side of + * the native-fee seam always resolve one address. Fee-output capture feeds + * consensus-relevant fee acceptance and must not depend on operator env on + * mainnet or testnet either. Testnet is an armed multi-operator federation * whose consensus_pin hashes only the static bundle, so an env-resolved override * there escapes the freeze and would let two honest nodes capture different fee * outputs and diverge the block-hashed ledger, the identical fork mainnet is @@ -38,23 +40,31 @@ const logger = getLogger(); function resolveFeeDestination(networkName, envOverride) { const m = /^([a-z]+)-(mainnet|testnet|regtest)$/.exec(networkName || '') let pinned = null + let tick = null if (m) { try { - pinned = getCoinConfigByFullName(m[1], m[2]).addresses.FEE_DESTINATION || null + const coin = getCoinConfigByFullName(m[1], m[2]) + pinned = coin.addresses.FEE_DESTINATION || null + tick = coin.tick || null } catch (e) { // Unknown coin/network (e.g. test doubles): no registry default, env-only below. } } if (envOverride) { - // Honored on regtest ONLY. On mainnet AND testnet the consensus-pinned registry - // default wins (matches src/coins/index.js resolveFeeDestination and the indexer's - // config, both regtest-only): the override escapes the consensus_pin freeze, so two - // honest nodes with different env would capture different fee outputs and fork the - // block-hashed ledger. When there is no pinned default (unknown coin / test double, - // pinned === null) the override still resolves so those paths keep working. - if (m && m[2] !== 'regtest' && pinned) { - if (envOverride !== pinned) - logger.info('WARNING: FEE_DESTINATION env is set but IGNORED on ' + m[2] + '; using the consensus-pinned registry address.') + // On a recognized coin/network the registry value always wins, on regtest too. It already + // carries the regtest-only XCHAIN_FEE_DESTINATION__REGTEST override, the one variable + // the indexer reads, so capture and fee validation cannot resolve two different addresses. + // On mainnet AND testnet that value is the consensus pin: an env override there escapes the + // consensus_pin freeze, so two honest nodes with different env would capture different fee + // outputs and fork the block-hashed ledger. With no registry value (unknown coin / test + // double, pinned === null) the bare override still resolves so those paths keep working. + if (m && pinned) { + if (envOverride !== pinned) { + const redirect = m[2] === 'regtest' + ? 'set XCHAIN_FEE_DESTINATION_' + (tick || '') + '_REGTEST to redirect fees, so the indexer validates the same address.' + : 'using the consensus-pinned registry address.' + logger.info('WARNING: FEE_DESTINATION env is set but IGNORED on ' + m[2] + ' (registry resolves ' + pinned + '); ' + redirect) + } return pinned } return envOverride diff --git a/src/protocol/protocol_time.js b/src/protocol/protocol_time.js index 318e939f..c65474eb 100644 --- a/src/protocol/protocol_time.js +++ b/src/protocol/protocol_time.js @@ -59,26 +59,24 @@ function isProtocolTimeMtpActive(network){ // The median of the previous MEDIAN_TIME_SPAN block timestamps, Bitcoin-style. // -// `previousBlockTimes` is the timestamps of the blocks BELOW the one being -// resolved, in any order; only the newest MEDIAN_TIME_SPAN of them are used, so -// callers may hand over a longer window. Genesis and the blocks just above it -// have fewer than a full span available: Bitcoin medians whatever exists rather -// than failing, and so does this. +// NEWEST-FIRST CONTRACT: `previousBlockTimes` must be ordered by height +// descending. Only its first MEDIAN_TIME_SPAN entries may participate, even +// when an older block has a higher timestamp. Genesis and the blocks just above +// it have fewer than a full span available: Bitcoin medians whatever exists +// rather than failing, and so does this. // // Returns null when nothing usable is supplied, so callers fail closed onto the // raw stamp rather than medianing to NaN. function medianTimePast(previousBlockTimes){ if (!Array.isArray(previousBlockTimes)) return null const times = previousBlockTimes + .slice(0, MEDIAN_TIME_SPAN) .map(Number) .filter((time) => Number.isFinite(time) && time > 0) if (times.length === 0) return null - // Newest MEDIAN_TIME_SPAN first, then median by value. Sorting by value - // alone would median the wrong set once a caller passes a longer window. - times.sort((a, b) => b - a) - const span = times.slice(0, MEDIAN_TIME_SPAN).sort((a, b) => a - b) - return span[Math.floor(span.length / 2)] + times.sort((a, b) => a - b) + return times[Math.floor(times.length / 2)] } // The instant a block's time-keyed decoder gates should read. diff --git a/src/sql/dispenser_extension_undo.sql b/src/sql/dispenser_extension_undo.sql new file mode 100644 index 00000000..49c5d66d --- /dev/null +++ b/src/sql/dispenser_extension_undo.sql @@ -0,0 +1,24 @@ +--******************************************************************** +-- +-- 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. +-- +--******************************************************************** + +-- Local reorg bookkeeping, not replicated: the value each dispenser row held before the first +-- DISPENSE expiration extend of a block, so a reorg of that block can put it back. +CREATE TABLE IF NOT EXISTS dispenser_extension_undo ( + block_index BIGINT UNSIGNED NOT NULL, -- height of the block whose extend overwrote the row + tx_index BIGINT UNSIGNED NOT NULL, + address_id BIGINT UNSIGNED NOT NULL, + prior_expiration BIGINT UNSIGNED DEFAULT NULL, -- dispensers.expiration before the block's first extend + prior_expired_block_index BIGINT UNSIGNED DEFAULT NULL, -- dispensers.expired_block_index before the block's first extend + PRIMARY KEY(block_index, tx_index, address_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; diff --git a/src/sql/migrations/2026-06-02-widen-ids-to-bigint.sql b/src/sql/migrations/2026-06-02-widen-ids-to-bigint.sql index 065dc9c2..9dd5ca20 100644 --- a/src/sql/migrations/2026-06-02-widen-ids-to-bigint.sql +++ b/src/sql/migrations/2026-06-02-widen-ids-to-bigint.sql @@ -12,7 +12,7 @@ -- --******************************************************************** --- xchain:migration mode=manual +-- xchain:migration mode=manual deploy-precondition=required -- (manual: a one-time column TYPE change across all id/key columns -- run with -- the decoder stopped, take a backup first; see HOW TO RUN below.) -- Migration: widen all 32-bit unsigned id/key columns to 64-bit diff --git a/src/sql/migrations/README.md b/src/sql/migrations/README.md index 2ae48b94..96e66e0f 100644 --- a/src/sql/migrations/README.md +++ b/src/sql/migrations/README.md @@ -57,3 +57,12 @@ migrate`, i.e. `node src/db/migrate.js`) or with `MIGRATION_STRICT_CHECKSUM=1`, mismatch fails closed (throws) so a diverged schema is caught in CI / by an operator; the passive decoder-startup path logs the mismatch at error level and continues, to avoid a surprise fleet-wide boot failure. + +CI also pins every committed migration, so an in-place edit fails at the commit +rather than on a fleet: `bin/lib/migration_residue_pin.js` keeps two hashes per +file in `test/fixtures/migration-executable-residue.json` (the executable residue +with comments and blank lines dropped, and the whole-file sha256 the ledger +records). Pin a new migration in the commit that adds it with +`node bin/lib/migration_residue_pin.js --add`. A comment-only edit to an applied +file needs a reviewed `MIGRATION_CHECKSUM_REBASELINES` entry; an executable change +needs a new dated migration instead. diff --git a/test/benchmarks/support/mock_database.js b/test/benchmarks/support/mock_database.js index 6839bc89..62fe2ab6 100644 --- a/test/benchmarks/support/mock_database.js +++ b/test/benchmarks/support/mock_database.js @@ -124,6 +124,11 @@ class MockDatabase { return true } + async recordDispenserExtensionUndo() { + this._track('recordDispenserExtensionUndo') + return true + } + async purgeExpiredDispensers(safeHeight) { this._track('purgeExpiredDispensers') return true diff --git a/test/chaos/ce06_chain_reorg.test.js b/test/chaos/ce06_chain_reorg.test.js index 25f099c5..2da3aac6 100644 --- a/test/chaos/ce06_chain_reorg.test.js +++ b/test/chaos/ce06_chain_reorg.test.js @@ -283,12 +283,13 @@ describe('CE-06: Chain Reorganization Detection and Recovery', function () { sinon.stub(decoder, 'sleep').resolves() const verifyStub = sinon.stub(decoder, 'verifyReorg').resolves(true) - const { logs } = await captureConsole(async () => { + const { warnings } = await captureConsole(async () => { await decoder.start() }) assert.ok(verifyStub.calledWith(5), 'should reconcile via verifyReorg with the current node tip') - assert.ok(logs.some(l => l.includes('Reconciling orphan blocks')), 'should log the node-tip regression reconcile') + // Warn, not info: a rollback must reach a warn-and-above alerting rule on every entry path. + assert.ok(warnings.some(l => l.includes('Reconciling orphan blocks')), 'should log the node-tip regression reconcile') assert.ok(mockDb.endTransaction.called, 'should end any open transaction before reconciling') }) }) diff --git a/test/chaos/support/helpers.js b/test/chaos/support/helpers.js index f3d83ac8..f09ce0c2 100644 --- a/test/chaos/support/helpers.js +++ b/test/chaos/support/helpers.js @@ -55,6 +55,7 @@ function createMockDatabase(overrides = {}) { insertDispenser: sinon.stub().resolves(true), insertEvent: sinon.stub().resolves(true), deleteOpenDispensers: sinon.stub().resolves(true), + recordDispenserExtensionUndo: async () => true, purgeExpiredDispensers: sinon.stub().resolves(true), deleteAndCompareTxsNotInList: sinon.stub().resolves({ transactionsDeleted: 0 }), isThereADispenserForAddress: sinon.stub().resolves(false), diff --git a/test/fixtures/action-manifest.json b/test/fixtures/action-manifest.json index 8aa9e150..028d4c86 100644 --- a/test/fixtures/action-manifest.json +++ b/test/fixtures/action-manifest.json @@ -14,13 +14,14 @@ "validator": "validator-broadcast on-chain action: decoded + indexed but NOT user-encodable (ANCHOR/ATTEST/NODEPROOF/ROLLCALL/SLASH)", "mirror-injected": "indexer-injected from the hub mirror, NOT chain-decoded (XCALL/XEXEC/CROSS_SETTLE)", "lifecycle": "system-generated sub-action, never a decoded wire tx (matches/expiries/dispense)", - "explorer-legacy-render": "render-only in the explorer (legacy order/dispenser cancel+edit views); no decoder/indexer twin" + "explorer-legacy-render": "live sub-action name the indexer writes by renaming its parent's actions row (ORDER/SWAP/DISPENSER format 1 -> _CANCEL, format 2 -> _EDIT, via updateActionIndex in xchain-indexer src/actions/{order,swap,dispenser}/settle.js; BET format 4 -> BET_EDIT via updateActionIndex in src/actions/bet/edit_lists_apply.js), rendered by the explorer and keyed by the indexer's escrow attribution (src/consensus/escrow_journal_writer/attribution.js); the wire tx decodes and dispatches as its parent, so the name is never wireDecoded or indexerHandled. The category key predates this definition: the formats are live, not legacy", + "settlement-anchor": "indexer-minted anchor row for a bridge-policy or shared-list settlement record (XPOLICY/LIST_SHARE); never a decoded wire tx and never dispatched, only rendered by the explorer" }, "notes": [ "The indexer protocol_changes registry ALSO holds non-action feature-gate flags: any this.addChange(...) entry whose name is NOT a key in this manifest's 'actions' map is a feature gate, not an action, and is excluded here. As of this pass that set is CONTROLLER_GUARD, CROSS_CHAIN_DEX, ISSUANCE_FEE, ISSUANCE_FEE_EMISSION_EXEMPT, LOCK_MAX_SUPPLY_EXACT, UNIFIED_FEES, VM_ACTIONS, VM_BALANCE_TOKENINFO, VM_BANNED_ASYNC, DEPLOY_BASE64_CODE, CROSS_CHAIN_ROYALTY, ISSUE_MINT_SUPPLY_CUMULATIVE_CAP, SLEEP_RESPECTS_LOCK_SLEEP, COINPAY_EXPIRE_TOKEN_AMOUNT, UNSTAKE_COOLDOWN_COMPLETION_ACTION, DELEGATE_REVOKE_NO_REINSERT, CONTRACT_INDEX_CANONICAL, SLASH_BURNS_PENDING_STAKE, NATIVE_FEE_PRICE_TIME_GATE, DEPLOY_INIT_STRICT, but the enumeration is illustrative, not authoritative: trust the exclusion RULE above it, not this list, since new flag-days are added to the registry without a manifest update. The indexer conformance guard compares the dispatch switch, not the registry.", "UNKNOWN is the indexer/explorer catch-all sentinel, not an action; excluded. Each guard drops UNKNOWN before comparing.", "Aliases are expanded to canonical names during canonicalization - before the ACTION-name gate (so no alias appears as its own action entry) but AFTER the compiled-size gate, which therefore measures the alias/wire form, not the expanded canonical record (decoder ACTION_ALIASES / indexer actionAliases).", - "userEncodableVersions is NOT a copy of the SDK Formats keys, it is the audit those keys are checked against. Every entry was read off xchain-indexer/src/actions/.js: the handler's this.formats map is the set of versions the indexer will parse at all, and a version is user-encodable only if nothing in the handler restricts it to indexer-synthesized input. Two versions the indexer parses are therefore absent here: VOTE v2 (finalize) rejects a user broadcast outright via `if(!data['IS_SYNTHETIC'])` in vote.js, and PRICE v0 is the validator COIN/FIAT snapshot, which only validates with a PBFT quorum of Ed25519 signatures from price-capability stakes, so no wallet can author one. XBRIDGE is the third case and the reason one entry carries a gapped version list: v0/v1 (XCHAIN lock/burn) and v3/v4 (general-token lock/burn) are user-broadcast, while v2 and v5 are the mirror-injected settle legs the indexer synthesizes from a finalized bridge_transfers row and refuses on broadcast ('invalid: XBRIDGE v2 is system-injected'), the ATTEST precedent of one action name carrying mixed formats. Adding a version here that the indexer will not accept from a user is worse than omitting one: the SDK guard would then demand a Format that builds a command guaranteed to be rejected on arrival, so re-run the audit against the handler before editing an array.", + "userEncodableVersions is NOT a copy of the SDK Formats keys, it is the audit those keys are checked against. Every entry was read off the indexer handler for that action, xchain-indexer/src/actions//index.js (or the flat src/actions/.js for the few single-file handlers): the handler's this.formats map is the set of versions the indexer will parse at all, and a version is user-encodable only if nothing in the handler restricts it to indexer-synthesized input. Four versions the indexer accepts, across three actions, are therefore absent here. Two are parsed and then gated: VOTE v2 (finalize) rejects a user broadcast outright via `if(!data['IS_SYNTHETIC'])` in xchain-indexer/src/actions/vote/index.js, and PRICE v0 is the validator COIN/FIAT snapshot, which only validates with a PBFT quorum of Ed25519 signatures from price-capability stakes, so no wallet can author one. XBRIDGE v2 and v5 are the other two, and they have no user format at all: v0/v1 (XCHAIN lock/burn) and v3/v4 (general-token lock/burn) are user-broadcast, while v2 and v5 are the mirror-injected settle legs the indexer synthesizes from a finalized bridge_transfers row and refuses on broadcast ('invalid: XBRIDGE v2 is system-injected' / 'invalid: XBRIDGE v5 is system-injected', in xchain-indexer/src/actions/xbridge/verdicts.js), the ATTEST precedent of one action name carrying mixed formats. That is why two entries carry gapped version lists, VOTE [0, 1, 3] and XBRIDGE [0, 1, 3, 4]. Adding a version here that the indexer will not accept from a user is worse than omitting one: the SDK guard would then demand a Format that builds a command guaranteed to be rejected on arrival, so re-run the audit against the handler before editing an array.", "BET is fully rolled out as of the P8 wallet form: wireDecoded + userEncodable (decoder, encoder gate, SDK), indexerHandled + the BET_EXPIRE lifecycle entry (P4), explorerRender (P7), walletForm (P8). It was staged one flag per work package on purpose, because flipping a flag ahead of the code makes that repo's conformance guard red, which is the intended signal rather than an oversight. BET_EXPIRE gained its own explorerRender in a later pass: it owns no table, so its detail reads the bet_feed_statuses row keyed by its action_index and joins through to the feed it expired." ], "aliases": { @@ -74,11 +75,15 @@ "category": "wire-user", "wireDecoded": true, "userEncodable": true, - "userEncodableVersions": [0, 1, 2, 3], + "userEncodableVersions": [0, 1, 2, 3, 4], "indexerHandled": true, "explorerRender": true, "walletForm": true }, + "BET_EDIT": { + "category": "explorer-legacy-render", + "explorerRender": true + }, "BET_EXPIRE": { "category": "lifecycle", "indexerHandled": true, @@ -252,6 +257,10 @@ "explorerRender": true, "walletForm": true }, + "LIST_SHARE": { + "category": "settlement-anchor", + "explorerRender": true + }, "MESSAGE": { "category": "wire-user", "wireDecoded": true, @@ -432,6 +441,10 @@ "category": "mirror-injected", "indexerHandled": true, "explorerRender": true + }, + "XPOLICY": { + "category": "settlement-anchor", + "explorerRender": true } } } diff --git a/test/fixtures/migration-executable-residue.json b/test/fixtures/migration-executable-residue.json new file mode 100644 index 00000000..bc33212a --- /dev/null +++ b/test/fixtures/migration-executable-residue.json @@ -0,0 +1,46 @@ +{ + "2026-05-28-unique-index-tables.sql": { + "residue": "9e4dcd6d94aed3532f7be06059d7a0b53dc4f9f63d19fb564beceba5648173f1", + "sha256": "4f7f53ea5423d5ad50e0a2136243dab9e215033e6a110c7b47e66ba5361d44c2" + }, + "2026-06-02-fix-previous-block-hash-byte-order.sql": { + "residue": "d23710057c7957212bc8d176f1598c70322e25c597f51f3b9b4ec6208c632d57", + "sha256": "db1e2cac25b7ed132dddaf33a483f35151208901c40a5b4c637d5b5f23492663" + }, + "2026-06-02-widen-ids-to-bigint.sql": { + "residue": "51d74c7078068b010db2decf5dad242094b218049be6cb232aea84853121cbd0", + "sha256": "33423de08d0869d7a81829301668383ee3cb47e7226bff587fcfcbf9fa0c7b1c" + }, + "2026-06-13-dispensers-expiration-bigint.sql": { + "residue": "3646f568c94a54e9fa18d7e484818fc8e28651aa83fa90c0198ee39cfabfd06b", + "sha256": "0e871ed4aea8649d6a5ffe866d78af38ceee37e5cd07d651287cfe1e8c99c8b2" + }, + "2026-06-15-events-data-mediumtext.sql": { + "residue": "915f2a14ba4835e7fae0a309419a39010f1a1604b6d946bdefe77e8c11ee1564", + "sha256": "3790d814dec1ecbf7be78065be82a9f7e4f983c4529620f3c1a7d01f129881e8" + }, + "2026-06-15-mempool-raw-strings.sql": { + "residue": "3598856a27b08598976aa31b72270be9b4b170e4dd449ba2c7e80aa4271a850d", + "sha256": "571c0a30c8264acf89e5f5a11e491cb2c733d96c9ec732c0036bf6cdeb273456" + }, + "2026-06-17-pubkeys-add-monotonic-id.sql": { + "residue": "2a13c0eb4501caae8e010b211521996574b4089490604f266807fa7ec5ca606d", + "sha256": "1d8406192690e5a754ec9430fcd9115e907f34944f340a70b776166a62f83868" + }, + "2026-07-24-pubkeys-widen-uncompressed.sql": { + "residue": "7da72df46a6af78cb4ede312db2ec2947d16c79b6c2692627ce9df029060981c", + "sha256": "156fca3b75b332ef099e8dd5d28624d9ebc26d34e143e37e1f9503b6c0da0c1d" + }, + "2026-08-10-action-data-utf8mb4.sql": { + "residue": "a4c87c86ffba8bc1b674ada7ce7f678612ea008f08a7d362d9dcfe546ce2ed1d", + "sha256": "0b3b2fefb780da1fb96a0d5518967b67b215676cc1ac02efc08ec1672d9091b2" + }, + "2026-08-22-mempool-first-seen.sql": { + "residue": "c3e167c81c150d5306b575f2465d798be267d0e1521385246321dc3b3f73d142", + "sha256": "65baa4b8645ac2de43de0291a2e9d0bac3d8dce83f4884894e2da6d19683b729" + }, + "2026-09-27-mempool-first-seen-datetime.sql": { + "residue": "ad7e67e22f93b412624d1eea2e3039ff2d523b4bb7142380ee6a91b64d64b266", + "sha256": "4342fc1560c68b7b14a86a7c0338dd879a769c1651f441348fc7ac9ec12bcc05" + } +} diff --git a/test/helpers/batchCaptureHarness.js b/test/helpers/batchCaptureHarness.js index 20ed70a6..3c12b68d 100644 --- a/test/helpers/batchCaptureHarness.js +++ b/test/helpers/batchCaptureHarness.js @@ -130,6 +130,7 @@ function buildDecoder(txSpecs, model, opts) { insertDispenser: (d) => model.insertDispenser(d), extendOpenDispenserExpirationBySource: (s, e, b) => model.extendOpenDispenserExpirationBySource(s, e, b), deleteOpenDispensers: (b, m) => model.deleteOpenDispensers(b, m), + recordDispenserExtensionUndo: async () => true, purgeExpiredDispensers: (h) => model.purgeExpiredDispensers(h), getAllOpenDispenserAddresses: () => model.getAllOpenDispenserAddresses(), getOpenDispenserOracleAddressBySource: (s) => model.getOpenDispenserOracleAddressBySource(s), diff --git a/test/helpers/sibling_checkout.js b/test/helpers/sibling_checkout.js index afcbac81..3cf5c6df 100644 --- a/test/helpers/sibling_checkout.js +++ b/test/helpers/sibling_checkout.js @@ -41,6 +41,7 @@ const fs = require('fs'); const path = require('path'); +const cp = require('child_process'); const OWN_ROOT = path.resolve(__dirname, '..', '..'); @@ -63,6 +64,26 @@ function checkoutRootOf(dir) { } } +function pinnedMainCheckout(root, name, env) { + let pins; + try { pins = JSON.parse(env.XCHAIN_SIBLING_COMMITS || '{}'); } + catch (e) { return false; } + const expected = pins && pins[name]; + if (typeof expected !== 'string' || !/^[0-9a-f]{40}$/.test(expected)) return false; + + let actual; + try { + actual = cp.execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] + }).trim(); + } catch (e) { return false; } + if (actual !== expected) return false; + + const clean = (args) => cp.spawnSync('git', ['-C', root, ...args], { stdio: 'ignore' }).status === 0; + return clean(['diff', '--quiet', '--ignore-submodules', '--']) + && clean(['diff', '--cached', '--quiet', '--ignore-submodules', '--']); +} + /** * Judge one sibling path. * @@ -74,6 +95,7 @@ function checkoutRootOf(dir) { */ function siblingCheckout(fromDir, target, opts = {}) { const ownRoot = opts.ownRoot || OWN_ROOT; + const env = opts.env || process.env; const abs = path.resolve(fromDir, target); if (!fs.existsSync(abs)) @@ -94,12 +116,15 @@ function siblingCheckout(fromDir, target, opts = {}) { const targetRoot = checkoutRootOf(real); const targetIsMain = targetRoot !== null && fs.lstatSync(path.join(targetRoot, '.git')).isDirectory(); - if (targetIsMain) + if (targetIsMain) { + if (pinnedMainCheckout(targetRoot, path.basename(entry), env)) + return { usable: true, path: abs, reason: null }; return { usable: false, path: abs, reason: 'sibling ' + path.basename(entry) + ' resolves through a symlink into the live main checkout ' + targetRoot + ', which no commit pins; cut a real sibling worktree to test against it', }; + } return { usable: true, path: abs, reason: null }; } @@ -120,4 +145,4 @@ function skipOrFail(ctx, verdict, what) { return false; } -module.exports = { siblingCheckout, skipOrFail, siblingsRequired, isLinkedWorktree }; +module.exports = { siblingCheckout, skipOrFail, siblingsRequired, isLinkedWorktree, pinnedMainCheckout }; diff --git a/test/unit/action_manifest_conformance.test.js b/test/unit/action_manifest_conformance.test.js index 4c7e2e2b..23dd7967 100644 --- a/test/unit/action_manifest_conformance.test.js +++ b/test/unit/action_manifest_conformance.test.js @@ -34,6 +34,7 @@ function manifestSlice(flag) { // Name the files that hold the literals (XChainDecoder.js only re-exports them). const DECODER_CONSTANTS_REL = 'src/XChainDecoder/constants.js'; const DECODER_ALIASES_REL = 'src/protocol/action_aliases.js'; +const SETTLEMENT_ANCHORS = ['LIST_SHARE', 'XPOLICY']; function localDecoderSet() { const src = decomment(fs.readFileSync(path.join(__dirname, '..', '..', ...DECODER_CONSTANTS_REL.split('/')), 'utf8')); const m = src.match(/VALID_ACTION_NAMES = new Set\(\[([\s\S]*?)\]\)/); @@ -55,6 +56,17 @@ describe('ACTION manifest conformance: decoder wireDecoded set @regression', fun ' (VALID_ACTION_NAMES in ' + DECODER_CONSTANTS_REL + ').'); }); + it('settlement anchors remain explorer-only and outside the wire decoder', function () { + const decoded = localDecoderSet(); + for (const name of SETTLEMENT_ANCHORS) { + assert.deepStrictEqual(MANIFEST.actions[name], { + category: 'settlement-anchor', + explorerRender: true + }, name + ' manifest capabilities drifted'); + assert.ok(!decoded.includes(name), name + ' must not enter VALID_ACTION_NAMES'); + } + }); + // The decoder is the on-chain arbiter that performs alias expansion // (ACTION_ALIASES[rawActionName] ?? rawActionName), so its alias table has to be // bound to the manifest or a new/retargeted alias leaves the arbiter silently green. diff --git a/test/unit/apply_bufferutils_patch_twin_parity.test.js b/test/unit/apply_bufferutils_patch_twin_parity.test.js new file mode 100644 index 00000000..4aa32aaa --- /dev/null +++ b/test/unit/apply_bufferutils_patch_twin_parity.test.js @@ -0,0 +1,53 @@ +// 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. + +// src/chain/apply_bufferutils_patch.js and src/chain/bufferutils.js are byte-identical +// twins of the xchain-utxo-tracker files at the same paths. Both services decode the +// same blocks, so a one-sided edit (the verifuint bounds, the unsigned 64-bit read, the +// writer's Number/BigInt acceptance) splits UTXO-set and action-record output values at +// the same height. Change both copies together; the tracker carries the mirror guard. +// +// Skips when the sibling checkout is absent or untrustworthy (sibling_checkout.js); +// set XCHAIN_REQUIRE_SIBLINGS=1 in CI so a missing sibling hard-fails instead. + +const assert = require('assert') +const fs = require('fs') +const path = require('path') +const { siblingCheckout, skipOrFail } = require('../helpers/sibling_checkout') + +const REPO_ROOT = path.join(__dirname, '..', '..') +const TRACKER_DIR = process.env.XCHAIN_UTXO_TRACKER_DIR || + path.join(__dirname, '..', '..', '..', 'xchain-utxo-tracker') +const TWIN_FILES = ['src/chain/apply_bufferutils_patch.js', 'src/chain/bufferutils.js'] + +// Names the first differing line so a drift report points at the edit. +function firstDifferingLine(a, b) { + const la = a.toString('utf8').split('\n') + const lb = b.toString('utf8').split('\n') + for (let i = 0; i < Math.max(la.length, lb.length); i++) { + if (la[i] !== lb[i]) return 'line ' + (i + 1) + ':\n decoder: ' + la[i] + '\n tracker: ' + lb[i] + } + return 'no line differs (whitespace or trailing bytes)' +} + +describe('bufferutils patch twin parity with xchain-utxo-tracker @regression', function () { + before(function () { + const verdict = siblingCheckout(__dirname, path.join(TRACKER_DIR, TWIN_FILES[0])) + skipOrFail(this, verdict, 'the bufferutils patch twin guard') + }) + + for (const rel of TWIN_FILES) { + it(rel + ' is byte-identical in both repos', function () { + const local = fs.readFileSync(path.join(REPO_ROOT, rel)) + const twin = fs.readFileSync(path.join(TRACKER_DIR, rel)) + assert.ok(local.equals(twin), rel + ' has drifted from xchain-utxo-tracker at ' + firstDifferingLine(local, twin)) + }) + } +}) diff --git a/test/unit/auxpow_reassembly.test.js b/test/unit/auxpow_reassembly.test.js index 9c7dcf2e..93dc34bc 100644 --- a/test/unit/auxpow_reassembly.test.js +++ b/test/unit/auxpow_reassembly.test.js @@ -162,6 +162,25 @@ describe('malformed-AuxPoW block reassembly fallback', function () { }) }) +// The node's own RPC code is the other half of telling an unreachable or warming +// node apart from unusable block bytes, so it must survive the wrap like error.code. +describe('malformed-AuxPoW block reassembly fallback', function () { + it('getBlockReassembled carries rpcCode/rpcMessage through the wrap, untagged', async function () { + const rpcErr = new Error('block header: RPC error -28: Loading block index...') + Object.defineProperty(rpcErr, 'rpcCode', { value: -28, enumerable: false, configurable: true }) + Object.defineProperty(rpcErr, 'rpcMessage', { value: 'Loading block index...', enumerable: false, configurable: true }) + const connector = makeConnector({ getBlockHeader: async () => { throw rpcErr } }) + await assert.rejects(() => connector.getBlockReassembled('hash'), (err) => { + assert.strictEqual(err.rpcCode, -28) + assert.strictEqual(err.rpcMessage, 'Loading block index...') + assert.strictEqual(err.propertyIsEnumerable('rpcCode'), false) + assert.strictEqual(err.cause, rpcErr) + assert.strictEqual(err.auxPowParseFailure, undefined) + return true + }) + }) +}) + describe('malformed-AuxPoW block reassembly fallback', function () { describe('BlockchainConnector.probeTxIndex', function () { @@ -319,3 +338,22 @@ describe('malformed-AuxPoW block reassembly fallback', function () { }) }) }) + +// The per-tx fan-out is where a saturated node shows up, so the real getRawTransaction +// ladder's exhaustion must still hand the wrapper a transport code to copy. +describe('malformed-AuxPoW block reassembly fallback', function () { + it('getBlockReassembled keeps error.code when the real getRawTransaction ladder exhausts', async function () { + const connector = makeConnector({ + getBlockHeader: async () => HEADER_HEX, + getBlockVerbose: async () => ({ tx: [TXID] }), + sleep: async () => {}, + rpcPost: async () => { throw Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' }) }, + }) + await assert.rejects(() => connector.getBlockReassembled('hash'), (err) => { + assert.match(err.message, /failed after 10 attempts/) + assert.strictEqual(err.code, 'ECONNRESET', 'the transport code must reach the wrapper') + assert.strictEqual(err.auxPowParseFailure, undefined) + return true + }) + }) +}) diff --git a/test/unit/auxpow_strip_parity.test.js b/test/unit/auxpow_strip_parity.test.js index a4296ca5..b75e57c5 100644 --- a/test/unit/auxpow_strip_parity.test.js +++ b/test/unit/auxpow_strip_parity.test.js @@ -9,7 +9,7 @@ // contact legal@dankest.llc. // The AuxPoW strip primitives are duplicated between -// xchain-decoder/src/chain/blockchain_connector.js and the xchain-utxo-tracker twin. +// xchain-decoder/src/chain/blockchain_connector/auxpow_codec.js and the xchain-utxo-tracker twin. // Both carry "Keep in sync with ..." comments, but nothing enforces that // automatically: one repo can wrap its errors differently, or factor the strip // logic into a differently named helper, while the comments still claim parity. @@ -177,11 +177,11 @@ describe('AuxPoW strip parity with xchain-utxo-tracker @regression', function () for (const name of SHARED_FUNCTIONS) { assert.ok( localSource.includes( - 'Keep in sync with xchain-utxo-tracker/src/chain/blockchain_connector.js ' + name), + 'Keep in sync with xchain-utxo-tracker/src/chain/blockchain_connector/auxpow_codec.js ' + name), `decoder copy of ${name} lacks its Keep-in-sync comment`) assert.ok( twinSource.includes( - 'Keep in sync with xchain-decoder/src/chain/blockchain_connector.js ' + name), + 'Keep in sync with xchain-decoder/src/chain/blockchain_connector/auxpow_codec.js ' + name), `utxo-tracker copy of ${name} lacks its Keep-in-sync comment`) } }) diff --git a/test/unit/batch_dispenser_address_id_collapse.test.js b/test/unit/batch_dispenser_address_id_collapse.test.js new file mode 100644 index 00000000..8e90cad1 --- /dev/null +++ b/test/unit/batch_dispenser_address_id_collapse.test.js @@ -0,0 +1,76 @@ +// 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 constants = require('../../src/protocol/constants.js') +const { + collapseDispenserRegistrations, + isDispenserAddressIdCollapseActive, +} = require('../../src/protocol/batch_sub_command_capture.js') + +describe('collapseDispenserRegistrations address-id collapse', function () { + const original = constants.DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION + const candidates = [ + { address: 'mAbc', sourceAddress: 'src', oracleAddress: null, expiration: 10 }, + { address: 'mabc', sourceAddress: 'src', oracleAddress: 'oracle', expiration: 30 }, + { address: 'mxyz', sourceAddress: 'src', oracleAddress: null, expiration: 5 }, + ] + + beforeEach(function () { + constants.DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION = { + mainnet: null, testnet: null, regtest: 0, + } + }) + + afterEach(function () { + if (original === undefined) + delete constants.DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION + else + constants.DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION = original + }) + + it('uses numeric activation boundaries and rejects invalid inputs', function () { + constants.DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION.mainnet = 100 + assert.strictEqual(isDispenserAddressIdCollapseActive('mainnet', 99), false) + assert.strictEqual(isDispenserAddressIdCollapseActive('mainnet', 100), true) + assert.strictEqual(isDispenserAddressIdCollapseActive('regtest', 0), true) + assert.strictEqual(isDispenserAddressIdCollapseActive('nonsense', 1), false) + assert.strictEqual(isDispenserAddressIdCollapseActive('regtest', 'x'), false) + }) + + it('stays inactive everywhere while the constants define no gate', function () { + delete constants.DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION + assert.strictEqual(isDispenserAddressIdCollapseActive('regtest', 1), false) + assert.strictEqual(collapseDispenserRegistrations(candidates, 'regtest', 1).length, 3) + }) + + it('keeps table-equivalent variants apart below the gate', function () { + assert.strictEqual(collapseDispenserRegistrations(candidates).length, 3) + assert.strictEqual(collapseDispenserRegistrations(candidates, 'mainnet', 1).length, 3) + }) + + it('merges table-equivalent variants once active', function () { + const out = collapseDispenserRegistrations(candidates, 'regtest', 1) + assert.deepStrictEqual(out, [ + { address: 'mAbc', sourceAddress: 'src', oracleAddress: 'oracle', expiration: 30 }, + { address: 'mxyz', sourceAddress: 'src', oracleAddress: null, expiration: 5 }, + ]) + }) + + it('merges trailing-space variants and tolerates non-lists once active', function () { + const out = collapseDispenserRegistrations([ + { address: 'mq', sourceAddress: 's', oracleAddress: null, expiration: 1 }, + { address: 'mq ', sourceAddress: 's', oracleAddress: null, expiration: 2 }, + ], 'regtest', 1) + assert.strictEqual(out.length, 1) + assert.strictEqual(out[0].expiration, 2) + assert.deepStrictEqual(collapseDispenserRegistrations(undefined, 'regtest', 1), []) + }) +}) diff --git a/test/unit/batch_dispenser_registration.test.js b/test/unit/batch_dispenser_registration.test.js index 440f97ae..ce388210 100644 --- a/test/unit/batch_dispenser_registration.test.js +++ b/test/unit/batch_dispenser_registration.test.js @@ -51,62 +51,97 @@ const { runAll, runOne, } = require('./batch_dispenser_registration.test/support/helpers.js') +const protocolConstants = require('../../src/protocol/constants.js') -describe('BATCH dispenser registration', function () { - this.timeout(0) +function defineTopLevelCreateTests(){ + it('registers a top-level create above the gate', async () => { + const decoder = await runOne(create({ oracle: ORACLE_A }), ABOVE_GATE) + assert.strictEqual(decoder.model.rows.length, 1) + assert.deepStrictEqual(decoder.model.rows[0], { + txIndex: 1, address: SOURCE, expiration: EXP_LATE, + oracleAddress: ORACLE_A, sourceAddress: null, expiredBlockIndex: null }) + }) - describe('a top-level DISPENSER is untouched on both sides of the gate', function () { + it('registers a top-level create below the gate, byte-identically', async () => { + const decoder = await runOne(create({ oracle: ORACLE_A }), BELOW_GATE) + assert.deepStrictEqual(decoder.model.rows, [{ + txIndex: 1, address: SOURCE, expiration: EXP_LATE, + oracleAddress: ORACLE_A, sourceAddress: null, expiredBlockIndex: null }]) + }) - it('registers a top-level create above the gate', async () => { - const decoder = await runOne(create({ oracle: ORACLE_A }), ABOVE_GATE) + it('defaults an omitted EXPIRATION from the block time on both sides', async () => { + for (const venue of [ABOVE_GATE, BELOW_GATE]) { + const decoder = await runOne(CREATE_NO_TAIL, venue) assert.strictEqual(decoder.model.rows.length, 1) - assert.deepStrictEqual(decoder.model.rows[0], { - txIndex: 1, address: SOURCE, expiration: EXP_LATE, - oracleAddress: ORACLE_A, sourceAddress: null, expiredBlockIndex: null }) - }) + assert.strictEqual(decoder.model.rows[0].expiration, + decoder.getDefaultExpiration(T0)) + } + }) - it('registers a top-level create below the gate, byte-identically', async () => { - const decoder = await runOne(create({ oracle: ORACLE_A }), BELOW_GATE) - assert.deepStrictEqual(decoder.model.rows, [{ - txIndex: 1, address: SOURCE, expiration: EXP_LATE, - oracleAddress: ORACLE_A, sourceAddress: null, expiredBlockIndex: null }]) - }) + it('registers a delegated create on GET_ADDRESS and records the create SOURCE', async () => { + for (const venue of [ABOVE_GATE, BELOW_GATE]) { + const decoder = await runOne(create({ getAddress: DELEGATE_A }), venue) + assert.strictEqual(decoder.model.rows.length, 1) + assert.strictEqual(decoder.model.rows[0].address, DELEGATE_A) + assert.strictEqual(decoder.model.rows[0].sourceAddress, SOURCE) + } + }) +} - it('defaults an omitted EXPIRATION from the block time on both sides', async () => { - for (const venue of [ABOVE_GATE, BELOW_GATE]) { - const decoder = await runOne(CREATE_NO_TAIL, venue) - assert.strictEqual(decoder.model.rows.length, 1) - assert.strictEqual(decoder.model.rows[0].expiration, - decoder.getDefaultExpiration(T0)) - } - }) +function defineTopLevelEditTests(){ + it('still extends on a top-level v2 edit, and registers no create row', async () => { + for (const venue of [ABOVE_GATE, BELOW_GATE]) { + const decoder = await runAll([ + { id: 'create01', action: create({ expiration: EXP_EARLY }), source: SOURCE, outputs: [] }, + { id: 'edit01', action: refill(EXP_LATE), source: SOURCE, outputs: [] }, + ], venue) + assert.strictEqual(decoder.model.rows.length, 1, 'an edit creates no row') + assert.strictEqual(decoder.model.rows[0].expiration, EXP_LATE) + assert.strictEqual(decoder.model.extendCalls.length, 1) + } + }) - it('registers a delegated create on GET_ADDRESS and records the create SOURCE', async () => { - for (const venue of [ABOVE_GATE, BELOW_GATE]) { - const decoder = await runOne(create({ getAddress: DELEGATE_A }), venue) - assert.strictEqual(decoder.model.rows.length, 1) - assert.strictEqual(decoder.model.rows[0].address, DELEGATE_A) - assert.strictEqual(decoder.model.rows[0].sourceAddress, SOURCE) - } - }) + it('skips a top-level create whose coins name another chain, on both sides', async () => { + for (const venue of [ABOVE_GATE, BELOW_GATE]) { + const decoder = await runOne(create({ giveCoin: 'DOGE', getCoin: 'DOGE' }), venue) + assert.deepStrictEqual(decoder.model.rows, []) + } + }) +} + +function defineAddressIdCollapseTests(){ + it('collapses table-equivalent address variants through the block loop', async () => { + const original = protocolConstants.DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION + protocolConstants.DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION = { + mainnet: null, testnet: null, regtest: 0, + } + try { + const first = create({ getAddress: 'bcrt1qCaseVariant', expiration: EXP_EARLY }) + const second = create({ getAddress: 'bcrt1qcasevariant', expiration: EXP_LATE }) + const decoder = await runOne(`BATCH|0|${first};${second}`, ABOVE_GATE) + assert.strictEqual(decoder.model.insertCalls, 1) + assert.deepStrictEqual(decoder.model.rows.map(row => ({ + address: row.address, + expiration: row.expiration, + })), [{ address: 'bcrt1qCaseVariant', expiration: EXP_LATE }]) + } finally { + if (original === undefined) + delete protocolConstants.DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION + else + protocolConstants.DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION = original + } + }) +} + +describe('BATCH dispenser registration', function () { + this.timeout(0) - it('still extends on a top-level v2 edit, and registers no create row', async () => { - for (const venue of [ABOVE_GATE, BELOW_GATE]) { - const decoder = await runAll([ - { id: 'create01', action: create({ expiration: EXP_EARLY }), source: SOURCE, outputs: [] }, - { id: 'edit01', action: refill(EXP_LATE), source: SOURCE, outputs: [] }, - ], venue) - assert.strictEqual(decoder.model.rows.length, 1, 'an edit creates no row') - assert.strictEqual(decoder.model.rows[0].expiration, EXP_LATE) - assert.strictEqual(decoder.model.extendCalls.length, 1) - } - }) + describe('a top-level DISPENSER is untouched on both sides of the gate', function () { + defineTopLevelCreateTests() + defineTopLevelEditTests() + }) - it('skips a top-level create whose coins name another chain, on both sides', async () => { - for (const venue of [ABOVE_GATE, BELOW_GATE]) { - const decoder = await runOne(create({ giveCoin: 'DOGE', getCoin: 'DOGE' }), venue) - assert.deepStrictEqual(decoder.model.rows, []) - } - }) + describe('address-id collapse activation', function () { + defineAddressIdCollapseTests() }) }) diff --git a/test/unit/batch_dispenser_registration.test/13_collapse_registrations_by_key.test.js b/test/unit/batch_dispenser_registration.test/13_collapse_registrations_by_key.test.js new file mode 100644 index 00000000..3a50af83 --- /dev/null +++ b/test/unit/batch_dispenser_registration.test/13_collapse_registrations_by_key.test.js @@ -0,0 +1,74 @@ +// 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 { collapseDispenserRegistrations } = require('../../../src/protocol/batch_sub_command_capture.js') +const { collapseRegistrationsByKey } = require('../../../src/protocol/dispenser_registration_collapse.js') + +describe('collapseRegistrationsByKey', function () { + const candidates = [ + { address: 'mAbc', sourceAddress: 'source-one', oracleAddress: '', expiration: 10 }, + { address: 'mabc', sourceAddress: 'source-two', oracleAddress: 'oracle-two', expiration: 30 }, + { address: 'mAbc', sourceAddress: 'source-three', oracleAddress: 'oracle-three', expiration: 20 }, + { address: 'mXyz', sourceAddress: 'source-four', oracleAddress: 'oracle-four', expiration: 5 }, + ] + + function callUnchanged(input, keyOf){ + const snapshot = JSON.stringify(input) + const result = collapseRegistrationsByKey(input, keyOf) + assert.strictEqual(JSON.stringify(input), snapshot) + return result + } + + it('matches the existing raw-address collapse when no key is provided', function () { + assert.deepStrictEqual(callUnchanged(candidates), collapseDispenserRegistrations(candidates)) + assert.deepStrictEqual(callUnchanged(candidates, null), collapseDispenserRegistrations(candidates)) + assert.strictEqual(callUnchanged(candidates).length, 3) + }) + + it('collapses selected keys while preserving first-seen values and merge rules', function () { + const result = callUnchanged(candidates, address => address.toLowerCase()) + assert.deepStrictEqual(result, [ + { address: 'mAbc', sourceAddress: 'source-one', oracleAddress: 'oracle-two', expiration: 30 }, + { address: 'mXyz', sourceAddress: 'source-four', oracleAddress: 'oracle-four', expiration: 5 }, + ]) + }) + + it('keeps distinct selected addresses separate', function () { + const result = callUnchanged(candidates.slice(0, 2), address => address) + assert.deepStrictEqual(result.map(candidate => candidate.address), ['mAbc', 'mabc']) + }) + + it('falls back to the raw address when the selected key is null', function () { + const result = callUnchanged(candidates, () => null) + assert.deepStrictEqual(result, collapseDispenserRegistrations(candidates)) + assert.deepStrictEqual(callUnchanged(candidates, () => ''), + collapseDispenserRegistrations(candidates)) + }) + + it('skips missing candidates and candidates without an address', function () { + const input = [null, { address: '' }, candidates[0]] + assert.deepStrictEqual(callUnchanged(input), [ + { address: 'mAbc', sourceAddress: 'source-one', oracleAddress: null, expiration: 10 }, + ]) + }) + + it('returns an empty array for non-array input', function () { + assert.deepStrictEqual(collapseRegistrationsByKey('not-an-array', address => address), []) + assert.deepStrictEqual(collapseRegistrationsByKey(undefined), []) + }) + + it('returns objects that do not alias input candidates', function () { + const result = callUnchanged(candidates, address => address.toLowerCase()) + result[0].expiration = 0 + assert.strictEqual(candidates[0].expiration, 10) + assert.strictEqual(candidates[1].expiration, 30) + }) +}) diff --git a/test/unit/batch_dispenser_registration.test/support/helpers.js b/test/unit/batch_dispenser_registration.test/support/helpers.js index a0692ab8..5d757de6 100644 --- a/test/unit/batch_dispenser_registration.test/support/helpers.js +++ b/test/unit/batch_dispenser_registration.test/support/helpers.js @@ -152,6 +152,7 @@ function databaseFor(decoder, model, captured) { extendOpenDispenserExpirationBySource: (s, e, b) => model.extendOpenDispenserExpirationBySource(s, e, b), deleteOpenDispensers: (b, m) => model.deleteOpenDispensers(b, m), + recordDispenserExtensionUndo: async () => true, purgeExpiredDispensers: (h) => model.purgeExpiredDispensers(h), getAllOpenDispenserAddresses: () => model.getAllOpenDispenserAddresses(), getOpenDispenserOracleAddressBySource: (s) => model.getOpenDispenserOracleAddressBySource(s), diff --git a/test/unit/batch_sub_command_name_gate.test/a_provably_rejected_sub_command_suppresses_the_whole_capture_view.test.js b/test/unit/batch_sub_command_name_gate.test/a_provably_rejected_sub_command_suppresses_the_whole_capture_view.test.js index b31cfd86..8da38b4d 100644 --- a/test/unit/batch_sub_command_name_gate.test/a_provably_rejected_sub_command_suppresses_the_whole_capture_view.test.js +++ b/test/unit/batch_sub_command_name_gate.test/a_provably_rejected_sub_command_suppresses_the_whole_capture_view.test.js @@ -171,6 +171,7 @@ function databaseFor(decoder, model, captured) { insertDispenser: (d) => model.insertDispenser(d), extendOpenDispenserExpirationBySource: (s, e, b) => model.extendOpenDispenserExpirationBySource(s, e, b), deleteOpenDispensers: (b, m) => model.deleteOpenDispensers(b, m), + recordDispenserExtensionUndo: async () => true, purgeExpiredDispensers: (h) => model.purgeExpiredDispensers(h), getAllOpenDispenserAddresses: () => model.getAllOpenDispenserAddresses(), getOpenDispenserOracleAddressBySource: (s) => model.getOpenDispenserOracleAddressBySource(s), diff --git a/test/unit/batch_sub_command_name_gate.test/sub_command_action_names_are_alias_expanded_above_the_gate.test.js b/test/unit/batch_sub_command_name_gate.test/sub_command_action_names_are_alias_expanded_above_the_gate.test.js index 3d2372e8..d5eedbe9 100644 --- a/test/unit/batch_sub_command_name_gate.test/sub_command_action_names_are_alias_expanded_above_the_gate.test.js +++ b/test/unit/batch_sub_command_name_gate.test/sub_command_action_names_are_alias_expanded_above_the_gate.test.js @@ -105,6 +105,7 @@ function databaseFor(decoder, model, captured) { insertDispenser: (d) => model.insertDispenser(d), extendOpenDispenserExpirationBySource: (s, e, b) => model.extendOpenDispenserExpirationBySource(s, e, b), deleteOpenDispensers: (b, m) => model.deleteOpenDispensers(b, m), + recordDispenserExtensionUndo: async () => true, purgeExpiredDispensers: (h) => model.purgeExpiredDispensers(h), getAllOpenDispenserAddresses: () => model.getAllOpenDispenserAddresses(), getOpenDispenserOracleAddressBySource: (s) => model.getOpenDispenserOracleAddressBySource(s), diff --git a/test/unit/blockchain_connector.test.js b/test/unit/blockchain_connector.test.js index c2fbaff1..7f1e775e 100644 --- a/test/unit/blockchain_connector.test.js +++ b/test/unit/blockchain_connector.test.js @@ -16,6 +16,9 @@ const BlockchainConnector = require('../../src/chain/blockchain_connector') let connector let axiosStub +// A well-formed block hash: getBlockHash refuses anything that is not 64 hex characters. +const BLOCK_HASH = '00000000000000000007' + 'a'.repeat(44) + function registerConnectorHooks() { beforeEach(() => { connector = new BlockchainConnector('127.0.0.1', 8332, 'testuser', 'testpass') @@ -111,14 +114,14 @@ describe('BlockchainConnector', () => { describe('#getBlockHash()', () => { it('should return the block hash on success', async () => { - axiosStub.resolves({ data: { result: 'abcdef1234' } }) + axiosStub.resolves({ data: { result: BLOCK_HASH } }) const result = await connector.getBlockHash(100) - assert.strictEqual(result, 'abcdef1234') + assert.strictEqual(result, BLOCK_HASH) }) it('should pass the block index as params', async () => { - axiosStub.resolves({ data: { result: 'hash' } }) + axiosStub.resolves({ data: { result: BLOCK_HASH } }) await connector.getBlockHash(42) const callData = axiosStub.firstCall.args[1] @@ -131,7 +134,7 @@ describe('BlockchainConnector', () => { // (decoder never wrote the REORG event → indexer never rolled back). getBlockHash must // coerce the height to a Number so the JSON-RPC params are serializable. it('should coerce a BigInt block index to a Number param (serializable JSON-RPC body)', async () => { - axiosStub.resolves({ data: { result: 'hash' } }) + axiosStub.resolves({ data: { result: BLOCK_HASH } }) await connector.getBlockHash(199n) const callData = axiosStub.firstCall.args[1] @@ -158,6 +161,46 @@ describe('BlockchainConnector', () => { }) }) +describe('BlockchainConnector', () => { + registerConnectorHooks() + + describe('#getBlockHash() result shape', () => { + // The reorg walk deletes a stored block on a hash mismatch, so a malformed + // answer must throw into the caller's retry instead of reading as a fork. + it('rejects a malformed result and counts it as an RPC error', async () => { + const bad = ['', 0, false, {}, [], 12345, 'a'.repeat(63), 'a'.repeat(65), 'g' + 'a'.repeat(63)] + for (const result of bad) { + axiosStub.resolves({ data: { result } }) + const before = connector.rpcErrors + await assert.rejects(() => connector.getBlockHash(1), /malformed result/, JSON.stringify(result)) + assert.strictEqual(connector.rpcErrors, before + 1, JSON.stringify(result)) + } + }) + + it('returns an uppercase hash lowercased so a case change cannot fake a fork', async () => { + axiosStub.resolves({ data: { result: BLOCK_HASH.toUpperCase() } }) + assert.strictEqual(await connector.getBlockHash(1), BLOCK_HASH) + }) + }) +}) + +describe('BlockchainConnector', () => { + registerConnectorHooks() + + // The HTTP-200 twin of the HTTP-500 rpcCode test: the error leaves the shared + // ladder with the node's code and message attached, counted once, never retried. + it('getBlockHash rethrows an HTTP-200 JSON-RPC error carrying rpcCode/rpcMessage', async () => { + axiosStub.resolves({ status: 200, data: { result: null, error: { code: -8, message: 'Block height out of range' } } }) + await assert.rejects(() => connector.getBlockHash(999999999), (err) => { + assert.strictEqual(err.rpcCode, -8) + assert.strictEqual(err.rpcMessage, 'Block height out of range') + return true + }) + assert.strictEqual(axiosStub.callCount, 1) + assert.strictEqual(connector.rpcErrors, 1) + }) +}) + describe('BlockchainConnector', () => { registerConnectorHooks() @@ -261,10 +304,10 @@ describe('BlockchainConnector', () => { describe('#getRawTransaction()', () => { it('should return raw tx hex on success', async () => { - axiosStub.resolves({ data: { result: 'rawtxhex' } }) + axiosStub.resolves({ data: { result: '0100beef' } }) const result = await connector.getRawTransaction('txid123') - assert.strictEqual(result, 'rawtxhex') + assert.strictEqual(result, '0100beef') }) it('should resolve null when response has no result (tx mined/evicted)', async () => { @@ -290,10 +333,10 @@ describe('BlockchainConnector', () => { it('should resolve on success after retries', async () => { axiosStub.onCall(0).rejects(new Error('fail')) axiosStub.onCall(1).rejects(new Error('fail')) - axiosStub.onCall(2).resolves({ data: { result: 'txhex' } }) + axiosStub.onCall(2).resolves({ data: { result: 'aa01' } }) const result = await connector.getRawTransaction('txid') - assert.strictEqual(result, 'txhex') + assert.strictEqual(result, 'aa01') }).timeout(5000) it('[REGRESSION P2] R-RPC-002: should back off longer on -429 work queue depth exceeded', async () => { @@ -302,13 +345,13 @@ describe('BlockchainConnector', () => { // a JSON body whose error.code === -429 (NOT an actual HTTP 429 response). queueError.response = { data: { error: { code: -429, message: 'Work queue depth exceeded' } } } axiosStub.onCall(0).rejects(queueError) - axiosStub.onCall(1).resolves({ data: { result: 'txhex' } }) + axiosStub.onCall(1).resolves({ data: { result: 'aa01' } }) const start = Date.now() const result = await connector.getRawTransaction('txid') const elapsed = Date.now() - start - assert.strictEqual(result, 'txhex') + assert.strictEqual(result, 'aa01') // -429 backoff is 5000ms vs normal 500ms assert.ok(elapsed >= 4000, `Expected >= 4000ms backoff, got ${elapsed}ms`) }).timeout(10000) @@ -320,11 +363,11 @@ describe('BlockchainConnector', () => { describe('#getRawTransactions()', () => { it('should batch multiple getRawTransaction calls', async () => { - axiosStub.resolves({ data: { result: 'txhex' } }) + axiosStub.resolves({ data: { result: 'aa01' } }) const results = await connector.getRawTransactions(['tx1', 'tx2', 'tx3']) assert.strictEqual(results.length, 3) - assert.strictEqual(results[0], 'txhex') + assert.strictEqual(results[0], 'aa01') }) it('should return empty array for empty input', async () => { @@ -336,12 +379,12 @@ describe('BlockchainConnector', () => { // tx2 was evicted between getRawMempool and the fetch: empty RPC result. // The batch must still return the other txs with a null hole for the missing one, // rather than rejecting and dropping every txid in the batch. - axiosStub.onCall(0).resolves({ data: { result: 'txhex1' } }) + axiosStub.onCall(0).resolves({ data: { result: 'aa11' } }) axiosStub.onCall(1).resolves({ data: { result: null, error: { message: 'tx not found' } } }) - axiosStub.onCall(2).resolves({ data: { result: 'txhex3' } }) + axiosStub.onCall(2).resolves({ data: { result: 'aa33' } }) const results = await connector.getRawTransactions(['tx1', 'tx2', 'tx3']) - assert.deepStrictEqual(results, ['txhex1', null, 'txhex3']) + assert.deepStrictEqual(results, ['aa11', null, 'aa33']) }) }) }) diff --git a/test/unit/blockchain_connector_extra.test.js b/test/unit/blockchain_connector_extra.test.js index 334aab59..2c01b360 100644 --- a/test/unit/blockchain_connector_extra.test.js +++ b/test/unit/blockchain_connector_extra.test.js @@ -245,10 +245,10 @@ describe('BlockchainConnector (extra coverage)', () => { const timeoutErr = Object.assign(new Error('timeout'), { code: 'ECONNABORTED' }) axiosStub.onCall(0).rejects(timeoutErr) - axiosStub.onCall(1).resolves({ data: { result: 'somehash' } }) + axiosStub.onCall(1).resolves({ data: { result: 'ab'.repeat(32) } }) const result = await connector.getBlockHash(100) - assert.strictEqual(result, 'somehash') + assert.strictEqual(result, 'ab'.repeat(32)) assert.strictEqual(axiosStub.callCount, 2) }) @@ -292,10 +292,10 @@ describe('BlockchainConnector (extra coverage)', () => { it('should retry on ECONNABORTED and succeed on a later attempt', async () => { const abortErr = Object.assign(new Error('timeout'), { code: 'ECONNABORTED' }) axiosStub.onCall(0).rejects(abortErr) - axiosStub.onCall(1).resolves({ data: { result: 'txhex' } }) + axiosStub.onCall(1).resolves({ data: { result: 'aa01' } }) const result = await connector.getRawTransaction('txid') - assert.strictEqual(result, 'txhex') + assert.strictEqual(result, 'aa01') assert.strictEqual(axiosStub.callCount, 2) }).timeout(5000) }) @@ -311,13 +311,13 @@ describe('BlockchainConnector (extra coverage)', () => { it('should back off longer on ECONNRESET (Dogecoin queue-full signal)', async () => { const resetErr = Object.assign(new Error('reset'), { code: 'ECONNRESET' }) axiosStub.onCall(0).rejects(resetErr) - axiosStub.onCall(1).resolves({ data: { result: 'txhex' } }) + axiosStub.onCall(1).resolves({ data: { result: 'aa01' } }) const start = Date.now() const result = await connector.getRawTransaction('txid') const elapsed = Date.now() - start - assert.strictEqual(result, 'txhex') + assert.strictEqual(result, 'aa01') // ECONNRESET backoff is 5000ms assert.ok(elapsed >= 4000, `Expected >= 4000ms, got ${elapsed}ms`) }).timeout(10000) @@ -376,10 +376,10 @@ describe('BlockchainConnector (extra coverage)', () => { const timeoutErr = Object.assign(new Error('timeout'), { code: 'ECONNABORTED' }) axiosStub.onCall(0).rejects(timeoutErr) axiosStub.onCall(1).rejects(timeoutErr) - axiosStub.onCall(2).resolves({ data: { result: 'blockhash' } }) + axiosStub.onCall(2).resolves({ data: { result: 'cd'.repeat(32) } }) const result = await connector.getBlockHash(100) - assert.strictEqual(result, 'blockhash') + assert.strictEqual(result, 'cd'.repeat(32)) assert.strictEqual(backoffSpy.callCount, 2) }) diff --git a/test/unit/blockchain_connector_review_fixes.test.js b/test/unit/blockchain_connector_review_fixes.test.js index 0126328e..d2d38654 100644 --- a/test/unit/blockchain_connector_review_fixes.test.js +++ b/test/unit/blockchain_connector_review_fixes.test.js @@ -47,10 +47,10 @@ describe('BlockchainConnector RPC error accounting and reporting', () => { it('does NOT increment rpcErrors when the fetch succeeds on the 10th attempt', async () => { const timeoutErr = Object.assign(new Error('timeout'), { code: 'ECONNABORTED' }) for (let i = 0; i < 9; i++) axiosStub.onCall(i).rejects(timeoutErr) - axiosStub.onCall(9).resolves({ data: { result: 'txhex' } }) + axiosStub.onCall(9).resolves({ data: { result: 'aa01' } }) const result = await connector.getRawTransaction('txid') - assert.strictEqual(result, 'txhex') + assert.strictEqual(result, 'aa01') assert.strictEqual(axiosStub.callCount, 10) assert.strictEqual(connector.rpcErrors, 0, 'a recovered fetch on the last attempt must not report an RPC error') @@ -185,13 +185,13 @@ describe('BlockchainConnector RPC error accounting and reporting', () => { // the contract for the next one rather than a behaviour change. it('returns a falsy-but-present result instead of throwing', async () => { axiosStub.resolves({ data: { result: '' } }) - assert.strictEqual(await connector.getBlockHash(0), '') + assert.strictEqual(await connector.getBlockchainInfo(), '') axiosStub.resolves({ data: { result: 0 } }) - assert.strictEqual(await connector.getBlockHash(0), 0) + assert.strictEqual(await connector.getBlockchainInfo(), 0) axiosStub.resolves({ data: { result: false } }) - assert.strictEqual(await connector.getBlockHash(0), false) + assert.strictEqual(await connector.getBlockchainInfo(), false) assert.strictEqual(connector.rpcErrors, 0, 'a valid falsy result is not an RPC failure') }).timeout(5000) @@ -275,10 +275,10 @@ const LADDER_METHODS = [ function blockPathRoutingTests() { it('routes every block-path method through the shared ladder', async () => { const seen = [] - connector.rpcCallWithTimeoutRetry = async (data) => { seen.push(data.method); return 'ok' } + connector.rpcCallWithTimeoutRetry = async (data) => { seen.push(data.method); return data.method === 'getblockhash' ? 'ab'.repeat(32) : 'ok' } - for (const [name, args] of LADDER_METHODS) { - assert.strictEqual(await connector[name](...args), 'ok', + for (const [name, args, rpc] of LADDER_METHODS) { + assert.strictEqual(await connector[name](...args), rpc === 'getblockhash' ? 'ab'.repeat(32) : 'ok', `${name} must go through the shared ladder, not a private copy`) } assert.deepStrictEqual(seen, LADDER_METHODS.map(([, , rpc]) => rpc)) diff --git a/test/unit/chain/blockchain_connector/rpc_helpers_config.test.js b/test/unit/chain/blockchain_connector/rpc_helpers_config.test.js new file mode 100644 index 00000000..f31e833f --- /dev/null +++ b/test/unit/chain/blockchain_connector/rpc_helpers_config.test.js @@ -0,0 +1,112 @@ +/********************************************************************* + * + * 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' + +const assert = require('assert') +const { logger } = require('../../../../src/chain/blockchain_connector/constants.js') +const { + envInt, + normalizeEndpoint, + nodeReachabilityFrom, +} = require('../../../../src/chain/blockchain_connector/rpc_helpers.js') + +describe('envInt', () => { + let originalWarn + let warnings + + beforeEach(() => { + originalWarn = logger.warn + warnings = [] + logger.warn = (...args) => warnings.push(args) + }) + + afterEach(() => { + logger.warn = originalWarn + }) + + it('uses the fallback for missing, empty, and invalid values', () => { + for (const raw of [undefined, null, '', 0, 'x', -2]) { + assert.strictEqual(envInt(raw, 7, 'RPC_TIMEOUT'), 7) + } + }) + + it('trims valid integers and honors a custom minimum', () => { + assert.strictEqual(envInt(' 8 ', 7, 'RPC_TIMEOUT'), 8) + assert.strictEqual(envInt(-2, 7, 'RPC_TIMEOUT', -5), -2) + assert.strictEqual(envInt(0, 7, 'RPC_TIMEOUT', 0), 0) + }) + + it('warns for an empty value but not an undefined value', () => { + envInt('', 7, 'RPC_TIMEOUT') + assert.strictEqual(warnings.length, 1) + + warnings.length = 0 + envInt(undefined, 7, 'RPC_TIMEOUT') + assert.strictEqual(warnings.length, 0) + }) +}) + +describe('normalizeEndpoint', () => { + it('adds missing protocols and ports while preserving complete URLs', () => { + assert.strictEqual(normalizeEndpoint('node1', 8332), 'http://node1:8332') + assert.strictEqual(normalizeEndpoint('https://a.b:99', 8332), 'https://a.b:99') + assert.strictEqual(normalizeEndpoint(' http://h ', 1), 'http://h:1') + }) + + it('rejects malformed endpoints', () => { + assert.throws( + () => normalizeEndpoint('a:b:c', 8332), + /^Error: BlockchainConnector: invalid RPC endpoint: a:b:c$/, + ) + }) +}) + +describe('nodeReachabilityFrom', () => { + it('reports an unknown state before any node attempt', () => { + assert.deepStrictEqual(nodeReachabilityFrom(1000, 0, 0, 5000), { + node_last_ok_at: null, + node_unreachable: null, + }) + }) + + it('reports the last success when the latest attempt succeeded', () => { + assert.deepStrictEqual(nodeReachabilityFrom(1000, 4000, 3000, 6000), { + node_last_ok_at: '1970-01-01T00:00:04.000Z', + node_unreachable: null, + }) + }) + + it('dates an initial outage from connector startup', () => { + assert.deepStrictEqual(nodeReachabilityFrom(1000, 0, 500, 6000), { + node_last_ok_at: null, + node_unreachable: { + since: '1970-01-01T00:00:01.000Z', + last_ok_at: null, + seconds: 5, + }, + }) + }) + + it('dates a later outage from the last successful attempt', () => { + assert.deepStrictEqual(nodeReachabilityFrom(1000, 2000, 3000, 6000), { + node_last_ok_at: '1970-01-01T00:00:02.000Z', + node_unreachable: { + since: '1970-01-01T00:00:02.000Z', + last_ok_at: '1970-01-01T00:00:02.000Z', + seconds: 4, + }, + }) + }) +}) diff --git a/test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js b/test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js new file mode 100644 index 00000000..147af892 --- /dev/null +++ b/test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js @@ -0,0 +1,119 @@ +// 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 { + rpcResult, + sanitizeRpcError, +} = require('../../../../src/chain/blockchain_connector/rpc_helpers') + +function assertThrowsLabel(response, label) { + assert.throws( + () => rpcResult(response, label), + (error) => error.message === label + ) +} + +describe('rpcResult errors', () => { + it('returns a positive result', () => { + assert.strictEqual(rpcResult({ data: { result: 5 } }, 'read'), 5) + }) + + it('returns a zero result', () => { + assert.strictEqual(rpcResult({ data: { result: 0 } }, 'read'), 0) + }) + + it('throws the RPC code and message', () => { + const response = { data: { error: { code: -5, message: 'bad' } } } + assert.throws( + () => rpcResult(response, 'read'), + { message: 'read: RPC error -5: bad' } + ) + }) + + it('serializes an RPC error without a string message', () => { + const response = { data: { error: { code: 1 } } } + assert.throws( + () => rpcResult(response, 'read'), + { message: 'read: RPC error 1: {"code":1}' } + ) + }) + + // A JSON-RPC 2.0 node sends its error with HTTP 200, so the fields an HTTP-500 + // error gets from sanitizeRpcError must be attached here too. + it('attaches a non-enumerable rpcCode and rpcMessage to an HTTP-200 RPC error', () => { + const response = { data: { error: { code: -8, message: 'Block height out of range' } } } + assert.throws(() => rpcResult(response, 'read'), (error) => { + assert.strictEqual(error.message, 'read: RPC error -8: Block height out of range') + assert.strictEqual(error.rpcCode, -8) + assert.strictEqual(error.rpcMessage, 'Block height out of range') + assert.strictEqual(error.propertyIsEnumerable('rpcCode'), false) + assert.strictEqual(error.propertyIsEnumerable('rpcMessage'), false) + return true + }) + }) + + it('leaves rpcMessage undefined for a non-string message and rpcCode undefined for no code', () => { + assert.throws(() => rpcResult({ data: { error: { code: 1 } } }, 'read'), (error) => + error.rpcCode === 1 && error.rpcMessage === undefined) + assert.throws(() => rpcResult({ data: { error: { message: 'x' } } }, 'read'), (error) => + error.message === 'read: RPC error unknown: x' && error.rpcCode === undefined && error.rpcMessage === 'x') + }) + + it('throws only the label for a null response', () => { + assertThrowsLabel(null, 'missing') + }) + + it('throws only the label for an absent result', () => { + assertThrowsLabel({ data: {} }, 'missing') + }) + + it('throws only the label for a null result', () => { + assertThrowsLabel({ data: { result: null } }, 'missing') + }) +}) + +describe('sanitizeRpcError', () => { + it('scrubs transport details and preserves RPC details', () => { + const error = Object.assign(new Error('boom'), { + config: { + auth: { username: 'user', password: 'secret' }, + headers: { Authorization: 'Basic secret', Other: 'kept' }, + }, + request: { socket: 'request' }, + response: { + status: 500, + data: { error: { code: -28, message: 'warming' } }, + }, + }) + + assert.strictEqual(sanitizeRpcError(error), 'boom (RPC error -28: warming)') + assert.strictEqual(error.config.auth, undefined) + assert.deepStrictEqual(error.config.headers, { Other: 'kept' }) + assert.strictEqual(error.request, undefined) + assert.deepStrictEqual(error.response, { status: 500 }) + assert.strictEqual(error.rpcCode, -28) + assert.strictEqual(error.rpcMessage, 'warming') + assert.strictEqual(error.propertyIsEnumerable('rpcCode'), false) + assert.strictEqual(error.propertyIsEnumerable('rpcMessage'), false) + }) + + it('returns the message of an error without a response', () => { + assert.strictEqual(sanitizeRpcError(new Error('plain')), 'plain') + }) + + it('returns a string unchanged', () => { + assert.strictEqual(sanitizeRpcError('text'), 'text') + }) + + it('returns null as a string', () => { + assert.strictEqual(sanitizeRpcError(null), 'null') + }) +}) diff --git a/test/unit/coins_conformance.test.js b/test/unit/coins_conformance.test.js index d4781aac..39eb1279 100644 --- a/test/unit/coins_conformance.test.js +++ b/test/unit/coins_conformance.test.js @@ -34,12 +34,11 @@ const path = require('path'); const coins = require('../../src/coins'); const { CONSENSUS_CONFIG_PIN } = require('../../src/coins/consensus_pin.js'); +const { siblingCheckout, skipOrFail } = require('../helpers/sibling_checkout.js'); const LOCAL_COINS_DIR = path.join(__dirname, '..', '..', 'src', 'coins'); const HUB_DIR = process.env.XCHAIN_HUB_DIR || path.join(__dirname, '..', '..', '..', 'xchain-hub'); const CANON_DIR = path.join(HUB_DIR, 'src', 'coins'); -const CANON_PRESENT = fs.existsSync(CANON_DIR); -const REQUIRE_SIBLINGS = process.env.XCHAIN_REQUIRE_SIBLINGS === '1'; // Keep in lockstep with FILES in xchain-hub/bin/sync-coins.sh. const VENDORED_FILES = ['BTC.js', 'LTC.js', 'DOGE.js', 'index.js', 'consensus_pin.js']; @@ -82,11 +81,8 @@ describe('coin-registry conformance (vendored copy) @regression', function(){ describe('byte-identity to canonical xchain-hub/src/coins', function(){ before(function(){ - if(!CANON_PRESENT){ - if(REQUIRE_SIBLINGS) - throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but canonical xchain-hub coins dir not found at ' + CANON_DIR); - this.skip(); - } + const hub = siblingCheckout(__dirname, CANON_DIR); + if(!hub.usable) skipOrFail(this, hub, 'the canonical xchain-hub coins byte-identity guard'); }); VENDORED_FILES.forEach(function(f){ diff --git a/test/unit/compiled_push_size_conformance.test.js b/test/unit/compiled_push_size_conformance.test.js index 83518ca0..fee420ba 100644 --- a/test/unit/compiled_push_size_conformance.test.js +++ b/test/unit/compiled_push_size_conformance.test.js @@ -26,8 +26,8 @@ 'use strict'; const assert = require('assert'); -const fs = require('fs'); const path = require('path'); +const { siblingCheckout, skipOrFail } = require('../helpers/sibling_checkout.js'); const bitcoin = require('bitcoinjs-lib'); const XChainDecoder = require('../../src/XChainDecoder.js'); @@ -76,12 +76,12 @@ describe('compiled-push-size arbiter conformance', function () { describe('compiled-push-size arbiter conformance', function () { // CONFORMANCE: the encoder's emit-side helper must be the same function. - // Skips when the sibling xchain-encoder is not checked out. + // Skips when xchain-encoder is absent or is a lane symlink into a live main checkout. describe('parity with the encoder compiledPushSize', function () { const ENCODER = process.env.XCHAIN_ENCODER_DIR || path.join(__dirname, '..', '..', '..', 'xchain-encoder'); const VALIDATOR = path.join(ENCODER, 'src', 'common', 'validator.js'); - before(function () { if (!fs.existsSync(VALIDATOR)) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('xchain-encoder sibling not found at ' + VALIDATOR + ' but XCHAIN_REQUIRE_SIBLINGS=1'); this.skip(); } }); + before(function () { skipOrFail(this, siblingCheckout(__dirname, VALIDATOR), 'the encoder compiledPushSize parity guard'); }); it('agrees with the decoder helper for every length up to the ceiling', function () { const encoderPushSize = require(VALIDATOR).compiledPushSize; @@ -108,7 +108,7 @@ describe('compiled-push-size arbiter conformance', function () { const ENCODER = process.env.XCHAIN_ENCODER_DIR || path.join(__dirname, '..', '..', '..', 'xchain-encoder'); const VALIDATOR = path.join(ENCODER, 'src', 'common', 'validator.js'); - before(function () { if (!fs.existsSync(VALIDATOR)) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('xchain-encoder sibling not found at ' + VALIDATOR + ' but XCHAIN_REQUIRE_SIBLINGS=1'); this.skip(); } }); + before(function () { skipOrFail(this, siblingCheckout(__dirname, VALIDATOR), 'the encoder compiledPushSize parity guard'); }); // The envelope band, which the sweep above cannot reach. // @@ -149,7 +149,7 @@ describe('compiled-push-size arbiter conformance', function () { const ENCODER = process.env.XCHAIN_ENCODER_DIR || path.join(__dirname, '..', '..', '..', 'xchain-encoder'); const VALIDATOR = path.join(ENCODER, 'src', 'common', 'validator.js'); - before(function () { if (!fs.existsSync(VALIDATOR)) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('xchain-encoder sibling not found at ' + VALIDATOR + ' but XCHAIN_REQUIRE_SIBLINGS=1'); this.skip(); } }); + before(function () { skipOrFail(this, siblingCheckout(__dirname, VALIDATOR), 'the encoder compiledPushSize parity guard'); }); describe('envelope push band (0xffff .. ENVELOPE_MAX_PAYLOAD)', function () { const BAND = [8192, 65534, 65535, 65536, 65537, 200000, 390000]; @@ -203,3 +203,87 @@ describe('compiled-push-size arbiter conformance', function () { }); }); }); + +function describeCatchingUp(helpers) { + describe('nodeStillCatchingUp', function () { + it('is true only for a strict true initialblockdownload', function () { + assert.strictEqual(helpers.nodeStillCatchingUp({ initialblockdownload: true }), true); + assert.strictEqual(helpers.nodeStillCatchingUp({ initialblockdownload: false }), false); + assert.strictEqual(helpers.nodeStillCatchingUp({ initialblockdownload: 'true' }), false); + assert.strictEqual(helpers.nodeStillCatchingUp({}), false); + assert.strictEqual(helpers.nodeStillCatchingUp(null), false); + assert.strictEqual(helpers.nodeStillCatchingUp(undefined), false); + }); + }); +} + +function describePushSize(helpers) { + describe('compiledPushSize', function () { + const size = helpers.compiledPushSize; + + it('adds one byte through 75, two through 255, three from 256', function () { + const cases = [[0, 1], [1, 2], [75, 76], [76, 78], [255, 257], [256, 259]]; + for (const [len, want] of cases) assert.strictEqual(size(len), want, `length ${len}`); + }); + + it('keeps the three-byte overhead across 65535 and 65536', function () { + assert.strictEqual(size(65535), 65538); + assert.strictEqual(size(65536), 65539); + }); + }); +} + +function describeCanonicalize(helpers) { + describe('canonicalizeActionPayload', function () { + const canon = helpers.canonicalizeActionPayload; + + it('expands an alias and preserves bytes after the first pipe', function () { + const input = Buffer.from('TRANSFER|a|b'); + const out = canon(input); + assert.strictEqual(out.buffer.toString(), 'SEND|a|b'); + assert.strictEqual(out.rawActionName, 'TRANSFER'); + assert.strictEqual(out.actionName, 'SEND'); + assert.strictEqual(out.isKnown, true); + assert.strictEqual(canon(out.buffer).buffer.toString(), 'SEND|a|b'); + }); + + it('returns the original buffer for a canonical or unknown name', function () { + const known = Buffer.from('SEND|x'); + assert.strictEqual(canon(known).buffer, known); + const unknown = Buffer.from('NOPE|x'); + const out = canon(unknown); + assert.strictEqual(out.buffer, unknown); + assert.strictEqual(out.isKnown, false); + }); + + it('treats a pipe-free payload as all name', function () { + const out = canon(Buffer.from('ADDR')); + assert.strictEqual(out.buffer.toString(), 'ADDRESS'); + assert.strictEqual(out.actionName, 'ADDRESS'); + }); + }); +} + +function describeBufferutils(helpers) { + describe('bigIntBufferutilsActive', function () { + it('reports the installed bufferutils as patched', function () { + assert.strictEqual(helpers.bigIntBufferutilsActive(), true); + }); + + it('fails safe for a missing reader or a throwing reader', function () { + assert.strictEqual(helpers.bigIntBufferutilsActive({}), false); + const throwing = { BufferReader: class { readUInt64() { throw new Error('value out of range'); } } }; + assert.strictEqual(helpers.bigIntBufferutilsActive(throwing), false); + const tolerant = { BufferReader: class { readUInt64() { return 2n ** 53n; } } }; + assert.strictEqual(helpers.bigIntBufferutilsActive(tolerant), true); + }); + }); +} + +describe('payload_helpers pure functions', function () { + const helpers = require('../../src/XChainDecoder/payload_helpers.js'); + describeCatchingUp(helpers); + describePushSize(helpers); + describeCanonicalize(helpers); + describeBufferutils(helpers); +}); diff --git a/test/unit/db.test/07_database_alter_table_for_drift_relax.test.js b/test/unit/db.test/07_database_alter_table_for_drift_relax.test.js new file mode 100644 index 00000000..4737b8b4 --- /dev/null +++ b/test/unit/db.test/07_database_alter_table_for_drift_relax.test.js @@ -0,0 +1,79 @@ +// 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 fs = require('fs') +const os = require('os') +const path = require('path') +const Database = require('../../../src/db.js') + +// One table whose every column the source declares nullable; the live rows decide +// which ones the reconciler may relax with a bare MODIFY. +const SOURCE = 'CREATE TABLE t (\n id INT NULL,\n a VARCHAR(10) NULL,\n b INT NULL\n) ENGINE=InnoDB;\n' + +const liveCol = (name, over) => Object.assign({ + COLUMN_NAME: name, IS_NULLABLE: 'NO', COLUMN_TYPE: 'int(11)', COLUMN_KEY: '', EXTRA: '', + COLUMN_DEFAULT: null, COLUMN_COMMENT: '', GENERATION_EXPRESSION: '', COLLATION_NAME: null, +}, over) + +// Fill in every source column the case does not name as already nullable, so only the named ones drift. +const withRest = (rows) => rows.concat(['id', 'a', 'b'].filter(n => !rows.some(r => r.COLUMN_NAME === n)).map(n => liveCol(n, { IS_NULLABLE: 'YES' }))) + +// Run alterTableForDrift against fake live columns and return the ALTERs it issued. +async function altersFor(liveRows) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'decoder-drift-relax-')) + fs.writeFileSync(path.join(dir, 't.sql'), SOURCE) + const db = new Database('127.0.0.1', 3306, 'test_db', 'user', 'pass') + db.sqlPath = dir + const alters = [] + const conn = { query: async (sql) => { + if (/information_schema\.columns/i.test(sql)) return withRest(liveRows) + alters.push(sql) + return [] + } } + await db.alterTableForDrift('t.sql', conn) + return alters +} + +describe('Database#alterTableForDrift() NOT NULL relax keeps live attributes', () => { + it('reads every attribute a bare MODIFY could drop from the live column', async () => { + let probe = '' + const db = new Database('127.0.0.1', 3306, 'test_db', 'user', 'pass') + db.sqlPath = fs.mkdtempSync(path.join(os.tmpdir(), 'decoder-drift-relax-')) + fs.writeFileSync(path.join(db.sqlPath, 't.sql'), SOURCE) + await db.alterTableForDrift('t.sql', { query: async (sql) => { if (!probe) probe = sql; return [] } }) + for (const col of ['COLUMN_DEFAULT', 'COLUMN_COMMENT', 'GENERATION_EXPRESSION', 'COLLATION_NAME']) + assert.match(probe, new RegExp(col), 'live query must read ' + col) + }) + + it('skips the relax when the live column carries a DEFAULT, COMMENT, ON UPDATE or generation expression', async () => { + for (const over of [ + { COLUMN_DEFAULT: '0' }, + { COLUMN_COMMENT: 'why' }, + { EXTRA: 'on update current_timestamp()' }, + { GENERATION_EXPRESSION: '`a` + 1', EXTRA: 'VIRTUAL GENERATED' }, + ]) { + assert.deepStrictEqual(await altersFor([liveCol('b', over)]), [], JSON.stringify(over)) + } + }) + + it('restates the live collation when it relaxes a clean column', async () => { + const alters = await altersFor([liveCol('a', { COLUMN_TYPE: 'varchar(10)', COLLATION_NAME: 'utf8mb4_bin' }), liveCol('b')]) + assert.deepStrictEqual(alters, [ + 'ALTER TABLE `t` MODIFY `a` varchar(10) COLLATE utf8mb4_bin NULL', + 'ALTER TABLE `t` MODIFY `b` int(11) NULL', + ]) + }) + + it('still never relaxes a primary-key or AUTO_INCREMENT column', async () => { + const alters = await altersFor([liveCol('id', { COLUMN_KEY: 'PRI' }), liveCol('b', { EXTRA: 'auto_increment' })]) + assert.deepStrictEqual(alters, []) + }) +}) diff --git a/test/unit/db/migration_query_timeout.test.js b/test/unit/db/migration_query_timeout.test.js new file mode 100644 index 00000000..bbce0b32 --- /dev/null +++ b/test/unit/db/migration_query_timeout.test.js @@ -0,0 +1,113 @@ +'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. + +const assert = require('assert'); + +const CONFIG_PATH = require.resolve('../../../src/config'); +const MODULE_PATH = require.resolve('../../../src/db/migration_query_timeout'); +const RELEASE_LOCK_SQL = 'SELECT RELEASE_LOCK(?)'; +const SET_TIMEOUT_SQL = 'SET SESSION max_statement_time = ?'; + +function reloadModule() { + delete require.cache[CONFIG_PATH]; + delete require.cache[MODULE_PATH]; + return require(MODULE_PATH); +} + +async function withTimeoutEnvironment(value, action) { + const saved = process.env.MIGRATE_QUERY_TIMEOUT; + process.env.MIGRATE_QUERY_TIMEOUT = value; + try { return await action(reloadModule()); } + finally { + if (saved === undefined) delete process.env.MIGRATE_QUERY_TIMEOUT; + else process.env.MIGRATE_QUERY_TIMEOUT = saved; + delete require.cache[CONFIG_PATH]; + delete require.cache[MODULE_PATH]; + } +} + +function recordingConnection(rejectedTimeoutSeconds) { + const calls = []; + let destroyCalls = 0; + return { + calls, + get destroyCalls() { return destroyCalls; }, + async query(sql, params) { + calls.push({ sql, params }); + if (sql === SET_TIMEOUT_SQL && params[0] === rejectedTimeoutSeconds) { + throw new Error('restore failed'); + } + }, + async destroy() { destroyCalls += 1; }, + }; +} + +describe('db/migration_query_timeout', function () { + it('exports a one-hour default migration timeout', function () { + const { DEFAULT_MIGRATE_QUERY_TIMEOUT_MS } = reloadModule(); + assert.strictEqual(DEFAULT_MIGRATE_QUERY_TIMEOUT_MS, 3600000); + }); + + for (const [raw, expected] of [['0', 0], ['5000', 5000], ['', 3600000], ['abc', 3600000], ['-1', 3600000]]) { + it(`reads MIGRATE_QUERY_TIMEOUT=${JSON.stringify(raw)}`, async function () { + await withTimeoutEnvironment(raw, ({ migrationQueryTimeoutMs }) => { + assert.strictEqual(migrationQueryTimeoutMs(), expected); + }); + }); + } + + it('releases an untouched session without changing its timeout', async function () { + const { migrationSession } = reloadModule(); + const conn = recordingConnection(); + const released = await migrationSession(conn, 'migration-lock', 30000).release(); + + assert.strictEqual(released, true); + assert.deepStrictEqual(conn.calls, [ + { sql: RELEASE_LOCK_SQL, params: ['migration-lock'] }, + ]); + }); + + it('activates once, restores the runtime timeout, and releases the lock', async function () { + await withTimeoutEnvironment('5000', async ({ migrationSession }) => { + const conn = recordingConnection(); + const session = migrationSession(conn, 'migration-lock', 30000); + + await session.activate(); + await session.activate(); + const released = await session.release(); + + assert.strictEqual(released, true); + assert.deepStrictEqual(conn.calls, [ + { sql: SET_TIMEOUT_SQL, params: [5] }, + { sql: SET_TIMEOUT_SQL, params: [30] }, + { sql: RELEASE_LOCK_SQL, params: ['migration-lock'] }, + ]); + }); + }); + + it('releases and destroys the connection when timeout restoration fails', async function () { + const { migrationSession } = reloadModule(); + const conn = recordingConnection(30); + const session = migrationSession(conn, 'migration-lock', 30000); + + await session.activate(); + const released = await session.release(); + + assert.strictEqual(released, false); + assert.deepStrictEqual(conn.calls, [ + { sql: SET_TIMEOUT_SQL, params: [3600] }, + { sql: SET_TIMEOUT_SQL, params: [30] }, + { sql: RELEASE_LOCK_SQL, params: ['migration-lock'] }, + ]); + assert.strictEqual(conn.destroyCalls, 1); + }); +}); diff --git a/test/unit/db_queries.test/04_delete_block_by_index.test.js b/test/unit/db_queries.test/04_delete_block_by_index.test.js index 469e0763..ce00a178 100644 --- a/test/unit/db_queries.test/04_delete_block_by_index.test.js +++ b/test/unit/db_queries.test/04_delete_block_by_index.test.js @@ -43,10 +43,26 @@ function injectPool(db, pool) { db.pool = pool; } +// The resurrect UPDATE (clearing expiry marks left by this now-orphaned block) +// must run BEFORE the dispenser row-delete, so a dispenser expired by this block +// is restored on reorg; the extension restore must run before the undo rows it +// reads are deleted. +function assertRestoreOrdering(calls) { + const resurrectIdx = calls.findIndex(s => /UPDATE\s+dispensers\s+SET\s+expired_block_index\s*=\s*NULL/i.test(s)); + const dispDeleteIdx = calls.findIndex(s => /DELETE\s+FROM\s+dispensers/i.test(s)); + assert.ok(resurrectIdx >= 0, 'must clear soft-expiry marks for the orphaned block'); + assert.ok(resurrectIdx < dispDeleteIdx, 'resurrect UPDATE must precede the dispenser DELETE'); + // The extension restore UPDATE must run before the undo rows it reads are deleted. + const restoreIdx = calls.findIndex(s => /UPDATE\s+dispensers\s+d\s+INNER\s+JOIN\s+dispenser_extension_undo/i.test(s)); + const undoDeleteIdx = calls.findIndex(s => /DELETE\s+FROM\s+dispenser_extension_undo/i.test(s)); + assert.ok(restoreIdx >= 0, 'must restore pre-extend expirations for the orphaned block'); + assert.ok(restoreIdx < undoDeleteIdx, 'restore UPDATE must precede the undo DELETE'); +} + describe('Database#deleteBlockByIndex()', () => { afterEach(() => sinon.restore()); - it('executes 4 DELETE queries and returns true on success', async () => { + it('executes 5 DELETE queries and returns true on success', async () => { const db = makeDb(); const q = sinon.stub().resolves([]); const { pool, conn } = withConn(q); @@ -55,8 +71,9 @@ describe('Database#deleteBlockByIndex()', () => { assert.strictEqual(r, true); const calls = conn.query.getCalls().map(c => c.args[0]); // Explicit per-table checks instead of a magic DELETE count: the rollback - // must touch exactly transaction_outputs, dispensers, transactions, blocks. - for (const table of ['transaction_outputs', 'dispensers', 'transactions', 'blocks']) { + // must touch exactly transaction_outputs, dispensers, dispenser_extension_undo, + // transactions, blocks. + for (const table of ['transaction_outputs', 'dispensers', 'dispenser_extension_undo', 'transactions', 'blocks']) { assert.ok( calls.some(s => new RegExp(`DELETE\\s+FROM\\s+${table}\\b`, 'i').test(s)), `must DELETE FROM ${table} on reorg rollback` @@ -71,14 +88,8 @@ describe('Database#deleteBlockByIndex()', () => { 'events/index_addresses must never be deleted on reorg' ); const deletes = calls.filter(s => /DELETE/i.test(s)); - assert.strictEqual(deletes.length, 4, 'no additional undeclared DELETE targets'); - // The resurrect UPDATE (clearing expiry marks left by this now-orphaned - // block) must run BEFORE the dispenser row-delete, so a dispenser expired - // by this block is restored on reorg. - const resurrectIdx = calls.findIndex(s => /UPDATE\s+dispensers\s+SET\s+expired_block_index\s*=\s*NULL/i.test(s)); - const dispDeleteIdx = calls.findIndex(s => /DELETE\s+FROM\s+dispensers/i.test(s)); - assert.ok(resurrectIdx >= 0, 'must clear soft-expiry marks for the orphaned block'); - assert.ok(resurrectIdx < dispDeleteIdx, 'resurrect UPDATE must precede the dispenser DELETE'); + assert.strictEqual(deletes.length, 5, 'no additional undeclared DELETE targets'); + assertRestoreOrdering(calls); }); it('throws on query error (propagates after rolling back)', async () => { diff --git a/test/unit/decoder_halt_diagnostics.test.js b/test/unit/decoder_halt_diagnostics.test.js index d38d67e9..32f5d6e0 100644 --- a/test/unit/decoder_halt_diagnostics.test.js +++ b/test/unit/decoder_halt_diagnostics.test.js @@ -186,7 +186,8 @@ describe('api.js GET /status halt surface (source pin)', function () { function statusRouteBody() { const at = src.indexOf("app.get('/status'") assert.ok(at > -1, 'GET /status route missing from api.js') - return src.slice(at, at + 3000) + // Bounded by the next function, not a byte count, so a new field cannot push a pinned one out. + return src.slice(at, src.indexOf('function createJsonRpcController', at)) } it('publishes reorg_halt_checked_at beside reorg_halted', function () { diff --git a/test/unit/decoder_live_heartbeat.test.js b/test/unit/decoder_live_heartbeat.test.js index 4376aed0..91674cb5 100644 --- a/test/unit/decoder_live_heartbeat.test.js +++ b/test/unit/decoder_live_heartbeat.test.js @@ -111,7 +111,7 @@ describe('/live gates on the poll-loop heartbeat', function () { const decoder = caughtUpDecoder(); decoder.lastPollAt = 0; const res = await getLive(liveApp(decoder)); - assert.strictEqual(res.status, 200, 'a long initial sync must not be restarted'); + assert.strictEqual(res.status, 200, 'a decoder still in pre-loop boot (e.g. running migrations) must not be restarted'); assert.strictEqual(res.body.poll_silent, false); assert.strictEqual(res.body.last_poll_at, null); }); @@ -284,3 +284,23 @@ describe('a failed DB ping keeps the halt the decoder already knows', function ( assert.strictEqual(res.body.reorg_halt_checked_at, null); }); }); + +describe('/live keeps a halt whose marker write failed past the probe TTL', function () { + // The state haltReorg leaves when the write fails: halted in memory, nothing + // durable, cache already expired. The re-probe reads no row and must not clear it. + it('still reports reorg_halted with its reason after the re-probe reads no row', async function () { + const decoder = caughtUpDecoder(); + decoder.reorgHalted = true; + decoder.reorgHaltReason = 'over-deep rollback'; + decoder.reorgHaltAt = '2026-10-05T00:00:00.000Z'; + decoder.reorgHaltMarkerPersisted = false; + decoder.reorgHaltCheckedAt = 1; + let reads = 0; + decoder.db.getReorgHaltMarker = async () => { reads++; return { halted: false, at: null, reason: null }; }; + const res = await getLive(liveApp(decoder)); + assert.strictEqual(reads, 1, 'the expired cache must trigger a real re-read'); + assert.strictEqual(res.body.reorg_halted, true); + assert.strictEqual(res.body.reorg_halt_reason, 'over-deep rollback'); + assert.strictEqual(res.body.reorg_halted_at, '2026-10-05T00:00:00.000Z'); + }); +}); diff --git a/test/unit/decoder_tip_stale_surface.test.js b/test/unit/decoder_tip_stale_surface.test.js index 096b8f99..3cd75de2 100644 --- a/test/unit/decoder_tip_stale_surface.test.js +++ b/test/unit/decoder_tip_stale_surface.test.js @@ -250,7 +250,7 @@ describe('registerDecoderMetrics() feed-freshness gauges', function () { }); it('emits no last-poll timestamp before the first iteration, but still reports not-silent', function () { - // lastPollAt 0 means the loop has not run yet (long initial sync), which + // lastPollAt 0 means the loop has not run yet (pre-loop boot), which // isPollSilent() reads as not silent; a 0 timestamp series would read as 1970. const registry = new Registry(); registerDecoderMetrics(registry, makeDecoder()); diff --git a/test/unit/dispenser_address_id_collapse_activation.test.js b/test/unit/dispenser_address_id_collapse_activation.test.js new file mode 100644 index 00000000..ad9e0d53 --- /dev/null +++ b/test/unit/dispenser_address_id_collapse_activation.test.js @@ -0,0 +1,62 @@ +'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. + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const activationPath = path.join(__dirname, '..', '..', 'src', 'protocol', 'constants', 'activation.js'); +const activation = require(activationPath); +const constants = require('../../src/protocol/constants.js'); + +const EXPLICIT_DOCS_DIR = process.env.XCHAIN_DOCS_DIR; +const DOCS_CONSTANTS = EXPLICIT_DOCS_DIR + ? path.join(EXPLICIT_DOCS_DIR, 'protocol', 'constants.js') + : path.join(__dirname, '..', '..', '..', 'xchain-documentation', 'protocol', 'constants.js'); +const NAME = 'DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION'; + +function declaration(source) { + const marker = `const ${NAME} = {`; + const start = source.indexOf(marker); + assert.notStrictEqual(start, -1, `${NAME} declaration is missing`); + const end = source.indexOf('\n};', start); + assert.notStrictEqual(end, -1, `${NAME} declaration is unterminated`); + return source.slice(start, end + 3); +} + +describe('DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION conformance', function () { + + it('keeps mainnet and testnet inert while regtest is genesis-active', function () { + assert.deepStrictEqual(activation[NAME], { + mainnet: 9999999999, + testnet: 9999999999, + regtest: 0, + }); + }); + + it('is re-exported by the public constants module', function () { + assert.ok(activation[NAME]); + assert.strictEqual(constants[NAME], activation[NAME]); + }); + + it('is byte-equal to the canonical declaration in xchain-documentation', function () { + if (!fs.existsSync(DOCS_CONSTANTS)) { + if (EXPLICIT_DOCS_DIR) + assert.fail('XCHAIN_DOCS_DIR does not contain protocol/constants.js'); + this.skip(); + } + + const decoderSource = fs.readFileSync(activationPath, 'utf8'); + const docsSource = fs.readFileSync(DOCS_CONSTANTS, 'utf8'); + assert.strictEqual(declaration(decoderSource), declaration(docsSource)); + }); +}); diff --git a/test/unit/dispenser_address_key.test.js b/test/unit/dispenser_address_key.test.js new file mode 100644 index 00000000..df6368a8 --- /dev/null +++ b/test/unit/dispenser_address_key.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. + +'use strict' + +const assert = require('assert') +const { dispenserAddressKey } = require('../../src/protocol/batch_sub_command_capture/dispenser_address_key.js') + +describe('dispenserAddressKey', function () { + + it('folds ASCII letters to lower case', function () { + assert.strictEqual(dispenserAddressKey('BCRT1QXYZ'), 'bcrt1qxyz') + assert.strictEqual(dispenserAddressKey('mAbC'), dispenserAddressKey('mabc')) + }) + + it('drops trailing spaces', function () { + assert.strictEqual(dispenserAddressKey('mAbC '), 'mabc') + assert.strictEqual(dispenserAddressKey(' '), '') + }) + + it('keeps leading spaces and other characters', function () { + assert.notStrictEqual(dispenserAddressKey(' mabc'), dispenserAddressKey('mabc')) + assert.strictEqual(dispenserAddressKey(' MABC '), ' mabc') + assert.strictEqual(dispenserAddressKey('ÄBC\t\u00a0'), 'Äbc\t\u00a0') + }) + + it('keeps distinct addresses distinct', function () { + assert.notStrictEqual(dispenserAddressKey('mabc'), dispenserAddressKey('mabd')) + assert.strictEqual(typeof dispenserAddressKey('mAbC'), 'string') + }) +}) diff --git a/test/unit/dispenser_cancel_grace.test.js b/test/unit/dispenser_cancel_grace.test.js index 1636ac73..0c041c5b 100644 --- a/test/unit/dispenser_cancel_grace.test.js +++ b/test/unit/dispenser_cancel_grace.test.js @@ -69,12 +69,16 @@ class DispenserModel { constructor(){ this.rows = [] this.captureLoads = [] + this.purgeCalls = [] } async insertDispenser(){ return true } async extendOpenDispenserExpirationBySource(){ return true } async getOpenDispenserOracleAddressBySource(){ return null } async getOpenDispenserOracleAddressesBySource(){ return [] } - async purgeExpiredDispensers(){ return true } + async purgeExpiredDispensers(safeHeight, graceFloor){ + this.purgeCalls.push({ safeHeight, graceFloor }) + return true + } // Mirrors deleteOpenDispensers: stamp open rows whose expiration < minExpiration. // // expiredBlockTime stands in for the `LEFT JOIN blocks eb ON eb.block_index = @@ -179,7 +183,8 @@ function runTwoBlocks(consensusNetwork, expireAt, payAt, model){ insertDispenser: (d) => model.insertDispenser(d), extendOpenDispenserExpirationBySource: (s, e, b) => model.extendOpenDispenserExpirationBySource(s, e, b), deleteOpenDispensers: (b, m) => model.deleteOpenDispensers(b, m), - purgeExpiredDispensers: (h) => model.purgeExpiredDispensers(h), + recordDispenserExtensionUndo: async () => true, + purgeExpiredDispensers: (h, f) => model.purgeExpiredDispensers(h, f), getAllOpenDispenserAddresses: (f) => model.getAllOpenDispenserAddresses(f), getOpenDispenserOracleAddressBySource: (s) => model.getOpenDispenserOracleAddressBySource(s), getOpenDispenserOracleAddressesBySource: (s) => model.getOpenDispenserOracleAddressesBySource(s), @@ -204,9 +209,7 @@ function fundedCancelledDispenser(){ return model } -describe('dispenser cancellation grace: decoder capture outlasts the indexer fill window', function () { - this.timeout(0) - +function registerPostExpiryCaptureTest(){ it('captures a payment made after expiry while the indexer still settles fills', async () => { // The finding's named failure mode, driven end to end. The dispenser is cancelled at // EXPIRATION - 600, so the indexer keeps settling until EXPIRATION + 3000. A payment @@ -227,6 +230,10 @@ describe('dispenser cancellation grace: decoder capture outlasts the indexer fil const payLoad = model.captureLoads[1] assert.strictEqual(payLoad.floor, payAt - DISPENSER_CANCEL_GRACE_SECONDS, 'the block loop must pass the grace floor derived from this block header time') + assert.deepStrictEqual(model.purgeCalls[1], { + safeHeight: 1 - XChainDecoder.DISPENSER_EXPIRE_SAFE_DEPTH, + graceFloor: payAt - DISPENSER_CANCEL_GRACE_SECONDS, + }, 'the purge must use the same committed block time and grace duration') assert.ok(payLoad.set.has(ADDR), 'a payment inside the indexer fill window must still be captured by the decoder') @@ -234,7 +241,9 @@ describe('dispenser cancellation grace: decoder capture outlasts the indexer fil // against the widened set rather than a copy made for the assertion. assert.strictEqual(setsSeenByParse[1], payLoad.set) }) +} +function registerInactiveGateTest(){ it('keeps the unwidened capture set below the flag-day (the other side of the gate)', async () => { // Same blocks, same model, gate DISARMED. Every network in the map is armed at genesis // since the 2026-09-09 ruling, so the below-gate branch is reached by disarming mainnet @@ -257,9 +266,17 @@ describe('dispenser cancellation grace: decoder capture outlasts the indexer fil const payLoad = model.captureLoads[1] assert.strictEqual(payLoad.floor, null, 'below the gate the block loop must pass no floor at all') + assert.strictEqual(model.purgeCalls[1].graceFloor, null, + 'the independent purge gate must preserve the legacy height-only delete') assert.ok(!payLoad.set.has(ADDR), 'below the gate the expired dispenser stays out of the capture set') }) +} + +describe('dispenser cancellation grace: decoder capture outlasts the indexer fill window', function () { + this.timeout(0) + registerPostExpiryCaptureTest() + registerInactiveGateTest() }) describe('dispenser cancellation grace: decoder capture outlasts the indexer fill window', function () { diff --git a/test/unit/dispenser_exact_integer_expiration.test.js b/test/unit/dispenser_exact_integer_expiration.test.js new file mode 100644 index 00000000..a0165ec9 --- /dev/null +++ b/test/unit/dispenser_exact_integer_expiration.test.js @@ -0,0 +1,151 @@ +// Copyright (c) 2025-2026 Dankest, LLC +// SPDX-License-Identifier: AGPL-3.0-or-later + +'use strict' + +const assert = require('assert') +const constants = require('../../src/protocol/constants.js') +const activation = require('../../src/protocol/constants/activation.js') +const { isExactIntegerExpirationActive, isExactIntegerToken } = + require('../../src/XChainDecoder/dispenser_registration.js') +const { DispenserModel, buildDecoder, T0, ADDR, CREATE } = + require('./dispenser_lifecycle_mirror.test/helpers/support.js') + +const NAME = 'EXACT_INTEGER_EXPIRATION_ACTIVATION' + +function restoreActivationAround() { + const saved = {} + + beforeEach(function () { + for (const network of Object.keys(activation[NAME])) + saved[network] = activation[NAME][network] + }) + + afterEach(function () { + for (const network of Object.keys(saved)) + activation[NAME][network] = saved[network] + }) +} + +describe('DISPENSER exact integer EXPIRATION activation', function () { + restoreActivationAround() + + it('is public and UNARMED on every network', function () { + assert.deepStrictEqual(activation[NAME], { + mainnet: null, + testnet: null, + regtest: null, + }) + assert.strictEqual(constants[NAME], activation[NAME]) + for (const network of Object.keys(activation[NAME])) + assert.strictEqual(isExactIntegerExpirationActive(network, 4000000000), false) + assert.strictEqual(isExactIntegerExpirationActive('unknown', 4000000000), false) + assert.strictEqual(isExactIntegerExpirationActive('regtest', NaN), false) + }) + + it('uses inclusive block-time activation semantics', function () { + activation[NAME].regtest = T0 + assert.strictEqual(isExactIntegerExpirationActive('regtest', T0 - 1), false) + assert.strictEqual(isExactIntegerExpirationActive('regtest', T0), true) + assert.strictEqual(isExactIntegerExpirationActive('regtest', T0 + 1), true) + }) + + it('tests integer value in exact decimal space', function () { + for (const value of ['0', '-0', '+12', '12.0', '.0', '1e3', '1000e-3']) + assert.strictEqual(isExactIntegerToken(value), true, value) + for (const value of ['1.5', '.5', '1e-3', '1000.0000000000000001', 'text', '']) + assert.strictEqual(isExactIntegerToken(value), false, value) + }) + +}) + +describe('DISPENSER exact integer EXPIRATION decoding', function () { + restoreActivationAround() + + it('preserves precision-rounded create and edit behavior below the gate', async function () { + const model = new DispenserModel() + const createToken = `${T0 + 1000000}.0000000001` + const editToken = `${T0 + 2000000}.0000000001` + const create = `DISPENSER|0|BTC|TICK|1||10|BTC||1|||||${createToken}` + const edit = `DISPENSER|2|7||${editToken}||` + const decoder = buildDecoder([ + { id: 'create01', action: create, source: ADDR }, + { id: 'edit01', action: edit, source: ADDR }, + ], model) + + await decoder.start() + + assert.strictEqual(model.calls.insert.length, 1) + assert.strictEqual(model.calls.extend.length, 1) + assert.strictEqual(decoder.parseErrors, 0) + }) + + it('rejects a precision-rounded fractional create above the gate', async function () { + activation[NAME].regtest = 0 + const model = new DispenserModel() + const token = `${T0 + 1000000}.0000000001` + const create = `DISPENSER|0|BTC|TICK|1||10|BTC||1|||||${token}` + const decoder = buildDecoder([ + { id: 'create01', action: create, source: ADDR }, + ], model) + + await decoder.start() + + assert.strictEqual(model.calls.insert.length, 0) + assert.strictEqual(decoder.parseErrors, 1) + }) + + it('does not mirror a precision-rounded fractional edit above the gate', async function () { + activation[NAME].regtest = 0 + const model = new DispenserModel() + const token = `${T0 + 2000000}.0000000001` + const edit = `DISPENSER|2|7||${token}||` + const decoder = buildDecoder([ + { id: 'create01', action: CREATE, source: ADDR }, + { id: 'edit01', action: edit, source: ADDR }, + ], model) + + await decoder.start() + + assert.strictEqual(model.calls.insert.length, 1) + assert.strictEqual(model.calls.extend.length, 0) + assert.strictEqual(model.rows[0].expiration, T0 + 1000000) + }) + +}) + +describe('DISPENSER exact integer EXPIRATION valid spellings', function () { + restoreActivationAround() + + it('keeps exactly integral decimal and exponent spellings valid above the gate', async function () { + activation[NAME].regtest = 0 + const model = new DispenserModel() + const create = `DISPENSER|0|BTC|TICK|1||10|BTC||1|||||${T0 + 1000000}.0` + const edit = `DISPENSER|2|7||${T0 + 2000000}e0||` + const decoder = buildDecoder([ + { id: 'create01', action: create, source: ADDR }, + { id: 'edit01', action: edit, source: ADDR }, + ], model) + + await decoder.start() + + assert.strictEqual(model.calls.insert.length, 1) + assert.strictEqual(model.calls.extend.length, 1) + assert.strictEqual(decoder.parseErrors, 0) + assert.strictEqual(model.rows[0].expiration, T0 + 2000000) + }) + + it('keeps an omitted create EXPIRATION on the default path above the gate', async function () { + activation[NAME].regtest = 0 + const model = new DispenserModel() + const create = 'DISPENSER|0|BTC|TICK|1||10|BTC||1' + const decoder = buildDecoder([ + { id: 'create01', action: create, source: ADDR }, + ], model) + + await decoder.start() + + assert.strictEqual(model.calls.insert.length, 1) + assert.strictEqual(decoder.parseErrors, 0) + }) +}) diff --git a/test/unit/dispenser_expiry_realign.test.js b/test/unit/dispenser_expiry_realign.test.js index dbc1f112..14863a7c 100644 --- a/test/unit/dispenser_expiry_realign.test.js +++ b/test/unit/dispenser_expiry_realign.test.js @@ -152,6 +152,7 @@ function buildDecoder(txSpecs, model) { insertDispenser: (d) => model.insertDispenser(d), extendOpenDispenserExpirationBySource: (s, e, b) => model.extendOpenDispenserExpirationBySource(s, e, b), deleteOpenDispensers: (b, m) => model.deleteOpenDispensers(b, m), + recordDispenserExtensionUndo: async () => true, purgeExpiredDispensers: (h) => model.purgeExpiredDispensers(h), getAllOpenDispenserAddresses: () => model.getAllOpenDispenserAddresses(), getOpenDispenserOracleAddressBySource: (s) => model.getOpenDispenserOracleAddressBySource(s), diff --git a/test/unit/dispenser_extension_undo_reorg.test.js b/test/unit/dispenser_extension_undo_reorg.test.js new file mode 100644 index 00000000..d89c1ba1 --- /dev/null +++ b/test/unit/dispenser_extension_undo_reorg.test.js @@ -0,0 +1,171 @@ +'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. + +// A DISPENSE expiration extend overwrites dispensers.expiration and may clear a soft-expiry +// mark. The undo table records the row as it stood before the block's first extend, a reorg +// of that block puts it back, and the reorg-safe purge drops the rows. The fake connection +// below interprets only the statements this path issues, in plain JS, so the order of +// record, extend, restore and the generic mark clear is what the cases exercise. + +const assert = require('assert'); +const sinon = require('sinon'); +const Database = require('../../src/db.js'); +const { extendEditedDispenser } = require('../../src/XChainDecoder/dispenser_registration.js'); + +function makeDb() { + return new Database('127.0.0.1', 3306, 'xchain_btc_regtest', 'u', 'p'); +} + +function fakeStore(rows) { + const store = { dispensers: rows, undo: [], log: [] }; + store.query = async (sql, args = []) => { + const s = sql.replace(/\s+/g, ' ').trim(); + store.log.push(s); + if (/^INSERT IGNORE INTO dispenser_extension_undo/.test(s)) { + const [block] = args; + for (const d of store.dispensers) { + if (d.expired_block_index == null || d.expired_block_index === args[3]) { + if (!store.undo.some(u => u.block_index === block && u.tx_index === d.tx_index && u.address_id === d.address_id)) { + store.undo.push({ block_index: block, tx_index: d.tx_index, address_id: d.address_id, + prior_expiration: d.expiration, prior_expired_block_index: d.expired_block_index }); + } + } + } + } else if (/^UPDATE dispensers d INNER JOIN dispenser_extension_undo u/.test(s)) { + for (const u of store.undo.filter(x => x.block_index === args[0])) { + const d = store.dispensers.find(x => x.tx_index === u.tx_index && x.address_id === u.address_id); + if (!d) continue; + d.expiration = u.prior_expiration; + d.expired_block_index = u.prior_expired_block_index === u.block_index ? null : u.prior_expired_block_index; + } + } else if (/^DELETE FROM dispenser_extension_undo WHERE block_index = \?/.test(s)) { + store.undo = store.undo.filter(u => u.block_index !== args[0]); + } else if (/^DELETE FROM dispenser_extension_undo WHERE block_index <= \?/.test(s)) { + store.undo = store.undo.filter(u => u.block_index > args[0]); + } else if (/^UPDATE dispensers\s+SET\s+expiration = GREATEST/i.test(sql.trim())) { + const [newExp, stamp] = args; + for (const d of store.dispensers) { + if (d.expired_block_index == null || d.expired_block_index === args[4]) { + d.expiration = Math.max(d.expiration, newExp); + if (d.expired_block_index === stamp) d.expired_block_index = null; + } + } + } else if (/^UPDATE dispensers SET expired_block_index = NULL WHERE expired_block_index = \?/.test(s)) { + for (const d of store.dispensers) if (d.expired_block_index === args[0]) d.expired_block_index = null; + } + return []; + }; + return store; +} + +function attach(db, store) { + const conn = { query: store.query, release: sinon.stub().resolves() }; + db.pool = { getConnection: sinon.stub().resolves(conn) }; + return conn; +} + +describe('dispenser expiration extend undo', () => { + afterEach(() => sinon.restore()); + + it('records the pre-extend row with the block-start value kept on a second extend', async () => { + const db = makeDb(); + const store = fakeStore([{ tx_index: 1, address_id: 7, expiration: 1000, expired_block_index: null }]); + attach(db, store); + assert.strictEqual(await db.recordDispenserExtensionUndo('src', 50), true); + await db.extendOpenDispenserExpirationBySource('src', 5000, 50); + await db.recordDispenserExtensionUndo('src', 50); + await db.extendOpenDispenserExpirationBySource('src', 9000, 50); + assert.strictEqual(store.dispensers[0].expiration, 9000); + assert.deepStrictEqual(store.undo.map(u => [u.block_index, u.prior_expiration, u.prior_expired_block_index]), [[50, 1000, null]]); + }); + + it('restores expiration and an orphaned-block expiry mark through deleteBlockByIndex', async () => { + const db = makeDb(); + const store = fakeStore([ + { tx_index: 1, address_id: 7, expiration: 1000, expired_block_index: 50 }, + { tx_index: 2, address_id: 8, expiration: 2000, expired_block_index: null }, + ]); + attach(db, store); + db.beginTransaction = async () => {}; + db.commitTransaction = async () => true; + db.transactionConnection = null; + await db.recordDispenserExtensionUndo('src', 50); + await db.extendOpenDispenserExpirationBySource('src', 9000, 50); + assert.deepStrictEqual(store.dispensers.map(d => [d.expiration, d.expired_block_index]), [[9000, null], [9000, null]]); + + await db.deleteBlockByIndex(50, 'hash50'); + + assert.deepStrictEqual(store.dispensers.map(d => [d.expiration, d.expired_block_index]), [[1000, null], [2000, null]]); + assert.strictEqual(store.undo.length, 0, 'the block undo rows are dropped with the block'); + const restoreAt = store.log.findIndex(l => /^UPDATE dispensers d INNER JOIN dispenser_extension_undo/.test(l)); + const clearAt = store.log.findIndex(l => /^UPDATE dispensers SET expired_block_index = NULL/.test(l)); + assert.ok(restoreAt >= 0 && clearAt > restoreAt, 'the restore runs before the generic stamp clear'); + }); + + it('keeps an earlier block close in place when restoring', async () => { + const db = makeDb(); + const store = fakeStore([{ tx_index: 1, address_id: 7, expiration: 1000, expired_block_index: 40 }]); + attach(db, store); + store.undo.push({ block_index: 50, tx_index: 1, address_id: 7, prior_expiration: 1000, prior_expired_block_index: 40 }); + await db.restoreDispenserExtensions({ query: store.query }, 50); + assert.strictEqual(store.dispensers[0].expired_block_index, 40); + }); + +}); + +describe('dispenser extension undo purge and failure', () => { + afterEach(() => sinon.restore()); + + it('purges undo rows at the reorg-safe height with the expired dispensers', async () => { + const db = makeDb(); + const store = fakeStore([]); + store.undo.push({ block_index: 10, tx_index: 1, address_id: 7 }, { block_index: 200, tx_index: 1, address_id: 7 }); + attach(db, store); + assert.strictEqual(await db.purgeExpiredDispensers(100), true); + assert.deepStrictEqual(store.undo.map(u => u.block_index), [200]); + }); + + it('record returns false on a query error', async () => { + const db = makeDb(); + db.pool = { getConnection: sinon.stub().resolves({ query: sinon.stub().rejects(new Error('boom')), release: sinon.stub().resolves() }) }; + assert.strictEqual(await db.recordDispenserExtensionUndo('src', 50), false); + }); +}); + +describe('extendEditedDispenser', () => { + it('records before it extends', async () => { + const order = []; + const ctx = { db: { + recordDispenserExtensionUndo: async () => { order.push('record'); return true }, + extendOpenDispenserExpirationBySource: async () => { order.push('extend'); return true }, + } }; + await extendEditedDispenser.call(ctx, { editSource: 's', newExpiration: 9 }, 50); + assert.deepStrictEqual(order, ['record', 'extend']); + }); + + it('rolls back without extending when the record fails', async () => { + const extend = sinon.stub().resolves(true); + const ctx = { db: { recordDispenserExtensionUndo: async () => false, extendOpenDispenserExpirationBySource: extend } }; + assert.strictEqual(await extendEditedDispenser.call(ctx, { editSource: 's', newExpiration: 9 }, 50), 'rollback'); + assert.ok(extend.notCalled); + }); +}); + +describe('dispenser_extension_undo schema', () => { + it('is a src/sql table the startup reconciler creates and drift-checks, with nullable pre-images', () => { + const sql = require('fs').readFileSync(require('path').join(__dirname, '../../src/sql/dispenser_extension_undo.sql'), 'utf8'); + const cols = makeDb().parseExpectedColumns(sql); + assert.deepStrictEqual(cols.map(c => c.name), + ['block_index', 'tx_index', 'address_id', 'prior_expiration', 'prior_expired_block_index']); + assert.deepStrictEqual(cols.filter(c => c.nullable).map(c => c.name), ['prior_expiration', 'prior_expired_block_index']); + }); +}); diff --git a/test/unit/dispenser_field_offsets.test.js b/test/unit/dispenser_field_offsets.test.js index 80bf3441..c6912f90 100644 --- a/test/unit/dispenser_field_offsets.test.js +++ b/test/unit/dispenser_field_offsets.test.js @@ -17,8 +17,8 @@ // address the dispenser row is registered under), ORACLE_ADDRESS (the token oracle-fee // capture keys on), the v0 create EXPIRATION and the v2 edit EXPIRATION. The // authoritative layout is the indexer's own format strings -// (xchain-indexer/src/actions/dispenser/index.js this.formats, moved there from the former -// single-file src/actions/dispenser.js by the indexer M3 directory split), and until this +// (xchain-indexer/src/actions/dispenser/index.js this.formats, moved into +// xchain-indexer/src/actions/dispenser/ by the indexer M3 directory split), and until this // guard existed the only thing binding the two was a prose comment, while every comparable // dependency at this seam already had a mechanical gate (indexer_batch_limits.js vendoring, // oracle_fee_output_activation_conformance.test.js). diff --git a/test/unit/dispenser_lifecycle_mirror.test/helpers/support.js b/test/unit/dispenser_lifecycle_mirror.test/helpers/support.js index c2364c30..681fcfb4 100644 --- a/test/unit/dispenser_lifecycle_mirror.test/helpers/support.js +++ b/test/unit/dispenser_lifecycle_mirror.test/helpers/support.js @@ -165,6 +165,7 @@ function buildDecoder(txSpecs, model) { insertDispenser: (d) => model.insertDispenser(d), extendOpenDispenserExpirationBySource: (s, e, b) => model.extendOpenDispenserExpirationBySource(s, e, b), deleteOpenDispensers: (b, m) => model.deleteOpenDispensers(b, m), + recordDispenserExtensionUndo: async () => true, purgeExpiredDispensers: (h) => model.purgeExpiredDispensers(h), getAllOpenDispenserAddresses: () => model.getAllOpenDispenserAddresses(), getOpenDispenserOracleAddressBySource: (s) => model.getOpenDispenserOracleAddressBySource(s), diff --git a/test/unit/dispenser_oracle_fee_output.test/helpers/support.js b/test/unit/dispenser_oracle_fee_output.test/helpers/support.js index 22448c6a..dc439e75 100644 --- a/test/unit/dispenser_oracle_fee_output.test/helpers/support.js +++ b/test/unit/dispenser_oracle_fee_output.test/helpers/support.js @@ -120,6 +120,7 @@ function buildDecoder(txSpecs, model, opts) { insertDispenser: (d) => model.insertDispenser(d), extendOpenDispenserExpirationBySource: (s, e) => model.extendOpenDispenserExpirationBySource(s, e), deleteOpenDispensers: (b, m) => model.deleteOpenDispensers(b, m), + recordDispenserExtensionUndo: async () => true, purgeExpiredDispensers: (h) => model.purgeExpiredDispensers(h), getAllOpenDispenserAddresses: () => model.getAllOpenDispenserAddresses(), // opts.oracleLookup stands in for whichever accessor the flag-day routes to, so a diff --git a/test/unit/dispenser_protocol_time.test.js b/test/unit/dispenser_protocol_time.test.js index 264bf4bb..594b2120 100644 --- a/test/unit/dispenser_protocol_time.test.js +++ b/test/unit/dispenser_protocol_time.test.js @@ -85,6 +85,7 @@ function configureDecoderDatabase(decoder, calls){ calls.captureFloors.push(floor) return new Set() }, + recordDispenserExtensionUndo: async () => true, purgeExpiredDispensers: async () => true, insertTransaction: async () => true, insertTransactionOutput: async () => true, diff --git a/test/unit/dispenser_purge_failure_count.test.js b/test/unit/dispenser_purge_failure_count.test.js new file mode 100644 index 00000000..02e85f8e --- /dev/null +++ b/test/unit/dispenser_purge_failure_count.test.js @@ -0,0 +1,85 @@ +'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 + +// A failed post-commit expired-dispenser purge is counted and exported, never thrown: the batch +// has already committed, so the loop must carry on and the failure must reach monitoring. + +const assert = require('assert') +const sinon = require('sinon') + +const { storeBlock } = require('../../src/XChainDecoder/block_store.js') +const { registerDecoderMetrics } = require('../../src/metrics/decoder_metrics') +const { Registry } = require('../../src/observability/metrics') + +function blockLoopContext(purgeResult){ + return { + consensusNetwork: 'regtest', + blockchainInfoLastBlock: 0, + startBlockIndex: 0, + lastProcessedBlockIndex: -1, + dispenserExpireSafeDepth: 1, + parseErrors: 0, + dispenserPurgeFailures: 0, + log: () => {}, + db: { + beginTransaction: sinon.stub().resolves(), + insertBlock: sinon.stub().resolves(true), + deleteOpenDispensers: sinon.stub().resolves(true), + getAllOpenDispenserAddresses: sinon.stub().resolves(new Set()), + commitTransaction: sinon.stub().resolves(true), + purgeExpiredDispensers: sinon.stub().resolves(purgeResult), + }, + } +} + +function emptyLoop(){ + return { + blocksQuantity: 0, + blocksCount: 0, + transactionsCount: 0, + validTransactionsCount: 0, + outputCount: 0, + insertQuarantine: new Set(), + txParseRetryCounts: new Map(), + insertQuarantineCounts: new Map(), + startTimeStamp: Date.now(), + } +} + +async function commitOneBlock(context){ + const loop = emptyLoop() + await storeBlock.call(context, loop, { timestamp: 1700000000, transactions: [] }, 0, 'block-0', 'previous') + return loop +} + +describe('post-commit dispenser purge failure count', function () { + it('counts a purge that returns false and still finishes the batch', async function () { + const context = blockLoopContext(false) + const loop = await commitOneBlock(context) + + assert.strictEqual(context.db.commitTransaction.callCount, 1) + assert.strictEqual(context.db.purgeExpiredDispensers.callCount, 1) + assert.strictEqual(context.dispenserPurgeFailures, 1) + assert.strictEqual(context.parseErrors, 0, 'a purge failure is not a parse error') + assert.strictEqual(loop.blocksCount, 0, 'the post-commit batch reset still ran') + }) + + it('leaves the count at zero when the purge succeeds', async function () { + const context = blockLoopContext(true) + await commitOneBlock(context) + + assert.strictEqual(context.db.purgeExpiredDispensers.callCount, 1) + assert.strictEqual(context.dispenserPurgeFailures, 0) + }) + + it('exports the count as a Prometheus counter', function () { + const registry = new Registry() + registerDecoderMetrics(registry, { parseErrors: 0, rpcErrors: 0, dispenserPurgeFailures: 4 }) + + assert.match(registry.render(), /^xchain_decoder_dispenser_purge_failures_total 4$/m) + }) +}) diff --git a/test/unit/dispenser_purge_grace.test.js b/test/unit/dispenser_purge_grace.test.js new file mode 100644 index 00000000..276ae90c --- /dev/null +++ b/test/unit/dispenser_purge_grace.test.js @@ -0,0 +1,142 @@ +'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 + +const assert = require('assert') +const sinon = require('sinon') + +const Database = require('../../src/db.js') +const { storeBlock } = require('../../src/XChainDecoder/block_store.js') +const { + DISPENSER_CANCEL_GRACE_SECONDS, + cancelGraceFloor, + purgeGraceFloor, +} = require('../../src/protocol/dispenser_cancel_grace.js') + +function makeDb(){ + return new Database('127.0.0.1', 3306, 'xchain_btc_regtest', 'u', 'p') +} + +function injectQuery(db, query){ + const connection = { + query, + release: sinon.stub().resolves(), + } + db.pool = { getConnection: sinon.stub().resolves(connection) } + return connection +} + +function blockLoopContext(consensusNetwork){ + const purgeExpiredDispensers = sinon.stub().resolves(true) + return { + consensusNetwork, + blockchainInfoLastBlock: 0, + startBlockIndex: 0, + lastProcessedBlockIndex: -1, + dispenserExpireSafeDepth: 1, + log: () => {}, + db: { + beginTransaction: sinon.stub().resolves(), + insertBlock: sinon.stub().resolves(true), + deleteOpenDispensers: sinon.stub().resolves(true), + getAllOpenDispenserAddresses: sinon.stub().resolves(new Set()), + commitTransaction: sinon.stub().resolves(true), + purgeExpiredDispensers, + }, + purgeStub: purgeExpiredDispensers, + } +} + +function emptyLoop(){ + return { + blocksQuantity: 0, + blocksCount: 0, + transactionsCount: 0, + validTransactionsCount: 0, + outputCount: 0, + insertQuarantine: new Set(), + txParseRetryCounts: new Map(), + insertQuarantineCounts: new Map(), + startTimeStamp: Date.now(), + } +} + +describe('dispenser purge grace decision', function () { + it('uses a gate independent from capture grace', function () { + const blockTime = 1700000000 + assert.strictEqual(cancelGraceFloor('mainnet', blockTime), + blockTime - DISPENSER_CANCEL_GRACE_SECONDS) + assert.strictEqual(purgeGraceFloor('mainnet', blockTime), null) + assert.strictEqual(purgeGraceFloor('regtest', blockTime), + blockTime - DISPENSER_CANCEL_GRACE_SECONDS) + }) + + it('activates at the purge gate boundary and rejects invalid times', function () { + const activation = 9999999999 + assert.strictEqual(purgeGraceFloor('mainnet', activation - 1), null) + assert.strictEqual(purgeGraceFloor('mainnet', activation), + activation - DISPENSER_CANCEL_GRACE_SECONDS) + assert.strictEqual(purgeGraceFloor('unknown', activation), null) + assert.strictEqual(purgeGraceFloor('regtest', NaN), null) + }) +}) + +describe('Database#purgeExpiredDispensers() grace predicate', function () { + afterEach(() => sinon.restore()) + + it('keeps the legacy height-only delete when no purge floor is active', async function () { + const db = makeDb() + const query = sinon.stub().resolves([]) + injectQuery(db, query) + + assert.strictEqual(await db.purgeExpiredDispensers(900, null), true) + + const [sql, args] = query.firstCall.args + assert.match(sql, /DELETE\s+FROM\s+dispensers/i) + assert.doesNotMatch(sql, /JOIN\s+blocks/i) + assert.deepStrictEqual(args, [900]) + }) + + it('requires both the mark time and expiration to be older than the grace floor', async function () { + const db = makeDb() + const query = sinon.stub().resolves([]) + injectQuery(db, query) + const floor = 1700000000 + + assert.strictEqual(await db.purgeExpiredDispensers(900, floor), true) + + const [sql, args] = query.firstCall.args + assert.match(sql, /DELETE\s+d\s+FROM\s+dispensers\s+d/i) + assert.match(sql, /LEFT\s+JOIN\s+blocks\s+eb\s+ON\s+eb\.block_index\s*=\s*d\.expired_block_index/i) + assert.match(sql, /d\.expired_block_index\s*<=\s*\?/i) + assert.match(sql, /d\.expiration\s*<\s*\?/i) + assert.match(sql, /eb\.block_time\s+IS\s+NULL\s+OR\s+eb\.block_time\s*<\s*\?/i) + assert.deepStrictEqual(args, [900, floor, floor]) + }) +}) + +describe('block store purge grace plumbing', function () { + it('passes a grace floor derived from the committed block protocol time', async function () { + const context = blockLoopContext('regtest') + const block = { timestamp: 1700000000, transactions: [] } + + await storeBlock.call(context, emptyLoop(), block, 0, 'block-0', 'previous') + + assert.deepStrictEqual(context.purgeStub.firstCall.args, [ + -1, + block.timestamp - DISPENSER_CANCEL_GRACE_SECONDS, + ]) + }) + + it('passes no floor below the purge gate', async function () { + const context = blockLoopContext('mainnet') + const block = { timestamp: 1700000000, transactions: [] } + + await storeBlock.call(context, emptyLoop(), block, 0, 'block-0', 'previous') + + assert.deepStrictEqual(context.purgeStub.firstCall.args, [-1, null]) + }) +}) diff --git a/test/unit/dispenser_purge_grace_activation.test.js b/test/unit/dispenser_purge_grace_activation.test.js new file mode 100644 index 00000000..6a5172c7 --- /dev/null +++ b/test/unit/dispenser_purge_grace_activation.test.js @@ -0,0 +1,48 @@ +'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. + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const { DISPENSER_PURGE_GRACE_ACTIVATION } = require('../../src/protocol/constants.js'); + +const EXPLICIT_DOC_CONSTANTS = process.env.XC_DOC_CONSTANTS; +const DOC_CONSTANTS = EXPLICIT_DOC_CONSTANTS || (process.env.XCHAIN_DOCS_DIR + ? path.join(process.env.XCHAIN_DOCS_DIR, 'protocol', 'constants.js') + : path.join(__dirname, '..', '..', '..', 'xchain-documentation', 'protocol', 'constants.js')); + +describe('DISPENSER_PURGE_GRACE_ACTIVATION conformance', function () { + + it('keeps mainnet and testnet unarmed while regtest is genesis-active', function () { + assert.deepStrictEqual(DISPENSER_PURGE_GRACE_ACTIVATION, { + mainnet: 9999999999, + testnet: 9999999999, + regtest: 0, + }); + }); + + it('is value-identical to the canonical map in xchain-documentation', function () { + if (!fs.existsSync(DOC_CONSTANTS)) { + if (EXPLICIT_DOC_CONSTANTS) + assert.fail('XC_DOC_CONSTANTS does not exist: ' + DOC_CONSTANTS); + this.skip(); + } + const canonical = require(DOC_CONSTANTS).DISPENSER_PURGE_GRACE_ACTIVATION; + if (!canonical && !EXPLICIT_DOC_CONSTANTS) this.skip(); + assert.ok(canonical && typeof canonical === 'object', + 'xchain-documentation/protocol/constants.js must export ' + + 'DISPENSER_PURGE_GRACE_ACTIVATION'); + assert.deepStrictEqual(DISPENSER_PURGE_GRACE_ACTIVATION, canonical, + 'the decoder purge-grace activation map drifted from the canonical map'); + }); +}); diff --git a/test/unit/dispenser_wide_expiration.test.js b/test/unit/dispenser_wide_expiration.test.js new file mode 100644 index 00000000..cfce7bbe --- /dev/null +++ b/test/unit/dispenser_wide_expiration.test.js @@ -0,0 +1,160 @@ +'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 + +const assert = require('assert') +const { + DISPENSER_WIDE_EXPIRATION_ACTIVATION, + MAX_SAFE_DISPENSER_EXPIRATION, + U64_MAX, + isDispenserWideExpirationActive, + normalizeDispenserExpiration, +} = require('../../src/XChainDecoder/dispenser_wide_expiration.js') +const { + collectDispenserCreates, + dispenserEditExtension, +} = require('../../src/XChainDecoder/dispenser_registration.js') +const constants = require('../../src/protocol/constants.js') + +const MAX_SAFE = String(Number.MAX_SAFE_INTEGER) +const FIRST_WIDE = '9007199254740992' +const SECOND_WIDE = '9007199254740993' +const U64_MAX_TOKEN = String(U64_MAX) +const U64_OVERFLOW = '18446744073709551616' + +function createCommand(expiration){ + return `DISPENSER|0|BTC|TICK|1||10|BTC||1|||||${expiration}` +} + +function editCommand(expiration){ + return `DISPENSER|2|123||${expiration}` +} + +function context(network){ + return { + consensusNetwork: network, + parseErrors: 0, + getDefaultExpiration: timestamp => timestamp + 86400, + dispenserOpensForThisChain: () => true, + hasRequiredDispenserCreateFields: () => true, + } +} + +function collect(expiration, network, blockTime){ + const decoder = context(network) + const registrations = collectDispenserCreates.call( + decoder, + [createCommand(expiration)], + 'DISPENSER|', + { source: 'source-address' }, + { timestamp: blockTime }, + 'tx-hash', + 12, + ) + return { decoder, registrations } +} + +describe('DISPENSER wide expiration', function () { + it('pins the activation map and public constants export', function () { + assert.deepStrictEqual(DISPENSER_WIDE_EXPIRATION_ACTIVATION, { + mainnet: 9999999999, + testnet: 9999999999, + regtest: 0, + }) + assert.strictEqual( + constants.DISPENSER_WIDE_EXPIRATION_ACTIVATION, + DISPENSER_WIDE_EXPIRATION_ACTIVATION, + ) + }) + + it('uses inclusive activation semantics and fails closed', function () { + const gate = DISPENSER_WIDE_EXPIRATION_ACTIVATION.mainnet + assert.strictEqual(isDispenserWideExpirationActive('mainnet', gate - 1), false) + assert.strictEqual(isDispenserWideExpirationActive('mainnet', gate), true) + assert.strictEqual(isDispenserWideExpirationActive('unknown', gate), false) + assert.strictEqual(isDispenserWideExpirationActive('regtest', 'not-a-time'), false) + }) + + it('keeps every safe unsigned integer exact on both sides of the gate', function () { + const gate = DISPENSER_WIDE_EXPIRATION_ACTIVATION.mainnet + for (const token of ['0', '4294967295', MAX_SAFE]){ + const expected = Number(token) + assert.strictEqual(normalizeDispenserExpiration(token, 'mainnet', gate - 1), expected) + assert.strictEqual(normalizeDispenserExpiration(token, 'mainnet', gate), expected) + } + }) + + it('clamps every decimal token in the u64 gap without Number rounding', function () { + for (const token of [FIRST_WIDE, SECOND_WIDE, U64_MAX_TOKEN]){ + assert.strictEqual( + normalizeDispenserExpiration(token, 'regtest', 0), + MAX_SAFE_DISPENSER_EXPIRATION, + token, + ) + } + assert.strictEqual(normalizeDispenserExpiration('+' + FIRST_WIDE, 'regtest', 0), MAX_SAFE_DISPENSER_EXPIRATION) + assert.strictEqual(normalizeDispenserExpiration(' ' + FIRST_WIDE + ' ', 'regtest', 0), MAX_SAFE_DISPENSER_EXPIRATION) + assert.strictEqual(normalizeDispenserExpiration(FIRST_WIDE + '.0', 'regtest', 0), MAX_SAFE_DISPENSER_EXPIRATION) + assert.strictEqual(normalizeDispenserExpiration('9.007199254740992e15', 'regtest', 0), MAX_SAFE_DISPENSER_EXPIRATION) + }) + +}) + +describe('DISPENSER wide expiration registration', function () { + it('retains the historical rejection below the gate', function () { + const gate = DISPENSER_WIDE_EXPIRATION_ACTIVATION.mainnet + assert.strictEqual(normalizeDispenserExpiration(FIRST_WIDE, 'mainnet', gate - 1), null) + const { decoder, registrations } = collect(FIRST_WIDE, 'mainnet', gate - 1) + assert.deepStrictEqual(registrations, []) + assert.strictEqual(decoder.parseErrors, 1) + }) + + it('rejects values the indexer u64 column cannot represent', function () { + for (const token of [U64_OVERFLOW, U64_OVERFLOW + '.0', '-1', '1.5', '1e300', '', 'not-a-number']){ + assert.strictEqual(normalizeDispenserExpiration(token, 'regtest', 0), null, token) + } + }) + + it('registers a wide create at the clamped advisory expiration', function () { + const { decoder, registrations } = collect(U64_MAX_TOKEN, 'regtest', 0) + assert.strictEqual(decoder.parseErrors, 0) + assert.deepStrictEqual(registrations, [{ + address: 'source-address', + sourceAddress: 'source-address', + oracleAddress: null, + expiration: MAX_SAFE_DISPENSER_EXPIRATION, + }]) + }) + + it('extends for a wide edit using the same clamp', function () { + const decoder = context('regtest') + const extension = dispenserEditExtension.call( + decoder, + editCommand(SECOND_WIDE), + 'DISPENSER|', + { source: 'source-address' }, + { timestamp: 1 }, + ) + assert.deepStrictEqual(extension, { + editSource: 'source-address', + newExpiration: MAX_SAFE_DISPENSER_EXPIRATION, + }) + }) + + it('does not extend for a wide edit below the gate or an overflowing edit', function () { + const gate = DISPENSER_WIDE_EXPIRATION_ACTIVATION.mainnet + const decoder = context('mainnet') + assert.strictEqual(dispenserEditExtension.call( + decoder, editCommand(FIRST_WIDE), 'DISPENSER|', + { source: 'source-address' }, { timestamp: gate - 1 }, + ), null) + decoder.consensusNetwork = 'regtest' + assert.strictEqual(dispenserEditExtension.call( + decoder, editCommand(U64_OVERFLOW), 'DISPENSER|', + { source: 'source-address' }, { timestamp: 1 }, + ), null) + }) +}) diff --git a/test/unit/fee_destination.test.js b/test/unit/fee_destination.test.js index ea75b786..aa5288d7 100644 --- a/test/unit/fee_destination.test.js +++ b/test/unit/fee_destination.test.js @@ -41,9 +41,10 @@ describe('XChainDecoder fee destination @unit', () => { // The api.js entry point resolves FEE_DESTINATION through resolveFeeDestination: the vendored // coin registry supplies the consensus-pinned default (so a stock install captures fee outputs -// without env), an env override wins on regtest ONLY, and mainnet AND testnet always use the pin -// (testnet is an armed multi-operator federation, so honoring the override there forks the ledger -// exactly as it would on mainnet - same rule as the registry's regtest-only per-coin override). +// without env), and its regtest-only XCHAIN_FEE_DESTINATION__REGTEST is the one override, +// shared with the indexer. A bare FEE_DESTINATION env never wins on a recognized coin/network +// (testnet is an armed multi-operator federation, so honoring it there forks the ledger exactly +// as it would on mainnet, and on regtest it would split capture from validation). describe('resolveFeeDestination @unit', () => { it('defaults to the registry pin for every coin and network when no env override is set', () => { for (const coin of ['bitcoin', 'litecoin', 'dogecoin']) { @@ -57,8 +58,28 @@ describe('resolveFeeDestination @unit', () => { } }) - it('honors an env override on regtest ONLY', () => { - assert.strictEqual(resolveFeeDestination('litecoin-regtest', REAL_ADDR), REAL_ADDR) + it('ignores a bare env override on regtest too, so capture and indexer validation resolve one address', () => { + // The indexer reads only the registry value (XCHAIN_FEE_DESTINATION__REGTEST applied); + // a bare FEE_DESTINATION honored here would capture outputs to an address it never validates. + for (const [coin, tick] of [['dogecoin', 'DOGE'], ['litecoin', 'LTC'], ['bitcoin', 'BTC']]) { + const pinned = getCoinConfig(tick, 'regtest').addresses.FEE_DESTINATION + assert.notStrictEqual(pinned, REAL_ADDR) + assert.strictEqual(resolveFeeDestination(coin + '-regtest', REAL_ADDR), pinned, + coin + '-regtest must ignore the bare env override and use the registry value') + } + }) + + it('follows the registry XCHAIN_FEE_DESTINATION__REGTEST override on regtest', () => { + const key = 'XCHAIN_FEE_DESTINATION_LTC_REGTEST' + const prior = process.env[key] + process.env[key] = REAL_ADDR + try { + assert.strictEqual(resolveFeeDestination('litecoin-regtest', null), REAL_ADDR) + assert.strictEqual(resolveFeeDestination('litecoin-regtest', 'mSomeOtherBareEnvAddress11111111'), REAL_ADDR) + } finally { + if (prior === undefined) delete process.env[key] + else process.env[key] = prior + } }) it('ignores an env override on mainnet AND testnet and returns the pin (armed-federation fork guard)', () => { diff --git a/test/unit/getmempool_stale_read.test.js b/test/unit/getmempool_stale_read.test.js new file mode 100644 index 00000000..3cb07039 --- /dev/null +++ b/test/unit/getmempool_stale_read.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. + * + *********************************************************************/ + +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); +const { createMempoolMethods } = require('../../src/api'); + +let clock; +let previousTtl; + +async function servesStaleSnapshotAndRetries(){ + const firstRow = { tx_hash: 'aa', source: 'alice', data: 'first', first_seen: 11 }; + const partialRow = { tx_hash: 'cc', source: 'carol', data: 'partial', first_seen: 33 }; + const recoveredRow = { tx_hash: 'bb', source: 'bob', data: 'second', first_seen: 22 }; + const getRows = sinon.stub(); + getRows.onCall(0).resolves([firstRow]); + getRows.onCall(1).resolves([partialRow]); + getRows.onCall(2).resolves([recoveredRow]); + const getTotal = sinon.stub(); + getTotal.onCall(0).resolves(7); + getTotal.onCall(1).rejects(new Error('count failed')); + getTotal.onCall(2).resolves(8); + const decoder = { + nodeMempoolTxCount: 12, + nodeMempoolUpdatedAt: 900, + mempoolDb: { + getMempoolTransactions: getRows, + getMempoolTransactionCount: getTotal + } + }; + const getmempool = createMempoolMethods(decoder).getmempool; + + const fresh = await getmempool({ limit: 500 }); + assert.strictEqual(fresh.stale, false); + assert.strictEqual(fresh.read_ok_at, 1000); + assert.strictEqual(fresh.total, 7); + assert.strictEqual(fresh.rows[0].tx_hash, 'aa'); + + clock.tick(5000); + const failed = await getmempool({ limit: 500 }); + assert.strictEqual(failed.stale, true); + assert.strictEqual(failed.read_ok_at, 1000); + assert.strictEqual(failed.total, 7); + assert.strictEqual(failed.rows[0].tx_hash, 'aa'); + + const recovered = await getmempool({ limit: 500 }); + assert.strictEqual(getRows.callCount, 3); + assert.strictEqual(getTotal.callCount, 3); + assert.strictEqual(recovered.stale, false); + assert.strictEqual(recovered.read_ok_at, 6000); + assert.strictEqual(recovered.total, 8); + assert.strictEqual(recovered.rows[0].tx_hash, 'bb'); +} + +async function reportsStaleEmptyFallback(){ + const getRows = sinon.stub().rejects(new Error('read failed')); + const decoder = { + nodeMempoolTxCount: -1, + nodeMempoolUpdatedAt: null, + mempoolDb: { + getMempoolTransactions: getRows, + getMempoolTransactionCount: sinon.stub().resolves(0) + } + }; + const getmempool = createMempoolMethods(decoder).getmempool; + + const result = await getmempool({ limit: 500 }); + assert.strictEqual(result.stale, true); + assert.strictEqual(result.read_ok_at, null); + assert.strictEqual(result.total, 0); + assert.deepStrictEqual(result.rows, []); + + await getmempool({ limit: 500 }); + assert.strictEqual(getRows.callCount, 2); +} + +describe('getmempool stale reads', function () { + beforeEach(function () { + previousTtl = process.env.GETMEMPOOL_CACHE_MS; + process.env.GETMEMPOOL_CACHE_MS = '5000'; + clock = sinon.useFakeTimers({ now: 1000, toFake: ['Date'] }); + sinon.stub(console, 'error'); + }); + + afterEach(function () { + clock.restore(); + sinon.restore(); + if (previousTtl === undefined) delete process.env.GETMEMPOOL_CACHE_MS; + else process.env.GETMEMPOOL_CACHE_MS = previousTtl; + }); + + it('serves the last good snapshot as stale and retries immediately after a failed refresh', servesStaleSnapshotAndRetries); + it('reports a stale empty fallback when the first read fails without a prior good timestamp', reportsStaleEmptyFallback); +}); diff --git a/test/unit/mempool_api_surface.test.js b/test/unit/mempool_api_surface.test.js index b9b73bcd..83d58546 100644 --- a/test/unit/mempool_api_surface.test.js +++ b/test/unit/mempool_api_surface.test.js @@ -29,6 +29,7 @@ const path = require('path'); const sinon = require('sinon'); const XChainDecoder = require('../../src/XChainDecoder'); const Database = require('../../src/db.js'); +const { createMempoolMethods } = require('../../src/api'); function makeDecoder() { return new XChainDecoder( @@ -84,14 +85,14 @@ describe('Database#getMempoolTransactions()', () => { it('reads the raw-string columns + first_seen in tx_hash order with a clamped limit', async () => { const db = new Database('127.0.0.1', 3306, 'xchain_btc', 'u', 'p'); - const row = { tx_hash: 'aa', source: 's', data: 'MINT|0|TOK|1', first_seen: new Date() }; + const row = { tx_hash: 'aa', source: 's', data: 'MINT|0|TOK|1', first_seen: 1767225600n }; const q = sinon.stub().resolves([row]); const { pool, conn } = withConn(q); db.pool = pool; const rows = await db.getMempoolTransactions(9999); - assert.deepStrictEqual(rows, [row]); + assert.deepStrictEqual(rows, [{ ...row, first_seen: 1767225600 }]); const sql = q.firstCall.args[0]; - assert.ok(sql.includes('tx_hash, source, data, first_seen')); + assert.ok(sql.includes('tx_hash, source, data, UNIX_TIMESTAMP(first_seen) AS first_seen')); // Action-carrying rows only: the table holds a row for EVERY mempool tx // (data blanked to '' when the tx carried no valid ACTION), so an // unfiltered window fills with actionless rows on a busy chain and the @@ -104,6 +105,19 @@ describe('Database#getMempoolTransactions()', () => { assert.ok(conn.release.calledOnce); }); + it('returns first_seen as plain unix seconds whatever the host time zone', async () => { + const db = new Database('127.0.0.1', 3306, 'xchain_btc', 'u', 'p'); + const q = sinon.stub().resolves([ + { tx_hash: 'aa', source: 's', data: 'd', first_seen: 1767225600n }, + { tx_hash: 'bb', source: 's', data: 'd', first_seen: '1767225601' }, + { tx_hash: 'cc', source: 's', data: 'd', first_seen: null }, + ]); + db.pool = withConn(q).pool; + const rows = await db.getMempoolTransactions(10); + assert.deepStrictEqual(rows.map(r => r.first_seen), [1767225600, 1767225601, null]); + assert.doesNotThrow(() => JSON.stringify(rows)); + }); + it('releases the connection even when the query throws', async () => { const db = new Database('127.0.0.1', 3306, 'xchain_btc', 'u', 'p'); const q = sinon.stub().rejects(new Error('boom')); @@ -135,11 +149,56 @@ describe('Database#getMempoolTransactionCount()', () => { }); }); -// api.js builds its JSON-RPC controller inside startApi() (it is not exported), -// so the getmempool contract is pinned at the source level, the same way the -// explorer pins this repo's mempool INSERT site: the method must exist, consult -// its TTL cache before the DB, clamp the row window, and map the decoder's -// node-mempool snapshot fields into the response. +describe('api.js getmempool stale fallback', () => { + let clock; + let previousTtl; + + beforeEach(() => { + previousTtl = process.env.GETMEMPOOL_CACHE_MS; + process.env.GETMEMPOOL_CACHE_MS = '5000'; + clock = sinon.useFakeTimers({ now: 1000, toFake: ['Date'] }); + sinon.stub(console, 'error'); + }); + + afterEach(() => { + clock.restore(); + sinon.restore(); + if (previousTtl === undefined) delete process.env.GETMEMPOOL_CACHE_MS; + else process.env.GETMEMPOOL_CACHE_MS = previousTtl; + }); + + it('marks a failed refresh stale and preserves the last successful read', async () => { + const getRows = sinon.stub(); + getRows.onFirstCall().resolves([ + { tx_hash: 'aa', source: 'alice', data: 'first', first_seen: 11 } + ]); + getRows.onSecondCall().rejects(new Error('read failed')); + const decoder = { + nodeMempoolTxCount: 12, + nodeMempoolUpdatedAt: 900, + mempoolDb: { + getMempoolTransactions: getRows, + getMempoolTransactionCount: sinon.stub().resolves(7) + } + }; + const getmempool = createMempoolMethods(decoder).getmempool; + + const fresh = await getmempool({ limit: 500 }); + clock.tick(5000); + const failed = await getmempool({ limit: 500 }); + + assert.strictEqual(fresh.stale, false); + assert.strictEqual(fresh.read_ok_at, 1000); + assert.strictEqual(failed.stale, true); + assert.strictEqual(failed.read_ok_at, fresh.read_ok_at); + assert.strictEqual(failed.total, fresh.total); + assert.deepStrictEqual(failed.rows, fresh.rows); + }); +}); + +// Keep the source contract pinned alongside the runtime behavior: the method +// must exist, consult its TTL cache before the DB, clamp the row window, and map +// the decoder's node-mempool snapshot fields into the response. describe('api.js getmempool method (source pin)', () => { const src = fs.readFileSync(path.join(__dirname, '../../src/api.js'), 'utf8'); @@ -162,6 +221,13 @@ describe('api.js getmempool method (source pin)', () => { assert.ok(/Math\.min\(parseInt\(params && params\.limit, 10\) \|\| 500, 500\)/.test(body), 'per-request limit clamp missing'); }); + it('serves first_seen as the SQL-read unix seconds, never a driver Date', () => { + const at = src.indexOf('async getmempool('); + const body = src.slice(at, at + 3000); + assert.ok(!body.includes('getTime()'), 'getmempool must not convert a host-local driver Date'); + assert.ok(body.includes('Number.isFinite(r.first_seen)'), 'first_seen must pass through as a number or null'); + }); + it('maps the node-mempool observation snapshot into the response', () => { const at = src.indexOf('async getmempool('); const body = src.slice(at, at + 3000); diff --git a/test/unit/mempool_isolation.test.js b/test/unit/mempool_isolation.test.js index e71d7298..eda0d65f 100644 --- a/test/unit/mempool_isolation.test.js +++ b/test/unit/mempool_isolation.test.js @@ -28,6 +28,7 @@ function buildDecoder(insertResult) { const calls = { db: [], mempoolDb: [] } const spyDb = (label) => ({ + getAllOpenDispenserAddresses: async () => { calls[label].push('openDispensers'); return new Set() }, deleteAndCompareTxsNotInList: async () => { calls[label].push('delete'); return { transactionsDeleted: 0 } }, insertMempoolTransaction: async () => { calls[label].push('insert'); return insertResult }, // A block delete/rollback surface: touching these from the mempool path would be the bug. @@ -60,7 +61,7 @@ describe('updateMempool DB isolation', function () { it('routes the mempool DELETE and INSERT to mempoolDb, never to the block db', async () => { const { decoder, calls } = buildDecoder(true) await decoder.updateMempool() - assert.deepStrictEqual(calls.mempoolDb, ['delete', 'insert'], 'mempool work must run on mempoolDb') + assert.deepStrictEqual(calls.mempoolDb, ['openDispensers', 'delete', 'insert'], 'mempool work must run on mempoolDb') assert.deepStrictEqual(calls.db, [], 'the block db must be untouched by mempool maintenance') }) diff --git a/test/unit/mempool_unattributable_action.test.js b/test/unit/mempool_unattributable_action.test.js new file mode 100644 index 00000000..a814797d --- /dev/null +++ b/test/unit/mempool_unattributable_action.test.js @@ -0,0 +1,108 @@ +// 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 XChainDecoder = require('../../src/XChainDecoder') + +// Pin mempool admission to the block path's attribution rule (hasStorableContent). +// The block path skips an ACTION whose source cannot be resolved, so the mempool +// must not publish it as pending; it keeps a blanked row as the seen-set entry. +function runOneMempoolCycle(parseResult, openDispensers = new Set()) { + const decoder = new XChainDecoder( + 'bitcoin-regtest', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null + ) + const inserted = [] + const floors = [] + decoder.mempoolDb = { + getAllOpenDispenserAddresses: async (floor) => { floors.push(floor); return openDispensers }, + deleteAndCompareTxsNotInList: async () => ({ transactionsDeleted: 0 }), + insertMempoolTransaction: async (row) => { inserted.push(row); return true }, + } + decoder.db = {} + decoder.connector = { + getRawMempool: async () => ['txid1'], + getRawTransactions: async () => ['hexdata'], + } + decoder.xchainBlockDecoder = { transactionFromHex: () => ({ ins: [{}], getId: () => 'txid1' }) } + const parseSets = [] + decoder.parseTransaction = async (...args) => { + const openSet = args[1] + parseSets.push(openSet) + return typeof parseResult === 'function' ? parseResult(openSet) : parseResult + } + return decoder.updateMempool().then(() => ({ decoder, inserted, floors, parseSets })) +} + +function actionResult(source, extra) { + const payload = Buffer.from('COINPAY|0|abc|def') + return Object.assign({ + data: payload, compiledDataLength: payload.length + 1, source, + destination: null, amount: '0', dispenseOutputs: [], paymentOutputs: [], + }, extra) +} + +describe('mempool admission of an ACTION with no resolvable source', function () { + this.timeout(0) + + it('blanks an unattributable ACTION to an empty string and keeps the row', async () => { + const { decoder, inserted } = await runOneMempoolCycle(actionResult(null)) + assert.strictEqual(decoder.hasStorableContent(actionResult(null)), false, 'the block path skips this tx') + assert.strictEqual(inserted.length, 1, 'the row must still be written as the seen-set entry') + assert.strictEqual(inserted[0].data, '', 'an unattributable ACTION must not be published as pending') + assert.strictEqual(inserted[0].raw_data, null) + }) + + it('keeps the ACTION text when the source resolves', async () => { + const { inserted } = await runOneMempoolCycle(actionResult('src')) + assert.strictEqual(inserted.length, 1) + assert.strictEqual(inserted[0].data, 'COINPAY|0|abc|def') + }) + + it('still writes a blanked row for an actionless tx with no source', async () => { + const { inserted } = await runOneMempoolCycle(actionResult(null, { data: Buffer.alloc(0), compiledDataLength: 0 })) + assert.strictEqual(inserted.length, 1, 'an actionless tx must keep its seen-set row') + assert.strictEqual(inserted[0].data, '') + }) + + it('keeps the ACTION text for a null source when a dispense output makes the tx storable', async () => { + const dispense = [{ txIndex: 'txid1', vout: 0, destinationAddress: 'd', amount: 1 }] + const { inserted } = await runOneMempoolCycle(actionResult(null, { dispenseOutputs: dispense })) + assert.strictEqual(inserted.length, 1) + assert.strictEqual(inserted[0].data, 'COINPAY|0|abc|def', 'must match the block path, which stores this tx') + }) + + it('parses against the getAllOpenDispenserAddresses set, so an unattributed ACTION paying an open dispenser keeps its text', async () => { + const open = new Set(['dispenserAddr']) + // Classify the payment output the way captureOutputAddress does: a dispense + // output only when its address is in the open-dispenser set the parse is handed. + const classify = (openSet) => actionResult(null, { + dispenseOutputs: (openSet && openSet.has('dispenserAddr')) + ? [{ txIndex: 'txid1', vout: 1, destinationAddress: 'dispenserAddr', amount: 1 }] + : [], + }) + const { inserted, parseSets } = await runOneMempoolCycle(classify, open) + assert.strictEqual(parseSets[0], open, 'the mempool parse must see the loaded open-dispenser set') + assert.strictEqual(inserted.length, 1) + assert.strictEqual(inserted[0].data, 'COINPAY|0|abc|def', 'the block path stores this ACTION on confirm') + }) + + it('loads the open-dispenser set with the cancelGraceFloor for the network', async () => { + const { floors } = await runOneMempoolCycle(actionResult('src')) + assert.strictEqual(floors.length, 1, 'one load per cycle') + assert.ok(floors[0] === null || Number.isFinite(floors[0]), 'the floor is null below the gate or a finite time') + }) + + it('still runs the cycle against an empty set when getAllOpenDispenserAddresses fails', async () => { + const { inserted, parseSets } = await runOneMempoolCycle(actionResult('src'), null) + assert.ok(parseSets[0] instanceof Set && parseSets[0].size === 0, 'a failed load classifies against no open dispensers') + assert.strictEqual(inserted.length, 1, 'pruning and new arrivals must not stall on the failed read') + assert.strictEqual(inserted[0].data, 'COINPAY|0|abc|def') + }) +}) diff --git a/test/unit/migrate_status.test.js b/test/unit/migrate_status.test.js index 91692e3e..adcb42c9 100644 --- a/test/unit/migrate_status.test.js +++ b/test/unit/migrate_status.test.js @@ -17,7 +17,6 @@ const assert = require('assert'); const sinon = require('sinon'); const fs = require('fs'); -const os = require('os'); const path = require('path'); const DB_PATH = require.resolve('../../src/db.js'); @@ -27,7 +26,11 @@ const ENV_KEYS = ['DECODER_DB_HOST', 'DECODER_DB_PORT', 'DECODER_DB_NAME', 'DECODER_DB_USER', 'DECODER_DB_PASS']; let savedEnv, savedExitCode, savedArgv, exitStub, consoleErrStub, consoleLogStub; -let tmpDir, fileA, fileB, fileC; +let fileA, fileB, fileC; + +const STATUS_SQL_PATH = path.join(__dirname, 'status-fixture'); +const STATUS_MIGRATIONS_PATH = path.join(STATUS_SQL_PATH, 'migrations'); +const STATUS_FILES = ['2026-01-01-a.sql', '2026-01-02-b.sql', '2026-01-03-c.sql']; function prepareMigrateTest() { savedEnv = {}; @@ -118,20 +121,16 @@ function loadMigrateWith(fakeDbClass) { function prepareStatusTest() { prepareMigrateTest(); - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'decoder-migrate-status-')); - const migrationsDir = path.join(tmpDir, 'migrations'); - fs.mkdirSync(migrationsDir); - fileA = '2026-01-01-a.sql'; - fileB = '2026-01-02-b.sql'; - fileC = '2026-01-03-c.sql'; - for (const file of [fileA, fileB, fileC]) { - fs.writeFileSync(path.join(migrationsDir, file), '-- xchain:migration mode=auto\nSELECT 1;\n'); - } + [fileA, fileB, fileC] = STATUS_FILES; + const readdirSync = fs.readdirSync; + sinon.stub(fs, 'readdirSync').callsFake((dir, ...args) => { + if (dir === STATUS_MIGRATIONS_PATH) return [...STATUS_FILES]; + return readdirSync.call(fs, dir, ...args); + }); } function restoreStatusTest() { restoreMigrateTest(); - fs.rmSync(tmpDir, { recursive: true, force: true }); } function setEnv() { @@ -151,7 +150,7 @@ describe('migrate.js operator CLI --status/--json @regression', function () { it('--status --json prints one JSON object with applied and pending arrays and applies nothing', async function () { setEnv(); process.argv = ['node', 'migrate.js', '--status', '--json']; - const fake = makeFakeDbForStatus({ sqlPath: tmpDir, appliedNames: [fileA] }); + const fake = makeFakeDbForStatus({ sqlPath: STATUS_SQL_PATH, appliedNames: [fileA] }); loadMigrateWith(fake.FakeDatabase); await fake.done; @@ -175,7 +174,7 @@ describe('migrate.js operator CLI --status/--json @regression', function () { setEnv(); process.argv = ['node', 'migrate.js', '--status', '--json']; const noSuchTable = Object.assign(new Error('no such table'), { code: 'ER_NO_SUCH_TABLE', errno: 1146 }); - const fake = makeFakeDbForStatus({ sqlPath: tmpDir, queryError: noSuchTable }); + const fake = makeFakeDbForStatus({ sqlPath: STATUS_SQL_PATH, queryError: noSuchTable }); loadMigrateWith(fake.FakeDatabase); await fake.done; @@ -192,7 +191,7 @@ describe('migrate.js operator CLI --status/--json @regression', function () { it('--status without --json prints a human-readable line instead of JSON', async function () { setEnv(); process.argv = ['node', 'migrate.js', '--status']; - const fake = makeFakeDbForStatus({ sqlPath: tmpDir, appliedNames: [fileA, fileB] }); + const fake = makeFakeDbForStatus({ sqlPath: STATUS_SQL_PATH, appliedNames: [fileA, fileB] }); loadMigrateWith(fake.FakeDatabase); await fake.done; @@ -205,7 +204,7 @@ describe('migrate.js operator CLI --status/--json @regression', function () { it('a real query failure during --status still fails loudly and closes the pool', async function () { setEnv(); process.argv = ['node', 'migrate.js', '--status', '--json']; - const fake = makeFakeDbForStatus({ sqlPath: tmpDir, queryError: new Error('connection refused') }); + const fake = makeFakeDbForStatus({ sqlPath: STATUS_SQL_PATH, queryError: new Error('connection refused') }); loadMigrateWith(fake.FakeDatabase); await fake.done; diff --git a/test/unit/migration_preconditions.test.js b/test/unit/migration_preconditions.test.js index 6b18a2a8..24aa4f6b 100644 --- a/test/unit/migration_preconditions.test.js +++ b/test/unit/migration_preconditions.test.js @@ -18,7 +18,7 @@ * A startup assertion that requires an operator-gated migration is a deploy * precondition: build the code, ship it to a database that never applied the * migration, and the service crash-loops on boot. A v0.10.0 fleet deploy put five - * of nine decoders into exactly that state, because none of the three migrations + * of nine decoders into exactly that state, because none of the four migrations * this tree asserts at startup carried a header the deploy tool could read. * * The fix has two halves that must agree: Database.STARTUP_ASSERTED_MIGRATIONS @@ -87,9 +87,10 @@ describe('Database.migrationDeclaresDeployPrecondition @regression @tier1', func describe('Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1', function () { - it('registers exactly the three migrations this tree asserts at startup', function () { + it('registers exactly the four migrations this tree asserts at startup', function () { const files = Database.STARTUP_ASSERTED_MIGRATIONS.map(m => m.file).sort(); assert.deepStrictEqual(files, [ + '2026-06-02-widen-ids-to-bigint.sql', '2026-06-13-dispensers-expiration-bigint.sql', '2026-07-24-pubkeys-widen-uncompressed.sql', '2026-08-10-action-data-utf8mb4.sql', @@ -148,6 +149,8 @@ describe('Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1', function () describe('startupAssertedMigrationFile()', function () { it('resolves each registered assertion to its migration filename', function () { + assert.strictEqual(Database.startupAssertedMigrationFile('assertTransactionIdsAreBigint'), + '2026-06-02-widen-ids-to-bigint.sql'); assert.strictEqual(Database.startupAssertedMigrationFile('assertDispenserExpirationIsBigintUnsigned'), '2026-06-13-dispensers-expiration-bigint.sql'); assert.strictEqual(Database.startupAssertedMigrationFile('assertPubkeyColumnIsUncompressedWide'), diff --git a/test/unit/migration_preconditions_bigint.test.js b/test/unit/migration_preconditions_bigint.test.js new file mode 100644 index 00000000..f2c5af34 --- /dev/null +++ b/test/unit/migration_preconditions_bigint.test.js @@ -0,0 +1,107 @@ +'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. + * + ********************************************************************/ + +const assert = require('assert'); + +const Database = require('../../src/db'); + +const columns = [ + { col: 'tx_index', dataType: 'bigint', columnType: 'bigint(20) unsigned' }, + { col: 'tx_hash_id', dataType: 'bigint', columnType: 'bigint(20) unsigned' }, + { col: 'block_index', dataType: 'bigint', columnType: 'bigint(20) unsigned' }, + { col: 'source_id', dataType: 'bigint', columnType: 'bigint(20) unsigned' }, + { col: 'destination_id', dataType: 'bigint', columnType: 'bigint(20) unsigned' } +]; + +function ctxReturning(rows) { + return { + dbName: 'test_decoder', + transactionConnection: null, + getConnection: async () => ({ + query: async () => rows.map(row => ({ tableExists: 1, ...row })), + release: async () => {} + }) + }; +} + +describe('assertTransactionIdsAreBigint types @regression @tier1', function () { + + it('names the exact migration file when a column is still INT', async function () { + let message = null; + try { + await Database.prototype.assertTransactionIdsAreBigint.call(ctxReturning([ + { ...columns[0], dataType: 'int', columnType: 'int(10) unsigned' }, + ...columns.slice(1) + ])); + } catch (err) { + message = err.message; + } + assert.ok(message, 'an INT id column must fail the assertion'); + assert.ok(message.includes('--file 2026-06-02-widen-ids-to-bigint.sql'), + 'the halt message must name the migration; got: ' + message); + }); + + it('rejects a signed BIGINT id column', async function () { + await assert.rejects( + Database.prototype.assertTransactionIdsAreBigint.call(ctxReturning([ + { ...columns[0], columnType: 'bigint(20)' }, + ...columns.slice(1) + ])), + /BIGINT UNSIGNED.*tx_index=bigint\(20\)/s); + }); + + it('accepts the complete transactions id set at BIGINT UNSIGNED', async function () { + await Database.prototype.assertTransactionIdsAreBigint.call(ctxReturning(columns)); + }); +}); + +describe('assertTransactionIdsAreBigint metadata @regression @tier1', function () { + it('rejects a missing id column', async function () { + await assert.rejects( + Database.prototype.assertTransactionIdsAreBigint.call(ctxReturning(columns.slice(1))), + /tx_index=missing/); + }); + + it('skips when the transactions table does not exist yet', async function () { + await Database.prototype.assertTransactionIdsAreBigint.call(ctxReturning([])); + }); + + it('skips unrelated fixture rows that did not come from its schema query', async function () { + const ctx = ctxReturning([]); + ctx.getConnection = async () => ({ + query: async () => [{ dataType: 'bigint', columnType: 'bigint(20) unsigned' }], + release: async () => {} + }); + await Database.prototype.assertTransactionIdsAreBigint.call(ctx); + }); +}); + +describe('runMigrations BIGINT id startup contract @regression @tier1', function () { + + it('fails closed when the required assertion method is unavailable', async function () { + const ctx = { + assertStrictSqlMode: async () => {}, + runMigrationsInner: async () => ({ applied: [], pending: [] }), + assertDispenserExpirationIsBigintUnsigned: async () => {}, + assertPubkeyColumnIsUncompressedWide: async () => {}, + assertActionDataIsUtf8mb4: async () => {} + }; + + await assert.rejects( + Database.prototype.runMigrations.call(ctx), + /assertTransactionIdsAreBigint/); + }); +}); diff --git a/test/unit/migration_runner.test.js b/test/unit/migration_runner.test.js index 0562836f..22fe911d 100644 --- a/test/unit/migration_runner.test.js +++ b/test/unit/migration_runner.test.js @@ -205,10 +205,10 @@ describe('Database.destructiveAutoStatement() @regression', function () { assert.ok(scanSql("SET @s = 'DROP TABLE events'; PREPARE stmt FROM @s; EXECUTE stmt;")); }); - it('does NOT flag benign system-variable SETs (SET NAMES / SET sql_mode / SET @@)', function () { + it('does NOT flag the allow-listed session SETs (SET NAMES / a single UTC time_zone)', function () { assert.strictEqual(scanSql('SET NAMES utf8mb4;'), null); - assert.strictEqual(scanSql('SET sql_mode = "STRICT_ALL_TABLES";'), null); - assert.strictEqual(scanSql('SET @@session.foreign_key_checks = 0;'), null); + assert.strictEqual(scanSql("SET time_zone = '+00:00';"), null); + assert.strictEqual(scanSql("SET @@session.time_zone = '+00:00';"), null); }); // The indexer twin carries the same cases; keep the two suites in step. diff --git a/test/unit/migration_runner.test/07_database_schema_contract_guards.test.js b/test/unit/migration_runner.test/07_database_schema_contract_guards.test.js index 164b66f4..4bfdb4f7 100644 --- a/test/unit/migration_runner.test/07_database_schema_contract_guards.test.js +++ b/test/unit/migration_runner.test/07_database_schema_contract_guards.test.js @@ -156,14 +156,31 @@ describe('Database schema-contract guards @regression', function () { runMigrationsInner: async () => ({ applied: [], pending: [], lockSkipped: true }), assertDispenserExpirationIsBigintUnsigned: async () => { calls.push('dispenser'); }, assertPubkeyColumnIsUncompressedWide: async () => { calls.push('pubkey'); }, + assertTransactionIdsAreBigint: async () => { calls.push('bigint ids'); }, assertActionDataIsUtf8mb4: async () => { calls.push('utf8mb4'); }, assertStrictSqlMode: async () => { calls.push('sqlmode'); } }; const result = await Database.prototype.runMigrations.call(ctx); - assert.deepStrictEqual(calls, ['dispenser', 'pubkey', 'utf8mb4', 'sqlmode']); + // sql_mode is session state, so it is checked before the body; the schema guards follow it. + assert.deepStrictEqual(calls, ['sqlmode', 'dispenser', 'pubkey', 'bigint ids', 'utf8mb4']); assert.strictEqual(result.lockSkipped, true); }); + it('refuses a lax session before the migration body runs a single statement', async function () { + // A backfill committed under a lax or NO_BACKSLASH_ESCAPES session is already truncated, + // so the guard has to stop the run before runMigrationsInner, not report after it. + const calls = []; + const ctx = { + runMigrationsInner: async () => { calls.push('body'); return { applied: [], pending: [] }; }, + assertDispenserExpirationIsBigintUnsigned: async () => { calls.push('dispenser'); }, + assertPubkeyColumnIsUncompressedWide: async () => { calls.push('pubkey'); }, + assertActionDataIsUtf8mb4: async () => { calls.push('utf8mb4'); }, + assertStrictSqlMode: async () => { calls.push('sqlmode'); throw new Error('lax session'); } + }; + await assert.rejects(Database.prototype.runMigrations.call(ctx), /lax session/); + assert.deepStrictEqual(calls, ['sqlmode']); + }); + }); // The action-text charset is a mode=manual widen (a charset conversion rewrites every diff --git a/test/unit/migration_runner.test/09_migration_query_timeout.test.js b/test/unit/migration_runner.test/09_migration_query_timeout.test.js index 5c7c3ee5..4f9abf47 100644 --- a/test/unit/migration_runner.test/09_migration_query_timeout.test.js +++ b/test/unit/migration_runner.test/09_migration_query_timeout.test.js @@ -139,3 +139,36 @@ describe('runMigrations() migration-scoped statement timeout on scoped runs @reg }); }); + +// A migration file can SET session variables the pool never resets on release, so a +// connection that ran file statements must not go back to the pool. +describe('runMigrations() migration connection retirement @regression', function () { + + it('closes, never pools, a connection that applied a migration file, after unlocking', async function () { + const { db, calls, counts } = makeDb(); + const res = await withTimeoutEnv(undefined, () => db.runMigrationsInner({ includeManual: true })); + assert.deepStrictEqual(res.applied, [FILE]); + assert.deepStrictEqual(counts, { release: 0, destroy: 1 }); + assert.ok(indexOf(calls, /RELEASE_LOCK/) > indexOf(calls, /MODIFY c BIGINT/), 'the lock is still released first'); + }); + + it('closes the connection when a file fails partway through its statements', async function () { + const { db, counts } = makeDb([], { failBody: true }); + await assert.rejects(() => db.runMigrationsInner({ includeManual: true }), /body boom/); + assert.deepStrictEqual(counts, { release: 0, destroy: 1 }); + }); + + it('returns the connection to the pool when no file ran', async function () { + const sum = crypto.createHash('sha256').update(BODY).digest('hex'); + const nothingPending = makeDb([{ name: FILE, checksum: sum }]); + const res = await nothingPending.db.runMigrationsInner({ includeManual: true }); + assert.deepStrictEqual(res.applied, []); + assert.deepStrictEqual(nothingPending.counts, { release: 1, destroy: 0 }); + + const lockSkipped = makeDb([], { lock: false }); + const skipped = await lockSkipped.db.runMigrationsInner({ includeManual: true }); + assert.strictEqual(skipped.lockSkipped, true); + assert.deepStrictEqual(lockSkipped.counts, { release: 1, destroy: 0 }); + }); + +}); diff --git a/test/unit/migration_runner.test/10_migration_executable_residue_pin.test.js b/test/unit/migration_runner.test/10_migration_executable_residue_pin.test.js new file mode 100644 index 00000000..e82e326a --- /dev/null +++ b/test/unit/migration_runner.test/10_migration_executable_residue_pin.test.js @@ -0,0 +1,90 @@ +'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. + * + ********************************************************************** + * Migration immutability in CI: every pinned migration keeps its executable residue, + * and its whole-file hash changes only under a reviewed rebaseline. The runtime guard + * (reconcileAppliedMigration) sees an in-place edit only once a deployed ledger + * disagrees; this sees it at the commit. Pin new files with + * bin/lib/migration_residue_pin.js --add. + * + ********************************************************************/ + +const assert = require('assert'); +const fs = require('fs'); + +const pin = require('../../../bin/lib/migration_residue_pin.js'); +const { MIGRATION_CHECKSUM_REBASELINES } = require('../../../src/db'); + +describe('migration executable-residue pin @regression', function () { + const fixture = JSON.parse(fs.readFileSync(pin.FIXTURE, 'utf8')); + + it('every pinned migration on disk still matches its pin', function () { + const bad = pin.findViolations({ files: pin.readTree(), fixture, rebaselines: MIGRATION_CHECKSUM_REBASELINES }); + assert.deepStrictEqual(bad.map((v) => v.kind + ': ' + v.message), []); + }); + + it('pins at least the migrations the rebaseline table names', function () { + for (const file of Object.keys(MIGRATION_CHECKSUM_REBASELINES)) assert.ok(fixture[file], file + ' is not pinned'); + }); + + it('pins each rebaselined file at the hash its rebaseline heals the ledger to', function () { + // A pin that disagrees with `to` would let the file drift from what healed fleets record. + for (const [file, heal] of Object.entries(MIGRATION_CHECKSUM_REBASELINES)) { + assert.strictEqual(fixture[file].sha256, heal.to, file + ': pinned sha256 is not the rebaseline target'); + } + }); +}); + +describe('migration executable-residue pin: the guard bites @regression', function () { + const FILE = '2099-01-01-sample.sql'; + const RAW = '-- xchain:migration mode=auto\nALTER TABLE t ADD COLUMN IF NOT EXISTS c INT;\n'; + const fixture = { [FILE]: pin.pinOf(RAW) }; + const kinds = (files, rebaselines) => pin.findViolations({ files, fixture, rebaselines: rebaselines || {} }).map((v) => v.kind); + + it('an untouched file passes', function () { + assert.deepStrictEqual(kinds({ [FILE]: RAW }), []); + }); + + it('an added statement is a residue violation, even with a rebaseline', function () { + const edited = RAW + 'SELECT 1;\n'; + assert.deepStrictEqual(kinds({ [FILE]: edited }), ['residue']); + const heal = { [FILE]: { from: pin.pinOf(RAW).sha256, to: pin.pinOf(edited).sha256 } }; + assert.deepStrictEqual(kinds({ [FILE]: edited }, heal), ['residue']); + }); + + it('a comment-only edit is a content violation unless a rebaseline heals exactly that pair', function () { + const edited = '-- a note\n' + RAW; + assert.deepStrictEqual(kinds({ [FILE]: edited }), ['content']); + assert.deepStrictEqual(kinds({ [FILE]: edited }, { [FILE]: { from: pin.pinOf(RAW).sha256, to: pin.pinOf(edited).sha256 } }), []); + assert.deepStrictEqual(kinds({ [FILE]: edited }, { [FILE]: { from: 'f'.repeat(64), to: pin.pinOf(edited).sha256 } }), ['content']); + }); + + it('a removed pinned file is missing, and a new unpinned file is free', function () { + assert.deepStrictEqual(kinds({}), ['missing']); + assert.deepStrictEqual(kinds({ [FILE]: RAW, '2099-01-02-new.sql': 'SELECT 1;' }), []); + }); + + it('an unpinned file that sorts before the newest pin is rejected', function () { + const old = '2098-12-31-missed.sql'; + const bad = pin.findViolations({ + files: { [FILE]: RAW, [old]: 'SELECT 1;' }, + fixture, + rebaselines: {} + }); + assert.deepStrictEqual(bad.map((v) => v.kind + ':' + v.file), ['unpinned:' + old]); + assert.ok(bad[0].message.includes('sorts before the newest pinned migration ' + FILE)); + assert.ok(bad[0].message.includes('--add')); + }); +}); diff --git a/test/unit/migration_runner.test/11_destructive_guard_session_set.test.js b/test/unit/migration_runner.test/11_destructive_guard_session_set.test.js new file mode 100644 index 00000000..bb0d5c56 --- /dev/null +++ b/test/unit/migration_runner.test/11_destructive_guard_session_set.test.js @@ -0,0 +1,73 @@ +'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. + * + ********************************************************************** + * Schema migration runner: the session-SET allow-list of the auto-apply scan. + * + * Only SET NAMES and a single UTC time_zone may ride in a mode=auto file. A relaxed + * sql_mode (or foreign_key_checks, unique_checks, ...) switches off the strict-mode + * backstop, so a narrowing MODIFY later in the same file truncates instead of + * failing; SET STATEMENT ... FOR hides a whole statement behind the SET keyword. + * + ********************************************************************/ + +const assert = require('assert'); + +const Database = require('../../../src/db'); + +const modeOf = Database.prototype.migrationMode.bind({}); +const scanOf = Database.prototype.destructiveAutoStatement.bind(Database.prototype); +const splitOf = (raw) => Database.prototype.splitSqlStatements.call(Database.prototype, raw); + +describe('Database.destructiveAutoStatement() session SETs @regression', function () { + it('keeps the allow-listed spellings auto-eligible', function () { + for (const stmt of [ + 'SET NAMES utf8mb4', + 'SET NAMES utf8mb4 COLLATE utf8mb4_general_ci', + "SET time_zone = '+00:00'", + "SET SESSION time_zone='+00:00'", + "SET @@session.time_zone = '+00:00'", + ]) { + assert.strictEqual(scanOf([stmt]), null, stmt + ' must stay auto-eligible'); + } + }); + + it('flags every other SET: sql_mode, check flags, GLOBAL, multi-assignment, other zones, SET STATEMENT', function () { + for (const stmt of [ + 'SET sql_mode = "STRICT_ALL_TABLES"', + "SET sql_mode='NO_ENGINE_SUBSTITUTION'", + "SET SESSION sql_mode=''", + 'SET @@session.foreign_key_checks = 0', + 'SET @@local.unique_checks=0', + 'SET foreign_key_checks = 0', + "SET GLOBAL sql_mode=''", + "SET @@global.time_zone = '+00:00'", + "SET time_zone='+00:00', sql_mode=''", + "SET time_zone='+05:00'", + 'SET STATEMENT max_statement_time=0 FOR DROP TABLE transactions', + 'SET CHARACTER SET utf8mb4', + ]) { + assert.strictEqual(scanOf([stmt]), stmt, stmt + ' must not be auto-eligible'); + } + }); + + it('flags the relax-then-narrow file end to end, and keeps a UTC zone before an additive ALTER auto', function () { + const raw = '-- xchain:migration mode=auto\n' + + "SET sql_mode = 'NO_ENGINE_SUBSTITUTION';\n" + + 'ALTER TABLE transactions MODIFY data VARCHAR(30);\n'; + assert.strictEqual(modeOf(raw), 'auto'); + assert.match(String(scanOf(splitOf(raw))), /^SET sql_mode/); + assert.strictEqual(scanOf(["SET time_zone = '+00:00'", 'ALTER TABLE t ADD INDEX IF NOT EXISTS k (c)']), null); + }); +}); diff --git a/test/unit/migration_runner.test/12_destructive_guard_modify_convert.test.js b/test/unit/migration_runner.test/12_destructive_guard_modify_convert.test.js new file mode 100644 index 00000000..6d351550 --- /dev/null +++ b/test/unit/migration_runner.test/12_destructive_guard_modify_convert.test.js @@ -0,0 +1,71 @@ +'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. + * + ********************************************************************** + * Schema migration runner: CONVERT TO and MODIFY id rules of the auto-apply scan. + * + * CONVERT TO CHARACTER SET rewrites every text column of a table, and a MODIFY that + * restates id without AUTO_INCREMENT strips the attribute, so neither may auto-run. + * + ********************************************************************/ + +const assert = require('assert'); + +const Database = require('../../../src/db'); + +const scanOf = Database.prototype.destructiveAutoStatement.bind(Database.prototype); +const splitOf = (raw) => Database.prototype.splitSqlStatements.call(Database.prototype, raw); + +describe('Database.destructiveAutoStatement() CONVERT TO and MODIFY id @regression', function () { + it('flags ALTER TABLE ... CONVERT TO in every spelling', function () { + for (const stmt of [ + 'ALTER TABLE t CONVERT TO CHARACTER SET utf8mb4', + 'ALTER TABLE t CONVERT TO CHARSET utf8mb4 COLLATE utf8mb4_bin', + 'alter online table `t` convert to character set latin1', + 'ALTER TABLE t ADD COLUMN c INT, CONVERT TO CHARACTER SET utf8mb4', + ]) { + assert.strictEqual(scanOf([stmt]), stmt, stmt + ' must not be auto-eligible'); + } + }); + + it('flags a MODIFY of id that drops AUTO_INCREMENT', function () { + for (const stmt of [ + 'ALTER TABLE t MODIFY id BIGINT', + 'ALTER TABLE t MODIFY COLUMN id BIGINT UNSIGNED', + 'ALTER TABLE t MODIFY `id` BIGINT NULL', + 'ALTER TABLE t MODIFY id BIGINT NOT NULL AUTO_INCREMENT, MODIFY `id` BIGINT', + 'ALTER TABLE t ADD COLUMN c INT, MODIFY id BIGINT', + 'ALTER TABLE t /* note */ MODIFY id BIGINT', + ]) { + assert.strictEqual(scanOf([stmt]), stmt, stmt + ' must not be auto-eligible'); + } + }); + + it('keeps the AUTO_INCREMENT id repair and unrelated MODIFYs auto-eligible', function () { + for (const stmt of [ + 'ALTER TABLE t MODIFY id BIGINT NOT NULL AUTO_INCREMENT', + 'ALTER TABLE t MODIFY COLUMN id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT', + 'ALTER TABLE t MODIFY identifier VARCHAR(10) NULL', + 'ALTER TABLE t MODIFY memo MEDIUMTEXT NULL', + 'ALTER TABLE t ADD COLUMN c INT', + ]) { + assert.strictEqual(scanOf([stmt]), null, stmt + ' must stay auto-eligible'); + } + }); + + it('refuses a mode=auto file that converts the charset, from raw file text', function () { + const raw = '-- xchain:migration mode=auto\nALTER TABLE transactions CONVERT TO CHARACTER SET utf8mb3;\n'; + assert.match(String(scanOf(splitOf(raw))), /CONVERT TO CHARACTER SET utf8mb3/); + }); +}); diff --git a/test/unit/migration_runner.test/13_live_schema_guard.test.js b/test/unit/migration_runner.test/13_live_schema_guard.test.js new file mode 100644 index 00000000..c7195f60 --- /dev/null +++ b/test/unit/migration_runner.test/13_live_schema_guard.test.js @@ -0,0 +1,109 @@ +'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. + * + ********************************************************************** + * Live-schema MODIFY guard: an auto migration whose MODIFY would strip or narrow a live + * column is refused before it runs. + * + ********************************************************************/ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const Database = require('../../../src/db'); +const { assertNoLiveColumnLoss, modifyClauses } = require('../../../src/db/migration_live_schema_guard.js'); + +const statementsOf = (raw) => Database.prototype.splitSqlStatements.call(Database.prototype, raw); + +const liveRow = (over) => Object.assign({ + COLUMN_TYPE: 'bigint(20) unsigned', COLUMN_DEFAULT: null, EXTRA: '', COLUMN_COMMENT: '', + GENERATION_EXPRESSION: '', CHARACTER_SET_NAME: null, +}, over); + +async function verdict(live, sql) { + const conn = { query: async () => (live ? [live] : []) }; + try { await assertNoLiveColumnLoss(conn, 'f.sql', statementsOf(sql)); return null; } + catch (e) { return e.message; } +} + +describe('decoder migration live-schema MODIFY guard @regression', function () { + it('finds each MODIFY clause, ignoring FIRST/AFTER and other clauses', function () { + const got = modifyClauses(statementsOf( + 'ALTER TABLE `t` MODIFY COLUMN a INT NOT NULL AFTER b, ADD COLUMN c INT, MODIFY d VARCHAR(10) DEFAULT \'x,y\';')); + assert.deepStrictEqual(got.map(g => [g.table, g.column]), [['t', 'a'], ['t', 'd']]); + assert.strictEqual(got[0].definition, 'INT NOT NULL'); + }); + + it('refuses a MODIFY that strips AUTO_INCREMENT, a DEFAULT, ON UPDATE, COMMENT or a generation expression', async function () { + assert.match(await verdict(liveRow({ EXTRA: 'auto_increment' }), 'ALTER TABLE t MODIFY id BIGINT UNSIGNED NOT NULL;'), /strips AUTO_INCREMENT/); + assert.match(await verdict(liveRow({ COLUMN_TYPE: 'int(11)', COLUMN_DEFAULT: '0' }), 'ALTER TABLE t MODIFY n INT NOT NULL;'), /strips DEFAULT 0/); + assert.match(await verdict(liveRow({ COLUMN_TYPE: 'timestamp', EXTRA: 'on update current_timestamp()' }), + 'ALTER TABLE t MODIFY u TIMESTAMP;'), /strips ON UPDATE/); + assert.match(await verdict(liveRow({ COLUMN_COMMENT: 'why' }), 'ALTER TABLE t MODIFY n BIGINT UNSIGNED;'), /strips COMMENT/); + assert.match(await verdict(liveRow({ COLUMN_TYPE: 'int(11)', EXTRA: 'VIRTUAL GENERATED', GENERATION_EXPRESSION: '`a` + 1' }), + 'ALTER TABLE t MODIFY g INT;'), /generation expression/); + }); + + it('refuses narrowing of type, length and charset', async function () { + assert.match(await verdict(liveRow({ COLUMN_TYPE: 'bigint(20)' }), 'ALTER TABLE t MODIFY n INT;'), /narrows the type/); + assert.match(await verdict(liveRow({ COLUMN_TYPE: 'varchar(64)', COLUMN_DEFAULT: "'x'" }), "ALTER TABLE t MODIFY s VARCHAR(32) DEFAULT 'x';"), /varchar\(64\) -> varchar\(32\)/); + assert.match(await verdict(liveRow({ COLUMN_TYPE: 'mediumtext', CHARACTER_SET_NAME: 'utf8mb4' }), + 'ALTER TABLE t MODIFY data MEDIUMTEXT CHARACTER SET utf8mb3;'), /charset/); + }); + + it('passes a MODIFY that restates every attribute or only widens, and an absent column', async function () { + assert.strictEqual(await verdict(liveRow({ EXTRA: 'auto_increment' }), + 'ALTER TABLE t MODIFY id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT;'), null); + assert.strictEqual(await verdict(liveRow({ COLUMN_TYPE: 'text' }), 'ALTER TABLE events MODIFY COLUMN data MEDIUMTEXT;'), null); + assert.strictEqual(await verdict(null, 'ALTER TABLE t MODIFY n INT;'), null); + assert.strictEqual(await verdict({ dataType: 'bigint' }, 'ALTER TABLE t MODIFY n INT;'), null); + }); + + it('sees a MODIFY behind a block comment and ignores a keyword inside one', async function () { + const live = liveRow({ COLUMN_TYPE: 'int(11)', COLUMN_DEFAULT: '0' }); + assert.match(await verdict(live, '/* widen */ ALTER TABLE t MODIFY n INT;'), /strips DEFAULT 0/); + assert.match(await verdict(live, 'ALTER TABLE t /* note */ MODIFY n INT;'), /strips DEFAULT 0/); + assert.match(await verdict(live, 'ALTER TABLE t MODIFY n INT /* DEFAULT dropped */;'), /strips DEFAULT 0/); + }); +}); + +describe('decoder migration runner applies the live-schema guard to auto files @regression', function () { + function makeDb(live) { + const executed = []; + const conn = { + async query(sql) { + if (/GET_LOCK|RELEASE_LOCK|^SET SESSION|CREATE TABLE|schema_migrations/.test(sql)) return /GET_LOCK/.test(sql) ? [{ l: '1' }] : []; + if (/@@SESSION\.sql_mode/.test(sql)) return [{ mode: 'STRICT_TRANS_TABLES' }]; + if (/COLUMN_DEFAULT/.test(sql)) return [live]; + executed.push(sql); + return []; + }, + async release() {}, + }; + const db = Object.create(Database.prototype); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'decoder-live-guard-')); + fs.mkdirSync(path.join(root, 'migrations')); + fs.writeFileSync(path.join(root, 'migrations', '2099-01-01-t.sql'), '-- xchain:migration mode=auto\nALTER TABLE t MODIFY s VARCHAR(64);\n'); + Object.assign(db, { sqlPath: root, dbName: 'fake_db', getConnection: async () => conn, ensureMigrationsLedger: async () => {} }); + return { db, executed }; + } + + it('refuses an auto MODIFY that would strip a live DEFAULT before running any statement', async function () { + const { db, executed } = makeDb(liveRow({ COLUMN_TYPE: 'varchar(64)', COLUMN_DEFAULT: "'x'" })); + await assert.rejects(() => db.runMigrations({}), /would damage the live column: strips DEFAULT/); + assert.ok(!executed.some((s) => /^ALTER/i.test(s)), 'the MODIFY never ran'); + }); +}); diff --git a/test/unit/migration_runner.test/14_destructive_guard_engine.test.js b/test/unit/migration_runner.test/14_destructive_guard_engine.test.js new file mode 100644 index 00000000..607daaca --- /dev/null +++ b/test/unit/migration_runner.test/14_destructive_guard_engine.test.js @@ -0,0 +1,69 @@ +'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. + * + ********************************************************************** + * Schema migration runner: storage-engine rule of the auto-apply scan. + * + * MEMORY loses every row on restart and MyISAM/Aria sit outside the transaction a + * reorg rollback undoes, so only InnoDB tables may be created or kept by an auto file. + * + ********************************************************************/ + +const assert = require('assert'); + +const Database = require('../../../src/db'); + +const destructiveOf = Database.prototype.destructiveAutoStatement.bind(Database.prototype); + +describe('Database.destructiveAutoStatement() storage engine @regression', function () { + it('flags every ALTER TABLE that names an ENGINE, InnoDB included', function () { + for (const stmt of [ + 'ALTER TABLE balances ENGINE=Aria', + 'ALTER TABLE balances ENGINE=MyISAM', + 'ALTER TABLE balances ENGINE = MEMORY', + 'ALTER TABLE balances ENGINE=InnoDB', + 'alter table t engine myisam', + 'ALTER ONLINE TABLE t ENGINE=InnoDB', + 'ALTER TABLE t ADD COLUMN c INT NULL, ENGINE=Aria', + ]) { + assert.strictEqual(destructiveOf([stmt]), stmt, stmt + ' must not be auto-eligible'); + } + }); + + it('flags a CREATE TABLE that names any engine other than InnoDB', function () { + for (const stmt of [ + 'CREATE TABLE t (id BIGINT) ENGINE=MEMORY', + 'CREATE TEMPORARY TABLE t (id BIGINT) ENGINE=MyISAM', + "CREATE TABLE t (id BIGINT) ENGINE='Aria'", + 'CREATE TABLE IF NOT EXISTS t (id BIGINT) engine = `MyISAM`', + 'CREATE TABLE t (id BIGINT) ENGINE Aria', + ]) { + assert.strictEqual(destructiveOf([stmt]), stmt, stmt + ' must not be auto-eligible'); + } + }); + + it('keeps InnoDB and engine-less CREATE TABLE and unrelated ALTERs auto-eligible', function () { + for (const stmt of [ + 'CREATE TABLE t (id BIGINT) ENGINE=InnoDB', + 'CREATE TABLE IF NOT EXISTS t (id BIGINT) engine = innodb', + "CREATE TABLE t (id BIGINT) ENGINE='InnoDB' DEFAULT CHARSET=utf8mb4", + 'CREATE TEMPORARY TABLE t (id BIGINT) ENGINE=InnoDB', + 'CREATE TABLE t (id BIGINT)', + 'ALTER TABLE t ADD COLUMN c INT NULL', + 'ALTER TABLE t ADD COLUMN engine_kind INT NULL', + ]) { + assert.strictEqual(destructiveOf([stmt]), null, stmt + ' must stay auto-eligible'); + } + }); +}); diff --git a/test/unit/migration_runner.test/15_destructive_guard_drop_target.test.js b/test/unit/migration_runner.test/15_destructive_guard_drop_target.test.js new file mode 100644 index 00000000..b77099b5 --- /dev/null +++ b/test/unit/migration_runner.test/15_destructive_guard_drop_target.test.js @@ -0,0 +1,71 @@ +'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. + * + ********************************************************************** + * Schema migration runner: DROP target rule of the auto-apply scan. + * + * Only a bare, unquoted metadata keyword may follow DROP in an auto ALTER. A quoted + * name is always a column, and a token the scan cannot read is not provably safe. + * + ********************************************************************/ + +const assert = require('assert'); + +const Database = require('../../../src/db'); + +const destructiveOf = Database.prototype.destructiveAutoStatement.bind(Database.prototype); + +const KEYWORDS = ['index', 'key', 'foreign', 'constraint', 'check', 'default', 'primary']; + +describe('Database.destructiveAutoStatement() DROP target @regression', function () { + it('flags a DROP of a backtick-quoted column named like a metadata keyword', function () { + for (const kw of KEYWORDS) { + for (const name of [kw, kw.toUpperCase()]) { + const stmt = 'ALTER TABLE t DROP `' + name + '`'; + assert.strictEqual(destructiveOf([stmt]), stmt, stmt + ' must not be auto-eligible'); + } + } + }); + + it('flags a DROP whose target only starts with a keyword or cannot be read', function () { + for (const stmt of [ + 'ALTER TABLE t DROP key1', + 'ALTER TABLE t DROP default2', + 'ALTER TABLE t DROP 1col', + 'ALTER TABLE t DROP "key"', + 'ALTER TABLE t DROP`key`', + 'ALTER TABLE t ADD COLUMN y INT NULL, DROP `primary`', + ]) { + assert.strictEqual(destructiveOf([stmt]), stmt, stmt + ' must not be auto-eligible'); + } + }); + + it('keeps bare metadata-only DROP forms auto-eligible', function () { + for (const stmt of [ + 'ALTER TABLE t DROP INDEX IF EXISTS idx', + 'alter table t drop key k', + 'ALTER TABLE t DROP INDEX `idx_name`', + 'ALTER TABLE t DROP INDEX`idx_name`', + 'ALTER TABLE t DROP FOREIGN KEY `fk_b`', + 'ALTER TABLE t DROP CONSTRAINT chk', + 'ALTER TABLE t DROP CHECK chk', + 'ALTER TABLE t DROP PRIMARY KEY, ADD PRIMARY KEY (id, seq)', + 'ALTER TABLE t ALTER COLUMN x DROP DEFAULT', + 'ALTER TABLE t ALTER COLUMN `key` DROP DEFAULT', + 'ALTER TABLE t ALTER COLUMN x DROP DEFAULT, ADD COLUMN y INT NULL', + ]) { + assert.strictEqual(destructiveOf([stmt]), null, stmt + ' must stay auto-eligible'); + } + }); +}); diff --git a/test/unit/migration_runner.test/16_destructive_guard_quoted_comments.test.js b/test/unit/migration_runner.test/16_destructive_guard_quoted_comments.test.js new file mode 100644 index 00000000..8e09103c --- /dev/null +++ b/test/unit/migration_runner.test/16_destructive_guard_quoted_comments.test.js @@ -0,0 +1,82 @@ +'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. + * + ********************************************************************** + * Comment markers inside quoted spans: the auto-apply scan and the live-schema guard strip + * block comments with the splitter's quote model, so a '/*' literal and a later '*' + '/' + * literal cannot hide the real clauses between them. Kept in step with the indexer suite. + * + ********************************************************************/ + +const assert = require('assert'); + +const Database = require('../../../src/db'); +const { modifyClauses, stripBlockComments } = require('../../../src/db/migration_live_schema_guard.js'); + +const destructiveOf = Database.prototype.destructiveAutoStatement.bind(Database.prototype); +const statementsOf = (raw) => Database.prototype.splitSqlStatements.call(Database.prototype, raw); + +const HIDDEN = ['DROP COLUMN amount', 'RENAME TO balances_old', 'CHANGE amount amount2 BIGINT', 'ENGINE=MEMORY']; +const between = (open, close, clause) => 'ALTER TABLE balances ADD COLUMN IF NOT EXISTS a VARCHAR(4) DEFAULT ' + open + + ', ' + clause + ', ADD COLUMN IF NOT EXISTS b VARCHAR(4) DEFAULT ' + close + ';'; + +describe('Database.destructiveAutoStatement() quoted comment markers @regression', function () { + it('flags a destructive clause hidden between a /* literal and a later */ literal', function () { + for (const clause of HIDDEN) { + for (const [open, close] of [["'/*'", "'*/'"], ['"/*"', '"*/"']]) { + const sql = between(open, close, clause); + const offender = destructiveOf(statementsOf(sql)); + assert.ok(offender && offender.includes(clause), sql + ' must not be auto-eligible'); + } + } + }); + + it('flags the same trick through backtick identifiers, one ending in a backslash', function () { + for (const sql of [ + 'ALTER TABLE balances ADD COLUMN `x/*` INT, DROP COLUMN amount, ADD COLUMN `y*/` INT;', + 'ALTER TABLE balances ADD COLUMN `a\\` INT, ADD COLUMN `x/*` INT, DROP COLUMN amount, ADD COLUMN `y*/` INT;', + ]) { + const offender = destructiveOf(statementsOf(sql)); + assert.ok(offender && /DROP COLUMN amount/.test(offender), sql + ' must not be auto-eligible'); + } + }); + + it('still reads a real comment as a comment and a quoted marker as data', function () { + for (const sql of [ + "ALTER TABLE t ADD COLUMN c VARCHAR(4) DEFAULT '/*';", + "ALTER TABLE t ADD COLUMN c VARCHAR(4) DEFAULT '*/' /* note: DROP TABLE x */;", + '/* note */ ALTER TABLE t ADD COLUMN c INT;', + ]) assert.strictEqual(destructiveOf(statementsOf(sql)), null, sql); + assert.ok(destructiveOf(statementsOf('/* note */ DROP TABLE balances;'))); + }); +}); + +describe('decoder live-schema guard quoted comment markers @regression', function () { + it('finds a MODIFY between quoted markers or after a backslash-ended identifier', function () { + for (const sql of [ + "ALTER TABLE t ADD COLUMN x VARCHAR(4) DEFAULT '/*', MODIFY amount INT, ADD COLUMN y VARCHAR(4) DEFAULT '*/';", + 'ALTER TABLE t ADD COLUMN `a\\` INT, MODIFY amount INT;', + ]) assert.deepStrictEqual(modifyClauses(statementsOf(sql)).map(m => m.column), ['amount'], sql); + }); + + it('strips block comments with the splitter quote model', function () { + assert.strictEqual(stripBlockComments('a /* x */ b'), 'a b'); + assert.strictEqual(stripBlockComments("'/* x */' \"*/\" `/*`"), "'/* x */' \"*/\" `/*`"); + assert.strictEqual(stripBlockComments("'a''b' /* c */ d"), "'a''b' d"); + assert.strictEqual(stripBlockComments("'a\\' /* x */' /* y */ z"), "'a\\' /* x */' z"); + assert.strictEqual(stripBlockComments('`a\\` /* x */ b'), '`a\\` b'); + assert.strictEqual(stripBlockComments('a /*!50000 b */ c /*M!100100 d */'), 'a /*!50000 b */ c /*M!100100 d */'); + assert.strictEqual(stripBlockComments('a /* b'), 'a /* b'); + }); +}); diff --git a/test/unit/migration_runner.test/17_live_schema_guard_types.test.js b/test/unit/migration_runner.test/17_live_schema_guard_types.test.js new file mode 100644 index 00000000..6ebced83 --- /dev/null +++ b/test/unit/migration_runner.test/17_live_schema_guard_types.test.js @@ -0,0 +1,97 @@ +'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. + * + ********************************************************************** + * Live-schema MODIFY guard, type and charset rules: a type change with no widening rule + * fails closed, a same-name restatement may not drop precision, and a text MODIFY that + * names no charset or collation may not fall back to a different table default. Kept in + * step with the indexer suite. + * + ********************************************************************/ + +const assert = require('assert'); + +const Database = require('../../../src/db'); +const { assertNoLiveColumnLoss, losses } = require('../../../src/db/migration_live_schema_guard.js'); + +const statementsOf = (raw) => Database.prototype.splitSqlStatements.call(Database.prototype, raw); + +const liveRow = (over) => Object.assign({ + COLUMN_TYPE: 'bigint(20) unsigned', COLUMN_DEFAULT: null, EXTRA: '', COLUMN_COMMENT: '', + GENERATION_EXPRESSION: '', CHARACTER_SET_NAME: null, COLLATION_NAME: null, TABLE_COLLATION: null, +}, over); +const lossOf = (over, definition) => losses(liveRow(over), definition).join('; '); +const text = (type, set, collation, table) => + ({ COLUMN_TYPE: type, CHARACTER_SET_NAME: set, COLLATION_NAME: collation, TABLE_COLLATION: table }); + +describe('decoder migration live-schema MODIFY guard: type changes @regression', function () { + it('refuses a type change it has no widening rule for', function () { + for (const [live, next] of [ + ['decimal(8,4)', 'INT'], ['datetime', 'DATE'], ['timestamp', 'DATE'], ['double', 'FLOAT'], + ['double', 'INT'], ['date', 'TIME'], ['datetime', 'TIMESTAMP'], ["enum('a','b')", 'VARCHAR(10)'], + ['varchar(250)', 'TINYTEXT'], ['bigint(20)', 'DECIMAL(5,2)'], ['int(11)', 'DECIMAL(12,4)'], + ['bigint(20) unsigned', 'DECIMAL(19,0)'], ['int(11)', 'DECIMAL(10,0) UNSIGNED'], + ]) assert.match(lossOf({ COLUMN_TYPE: live }, next), /narrows the type/, live + ' -> ' + next); + }); + + it('refuses a same-name restatement that drops precision, fractional seconds or width', function () { + for (const [live, next] of [ + ['decimal(20,8)', 'DECIMAL'], ['datetime(6)', 'DATETIME'], ['timestamp(3)', 'TIMESTAMP'], + ['time(6)', 'TIME'], ['bit(8)', 'BIT(4)'], ['double(20,8)', 'DOUBLE(10,2)'], ['float', 'FLOAT(7,4)'], + ['decimal(10,2)', 'DECIMAL(10,2) UNSIGNED'], + ]) assert.match(lossOf({ COLUMN_TYPE: live }, next), /narrows the type/, live + ' -> ' + next); + }); + + it('passes widenings and alias restatements', function () { + for (const [live, next] of [ + ['timestamp(3)', 'DATETIME(6)'], ['date', 'DATETIME'], ['int(11)', 'DECIMAL(10,0)'], + ['bigint(20) unsigned', 'DECIMAL(20,0) UNSIGNED'], ['decimal(8,4)', 'DECIMAL(10,4)'], + ['decimal(8,4)', 'NUMERIC(8,4)'], ['decimal(10,0)', 'DECIMAL'], ['float', 'DOUBLE'], + ['datetime(6)', 'DATETIME(6)'], ['tinyint(1)', 'BOOLEAN'], ['longtext', 'JSON'], ['varchar(250)', 'TEXT'], + ]) assert.strictEqual(lossOf({ COLUMN_TYPE: live }, next), '', live + ' -> ' + next); + }); + + it('names the live and restated types when a decimal score would be rounded to an integer', async function () { + const conn = { query: async () => [liveRow({ COLUMN_TYPE: 'decimal(8,4)', COLUMN_DEFAULT: '0.0000' })] }; + await assert.rejects(assertNoLiveColumnLoss(conn, 'f.sql', + statementsOf('ALTER TABLE attest_validator_stats MODIFY quality_score INT DEFAULT 0;')), + /narrows the type \(decimal\(8,4\) -> int\)/); + }); + + it('passes the timestamp to datetime move the committed auto migrations make', function () { + assert.strictEqual(lossOf({ COLUMN_TYPE: 'timestamp', COLUMN_DEFAULT: 'current_timestamp()' }, + 'DATETIME DEFAULT CURRENT_TIMESTAMP'), ''); + }); +}); + +describe('decoder migration live-schema MODIFY guard: omitted charset and collation @regression', function () { + it('refuses a charset-less text MODIFY that falls back to a different table charset', function () { + for (const table of ['utf8mb3_general_ci', 'utf8_general_ci']) { + assert.match(lossOf(text('varchar(250)', 'utf8mb4', 'utf8mb4_general_ci', table), 'VARCHAR(500)'), + /omits CHARACTER SET \(utf8mb4_general_ci -> table default utf8mb3_general_ci\)/, table); + } + }); + + it('refuses a charset-less text MODIFY that drops a column collation', function () { + assert.match(lossOf(text('varchar(100)', 'utf8mb3', 'utf8mb3_bin', 'utf8mb3_general_ci'), 'VARCHAR(200)'), + /omits COLLATE \(utf8mb3_bin -> table default utf8mb3_general_ci\)/); + }); + + it('passes when the column already carries the table default or the MODIFY names its charset', function () { + assert.strictEqual(lossOf(text("enum('a')", 'utf8mb3', 'utf8mb3_general_ci', 'utf8_general_ci'), "ENUM('a','b')"), ''); + assert.strictEqual(lossOf(text('varchar(250)', 'utf8mb4', 'utf8mb4_general_ci', 'utf8mb4_general_ci'), 'VARCHAR(500)'), ''); + assert.strictEqual(lossOf(text('varchar(250)', 'utf8mb4', 'utf8mb4_general_ci', 'utf8mb3_general_ci'), + 'VARCHAR(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci'), ''); + }); +}); diff --git a/test/unit/node_url_failover.test.js b/test/unit/node_url_failover.test.js index 6c3522f0..3f5bc265 100644 --- a/test/unit/node_url_failover.test.js +++ b/test/unit/node_url_failover.test.js @@ -130,10 +130,10 @@ function defineThresholdAndRetryTests() { const connector = makeConnector('10.0.0.2', 2) axiosStub.onCall(0).rejects(connectionError('ECONNABORTED')) axiosStub.onCall(1).rejects(connectionError('ECONNABORTED')) - axiosStub.onCall(2).resolves({ data: { result: 'deadbeef' } }) + axiosStub.onCall(2).resolves({ data: { result: 'deadbeef'.repeat(8) } }) const hash = await connector.getBlockHash(5) - assert.strictEqual(hash, 'deadbeef') + assert.strictEqual(hash, 'deadbeef'.repeat(8)) assert.strictEqual(axiosStub.getCall(2).args[0], 'http://10.0.0.2:8332') }) } diff --git a/test/unit/parse_loop_quarantine.test.js b/test/unit/parse_loop_quarantine.test.js index 9cf1b4f7..403bb24f 100644 --- a/test/unit/parse_loop_quarantine.test.js +++ b/test/unit/parse_loop_quarantine.test.js @@ -206,3 +206,159 @@ describe('XChainDecoder parse-loop quarantine', function () { assert.strictEqual(decoder.mempoolBusy, false, 'the busy flag must be released') }) }) + +// Quarantine retry state belongs to one tx position in one exact block. +// A second tx in the same block, or a sibling block that replaced the tried +// one at the same height, must get its own full retry budget and must never +// inherit a quarantine it was not tried for: either would make this instance +// skip a tx its peers store, shifting every later tx_index. + +const { TX_PARSE_MAX_RETRIES } = require('../../src/XChainDecoder/constants.js') + +// Stop the loop after this many block passes so a wedge fails instead of hanging. +const PASS_LIMIT = 60 + +// Drive the real block loop at height 0. `chain.hash` and `chain.txs` are read on +// every pass, so a test swaps the block by mutating them. +function buildChainDecoder(chain){ + const decoder = new XChainDecoder('bitcoin-regtest', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null) + decoder.startBlockIndex = 0 + decoder.sleep = async () => {} + const calls = { passes: 0, wedged: false, commit: 0, inserted: [], insertEvent: [] } + decoder.connector = { + getBlockchainInfo: async () => ({ verificationprogress: 1, blocks: 0 }), + getBlockHash: async () => chain.hash, + getBlock: async () => '' + } + decoder.db = { + createDatabase: async () => true, verifyDatabase: async () => true, + verifyTables: async () => true, runMigrations: async () => ({ applied: [], pending: [] }), + getLastBlockIndex: async () => -1, getLastTxIndex: async () => 0, + beginTransaction: async () => {}, endTransaction: async () => {}, + commitTransaction: async () => { calls.commit++; decoder.stopFlag = true; return true }, + deleteOpenDispensers: async () => true, purgeExpiredDispensers: async () => true, + getAllOpenDispenserAddresses: async () => new Set(), + insertEvent: async (code, data) => { calls.insertEvent.push({ code, data }); return true }, + insertBlock: async () => true, + insertTransaction: async (tx) => { + const result = chain.insert ? chain.insert(tx.hash) : true + if (result === true) calls.inserted.push(tx.hash) + return result + }, + insertTransactionOutput: async () => true, + DUPLICATED_TRANSACTION: 1, POISON_ROW: 2 + } + decoder.xchainBlockDecoder = { + blockFromHex: () => { + calls.passes++ + if (calls.passes > PASS_LIMIT){ calls.wedged = true; decoder.stopFlag = true } + return { prevHash: Buffer.alloc(32), timestamp: 1700000000, transactions: chain.txs.map(fakeTx) } + } + } + return { decoder, calls } +} + +// A parse result that reaches insertTransaction (a valid SEND payload). +function storableParse(){ + return { + data: Buffer.from('SEND|x'), compiledDataLength: 6, rawData: null, + source: 'someaddr', destination: null, amount: 0, + dispenseOutputs: [], paymentOutputs: [] + } +} + +function quarantinedHashes(calls){ + return calls.insertEvent.filter(e => e.code === 'PARSE_ERROR').map(e => e.data.tx_hash) +} + +// Count calls per tx hash and throw for the hashes `shouldThrow` names. +function countingParse(parseCalls, shouldThrow){ + return async (tx) => { + const id = tx.getId() + parseCalls[id] = (parseCalls[id] || 0) + 1 + if (shouldThrow(id, parseCalls[id])) throw new Error('parse failure ' + id) + return null + } +} + +describe('quarantine retry state is per tx position and per block', function () { + this.timeout(0) + + it('a transient parse throw on a second tx after a quarantine in the same block is still retried', async function () { + const chain = { hash: 'blockA', txs: ['aa', 'bb'] } + const { decoder, calls } = buildChainDecoder(chain) + const parseCalls = {} + decoder.parseTransaction = countingParse(parseCalls, (id, n) => id === 'aa' || (id === 'bb' && n === 1)) + await decoder.start() + assert.strictEqual(calls.wedged, false, 'the block must not wedge') + assert.strictEqual(calls.commit, 1, 'the block commits') + assert.deepStrictEqual([...new Set(quarantinedHashes(calls))], ['aa'], 'only the poison tx is quarantined') + assert.ok(parseCalls.bb >= 2, 'the transiently failing tx is parsed again') + }) + + it('two poison txs in one block each get their own parse retries and the block commits', async function () { + const chain = { hash: 'blockA', txs: ['aa', 'bb'] } + const { decoder, calls } = buildChainDecoder(chain) + const parseCalls = {} + decoder.parseTransaction = countingParse(parseCalls, () => true) + await decoder.start() + assert.strictEqual(calls.wedged, false, 'the block must not wedge') + assert.strictEqual(calls.commit, 1, 'the block commits') + assert.deepStrictEqual([...new Set(quarantinedHashes(calls))].sort(), ['aa', 'bb']) + assert.strictEqual(parseCalls.bb, TX_PARSE_MAX_RETRIES + 1, 'the second poison tx is tried the full number of times') + }) + + it('a sibling block at the same height does not inherit the parse retry count', async function () { + const chain = { hash: 'blockA', txs: ['cafe01'] } + const { decoder, calls } = buildChainDecoder(chain) + const parseCalls = {} + decoder.parseTransaction = countingParse(parseCalls, (id, n) => { + if (id === 'cafe01' && n === TX_PARSE_MAX_RETRIES){ chain.hash = 'blockB'; chain.txs = ['beef02'] } + return id === 'cafe01' || n === 1 + }) + await decoder.start() + assert.strictEqual(calls.wedged, false, 'the block must not wedge') + assert.strictEqual(calls.commit, 1, 'the sibling block commits') + assert.deepStrictEqual(quarantinedHashes(calls), [], 'the sibling tx was never quarantined') + assert.strictEqual(parseCalls.beef02, 2, 'the sibling tx got a retry of its own') + }) +}) + +describe('quarantine retry state is per tx position and per block', function () { + this.timeout(0) + + it('a sibling block at the same height does not inherit an INSERT quarantine', async function () { + const chain = { hash: 'blockA', txs: ['cafe01'] } + const { decoder, calls } = buildChainDecoder(chain) + decoder.parseTransaction = async () => storableParse() + let poisonHits = 0 + chain.insert = (hash) => { + if (hash !== 'cafe01') return true + poisonHits++ + if (poisonHits > TX_PARSE_MAX_RETRIES){ chain.hash = 'blockB'; chain.txs = ['beef02'] } + return decoder.db.POISON_ROW + } + await decoder.start() + assert.strictEqual(calls.wedged, false, 'the block must not wedge') + assert.strictEqual(calls.commit, 1, 'the sibling block commits') + assert.deepStrictEqual(calls.inserted, ['beef02'], 'the sibling tx is inserted, not skipped') + assert.deepStrictEqual(quarantinedHashes(calls), [], 'no PARSE_ERROR names a tx that was never tried') + }) + + it('a one-off INSERT rejection on a second tx after a quarantine in the same block is still retried', async function () { + const chain = { hash: 'blockA', txs: ['cafe01', 'beef02'] } + const { decoder, calls } = buildChainDecoder(chain) + decoder.parseTransaction = async () => storableParse() + let beefHits = 0 + chain.insert = (hash) => { + if (hash === 'cafe01') return decoder.db.POISON_ROW + beefHits++ + return beefHits === 1 ? decoder.db.POISON_ROW : true + } + await decoder.start() + assert.strictEqual(calls.wedged, false, 'the block must not wedge') + assert.strictEqual(calls.commit, 1, 'the block commits') + assert.deepStrictEqual([...new Set(quarantinedHashes(calls))], ['cafe01'], 'only the poison row is quarantined') + assert.deepStrictEqual(calls.inserted, ['beef02'], 'the second tx is inserted on its retry') + }) +}) diff --git a/test/unit/protocol_time.test.js b/test/unit/protocol_time.test.js index d4090681..2fe5620b 100644 --- a/test/unit/protocol_time.test.js +++ b/test/unit/protocol_time.test.js @@ -36,6 +36,24 @@ describe('protocol time', function () { assert.strictEqual(protocolTime('testnet', false, previous), false) }) + it('takes the first 11 height-descending entries before sorting by timestamp', function () { + const newestFirst = [ + 100, 900, 200, 800, 300, 700, 400, 600, 500, 1000, 1100, + 999999, + ] + + assert.strictEqual(medianTimePast(newestFirst), 600) + }) + + it('does not replace an unusable window entry with an older entry', function () { + const newestFirst = [ + 100, 200, 300, 400, 500, NaN, 700, 800, 900, 1000, 1100, + 999999, + ] + + assert.strictEqual(medianTimePast(newestFirst), 700) + }) + it('returns an immutable context without changing replay inputs', function () { const input = previous.slice() const first = createBlockTimeContext('testnet', 500, input) diff --git a/test/unit/protocol_time_caller_order.test.js b/test/unit/protocol_time_caller_order.test.js new file mode 100644 index 00000000..c218b485 --- /dev/null +++ b/test/unit/protocol_time_caller_order.test.js @@ -0,0 +1,58 @@ +// 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' + +const assert = require('assert') +const { fetchPreviousBlockTimes } = require('../../src/XChainDecoder/block_store.js') +const { MEDIAN_TIME_SPAN } = require('../../src/protocol/protocol_time.js') + +describe('protocol time newest-first caller contract', function () { + it('fetchPreviousBlockTimes returns a height-ordered window bounded by the median span', async function () { + const calls = [] + const stamps = Array.from( + { length: 20 }, + (_, height) => height % 2 === 0 ? 5000 - height : 1000 + height + ) + const context = { + db: { + getBlockByIndex: async (height) => { + calls.push(height) + return { block_time: stamps[height] } + }, + }, + } + + const times = await fetchPreviousBlockTimes.call(context, 20, stamps.length) + const expectedHeights = Array.from( + { length: MEDIAN_TIME_SPAN }, + (_, offset) => 19 - offset + ) + + assert.deepStrictEqual(calls, expectedHeights) + assert.strictEqual(times.length, MEDIAN_TIME_SPAN) + assert.deepStrictEqual(times, expectedHeights.map((height) => stamps[height])) + assert.notDeepStrictEqual(times, [...times].sort((a, b) => b - a)) + }) + + it('returns the available newest-first prefix near genesis', async function () { + const stamps = [3200, 900, 2500] + const context = { + db: { + getBlockByIndex: async (height) => ({ block_time: stamps[height] }), + }, + } + + assert.deepStrictEqual( + await fetchPreviousBlockTimes.call(context, stamps.length, MEDIAN_TIME_SPAN), + [2500, 900, 3200] + ) + }) +}) diff --git a/test/unit/raw_transaction_shape_guard.test.js b/test/unit/raw_transaction_shape_guard.test.js new file mode 100644 index 00000000..5c26d636 --- /dev/null +++ b/test/unit/raw_transaction_shape_guard.test.js @@ -0,0 +1,79 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * getRawTransaction answers with a whole hex string or fails. The prevout and + * envelope callers rely on that: they decode the answer outside their tagged + * try, so a decode throw is content every instance sees alike and goes to the + * quarantine ladder. A truthy non-hex answer from a proxy or shim passed as + * content would quarantine a tx on one instance that healthy instances accept, + * so it must be a transport fault that rides the retry loop. + */ + +'use strict' + +const assert = require('assert') +const queries = require('../../src/chain/blockchain_connector/transaction_queries.js') + +// Script the node's answers in order (the last one repeats) and record the run. +async function drive(results) { + let calls = 0 + const sleeps = [] + const connector = { + rpcErrors: 0, + sleep: async (ms) => { sleeps.push(ms) }, + rpcPost: async () => ({ status: 200, data: { result: results[Math.min(calls++, results.length - 1)] } }), + } + try { + const value = await queries.getRawTransaction.call(connector, 'txid-under-test') + return { value, calls, sleeps, rpcErrors: connector.rpcErrors } + } catch (err) { + return { error: err, calls, sleeps, rpcErrors: connector.rpcErrors } + } +} + +describe('getRawTransaction accepts only a hex string', function () { + it('passes a hex answer through unchanged', async function () { + const got = await drive(['0100abCD']) + assert.strictEqual(got.value, '0100abCD') + assert.strictEqual(got.calls, 1) + }) + + for (const [name, bad] of [ + ['an object', { hex: '00' }], + ['a number', 42], + ['an odd-length hex string', 'abc'], + ['a non-hex string', 'busy'], + ]) { + it(`retries ${name} and counts no RPC error when the next answer is whole`, async function () { + const got = await drive([bad, '00ff']) + assert.strictEqual(got.value, '00ff', `${name} must not be handed to the decoder`) + assert.deepStrictEqual(got.sleeps, [500]) + assert.strictEqual(got.rpcErrors, 0, 'a fetch that recovers counts zero') + }) + } + + it('rejects after the retry budget, counting one RPC error and naming the txid', async function () { + const got = await drive([{ hex: '00' }]) + assert.ok(got.error, 'a node that never answers hex must fail, not resolve') + assert.strictEqual(got.calls, 10) + assert.strictEqual(got.rpcErrors, 1) + assert.ok(/txid-under-test/.test(got.error.message)) + assert.ok(/malformed/.test(got.error.message), got.error.message) + }) + + it('still resolves null on an empty answer', async function () { + const got = await drive([null]) + assert.strictEqual(got.value, null) + assert.strictEqual(got.calls, 1) + }) +}) diff --git a/test/unit/reorg_depth_across_restart.test.js b/test/unit/reorg_depth_across_restart.test.js index f86d782c..a20ced19 100644 --- a/test/unit/reorg_depth_across_restart.test.js +++ b/test/unit/reorg_depth_across_restart.test.js @@ -135,6 +135,15 @@ describe('verifyReorg: the safe-depth ceiling survives a lost halt marker', func assert.strictEqual(decoder.getReorgHaltStatus().halted, false) }) + it('treats a prior depth that is not a non-negative integer as a read fault, deleting nothing', async function () { + for (const value of [undefined, NaN, '3', -1, 2.5]){ + const { decoder, deleted } = restartedDecoder({ countReorgDeletesAboveTip: async () => value }) + await assert.rejects(() => decoder.verifyReorg(NODE_TIP), /prior rollback depth could not be read/) + assert.strictEqual(deleted.length, 0, String(value)) + assert.strictEqual(decoder.getReorgHaltStatus().halted, false, String(value)) + } + }) + it('recovers when the read fault is transient', async function () { let attempts = 0 const { decoder, deleted } = restartedDecoder({ diff --git a/test/unit/reorg_halt_clear.test.js b/test/unit/reorg_halt_clear.test.js index bfb2c589..340453c9 100644 --- a/test/unit/reorg_halt_clear.test.js +++ b/test/unit/reorg_halt_clear.test.js @@ -202,10 +202,20 @@ function fakeDb({ halted = true, haltId = 7, deletesAboveTip = 0, dispensers = 0 countReorgDeletesAboveTip: async () => deletesAboveTip, countDispensers: async () => dispensers, hasDispenserTransactions: async () => dispenserTxs, - clearReorgHalt: async (opts) => { calls.clear.push(opts); return clearResult } + clearReorgHalt: async (opts) => { calls.clear.push(opts); return clearResult }, + // A strict session by default; the sql_mode cases below wire in the real guard. + assertStrictSqlMode: async () => {} } return { db, calls } } + +// Wire the decoder's real strict-mode guard onto a fake db whose session reports `mode`. +function withSessionMode(db, mode) { + db.assertStrictSqlMode = Database.prototype.assertStrictSqlMode + db.transactionConnection = null + db.getConnection = async () => ({ query: async () => [{ mode }], release: async () => {} }) + return db +} const quiet = { log: () => {}, error: () => {} } const REASON = 'BTC mainnet decoder, no dispensers exist yet, block range intact' @@ -257,6 +267,28 @@ describe('clear-reorg-halt CLI', function () { }) }) +describe('clear-reorg-halt CLI', function () { + + // fakeDb's destructuring default turns an undefined count into 0, so each value + // is wired onto the method directly. + it('refuses, and cannot be forced, when the above-tip count is not a non-negative integer', async function () { + for (const value of [undefined, null, NaN, '0', {}, -1, 1.5]){ + for (const argv of [['--reason', REASON, '--force'], ['--dry-run']]){ + const { db, calls } = fakeDb() + db.countReorgDeletesAboveTip = async () => value + const errors = [] + const exit = await run({ db, argv, log: () => {}, error: (l) => errors.push(l) }) + const label = String(typeof value) + ' ' + String(value) + ' ' + argv.join(' ') + assert.strictEqual(exit, EXIT.NOT_RESYNCED, label) + assert.strictEqual(calls.clear.length, 0, label) + const refusal = errors.join('\n') + assert.match(refusal, /cannot be forced/, label) + assert.doesNotMatch(refusal, /undefined block|NaN block|null block/, label) + } + } + }) +}) + describe('clear-reorg-halt CLI', function () { it('refuses a database that has held dispenser state unless forced, and records the force', async function () { @@ -317,3 +349,27 @@ describe('clear-reorg-halt CLI', function () { } }) }) + +describe('clear-reorg-halt CLI', function () { + // The CLI builds its own pool and never runs runMigrations, so it checks the + // session itself: the row it writes decides halt state for every reader. + it('refuses a lax or NO_BACKSLASH_ESCAPES session, dry run included, and never clears', async function () { + for (const mode of ['', 'NO_ENGINE_SUBSTITUTION', 'STRICT_TRANS_TABLES,NO_BACKSLASH_ESCAPES']) { + for (const argv of [['--reason', REASON], ['--dry-run']]) { + const { db, calls } = fakeDb() + withSessionMode(db, mode) + const errors = [] + assert.strictEqual(await run({ db, argv, log: () => {}, error: (l) => errors.push(l) }), EXIT.FAILED, mode) + assert.strictEqual(calls.clear.length, 0) + assert.ok(errors.some(l => /REFUSED/.test(l) && /strict-mode check/.test(l)), mode) + } + } + }) + + it('still clears on the stock strict session', async function () { + const { db, calls } = fakeDb() + withSessionMode(db, 'STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION') + assert.strictEqual(await run({ db, argv: ['--reason', REASON], ...quiet }), EXIT.OK) + assert.strictEqual(calls.clear.length, 1) + }) +}) diff --git a/test/unit/reorg_halt_surface.test.js b/test/unit/reorg_halt_surface.test.js index 551e2908..478d2c95 100644 --- a/test/unit/reorg_halt_surface.test.js +++ b/test/unit/reorg_halt_surface.test.js @@ -19,6 +19,8 @@ const assert = require('assert') const XChainDecoder = require('../../src/XChainDecoder') const Database = require('../../src/db.js') +const { haltReorg } = require('../../src/XChainDecoder/reorg_halt.js') +const { REORG_HALT_PROBE_INTERVAL_MS } = require('../../src/XChainDecoder/constants.js') function makeDecoder() { return new XChainDecoder( @@ -289,3 +291,67 @@ describe('Database.markReorgHalted reports the row, not the ack', function () { assert.strictEqual(calls.insert, 0) }) }) + +// A halt whose marker write failed leaves no row, so the first probe after the TTL +// reads "not halted". That absence is not a clear: the halt must survive it, or every +// surface publishes reorg_halted=false for the one halt a restart silently resumes. +const NO_ROW = { halted: false, at: null, reason: null } + +async function haltedUnrecorded(db) { + const decoder = makeDecoder() + decoder.db = db + await haltReorg.call(decoder, 'over-deep rollback past safe-depth', []) + return decoder +} + +async function assertStillHalted(decoder) { + const before = decoder.getReorgHaltStatus() + const later = Date.now() + REORG_HALT_PROBE_INTERVAL_MS + 1000 + const status = await decoder.checkReorgHalt({ now: later }) + assert.strictEqual(status.halted, true, 'an unrecorded halt must not read as cleared') + assert.strictEqual(status.reason, before.reason) + assert.strictEqual(status.at, before.at) + assert.strictEqual(status.marker_persisted, false) + assert.strictEqual(status.checked_at, later, 'the probe still stamps checked_at') +} + +describe('XChainDecoder keeps an unrecorded halt across the TTL re-probe', function () { + it('keeps the halt when the write returned false and the read finds no row', async function () { + await assertStillHalted(await haltedUnrecorded({ + markReorgHalted: async () => false, getReorgHaltMarker: async () => NO_ROW + })) + }) + + it('keeps the halt when db.markReorgHalted does not exist', async function () { + await assertStillHalted(await haltedUnrecorded({ getReorgHaltMarker: async () => NO_ROW })) + }) + + it('keeps the halt when the newest row is an older operator clear', async function () { + await assertStillHalted(await haltedUnrecorded({ + markReorgHalted: async () => false, + getReorgHaltMarker: async () => ({ ...NO_ROW, cleared_at: '2026-01-01T00:00:00.000Z', cleared_reason: 'old clear' }) + })) + }) +}) + +describe('XChainDecoder keeps an unrecorded halt across the TTL re-probe', function () { + it('adopts the row when the write reported failure but the read finds the halt', async function () { + const decoder = await haltedUnrecorded({ + markReorgHalted: async () => false, + getReorgHaltMarker: async () => ({ halted: true, at: '2026-10-01T00:00:00.000Z', reason: 'landed late' }) + }) + const status = await decoder.checkReorgHalt({ force: true }) + assert.strictEqual(status.halted, true) + assert.strictEqual(status.marker_persisted, true) + }) + + it('still clears a recorded halt once an operator clear is read', async function () { + let row = { halted: true, at: '2026-10-01T00:00:00.000Z', reason: 'over-deep' } + const decoder = await haltedUnrecorded({ markReorgHalted: async () => true, getReorgHaltMarker: async () => row }) + assert.strictEqual(decoder.getReorgHaltStatus().marker_persisted, true) + row = { ...NO_ROW, cleared_at: '2026-10-02T00:00:00.000Z', cleared_reason: 'verified' } + const status = await decoder.checkReorgHalt({ force: true }) + assert.strictEqual(status.halted, false, 'an operator clear of a recorded halt must still land') + assert.strictEqual(status.cleared_reason, 'verified') + }) +}) diff --git a/test/unit/reorg_table_coverage.test.js b/test/unit/reorg_table_coverage.test.js new file mode 100644 index 00000000..16a79fa2 --- /dev/null +++ b/test/unit/reorg_table_coverage.test.js @@ -0,0 +1,106 @@ +// 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 decoder table carries a recorded reorg decision. Each table in src/sql/ +// must be either deleted by deleteBlockByIndex (rollback) or exempted here with +// a written reason, so a new table cannot ship without one. + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const sinon = require('sinon'); +const Database = require('../../src/db.js'); + +const SQL_DIR = path.join(__dirname, '..', '..', 'src', 'sql'); +const UNIVERSE = fs.readdirSync(SQL_DIR) + .filter(f => f.endsWith('.sql')) + .map(f => f.slice(0, -'.sql'.length)) + .sort(); + +const REORG_POLICY = Object.freeze({ + blocks: { mode: 'rollback' }, + transactions: { mode: 'rollback' }, + transaction_outputs: { mode: 'rollback' }, + dispensers: { mode: 'rollback' }, + dispenser_extension_undo: { mode: 'rollback' }, + events: { mode: 'exempt', reason: + 'Append-only audit log with no block_index column; the REORG marker records the rollback in this same log, ' + + 'and the indexer reads events by ascending id, so removing rows would break its reorg detection.' }, + index_addresses: { mode: 'exempt', reason: + 'Append-only INSERT IGNORE first-reference lookup; its AUTO_INCREMENT id is a local artifact that consumers ' + + 'resolve back to the address string and never feed into a consensus value.' }, + index_transactions: { mode: 'exempt', reason: + 'Append-only INSERT IGNORE hash lookup; an orphaned row is referenced only by rows that are rolled back, ' + + 'and only by id, so it is inert once its block is deleted.' }, + pubkeys: { mode: 'exempt', reason: + 'address_id -> pubkey written INSERT IGNORE at first spend and keyed by index_addresses ids, which are never ' + + 'rolled back or reused, so a key stays bound to its own address. The indexer reads it only as source_pubkey ' + + 'into its own pubkeys mirror, which no block hash or state root reads, so a row an orphaned block revealed early ' + + 'differs from a from-genesis node only in that unhashed mirror. Never feed source_pubkey into a hashed value.' }, + mempool_transactions: { mode: 'exempt', reason: + 'Node-local snapshot of unconfirmed transactions with no block linkage; the mempool poll reconciles it ' + + 'against the node mempool every cycle (reconcileMempoolSnapshot).' }, +}); + +// Collect the table names deleteBlockByIndex issues DELETE statements against. +async function deletedTables() { + const conn = { + query: sinon.stub().resolves([]), + release: sinon.stub().resolves(), + beginTransaction: sinon.stub().resolves(), + commit: sinon.stub().resolves(), + rollback: sinon.stub().resolves(), + }; + const db = new Database('127.0.0.1', 3306, 'xchain_btc_mainnet', 'u', 'p'); + db.pool = { getConnection: sinon.stub().resolves(conn) }; + await db.deleteBlockByIndex(10, 'deadbeef'); + const tables = new Set(); + for (const call of conn.query.getCalls()) { + for (const m of String(call.args[0]).matchAll(/DELETE\s+(?:\w+\s+)?FROM\s+(\w+)/gi)) tables.add(m[1]); + } + return tables; +} + +describe('decoder reorg table coverage', function () { + afterEach(() => sinon.restore()); + + it('reads a non-trivial table list from src/sql', function () { + assert.ok(UNIVERSE.length >= 10, 'expected at least 10 tables in ' + SQL_DIR + ', found ' + UNIVERSE.length); + }); + + it('every src/sql table has a recorded reorg decision', function () { + const unclassified = UNIVERSE.filter(t => !Object.prototype.hasOwnProperty.call(REORG_POLICY, t)); + assert.deepStrictEqual(unclassified, [], + 'tables with no reorg decision: ' + unclassified.join(', ') + '. Delete them in deleteBlockByIndex ' + + '(src/db/blocks.js) and mark them rollback here, or exempt them here with a reason.'); + }); + + it('every policy entry names a real src/sql table', function () { + const stale = Object.keys(REORG_POLICY).filter(t => !UNIVERSE.includes(t)); + assert.deepStrictEqual(stale, [], 'policy entries for tables no longer in src/sql: ' + stale.join(', ')); + }); + + it('every exemption carries a written reason', function () { + for (const [table, policy] of Object.entries(REORG_POLICY)) { + if (policy.mode !== 'exempt') continue; + assert.ok(typeof policy.reason === 'string' && policy.reason.length > 40, + 'exempt table ' + table + ' needs a reason longer than 40 characters'); + } + }); + + it('deleteBlockByIndex deletes exactly the rollback tables', async function () { + const deleted = [...await deletedTables()].sort(); + const rollback = Object.keys(REORG_POLICY).filter(t => REORG_POLICY[t].mode === 'rollback').sort(); + assert.deepStrictEqual(deleted, rollback, + 'deleteBlockByIndex DELETE targets must equal the rollback tables in REORG_POLICY'); + }); +}); diff --git a/test/unit/rpc_retry_exhaustion_identity.test.js b/test/unit/rpc_retry_exhaustion_identity.test.js new file mode 100644 index 00000000..8016958f --- /dev/null +++ b/test/unit/rpc_retry_exhaustion_identity.test.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. + * + ********************************************************************** + * A retry ladder that gives up builds a fresh error, which must still carry the + * last cause's error.code and rpcCode/rpcMessage: getBlockReassembled copies + * those to tell an unreachable node from a block whose bytes are unusable. + */ + +'use strict' + +const assert = require('assert') +const sinon = require('sinon') +const axios = require('axios') +const BlockchainConnector = require('../../src/chain/blockchain_connector') +let connector +let axiosStub + +function setUpConnector() { + connector = new BlockchainConnector('127.0.0.1', 8332, 'user', 'pass') + connector.sleep = async () => {} // no real backoff delays + axiosStub = sinon.stub(axios, 'post') +} + +function restoreStubs() { + sinon.restore() +} + +describe('BlockchainConnector retry exhaustion keeps the cause identity', () => { + beforeEach(setUpConnector) + afterEach(restoreStubs) + + it('getRawTransaction exhaustion carries the transport code of the last attempt', async () => { + axiosStub.callsFake(async () => { throw Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' }) }) + + const err = await connector.getRawTransaction('txid').then(() => null, (e) => e) + assert.ok(err, 'all 10 attempts failing must reject') + assert.match(err.message, /failed after 10 attempts/) + assert.strictEqual(err.code, 'ECONNRESET') + assert.strictEqual(err.rpcCode, undefined) + }).timeout(5000) + + it('getRawTransaction exhaustion carries the node rpcCode from an HTTP-500 body', async () => { + axiosStub.callsFake(async () => { + throw Object.assign(new Error('Request failed with status code 500'), { + code: 'ERR_BAD_RESPONSE', + response: { status: 500, data: { error: { code: -8, message: 'Block height out of range' } } }, + }) + }) + + const err = await connector.getRawTransaction('txid').then(() => null, (e) => e) + assert.match(err.message, /failed after 10 attempts/) + assert.strictEqual(err.code, 'ERR_BAD_RESPONSE') + assert.strictEqual(err.rpcCode, -8) + assert.strictEqual(err.rpcMessage, 'Block height out of range') + }).timeout(5000) + + it('getRawTransaction exhaustion carries the rpcCode of an HTTP-200 JSON-RPC error', async () => { + axiosStub.resolves({ status: 200, data: { result: null, error: { code: -429, message: 'Work queue depth exceeded' } } }) + + const err = await connector.getRawTransaction('txid').then(() => null, (e) => e) + assert.match(err.message, /failed after 10 attempts/) + assert.strictEqual(err.rpcCode, -429) + }).timeout(5000) + + it('rpcCallWithTimeoutRetry exhaustion carries ECONNABORTED and keeps its message', async () => { + axiosStub.callsFake(async () => { throw Object.assign(new Error('timeout of 0ms exceeded'), { code: 'ECONNABORTED' }) }) + + const err = await connector.getBlockHash(1).then(() => null, (e) => e) + assert.ok(err, 'ten timeouts must reject') + assert.match(err.message, /There were problems getting block hash\./) + assert.strictEqual(err.code, 'ECONNABORTED') + assert.strictEqual(connector.rpcErrors, 1) + }).timeout(5000) +}) diff --git a/test/unit/sibling_coverage.test.js b/test/unit/sibling_coverage.test.js index 294b5a73..413e3570 100644 --- a/test/unit/sibling_coverage.test.js +++ b/test/unit/sibling_coverage.test.js @@ -16,8 +16,9 @@ // Nine guards in test/unit are byte-identity or value-parity checks against a // SIBLING checkout (the roundtrip-conformance fixture against xchain-encoder, // the vendored coins registry against xchain-hub, the AuxPoW strip against -// xchain-utxo-tracker, and so on). Every one of them resolves its sibling with -// existsSync and calls this.skip() when it is absent. That is the right +// xchain-utxo-tracker, and so on). Each resolves its sibling with existsSync or +// through test/helpers/sibling_checkout.js (which also refuses a lane symlink +// into a live main checkout) and calls this.skip() when it is unusable. That is the right // behaviour for a single-repo checkout, and it is invisible: mocha reports a // skipped test as `pending`, the suite still exits 0, and the push gate prints // PASS. A sibling the CI venue was never told to ship (.ci-siblings) is diff --git a/test/unit/status_stale_tip_field.test.js b/test/unit/status_stale_tip_field.test.js new file mode 100644 index 00000000..f64b631e --- /dev/null +++ b/test/unit/status_stale_tip_field.test.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. + * + ********************************************************************** + * GET /status is the node bootstrap gate's fallback when JSON-RPC health is + * unavailable. It built its body field by field and carried lag without the + * stale-tip flag, so a frozen tip read as a trustworthy lag of zero there. + * These drive the real registrar, as the /live tests do. + */ + +'use strict' + +const assert = require('assert') +const http = require('http') +const express = require('express') +const XChainDecoder = require('../../src/XChainDecoder') +const { registerStatusRoute } = require('../../src/api') + +// src/XChainDecoder.js BLOCKCHAIN_INFO_REFRESH_MS; stale is > 2x this. +const REFRESH_MS = 30000 + +// A caught-up decoder with a fresh tip and a reachable DB. +function statusDecoder() { + const decoder = new XChainDecoder( + 'bitcoin-regtest', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null + ) + decoder.lastProcessedBlockIndex = 150 + decoder.blockchainInfoLastBlock = 150 + decoder.blockchainInfoLastRefreshAt = Date.now() + decoder.lastAdvanceAt = Date.now() + decoder.db = { ping: async () => true } + decoder.connector = { rpcErrors: 0 } + return decoder +} + +function getStatus(decoder, running = true) { + const app = express() + registerStatusRoute(app, decoder, () => running) + return new Promise((resolve, reject) => { + const server = app.listen(0, () => { + http.get({ port: server.address().port, path: '/status' }, (res) => { + let body = '' + res.on('data', (c) => { body += c }) + res.on('end', () => { + server.close() + resolve({ status: res.statusCode, body: JSON.parse(body) }) + }) + }).on('error', (e) => { server.close(); reject(e) }) + }) + }) +} + +describe('/status reports the stale tip without gating on it', function () { + it('answers 200 on a stale tip but flags its zero lag as untrustworthy', async function () { + const decoder = statusDecoder() + decoder.blockchainInfoLastRefreshAt = Date.now() - (3 * REFRESH_MS) + const res = await getStatus(decoder) + assert.strictEqual(res.status, 200, 'the HTTP code stays keyed on running+db') + assert.strictEqual(res.body.lag, 0) + assert.strictEqual(res.body.node_height_stale, true, + 'the bootstrap gate refuses on this flag; without it a frozen tip passes as caught up') + }) + + it('reports the flag as a stable boolean, not an absent key, when the tip is fresh', async function () { + const res = await getStatus(statusDecoder()) + assert.strictEqual(res.status, 200) + assert.strictEqual(res.body.node_height_stale, false) + }) + + it('keeps 503 on a stopped decoder whatever the tip says', async function () { + const res = await getStatus(statusDecoder(), false) + assert.strictEqual(res.status, 503) + assert.strictEqual(res.body.node_height_stale, false) + }) +}) diff --git a/test/unit/taproot_envelope.test/05_constants_conformance.test.js b/test/unit/taproot_envelope.test/05_constants_conformance.test.js index 16efd7cd..6acb68c4 100644 --- a/test/unit/taproot_envelope.test/05_constants_conformance.test.js +++ b/test/unit/taproot_envelope.test/05_constants_conformance.test.js @@ -32,8 +32,8 @@ * framed with OP_PUSHDATA4, which the legacy compiledPushSize re-measure * does not model -- the envelope must never route through it); * 5. constants conformance: decoder == encoder == documentation for - * ENVELOPE_MAX_PAYLOAD and the recognition-height map (skip-if-absent - * sibling checkout, matching the compiledPushSizeConformance convention); + * ENVELOPE_MAX_PAYLOAD and the recognition-height map (siblings gated + * through test/helpers/sibling_checkout.js, as compiledPushSizeConformance is); * 6. wire fidelity: a REAL encoder-built, fully signed reveal parses * byte-identically (sibling-gated on xchain-encoder). */ @@ -42,6 +42,7 @@ const fs = require('fs') const path = require('path') +const { siblingCheckout, skipOrFail } = require('../../helpers/sibling_checkout.js') const { assert, sinon, @@ -93,9 +94,7 @@ const VECTORS_PATH = path.join(DOCS_DIR, 'protocol', 'test-vectors', 'taproot_en // Skip without the sibling checkout, unless XCHAIN_REQUIRE_SIBLINGS=1 makes absence a failure. function requireDocsSibling(){ - if (fs.existsSync(VECTORS_PATH)) return - if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('xchain-documentation sibling not found at ' + VECTORS_PATH + ' but XCHAIN_REQUIRE_SIBLINGS=1') - this.skip() + skipOrFail(this, siblingCheckout(__dirname, VECTORS_PATH), 'the taproot envelope golden-vector guard') } function carriesBytes(adv){ @@ -141,6 +140,8 @@ describe('Taproot envelope recognition', function () { assert.strictEqual(XChainDecoder.ENVELOPE_MAX_PAYLOAD, CONSTANTS.ENVELOPE_MAX_PAYLOAD) assert.strictEqual(CONSTANTS.ENVELOPE_MAX_PAYLOAD, 390000) assert.deepStrictEqual(XChainDecoder.ENVELOPE_RECOGNITION_ACTIVATION, CONSTANTS.ENVELOPE_RECOGNITION_ACTIVATION) + assert.strictEqual(XChainDecoder.DISPENSER_WIDE_EXPIRATION_ACTIVATION, + CONSTANTS.DISPENSER_WIDE_EXPIRATION_ACTIVATION) }) // Pins the ARMED map exactly (operator §7 cohort call, 2026-08-01). Every value @@ -154,6 +155,17 @@ describe('Taproot envelope recognition', function () { DOGE: { mainnet: null, testnet: null, regtest: null }, }) }) + + // Pins the carrier-recognition map literally, for the same fork reason as the map + // above; the docs-parity case below skips without the sibling and only compares copies. + // Arming a mainnet height is a deploy-train act and must edit this assertion on purpose. + it('the carrier-recognition map is exactly the shipped shape: mainnet unarmed, genesis-active test networks, DOGE never', function () { + assert.deepStrictEqual(CONSTANTS.ENVELOPE_CARRIER_RECOGNITION_ACTIVATION, { + BTC: { mainnet: null, testnet: 0, regtest: 0 }, + LTC: { mainnet: null, testnet: 0, regtest: 0 }, + DOGE: { mainnet: null, testnet: null, regtest: null }, + }) + }) }) }) @@ -165,7 +177,7 @@ describe('Taproot envelope recognition', function () { const DOCS = process.env.XCHAIN_DOCUMENTATION_DIR || path.join(__dirname, '..', '..', '..', '..', 'xchain-documentation') const DOCS_CONSTANTS = path.join(DOCS, 'protocol', 'constants.js') - before(function () { if (!fs.existsSync(DOCS_CONSTANTS)) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('xchain-documentation sibling not found at ' + DOCS_CONSTANTS + ' but XCHAIN_REQUIRE_SIBLINGS=1'); this.skip(); } }) + before(function () { skipOrFail(this, siblingCheckout(__dirname, DOCS_CONSTANTS), 'the envelope constants parity guard against xchain-documentation') }) it('ENVELOPE_MAX_PAYLOAD and both activation maps are byte-equal to the canonical copy', function () { const docs = require(DOCS_CONSTANTS) @@ -262,7 +274,7 @@ describe('Taproot envelope recognition', function () { const ENCODER = process.env.XCHAIN_ENCODER_DIR || path.join(__dirname, '..', '..', '..', '..', 'xchain-encoder') const VALIDATOR = path.join(ENCODER, 'src', 'common', 'validator.js') - before(function () { if (!fs.existsSync(VALIDATOR)) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('xchain-encoder sibling not found at ' + VALIDATOR + ' but XCHAIN_REQUIRE_SIBLINGS=1'); this.skip(); } }) + before(function () { skipOrFail(this, siblingCheckout(__dirname, VALIDATOR), 'the ENVELOPE_MAX_PAYLOAD parity guard against xchain-encoder') }) it('ENVELOPE_MAX_PAYLOAD stays equal across the two services', function () { const v = require(VALIDATOR) diff --git a/test/unit/taproot_envelope_published_vectors.test.js b/test/unit/taproot_envelope_published_vectors.test.js new file mode 100644 index 00000000..045e02d0 --- /dev/null +++ b/test/unit/taproot_envelope_published_vectors.test.js @@ -0,0 +1,115 @@ +/* + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +'use strict' + +const assert = require('node:assert/strict') +const crypto = require('node:crypto') +const fs = require('node:fs') +const bitcoin = require('bitcoinjs-lib') +const { createDecoder } = require('./taproot_envelope.test/helpers/taproot_envelope.js') + +const CHUNK_SIZE = 520 +const CHUNKING_RECIPE = 'action "FILE|0|chunks.bin|application/octet-stream|||||||" ' + + '+ 1200 rawData bytes where byte[i] = (i*7+13) & 0xff' +const REBALANCE_RECIPE = 'action "FILE|0|rebalance.bin|application/octet-stream|||||||" ' + + '+ 985 rawData bytes where byte[i] = (i*7+13) & 0xff for i < 984 and byte[984] = 0x07' + +function sha256(buf){ + return crypto.createHash('sha256').update(buf).digest('hex') +} + +function generatedRawData(length, finalByte){ + const rawData = Buffer.alloc(length) + for (let i = 0; i < length; i++) rawData[i] = (i * 7 + 13) & 0xff + if (finalByte !== undefined) rawData[length - 1] = finalByte + return rawData +} + +function compiledPayload(action, rawData){ + return bitcoin.script.compile([Buffer.from(action, 'utf8'), rawData]) +} + +function envelopeChunks(payload){ + const chunks = [] + for (let offset = 0; offset < payload.length; offset += CHUNK_SIZE){ + chunks.push(payload.subarray(offset, offset + CHUNK_SIZE)) + } + const last = chunks[chunks.length - 1] + if (chunks.length >= 2 && last.length === 1 + && ((last[0] >= 0x01 && last[0] <= 0x10) || last[0] === 0x81)){ + const previous = chunks[chunks.length - 2] + chunks[chunks.length - 2] = previous.subarray(0, previous.length - 1) + chunks[chunks.length - 1] = Buffer.concat([previous.subarray(previous.length - 1), last]) + } + return chunks +} + +function envelopeScript(payload, xonlyHex){ + return bitcoin.script.compile([ + bitcoin.opcodes.OP_0, + bitcoin.opcodes.OP_IF, + Buffer.from('XCHN'), + Buffer.from([0x00]), + ...envelopeChunks(payload), + bitcoin.opcodes.OP_ENDIF, + Buffer.from(xonlyHex, 'hex'), + bitcoin.opcodes.OP_CHECKSIG + ]) +} + +function assertPublishedRecognition(decoder, vectors, vector, payload){ + const chunks = envelopeChunks(payload) + const script = envelopeScript(payload, vectors.envelope_grammar.internal_pubkey_xonly) + const controlBlock = Buffer.from(vectors.envelope_grammar.control_block_hex, 'hex') + + assert.equal(payload.length, vector.compiled_payload_length) + assert.equal(sha256(payload), vector.compiled_payload_sha256) + assert.deepEqual(chunks.map(chunk => chunk.length), vector.push_lengths) + if (vector.envelope_script_length !== undefined) assert.equal(script.length, vector.envelope_script_length) + assert.equal(sha256(script), vector.envelope_script_sha256) + + const recognized = decoder.detectEnvelopeWitness([Buffer.alloc(64), script, controlBlock]) + assert.ok(recognized, 'published envelope script was not recognized') + assert.deepEqual(recognized.script, script) + assert.deepEqual(recognized.payload, payload) +} + +describe('Taproot envelope published chunk vectors', function () { + let vectors + let decoder + + before(function () { + if (!process.env.TAPROOT_VECTORS_FILE) this.skip() + vectors = JSON.parse(fs.readFileSync(process.env.TAPROOT_VECTORS_FILE, 'utf8')) + decoder = createDecoder() + }) + + it('recognizes and reassembles the published envelope_chunking vector', function () { + const vector = vectors.envelope_chunking + assert.equal(vector.payload_generation, CHUNKING_RECIPE) + const payload = compiledPayload( + 'FILE|0|chunks.bin|application/octet-stream|||||||', + generatedRawData(1200) + ) + + assertPublishedRecognition(decoder, vectors, vector, payload) + }) + + it('recognizes and reassembles the published chunk_rebalance vector', function () { + const vector = vectors.chunk_rebalance + assert.equal(vector.payload_generation, REBALANCE_RECIPE) + const payload = compiledPayload( + 'FILE|0|rebalance.bin|application/octet-stream|||||||', + generatedRawData(985, 0x07) + ) + + assert.equal(payload.length % CHUNK_SIZE, 1) + assert.equal(payload[payload.length - 1], 0x07) + assertPublishedRecognition(decoder, vectors, vector, payload) + }) +}) diff --git a/test/unit/unattributed_action_skip.test.js b/test/unit/unattributed_action_skip.test.js new file mode 100644 index 00000000..37b25a77 --- /dev/null +++ b/test/unit/unattributed_action_skip.test.js @@ -0,0 +1,96 @@ +// 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 XChainDecoder = require('../../src/XChainDecoder') + +// A tx carrying XChain data whose source address cannot be resolved is skipped +// without a row or an event. The skip must still reach parse_errors, the counter +// health, the probe and the metrics endpoint all read, so it is not log-only. +const PREV_WIRE = Buffer.from( + '00112233445566778899aabbccddeeff0123456789abcdeffedcba9876543210', + 'hex' +) + +// Build a decoder over one fake block whose single tx parses to `parseResult`. +function buildDecoder(parseResult) { + const decoder = new XChainDecoder( + 'bitcoin-regtest', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null + ) + decoder.startBlockIndex = 0 + decoder.sleep = async () => {} + const calls = { insertBlock: 0, insertTransaction: 0, insertEvent: 0, commitTransaction: 0 } + decoder.connector = { + getBlockchainInfo: async () => ({ verificationprogress: 1, blocks: 0 }), + getBlockHash: async () => 'aabbccdd', + getBlock: async () => '' + } + decoder.db = fakeDb(decoder, calls) + decoder.xchainBlockDecoder = { + blockFromHex: () => ({ + prevHash: Buffer.from(PREV_WIRE), + timestamp: 1700000000, + transactions: [{ getId: () => 'cafe01', outs: [] }] + }) + } + decoder.parseTransaction = async () => parseResult + return { decoder, calls } +} + +// Block-db stub that counts every write and stops the loop at the first commit. +function fakeDb(decoder, calls) { + return { + createDatabase: async () => true, + verifyDatabase: async () => true, + verifyTables: async () => true, + runMigrations: async () => ({ applied: [], pending: [] }), + getLastBlockIndex: async () => -1, + getLastTxIndex: async () => 0, + beginTransaction: async () => {}, + endTransaction: async () => {}, + commitTransaction: async () => { calls.commitTransaction++; decoder.stopFlag = true; return true }, + deleteOpenDispensers: async () => true, + purgeExpiredDispensers: async () => {}, + getAllOpenDispenserAddresses: async () => new Set(), + insertEvent: async () => { calls.insertEvent++; return true }, + insertTransaction: async () => { calls.insertTransaction++; return true }, + insertBlock: async () => { calls.insertBlock++; return true } + } +} + +function parseResultWith(data, source) { + return { data, source, destination: null, amount: 0, dispenseOutputs: [], paymentOutputs: [] } +} + +describe('XChainDecoder unattributed XChain payload skip', function () { + this.timeout(0) + + it('counts a payload with no resolvable source toward parse_errors and stores nothing', async function () { + const { decoder, calls } = buildDecoder(parseResultWith(Buffer.from('XYZ'), null)) + + await decoder.start() + + assert.strictEqual(decoder.parseErrors, 1, 'the unattributed skip must reach parse_errors') + assert.strictEqual(calls.insertTransaction, 0, 'no transactions row for an unattributed payload') + assert.strictEqual(calls.insertEvent, 0, 'no event row: decoder content is replicated') + assert.strictEqual(calls.insertBlock, 1, 'the block itself still commits') + assert.strictEqual(calls.commitTransaction, 1) + }) + + it('leaves parse_errors untouched for a tx with no payload and no source', async function () { + const { decoder, calls } = buildDecoder(parseResultWith(Buffer.alloc(0), null)) + + await decoder.start() + + assert.strictEqual(decoder.parseErrors, 0, 'an ordinary non-XChain tx is not a parse error') + assert.strictEqual(calls.insertTransaction, 0) + assert.strictEqual(calls.insertBlock, 1) + }) +}) diff --git a/test/unit/valid_action_names_flag_day.test.js b/test/unit/valid_action_names_flag_day.test.js new file mode 100644 index 00000000..7064da4b --- /dev/null +++ b/test/unit/valid_action_names_flag_day.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. + +const assert = require('assert') +const crypto = require('crypto') +const { VALID_ACTION_NAMES } = require('../../src/XChainDecoder/constants.js') + +// Membership of VALID_ACTION_NAMES is consensus: every node must accept the same +// top-level names at the same height. Adding or removing a name here without a +// height-keyed activation forks the fleet, so this pin fails until the change is +// made deliberately, together with its flag-day. +const PINNED_NAMES = [ + 'ADDRESS', 'AIRDROP', 'ANCHOR', 'ATTEST', 'BATCH', 'BET', 'BROADCAST', 'CALLBACK', + 'COINPAY', 'COLLECT', 'DELEGATE', 'DEPLOY', 'DEPOSIT', 'DESTROY', 'DISPENSER', + 'DIVIDEND', 'EXECUTE', 'FILE', 'ISSUE', 'LINK', 'LIST', 'MESSAGE', 'MINT', + 'NODEPROOF', 'ORDER', 'PRICE', 'ROLLCALL', 'SEND', 'SLASH', 'SLEEP', 'STAKE', + 'SWAP', 'SWEEP', 'UNSTAKE', 'VOTE', 'WITHDRAW', 'XBRIDGE' +] + +describe('VALID_ACTION_NAMES flag-day pin @regression', function () { + it('holds exactly the pinned names', function () { + const actual = [...VALID_ACTION_NAMES].sort() + const expected = [...PINNED_NAMES].sort() + assert.deepStrictEqual({ + added: actual.filter(n => !expected.includes(n)), + removed: expected.filter(n => !actual.includes(n)) + }, { added: [], removed: [] }, + 'VALID_ACTION_NAMES changed: a membership change is a consensus flag-day, not an in-place edit') + }) + + it('pins a stable digest of the sorted membership', function () { + const digest = crypto.createHash('sha256').update([...VALID_ACTION_NAMES].sort().join('\n')).digest('hex') + const expected = crypto.createHash('sha256').update([...PINNED_NAMES].sort().join('\n')).digest('hex') + assert.strictEqual(digest, expected) + }) + + it('contains only upper-case names with no duplicates in the pin', function () { + for (const n of VALID_ACTION_NAMES) assert.match(n, /^[A-Z]+$/) + assert.strictEqual(new Set(PINNED_NAMES).size, PINNED_NAMES.length) + }) + + it('falsification: a changed membership is detected', function () { + const mutated = new Set(VALID_ACTION_NAMES).add('NEWACTION') + const added = [...mutated].filter(n => !PINNED_NAMES.includes(n)) + assert.deepStrictEqual(added, ['NEWACTION']) + }) +}) diff --git a/test/unit/verify_reorg_retry.test/01_xchain_decoder_verify_reorg_mid_walk_tip_regression.test.js b/test/unit/verify_reorg_retry.test/01_xchain_decoder_verify_reorg_mid_walk_tip_regression.test.js index cd78b99b..a3baa086 100644 --- a/test/unit/verify_reorg_retry.test/01_xchain_decoder_verify_reorg_mid_walk_tip_regression.test.js +++ b/test/unit/verify_reorg_retry.test/01_xchain_decoder_verify_reorg_mid_walk_tip_regression.test.js @@ -205,3 +205,61 @@ describe('XChainDecoder.verifyReorg mid-walk tip regression', function () { assert.ok(sleeps >= 3, 'retries through the node outage instead of crashing') }) }) + +// Build a decoder whose node regresses to tip 102 mid-walk and reports `infos` in turn +// (the last one repeats); `exitAfterSleeps` ends a deliberately parked walk. +function catchingUpWalk(infos, exitAfterSleeps) { + const decoder = new XChainDecoder( + 'bitcoin-mainnet', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null + ) + decoder.startBlockIndex = 0 + decoder.chainGenesisHash = OUR_GENESIS + const state = { top: 105, deleted: [], sleeps: 0, infoReads: 0 } + decoder.sleep = async () => { + if (exitAfterSleeps && ++state.sleeps >= exitAfterSleeps) state.top = -1 + } + const dbHash = { 105: 'db105', 104: 'db104', 103: 'db103', 102: 'match102', 101: 'match101', 100: 'match100' } + decoder.connector = { + getBlockHash: async (h) => { + if (h === 0) return OUR_GENESIS + if (h > 102) throw new Error('Block height out of range') + return 'match' + h + }, + getBlockchainInfo: async () => infos[Math.min(state.infoReads++, infos.length - 1)] + } + decoder.db = { + getLastBlockIndex: async () => state.top, + getBlockByIndex: async (h) => (h < 0 ? null : { block_hash: dbHash[h] }), + deleteBlockByIndex: async (h) => { state.deleted.push(h); state.top = h - 1 }, + insertEvent: async () => true, + isReorgHalted: async () => false, + markReorgHalted: async () => {} + } + return { decoder, state } +} + +describe('XChainDecoder.verifyReorg mid-walk tip refresh from a catching-up node', function () { + this.timeout(0) + + it('ignores a refreshed tip while the node reports initial block download', async function () { + const ibd = { blocks: 102, chain: 'main', initialblockdownload: true, verificationprogress: 0.9999 } + const { decoder, state } = catchingUpWalk([ibd], 3) + assert.strictEqual(await decoder.verifyReorg(105), true) + assert.deepStrictEqual(state.deleted, [], 'a catching-up node is waited on, never rolled back') + }) + + it('ignores a refreshed tip while verification progress is below the parse threshold', async function () { + const slow = { blocks: 102, chain: 'main', initialblockdownload: false, verificationprogress: 0.5 } + const { decoder, state } = catchingUpWalk([slow], 3) + assert.strictEqual(await decoder.verifyReorg(105), true) + assert.deepStrictEqual(state.deleted, [], 'an unsynced node tip must not drive above-tip deletes') + }) + + it('accepts the refreshed tip once the node has finished catching up', async function () { + const ibd = { blocks: 102, chain: 'main', initialblockdownload: true, verificationprogress: 0.9999 } + const synced = { blocks: 102, chain: 'main', initialblockdownload: false, verificationprogress: 1 } + const { decoder, state } = catchingUpWalk([ibd, ibd, synced]) + assert.strictEqual(await decoder.verifyReorg(105), true) + assert.deepStrictEqual(state.deleted, [105, 104, 103], 'the wait resolves once the node is synced') + }) +}) diff --git a/test/unit/verify_reorg_retry.test/03_reorg_entry_paths_log_at_warn.test.js b/test/unit/verify_reorg_retry.test/03_reorg_entry_paths_log_at_warn.test.js new file mode 100644 index 00000000..28962a15 --- /dev/null +++ b/test/unit/verify_reorg_retry.test/03_reorg_entry_paths_log_at_warn.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. + * + ********************************************************************** + * Every path into a rollback logs at warn. + * + * decoder.logWarn exists so a reorg reaches a warn-and-above alerting rule; + * decoder.log writes info to stdout, which those rules do not read. A rollback + * can start from a forward hash mismatch, an equal-height tip swap or a node tip + * below the stored tip, so each entry line and the shared rollback summary must + * go through logWarn, or alerting sees a reorg only when one path detected it. + */ + +'use strict' + +const assert = require('assert') +const fs = require('fs') +const path = require('path') + +const SRC_DIR = path.join(__dirname, '..', '..', '..', 'src', 'XChainDecoder') + +// Return the logger method that emits the line carrying `marker` in `file`. +function loggerFor(file, marker){ + const src = fs.readFileSync(path.join(SRC_DIR, file), 'utf8') + const at = src.indexOf(marker) + assert.ok(at > 0, file + ' no longer carries "' + marker + '"; update this guard with the new text') + const lineStart = src.lastIndexOf('\n', at) + 1 + const m = /this\.(log\w*)\(/.exec(src.slice(lineStart, at)) + assert.ok(m, 'no this.log* call precedes "' + marker + '" on its line in ' + file) + return m[1] +} + +describe('reorg entry paths log at warn @unit', function () { + const SITES = [ + ['block_ingest.js', 'A reorg has been detected at block'], + ['sync_loop.js', 'Equal-height tip replacement detected'], + ['tip_refresh.js', 'Reconciling orphan blocks...'], + ['reorg_verification.js', 'reorg: rolled back'], + ] + for (const [file, marker] of SITES){ + it(file + ' logs "' + marker + '" through logWarn', function () { + assert.strictEqual(loggerFor(file, marker), 'logWarn') + }) + } +})