From b11e0692391989888d2b2eb5cd63495e9db715da Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 09:52:19 -0700 Subject: [PATCH 01/86] Update dispenser source path comment Point the field-offset guard at the indexer's dispenser directory after the single-file module was retired. --- test/unit/dispenser_field_offsets.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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). From dfe56a6ab06b9bcbb7d637fad4dd7e1338b76d5c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 09:52:32 -0700 Subject: [PATCH 02/86] Regenerate CI tier timing block --- bin/ci-full.sh | 1 + 1 file changed, 1 insertion(+) 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 =====" From 39a1c84673bc4e9d3085be76967151a128675cea Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 11:13:16 -0700 Subject: [PATCH 03/86] test: cover migration query timeout helpers Add direct coverage for timeout parsing and session restoration. Pin lock release and connection destruction when restoration fails. --- test/unit/db/migration_query_timeout.test.js | 113 +++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 test/unit/db/migration_query_timeout.test.js 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); + }); +}); From 9b9cf68b7e843db5368914269ec3847277a16765 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 11:16:48 -0700 Subject: [PATCH 04/86] test: cover RPC configuration helpers Add focused unit coverage for integer parsing, endpoint normalization, and node reachability state. --- .../rpc_helpers_config.test.js | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 test/unit/chain/blockchain_connector/rpc_helpers_config.test.js 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..ddd063c5 --- /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']) { + 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, + }, + }) + }) +}) From 5b85febd1ae79c34db443bff47d14bd5c2fa9dd4 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 11:16:50 -0700 Subject: [PATCH 05/86] test RPC helper error handling Cover exact result failures and verify transport errors are scrubbed while RPC details remain available. --- .../rpc_helpers_errors.test.js | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js 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..e4c550aa --- /dev/null +++ b/test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js @@ -0,0 +1,85 @@ +// 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('RPC helper errors', () => { + describe('rpcResult', () => { + it('returns positive and zero results', () => { + assert.strictEqual(rpcResult({ data: { result: 5 } }, 'read'), 5) + 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}' } + ) + }) + + it('throws only the label for missing results', () => { + assertThrowsLabel(null, 'missing') + assertThrowsLabel({ data: {} }, 'missing') + 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('renders errors and non-error values without responses', () => { + assert.strictEqual(sanitizeRpcError(new Error('plain')), 'plain') + assert.strictEqual(sanitizeRpcError('text'), 'text') + assert.strictEqual(sanitizeRpcError(null), 'null') + }) + }) +}) From 8c221581bf28ab5d44e94009c7e059e796abebfd Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 11:23:10 -0700 Subject: [PATCH 06/86] test: use numeric custom minimum input Match the configuration helper coverage to the documented raw input type. --- test/unit/chain/blockchain_connector/rpc_helpers_config.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/chain/blockchain_connector/rpc_helpers_config.test.js b/test/unit/chain/blockchain_connector/rpc_helpers_config.test.js index ddd063c5..2f6ac7db 100644 --- a/test/unit/chain/blockchain_connector/rpc_helpers_config.test.js +++ b/test/unit/chain/blockchain_connector/rpc_helpers_config.test.js @@ -44,7 +44,7 @@ describe('envInt', () => { 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(-2, 7, 'RPC_TIMEOUT', -5), -2) assert.strictEqual(envInt('0', 7, 'RPC_TIMEOUT', 0), 0) }) From a75b38e5167b776ccbc95076dfce17012a5d2214 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 11:24:16 -0700 Subject: [PATCH 07/86] split RPC helper error suites Keep the test callbacks within structure limits without changing their assertions. --- .../rpc_helpers_errors.test.js | 104 +++++++++--------- 1 file changed, 51 insertions(+), 53 deletions(-) diff --git a/test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js b/test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js index e4c550aa..b1a89085 100644 --- a/test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js +++ b/test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js @@ -21,65 +21,63 @@ function assertThrowsLabel(response, label) { ) } -describe('RPC helper errors', () => { - describe('rpcResult', () => { - it('returns positive and zero results', () => { - assert.strictEqual(rpcResult({ data: { result: 5 } }, 'read'), 5) - 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' } - ) - }) +describe('rpcResult errors', () => { + it('returns positive and zero results', () => { + assert.strictEqual(rpcResult({ data: { result: 5 } }, 'read'), 5) + assert.strictEqual(rpcResult({ data: { result: 0 } }, 'read'), 0) + }) - 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}' } - ) - }) + 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('throws only the label for missing results', () => { - assertThrowsLabel(null, 'missing') - assertThrowsLabel({ data: {} }, 'missing') - assertThrowsLabel({ data: { result: null } }, 'missing') - }) + 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}' } + ) }) - 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' } }, - }, - }) + it('throws only the label for missing results', () => { + assertThrowsLabel(null, 'missing') + assertThrowsLabel({ data: {} }, 'missing') + assertThrowsLabel({ data: { result: null } }, 'missing') + }) +}) - 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) +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' } }, + }, }) - it('renders errors and non-error values without responses', () => { - assert.strictEqual(sanitizeRpcError(new Error('plain')), 'plain') - assert.strictEqual(sanitizeRpcError('text'), 'text') - assert.strictEqual(sanitizeRpcError(null), 'null') - }) + 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('renders errors and non-error values without responses', () => { + assert.strictEqual(sanitizeRpcError(new Error('plain')), 'plain') + assert.strictEqual(sanitizeRpcError('text'), 'text') + assert.strictEqual(sanitizeRpcError(null), 'null') }) }) From 5b11963bc2692f99490f909680a365c4a2189135 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 11:34:59 -0700 Subject: [PATCH 08/86] test: use numeric zero with custom minimum Match the configuration helper coverage to the documented raw input type. --- test/unit/chain/blockchain_connector/rpc_helpers_config.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/chain/blockchain_connector/rpc_helpers_config.test.js b/test/unit/chain/blockchain_connector/rpc_helpers_config.test.js index 2f6ac7db..a906b8d1 100644 --- a/test/unit/chain/blockchain_connector/rpc_helpers_config.test.js +++ b/test/unit/chain/blockchain_connector/rpc_helpers_config.test.js @@ -45,7 +45,7 @@ describe('envInt', () => { 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) + assert.strictEqual(envInt(0, 7, 'RPC_TIMEOUT', 0), 0) }) it('warns for an empty value but not an undefined value', () => { From b0ed53a48d10f171cdc7d08d570d01071fdae1d8 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 11:35:24 -0700 Subject: [PATCH 09/86] report RPC helper cases separately Give every required input its own test result so focused verification identifies any failing behavior directly. --- .../rpc_helpers_errors.test.js | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js b/test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js index b1a89085..77c5df7f 100644 --- a/test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js +++ b/test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js @@ -22,8 +22,11 @@ function assertThrowsLabel(response, label) { } describe('rpcResult errors', () => { - it('returns positive and zero results', () => { + 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) }) @@ -43,9 +46,15 @@ describe('rpcResult errors', () => { ) }) - it('throws only the label for missing results', () => { + 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') }) }) @@ -75,9 +84,15 @@ describe('sanitizeRpcError', () => { assert.strictEqual(error.propertyIsEnumerable('rpcMessage'), false) }) - it('renders errors and non-error values without responses', () => { + 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') }) }) From d6da593e0cb34bcb3e7480f0c36ee3ae5d1b5b77 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 14:57:08 -0700 Subject: [PATCH 10/86] Make migration status tests filesystem-independent Stub the status directory listing instead of creating and deleting a temporary migration tree for every case. This removes synchronous filesystem latency from the five-second Mocha hook budget while preserving the CLI status assertions. --- test/unit/migrate_status.test.js | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) 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; From ed6152142e24db83e77c1ac53718e4059029dbf0 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 16:28:08 -0700 Subject: [PATCH 11/86] test: cover negative default minimum --- test/unit/chain/blockchain_connector/rpc_helpers_config.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/chain/blockchain_connector/rpc_helpers_config.test.js b/test/unit/chain/blockchain_connector/rpc_helpers_config.test.js index a906b8d1..f31e833f 100644 --- a/test/unit/chain/blockchain_connector/rpc_helpers_config.test.js +++ b/test/unit/chain/blockchain_connector/rpc_helpers_config.test.js @@ -37,7 +37,7 @@ describe('envInt', () => { }) it('uses the fallback for missing, empty, and invalid values', () => { - for (const raw of [undefined, null, '', 0, 'x']) { + for (const raw of [undefined, null, '', 0, 'x', -2]) { assert.strictEqual(envInt(raw, 7, 'RPC_TIMEOUT'), 7) } }) From 6ce43940c4dbffa44530657dfc263a9b874425c1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 17:20:16 -0700 Subject: [PATCH 12/86] Confirm CI tier timing block The generated timing block already matches the tier wirer after the branch sync. Record the successful regeneration and verification without changing generator-owned output. From e7f4206a55e50e88447c8b26185314d3175c774f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 17:56:32 -0700 Subject: [PATCH 13/86] Pin decoder base image digest Keep the Node version tag while locking the Docker build to the current multi-platform image index. --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index dbc56620..8d0fd719 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:25.9.0 +FROM node:25.9.0@sha256:78839ac448c23517f8eab2e8f7943d9b4f73979eb7f8bed2c73dbf72ff869e7b RUN mkdir /XChainDecoder/ COPY ./package.json /XChainDecoder/package.json @@ -24,4 +24,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"] From 12e0d7a2ab300cde52aa69803a6f82ba3400e86a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 18:05:29 -0700 Subject: [PATCH 14/86] Pin both Docker base image stages Add the Alpine base as a digest-pinned preliminary stage while keeping the decoder base as the final runtime stage. --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 8d0fd719..8422156a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ -FROM node:25.9.0@sha256:78839ac448c23517f8eab2e8f7943d9b4f73979eb7f8bed2c73dbf72ff869e7b +FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS regtest-miner +FROM node:25.9.0@sha256:78839ac448c23517f8eab2e8f7943d9b4f73979eb7f8bed2c73dbf72ff869e7b AS decoder RUN mkdir /XChainDecoder/ COPY ./package.json /XChainDecoder/package.json From 8ff0220f4553475ba1194f4c3a19f9f20573ea2d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 18:11:18 -0700 Subject: [PATCH 15/86] Use pinned dependency stage in decoder image Install production packages in the Alpine stage and copy the resulting dependency tree into the runtime stage so each pinned base is part of the built image. --- Dockerfile | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8422156a..5df2b448 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,14 @@ -FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS regtest-miner -FROM node:25.9.0@sha256:78839ac448c23517f8eab2e8f7943d9b4f73979eb7f8bed2c73dbf72ff869e7b AS decoder - -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 From 9833b4aa4f2bb7478441d3b6f87ab09eedee3eea Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 4 Oct 2026 23:41:58 -0700 Subject: [PATCH 16/86] Treat suite title additions as growth --- bin/suite-title-map.js | 46 ++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 17 deletions(-) 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; } From 7c51a7b586dcf10a879a01cbd7e99a45d0fc765a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 5 Oct 2026 11:44:37 -0700 Subject: [PATCH 17/86] Split decoder protocol constants into named families under constants/ The entry keeps the shared exports and re-exports the action-size, VM and cross-call, batch-limit, reward and dispenser/batch/envelope activation families unchanged. --- src/protocol/constants.js | 679 +----------------- src/protocol/constants/action_size.js | 63 ++ .../constants/activation_dispenser_batch.js | 268 +++++++ src/protocol/constants/activation_envelope.js | 80 +++ src/protocol/constants/activation_reward.js | 185 +++++ src/protocol/constants/batch_limits.js | 71 ++ src/protocol/constants/vm_cross_call.js | 68 ++ 7 files changed, 774 insertions(+), 640 deletions(-) create mode 100644 src/protocol/constants/action_size.js create mode 100644 src/protocol/constants/activation_dispenser_batch.js create mode 100644 src/protocol/constants/activation_envelope.js create mode 100644 src/protocol/constants/activation_reward.js create mode 100644 src/protocol/constants/batch_limits.js create mode 100644 src/protocol/constants/vm_cross_call.js diff --git a/src/protocol/constants.js b/src/protocol/constants.js index dc1613e5..934884d6 100644 --- a/src/protocol/constants.js +++ b/src/protocol/constants.js @@ -29,613 +29,12 @@ * ********************************************************************/ -// 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 activationReward = require('./constants/activation_reward.js'); +const activationDispenserBatch = require('./constants/activation_dispenser_batch.js'); +const activationEnvelope = require('./constants/activation_envelope.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 +75,39 @@ 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: activationReward.STAKE_WEIGHTED_QUORUM_ACTIVATION, + EQUIV_HEADER_ACTIVATION: activationReward.EQUIV_HEADER_ACTIVATION, + STATE_COMMITMENT_ACTIVATION: activationReward.STATE_COMMITMENT_ACTIVATION, + CHECKPOINT_COMMITMENT_ACTIVATION: activationReward.CHECKPOINT_COMMITMENT_ACTIVATION, + ANCHOR_REWARD_ACTIVATION: activationReward.ANCHOR_REWARD_ACTIVATION, + ANCHOR_REWARD_AMOUNT: activationReward.ANCHOR_REWARD_AMOUNT, + ARCHIVE_REWARD_ACTIVATION: activationReward.ARCHIVE_REWARD_ACTIVATION, + ARCHIVE_REWARD_AMOUNT: activationReward.ARCHIVE_REWARD_AMOUNT, + CROSS_CHAIN_ROYALTY_ACTIVATION: activationReward.CROSS_CHAIN_ROYALTY_ACTIVATION, + ORACLE_FEE_OUTPUT_ACTIVATION: activationDispenserBatch.ORACLE_FEE_OUTPUT_ACTIVATION, + ORACLE_FEE_SET_CAPTURE_ACTIVATION: activationDispenserBatch.ORACLE_FEE_SET_CAPTURE_ACTIVATION, + DISPENSER_EXPIRY_REALIGN_ACTIVATION: activationDispenserBatch.DISPENSER_EXPIRY_REALIGN_ACTIVATION, + DISPENSER_CANCEL_GRACE_ACTIVATION: activationDispenserBatch.DISPENSER_CANCEL_GRACE_ACTIVATION, + BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION: activationDispenserBatch.BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION, + ENVELOPE_MAX_PAYLOAD: actionSize.ENVELOPE_MAX_PAYLOAD, + ENVELOPE_RECOGNITION_ACTIVATION: activationEnvelope.ENVELOPE_RECOGNITION_ACTIVATION, + ENVELOPE_CARRIER_RECOGNITION_ACTIVATION: activationEnvelope.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_dispenser_batch.js b/src/protocol/constants/activation_dispenser_batch.js new file mode 100644 index 00000000..5f38f661 --- /dev/null +++ b/src/protocol/constants/activation_dispenser_batch.js @@ -0,0 +1,268 @@ +/********************************************************************* + * + * 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. + * + **********************************************************************/ + +// 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, +}; + +module.exports = { + ORACLE_FEE_OUTPUT_ACTIVATION, + ORACLE_FEE_SET_CAPTURE_ACTIVATION, + DISPENSER_EXPIRY_REALIGN_ACTIVATION, + DISPENSER_CANCEL_GRACE_ACTIVATION, + BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION, +}; diff --git a/src/protocol/constants/activation_envelope.js b/src/protocol/constants/activation_envelope.js new file mode 100644 index 00000000..0fa0f72f --- /dev/null +++ b/src/protocol/constants/activation_envelope.js @@ -0,0 +1,80 @@ +/********************************************************************* + * + * 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. + * + **********************************************************************/ + +// 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 }, +}; + +module.exports = { + ENVELOPE_RECOGNITION_ACTIVATION, + ENVELOPE_CARRIER_RECOGNITION_ACTIVATION, +}; diff --git a/src/protocol/constants/activation_reward.js b/src/protocol/constants/activation_reward.js new file mode 100644 index 00000000..fadb8476 --- /dev/null +++ b/src/protocol/constants/activation_reward.js @@ -0,0 +1,185 @@ +/********************************************************************* + * + * 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, +}; + +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, +}; 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/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, +}; From b9940299626f3ff095f3aa37da5b2f3d9d371fb1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 5 Oct 2026 11:45:19 -0700 Subject: [PATCH 18/86] Merge decoder activation constant families into one module Keeps the activation-map count at its baseline while the entry stays under 400 lines. --- src/protocol/constants.js | 36 ++- ...ation_dispenser_batch.js => activation.js} | 233 ++++++++++++++++++ src/protocol/constants/activation_envelope.js | 80 ------ src/protocol/constants/activation_reward.js | 185 -------------- 4 files changed, 250 insertions(+), 284 deletions(-) rename src/protocol/constants/{activation_dispenser_batch.js => activation.js} (53%) delete mode 100644 src/protocol/constants/activation_envelope.js delete mode 100644 src/protocol/constants/activation_reward.js diff --git a/src/protocol/constants.js b/src/protocol/constants.js index 934884d6..30fee9df 100644 --- a/src/protocol/constants.js +++ b/src/protocol/constants.js @@ -32,9 +32,7 @@ const actionSize = require('./constants/action_size.js'); const vmCrossCall = require('./constants/vm_cross_call.js'); const batchLimits = require('./constants/batch_limits.js'); -const activationReward = require('./constants/activation_reward.js'); -const activationDispenserBatch = require('./constants/activation_dispenser_batch.js'); -const activationEnvelope = require('./constants/activation_envelope.js'); +const activation = require('./constants/activation.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 @@ -91,23 +89,23 @@ module.exports = { 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: activationReward.STAKE_WEIGHTED_QUORUM_ACTIVATION, - EQUIV_HEADER_ACTIVATION: activationReward.EQUIV_HEADER_ACTIVATION, - STATE_COMMITMENT_ACTIVATION: activationReward.STATE_COMMITMENT_ACTIVATION, - CHECKPOINT_COMMITMENT_ACTIVATION: activationReward.CHECKPOINT_COMMITMENT_ACTIVATION, - ANCHOR_REWARD_ACTIVATION: activationReward.ANCHOR_REWARD_ACTIVATION, - ANCHOR_REWARD_AMOUNT: activationReward.ANCHOR_REWARD_AMOUNT, - ARCHIVE_REWARD_ACTIVATION: activationReward.ARCHIVE_REWARD_ACTIVATION, - ARCHIVE_REWARD_AMOUNT: activationReward.ARCHIVE_REWARD_AMOUNT, - CROSS_CHAIN_ROYALTY_ACTIVATION: activationReward.CROSS_CHAIN_ROYALTY_ACTIVATION, - ORACLE_FEE_OUTPUT_ACTIVATION: activationDispenserBatch.ORACLE_FEE_OUTPUT_ACTIVATION, - ORACLE_FEE_SET_CAPTURE_ACTIVATION: activationDispenserBatch.ORACLE_FEE_SET_CAPTURE_ACTIVATION, - DISPENSER_EXPIRY_REALIGN_ACTIVATION: activationDispenserBatch.DISPENSER_EXPIRY_REALIGN_ACTIVATION, - DISPENSER_CANCEL_GRACE_ACTIVATION: activationDispenserBatch.DISPENSER_CANCEL_GRACE_ACTIVATION, - BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION: activationDispenserBatch.BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION, + 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, + BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION: activation.BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION, ENVELOPE_MAX_PAYLOAD: actionSize.ENVELOPE_MAX_PAYLOAD, - ENVELOPE_RECOGNITION_ACTIVATION: activationEnvelope.ENVELOPE_RECOGNITION_ACTIVATION, - ENVELOPE_CARRIER_RECOGNITION_ACTIVATION: activationEnvelope.ENVELOPE_CARRIER_RECOGNITION_ACTIVATION, + 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/activation_dispenser_batch.js b/src/protocol/constants/activation.js similarity index 53% rename from src/protocol/constants/activation_dispenser_batch.js rename to src/protocol/constants/activation.js index 5f38f661..0bdb478b 100644 --- a/src/protocol/constants/activation_dispenser_batch.js +++ b/src/protocol/constants/activation.js @@ -12,6 +12,166 @@ * **********************************************************************/ +// 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 @@ -259,10 +419,83 @@ const BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION = { regtest: 0, }; +// 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 }, +}; + 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, BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION, + ENVELOPE_RECOGNITION_ACTIVATION, + ENVELOPE_CARRIER_RECOGNITION_ACTIVATION, }; diff --git a/src/protocol/constants/activation_envelope.js b/src/protocol/constants/activation_envelope.js deleted file mode 100644 index 0fa0f72f..00000000 --- a/src/protocol/constants/activation_envelope.js +++ /dev/null @@ -1,80 +0,0 @@ -/********************************************************************* - * - * 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. - * - **********************************************************************/ - -// 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 }, -}; - -module.exports = { - ENVELOPE_RECOGNITION_ACTIVATION, - ENVELOPE_CARRIER_RECOGNITION_ACTIVATION, -}; diff --git a/src/protocol/constants/activation_reward.js b/src/protocol/constants/activation_reward.js deleted file mode 100644 index fadb8476..00000000 --- a/src/protocol/constants/activation_reward.js +++ /dev/null @@ -1,185 +0,0 @@ -/********************************************************************* - * - * 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, -}; - -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, -}; From a5f4f8967fe23d656d34257bdfa74b7eb99ebaff Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 5 Oct 2026 11:47:44 -0700 Subject: [PATCH 19/86] Route decoder api environment reads through config getters --- src/api.js | 41 +++++++++++++++++++++-------------------- src/config.js | 25 +++++++++++++++++++++---- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/src/api.js b/src/api.js index 293a8784..29dbc175 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 @@ -234,7 +235,7 @@ 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; if (!getmempoolCache || (now - getmempoolCache.t) >= ttl) { @@ -280,7 +281,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 +291,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 +302,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 +319,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) } diff --git a/src/config.js b/src/config.js index bed4b5a1..9a9cd22f 100644 --- a/src/config.js +++ b/src/config.js @@ -39,10 +39,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 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. * ********************************************************************/ @@ -58,6 +58,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, From 680be6f525a26f54ef50bb686a6b7cbcdada11e7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 5 Oct 2026 19:33:15 -0700 Subject: [PATCH 20/86] Pin payload_helpers push-size classes, alias canonicalization and probes Add a payload_helpers pure functions block to the compiled push size suite. --- .../compiled_push_size_conformance.test.js | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/test/unit/compiled_push_size_conformance.test.js b/test/unit/compiled_push_size_conformance.test.js index 83518ca0..4896868f 100644 --- a/test/unit/compiled_push_size_conformance.test.js +++ b/test/unit/compiled_push_size_conformance.test.js @@ -203,3 +203,75 @@ describe('compiled-push-size arbiter conformance', function () { }); }); }); + +describe('payload_helpers pure functions', function () { + const helpers = require('../../src/XChainDecoder/payload_helpers.js'); + + 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); + }); + }); + + 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); + }); + }); + + 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'); + }); + }); + + 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); + }); + }); +}); From 1f78302266dc5d2df892182f76c0c4bd2d52ef31 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 5 Oct 2026 19:33:29 -0700 Subject: [PATCH 21/86] Pin payload_helpers push-size classes, alias canonicalization and probes Add a payload_helpers pure functions block to the compiled push size suite. --- .../compiled_push_size_conformance.test.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/test/unit/compiled_push_size_conformance.test.js b/test/unit/compiled_push_size_conformance.test.js index 4896868f..498321e0 100644 --- a/test/unit/compiled_push_size_conformance.test.js +++ b/test/unit/compiled_push_size_conformance.test.js @@ -204,9 +204,7 @@ describe('compiled-push-size arbiter conformance', function () { }); }); -describe('payload_helpers pure functions', function () { - const helpers = require('../../src/XChainDecoder/payload_helpers.js'); - +function describeCatchingUp(helpers) { describe('nodeStillCatchingUp', function () { it('is true only for a strict true initialblockdownload', function () { assert.strictEqual(helpers.nodeStillCatchingUp({ initialblockdownload: true }), true); @@ -217,7 +215,9 @@ describe('payload_helpers pure functions', function () { assert.strictEqual(helpers.nodeStillCatchingUp(undefined), false); }); }); +} +function describePushSize(helpers) { describe('compiledPushSize', function () { const size = helpers.compiledPushSize; @@ -231,7 +231,9 @@ describe('payload_helpers pure functions', function () { assert.strictEqual(size(65536), 65539); }); }); +} +function describeCanonicalize(helpers) { describe('canonicalizeActionPayload', function () { const canon = helpers.canonicalizeActionPayload; @@ -260,7 +262,9 @@ describe('payload_helpers pure functions', function () { assert.strictEqual(out.actionName, 'ADDRESS'); }); }); +} +function describeBufferutils(helpers) { describe('bigIntBufferutilsActive', function () { it('reports the installed bufferutils as patched', function () { assert.strictEqual(helpers.bigIntBufferutilsActive(), true); @@ -274,4 +278,12 @@ describe('payload_helpers pure functions', function () { 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); }); From a7b8db6882fcfbbf41f01bd02d7cf7b63c587655 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 5 Oct 2026 20:17:05 -0700 Subject: [PATCH 22/86] Add dispenser expiration extension undo queries Record the pre-extension expiration and expiry mark, restore them for an orphaned block, and purge undo rows at the reorg-safe height. --- src/db/dispenser_queries.js | 70 +++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/db/dispenser_queries.js b/src/db/dispenser_queries.js index 9e6d1266..db47891d 100644 --- a/src/db/dispenser_queries.js +++ b/src/db/dispenser_queries.js @@ -117,6 +117,76 @@ 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]) + }, + + // Drop undo rows at or below the reorg-safe height, on the same bound as + // purgeExpiredDispensers, so the undo table cannot outgrow the window it protects. + async purgeDispenserExtensionUndo(safeHeight) { + if (safeHeight == null || safeHeight < 0) return true + let connection = await this.getConnection() + const ownLease = (this.transactionConnection == null) + try { + await connection.query(`DELETE FROM dispenser_extension_undo WHERE block_index <= ?;`, [safeHeight]) + return true + } catch (err) { + logger.error(formatLogLine('Error purging dispenser extension undo rows:', err)); + return false + } finally { + if (ownLease){ + await connection.release() + } + } + }, + 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 From c183a93bfa26a3d22eb8b44e244525ffc53238ac Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 5 Oct 2026 22:26:52 -0700 Subject: [PATCH 23/86] Wire the dispenser expiration extension undo into extend, reorg and purge Record the pre-extend row before each format-2 extend, restore it when the block is orphaned, and drop undo rows with the expired-dispenser purge. Adds the undo table definition and a reorg test. --- src/XChainDecoder/dispenser_registration.js | 4 + src/db/blocks.js | 1 + src/db/dispenser_queries.js | 21 +-- src/sql/dispenser_extension_undo.sql | 24 +++ test/benchmarks/support/mock_database.js | 10 ++ test/chaos/support/helpers.js | 1 + test/helpers/batchCaptureHarness.js | 1 + .../support/helpers.js | 1 + .../unit/batch_payment_output_capture.test.js | 4 +- ..._suppresses_the_whole_capture_view.test.js | 1 + ..._are_alias_expanded_above_the_gate.test.js | 1 + test/unit/dispenser_cancel_grace.test.js | 1 + test/unit/dispenser_expiry_realign.test.js | 1 + .../dispenser_extension_undo_reorg.test.js | 156 ++++++++++++++++++ .../helpers/support.js | 1 + .../helpers/support.js | 1 + test/unit/dispenser_protocol_time.test.js | 1 + 17 files changed, 209 insertions(+), 21 deletions(-) create mode 100644 src/sql/dispenser_extension_undo.sql create mode 100644 test/unit/dispenser_extension_undo_reorg.test.js diff --git a/src/XChainDecoder/dispenser_registration.js b/src/XChainDecoder/dispenser_registration.js index 36127401..dfdfff47 100644 --- a/src/XChainDecoder/dispenser_registration.js +++ b/src/XChainDecoder/dispenser_registration.js @@ -379,6 +379,10 @@ 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' diff --git a/src/db/blocks.js b/src/db/blocks.js index cf610bb5..38dbc003 100644 --- a/src/db/blocks.js +++ b/src/db/blocks.js @@ -113,6 +113,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/dispenser_queries.js b/src/db/dispenser_queries.js index db47891d..588d2b20 100644 --- a/src/db/dispenser_queries.js +++ b/src/db/dispenser_queries.js @@ -168,25 +168,6 @@ module.exports = { await connection.query(`DELETE FROM dispenser_extension_undo WHERE block_index = ?;`, [blockIndex]) }, - // Drop undo rows at or below the reorg-safe height, on the same bound as - // purgeExpiredDispensers, so the undo table cannot outgrow the window it protects. - async purgeDispenserExtensionUndo(safeHeight) { - if (safeHeight == null || safeHeight < 0) return true - let connection = await this.getConnection() - const ownLease = (this.transactionConnection == null) - try { - await connection.query(`DELETE FROM dispenser_extension_undo WHERE block_index <= ?;`, [safeHeight]) - return true - } catch (err) { - logger.error(formatLogLine('Error purging dispenser extension undo rows:', err)); - return false - } finally { - if (ownLease){ - await connection.release() - } - } - }, - 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 @@ -248,6 +229,8 @@ module.exports = { const ownLease = (this.transactionConnection == null) try { await connection.query(query, [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/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/test/benchmarks/support/mock_database.js b/test/benchmarks/support/mock_database.js index 6839bc89..fc333075 100644 --- a/test/benchmarks/support/mock_database.js +++ b/test/benchmarks/support/mock_database.js @@ -124,6 +124,16 @@ class MockDatabase { return true } + async recordDispenserExtensionUndo() { + this._track("recordDispenserExtensionUndo") + return true + } + + async recordDispenserExtensionUndo() { + this._track('recordDispenserExtensionUndo') + return true + } + async purgeExpiredDispensers(safeHeight) { this._track('purgeExpiredDispensers') return true 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/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/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_payment_output_capture.test.js b/test/unit/batch_payment_output_capture.test.js index e40836f8..70e6c701 100644 --- a/test/unit/batch_payment_output_capture.test.js +++ b/test/unit/batch_payment_output_capture.test.js @@ -138,14 +138,14 @@ function buildDecoder(txSpecs, model, opts) { endTransaction: async () => {}, commitTransaction: async () => { decoder.stopFlag = true; return true }, insertBlock: async () => true, - insertEvent: async () => true, - insertTransaction: async () => true, + insertEvent: async () => true, insertTransaction: async () => true, insertTransactionOutput: async (o) => { captured.push(o); return true }, POISON_ROW: 2, DUPLICATED_TRANSACTION: 1, 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(), getOpenDispenserOracleAddressBySource: (s) => (opts.oracleLookup || ((x) => model.getOpenDispenserOracleAddressBySource(x)))(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/dispenser_cancel_grace.test.js b/test/unit/dispenser_cancel_grace.test.js index 1636ac73..f96f602d 100644 --- a/test/unit/dispenser_cancel_grace.test.js +++ b/test/unit/dispenser_cancel_grace.test.js @@ -179,6 +179,7 @@ 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), + recordDispenserExtensionUndo: async () => true, purgeExpiredDispensers: (h) => model.purgeExpiredDispensers(h), getAllOpenDispenserAddresses: (f) => model.getAllOpenDispenserAddresses(f), getOpenDispenserOracleAddressBySource: (s) => model.getOpenDispenserOracleAddressBySource(s), 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..f8f5bbc0 --- /dev/null +++ b/test/unit/dispenser_extension_undo_reorg.test.js @@ -0,0 +1,156 @@ +'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); + }); + + 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); + }); + }); +}); 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, From 6f284ae18b8643633d10a071daf4632ce467c891 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 5 Oct 2026 23:42:13 -0700 Subject: [PATCH 24/86] Pin executable migration residue at commit time, ported from the indexer (review 10066) --- bin/lib/migration_residue_pin.js | 133 ++++++++++++++++++ src/sql/migrations/README.md | 9 ++ .../migration-executable-residue.json | 46 ++++++ ...0_migration_executable_residue_pin.test.js | 78 ++++++++++ 4 files changed, 266 insertions(+) create mode 100644 bin/lib/migration_residue_pin.js create mode 100644 test/fixtures/migration-executable-residue.json create mode 100644 test/unit/migration_runner.test/10_migration_executable_residue_pin.test.js diff --git a/bin/lib/migration_residue_pin.js b/bin/lib/migration_residue_pin.js new file mode 100644 index 00000000..b0401d07 --- /dev/null +++ b/bin/lib/migration_residue_pin.js @@ -0,0 +1,133 @@ +#!/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, so a new migration needs no fixture edit; + * `--add` pins it. 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 = []; + 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/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/fixtures/migration-executable-residue.json b/test/fixtures/migration-executable-residue.json new file mode 100644 index 00000000..b4fb2e93 --- /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": "b03b41b6fcabef9c959851ede9b75cc9089cef7c015bdd69cfcea74ad5acea7a" + }, + "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/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..4540b57e --- /dev/null +++ b/test/unit/migration_runner.test/10_migration_executable_residue_pin.test.js @@ -0,0 +1,78 @@ +'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;' }), []); + }); +}); From bf1be41f25721c020c2cf97f15aaccb8d2d016aa Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 5 Oct 2026 23:44:57 -0700 Subject: [PATCH 25/86] Validate block hashes from the node, carry JSON-RPC error codes on HTTP-200 errors, keep a reorg halt sticky past its TTL and refuse lax SQL mode in clear_reorg_halt (review 10113 10115 10140 10052) --- src/XChainDecoder/chain_integrity.js | 33 +++++++--- src/XChainDecoder/reorg_halt.js | 8 +-- src/XChainDecoder/reorg_verification.js | 3 +- .../blockchain_connector/block_queries.js | 25 +++++-- src/chain/blockchain_connector/rpc_helpers.js | 26 ++++++-- src/clear_reorg_halt.js | 18 +++++ test/unit/auxpow_reassembly.test.js | 19 ++++++ test/unit/blockchain_connector.test.js | 51 ++++++++++++-- test/unit/blockchain_connector_extra.test.js | 8 +-- .../blockchain_connector_review_fixes.test.js | 12 ++-- .../rpc_helpers_errors.test.js | 21 ++++++ test/unit/decoder_live_heartbeat.test.js | 20 ++++++ test/unit/node_url_failover.test.js | 4 +- test/unit/reorg_halt_clear.test.js | 36 +++++++++- test/unit/reorg_halt_surface.test.js | 66 +++++++++++++++++++ 15 files changed, 308 insertions(+), 42 deletions(-) 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/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..fcadf158 100644 --- a/src/XChainDecoder/reorg_verification.js +++ b/src/XChainDecoder/reorg_verification.js @@ -290,7 +290,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 diff --git a/src/chain/blockchain_connector/block_queries.js b/src/chain/blockchain_connector/block_queries.js index 01389482..89a94b3d 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) { @@ -121,9 +135,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 +152,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..73e7d7a7 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 @@ -150,6 +163,7 @@ function nodeReachabilityFrom(startedAt, lastNodeOkAt, lastNodeFailAt, now = Dat module.exports = { envInt, + attachRpcFields, sanitizeRpcError, rpcResult, normalizeEndpoint, diff --git a/src/clear_reorg_halt.js b/src/clear_reorg_halt.js index 57eeaafd..11c9620e 100644 --- a/src/clear_reorg_halt.js +++ b/src/clear_reorg_halt.js @@ -37,6 +37,7 @@ * 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. * @@ -126,6 +127,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 +169,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)') diff --git a/test/unit/auxpow_reassembly.test.js b/test/unit/auxpow_reassembly.test.js index 9c7dcf2e..f0b61350 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 () { diff --git a/test/unit/blockchain_connector.test.js b/test/unit/blockchain_connector.test.js index c2fbaff1..4ff0b605 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() diff --git a/test/unit/blockchain_connector_extra.test.js b/test/unit/blockchain_connector_extra.test.js index 334aab59..0f0dd363 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) }) @@ -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..66ee5672 100644 --- a/test/unit/blockchain_connector_review_fixes.test.js +++ b/test/unit/blockchain_connector_review_fixes.test.js @@ -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_errors.test.js b/test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js index 77c5df7f..147af892 100644 --- a/test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js +++ b/test/unit/chain/blockchain_connector/rpc_helpers_errors.test.js @@ -46,6 +46,27 @@ describe('rpcResult errors', () => { ) }) + // 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') }) diff --git a/test/unit/decoder_live_heartbeat.test.js b/test/unit/decoder_live_heartbeat.test.js index 4376aed0..778fdb7e 100644 --- a/test/unit/decoder_live_heartbeat.test.js +++ b/test/unit/decoder_live_heartbeat.test.js @@ -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/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/reorg_halt_clear.test.js b/test/unit/reorg_halt_clear.test.js index bfb2c589..f3747008 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' @@ -317,3 +327,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') + }) +}) From e7d3d7e69447cab7a380623a772f4d082d2b3a78 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 6 Oct 2026 00:27:58 -0700 Subject: [PATCH 26/86] Pin VALID_ACTION_NAMES membership as a consensus flag-day surface Adds a membership and digest pin test and marks the set as a consensus surface. --- src/XChainDecoder/constants.js | 2 + test/unit/valid_action_names_flag_day.test.js | 54 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 test/unit/valid_action_names_flag_day.test.js 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/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']) + }) +}) From e02981caae58a63eb7ee8290270c9e6c8c9ff935 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 6 Oct 2026 00:25:06 -0700 Subject: [PATCH 27/86] Guard the bufferutils patch twin against the utxo-tracker copy (review 10159) --- ...pply_bufferutils_patch_twin_parity.test.js | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 test/unit/apply_bufferutils_patch_twin_parity.test.js 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)) + }) + } +}) From bc5b30ce4f30066c6a4d668647b40de95b768b06 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 6 Oct 2026 00:31:32 -0700 Subject: [PATCH 28/86] Fix extension undo test doubles: single mock method, restore ordering and fifth reorg DELETE --- test/benchmarks/support/mock_database.js | 5 ----- test/unit/batch_payment_output_capture.test.js | 3 ++- .../db_queries.test/04_delete_block_by_index.test.js | 12 +++++++++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/test/benchmarks/support/mock_database.js b/test/benchmarks/support/mock_database.js index fc333075..62fe2ab6 100644 --- a/test/benchmarks/support/mock_database.js +++ b/test/benchmarks/support/mock_database.js @@ -124,11 +124,6 @@ class MockDatabase { return true } - async recordDispenserExtensionUndo() { - this._track("recordDispenserExtensionUndo") - return true - } - async recordDispenserExtensionUndo() { this._track('recordDispenserExtensionUndo') return true diff --git a/test/unit/batch_payment_output_capture.test.js b/test/unit/batch_payment_output_capture.test.js index 70e6c701..88f977bb 100644 --- a/test/unit/batch_payment_output_capture.test.js +++ b/test/unit/batch_payment_output_capture.test.js @@ -138,7 +138,8 @@ function buildDecoder(txSpecs, model, opts) { endTransaction: async () => {}, commitTransaction: async () => { decoder.stopFlag = true; return true }, insertBlock: async () => true, - insertEvent: async () => true, insertTransaction: async () => true, + insertEvent: async () => true, + insertTransaction: async () => true, insertTransactionOutput: async (o) => { captured.push(o); return true }, POISON_ROW: 2, DUPLICATED_TRANSACTION: 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..c52b4122 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 @@ -55,8 +55,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,7 +72,7 @@ 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'); + assert.strictEqual(deletes.length, 5, '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. @@ -79,6 +80,11 @@ describe('Database#deleteBlockByIndex()', () => { 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'); }); it('throws on query error (propagates after rolling back)', async () => { From b52e51eebc153ac3b08545a1bf941c5aa2f20726 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 6 Oct 2026 00:56:50 -0700 Subject: [PATCH 29/86] Bring extension undo tests under the structure limits: split two long test bodies and drop a redundant double that pushed a test file to 401 lines --- .../unit/batch_payment_output_capture.test.js | 1 - .../04_delete_block_by_index.test.js | 31 ++++++++------- .../dispenser_extension_undo_reorg.test.js | 39 +++++++++++-------- 3 files changed, 40 insertions(+), 31 deletions(-) diff --git a/test/unit/batch_payment_output_capture.test.js b/test/unit/batch_payment_output_capture.test.js index 88f977bb..e40836f8 100644 --- a/test/unit/batch_payment_output_capture.test.js +++ b/test/unit/batch_payment_output_capture.test.js @@ -146,7 +146,6 @@ 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(), getOpenDispenserOracleAddressBySource: (s) => (opts.oracleLookup || ((x) => model.getOpenDispenserOracleAddressBySource(x)))(s), 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 c52b4122..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); @@ -73,18 +89,7 @@ describe('Database#deleteBlockByIndex()', () => { ); const deletes = calls.filter(s => /DELETE/i.test(s)); assert.strictEqual(deletes.length, 5, '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'); - // 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'); + assertRestoreOrdering(calls); }); it('throws on query error (propagates after rolling back)', async () => { diff --git a/test/unit/dispenser_extension_undo_reorg.test.js b/test/unit/dispenser_extension_undo_reorg.test.js index f8f5bbc0..c9d99e3f 100644 --- a/test/unit/dispenser_extension_undo_reorg.test.js +++ b/test/unit/dispenser_extension_undo_reorg.test.js @@ -120,6 +120,11 @@ describe('dispenser expiration extend undo', () => { 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([]); @@ -134,23 +139,23 @@ describe('dispenser expiration extend undo', () => { 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']); + }); - 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); - }); + 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); }); }); From aec20e361a2dab9432cc7c684caeac768101f27a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 6 Oct 2026 01:12:12 -0700 Subject: [PATCH 30/86] Prove the extension undo table is reconciled by the startup schema check, so no migration is needed --- test/unit/dispenser_extension_undo_reorg.test.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/unit/dispenser_extension_undo_reorg.test.js b/test/unit/dispenser_extension_undo_reorg.test.js index c9d99e3f..d89c1ba1 100644 --- a/test/unit/dispenser_extension_undo_reorg.test.js +++ b/test/unit/dispenser_extension_undo_reorg.test.js @@ -159,3 +159,13 @@ describe('extendEditedDispenser', () => { 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']); + }); +}); From a9c0cf78cbb59dc2b1b27fbe33e1eb21f3e0b5fb Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 6 Oct 2026 04:06:39 -0700 Subject: [PATCH 31/86] Declare the deleteBlockByIndex title rename from 4 to 5 DELETE queries in the suite-title rename map --- bin/pins/suite-title-renames.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bin/pins/suite-title-renames.json b/bin/pins/suite-title-renames.json index 12eb8b96..f5395d24 100644 --- a/bin/pins/suite-title-renames.json +++ b/bin/pins/suite-title-renames.json @@ -1,5 +1,9 @@ { "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" + } + } } From 27f12d8b6cbceaeeb8a8e9720d79d622a12ef6fd Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 6 Oct 2026 04:06:50 -0700 Subject: [PATCH 32/86] Add the CLA Assistant step to the PR checklist in CONTRIBUTING.md --- CONTRIBUTING.md | 1 + 1 file changed, 1 insertion(+) 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). From e4065131d280aba9c5bb6855cd03590fdf700302 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 6 Oct 2026 23:00:25 -0700 Subject: [PATCH 33/86] Review round 2026-10-06: 4 review fixes in xchain-decoder Fixes from the code review board, each checked by an independent verify pass: - Decoder logs equal-height and tip-regression reorgs at info, below the warn level its own logWarn exists for - runMigrations checks sql_mode only after migration backfills have run on the session - Regtest fee destination: the decoder accepts a bare FEE_DESTINATION env override that the indexer never reads, so the two sides can capture and valida - Re-vendor the action manifest fixture --- README.md | 2 +- bin/pins/identity.json | 2 +- bin/pins/suite-title-renames.json | 6 ++ src/XChainDecoder/reorg_verification.js | 2 +- src/XChainDecoder/sync_loop.js | 2 +- src/XChainDecoder/tip_refresh.js | 2 +- src/db/database_setup.js | 5 +- src/db/migration_ledger.js | 30 ++++++++ src/db/migration_query_timeout.js | 10 +++ src/db/migration_statements.js | 33 +++++---- src/db/migrations.js | 8 +- src/protocol/fee_destination.js | 40 ++++++---- test/chaos/ce06_chain_reorg.test.js | 5 +- test/fixtures/action-manifest.json | 2 +- test/unit/fee_destination.test.js | 31 ++++++-- test/unit/migration_runner.test.js | 6 +- ...07_database_schema_contract_guards.test.js | 18 ++++- .../09_migration_query_timeout.test.js | 33 +++++++++ .../11_destructive_guard_session_set.test.js | 73 +++++++++++++++++++ .../03_reorg_entry_paths_log_at_warn.test.js | 54 ++++++++++++++ 20 files changed, 314 insertions(+), 50 deletions(-) create mode 100644 src/db/migration_ledger.js create mode 100644 test/unit/migration_runner.test/11_destructive_guard_session_set.test.js create mode 100644 test/unit/verify_reorg_retry.test/03_reorg_entry_paths_log_at_warn.test.js 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/pins/identity.json b/bin/pins/identity.json index 41a039df..c4144386 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -9,7 +9,7 @@ "src/coins/index.js": "08b678d934fa647d3c7d14dc5ccdaeb86722ee32a3662de69e4a31cd7b09234c" }, "twinFixtures": { - "test/fixtures/action-manifest.json": "e4b386bf166203f65d0cd8a246bc0820a2518ed570ab2112367169dd8a5c1e69", + "test/fixtures/action-manifest.json": "20401eae9ef1bb1caa9462e70b1d0a6b89f685f31c4e2552438df4c85171bff2", "test/fixtures/roundtrip-conformance.json": "d9963ac7c35bdcfab99595fec040d7791d73119cb62fef20db136f8ea56236ed" } } diff --git a/bin/pins/suite-title-renames.json b/bin/pins/suite-title-renames.json index f5395d24..f9b92024 100644 --- a/bin/pins/suite-title-renames.json +++ b/bin/pins/suite-title-renames.json @@ -4,6 +4,12 @@ "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_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/src/XChainDecoder/reorg_verification.js b/src/XChainDecoder/reorg_verification.js index fcadf158..7c152058 100644 --- a/src/XChainDecoder/reorg_verification.js +++ b/src/XChainDecoder/reorg_verification.js @@ -346,7 +346,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/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/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/db/database_setup.js b/src/db/database_setup.js index 3a9bd87c..eb1274eb 100644 --- a/src/db/database_setup.js +++ b/src/db/database_setup.js @@ -204,12 +204,15 @@ 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.assertActionDataIsUtf8mb4(); - await this.assertStrictSqlMode(); return result; }, 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_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..84799b85 100644 --- a/src/db/migration_statements.js +++ b/src/db/migration_statements.js @@ -13,6 +13,7 @@ **********************************************************************/ const { opensBackslashEscape } = require('./query_helpers.js') +const { ensureLedgerAppliedAtDatetime } = require('./migration_ledger.js') // True when a `#` sits outside every quoted span - a line comment // stripSqlLineComments should already have removed. Quote-aware so a `#` @@ -37,6 +38,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 +53,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 @@ -137,18 +151,6 @@ function isDestructiveAlter(stmt, safeAlterDrop){ 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). @@ -191,7 +193,8 @@ module.exports = { // TABLESPACE clause, ALTER IGNORE TABLE (deletes duplicate-key rows), any other // ALTER than ALTER [ONLINE] TABLE, 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 diff --git a/src/db/migrations.js b/src/db/migrations.js index c1aec364..d293a025 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -17,7 +17,7 @@ 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') function validateMigrationTargets(files, only, dir){ // Targeted rollout: a name that matches no committed migration is almost @@ -174,6 +174,8 @@ async function applyMigrationFile(database, context, file, raw, checksum, mode){ } // 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 +271,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 +291,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 +299,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/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/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/fixtures/action-manifest.json b/test/fixtures/action-manifest.json index 8aa9e150..7317579d 100644 --- a/test/fixtures/action-manifest.json +++ b/test/fixtures/action-manifest.json @@ -20,7 +20,7 @@ "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": { 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/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..e34f12f6 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 @@ -160,10 +160,26 @@ describe('Database schema-contract guards @regression', function () { 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', '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/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/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') + }) + } +}) From 584447864b719a459441909a4f9154870e54a42c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 6 Oct 2026 23:47:52 -0700 Subject: [PATCH 34/86] Clarify AGPL attribution term State the required author credit as an additional term under AGPL section 7(b). --- NOTICE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 From 3e3eb527c5c22a4efbaa87ef1385d0e665191c11 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 7 Oct 2026 00:50:09 -0700 Subject: [PATCH 35/86] Review round 2026-10-06, third pass: 1 review fix in xchain-decoder Fixes from the code review board, each checked by an independent verify pass: - getRawTransaction retry exhaustion drops error.code/rpcCode, so getBlockReassembled cannot tell an unreachable node from unusable block bytes --- src/chain/blockchain_connector/rpc_helpers.js | 10 +++ .../blockchain_connector/rpc_transport.js | 6 +- .../transaction_queries.js | 9 +- test/unit/auxpow_reassembly.test.js | 19 +++++ .../rpc_retry_exhaustion_identity.test.js | 84 +++++++++++++++++++ 5 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 test/unit/rpc_retry_exhaustion_identity.test.js diff --git a/src/chain/blockchain_connector/rpc_helpers.js b/src/chain/blockchain_connector/rpc_helpers.js index 73e7d7a7..99f36ad7 100644 --- a/src/chain/blockchain_connector/rpc_helpers.js +++ b/src/chain/blockchain_connector/rpc_helpers.js @@ -161,9 +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..c9b06e93 100644 --- a/src/chain/blockchain_connector/transaction_queries.js +++ b/src/chain/blockchain_connector/transaction_queries.js @@ -14,7 +14,7 @@ const config = require('../../config'); const { logger } = require('./constants.js') -const { envInt, sanitizeRpcError } = require('./rpc_helpers.js') +const { carryErrorIdentity, envInt, sanitizeRpcError } = require('./rpc_helpers.js') function rawTransactionResponse(response, txid) { // A JSON-RPC 2.0 node (Bitcoin Core >= v28) answers an RPC error with @@ -101,7 +101,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 +111,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 +132,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/test/unit/auxpow_reassembly.test.js b/test/unit/auxpow_reassembly.test.js index f0b61350..93dc34bc 100644 --- a/test/unit/auxpow_reassembly.test.js +++ b/test/unit/auxpow_reassembly.test.js @@ -338,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/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) +}) From 16e81543433f537f904604aa1c6223bf48e6d414 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 7 Oct 2026 07:13:02 -0700 Subject: [PATCH 36/86] test(decoder): guard coin parity through sibling trust Resolve the canonical hub path with the shared sibling checkout helper. Skip refused siblings in soft mode and fail closed when sibling coverage is required. --- test/unit/coins_conformance.test.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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){ From 6e786934db79605c3347e31b4771bcc9ff253b87 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 7 Oct 2026 12:50:38 -0700 Subject: [PATCH 37/86] Assert transaction ID columns are BIGINT at startup Register the widening migration as a deploy precondition and fail startup when the transactions ID set is incomplete or not BIGINT. --- src/db/database_setup.js | 36 +++++++++++ src/db/migration_preconditions.js | 10 ++- .../2026-06-02-widen-ids-to-bigint.sql | 2 +- test/unit/migration_preconditions.test.js | 61 ++++++++++++++++++- 4 files changed, 104 insertions(+), 5 deletions(-) diff --git a/src/db/database_setup.js b/src/db/database_setup.js index eb1274eb..91584d9a 100644 --- a/src/db/database_setup.js +++ b/src/db/database_setup.js @@ -212,6 +212,7 @@ module.exports = { const result = await this.runMigrationsInner(opts); await this.assertDispenserExpirationIsBigintUnsigned(); await this.assertPubkeyColumnIsUncompressedWide(); + if(this.assertTransactionIdsAreBigint) await this.assertTransactionIdsAreBigint(); await this.assertActionDataIsUtf8mb4(); return result; }, @@ -288,6 +289,41 @@ 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 c.COLUMN_NAME AS col, c.DATA_TYPE AS dataType " + + "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) return; + const types = new Map(); + for(const row of rows){ + if(row.col != null) types.set(String(row.col).toLowerCase(), String(row.dataType || '').toLowerCase()); + } + const invalid = expected.filter(col => types.get(col) !== 'bigint'); + if(invalid.length){ + const found = invalid.map(col => col + '=' + (types.get(col) || 'missing')).join(', '); + throw new Error( + 'transactions id columns must use BIGINT, 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/migration_preconditions.js b/src/db/migration_preconditions.js index 112fd7a5..8cecd4e3 100644 --- a/src/db/migration_preconditions.js +++ b/src/db/migration_preconditions.js @@ -207,8 +207,9 @@ Database.DEPLOY_PRECONDITION_TAG = 'deploy-precondition=required'; // 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 +// The four startup assertions above (assertTransactionIdsAreBigint, +// assertDispenserExpirationIsBigintUnsigned, assertPubkeyColumnIsUncompressedWide, +// assertActionDataIsUtf8mb4) each require a // mode=manual migration, and none of the three 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 +226,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: '2026-06-02-widen-ids-to-bigint.sql', + assertion: 'assertTransactionIdsAreBigint', + symptom: 'Fatal decoder error: transactions id columns must use BIGINT' + }, { file: '2026-06-13-dispensers-expiration-bigint.sql', assertion: 'assertDispenserExpirationIsBigintUnsigned', 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/test/unit/migration_preconditions.test.js b/test/unit/migration_preconditions.test.js index 6b18a2a8..79b08679 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'), @@ -192,6 +195,24 @@ describe('startup assertion error text names the registered file @regression @ti 'the halt message must name the migration; got: ' + message); }); + it('assertTransactionIdsAreBigint names the exact migration file', async function () { + let message = null; + try { + await Database.prototype.assertTransactionIdsAreBigint.call(ctxReturning([ + { col: 'tx_index', dataType: 'int' }, + { col: 'tx_hash_id', dataType: 'bigint' }, + { col: 'block_index', dataType: 'bigint' }, + { col: 'source_id', dataType: 'bigint' }, + { col: 'destination_id', dataType: 'bigint' } + ])); + } 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('assertPubkeyColumnIsUncompressedWide names the exact migration file', async function () { let message = null; try { @@ -218,6 +239,42 @@ describe('startup assertion error text names the registered file @regression @ti }); }); +describe('assertTransactionIdsAreBigint @regression @tier1', function () { + + const columns = [ + { col: 'tx_index', dataType: 'bigint' }, + { col: 'tx_hash_id', dataType: 'bigint' }, + { col: 'block_index', dataType: 'bigint' }, + { col: 'source_id', dataType: 'bigint' }, + { col: 'destination_id', dataType: 'bigint' } + ]; + + function ctxReturning(rows) { + return { + dbName: 'test_decoder', + transactionConnection: null, + getConnection: async () => ({ + query: async () => rows, + release: async () => {} + }) + }; + } + + it('accepts the complete transactions id set at BIGINT', async function () { + await Database.prototype.assertTransactionIdsAreBigint.call(ctxReturning(columns)); + }); + + 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([])); + }); +}); + describe('Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression', function () { const skipWhen = Database.MIGRATION_PRECONDITIONS['2026-07-24-pubkeys-widen-uncompressed.sql'].skipWhen; From a9701321ebb47f69e73849086335265534b4d917 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 7 Oct 2026 13:51:37 -0700 Subject: [PATCH 38/86] test(decoder): refresh action manifest fixture Vendor the settlement-anchor manifest update, refresh its identity pin, and guard that the new anchors remain explorer-only. --- bin/pins/identity.json | 2 +- test/fixtures/action-manifest.json | 11 ++++++++++- test/unit/action_manifest_conformance.test.js | 12 ++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/bin/pins/identity.json b/bin/pins/identity.json index c4144386..f66014c7 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -9,7 +9,7 @@ "src/coins/index.js": "08b678d934fa647d3c7d14dc5ccdaeb86722ee32a3662de69e4a31cd7b09234c" }, "twinFixtures": { - "test/fixtures/action-manifest.json": "20401eae9ef1bb1caa9462e70b1d0a6b89f685f31c4e2552438df4c85171bff2", + "test/fixtures/action-manifest.json": "920779b5f20ee489bd02268e7a18065d44e52bd321076a34d6da41714396ec14", "test/fixtures/roundtrip-conformance.json": "d9963ac7c35bdcfab99595fec040d7791d73119cb62fef20db136f8ea56236ed" } } diff --git a/test/fixtures/action-manifest.json b/test/fixtures/action-manifest.json index 7317579d..8f152a7e 100644 --- a/test/fixtures/action-manifest.json +++ b/test/fixtures/action-manifest.json @@ -14,7 +14,8 @@ "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": "render-only in the explorer (legacy order/dispenser cancel+edit views); no decoder/indexer twin", + "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.", @@ -252,6 +253,10 @@ "explorerRender": true, "walletForm": true }, + "LIST_SHARE": { + "category": "settlement-anchor", + "explorerRender": true + }, "MESSAGE": { "category": "wire-user", "wireDecoded": true, @@ -432,6 +437,10 @@ "category": "mirror-injected", "indexerHandled": true, "explorerRender": true + }, + "XPOLICY": { + "category": "settlement-anchor", + "explorerRender": true } } } 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. From e093c0501ac064aa2f38bb4916dc22811a6f799c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 7 Oct 2026 14:35:29 -0700 Subject: [PATCH 39/86] Repair migration assertion gate compatibility Keep the new schema assertion distinct from unrelated query fixtures, preserve checksum healing for the tagged migration, and restore the structure ratchet. --- src/db/database_setup.js | 4 +-- src/db/migration_preconditions.js | 9 +++-- test/unit/migration_preconditions.test.js | 44 +++++++++++++---------- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/src/db/database_setup.js b/src/db/database_setup.js index 91584d9a..5792f184 100644 --- a/src/db/database_setup.js +++ b/src/db/database_setup.js @@ -295,7 +295,7 @@ module.exports = { try { conn = await this.getConnection(); const rows = await conn.query( - "SELECT c.COLUMN_NAME AS col, c.DATA_TYPE AS dataType " + + "SELECT 1 AS tableExists, c.COLUMN_NAME AS col, c.DATA_TYPE AS dataType " + "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 " + @@ -303,7 +303,7 @@ module.exports = { "WHERE t.table_schema = ? AND t.table_name = 'transactions'", [this.dbName] ); - if(!rows.length) return; + 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(), String(row.dataType || '').toLowerCase()); diff --git a/src/db/migration_preconditions.js b/src/db/migration_preconditions.js index 8cecd4e3..fe42f8d6 100644 --- a/src/db/migration_preconditions.js +++ b/src/db/migration_preconditions.js @@ -201,6 +201,11 @@ 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'; +const bigintIdRebaseline = Database.MIGRATION_CHECKSUM_REBASELINES[BIGINT_ID_MIGRATION]; +bigintIdRebaseline.from = [].concat(bigintIdRebaseline.from, bigintIdRebaseline.to); +bigintIdRebaseline.to = '33423de08d0869d7a81829301668383ee3cb47e7226bff587fcfcbf9fa0c7b1c'; + // Migrations this tree ASSERTS at startup: the service refuses to run when the // target database has not applied them. // @@ -210,7 +215,7 @@ Database.DEPLOY_PRECONDITION_TAG = 'deploy-precondition=required'; // The four startup assertions above (assertTransactionIdsAreBigint, // assertDispenserExpirationIsBigintUnsigned, assertPubkeyColumnIsUncompressedWide, // assertActionDataIsUtf8mb4) each require a -// mode=manual migration, and none of the three migration files carried a header the +// 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. // @@ -227,7 +232,7 @@ Database.DEPLOY_PRECONDITION_TAG = 'deploy-precondition=required'; // pool pins what it checks, so it holds by construction and cannot crash-loop a deploy. Database.STARTUP_ASSERTED_MIGRATIONS = [ { - file: '2026-06-02-widen-ids-to-bigint.sql', + file: BIGINT_ID_MIGRATION, assertion: 'assertTransactionIdsAreBigint', symptom: 'Fatal decoder error: transactions id columns must use BIGINT' }, diff --git a/test/unit/migration_preconditions.test.js b/test/unit/migration_preconditions.test.js index 79b08679..4ab9d22f 100644 --- a/test/unit/migration_preconditions.test.js +++ b/test/unit/migration_preconditions.test.js @@ -195,24 +195,6 @@ describe('startup assertion error text names the registered file @regression @ti 'the halt message must name the migration; got: ' + message); }); - it('assertTransactionIdsAreBigint names the exact migration file', async function () { - let message = null; - try { - await Database.prototype.assertTransactionIdsAreBigint.call(ctxReturning([ - { col: 'tx_index', dataType: 'int' }, - { col: 'tx_hash_id', dataType: 'bigint' }, - { col: 'block_index', dataType: 'bigint' }, - { col: 'source_id', dataType: 'bigint' }, - { col: 'destination_id', dataType: 'bigint' } - ])); - } 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('assertPubkeyColumnIsUncompressedWide names the exact migration file', async function () { let message = null; try { @@ -254,12 +236,27 @@ describe('assertTransactionIdsAreBigint @regression @tier1', function () { dbName: 'test_decoder', transactionConnection: null, getConnection: async () => ({ - query: async () => rows, + query: async () => rows.map(row => ({ tableExists: 1, ...row })), release: async () => {} }) }; } + 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' }, + ...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('accepts the complete transactions id set at BIGINT', async function () { await Database.prototype.assertTransactionIdsAreBigint.call(ctxReturning(columns)); }); @@ -273,6 +270,15 @@ describe('assertTransactionIdsAreBigint @regression @tier1', function () { 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('Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression', function () { From 13ac5eb89a22503614a21cf7f5ff655ec7df35fa Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 7 Oct 2026 14:57:04 -0700 Subject: [PATCH 40/86] Require BIGINT ID assertion during startup Invoke the registered transaction ID schema guard unconditionally and cover the fail-closed behavior when its method is unavailable. --- src/db/database_setup.js | 2 +- test/unit/migration_preconditions.test.js | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/db/database_setup.js b/src/db/database_setup.js index 5792f184..3815e0fb 100644 --- a/src/db/database_setup.js +++ b/src/db/database_setup.js @@ -212,7 +212,7 @@ module.exports = { const result = await this.runMigrationsInner(opts); await this.assertDispenserExpirationIsBigintUnsigned(); await this.assertPubkeyColumnIsUncompressedWide(); - if(this.assertTransactionIdsAreBigint) await this.assertTransactionIdsAreBigint(); + await this.assertTransactionIdsAreBigint(); await this.assertActionDataIsUtf8mb4(); return result; }, diff --git a/test/unit/migration_preconditions.test.js b/test/unit/migration_preconditions.test.js index 4ab9d22f..113f39d3 100644 --- a/test/unit/migration_preconditions.test.js +++ b/test/unit/migration_preconditions.test.js @@ -281,6 +281,23 @@ describe('assertTransactionIdsAreBigint @regression @tier1', function () { }); }); +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/); + }); +}); + describe('Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression', function () { const skipWhen = Database.MIGRATION_PRECONDITIONS['2026-07-24-pubkeys-widen-uncompressed.sql'].skipWhen; From edac8023af4f589ab4036e6350e2689aefd34ac8 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 7 Oct 2026 15:22:06 -0700 Subject: [PATCH 41/86] test(decoder): verify published envelope chunk vectors Rebuild the published chunking and rebalance payloads from their recipes. Assert their frozen hashes, push splits, witness recognition, and byte-identical decoder reassembly. --- ...taproot_envelope_published_vectors.test.js | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 test/unit/taproot_envelope_published_vectors.test.js 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) + }) +}) From 66cc1b4459aeb80e3280235d3064f8a338b30f57 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 7 Oct 2026 15:22:07 -0700 Subject: [PATCH 42/86] Add dispenser purge grace activation map Mirror the canonical unarmed production and testnet values, expose the map through protocol constants, and guard documentation parity. Remove the target-spacing assertion superseded by the independent purge gate. --- src/protocol/constants.js | 1 + src/protocol/constants/activation.js | 15 +++++++ .../dispenser_cancel_grace_activation.test.js | 17 -------- .../dispenser_purge_grace_activation.test.js | 42 +++++++++++++++++++ 4 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 test/unit/dispenser_purge_grace_activation.test.js diff --git a/src/protocol/constants.js b/src/protocol/constants.js index 30fee9df..f7c259db 100644 --- a/src/protocol/constants.js +++ b/src/protocol/constants.js @@ -102,6 +102,7 @@ module.exports = { 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, 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, diff --git a/src/protocol/constants/activation.js b/src/protocol/constants/activation.js index 0bdb478b..0aa651a4 100644 --- a/src/protocol/constants/activation.js +++ b/src/protocol/constants/activation.js @@ -346,6 +346,20 @@ const DISPENSER_CANCEL_GRACE_ACTIVATION = { 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, +}; + // 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 @@ -495,6 +509,7 @@ module.exports = { ORACLE_FEE_SET_CAPTURE_ACTIVATION, DISPENSER_EXPIRY_REALIGN_ACTIVATION, DISPENSER_CANCEL_GRACE_ACTIVATION, + DISPENSER_PURGE_GRACE_ACTIVATION, BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION, ENVELOPE_RECOGNITION_ACTIVATION, ENVELOPE_CARRIER_RECOGNITION_ACTIVATION, diff --git a/test/unit/dispenser_cancel_grace_activation.test.js b/test/unit/dispenser_cancel_grace_activation.test.js index c9a047a4..193be5e3 100644 --- a/test/unit/dispenser_cancel_grace_activation.test.js +++ b/test/unit/dispenser_cancel_grace_activation.test.js @@ -41,7 +41,6 @@ const { DISPENSER_CANCEL_GRACE_ACTIVATION, DISPENSER_CANCEL_GRACE_SECONDS, isDispenserCancelGraceActive, cancelGraceFloor } = require('../../src/protocol/dispenser_cancel_grace.js'); -const XChainDecoder = require('../../src/XChainDecoder.js'); const DOCS_CONSTANTS = process.env.XCHAIN_DOCS_DIR ? path.join(process.env.XCHAIN_DOCS_DIR, 'protocol', 'constants.js') @@ -207,20 +206,4 @@ describe('DISPENSER_CANCEL_GRACE_SECONDS cross-repo invariants', function () { assert.strictEqual(DISPENSER_CANCEL_GRACE_SECONDS, 3600); }); - it('the hard purge cannot reclaim a row that is still inside the grace window', function () { - // purgeExpiredDispensers hard-deletes a row 126 blocks (DISPENSER_EXPIRE_SAFE_DEPTH) - // after the block that stamped it. A row purged while still inside its grace window - // would drop out of the widened capture set early, so the depth has to outlast the - // grace on the FASTEST chain the platform decodes. Purge is keyed on block HEIGHT, so - // even a burst that outran this stays deterministic across nodes; the margin is what - // keeps the grace from being cosmetic on DOGE. - const FASTEST_TARGET_SPACING = 60; // DOGE (BTC 600 / LTC 150 / DOGE 60) - const purgeSpan = XChainDecoder.DISPENSER_EXPIRE_SAFE_DEPTH * FASTEST_TARGET_SPACING; - assert.ok( - purgeSpan > DISPENSER_CANCEL_GRACE_SECONDS, - `the purge span (${purgeSpan}s at target spacing) must exceed the grace window ` + - `(${DISPENSER_CANCEL_GRACE_SECONDS}s), or a cancelled dispenser is hard-deleted ` + - 'before the indexer stops matching it' - ); - }); }); 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..23c066e5 --- /dev/null +++ b/test/unit/dispenser_purge_grace_activation.test.js @@ -0,0 +1,42 @@ +'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 DOC_CONSTANTS = process.env.XC_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)) this.skip(); + const canonical = require(DOC_CONSTANTS).DISPENSER_PURGE_GRACE_ACTIVATION; + 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'); + }); +}); From 3d5258c6741ab4723b9205e0762290e9ac60544d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 7 Oct 2026 15:22:17 -0700 Subject: [PATCH 43/86] Document protocol-time caller ordering State the bounded newest-first input contract for decoder median time. Add direct coverage for height ordering, span width, and the shortened genesis window. --- bin/pins/at1-suite-titles.json | 14 +++-- src/XChainDecoder/block_store.js | 6 +- src/protocol/protocol_time.js | 14 ++++- test/unit/protocol_time_caller_order.test.js | 58 ++++++++++++++++++++ 4 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 test/unit/protocol_time_caller_order.test.js diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index dacce5cc..0245191b 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -2577,6 +2577,10 @@ "Database#getConnection() succeeds on first pool.getConnection call", "Database#getConnection() throws after maxAttempts (30) consecutive failures" ], + "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)", @@ -3102,8 +3106,8 @@ }, "scripts": { "test": { - "fileCount": 188, - "titleCount": 1759, + "fileCount": 189, + "titleCount": 1761, "files": { "test/unit/action_manifest_conformance.test.js": "d8cd8319c6020126", "test/unit/alias_expansion_boundary.test.js": "7a5cec648390c3a0", @@ -3249,6 +3253,7 @@ "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_caller_order.test.js": "d18469488f659a45", "test/unit/protocol_time.test.js": "a712626ef67e3e03", "test/unit/protocol_time_parity.test.js": "bea80fd3971653d5", "test/unit/remove_obfuscation.test.js": "0eb0aadd100cef95", @@ -3584,8 +3589,8 @@ } }, "test:unit": { - "fileCount": 188, - "titleCount": 1759, + "fileCount": 189, + "titleCount": 1761, "files": { "test/unit/action_manifest_conformance.test.js": "d8cd8319c6020126", "test/unit/alias_expansion_boundary.test.js": "7a5cec648390c3a0", @@ -3731,6 +3736,7 @@ "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_caller_order.test.js": "d18469488f659a45", "test/unit/protocol_time.test.js": "a712626ef67e3e03", "test/unit/protocol_time_parity.test.js": "bea80fd3971653d5", "test/unit/remove_obfuscation.test.js": "0eb0aadd100cef95", diff --git a/src/XChainDecoder/block_store.js b/src/XChainDecoder/block_store.js index 4d15b180..67db5483 100644 --- a/src/XChainDecoder/block_store.js +++ b/src/XChainDecoder/block_store.js @@ -119,7 +119,9 @@ 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 `span`, as medianTimePast's caller contract requires. A + // failed lookup returns no partial window, avoiding mixed reorg states. let previousBlockTimes = [] for (let height = nextBlockHeight - 1; height >= 0 && previousBlockTimes.length < span; height--){ let previousBlock @@ -195,4 +197,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/protocol/protocol_time.js b/src/protocol/protocol_time.js index 318e939f..3fa58369 100644 --- a/src/protocol/protocol_time.js +++ b/src/protocol/protocol_time.js @@ -59,9 +59,17 @@ 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 +// CALLER CONTRACT. `previousBlockTimes` is the timestamps of the blocks BELOW +// the one being resolved, NEWEST FIRST by block height, and at most +// MEDIAN_TIME_SPAN long. The one real caller, fetchPreviousBlockTimes in +// XChainDecoder/block_store.js, provides both guarantees. +// +// This function selects the newest entries by VALUE, not by position. For a +// window within the span the two are the same set, so the result is the Bitcoin +// median whatever the order. A longer window on a chain whose stamps are not +// monotonic would pick a different set than "the 11 blocks below", so the width +// bound belongs to the caller and a wider read is a contract violation, not a +// convenience. 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. // 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..fead7ae1 --- /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, MEDIAN_TIME_SPAN) + 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] + ) + }) +}) From f4cf047f972cbe56df9d4fe0ee904e9bd1dfc2a0 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 7 Oct 2026 15:32:22 -0700 Subject: [PATCH 44/86] Restore dispenser purge safety invariant Keep the existing hard-purge grace-window coverage alongside the new purge activation conformance map. --- .../dispenser_cancel_grace_activation.test.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/unit/dispenser_cancel_grace_activation.test.js b/test/unit/dispenser_cancel_grace_activation.test.js index 193be5e3..c9a047a4 100644 --- a/test/unit/dispenser_cancel_grace_activation.test.js +++ b/test/unit/dispenser_cancel_grace_activation.test.js @@ -41,6 +41,7 @@ const { DISPENSER_CANCEL_GRACE_ACTIVATION, DISPENSER_CANCEL_GRACE_SECONDS, isDispenserCancelGraceActive, cancelGraceFloor } = require('../../src/protocol/dispenser_cancel_grace.js'); +const XChainDecoder = require('../../src/XChainDecoder.js'); const DOCS_CONSTANTS = process.env.XCHAIN_DOCS_DIR ? path.join(process.env.XCHAIN_DOCS_DIR, 'protocol', 'constants.js') @@ -206,4 +207,20 @@ describe('DISPENSER_CANCEL_GRACE_SECONDS cross-repo invariants', function () { assert.strictEqual(DISPENSER_CANCEL_GRACE_SECONDS, 3600); }); + it('the hard purge cannot reclaim a row that is still inside the grace window', function () { + // purgeExpiredDispensers hard-deletes a row 126 blocks (DISPENSER_EXPIRE_SAFE_DEPTH) + // after the block that stamped it. A row purged while still inside its grace window + // would drop out of the widened capture set early, so the depth has to outlast the + // grace on the FASTEST chain the platform decodes. Purge is keyed on block HEIGHT, so + // even a burst that outran this stays deterministic across nodes; the margin is what + // keeps the grace from being cosmetic on DOGE. + const FASTEST_TARGET_SPACING = 60; // DOGE (BTC 600 / LTC 150 / DOGE 60) + const purgeSpan = XChainDecoder.DISPENSER_EXPIRE_SAFE_DEPTH * FASTEST_TARGET_SPACING; + assert.ok( + purgeSpan > DISPENSER_CANCEL_GRACE_SECONDS, + `the purge span (${purgeSpan}s at target spacing) must exceed the grace window ` + + `(${DISPENSER_CANCEL_GRACE_SECONDS}s), or a cancelled dispenser is hard-deleted ` + + 'before the indexer stops matching it' + ); + }); }); From 7378a5c5637dab62038dbd10d172ebb7db098e00 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 7 Oct 2026 15:32:44 -0700 Subject: [PATCH 45/86] Enforce protocol-time history bound Clamp previous-block timestamp reads to the consensus median span even when a wider window is requested. Exercise the bound with an oversized caller request so removing the enforcement fails the caller-contract test. --- src/XChainDecoder/block_store.js | 7 ++++--- test/unit/protocol_time_caller_order.test.js | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/XChainDecoder/block_store.js b/src/XChainDecoder/block_store.js index 67db5483..fc476101 100644 --- a/src/XChainDecoder/block_store.js +++ b/src/XChainDecoder/block_store.js @@ -120,10 +120,11 @@ async function finishBlock(loop, block, nextBlockHeight, nextBlockHash, openDisp async function fetchPreviousBlockTimes(nextBlockHeight, span){ // Walk strictly backward so the result is newest first by block height and - // never wider than `span`, as medianTimePast's caller contract requires. A - // failed lookup returns no partial window, avoiding mixed reorg states. + // 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) diff --git a/test/unit/protocol_time_caller_order.test.js b/test/unit/protocol_time_caller_order.test.js index fead7ae1..c218b485 100644 --- a/test/unit/protocol_time_caller_order.test.js +++ b/test/unit/protocol_time_caller_order.test.js @@ -30,7 +30,7 @@ describe('protocol time newest-first caller contract', function () { }, } - const times = await fetchPreviousBlockTimes.call(context, 20, MEDIAN_TIME_SPAN) + const times = await fetchPreviousBlockTimes.call(context, 20, stamps.length) const expectedHeights = Array.from( { length: MEDIAN_TIME_SPAN }, (_, offset) => 19 - offset From 22b287557d24e9eece1a37488746fafa59c405c1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 7 Oct 2026 18:28:39 -0700 Subject: [PATCH 46/86] Handle staged documentation constant rollout Skip parity only when the ordinary documentation sibling predates the new export. Keep explicitly supplied canonical snapshots strict so row verification still detects missing or drifted values. --- test/unit/dispenser_purge_grace_activation.test.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/unit/dispenser_purge_grace_activation.test.js b/test/unit/dispenser_purge_grace_activation.test.js index 23c066e5..6a5172c7 100644 --- a/test/unit/dispenser_purge_grace_activation.test.js +++ b/test/unit/dispenser_purge_grace_activation.test.js @@ -16,7 +16,8 @@ const path = require('path'); const { DISPENSER_PURGE_GRACE_ACTIVATION } = require('../../src/protocol/constants.js'); -const DOC_CONSTANTS = process.env.XC_DOC_CONSTANTS || (process.env.XCHAIN_DOCS_DIR +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')); @@ -31,8 +32,13 @@ describe('DISPENSER_PURGE_GRACE_ACTIVATION conformance', function () { }); it('is value-identical to the canonical map in xchain-documentation', function () { - if (!fs.existsSync(DOC_CONSTANTS)) this.skip(); + 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'); From 7ff9b8c7846062cf47cf140ab46937a3b41226b0 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 7 Oct 2026 19:44:39 -0700 Subject: [PATCH 47/86] Fix decoder mempool admission, reorg verification, migration guard and sync status findings - Decoder's mid-walk tip refresh accepts a tip from a node still in IBD, so it deletes valid above-tip blocks while the tracker waits - Decoder isPollSilent fail-quiet on lastPollAt==0 leaves a pre-loop boot hang reporting healthy on /live indefinitely; stated 'long initial sync' ratio - Decoder drops an XChain payload with no resolvable source with only a log line and no counter - Decoder getmempool reports first_seen shifted by the decoder host's UTC offset, and the explorer prefers that path and treats it as exact unix seconds - Decoder destructiveAutoStatement has drifted from the indexer classifier: no CONVERT TO, MODIFY-id, or live-column guard - Decoder drift reconciler's NOT NULL -> NULL relax silently drops DEFAULT/COMMENT/ON UPDATE/generated expression/collation --- src/XChainDecoder/mempool_refresh.js | 19 +- src/XChainDecoder/reorg_verification.js | 12 +- src/XChainDecoder/sync_status.js | 10 +- src/XChainDecoder/transaction_ingest.js | 3 + src/api.js | 4 +- src/db.js | 5 +- src/db/mempool.js | 15 +- src/db/migration_live_schema_guard.js | 200 ++++++++++++++++++ src/db/migration_statements.js | 14 +- src/db/migrations.js | 3 + src/db/table_drift.js | 53 +++-- ...tabase_alter_table_for_drift_relax.test.js | 79 +++++++ test/unit/decoder_live_heartbeat.test.js | 2 +- test/unit/decoder_tip_stale_surface.test.js | 2 +- test/unit/mempool_api_surface.test.js | 26 ++- .../mempool_unattributable_action.test.js | 73 +++++++ ...2_destructive_guard_modify_convert.test.js | 71 +++++++ .../13_live_schema_guard.test.js | 109 ++++++++++ test/unit/unattributed_action_skip.test.js | 96 +++++++++ ...rify_reorg_mid_walk_tip_regression.test.js | 58 +++++ 20 files changed, 814 insertions(+), 40 deletions(-) create mode 100644 src/db/migration_live_schema_guard.js create mode 100644 test/unit/db.test/07_database_alter_table_for_drift_relax.test.js create mode 100644 test/unit/mempool_unattributable_action.test.js create mode 100644 test/unit/migration_runner.test/12_destructive_guard_modify_convert.test.js create mode 100644 test/unit/migration_runner.test/13_live_schema_guard.test.js create mode 100644 test/unit/unattributed_action_skip.test.js diff --git a/src/XChainDecoder/mempool_refresh.js b/src/XChainDecoder/mempool_refresh.js index fb6a101b..130a4458 100644 --- a/src/XChainDecoder/mempool_refresh.js +++ b/src/XChainDecoder/mempool_refresh.js @@ -88,6 +88,19 @@ function* parseMempoolTransaction(nextTx, nextTransactionHash){ return parseResult } +// 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. +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){ let nextTx = decodeMempoolTransaction.call(this, nextTxHex, nextTxHexIndex) if (nextTx == null) { @@ -101,7 +114,7 @@ function* storeMempoolTransaction(nextTxHex, nextTxHexIndex){ 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 +124,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,7 +140,7 @@ 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) } } diff --git a/src/XChainDecoder/reorg_verification.js b/src/XChainDecoder/reorg_verification.js index 7c152058..bdd870af 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 @@ -161,6 +162,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 +193,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 } 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/transaction_ingest.js b/src/XChainDecoder/transaction_ingest.js index 6baacba6..484a0e3c 100644 --- a/src/XChainDecoder/transaction_ingest.js +++ b/src/XChainDecoder/transaction_ingest.js @@ -303,6 +303,9 @@ async function ingestTransaction(loop, block, nextBlockHeight, openDispenserAddr // 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`) } } diff --git a/src/api.js b/src/api.js index 29dbc175..d9348c98 100644 --- a/src/api.js +++ b/src/api.js @@ -265,8 +265,8 @@ function createMempoolMethods(decoder){ // 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) + // Unix seconds read in SQL (see getMempoolTransactions), never a driver Date. + first_seen: Number.isFinite(r.first_seen) ? r.first_seen : null })) }; } 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/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/migration_live_schema_guard.js b/src/db/migration_live_schema_guard.js new file mode 100644 index 00000000..a2432a80 --- /dev/null +++ b/src/db/migration_live_schema_guard.js @@ -0,0 +1,200 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************/ + +'use strict'; + +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']); + +// 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. +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(ch === '\\'){ cur += text[++i] || ''; } + else if(ch === quote) 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 MODIFY nor stand in for a restated attribute. Executable `/*!` and `/*M!` comments +// stay: the server runs them, and the destructive-DDL scan already refuses them. +function stripBlockComments(text){ + let out = '', quote = null; + for(let i = 0; i < text.length; i++){ + const ch = text[i]; + if(quote){ + out += ch; + if(ch === '\\'){ out += text[++i] || ''; } + else if(ch === quote) quote = null; + continue; + } + if(ch === "'" || ch === '"' || ch === '`'){ quote = ch; out += ch; continue; } + const end = ch === '/' && text[i + 1] === '*' && !/^(?:!|M!)/i.test(text.slice(i + 2, i + 4)) ? text.indexOf('*/', i + 2) : -1; + if(end !== -1){ 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; +} + +function narrowedType(live, next){ + if(!live || !next) return null; + 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 === 'decimal' && next.name === live.name){ + const [lp, ls = 0] = String(live.args).split(',').map(Number); + const [np, ns = 0] = String(next.args).split(',').map(Number); + return np - ns < lp - ls || ns < ls ? 'decimal(' + live.args + ') -> decimal(' + next.args + ')' : 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 && (live.name in INT_RANK || live.name in TEXT_RANK || live.name in BLOB_RANK || SIZED.has(live.name))){ + const widening = (live.name in INT_RANK && ['decimal', 'bigint'].includes(next.name)) || + (SIZED.has(live.name) && (next.name in TEXT_RANK || next.name in BLOB_RANK)) || + (live.name in TEXT_RANK && next.name === 'longtext'); + return widening ? null : live.name + ' -> ' + next.name; + } + return null; +} + +// 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 + ')'); + } + 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 ' + + 'FROM information_schema.columns WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND 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 }; diff --git a/src/db/migration_statements.js b/src/db/migration_statements.js index 84799b85..b612f344 100644 --- a/src/db/migration_statements.js +++ b/src/db/migration_statements.js @@ -127,6 +127,9 @@ function isDestructiveAlter(stmt, safeAlterDrop){ 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 @@ -147,6 +150,10 @@ 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; } @@ -187,9 +194,10 @@ 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 + // (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 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 // [UNIQUE|FULLTEXT|SPATIAL] INDEX (triggers, events, routines and views run SQL diff --git a/src/db/migrations.js b/src/db/migrations.js index d293a025..f98a22b7 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -18,6 +18,7 @@ const config = require('../config'); const Database = require('../db.js') const { logger } = require('./constants.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,6 +172,8 @@ 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(); 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/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/decoder_live_heartbeat.test.js b/test/unit/decoder_live_heartbeat.test.js index 778fdb7e..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); }); 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/mempool_api_surface.test.js b/test/unit/mempool_api_surface.test.js index b9b73bcd..820ca65b 100644 --- a/test/unit/mempool_api_surface.test.js +++ b/test/unit/mempool_api_surface.test.js @@ -84,14 +84,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 +104,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')); @@ -162,6 +175,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_unattributable_action.test.js b/test/unit/mempool_unattributable_action.test.js new file mode 100644 index 00000000..db271d8b --- /dev/null +++ b/test/unit/mempool_unattributable_action.test.js @@ -0,0 +1,73 @@ +// 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) { + const decoder = new XChainDecoder( + 'bitcoin-regtest', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null + ) + const inserted = [] + decoder.mempoolDb = { + 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' }) } + decoder.parseTransaction = async () => parseResult + return decoder.updateMempool().then(() => ({ decoder, inserted })) +} + +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') + }) +}) 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/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/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') + }) +}) From 4f176bd6e21a8011e4da2cc5f1ab311f27d28a4e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 7 Oct 2026 20:23:40 -0700 Subject: [PATCH 48/86] Split BIGINT migration guard tests Keep the deploy-precondition contract suite below the repository line limit and update the schema guard fixture to expect the required transaction ID assertion. --- test/unit/migration_preconditions.test.js | 77 --------------- .../migration_preconditions_bigint.test.js | 96 +++++++++++++++++++ ...07_database_schema_contract_guards.test.js | 3 +- 3 files changed, 98 insertions(+), 78 deletions(-) create mode 100644 test/unit/migration_preconditions_bigint.test.js diff --git a/test/unit/migration_preconditions.test.js b/test/unit/migration_preconditions.test.js index 113f39d3..24aa4f6b 100644 --- a/test/unit/migration_preconditions.test.js +++ b/test/unit/migration_preconditions.test.js @@ -221,83 +221,6 @@ describe('startup assertion error text names the registered file @regression @ti }); }); -describe('assertTransactionIdsAreBigint @regression @tier1', function () { - - const columns = [ - { col: 'tx_index', dataType: 'bigint' }, - { col: 'tx_hash_id', dataType: 'bigint' }, - { col: 'block_index', dataType: 'bigint' }, - { col: 'source_id', dataType: 'bigint' }, - { col: 'destination_id', dataType: 'bigint' } - ]; - - function ctxReturning(rows) { - return { - dbName: 'test_decoder', - transactionConnection: null, - getConnection: async () => ({ - query: async () => rows.map(row => ({ tableExists: 1, ...row })), - release: async () => {} - }) - }; - } - - 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' }, - ...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('accepts the complete transactions id set at BIGINT', async function () { - await Database.prototype.assertTransactionIdsAreBigint.call(ctxReturning(columns)); - }); - - 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/); - }); -}); - describe('Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression', function () { const skipWhen = Database.MIGRATION_PRECONDITIONS['2026-07-24-pubkeys-widen-uncompressed.sql'].skipWhen; diff --git a/test/unit/migration_preconditions_bigint.test.js b/test/unit/migration_preconditions_bigint.test.js new file mode 100644 index 00000000..a8ffe41b --- /dev/null +++ b/test/unit/migration_preconditions_bigint.test.js @@ -0,0 +1,96 @@ +'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'); + +describe('assertTransactionIdsAreBigint @regression @tier1', function () { + + const columns = [ + { col: 'tx_index', dataType: 'bigint' }, + { col: 'tx_hash_id', dataType: 'bigint' }, + { col: 'block_index', dataType: 'bigint' }, + { col: 'source_id', dataType: 'bigint' }, + { col: 'destination_id', dataType: 'bigint' } + ]; + + function ctxReturning(rows) { + return { + dbName: 'test_decoder', + transactionConnection: null, + getConnection: async () => ({ + query: async () => rows.map(row => ({ tableExists: 1, ...row })), + release: async () => {} + }) + }; + } + + 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' }, + ...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('accepts the complete transactions id set at BIGINT', async function () { + await Database.prototype.assertTransactionIdsAreBigint.call(ctxReturning(columns)); + }); + + 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/07_database_schema_contract_guards.test.js b/test/unit/migration_runner.test/07_database_schema_contract_guards.test.js index e34f12f6..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,12 +156,13 @@ 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); // sql_mode is session state, so it is checked before the body; the schema guards follow it. - assert.deepStrictEqual(calls, ['sqlmode', 'dispenser', 'pubkey', 'utf8mb4']); + assert.deepStrictEqual(calls, ['sqlmode', 'dispenser', 'pubkey', 'bigint ids', 'utf8mb4']); assert.strictEqual(result.lockSkipped, true); }); From a081c8c086d3dc5684144c5345a9da8257a6fb91 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 7 Oct 2026 22:29:13 -0700 Subject: [PATCH 49/86] Re-sync the coin registry from the canonical xchain-hub/src/coins The hub coin files changed on develop, so this copy failed the coins_conformance byte-identity check. Byte copy of BTC.js, LTC.js and DOGE.js from xchain-hub origin/develop, with the identity pin regenerated. --- bin/pins/identity.json | 6 +++--- src/coins/BTC.js | 2 +- src/coins/DOGE.js | 2 +- src/coins/LTC.js | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bin/pins/identity.json b/bin/pins/identity.json index c4144386..e9a984a1 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -2,9 +2,9 @@ "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" }, 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, From c3fba3de862b8902037c80c1b2d561487a6a6321 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 00:00:49 -0700 Subject: [PATCH 50/86] Gate dispenser purge on cancellation grace Derive a purge floor from committed protocol time under the independent activation map. Retain soft-expired rows until both their expiration and mark-block time fall outside cancellation grace, while preserving the legacy height-only query below the gate. --- src/XChainDecoder/block_store.js | 11 +- src/XChainDecoder/reorg_verification.js | 6 +- src/db/dispenser_queries.js | 33 ++++-- src/protocol/dispenser_cancel_grace.js | 27 ++++- test/unit/dispenser_cancel_grace.test.js | 14 ++- test/unit/dispenser_purge_grace.test.js | 140 +++++++++++++++++++++++ 6 files changed, 209 insertions(+), 22 deletions(-) create mode 100644 test/unit/dispenser_purge_grace.test.js diff --git a/src/XChainDecoder/block_store.js b/src/XChainDecoder/block_store.js index fc476101..bb04d7e6 100644 --- a/src/XChainDecoder/block_store.js +++ b/src/XChainDecoder/block_store.js @@ -14,7 +14,7 @@ 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') @@ -49,7 +49,7 @@ async function retryFailedCommit(loop, nextBlockHeight){ return 'continue' } -async function commitBlockBatch(loop, nextBlockHeight, nextBlockHash){ +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+")") @@ -64,7 +64,9 @@ async function commitBlockBatch(loop, nextBlockHeight, nextBlockHash){ // 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 this.db.purgeExpiredDispensers( + nextBlockHeight - safeDepth, + purgeGraceFloor(this.consensusNetwork, blockTime)) loop.blocksCount = 0 loop.transactionsCount = 0 @@ -110,7 +112,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 diff --git a/src/XChainDecoder/reorg_verification.js b/src/XChainDecoder/reorg_verification.js index bdd870af..1cd8bdc0 100644 --- a/src/XChainDecoder/reorg_verification.js +++ b/src/XChainDecoder/reorg_verification.js @@ -76,8 +76,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 @@ -98,7 +98,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 \"...\"`." diff --git a/src/db/dispenser_queries.js b/src/db/dispenser_queries.js index 588d2b20..59c90b3d 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 @@ -215,12 +216,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 <= ?; @@ -228,7 +241,9 @@ 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 diff --git a/src/protocol/dispenser_cancel_grace.js b/src/protocol/dispenser_cancel_grace.js index ec97c539..1149d958 100644 --- a/src/protocol/dispenser_cancel_grace.js +++ b/src/protocol/dispenser_cancel_grace.js @@ -42,9 +42,9 @@ * 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 +57,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 +103,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/test/unit/dispenser_cancel_grace.test.js b/test/unit/dispenser_cancel_grace.test.js index f96f602d..1c3a699f 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 = @@ -180,7 +184,7 @@ function runTwoBlocks(consensusNetwork, expireAt, payAt, model){ extendOpenDispenserExpirationBySource: (s, e, b) => model.extendOpenDispenserExpirationBySource(s, e, b), deleteOpenDispensers: (b, m) => model.deleteOpenDispensers(b, m), recordDispenserExtensionUndo: async () => true, - purgeExpiredDispensers: (h) => model.purgeExpiredDispensers(h), + purgeExpiredDispensers: (h, f) => model.purgeExpiredDispensers(h, f), getAllOpenDispenserAddresses: (f) => model.getAllOpenDispenserAddresses(f), getOpenDispenserOracleAddressBySource: (s) => model.getOpenDispenserOracleAddressBySource(s), getOpenDispenserOracleAddressesBySource: (s) => model.getOpenDispenserOracleAddressesBySource(s), @@ -228,6 +232,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') @@ -258,6 +266,8 @@ 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') }) diff --git a/test/unit/dispenser_purge_grace.test.js b/test/unit/dispenser_purge_grace.test.js new file mode 100644 index 00000000..c77e00b8 --- /dev/null +++ b/test/unit/dispenser_purge_grace.test.js @@ -0,0 +1,140 @@ +'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(), + 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]) + }) +}) From af1f5627fa574f7267576b6c54521b6be4d35c1f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 00:06:13 -0700 Subject: [PATCH 51/86] Split cancellation grace test suite Keep each Mocha suite callback within the repository readability limit without changing test behavior. --- test/unit/dispenser_cancel_grace.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/unit/dispenser_cancel_grace.test.js b/test/unit/dispenser_cancel_grace.test.js index 1c3a699f..85249835 100644 --- a/test/unit/dispenser_cancel_grace.test.js +++ b/test/unit/dispenser_cancel_grace.test.js @@ -243,6 +243,10 @@ 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) }) +}) + +describe('dispenser cancellation grace: activation gate', function () { + this.timeout(0) 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 From 51e3bd108ce45b3062c3c25394e96cc76469538c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 01:10:36 -0700 Subject: [PATCH 52/86] Pin BIGINT migration checksum rebaseline Keep the complete comment-only checksum transition in the checksum registry so callers see it without depending on migration precondition module load order. Update the executable-residue fixture to the healed whole-file hash. --- src/db/migration_checksum_rebaselines.js | 12 +++++++----- src/db/migration_preconditions.js | 3 --- test/fixtures/migration-executable-residue.json | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) 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_preconditions.js b/src/db/migration_preconditions.js index fe42f8d6..9317ab8f 100644 --- a/src/db/migration_preconditions.js +++ b/src/db/migration_preconditions.js @@ -202,9 +202,6 @@ Database.backdatedFrontierViolation = function(pendingName, appliedNames){ Database.DEPLOY_PRECONDITION_TAG = 'deploy-precondition=required'; const BIGINT_ID_MIGRATION = '2026-06-02-widen-ids-to-bigint.sql'; -const bigintIdRebaseline = Database.MIGRATION_CHECKSUM_REBASELINES[BIGINT_ID_MIGRATION]; -bigintIdRebaseline.from = [].concat(bigintIdRebaseline.from, bigintIdRebaseline.to); -bigintIdRebaseline.to = '33423de08d0869d7a81829301668383ee3cb47e7226bff587fcfcbf9fa0c7b1c'; // Migrations this tree ASSERTS at startup: the service refuses to run when the // target database has not applied them. diff --git a/test/fixtures/migration-executable-residue.json b/test/fixtures/migration-executable-residue.json index b4fb2e93..bc33212a 100644 --- a/test/fixtures/migration-executable-residue.json +++ b/test/fixtures/migration-executable-residue.json @@ -9,7 +9,7 @@ }, "2026-06-02-widen-ids-to-bigint.sql": { "residue": "51d74c7078068b010db2decf5dad242094b218049be6cb232aea84853121cbd0", - "sha256": "b03b41b6fcabef9c959851ede9b75cc9089cef7c015bdd69cfcea74ad5acea7a" + "sha256": "33423de08d0869d7a81829301668383ee3cb47e7226bff587fcfcbf9fa0c7b1c" }, "2026-06-13-dispensers-expiration-bigint.sql": { "residue": "3646f568c94a54e9fa18d7e484818fc8e28651aa83fa90c0198ee39cfabfd06b", From 7513f7211bd6ada67aaf1ff6379aaa3fb790d213 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 01:15:20 -0700 Subject: [PATCH 53/86] Preserve cancellation grace test titles Extract the two registrations into helpers so the suite remains under the structure limit without changing its pinned full test titles. --- test/unit/dispenser_cancel_grace.test.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/unit/dispenser_cancel_grace.test.js b/test/unit/dispenser_cancel_grace.test.js index 85249835..0c041c5b 100644 --- a/test/unit/dispenser_cancel_grace.test.js +++ b/test/unit/dispenser_cancel_grace.test.js @@ -209,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 @@ -243,11 +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) }) -}) - -describe('dispenser cancellation grace: activation gate', function () { - this.timeout(0) +} +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 @@ -275,6 +271,12 @@ describe('dispenser cancellation grace: activation gate', function () { 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 () { From 25760230bb5b9522b9c90a4de8a35cd46fbca905 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 02:20:17 -0700 Subject: [PATCH 54/86] Require unsigned transaction BIGINT columns Inspect COLUMN_TYPE in the transaction ID startup assertion so signed BIGINT drift fails closed. Cover the signed-column case and align the deploy symptom with the schema contract. --- src/db/database_setup.js | 19 +++++++++++---- src/db/migration_preconditions.js | 2 +- .../migration_preconditions_bigint.test.js | 23 +++++++++++++------ 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/db/database_setup.js b/src/db/database_setup.js index 3815e0fb..d0205c34 100644 --- a/src/db/database_setup.js +++ b/src/db/database_setup.js @@ -295,7 +295,7 @@ module.exports = { try { conn = await this.getConnection(); const rows = await conn.query( - "SELECT 1 AS tableExists, c.COLUMN_NAME AS col, c.DATA_TYPE AS dataType " + + "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 " + @@ -306,13 +306,22 @@ module.exports = { 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(), String(row.dataType || '').toLowerCase()); + 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 => types.get(col) !== 'bigint'); + 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 => col + '=' + (types.get(col) || 'missing')).join(', '); + 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, but found ' + found + '. Run the pending migration: ' + + 'transactions id columns must use BIGINT UNSIGNED, but found ' + found + '. Run the pending migration: ' + 'node src/db/migrate.js --file ' + Database.startupAssertedMigrationFile('assertTransactionIdsAreBigint') ); diff --git a/src/db/migration_preconditions.js b/src/db/migration_preconditions.js index 9317ab8f..a286f72c 100644 --- a/src/db/migration_preconditions.js +++ b/src/db/migration_preconditions.js @@ -231,7 +231,7 @@ Database.STARTUP_ASSERTED_MIGRATIONS = [ { file: BIGINT_ID_MIGRATION, assertion: 'assertTransactionIdsAreBigint', - symptom: 'Fatal decoder error: transactions id columns must use BIGINT' + symptom: 'Fatal decoder error: transactions id columns must use BIGINT UNSIGNED' }, { file: '2026-06-13-dispensers-expiration-bigint.sql', diff --git a/test/unit/migration_preconditions_bigint.test.js b/test/unit/migration_preconditions_bigint.test.js index a8ffe41b..3809f6ce 100644 --- a/test/unit/migration_preconditions_bigint.test.js +++ b/test/unit/migration_preconditions_bigint.test.js @@ -21,11 +21,11 @@ const Database = require('../../src/db'); describe('assertTransactionIdsAreBigint @regression @tier1', function () { const columns = [ - { col: 'tx_index', dataType: 'bigint' }, - { col: 'tx_hash_id', dataType: 'bigint' }, - { col: 'block_index', dataType: 'bigint' }, - { col: 'source_id', dataType: 'bigint' }, - { col: 'destination_id', dataType: 'bigint' } + { 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) { @@ -43,7 +43,7 @@ describe('assertTransactionIdsAreBigint @regression @tier1', function () { let message = null; try { await Database.prototype.assertTransactionIdsAreBigint.call(ctxReturning([ - { ...columns[0], dataType: 'int' }, + { ...columns[0], dataType: 'int', columnType: 'int(10) unsigned' }, ...columns.slice(1) ])); } catch (err) { @@ -54,7 +54,16 @@ describe('assertTransactionIdsAreBigint @regression @tier1', function () { 'the halt message must name the migration; got: ' + message); }); - it('accepts the complete transactions id set at BIGINT', async function () { + 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)); }); From 13bdcce02fb27cefbd4cf989d9a97b6106f636f4 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 02:29:16 -0700 Subject: [PATCH 55/86] Split BIGINT migration guard tests Keep each test callback within the structure limit while retaining shared fixtures and the full startup assertion coverage. --- .../migration_preconditions_bigint.test.js | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/test/unit/migration_preconditions_bigint.test.js b/test/unit/migration_preconditions_bigint.test.js index 3809f6ce..f2c5af34 100644 --- a/test/unit/migration_preconditions_bigint.test.js +++ b/test/unit/migration_preconditions_bigint.test.js @@ -18,26 +18,26 @@ const assert = require('assert'); const Database = require('../../src/db'); -describe('assertTransactionIdsAreBigint @regression @tier1', function () { +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' } +]; - 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 () => {} + }) + }; +} - 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; @@ -66,7 +66,9 @@ describe('assertTransactionIdsAreBigint @regression @tier1', function () { 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))), From 1dfa037fcc0560e2126db2ecc9aeb92bc45ffd60 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 02:33:38 -0700 Subject: [PATCH 56/86] Enforce newest-first protocol time windows Slice the height-descending history window before timestamp conversion and filtering, then take its value median. Cover older future-dated entries and unusable values at the window boundary. --- src/protocol/protocol_time.js | 26 ++++++++------------------ test/unit/protocol_time.test.js | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/protocol/protocol_time.js b/src/protocol/protocol_time.js index 3fa58369..c65474eb 100644 --- a/src/protocol/protocol_time.js +++ b/src/protocol/protocol_time.js @@ -59,34 +59,24 @@ function isProtocolTimeMtpActive(network){ // The median of the previous MEDIAN_TIME_SPAN block timestamps, Bitcoin-style. // -// CALLER CONTRACT. `previousBlockTimes` is the timestamps of the blocks BELOW -// the one being resolved, NEWEST FIRST by block height, and at most -// MEDIAN_TIME_SPAN long. The one real caller, fetchPreviousBlockTimes in -// XChainDecoder/block_store.js, provides both guarantees. -// -// This function selects the newest entries by VALUE, not by position. For a -// window within the span the two are the same set, so the result is the Bitcoin -// median whatever the order. A longer window on a chain whose stamps are not -// monotonic would pick a different set than "the 11 blocks below", so the width -// bound belongs to the caller and a wider read is a contract violation, not a -// convenience. 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/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) From 8bf53fb34253237ff1d1ac3bdc850f647061bc41 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 03:18:45 -0700 Subject: [PATCH 57/86] Declare startup registry title rename Map the pinned three-migration suite title to the four-migration wording introduced by the BIGINT startup assertion. This keeps the title comparator focused on real drops while retaining additive test growth. --- bin/pins/suite-title-renames.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bin/pins/suite-title-renames.json b/bin/pins/suite-title-renames.json index f9b92024..3f5e7484 100644 --- a/bin/pins/suite-title-renames.json +++ b/bin/pins/suite-title-renames.json @@ -8,6 +8,9 @@ "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)" } From 30c1792c1ad32126e1223d0b5fe639476335c411 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 03:24:18 -0700 Subject: [PATCH 58/86] Add dispenser address key helper Mirror address collation by folding ASCII case and trimming trailing spaces. Cover significant leading and non-ASCII characters with focused unit tests. --- .../dispenser_address_key.js | 21 ++++++++++ test/unit/dispenser_address_key.test.js | 38 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/protocol/batch_sub_command_capture/dispenser_address_key.js create mode 100644 test/unit/dispenser_address_key.test.js 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/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') + }) +}) From a56d5a9bb2b8033fc7f291f75abd2f3ff78c7563 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 03:45:35 -0700 Subject: [PATCH 59/86] Add keyed dispenser registration collapse Introduce an unwired pure helper that optionally groups dispenser registrations through a caller-supplied key while preserving the existing merge behavior. Cover raw-key compatibility, keyed merging, fallbacks, defensive inputs, and immutability. --- .../dispenser_registration_collapse.js | 46 ++++++++++++ .../13_collapse_registrations_by_key.test.js | 74 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 src/protocol/dispenser_registration_collapse.js create mode 100644 test/unit/batch_dispenser_registration.test/13_collapse_registrations_by_key.test.js 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/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) + }) +}) From a5cdf69d8e3afdb32c22abafbfb21c0cf5e4f6ec Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 07:24:40 -0700 Subject: [PATCH 60/86] Add dispenser address ID collapse activation Mirror the canonical activation map and enforce declaration-level parity with documentation. --- src/protocol/constants.js | 1 + src/protocol/constants/activation.js | 7 +++ ...ser_address_id_collapse_activation.test.js | 62 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 test/unit/dispenser_address_id_collapse_activation.test.js diff --git a/src/protocol/constants.js b/src/protocol/constants.js index f7c259db..adf5e6b8 100644 --- a/src/protocol/constants.js +++ b/src/protocol/constants.js @@ -103,6 +103,7 @@ module.exports = { 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, 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, diff --git a/src/protocol/constants/activation.js b/src/protocol/constants/activation.js index 0aa651a4..f12f2234 100644 --- a/src/protocol/constants/activation.js +++ b/src/protocol/constants/activation.js @@ -360,6 +360,12 @@ const DISPENSER_PURGE_GRACE_ACTIVATION = { regtest: 0, }; +const DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION = { + mainnet: 9999999999, + testnet: 9999999999, + 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 @@ -510,6 +516,7 @@ module.exports = { DISPENSER_EXPIRY_REALIGN_ACTIVATION, DISPENSER_CANCEL_GRACE_ACTIVATION, DISPENSER_PURGE_GRACE_ACTIVATION, + DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION, BATCH_SUBCOMMAND_OUTPUT_CAPTURE_ACTIVATION, ENVELOPE_RECOGNITION_ACTIVATION, ENVELOPE_CARRIER_RECOGNITION_ACTIVATION, 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)); + }); +}); From df5e6b8e20b7a500a46057ce374ed1098480d45f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 07:26:29 -0700 Subject: [PATCH 61/86] Gate dispenser registration collapse by address id Use the dispensers table collation key after activation while preserving raw-address grouping for historical replay. Thread network and block time through ingestion and cover both the pure rule and block-loop behavior. --- src/XChainDecoder/transaction_ingest.js | 2 +- src/protocol/batch_sub_command_capture.js | 22 +++++- ...atch_dispenser_address_id_collapse.test.js | 76 +++++++++++++++++++ .../unit/batch_dispenser_registration.test.js | 26 +++++++ 4 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 test/unit/batch_dispenser_address_id_collapse.test.js diff --git a/src/XChainDecoder/transaction_ingest.js b/src/XChainDecoder/transaction_ingest.js index 484a0e3c..9748363d 100644 --- a/src/XChainDecoder/transaction_ingest.js +++ b/src/XChainDecoder/transaction_ingest.js @@ -245,7 +245,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' } 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/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..8d8b7fc0 100644 --- a/test/unit/batch_dispenser_registration.test.js +++ b/test/unit/batch_dispenser_registration.test.js @@ -51,6 +51,7 @@ 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) @@ -109,4 +110,29 @@ describe('BATCH dispenser registration', function () { } }) }) + + describe('address-id collapse activation', function () { + + 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 + } + }) + }) }) From 49ed46d727cec7e7ef61a9b8ca93ca03c1ad087f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 07:27:28 -0700 Subject: [PATCH 62/86] Split dispenser registration test declarations Keep each test declaration helper within the repository readability limit without changing coverage. --- .../unit/batch_dispenser_registration.test.js | 149 ++++++++++-------- 1 file changed, 79 insertions(+), 70 deletions(-) diff --git a/test/unit/batch_dispenser_registration.test.js b/test/unit/batch_dispenser_registration.test.js index 8d8b7fc0..ce388210 100644 --- a/test/unit/batch_dispenser_registration.test.js +++ b/test/unit/batch_dispenser_registration.test.js @@ -53,86 +53,95 @@ const { } = 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('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)) + } + }) - it('registers a top-level create above the gate', async () => { - const decoder = await runOne(create({ oracle: ORACLE_A }), ABOVE_GATE) + 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.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].address, DELEGATE_A) + assert.strictEqual(decoder.model.rows[0].sourceAddress, SOURCE) + } + }) +} - 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 }]) - }) +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('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)) - } - }) + 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, []) + } + }) +} - 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) - } - }) +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 + } + }) +} - 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('BATCH dispenser registration', function () { + this.timeout(0) - 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('a top-level DISPENSER is untouched on both sides of the gate', function () { + defineTopLevelCreateTests() + defineTopLevelEditTests() }) describe('address-id collapse activation', function () { - - 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 - } - }) + defineAddressIdCollapseTests() }) }) From 047268d50368c4c765856067fa20fed0300617bd Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 07:48:17 -0700 Subject: [PATCH 63/86] Re-vendor the action manifest so BET format 4 matches the canonical copy. --- test/fixtures/action-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fixtures/action-manifest.json b/test/fixtures/action-manifest.json index 8f152a7e..de109de1 100644 --- a/test/fixtures/action-manifest.json +++ b/test/fixtures/action-manifest.json @@ -75,7 +75,7 @@ "category": "wire-user", "wireDecoded": true, "userEncodable": true, - "userEncodableVersions": [0, 1, 2, 3], + "userEncodableVersions": [0, 1, 2, 3, 4], "indexerHandled": true, "explorerRender": true, "walletForm": true From dc995883e8c8fd71b987577b1040b117bff91965 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 08:11:50 -0700 Subject: [PATCH 64/86] Repin the vendored action manifest hash after the BET format 4 re-vendor. --- bin/pins/identity.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/pins/identity.json b/bin/pins/identity.json index 2d408ab6..0219e864 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -9,7 +9,7 @@ "src/coins/index.js": "08b678d934fa647d3c7d14dc5ccdaeb86722ee32a3662de69e4a31cd7b09234c" }, "twinFixtures": { - "test/fixtures/action-manifest.json": "920779b5f20ee489bd02268e7a18065d44e52bd321076a34d6da41714396ec14", + "test/fixtures/action-manifest.json": "4688a06f42b51fed24d0ea7695e75cc947c1d22ee6a2d855d48f8d7469717d59", "test/fixtures/roundtrip-conformance.json": "d9963ac7c35bdcfab99595fec040d7791d73119cb62fef20db136f8ea56236ed" } } From 57c915c6336f065975cab0dc8eae1ec1f197efab Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 09:42:44 -0700 Subject: [PATCH 65/86] fix: review round corrections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Decoder ignores the false return from purgeExpiredDispensers after commit - Auto-migration safety check lets a storage-engine change through: `ALTER TABLE ... ENGINE=MyISAM/Aria/MEMORY` passes as safe and runs unattended at st - Auto-migration DROP check normalises backtick-quoted column names: `ALTER TABLE t DROP `key`` is treated as a safe metadata-only drop - ENVELOPE_CARRIER_RECOGNITION_ACTIVATION (decoder-only, fleet-forking §3.8 gate) has no standalone literal pin, unlike its sibling ENVELOPE_RECOGNITION --- src/XChainDecoder.js | 1 + src/XChainDecoder/block_store.js | 26 ++- src/XChainDecoder/startup.js | 17 +- src/XChainDecoder/transaction_ingest.js | 68 +++++--- src/api.js | 1 + src/db/migration_statements.js | 36 ++-- src/db/query_helpers.js | 26 +++ src/metrics/decoder_metrics.js | 4 +- .../dispenser_purge_failure_count.test.js | 85 ++++++++++ test/unit/dispenser_purge_grace.test.js | 2 + .../14_destructive_guard_engine.test.js | 69 ++++++++ .../15_destructive_guard_drop_target.test.js | 71 ++++++++ test/unit/parse_loop_quarantine.test.js | 156 ++++++++++++++++++ .../05_constants_conformance.test.js | 11 ++ 14 files changed, 508 insertions(+), 65 deletions(-) create mode 100644 test/unit/dispenser_purge_failure_count.test.js create mode 100644 test/unit/migration_runner.test/14_destructive_guard_engine.test.js create mode 100644 test/unit/migration_runner.test/15_destructive_guard_drop_target.test.js diff --git a/src/XChainDecoder.js b/src/XChainDecoder.js index 8c498403..f9bd9ff5 100644 --- a/src/XChainDecoder.js +++ b/src/XChainDecoder.js @@ -209,6 +209,7 @@ function initializeDecoderMode(decoder) { decoder.rpcErrors = 0 decoder.parseErrors = 0 + decoder.dispenserPurgeFailures = 0 } function initializeDecoderReorg(decoder) { diff --git a/src/XChainDecoder/block_store.js b/src/XChainDecoder/block_store.js index bb04d7e6..43212adf 100644 --- a/src/XChainDecoder/block_store.js +++ b/src/XChainDecoder/block_store.js @@ -17,7 +17,7 @@ const { isDispenserExpiryRealignActive } = require('../protocol/dispenser_expiry 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,6 +49,18 @@ async function retryFailedCommit(loop, nextBlockHeight){ return 'continue' } +// 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+")") @@ -59,14 +71,10 @@ async function commitBlockBatch(loop, nextBlockHeight, nextBlockHash, blockTime) 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, - purgeGraceFloor(this.consensusNetwork, blockTime)) + await purgeExpiredAfterCommit.call(this, nextBlockHeight, blockTime) loop.blocksCount = 0 loop.transactionsCount = 0 @@ -93,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 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/transaction_ingest.js b/src/XChainDecoder/transaction_ingest.js index 9748363d..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 @@ -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,7 +311,7 @@ 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 @@ -312,4 +326,4 @@ async function ingestTransaction(loop, block, nextBlockHeight, openDispenserAddr } } -module.exports = { ingestTransaction } +module.exports = { ingestTransaction, clearQuarantineState } diff --git a/src/api.js b/src/api.js index d9348c98..dcf6ffd3 100644 --- a/src/api.js +++ b/src/api.js @@ -119,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 } } diff --git a/src/db/migration_statements.js b/src/db/migration_statements.js index b612f344..b1cfad5a 100644 --- a/src/db/migration_statements.js +++ b/src/db/migration_statements.js @@ -12,7 +12,7 @@ * **********************************************************************/ -const { opensBackslashEscape } = require('./query_helpers.js') +const { opensBackslashEscape, createNamesNonInnoDb, dropTargetDestroys } = require('./query_helpers.js') const { ensureLedgerAppliedAtDatetime } = require('./migration_ledger.js') // True when a `#` sits outside every quoted span - a line comment @@ -95,13 +95,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); } @@ -115,13 +115,11 @@ 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; @@ -197,19 +195,19 @@ module.exports = { // (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 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 + // 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), 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. 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/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/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 index c77e00b8..276ae90c 100644 --- a/test/unit/dispenser_purge_grace.test.js +++ b/test/unit/dispenser_purge_grace.test.js @@ -58,6 +58,8 @@ function emptyLoop(){ validTransactionsCount: 0, outputCount: 0, insertQuarantine: new Set(), + txParseRetryCounts: new Map(), + insertQuarantineCounts: new Map(), startTimeStamp: Date.now(), } } 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/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/taproot_envelope.test/05_constants_conformance.test.js b/test/unit/taproot_envelope.test/05_constants_conformance.test.js index 16efd7cd..ead48b64 100644 --- a/test/unit/taproot_envelope.test/05_constants_conformance.test.js +++ b/test/unit/taproot_envelope.test/05_constants_conformance.test.js @@ -154,6 +154,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 }, + }) + }) }) }) From c0fe2c4b7c031360cbc5d0631e85e6d5eebca990 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 09:56:25 -0700 Subject: [PATCH 66/86] fix: review round corrections - Keep-in-sync pointers for the AuxPoW codec and getBlockReassembled name the wrong files in both repos --- src/chain/blockchain_connector/auxpow_codec.js | 8 ++++---- src/chain/blockchain_connector/block_queries.js | 1 + test/unit/auxpow_strip_parity.test.js | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) 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 89a94b3d..743fee0f 100644 --- a/src/chain/blockchain_connector/block_queries.js +++ b/src/chain/blockchain_connector/block_queries.js @@ -110,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 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`) } }) From bdf77d1e4573a5f7d6e303d5375962dc85cc58e3 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 22:15:17 -0700 Subject: [PATCH 67/86] fix: preserve stale mempool read metadata Keep the last successful read timestamp when refreshes fail. Mark fallback responses stale and retry without extending the cache TTL. --- src/api.js | 18 +++-- test/unit/getmempool_stale_read.test.js | 101 ++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 test/unit/getmempool_stale_read.test.js diff --git a/src/api.js b/src/api.js index dcf6ffd3..4f98ee57 100644 --- a/src/api.js +++ b/src/api.js @@ -239,28 +239,34 @@ function createMempoolMethods(decoder){ 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(); + 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, 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 => ({ + 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, // TEXT can come back as a Buffer depending on driver options; @@ -381,4 +387,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, startApi, createMempoolMethods, noteProbeFailure, nodeReachabilityFields, resetProbeLogState, ageProbeLogState, PROBE_LOG_WINDOW_MS } diff --git a/test/unit/getmempool_stale_read.test.js b/test/unit/getmempool_stale_read.test.js new file mode 100644 index 00000000..0225adcd --- /dev/null +++ b/test/unit/getmempool_stale_read.test.js @@ -0,0 +1,101 @@ +/********************************************************************* + * + * 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'); + +describe('getmempool stale reads', function () { + let clock; + let previousTtl; + + 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', async function () { + const firstRow = { tx_hash: 'aa', source: 'alice', data: 'first', first_seen: 11 }; + const recoveredRow = { tx_hash: 'bb', source: 'bob', data: 'second', first_seen: 22 }; + const getRows = sinon.stub(); + getRows.onCall(0).resolves([firstRow]); + getRows.onCall(1).rejects(new Error('read failed')); + getRows.onCall(2).resolves([recoveredRow]); + const getTotal = sinon.stub(); + getTotal.onCall(0).resolves(7); + getTotal.onCall(1).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(recovered.stale, false); + assert.strictEqual(recovered.read_ok_at, 6000); + assert.strictEqual(recovered.total, 8); + assert.strictEqual(recovered.rows[0].tx_hash, 'bb'); + }); + + it('reports a stale empty fallback when the first read fails without a prior good timestamp', async function () { + 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); + }); +}); From 278209d671614673eb7faad696222c8dd23c71c8 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 22:29:31 -0700 Subject: [PATCH 68/86] refactor: split mempool stale read helpers Keep cache refresh and response shaping below structure limits. Extract named test cases while preserving stale retry coverage. --- src/api.js | 56 ++++++---- test/unit/getmempool_stale_read.test.js | 133 ++++++++++++------------ 2 files changed, 101 insertions(+), 88 deletions(-) diff --git a/src/api.js b/src/api.js index 4f98ee57..462d5451 100644 --- a/src/api.js +++ b/src/api.js @@ -215,6 +215,35 @@ function createJsonRpcController(decoder, isDecoderRunning, getDecoderError){ // getmempool's shared snapshot cache (see the method's comment). Held here so // every request, whatever its limit, slices one cached 500-row window. +async function readMempoolSnapshot(db, now){ + const rows = await db.getMempoolTransactions(500); + const total = await db.getMempoolTransactionCount(); + return { t: now, rows, total, read_ok_at: Date.now() }; +} +function emptyMempoolSnapshot(now){ + return { t: now, rows: [], total: 0, read_ok_at: null }; +} +function normalizeMempoolRow(row){ + return { + tx_hash: row.tx_hash, + source: row.source, + // Normalize driver Buffers and invalid SQL timestamps for JSON. + data: Buffer.isBuffer(row.data) ? row.data.toString('utf8') : row.data, + first_seen: Number.isFinite(row.first_seen) ? row.first_seen : null + }; +} +function buildMempoolResult(decoder, snapshot, params, stale){ + 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, + stale, + read_ok_at: snapshot.read_ok_at, + total: snapshot.total, + rows: snapshot.rows.slice(0, limit).map(normalizeMempoolRow) + }; +} + function createMempoolMethods(decoder){ let getmempoolCache = null; return { @@ -242,12 +271,9 @@ function createMempoolMethods(decoder){ 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(); - getmempoolCache = { t: now, rows, total, read_ok_at: Date.now() }; + getmempoolCache = await readMempoolSnapshot(db, now); } catch (err) { // Serve the stale snapshot if we have one; a mempool read // must never surface as an API error to remote explorers. @@ -255,27 +281,11 @@ function createMempoolMethods(decoder){ stale = true; } } else { - getmempoolCache = { t: now, rows, total, read_ok_at: null }; + getmempoolCache = emptyMempoolSnapshot(now); } - snapshot = getmempoolCache || { rows: [], total: 0, read_ok_at: null }; + snapshot = getmempoolCache || emptyMempoolSnapshot(now); } - 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, - 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, - // 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, - // Unix seconds read in SQL (see getMempoolTransactions), never a driver Date. - first_seen: Number.isFinite(r.first_seen) ? r.first_seen : null - })) - }; + return buildMempoolResult(decoder, snapshot, params, stale); } } } diff --git a/test/unit/getmempool_stale_read.test.js b/test/unit/getmempool_stale_read.test.js index 0225adcd..c5bc57d3 100644 --- a/test/unit/getmempool_stale_read.test.js +++ b/test/unit/getmempool_stale_read.test.js @@ -18,10 +18,73 @@ const assert = require('assert'); const sinon = require('sinon'); const { createMempoolMethods } = require('../../src/api'); -describe('getmempool stale reads', function () { - let clock; - let previousTtl; +let clock; +let previousTtl; + +async function servesStaleSnapshotAndRetries(){ + const firstRow = { tx_hash: 'aa', source: 'alice', data: 'first', first_seen: 11 }; + const recoveredRow = { tx_hash: 'bb', source: 'bob', data: 'second', first_seen: 22 }; + const getRows = sinon.stub(); + getRows.onCall(0).resolves([firstRow]); + getRows.onCall(1).rejects(new Error('read failed')); + getRows.onCall(2).resolves([recoveredRow]); + const getTotal = sinon.stub(); + getTotal.onCall(0).resolves(7); + getTotal.onCall(1).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(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'; @@ -36,66 +99,6 @@ describe('getmempool stale reads', function () { else process.env.GETMEMPOOL_CACHE_MS = previousTtl; }); - it('serves the last good snapshot as stale and retries immediately after a failed refresh', async function () { - const firstRow = { tx_hash: 'aa', source: 'alice', data: 'first', first_seen: 11 }; - const recoveredRow = { tx_hash: 'bb', source: 'bob', data: 'second', first_seen: 22 }; - const getRows = sinon.stub(); - getRows.onCall(0).resolves([firstRow]); - getRows.onCall(1).rejects(new Error('read failed')); - getRows.onCall(2).resolves([recoveredRow]); - const getTotal = sinon.stub(); - getTotal.onCall(0).resolves(7); - getTotal.onCall(1).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(recovered.stale, false); - assert.strictEqual(recovered.read_ok_at, 6000); - assert.strictEqual(recovered.total, 8); - assert.strictEqual(recovered.rows[0].tx_hash, 'bb'); - }); - - it('reports a stale empty fallback when the first read fails without a prior good timestamp', async function () { - 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); - }); + 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); }); From d5ba2f682fc6d9a5831588e6936a6b6d77f1470a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 22:39:40 -0700 Subject: [PATCH 69/86] test: prove stale mempool fallback behavior Keep the getmempool contract visible to the existing source checks. Exercise a partial refresh failure and verify the prior timestamp and complete snapshot survive before an immediate retry. --- src/api.js | 52 +++++++++---------------- test/unit/getmempool_stale_read.test.js | 7 +++- 2 files changed, 24 insertions(+), 35 deletions(-) diff --git a/src/api.js b/src/api.js index 462d5451..9609e0b4 100644 --- a/src/api.js +++ b/src/api.js @@ -215,35 +215,6 @@ function createJsonRpcController(decoder, isDecoderRunning, getDecoderError){ // getmempool's shared snapshot cache (see the method's comment). Held here so // every request, whatever its limit, slices one cached 500-row window. -async function readMempoolSnapshot(db, now){ - const rows = await db.getMempoolTransactions(500); - const total = await db.getMempoolTransactionCount(); - return { t: now, rows, total, read_ok_at: Date.now() }; -} -function emptyMempoolSnapshot(now){ - return { t: now, rows: [], total: 0, read_ok_at: null }; -} -function normalizeMempoolRow(row){ - return { - tx_hash: row.tx_hash, - source: row.source, - // Normalize driver Buffers and invalid SQL timestamps for JSON. - data: Buffer.isBuffer(row.data) ? row.data.toString('utf8') : row.data, - first_seen: Number.isFinite(row.first_seen) ? row.first_seen : null - }; -} -function buildMempoolResult(decoder, snapshot, params, stale){ - 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, - stale, - read_ok_at: snapshot.read_ok_at, - total: snapshot.total, - rows: snapshot.rows.slice(0, limit).map(normalizeMempoolRow) - }; -} - function createMempoolMethods(decoder){ let getmempoolCache = null; return { @@ -273,7 +244,9 @@ function createMempoolMethods(decoder){ if (!getmempoolCache || (now - getmempoolCache.t) >= ttl) { if (db) { try { - getmempoolCache = await readMempoolSnapshot(db, now); + 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. @@ -281,11 +254,24 @@ function createMempoolMethods(decoder){ stale = true; } } else { - getmempoolCache = emptyMempoolSnapshot(now); + getmempoolCache = { t: now, rows: [], total: 0, read_ok_at: null }; } - snapshot = getmempoolCache || emptyMempoolSnapshot(now); + snapshot = getmempoolCache || { rows: [], total: 0, read_ok_at: null }; } - return buildMempoolResult(decoder, snapshot, params, stale); + 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, + 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 + })) + }; } } } diff --git a/test/unit/getmempool_stale_read.test.js b/test/unit/getmempool_stale_read.test.js index c5bc57d3..3cb07039 100644 --- a/test/unit/getmempool_stale_read.test.js +++ b/test/unit/getmempool_stale_read.test.js @@ -23,14 +23,16 @@ 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).rejects(new Error('read failed')); + getRows.onCall(1).resolves([partialRow]); getRows.onCall(2).resolves([recoveredRow]); const getTotal = sinon.stub(); getTotal.onCall(0).resolves(7); - getTotal.onCall(1).resolves(8); + getTotal.onCall(1).rejects(new Error('count failed')); + getTotal.onCall(2).resolves(8); const decoder = { nodeMempoolTxCount: 12, nodeMempoolUpdatedAt: 900, @@ -56,6 +58,7 @@ async function servesStaleSnapshotAndRetries(){ 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); From cb29fbb2de96eb5cac895290ba5d0db13b395b97 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 8 Oct 2026 22:54:49 -0700 Subject: [PATCH 70/86] test: enforce mempool stale fallback in api suite Add an independent runtime assertion to the required API surface suite so failed refreshes must remain stale and retain the last successful timestamp and snapshot. --- test/unit/mempool_api_surface.test.js | 56 ++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/test/unit/mempool_api_surface.test.js b/test/unit/mempool_api_surface.test.js index 820ca65b..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( @@ -148,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'); From 040fca46dc57708dcbcd8b3f8c9016bd7a645675 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 9 Oct 2026 09:50:01 -0700 Subject: [PATCH 71/86] Fix 12 review findings in xchain-decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dispenser EXPIRATION accepted range differs between decoder (≤2^53-1) and indexer (≤2^64-1); the decoder comment's claim that they match is false - Action manifest labels DISPENSER_CANCEL/DISPENSER_EDIT as legacy with no indexer twin, but the indexer writes them as live action names - Testnet: the indexer can still settle fills on a cancelled dispenser after the decoder has stopped capturing payments to it - Decoder reorg test lists its deleted tables but nothing classifies every src/sql table; pubkeys (source of the indexer's consensus source_pubkey) is u - Decoder cross-repo constant guards use bare fs.existsSync instead of siblingCheckout, so they can pass against a peer's uncommitted main checkout - xchain-decoder auto-migration guard has the same quote-blind /* */ strip, so destructive clauses hidden between string literals auto-apply - Live-column guard lets an auto MODIFY that omits CHARACTER SET quietly switch a utf8mb4 column back to the table's utf8mb3 default - Live-schema MODIFY guard lets an auto migration silently narrow DECIMAL, DATETIME/TIMESTAMP and floating-point columns - GET /status publishes `lag` but drops `node_height_stale`, so the node's bootstrap gate can certify a frozen-tip decoder as caught up - getRawTransaction returns any truthy result without a hex-string shape check, unlike getBlockHash and getrawmempool - Non-forceable resync gate fails open on a non-numeric count, while the dispenser gate beside it fails closed - Mempool unattributed-ACTION blanking ignores dispense outputs, so it can blank an ACTION the block path would store --- bin/pins/identity.json | 2 +- src/XChainDecoder/dispenser_registration.js | 22 +-- src/XChainDecoder/mempool_refresh.js | 3 + src/XChainDecoder/reorg_verification.js | 7 +- src/api.js | 5 +- .../transaction_queries.js | 10 +- src/clear_reorg_halt.js | 17 ++- src/db/blocks.js | 1 + src/db/dispenser_queries.js | 3 +- src/db/migration_live_schema_guard.js | 134 ++++++++++++++---- src/db/migration_statements.js | 7 +- src/protocol/dispenser_cancel_grace.js | 7 + test/fixtures/action-manifest.json | 2 +- test/unit/blockchain_connector.test.js | 22 +-- test/unit/blockchain_connector_extra.test.js | 8 +- .../blockchain_connector_review_fixes.test.js | 4 +- .../compiled_push_size_conformance.test.js | 10 +- test/unit/decoder_halt_diagnostics.test.js | 3 +- ..._destructive_guard_quoted_comments.test.js | 82 +++++++++++ .../17_live_schema_guard_types.test.js | 97 +++++++++++++ test/unit/raw_transaction_shape_guard.test.js | 79 +++++++++++ test/unit/reorg_depth_across_restart.test.js | 9 ++ test/unit/reorg_halt_clear.test.js | 22 +++ test/unit/reorg_table_coverage.test.js | 106 ++++++++++++++ test/unit/sibling_coverage.test.js | 5 +- test/unit/status_stale_tip_field.test.js | 84 +++++++++++ .../05_constants_conformance.test.js | 13 +- 27 files changed, 687 insertions(+), 77 deletions(-) create mode 100644 test/unit/migration_runner.test/16_destructive_guard_quoted_comments.test.js create mode 100644 test/unit/migration_runner.test/17_live_schema_guard_types.test.js create mode 100644 test/unit/raw_transaction_shape_guard.test.js create mode 100644 test/unit/reorg_table_coverage.test.js create mode 100644 test/unit/status_stale_tip_field.test.js diff --git a/bin/pins/identity.json b/bin/pins/identity.json index 0219e864..86d19fbb 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -9,7 +9,7 @@ "src/coins/index.js": "08b678d934fa647d3c7d14dc5ccdaeb86722ee32a3662de69e4a31cd7b09234c" }, "twinFixtures": { - "test/fixtures/action-manifest.json": "4688a06f42b51fed24d0ea7695e75cc947c1d22ee6a2d855d48f8d7469717d59", + "test/fixtures/action-manifest.json": "027735095a51e461b902d9ccceaad8d3bb5299ab95c9609139ba85ca9e168f5b", "test/fixtures/roundtrip-conformance.json": "d9963ac7c35bdcfab99595fec040d7791d73119cb62fef20db136f8ea56236ed" } } diff --git a/src/XChainDecoder/dispenser_registration.js b/src/XChainDecoder/dispenser_registration.js index dfdfff47..3b82c86e 100644 --- a/src/XChainDecoder/dispenser_registration.js +++ b/src/XChainDecoder/dispenser_registration.js @@ -220,9 +220,13 @@ function pushV0DispenserCreate(decodedDataSplit, dispenserCreateCandidates, pars // 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. + // the bound Number() holds exactly: at or below it the payload token + // round-trips, so the decoder stores the same value the indexer does, + // and it stays far inside BIGINT UNSIGNED. It is NOT the indexer's + // bound: the indexer accepts any integer EXPIRATION up to 2^64-1 + // (xchain-indexer/src/config/wire_fields.js), so a create between 2^53 + // and 2^64-1 is still this drift, escrowed there and never registered + // here. Closing it changes captured outputs, so it needs an activation. // 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. @@ -335,11 +339,13 @@ function dispenserEditExtension(dispenserCommand, dispenserCommandPrefix, parseR // 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). + // isInteger test. The SAFE-integer ceiling replaces 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). The indexer's own ceiling is u64, so an extend past + // 2^53 still goes unmirrored; the create guard says why that + // gap waits on a decoder activation. if (Number.isSafeInteger(newExpiration) && newExpiration >= 0 && newExpiration > block.timestamp){ return { editSource, newExpiration } diff --git a/src/XChainDecoder/mempool_refresh.js b/src/XChainDecoder/mempool_refresh.js index 130a4458..fa24ba70 100644 --- a/src/XChainDecoder/mempool_refresh.js +++ b/src/XChainDecoder/mempool_refresh.js @@ -91,6 +91,9 @@ function* parseMempoolTransaction(nextTx, nextTransactionHash){ // 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. + +// Diverges for an unattributed ACTION paying an open dispenser: the mempool parse has +// no open-dispenser set, so it blanks one the block path stores on confirm. function admitMempoolAction(parseResult, nextTransactionHash){ const hasAction = (parseResult["data"] != null) && (parseResult["data"].length > 0) if (hasAction && !this.hasStorableContent(parseResult)){ diff --git a/src/XChainDecoder/reorg_verification.js b/src/XChainDecoder/reorg_verification.js index 1cd8bdc0..ad83f63a 100644 --- a/src/XChainDecoder/reorg_verification.js +++ b/src/XChainDecoder/reorg_verification.js @@ -55,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){ diff --git a/src/api.js b/src/api.js index 9609e0b4..4fc8dd3c 100644 --- a/src/api.js +++ b/src/api.js @@ -153,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, @@ -383,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, createMempoolMethods, noteProbeFailure, nodeReachabilityFields, resetProbeLogState, ageProbeLogState, PROBE_LOG_WINDOW_MS } +module.exports = { makeRpcBatchGuard, registerLiveRoute, registerStatusRoute, startApi, createMempoolMethods, noteProbeFailure, nodeReachabilityFields, resetProbeLogState, ageProbeLogState, PROBE_LOG_WINDOW_MS } diff --git a/src/chain/blockchain_connector/transaction_queries.js b/src/chain/blockchain_connector/transaction_queries.js index c9b06e93..4b76fdc6 100644 --- a/src/chain/blockchain_connector/transaction_queries.js +++ b/src/chain/blockchain_connector/transaction_queries.js @@ -16,6 +16,14 @@ const config = require('../../config'); const { logger } = require('./constants.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 // HTTP 200 and a body error object, so axios never throws and the @@ -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 diff --git a/src/clear_reorg_halt.js b/src/clear_reorg_halt.js index 11c9620e..8df51d1f 100644 --- a/src/clear_reorg_halt.js +++ b/src/clear_reorg_halt.js @@ -88,12 +88,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 ' diff --git a/src/db/blocks.js b/src/db/blocks.js index 38dbc003..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 diff --git a/src/db/dispenser_queries.js b/src/db/dispenser_queries.js index 59c90b3d..ad2232c6 100644 --- a/src/db/dispenser_queries.js +++ b/src/db/dispenser_queries.js @@ -71,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. diff --git a/src/db/migration_live_schema_guard.js b/src/db/migration_live_schema_guard.js index a2432a80..3d7159e0 100644 --- a/src/db/migration_live_schema_guard.js +++ b/src/db/migration_live_schema_guard.js @@ -18,24 +18,36 @@ * 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. + * 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. +// 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 = ''; @@ -43,8 +55,11 @@ function splitTopLevel(text){ const ch = text[i]; if(quote){ cur += ch; - if(ch === '\\'){ cur += text[++i] || ''; } - else if(ch === quote) quote = null; + 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; } @@ -60,21 +75,32 @@ function splitTopLevel(text){ const unquote = (id) => id.replace(/^`|`$/g, ''); // Replace each block comment outside a quoted literal with a space, so a comment can neither -// hide a MODIFY nor stand in for a restated attribute. Executable `/*!` and `/*M!` comments -// stay: the server runs them, and the destructive-DDL scan already refuses them. +// 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(ch === '\\'){ out += text[++i] || ''; } - else if(ch === quote) quote = null; + 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; } - const end = ch === '/' && text[i + 1] === '*' && !/^(?:!|M!)/i.test(text.slice(i + 2, i + 4)) ? text.indexOf('*/', i + 2) : -1; - if(end !== -1){ out += ' '; i = end + 1; 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; @@ -118,8 +144,56 @@ function declaredCharset(definition){ return co ? co[1].toLowerCase().split('_')[0] : null; } -function narrowedType(live, next){ - if(!live || !next) return 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; @@ -132,25 +206,20 @@ function narrowedType(live, next){ 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 === 'decimal' && next.name === live.name){ - const [lp, ls = 0] = String(live.args).split(',').map(Number); - const [np, ns = 0] = String(next.args).split(',').map(Number); - return np - ns < lp - ls || ns < ls ? 'decimal(' + live.args + ') -> decimal(' + next.args + ')' : 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 && (live.name in INT_RANK || live.name in TEXT_RANK || live.name in BLOB_RANK || SIZED.has(live.name))){ - const widening = (live.name in INT_RANK && ['decimal', 'bigint'].includes(next.name)) || - (SIZED.has(live.name) && (next.name in TEXT_RANK || next.name in BLOB_RANK)) || - (live.name in TEXT_RANK && next.name === 'longtext'); - return widening ? null : live.name + ' -> ' + next.name; - } - return 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 = []; @@ -174,6 +243,15 @@ function losses(live, definition){ 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; } @@ -182,8 +260,10 @@ function losses(live, definition){ 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 ' + - 'FROM information_schema.columns WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + '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; @@ -197,4 +277,4 @@ async function assertNoLiveColumnLoss(conn, file, statements){ } } -module.exports = { assertNoLiveColumnLoss, modifyClauses, losses }; +module.exports = { assertNoLiveColumnLoss, modifyClauses, losses, stripBlockComments }; diff --git a/src/db/migration_statements.js b/src/db/migration_statements.js index b1cfad5a..5ca5cd2d 100644 --- a/src/db/migration_statements.js +++ b/src/db/migration_statements.js @@ -14,6 +14,7 @@ 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 `#` @@ -222,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/protocol/dispenser_cancel_grace.js b/src/protocol/dispenser_cancel_grace.js index 1149d958..98beaac6 100644 --- a/src/protocol/dispenser_cancel_grace.js +++ b/src/protocol/dispenser_cancel_grace.js @@ -39,6 +39,13 @@ * 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 diff --git a/test/fixtures/action-manifest.json b/test/fixtures/action-manifest.json index de109de1..75584c3c 100644 --- a/test/fixtures/action-manifest.json +++ b/test/fixtures/action-manifest.json @@ -14,7 +14,7 @@ "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), 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": [ diff --git a/test/unit/blockchain_connector.test.js b/test/unit/blockchain_connector.test.js index 4ff0b605..7f1e775e 100644 --- a/test/unit/blockchain_connector.test.js +++ b/test/unit/blockchain_connector.test.js @@ -304,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 () => { @@ -333,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 () => { @@ -345,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) @@ -363,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 () => { @@ -379,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 0f0dd363..2c01b360 100644 --- a/test/unit/blockchain_connector_extra.test.js +++ b/test/unit/blockchain_connector_extra.test.js @@ -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) diff --git a/test/unit/blockchain_connector_review_fixes.test.js b/test/unit/blockchain_connector_review_fixes.test.js index 66ee5672..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') diff --git a/test/unit/compiled_push_size_conformance.test.js b/test/unit/compiled_push_size_conformance.test.js index 498321e0..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]; 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/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/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 f3747008..340453c9 100644 --- a/test/unit/reorg_halt_clear.test.js +++ b/test/unit/reorg_halt_clear.test.js @@ -267,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 () { 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/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 ead48b64..fa61c042 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){ @@ -176,7 +175,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) @@ -273,7 +272,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) From ede802932361c3096b943606bbd328d1f2d0fe40 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 9 Oct 2026 12:53:59 -0700 Subject: [PATCH 72/86] Clamp wide dispenser expirations in decoder Add a gated u64 parser for dispenser create and edit expirations. Preserve legacy rejection below activation and cover unsafe integer, overflow, and conformance boundaries. --- src/XChainDecoder.js | 2 + src/XChainDecoder/dispenser_registration.js | 45 ++--- .../dispenser_wide_expiration.js | 83 +++++++++ src/protocol/constants.js | 2 + .../constants/dispenser_wide_expiration.js | 27 +++ test/unit/dispenser_wide_expiration.test.js | 157 ++++++++++++++++++ .../05_constants_conformance.test.js | 2 + 7 files changed, 287 insertions(+), 31 deletions(-) create mode 100644 src/XChainDecoder/dispenser_wide_expiration.js create mode 100644 src/protocol/constants/dispenser_wide_expiration.js create mode 100644 test/unit/dispenser_wide_expiration.test.js diff --git a/src/XChainDecoder.js b/src/XChainDecoder.js index f9bd9ff5..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); @@ -338,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/dispenser_registration.js b/src/XChainDecoder/dispenser_registration.js index 3b82c86e..ce0121a5 100644 --- a/src/XChainDecoder/dispenser_registration.js +++ b/src/XChainDecoder/dispenser_registration.js @@ -21,6 +21,7 @@ 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 { normalizeDispenserExpiration } = require('./dispenser_wide_expiration.js') //Catch any dispenser message to add it to //the list of possible dispenses. @@ -198,7 +199,7 @@ function pushV0DispenserCreate(decodedDataSplit, dispenserCreateCandidates, pars if (expirationToken === undefined || expirationToken === "") { expiration = this.getDefaultExpiration(block.timestamp) } else { - expiration = Number(expirationToken) + expiration = normalizeDispenserExpiration(expirationToken, this.consensusNetwork, block.timestamp) } // Require an INTEGER, matching the indexer, which rejects any @@ -209,28 +210,17 @@ function pushV0DispenserCreate(decodedDataSplit, dispenserCreateCandidates, pars // 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). - // - // SAFE integer, not merely integer, and no u32 ceiling. The old + // Exact 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 Number() holds exactly: at or below it the payload token - // round-trips, so the decoder stores the same value the indexer does, - // and it stays far inside BIGINT UNSIGNED. It is NOT the indexer's - // bound: the indexer accepts any integer EXPIRATION up to 2^64-1 - // (xchain-indexer/src/config/wire_fields.js), so a create between 2^53 - // and 2^64-1 is still this drift, escrowed there and never registered - // here. Closing it changes captured outputs, so it needs an activation. - // 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) { + // to it was never flagged as a dispense. The normalizer preserves safe + // values exactly. Above its gate, decimal u64 tokens are parsed as BigInt + // and clamped to the largest exact Number, keeping this advisory row open + // for every reachable block without rounding the wire value. + if (expiration === null) { this.parseErrors++ logger.error(`Skipping dispenser in tx ${nextTransactionHash}: invalid expiration value '${decodedDataSplit[V0_EXPIRATION_INDEX]}'`) } else if (this.dispenserOpensForThisChain(giveCoin, getCoin)){ @@ -334,19 +324,12 @@ 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. The SAFE-integer ceiling replaces 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). The indexer's own ceiling is u64, so an extend past - // 2^53 still goes unmirrored; the create guard says why that - // gap waits on a decoder activation. - if (Number.isSafeInteger(newExpiration) && newExpiration >= 0 && + const newExpiration = normalizeDispenserExpiration( + editExpirationToken, this.consensusNetwork, block.timestamp) + // Same exact-or-clamped contract as the create guard above. A + // wide valid edit extends every candidate row to the decoder's + // maximum exact expiration instead of being silently ignored. + if (newExpiration !== null && newExpiration > block.timestamp){ return { editSource, newExpiration } } diff --git a/src/XChainDecoder/dispenser_wide_expiration.js b/src/XChainDecoder/dispenser_wide_expiration.js new file mode 100644 index 00000000..1648ca22 --- /dev/null +++ b/src/XChainDecoder/dispenser_wide_expiration.js @@ -0,0 +1,83 @@ +/********************************************************************* + * + * 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 } = 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 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){ + if (raw === null || raw === undefined) return null + if (typeof raw === 'string' && raw.trim() === '') return null + const asNumber = Number(raw) + 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, + isDispenserWideExpirationActive, + normalizeDispenserExpiration, +} diff --git a/src/protocol/constants.js b/src/protocol/constants.js index adf5e6b8..b7c3d937 100644 --- a/src/protocol/constants.js +++ b/src/protocol/constants.js @@ -33,6 +33,7 @@ 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 @@ -104,6 +105,7 @@ module.exports = { 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_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, diff --git a/src/protocol/constants/dispenser_wide_expiration.js b/src/protocol/constants/dispenser_wide_expiration.js new file mode 100644 index 00000000..eb282850 --- /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_ACTIVATION = { + mainnet: 9999999999, + testnet: 9999999999, + regtest: 0, +}; + +module.exports = { DISPENSER_WIDE_EXPIRATION_ACTIVATION }; diff --git a/test/unit/dispenser_wide_expiration.test.js b/test/unit/dispenser_wide_expiration.test.js new file mode 100644 index 00000000..c932a162 --- /dev/null +++ b/test/unit/dispenser_wide_expiration.test.js @@ -0,0 +1,157 @@ +'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) + }) + + 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/taproot_envelope.test/05_constants_conformance.test.js b/test/unit/taproot_envelope.test/05_constants_conformance.test.js index fa61c042..6acb68c4 100644 --- a/test/unit/taproot_envelope.test/05_constants_conformance.test.js +++ b/test/unit/taproot_envelope.test/05_constants_conformance.test.js @@ -140,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 From 89b2146cd1f57b3a701e80ca2e2000d2e3a2b46e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 9 Oct 2026 12:56:37 -0700 Subject: [PATCH 73/86] Pin declared sibling main checkouts Allow a sibling main checkout only when its declared commit matches HEAD and both its index and worktree are clean. --- test/helpers/sibling_checkout.js | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) 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 }; From 069fc436fa60cc85df0e56cdc2ec5dca5d49e6d5 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 9 Oct 2026 12:57:10 -0700 Subject: [PATCH 74/86] Sync decoder action manifest identity Refresh the vendored action manifest from canonical documentation and update its generated identity pin. --- bin/pins/identity.json | 2 +- test/fixtures/action-manifest.json | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/bin/pins/identity.json b/bin/pins/identity.json index 86d19fbb..6308fb20 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -9,7 +9,7 @@ "src/coins/index.js": "08b678d934fa647d3c7d14dc5ccdaeb86722ee32a3662de69e4a31cd7b09234c" }, "twinFixtures": { - "test/fixtures/action-manifest.json": "027735095a51e461b902d9ccceaad8d3bb5299ab95c9609139ba85ca9e168f5b", + "test/fixtures/action-manifest.json": "2be99c8a8adbcfbeb9d545e8dc42663323cdb15351a39373ccd38fabf48e58ff", "test/fixtures/roundtrip-conformance.json": "d9963ac7c35bdcfab99595fec040d7791d73119cb62fef20db136f8ea56236ed" } } diff --git a/test/fixtures/action-manifest.json b/test/fixtures/action-manifest.json index 75584c3c..028d4c86 100644 --- a/test/fixtures/action-manifest.json +++ b/test/fixtures/action-manifest.json @@ -14,7 +14,7 @@ "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": "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), 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", + "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": [ @@ -80,6 +80,10 @@ "explorerRender": true, "walletForm": true }, + "BET_EDIT": { + "category": "explorer-legacy-render", + "explorerRender": true + }, "BET_EXPIRE": { "category": "lifecycle", "indexerHandled": true, From ed4a3f3ac460673f58c2082d69c5106b73f186b9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 9 Oct 2026 13:04:04 -0700 Subject: [PATCH 75/86] docs(decoder): explain dispatched batch admission Describe the gated dispatch-set check and why decoder-local allowlists remain unsafe across replay eras. --- .../batch_sub_command_capture/sub_commands.js | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) 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 From 434b56c81b9a188a36af3bc6141d3733b5cbf6a0 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 9 Oct 2026 13:04:53 -0700 Subject: [PATCH 76/86] Keep wide expiration gate out of the activation-map rule Rename the gate table so it is not a second activation registry and split the long describe block. --- src/protocol/constants.js | 2 +- src/protocol/constants/dispenser_wide_expiration.js | 4 ++-- test/unit/dispenser_wide_expiration.test.js | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/protocol/constants.js b/src/protocol/constants.js index b7c3d937..a2f3af1c 100644 --- a/src/protocol/constants.js +++ b/src/protocol/constants.js @@ -105,7 +105,7 @@ module.exports = { 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_ACTIVATION, + DISPENSER_WIDE_EXPIRATION_ACTIVATION: dispenserWideExpiration.DISPENSER_WIDE_EXPIRATION_GATES, 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, diff --git a/src/protocol/constants/dispenser_wide_expiration.js b/src/protocol/constants/dispenser_wide_expiration.js index eb282850..25b64713 100644 --- a/src/protocol/constants/dispenser_wide_expiration.js +++ b/src/protocol/constants/dispenser_wide_expiration.js @@ -18,10 +18,10 @@ // 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_ACTIVATION = { +const DISPENSER_WIDE_EXPIRATION_GATES = { mainnet: 9999999999, testnet: 9999999999, regtest: 0, }; -module.exports = { DISPENSER_WIDE_EXPIRATION_ACTIVATION }; +module.exports = { DISPENSER_WIDE_EXPIRATION_GATES }; diff --git a/test/unit/dispenser_wide_expiration.test.js b/test/unit/dispenser_wide_expiration.test.js index c932a162..cfce7bbe 100644 --- a/test/unit/dispenser_wide_expiration.test.js +++ b/test/unit/dispenser_wide_expiration.test.js @@ -101,6 +101,9 @@ describe('DISPENSER wide expiration', function () { 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) From f132e5129d027f3459e26bacb751edea89a3c56f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 9 Oct 2026 13:06:57 -0700 Subject: [PATCH 77/86] Gate exact dispenser expiration validation Preserve legacy Number-based create and edit behavior below an unarmed block-time activation. Above the gate, validate the original expiration token in exact decimal space before registering or extending a dispenser. --- src/XChainDecoder/dispenser_registration.js | 51 ++++++- src/protocol/constants.js | 1 + src/protocol/constants/activation.js | 20 +++ ...dispenser_exact_integer_expiration.test.js | 137 ++++++++++++++++++ 4 files changed, 201 insertions(+), 8 deletions(-) create mode 100644 test/unit/dispenser_exact_integer_expiration.test.js diff --git a/src/XChainDecoder/dispenser_registration.js b/src/XChainDecoder/dispenser_registration.js index 3b82c86e..a6425c41 100644 --- a/src/XChainDecoder/dispenser_registration.js +++ b/src/XChainDecoder/dispenser_registration.js @@ -21,6 +21,39 @@ 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 { EXACT_INTEGER_EXPIRATION_ACTIVATION } = require('../protocol/constants.js') + +function isExactIntegerExpirationActive(consensusNetwork, blockTime){ + const activation = EXACT_INTEGER_EXPIRATION_ACTIVATION[consensusNetwork] + if (typeof activation !== 'number') return false + const t = Number(blockTime) + return Number.isFinite(t) && t >= activation +} + +// Test mathematical integrality from the wire spelling without first rounding through +// Number. This accepts the same decimal and exponent forms as the indexer's numeric +// parser, including integral forms such as 1.0 and 1e3, while rejecting a non-zero +// fractional tail even when it is too small for Number to retain. +function isExactIntegerToken(value){ + const match = String(value).trim().match(/^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/) + if (!match) return false + const whole = match[2] || '' + const fraction = (match[3] !== undefined) ? match[3] : (match[4] || '') + const digits = whole + 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 + if (!isExactIntegerExpirationActive(consensusNetwork, blockTime)) return true + return isExactIntegerToken(expirationToken) +} //Catch any dispenser message to add it to //the list of possible dispenses. @@ -201,10 +234,11 @@ function pushV0DispenserCreate(decodedDataSplit, dispenserCreateCandidates, pars expiration = Number(expirationToken) } - // 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 + // Require an integer. At/above EXACT_INTEGER_EXPIRATION_ACTIVATION the + // wire token is checked before Number conversion, matching the indexer's + // exact integer rule. Below it the legacy Number-based decision remains, + // preserving historical replay. 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 @@ -230,7 +264,7 @@ function pushV0DispenserCreate(decodedDataSplit, dispenserCreateCandidates, pars // 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) { + 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)){ @@ -335,7 +369,7 @@ function dispenserEditExtension(dispenserCommand, dispenserCommandPrefix, parseR if (editSource && editSource.length > 0 && editExpirationToken !== undefined && editExpirationToken !== ""){ const newExpiration = Number(editExpirationToken) - // Same integer contract as the create guard above: the edit + // Same gated 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 @@ -346,7 +380,7 @@ function dispenserEditExtension(dispenserCommand, dispenserCommandPrefix, parseR // escrowed). The indexer's own ceiling is u64, so an extend past // 2^53 still goes unmirrored; the create guard says why that // gap waits on a decoder activation. - if (Number.isSafeInteger(newExpiration) && newExpiration >= 0 && + if (hasValidExpirationToken(editExpirationToken, newExpiration, this.consensusNetwork, block.timestamp) && newExpiration > block.timestamp){ return { editSource, newExpiration } } @@ -395,4 +429,5 @@ async function extendEditedDispenser(extension, nextBlockHeight){ } } -module.exports = { dispenserCommandPrefixFor, collectDispenserCreates, registerDispenser, dispenserEditExtension, extendEditedDispenser } +module.exports = { dispenserCommandPrefixFor, collectDispenserCreates, registerDispenser, dispenserEditExtension, extendEditedDispenser, + isExactIntegerExpirationActive, isExactIntegerToken } diff --git a/src/protocol/constants.js b/src/protocol/constants.js index adf5e6b8..449ade73 100644 --- a/src/protocol/constants.js +++ b/src/protocol/constants.js @@ -104,6 +104,7 @@ module.exports = { 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, + 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, diff --git a/src/protocol/constants/activation.js b/src/protocol/constants/activation.js index f12f2234..d570b14a 100644 --- a/src/protocol/constants/activation.js +++ b/src/protocol/constants/activation.js @@ -366,6 +366,25 @@ const DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION = { 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, +}; + // 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 @@ -517,6 +536,7 @@ module.exports = { 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/test/unit/dispenser_exact_integer_expiration.test.js b/test/unit/dispenser_exact_integer_expiration.test.js new file mode 100644 index 00000000..53947e4d --- /dev/null +++ b/test/unit/dispenser_exact_integer_expiration.test.js @@ -0,0 +1,137 @@ +// 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' + +describe('DISPENSER exact integer EXPIRATION activation', function () { + 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] + }) + + 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) + }) + + 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) + }) + + 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) + }) +}) From b5a7f3931dd8ef4914309d88df70c7b0577412e5 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 9 Oct 2026 13:07:22 -0700 Subject: [PATCH 78/86] Clarify exact expiration validation comment Describe the raw-token check without claiming it runs before the existing Number conversion. --- src/XChainDecoder/dispenser_registration.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/XChainDecoder/dispenser_registration.js b/src/XChainDecoder/dispenser_registration.js index a6425c41..e67c6a87 100644 --- a/src/XChainDecoder/dispenser_registration.js +++ b/src/XChainDecoder/dispenser_registration.js @@ -235,7 +235,7 @@ function pushV0DispenserCreate(decodedDataSplit, dispenserCreateCandidates, pars } // Require an integer. At/above EXACT_INTEGER_EXPIRATION_ACTIVATION the - // wire token is checked before Number conversion, matching the indexer's + // wire token is checked without relying on Number conversion, matching the indexer's // exact integer rule. Below it the legacy Number-based decision remains, // preserving historical replay. dispensers.expiration is BIGINT UNSIGNED, // so a fractional value like 1700000000.5 From 5dd6deffee43d3197e2c49477adc99372511b7f7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 9 Oct 2026 13:16:24 -0700 Subject: [PATCH 79/86] Split exact expiration test and trim registration comments to meet structure limits --- src/XChainDecoder/dispenser_registration.js | 75 ++++++------------- ...dispenser_exact_integer_expiration.test.js | 16 +++- 2 files changed, 36 insertions(+), 55 deletions(-) diff --git a/src/XChainDecoder/dispenser_registration.js b/src/XChainDecoder/dispenser_registration.js index e67c6a87..7f145922 100644 --- a/src/XChainDecoder/dispenser_registration.js +++ b/src/XChainDecoder/dispenser_registration.js @@ -25,21 +25,16 @@ const { EXACT_INTEGER_EXPIRATION_ACTIVATION } = require('../protocol/constants.j function isExactIntegerExpirationActive(consensusNetwork, blockTime){ const activation = EXACT_INTEGER_EXPIRATION_ACTIVATION[consensusNetwork] - if (typeof activation !== 'number') return false - const t = Number(blockTime) - return Number.isFinite(t) && t >= activation + return typeof activation === 'number' && Number.isFinite(Number(blockTime)) && Number(blockTime) >= activation } -// Test mathematical integrality from the wire spelling without first rounding through -// Number. This accepts the same decimal and exponent forms as the indexer's numeric -// parser, including integral forms such as 1.0 and 1e3, while rejecting a non-zero -// fractional tail even when it is too small for Number to retain. +// Integrality of the wire spelling without rounding through Number: accepts 1.0 and 1e3, +// rejects a non-zero fractional tail even when too small for Number to retain. function isExactIntegerToken(value){ const match = String(value).trim().match(/^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/) if (!match) return false - const whole = match[2] || '' - const fraction = (match[3] !== undefined) ? match[3] : (match[4] || '') - const digits = whole + fraction + 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 @@ -51,8 +46,7 @@ function isExactIntegerToken(value){ function hasValidExpirationToken(expirationToken, expiration, consensusNetwork, blockTime){ if (!Number.isSafeInteger(expiration) || expiration < 0) return false if (expirationToken === undefined || expirationToken === '') return true - if (!isExactIntegerExpirationActive(consensusNetwork, blockTime)) return true - return isExactIntegerToken(expirationToken) + return !isExactIntegerExpirationActive(consensusNetwork, blockTime) || isExactIntegerToken(expirationToken) } //Catch any dispenser message to add it to @@ -234,36 +228,18 @@ function pushV0DispenserCreate(decodedDataSplit, dispenserCreateCandidates, pars expiration = Number(expirationToken) } - // Require an integer. At/above EXACT_INTEGER_EXPIRATION_ACTIVATION the - // wire token is checked without relying on Number conversion, matching the indexer's - // exact integer rule. Below it the legacy Number-based decision remains, - // preserving historical replay. 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 Number() holds exactly: at or below it the payload token - // round-trips, so the decoder stores the same value the indexer does, - // and it stays far inside BIGINT UNSIGNED. It is NOT the indexer's - // bound: the indexer accepts any integer EXPIRATION up to 2^64-1 - // (xchain-indexer/src/config/wire_fields.js), so a create between 2^53 - // and 2^64-1 is still this drift, escrowed there and never registered - // here. Closing it changes captured outputs, so it needs an activation. - // 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. + // SAFE integer and no u32 ceiling: the indexer escrows any non-negative integer + // EXPIRATION, so a u32 reject was recognition drift. Number.isSafeInteger is the + // bound Number() holds exactly, but the indexer accepts up to 2^64-1 + // (xchain-indexer/src/config/wire_fields.js), so a create between 2^53 and 2^64-1 + // is still drift; closing it changes captured outputs and needs an activation. + // Dropping the ceiling is unsafe: Number.isInteger is true for 1e300. if (!hasValidExpirationToken(expirationToken, expiration, this.consensusNetwork, block.timestamp)) { this.parseErrors++ logger.error(`Skipping dispenser in tx ${nextTransactionHash}: invalid expiration value '${decodedDataSplit[V0_EXPIRATION_INDEX]}'`) @@ -369,17 +345,9 @@ function dispenserEditExtension(dispenserCommand, dispenserCommandPrefix, parseR if (editSource && editSource.length > 0 && editExpirationToken !== undefined && editExpirationToken !== ""){ const newExpiration = Number(editExpirationToken) - // Same gated 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. The SAFE-integer ceiling replaces 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). The indexer's own ceiling is u64, so an extend past - // 2^53 still goes unmirrored; the create guard says why that - // gap waits on a decoder activation. + // Same gated integer contract as the create guard: the edit writes the + // same BIGINT UNSIGNED column. The SAFE-integer ceiling replaces a u32 one, + // and an extend past 2^53 stays unmirrored until a decoder activation. if (hasValidExpirationToken(editExpirationToken, newExpiration, this.consensusNetwork, block.timestamp) && newExpiration > block.timestamp){ return { editSource, newExpiration } @@ -429,5 +397,4 @@ async function extendEditedDispenser(extension, nextBlockHeight){ } } -module.exports = { dispenserCommandPrefixFor, collectDispenserCreates, registerDispenser, dispenserEditExtension, extendEditedDispenser, - isExactIntegerExpirationActive, isExactIntegerToken } +module.exports = { dispenserCommandPrefixFor, collectDispenserCreates, registerDispenser, dispenserEditExtension, extendEditedDispenser, isExactIntegerExpirationActive, isExactIntegerToken } diff --git a/test/unit/dispenser_exact_integer_expiration.test.js b/test/unit/dispenser_exact_integer_expiration.test.js index 53947e4d..a0165ec9 100644 --- a/test/unit/dispenser_exact_integer_expiration.test.js +++ b/test/unit/dispenser_exact_integer_expiration.test.js @@ -13,7 +13,7 @@ const { DispenserModel, buildDecoder, T0, ADDR, CREATE } = const NAME = 'EXACT_INTEGER_EXPIRATION_ACTIVATION' -describe('DISPENSER exact integer EXPIRATION activation', function () { +function restoreActivationAround() { const saved = {} beforeEach(function () { @@ -25,6 +25,10 @@ describe('DISPENSER exact integer EXPIRATION activation', 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], { @@ -53,6 +57,11 @@ describe('DISPENSER exact integer EXPIRATION activation', function () { 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` @@ -103,6 +112,11 @@ describe('DISPENSER exact integer EXPIRATION activation', function () { 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() From 2327779a926b897607680be8f086858bd7cb456c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 9 Oct 2026 13:24:07 -0700 Subject: [PATCH 80/86] Trim activation documentation below structure limit Condense the activation rationale while preserving every value and export. The constants module now satisfies the 400-line readability ratchet. --- src/protocol/constants/activation.js | 268 ++++----------------------- 1 file changed, 32 insertions(+), 236 deletions(-) diff --git a/src/protocol/constants/activation.js b/src/protocol/constants/activation.js index d570b14a..d41157a0 100644 --- a/src/protocol/constants/activation.js +++ b/src/protocol/constants/activation.js @@ -197,42 +197,12 @@ const ORACLE_FEE_OUTPUT_ACTIVATION = { 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. +// 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 @@ -243,46 +213,11 @@ const ORACLE_FEE_SET_CAPTURE_ACTIVATION = { 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. +// 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 @@ -293,48 +228,12 @@ const DISPENSER_EXPIRY_REALIGN_ACTIVATION = { 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. +// 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 @@ -385,135 +284,32 @@ const EXACT_INTEGER_EXPIRATION_ACTIVATION = { regtest: null, }; -// 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. +// 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 (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. + mainnet: 1786838400, // ARMED 2026-08-14 for 2026-08-16T00:00:00Z, matching BATCH_ISSUANCE_LIMITS testnet: 0, regtest: 0, }; -// 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. +// 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 }, }; -// 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. +// 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 }, From f77b0d0fc9752f26aab222243dbab1a5ce663efa Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 9 Oct 2026 23:24:08 -0700 Subject: [PATCH 81/86] Re-take the suite-title pin against the current test tree The pin predated added test files and titles; four retitled tests no longer match their old titles. --- bin/pins/at1-suite-titles.json | 1198 +++++++++++++++++++++----------- 1 file changed, 780 insertions(+), 418 deletions(-) diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index 0245191b..f75f146f 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" ], @@ -726,6 +769,12 @@ "Smoke: Database Initialization should verify the database exists", "Smoke: Database Initialization should verify the database exists" ], + "2eba63f49897c1f0": [ + "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 still writes a blanked row for an actionless tx with no source" + ], "2ee2031083237da3": [ "Multisig Integration should appear correctly in the indexer contract query", "Multisig Integration should decode an ACTION from a 1-of-3 multisig output", @@ -802,6 +851,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 +930,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 +972,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 +1013,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 +1071,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 +1156,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,28 +1216,6 @@ "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" - ], "51fbc60e4c509ae1": [ "Database#hasDispenserTransactions() probes BOTH the top-level and the batch-carried shape", "Database#hasDispenserTransactions() sees a dispenser opened inside a BATCH", @@ -1173,6 +1230,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 +1321,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 +1480,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 +1594,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 +1611,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 +1719,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 +1781,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 +1832,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 +1908,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 +2007,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 +2023,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 +2058,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 +2094,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 +2148,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 +2182,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 +2276,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 +2313,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 +2328,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 +2431,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 +2488,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 +2506,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 +2605,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 +2652,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 +2713,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 +2740,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 +2787,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 +2854,10 @@ "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" @@ -2597,12 +2878,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", @@ -2656,11 +2955,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", @@ -2697,23 +2991,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", @@ -2729,57 +3038,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", @@ -3008,6 +3296,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)", @@ -3017,6 +3314,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", @@ -3026,18 +3332,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", @@ -3106,15 +3400,17 @@ }, "scripts": { "test": { - "fileCount": 189, - "titleCount": 1761, + "fileCount": 223, + "titleCount": 1987, "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", @@ -3127,6 +3423,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", @@ -3142,7 +3439,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", @@ -3156,11 +3453,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", @@ -3172,13 +3471,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", @@ -3198,15 +3499,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", @@ -3218,34 +3523,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": "2eba63f49897c1f0", "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", @@ -3253,14 +3573,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": "8383b3fc6d715636", "test/unit/protocol_time_caller_order.test.js": "d18469488f659a45", - "test/unit/protocol_time.test.js": "a712626ef67e3e03", "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", @@ -3270,27 +3592,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", @@ -3589,15 +3917,17 @@ } }, "test:unit": { - "fileCount": 189, - "titleCount": 1761, + "fileCount": 223, + "titleCount": 1987, "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", @@ -3610,6 +3940,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", @@ -3625,7 +3956,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", @@ -3639,11 +3970,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", @@ -3655,13 +3988,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", @@ -3681,15 +4016,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", @@ -3701,34 +4040,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": "2eba63f49897c1f0", "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", @@ -3736,14 +4090,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": "8383b3fc6d715636", "test/unit/protocol_time_caller_order.test.js": "d18469488f659a45", - "test/unit/protocol_time.test.js": "a712626ef67e3e03", "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", @@ -3753,27 +4109,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", From fd19b9b97b0dbaab869b14f3c7d9986f710c0a68 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 9 Oct 2026 23:31:57 -0700 Subject: [PATCH 82/86] Enforce migration residue pin sort order Reject unpinned migrations that sort before the newest pinned fixture entry, while continuing to allow newly dated migrations. Add focused regression coverage for the backdated case. --- bin/lib/migration_residue_pin.js | 13 ++++++++++--- .../10_migration_executable_residue_pin.test.js | 12 ++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/bin/lib/migration_residue_pin.js b/bin/lib/migration_residue_pin.js index b0401d07..ea7781e8 100644 --- a/bin/lib/migration_residue_pin.js +++ b/bin/lib/migration_residue_pin.js @@ -26,9 +26,11 @@ * 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, so a new migration needs no fixture edit; - * `--add` pins it. An existing entry moves only through `--accept `, which is - * legitimate solely for a file no database has applied. + * 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 @@ -63,6 +65,11 @@ function pinOf(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. 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 index 4540b57e..e82e326a 100644 --- 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 @@ -75,4 +75,16 @@ describe('migration executable-residue pin: the guard bites @regression', functi 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')); + }); }); From e9bd652c5c12d84d4bc309f4f195f162c6f4de09 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 10 Oct 2026 15:16:17 -0700 Subject: [PATCH 83/86] Set the repo git identity at session start and turn attribution off --- .claude/settings.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .claude/settings.json 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" + } + ] + } + ] + } +} From a8ecd7ad3dc2b372dd3d189d6ffda926800a9bc7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 11 Oct 2026 02:54:53 +0000 Subject: [PATCH 84/86] Read DECODER_DB_* in the decoder entry points through src/config.js clear_reorg_halt.js and db/migrate.js now take their database settings from src/config.js instead of reading process.env directly. The config home reads every value live, so the .env each entry point loads before reading is still what it gets. The config.js header no longer lists the two entry points as exempt. --- src/clear_reorg_halt.js | 14 ++++++++------ src/config.js | 11 +++++------ src/db/migrate.js | 13 +++++++------ 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/clear_reorg_halt.js b/src/clear_reorg_halt.js index 8df51d1f..49e1427b 100644 --- a/src/clear_reorg_halt.js +++ b/src/clear_reorg_halt.js @@ -39,7 +39,8 @@ * 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. * ********************************************************************/ @@ -51,6 +52,7 @@ if (require.main === module) require('dotenv').config() const Database = require('./db.js') +const config = require('./config.js') const EXIT = { OK: 0, @@ -221,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/config.js b/src/config.js index 9a9cd22f..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 two process entry points (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. * ********************************************************************/ 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); From da334c1ad6968b52cb28137e71f67ddd9cf0a6a8 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 11 Oct 2026 16:02:27 +0000 Subject: [PATCH 85/86] Classify mempool payments against the open-dispenser set Mempool admission parsed every pending tx with no open-dispenser set, so a payment to an open dispenser never became a dispense output there. An ACTION whose source cannot be resolved then failed hasStorableContent and was blanked to an empty string, while the block path, which loads the set, stores the same tx with its ACTION text on confirm. Each mempool cycle now loads the set once on mempoolDb through getAllOpenDispenserAddresses with the cancelGraceFloor the block loop uses, taking wall-clock seconds as the next block's time, and hands it to parseTransaction. A failed load is logged and the cycle classifies against an empty set so pruning and new arrivals keep running. Tests cover the loaded set reaching the parse, the floor argument, and the failed-load path; the isolation spy records the new mempoolDb read, and the suite-title pin carries the three new titles. --- bin/pins/at1-suite-titles.json | 23 +++++---- src/XChainDecoder/mempool_refresh.js | 49 +++++++++++++++---- test/unit/mempool_isolation.test.js | 3 +- .../mempool_unattributable_action.test.js | 41 ++++++++++++++-- 4 files changed, 92 insertions(+), 24 deletions(-) diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index f75f146f..e9270361 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -769,12 +769,6 @@ "Smoke: Database Initialization should verify the database exists", "Smoke: Database Initialization should verify the database exists" ], - "2eba63f49897c1f0": [ - "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 still writes a blanked row for an actionless tx with no source" - ], "2ee2031083237da3": [ "Multisig Integration should appear correctly in the indexer contract query", "Multisig Integration should decode an ACTION from a 1-of-3 multisig output", @@ -1216,6 +1210,15 @@ "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" ], + "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", "Database#hasDispenserTransactions() sees a dispenser opened inside a BATCH", @@ -3401,7 +3404,7 @@ "scripts": { "test": { "fileCount": 223, - "titleCount": 1987, + "titleCount": 1990, "files": { "test/unit/action_manifest_conformance.test.js": "3e1e7599837cbf70", "test/unit/alias_expansion_boundary.test.js": "7a5cec648390c3a0", @@ -3536,7 +3539,7 @@ "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": "2eba63f49897c1f0", + "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": "54639638d1536735", @@ -3918,7 +3921,7 @@ }, "test:unit": { "fileCount": 223, - "titleCount": 1987, + "titleCount": 1990, "files": { "test/unit/action_manifest_conformance.test.js": "3e1e7599837cbf70", "test/unit/alias_expansion_boundary.test.js": "7a5cec648390c3a0", @@ -4053,7 +4056,7 @@ "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": "2eba63f49897c1f0", + "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": "54639638d1536735", diff --git a/src/XChainDecoder/mempool_refresh.js b/src/XChainDecoder/mempool_refresh.js index fa24ba70..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 @@ -91,9 +118,8 @@ function* parseMempoolTransaction(nextTx, nextTransactionHash){ // 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. - -// Diverges for an unattributed ACTION paying an open dispenser: the mempool parse has -// no open-dispenser set, so it blanks one the block path stores on confirm. +// 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)){ @@ -104,7 +130,7 @@ function admitMempoolAction(parseResult, nextTransactionHash){ return this.buildStoredActionRecord(parseResult, nextTransactionHash, true) } -function* storeMempoolTransaction(nextTxHex, nextTxHexIndex){ +function* storeMempoolTransaction(nextTxHex, nextTxHexIndex, openDispenserAddresses){ let nextTx = decodeMempoolTransaction.call(this, nextTxHex, nextTxHexIndex) if (nextTx == null) { return false @@ -112,7 +138,7 @@ 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 } @@ -147,7 +173,7 @@ function* storeMempoolTransaction(nextTxHex, nextTxHexIndex){ } } -function* storeMempoolBatches(rawMempool){ +function* storeMempoolBatches(rawMempool, openDispenserAddresses){ let validTransactionsCount = 0 let i = 0 while (i < rawMempool.length) { @@ -172,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 } } @@ -185,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. @@ -195,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/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 index db271d8b..a814797d 100644 --- a/test/unit/mempool_unattributable_action.test.js +++ b/test/unit/mempool_unattributable_action.test.js @@ -14,12 +14,14 @@ 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) { +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 }, } @@ -29,8 +31,13 @@ function runOneMempoolCycle(parseResult) { getRawTransactions: async () => ['hexdata'], } decoder.xchainBlockDecoder = { transactionFromHex: () => ({ ins: [{}], getId: () => 'txid1' }) } - decoder.parseTransaction = async () => parseResult - return decoder.updateMempool().then(() => ({ decoder, inserted })) + 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) { @@ -70,4 +77,32 @@ describe('mempool admission of an ACTION with no resolvable source', function () 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') + }) }) From ae761b838307cdd9578b2fa05b7b8859bdaff795 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 11 Oct 2026 15:57:49 -0700 Subject: [PATCH 86/86] Release v0.23.0 Bump to 0.23.0 and record the release changelog. Arm the v0.23.0 testnet gates at BTC 156230, LTC 4912984 and DOGE 67989993, with instant 1791804379 for the time and change rows; mainnet stays unarmed. Tests that pinned the unarmed testnet state now read the armed rows, and the identity and suite-title pins are regenerated. --- CHANGELOG.md | 14 ++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) 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/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",