Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions bin/pins/suite-title-renames.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 3 additions & 3 deletions src/protocol/constants/activation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
29 changes: 29 additions & 0 deletions test/helpers/block_time_gate_map.js
Original file line number Diff line number Diff line change
@@ -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 };
11 changes: 5 additions & 6 deletions test/unit/dispenser_address_id_collapse_activation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 () {
Expand Down
59 changes: 59 additions & 0 deletions test/unit/dispenser_cancel_grace.test/block_time_gate_map.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
});
11 changes: 5 additions & 6 deletions test/unit/dispenser_cancel_grace_parent_floor_activation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
14 changes: 8 additions & 6 deletions test/unit/dispenser_purge_grace_activation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 () {
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading