diff --git a/CHANGELOG.md b/CHANGELOG.md index c9af3e64..b3cde483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.25.0] - 2026-10-18 + +### Changed +- Let the dispenser block-time gates take a testnet activation map so they arm on testnet in this train, and added fail-closed tests for unaudited clear rows. + ## [0.24.0] - 2026-10-17 ### Added diff --git a/bin/pins/suite-title-renames.json b/bin/pins/suite-title-renames.json index a7d9d99a..1b0ad3f8 100644 --- a/bin/pins/suite-title-renames.json +++ b/bin/pins/suite-title-renames.json @@ -8,6 +8,12 @@ "test/unit/db_queries.test/20_error_path_transaction_connection_branches.test.js": { "Database error-path transactionConnection branches createAddress: swallows INSERT error even with active transactionConnection": "Database error-path transactionConnection branches createAddress: rethrows an INSERT error inside an active transaction so the block rolls back" }, + "test/unit/dispenser_address_id_collapse_activation.test.js": { + "DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION conformance keeps mainnet and testnet inert while regtest is genesis-active": "DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION conformance keeps mainnet inert and regtest genesis-active while testnet is armable" + }, + "test/unit/dispenser_purge_grace_activation.test.js": { + "DISPENSER_PURGE_GRACE_ACTIVATION conformance keeps mainnet and testnet unarmed while regtest is genesis-active": "DISPENSER_PURGE_GRACE_ACTIVATION conformance keeps mainnet unarmed and regtest genesis-active while testnet is armable" + }, "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" }, diff --git a/package-lock.json b/package-lock.json index 0b9878f5..0113830c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-decoder", - "version": "0.24.0", + "version": "0.25.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-decoder", - "version": "0.24.0", + "version": "0.25.0", "license": "AGPL-3.0-or-later", "dependencies": { "axios": "^1.18.1", diff --git a/package.json b/package.json index 3a634a05..9d8fd06e 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.24.0", + "version": "0.25.0", "license": "AGPL-3.0-or-later", "repository": { "type": "git", diff --git a/src/protocol/constants/activation.js b/src/protocol/constants/activation.js index adefa88e..e35faebd 100644 --- a/src/protocol/constants/activation.js +++ b/src/protocol/constants/activation.js @@ -255,19 +255,19 @@ const DISPENSER_CANCEL_GRACE_ACTIVATION = { // Regtest is genesis-active so the grace-aware purge path is exercised there. const DISPENSER_PURGE_GRACE_ACTIVATION = { mainnet: 9999999999, - testnet: 9999999999, + testnet: 1792454400, regtest: 0, }; const DISPENSER_CANCEL_GRACE_PARENT_FLOOR_ACTIVATION = { mainnet: 9999999999, - testnet: 9999999999, + testnet: 1792454400, regtest: 0, }; const DISPENSER_ADDRESS_ID_COLLAPSE_ACTIVATION = { mainnet: 9999999999, - testnet: 9999999999, + testnet: 1792454400, regtest: 0, }; diff --git a/test/helpers/block_time_gate_map.js b/test/helpers/block_time_gate_map.js new file mode 100644 index 00000000..77b81478 --- /dev/null +++ b/test/helpers/block_time_gate_map.js @@ -0,0 +1,29 @@ +'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 UNARMED = 9999999999; +const MINIMUM_BLOCK_TIME = 1000000000; + +function assertMainnetUnarmedTestnetArmable(map, label) { + assert.strictEqual(map.mainnet, UNARMED, `${label}.mainnet must remain unarmed`); + assert.strictEqual(map.regtest, 0, `${label}.regtest must be genesis-active`); + assert.ok(map.testnet === UNARMED || ( + Number.isFinite(map.testnet) && + Number.isInteger(map.testnet) && + map.testnet >= MINIMUM_BLOCK_TIME && + map.testnet < UNARMED + ), `${label}.testnet must be unarmed or an integer block-time instant`); +} + +module.exports = { assertMainnetUnarmedTestnetArmable }; diff --git a/test/unit/dispenser_address_id_collapse_activation.test.js b/test/unit/dispenser_address_id_collapse_activation.test.js index ad9e0d53..323aa0af 100644 --- a/test/unit/dispenser_address_id_collapse_activation.test.js +++ b/test/unit/dispenser_address_id_collapse_activation.test.js @@ -17,6 +17,9 @@ 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 { + assertMainnetUnarmedTestnetArmable, +} = require('../helpers/block_time_gate_map.js'); const EXPLICIT_DOCS_DIR = process.env.XCHAIN_DOCS_DIR; const DOCS_CONSTANTS = EXPLICIT_DOCS_DIR @@ -35,12 +38,8 @@ function declaration(source) { 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('keeps mainnet inert and regtest genesis-active while testnet is armable', function () { + assertMainnetUnarmedTestnetArmable(activation[NAME], NAME); }); it('is re-exported by the public constants module', function () { diff --git a/test/unit/dispenser_cancel_grace.test/block_time_gate_map.test.js b/test/unit/dispenser_cancel_grace.test/block_time_gate_map.test.js new file mode 100644 index 00000000..077c1438 --- /dev/null +++ b/test/unit/dispenser_cancel_grace.test/block_time_gate_map.test.js @@ -0,0 +1,59 @@ +'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 { + assertMainnetUnarmedTestnetArmable, +} = require('../../helpers/block_time_gate_map.js'); + +const UNARMED = 9999999999; + +function map(overrides = {}) { + return { + mainnet: UNARMED, + testnet: UNARMED, + regtest: 0, + ...overrides, + }; +} + +describe('block-time gate map assertion', function () { + + it('accepts the shipped unarmed map', function () { + assert.doesNotThrow(() => assertMainnetUnarmedTestnetArmable(map(), 'gate')); + }); + + it('accepts an armed testnet block-time instant', function () { + assert.doesNotThrow(() => assertMainnetUnarmedTestnetArmable( + map({ testnet: 1760700000 }), 'gate' + )); + }); + + it('refuses an armed mainnet', function () { + assert.throws(() => assertMainnetUnarmedTestnetArmable( + map({ mainnet: 1760700000 }), 'gate' + ), /gate\.mainnet must remain unarmed/); + }); + + it('refuses a nonzero regtest', function () { + assert.throws(() => assertMainnetUnarmedTestnetArmable( + map({ regtest: 1 }), 'gate' + ), /gate\.regtest must be genesis-active/); + }); + + it('refuses a height-sized testnet value', function () { + assert.throws(() => assertMainnetUnarmedTestnetArmable( + map({ testnet: 900000 }), 'gate' + ), /gate\.testnet must be unarmed or an integer block-time instant/); + }); +}); diff --git a/test/unit/dispenser_cancel_grace_parent_floor_activation.test.js b/test/unit/dispenser_cancel_grace_parent_floor_activation.test.js index 702a561b..08660a73 100644 --- a/test/unit/dispenser_cancel_grace_parent_floor_activation.test.js +++ b/test/unit/dispenser_cancel_grace_parent_floor_activation.test.js @@ -14,17 +14,16 @@ const assert = require('assert'); const activation = require('../../src/protocol/constants/activation.js'); const constants = require('../../src/protocol/constants.js'); +const { + assertMainnetUnarmedTestnetArmable, +} = require('../helpers/block_time_gate_map.js'); const NAME = 'DISPENSER_CANCEL_GRACE_PARENT_FLOOR_ACTIVATION'; describe('DISPENSER_CANCEL_GRACE_PARENT_FLOOR_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('keeps mainnet inert and regtest genesis-active while testnet is armable', function () { + assertMainnetUnarmedTestnetArmable(activation[NAME], NAME); }); it('is re-exported by the public constants module', function () { diff --git a/test/unit/dispenser_purge_grace_activation.test.js b/test/unit/dispenser_purge_grace_activation.test.js index 6a5172c7..6491e4e7 100644 --- a/test/unit/dispenser_purge_grace_activation.test.js +++ b/test/unit/dispenser_purge_grace_activation.test.js @@ -15,6 +15,9 @@ const fs = require('fs'); const path = require('path'); const { DISPENSER_PURGE_GRACE_ACTIVATION } = require('../../src/protocol/constants.js'); +const { + assertMainnetUnarmedTestnetArmable, +} = require('../helpers/block_time_gate_map.js'); const EXPLICIT_DOC_CONSTANTS = process.env.XC_DOC_CONSTANTS; const DOC_CONSTANTS = EXPLICIT_DOC_CONSTANTS || (process.env.XCHAIN_DOCS_DIR @@ -23,12 +26,11 @@ const DOC_CONSTANTS = EXPLICIT_DOC_CONSTANTS || (process.env.XCHAIN_DOCS_DIR 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('keeps mainnet unarmed and regtest genesis-active while testnet is armable', function () { + assertMainnetUnarmedTestnetArmable( + DISPENSER_PURGE_GRACE_ACTIVATION, + 'DISPENSER_PURGE_GRACE_ACTIVATION' + ); }); it('is value-identical to the canonical map in xchain-documentation', function () { diff --git a/test/unit/reorg_halt_clear.test/02_unaudited_clear_row_fails_closed.test.js b/test/unit/reorg_halt_clear.test/02_unaudited_clear_row_fails_closed.test.js new file mode 100644 index 00000000..b1d1e051 --- /dev/null +++ b/test/unit/reorg_halt_clear.test/02_unaudited_clear_row_fails_closed.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. + * + *********************************************************************/ + +'use strict' + +const assert = require('assert') +const sinon = require('sinon') +const Database = require('../../../src/db.js') + +function dbAnswering(handler) { + const db = new Database('127.0.0.1', 3306, 'xchain_btc_mainnet', 'u', 'p') + const query = sinon.stub().callsFake(async (sql, params) => handler(sql, params)) + db.pool = { getConnection: sinon.stub().resolves({ query, release: sinon.stub().resolves() }) } + return { db, query } +} + +const clearRow = (id, data) => ({ id, time: '2026-09-08 10:00:00', code: 'REORG_HALT_CLEARED', data }) +const auditedClear = (id) => clearRow(id, JSON.stringify({ reason: 'long enough reason', at: '2026-09-08T11:00:00Z', cleared_halt_id: 9 })) + +describe('Database: the newest REORG_HALT / REORG_HALT_CLEARED row decides', function () { + afterEach(() => sinon.restore()) + + it('a clear row with an unparseable payload still counts as live (fail-closed)', async function () { + const { db } = dbAnswering(() => [clearRow(9, '{not json')]) + assert.strictEqual(await db.isReorgHalted(), true) + const m = await db.getReorgHaltMarker() + assert.strictEqual(m.halted, true) + assert.strictEqual(m.id, 9) + assert.strictEqual(m.cleared_at, null) + assert.match(m.reason, /unaudited REORG_HALT_CLEARED/) + }) + + it('a clear row with no reason or a reason under 8 characters still counts as live (fail-closed)', async function () { + const shapes = ['{}', 'null', '[]', JSON.stringify({ reason: 'short' }), JSON.stringify({ reason: 12345678 }), JSON.stringify({ reason: ' ok' })] + for (const data of shapes) { + const { db } = dbAnswering(() => [clearRow(9, data)]) + assert.strictEqual(await db.isReorgHalted(), true, 'clear row data ' + data + ' must not lift the halt') + } + }) + + it('a clear row holding an already-parsed payload with a valid reason still lifts the halt', async function () { + const { db } = dbAnswering(() => [clearRow(9, { reason: 'zero dispensers', at: '2026-09-08T10:00:00Z' })]) + assert.strictEqual(await db.isReorgHalted(), false) + }) + + it('an audited clear supersedes an unaudited clear row', async function () { + let state = [clearRow(9, '{}')] + const inserted = [] + const { db } = dbAnswering((sql, params) => { + if (/INSERT INTO events/.test(sql)) { inserted.push(params); state = [auditedClear(10)]; return { affectedRows: 1 } } + return state + }) + const res = await db.clearReorgHalt({ reason: 'long enough reason', expectedHaltId: 9 }) + assert.deepStrictEqual(res, { cleared: true, alreadyClear: false }) + assert.strictEqual(JSON.parse(inserted[0][2]).cleared_halt_id, 9) + }) + + it('markReorgHalted writes a REORG_HALT row when the live halt is an unaudited clear row', async function () { + let state = [clearRow(9, '{}')] + const inserted = [] + const { db } = dbAnswering((sql, params) => { + if (/INSERT INTO events/.test(sql)) { + inserted.push(params) + state = [{ id: 10, time: 't', code: 'REORG_HALT', data: params[2] }] + return { affectedRows: 1 } + } + return state + }) + assert.strictEqual(await db.markReorgHalted('over-deep'), true) + assert.strictEqual(inserted.length, 1) + assert.strictEqual(inserted[0][1], 'REORG_HALT') + }) +})