From 5a383011b6ad6f58738cc2706cc4d8b7d53336d7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 14:34:46 -0700 Subject: [PATCH 01/34] Enforce complete source coverage ratchet Run both coverage scripts with c8 --all. Raise synchronized floors from measured venue coverage and reject hidden source exclusions. --- bin/coverage-thresholds.json | 10 +++++----- package.json | 4 ++-- test/unit/repo/coverage_thresholds_sync.test.js | 12 ++++++++++++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/bin/coverage-thresholds.json b/bin/coverage-thresholds.json index d1115fc..e0072db 100644 --- a/bin/coverage-thresholds.json +++ b/bin/coverage-thresholds.json @@ -1,7 +1,7 @@ { - "comment": "Coverage floors for the CI coverage job (regression floors, ~1-1.5 points below measured, not tier targets; raise as coverage climbs). Mirrored into the coverage:check npm script in package.json and guarded by test/unit/repo/coverage_thresholds_sync.test.js. Re-measured 2026-08-14 WITH the declared siblings checked out, which is what the coverage job now does: 87.77 lines/statements, 86.67 branches, 67.48 functions over 587 unit tests; the functions floor stays at its existing 66, already inside the band, and remains a holding line rather than a target.", - "lines": 86.2, - "statements": 86.2, - "branches": 85.1, - "functions": 66 + "comment": "Coverage floors for the CI coverage job (regression floors, ~1-1.5 points below measured, not tier targets; raise as coverage climbs). Mirrored into the coverage:check npm script in package.json and guarded by test/unit/repo/coverage_thresholds_sync.test.js. Re-measured 2026-09-23 with --all and the declared siblings checked out: 96.45 lines/statements, 88.50 branches, 95.48 functions over 927 passing unit tests. No source files are excluded: all files under src, including process entry points and locally owned vendored code, remain in the measurement.", + "lines": 95.1, + "statements": 95.1, + "branches": 87.1, + "functions": 94.1 } diff --git a/package.json b/package.json index 90e2cda..79c01cc 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ "smoke-test": "npx mocha --timeout 10000 'test/smoke/**/*.test.js'", "test": "npx mocha --timeout 10000 --require ./test/setup/index.js 'test/unit/**/*.test.js' --recursive --exit", "test:regtest": "mocha --timeout 0 --require ./test/prepare_regtest.test.js", - "coverage": "c8 --reporter=text --reporter=html --include 'src/**/*.js' mocha --timeout 10000 --require ./test/setup/index.js 'test/unit/**/*.test.js' --exit", - "coverage:check": "c8 --check-coverage --lines 86.2 --statements 86.2 --branches 85.1 --functions 66 --reporter=text-summary --include 'src/**/*.js' mocha --timeout 10000 --require ./test/setup/index.js 'test/unit/**/*.test.js' --exit", + "coverage": "c8 --all --reporter=text --reporter=html --include 'src/**/*.js' mocha --timeout 10000 --require ./test/setup/index.js 'test/unit/**/*.test.js' --exit", + "coverage:check": "c8 --all --check-coverage --lines 95.1 --statements 95.1 --branches 87.1 --functions 94.1 --reporter=text-summary --include 'src/**/*.js' mocha --timeout 10000 --require ./test/setup/index.js 'test/unit/**/*.test.js' --exit", "ci": "npx mocha --timeout 10000 --require ./test/setup/index.js 'test/unit/**/*.test.js' --recursive --exit && npm run ci:security && npm run ci:regression && npm run ci:e2e && npm run ci:conformance", "ci:security": "npx mocha --timeout 30000 'test/security/**/*.test.js' --recursive --exit", "ci:regression": "npx mocha --timeout 30000 'test/regression/**/*.test.js' --recursive --exit", diff --git a/test/unit/repo/coverage_thresholds_sync.test.js b/test/unit/repo/coverage_thresholds_sync.test.js index db9a0b3..752ef4d 100644 --- a/test/unit/repo/coverage_thresholds_sync.test.js +++ b/test/unit/repo/coverage_thresholds_sync.test.js @@ -40,4 +40,16 @@ describe('coverage ratchet floors', () => { it('fails the job on a shortfall rather than only reporting it', () => { assert.match(pkg.scripts['coverage:check'], /--check-coverage/); }); + + it('measures files that the unit suite does not load', () => { + for (const name of ['coverage', 'coverage:check']) { + assert.match(pkg.scripts[name], /(?:^|\s)--all(?:\s|$)/, `${name} must run c8 with --all`); + } + }); + + it('carries no undocumented source exclusions', () => { + for (const name of ['coverage', 'coverage:check']) { + assert.doesNotMatch(pkg.scripts[name], /(?:^|\s)--exclude(?:=|\s)/); + } + }); }); From 6a2745b567f7387c129c80cd49fdddb03c4ea535 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 17:43:03 -0700 Subject: [PATCH 02/34] Retire duplicate fee scenarios and guard regtest reset Remove the utxo_fee integration scenarios that duplicate end-to-end coverage. Require explicit opt-in before the regtest prepare hook deletes local data, isolate it from dotenv configuration, and fix a let that should be const in the browser entry. --- src/browser/index.js | 2 +- test/integration/utxo_fee.test.js | 72 ------------ .../01_d_2_multiple_utxos_needed.test.js | 72 ------------ ...2_d_3_duplicate_utxo_deduplication.test.js | 70 ----------- .../03_d_4_unconfirmed_filtering.test.js | 83 ------------- .../04_d_5_utxo_tracker_fallback.test.js | 89 -------------- .../05_d_6_no_utxos_available.test.js | 87 -------------- ..._7_fee_capped_by_max_fee_per_bytes.test.js | 89 -------------- .../07_d_8_dust_floor_on_fee.test.js | 67 ----------- .../08_d_9_no_change_address.test.js | 65 ----------- ...10_legacy_non_segwit_utxo_handling.test.js | 98 ---------------- .../10_utxo_sorting_largest_first.test.js | 67 ----------- .../11_replace_by_fee_sequence.test.js | 79 ------------- .../12_fee_source_selection.test.js | 88 -------------- test/prepare_regtest.test.js | 109 +++++++++--------- 15 files changed, 58 insertions(+), 1079 deletions(-) delete mode 100644 test/integration/utxo_fee.test.js delete mode 100644 test/integration/utxo_fee.test/01_d_2_multiple_utxos_needed.test.js delete mode 100644 test/integration/utxo_fee.test/02_d_3_duplicate_utxo_deduplication.test.js delete mode 100644 test/integration/utxo_fee.test/03_d_4_unconfirmed_filtering.test.js delete mode 100644 test/integration/utxo_fee.test/04_d_5_utxo_tracker_fallback.test.js delete mode 100644 test/integration/utxo_fee.test/05_d_6_no_utxos_available.test.js delete mode 100644 test/integration/utxo_fee.test/06_d_7_fee_capped_by_max_fee_per_bytes.test.js delete mode 100644 test/integration/utxo_fee.test/07_d_8_dust_floor_on_fee.test.js delete mode 100644 test/integration/utxo_fee.test/08_d_9_no_change_address.test.js delete mode 100644 test/integration/utxo_fee.test/09_d_10_legacy_non_segwit_utxo_handling.test.js delete mode 100644 test/integration/utxo_fee.test/10_utxo_sorting_largest_first.test.js delete mode 100644 test/integration/utxo_fee.test/11_replace_by_fee_sequence.test.js delete mode 100644 test/integration/utxo_fee.test/12_fee_source_selection.test.js diff --git a/src/browser/index.js b/src/browser/index.js index 05ca47e..3ccee9f 100644 --- a/src/browser/index.js +++ b/src/browser/index.js @@ -12,7 +12,7 @@ * ********************************************************************/ -let XChainEncoder = require('../XChainEncoder'); +const XChainEncoder = require('../XChainEncoder'); window.XChainEncoder = XChainEncoder; diff --git a/test/integration/utxo_fee.test.js b/test/integration/utxo_fee.test.js deleted file mode 100644 index 6708569..0000000 --- a/test/integration/utxo_fee.test.js +++ /dev/null @@ -1,72 +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. - * - ********************************************************************** - * Category D: UTXO & Fee Integration - * - * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, - * dust floors, and change output logic with realistic ACTION payloads. - */ - -const assert = require('assert') -const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../src/XChainEncoder') -const { - TXID_A, - TXID_B, - TXID_C, - PUBKEY_BUF, - makeUtxo, - makeLegacyUtxo, - makeMempoolUtxo, - makeTrackerEnvelope, - makeEncoder, - getTestAddress, - buildRawTxHex -} = require('./helpers/utxoFactory') -const actions = require('./helpers/actionFactory') - -// These integration cases encode BITCOIN fee semantics (explicit fees honored -// verbatim, sub-estimate fees, 546 dust). The network was mislabeled -// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the -// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the -// semantics these assertions actually test. (DOGE's high-dust floor is covered -// in test/boundary/fee_calculation_boundaries.test.js.) -const NETWORK = 'bitcoin-regtest' - -describe('Category D: UTXO & Fee Integration', () => { - - describe('D-1: Single large UTXO covers everything', () => { - it('produces 1 input, 2 outputs (OP_RETURN + change)', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.psbt.data.inputs.length, 1) - assert.strictEqual(result.psbt.txOutputs.length, 2) - - // One OP_RETURN (value=0), one change - const opReturn = result.psbt.txOutputs.filter(o => o.value === 0) - const change = result.psbt.txOutputs.filter(o => o.value > 0) - assert.strictEqual(opReturn.length, 1) - assert.strictEqual(change.length, 1) - assert.strictEqual(change[0].value, 100000000 - 10000) - }) - }) -}) diff --git a/test/integration/utxo_fee.test/01_d_2_multiple_utxos_needed.test.js b/test/integration/utxo_fee.test/01_d_2_multiple_utxos_needed.test.js deleted file mode 100644 index 49484c8..0000000 --- a/test/integration/utxo_fee.test/01_d_2_multiple_utxos_needed.test.js +++ /dev/null @@ -1,72 +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. - * - ********************************************************************** - * Category D: UTXO & Fee Integration - * - * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, - * dust floors, and change output logic with realistic ACTION payloads. - */ - -const assert = require('assert') -const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') -const { - TXID_A, - TXID_B, - TXID_C, - PUBKEY_BUF, - makeUtxo, - makeLegacyUtxo, - makeMempoolUtxo, - makeTrackerEnvelope, - makeEncoder, - getTestAddress, - buildRawTxHex -} = require('../helpers/utxoFactory') -const actions = require('../helpers/actionFactory') - -// These integration cases encode BITCOIN fee semantics (explicit fees honored -// verbatim, sub-estimate fees, 546 dust). The network was mislabeled -// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the -// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the -// semantics these assertions actually test. (DOGE's high-dust floor is covered -// in test/boundary/fee_calculation_boundaries.test.js.) -const NETWORK = 'bitcoin-regtest' - -describe('Category D: UTXO & Fee Integration', () => { - - describe('D-2: Multiple UTXOs needed', () => { - it('adds UTXOs until inputs cover outputs + fee', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - // Three small UTXOs: 1000 + 1000 + 1000 = 3000 sats - // With fee of 2000, first UTXO (sorted largest=1000) won't cover it, - // so encoder must add more - const utxo1 = makeUtxo(NETWORK, TXID_A, 0, 1000) - const utxo2 = makeUtxo(NETWORK, TXID_B, 0, 1000) - const utxo3 = makeUtxo(NETWORK, TXID_C, 0, 1000) - - const result = await encoder.createTransaction( - [utxo1, utxo2, utxo3], address, null, - action.data, null, 2000, false, null, address, - null, null, null, true, 0.00001 - ) - - // Should need multiple inputs to cover the fee - assert.ok(result.psbt.data.inputs.length >= 2, - `expected >= 2 inputs, got ${result.psbt.data.inputs.length}`) - }) - }) -}) diff --git a/test/integration/utxo_fee.test/02_d_3_duplicate_utxo_deduplication.test.js b/test/integration/utxo_fee.test/02_d_3_duplicate_utxo_deduplication.test.js deleted file mode 100644 index 171af17..0000000 --- a/test/integration/utxo_fee.test/02_d_3_duplicate_utxo_deduplication.test.js +++ /dev/null @@ -1,70 +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. - * - ********************************************************************** - * Category D: UTXO & Fee Integration - * - * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, - * dust floors, and change output logic with realistic ACTION payloads. - */ - -const assert = require('assert') -const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') -const { - TXID_A, - TXID_B, - TXID_C, - PUBKEY_BUF, - makeUtxo, - makeLegacyUtxo, - makeMempoolUtxo, - makeTrackerEnvelope, - makeEncoder, - getTestAddress, - buildRawTxHex -} = require('../helpers/utxoFactory') -const actions = require('../helpers/actionFactory') - -// These integration cases encode BITCOIN fee semantics (explicit fees honored -// verbatim, sub-estimate fees, 546 dust). The network was mislabeled -// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the -// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the -// semantics these assertions actually test. (DOGE's high-dust floor is covered -// in test/boundary/fee_calculation_boundaries.test.js.) -const NETWORK = 'bitcoin-regtest' - -describe('Category D: UTXO & Fee Integration', () => { - - describe('D-3: Duplicate UTXO deduplication', () => { - it('removes duplicate UTXOs with same txid+vout', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - const dup1 = makeUtxo(NETWORK, TXID_A, 0, 50000000) - const dup2 = makeUtxo(NETWORK, TXID_A, 0, 50000000) - const dup3 = makeUtxo(NETWORK, TXID_A, 0, 50000000) - const unique = makeUtxo(NETWORK, TXID_B, 1, 30000000) - - const result = await encoder.createTransaction( - [dup1, dup2, dup3, unique], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - // Should have at most 2 unique UTXOs as inputs - assert.ok(result.psbt.data.inputs.length <= 2, - `expected <= 2 inputs after dedup, got ${result.psbt.data.inputs.length}`) - }) - }) -}) diff --git a/test/integration/utxo_fee.test/03_d_4_unconfirmed_filtering.test.js b/test/integration/utxo_fee.test/03_d_4_unconfirmed_filtering.test.js deleted file mode 100644 index 15c0f81..0000000 --- a/test/integration/utxo_fee.test/03_d_4_unconfirmed_filtering.test.js +++ /dev/null @@ -1,83 +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. - * - ********************************************************************** - * Category D: UTXO & Fee Integration - * - * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, - * dust floors, and change output logic with realistic ACTION payloads. - */ - -const assert = require('assert') -const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') -const { - TXID_A, - TXID_B, - TXID_C, - PUBKEY_BUF, - makeUtxo, - makeLegacyUtxo, - makeMempoolUtxo, - makeTrackerEnvelope, - makeEncoder, - getTestAddress, - buildRawTxHex -} = require('../helpers/utxoFactory') -const actions = require('../helpers/actionFactory') - -// These integration cases encode BITCOIN fee semantics (explicit fees honored -// verbatim, sub-estimate fees, 546 dust). The network was mislabeled -// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the -// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the -// semantics these assertions actually test. (DOGE's high-dust floor is covered -// in test/boundary/fee_calculation_boundaries.test.js.) -const NETWORK = 'bitcoin-regtest' - -describe('Category D: UTXO & Fee Integration', () => { - - describe('D-4: Unconfirmed filtering with unconfirmed=false', () => { - it('excludes mempool UTXOs when unconfirmed=false', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - const confirmed = makeUtxo(NETWORK, TXID_A, 0, 100000000) - confirmed.confirmations = 6 - const mempool = makeMempoolUtxo(TXID_B, 0, 50000000) - - const result = await encoder.createTransaction( - [mempool, confirmed], address, null, - action.data, null, 10000, false, null, address, - null, null, null, false, 0.00001 - ) - - assert.strictEqual(result.psbt.data.inputs.length, 1) - }) - - it('includes mempool UTXOs when unconfirmed=true', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - const mempool = makeMempoolUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [mempool], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.psbt.data.inputs.length, 1) - }) - }) -}) diff --git a/test/integration/utxo_fee.test/04_d_5_utxo_tracker_fallback.test.js b/test/integration/utxo_fee.test/04_d_5_utxo_tracker_fallback.test.js deleted file mode 100644 index 788d4c3..0000000 --- a/test/integration/utxo_fee.test/04_d_5_utxo_tracker_fallback.test.js +++ /dev/null @@ -1,89 +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. - * - ********************************************************************** - * Category D: UTXO & Fee Integration - * - * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, - * dust floors, and change output logic with realistic ACTION payloads. - */ - -const assert = require('assert') -const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') -const { - TXID_A, - TXID_B, - TXID_C, - PUBKEY_BUF, - makeUtxo, - makeLegacyUtxo, - makeMempoolUtxo, - makeTrackerEnvelope, - makeEncoder, - getTestAddress, - buildRawTxHex -} = require('../helpers/utxoFactory') -const actions = require('../helpers/actionFactory') - -// These integration cases encode BITCOIN fee semantics (explicit fees honored -// verbatim, sub-estimate fees, 546 dust). The network was mislabeled -// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the -// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the -// semantics these assertions actually test. (DOGE's high-dust floor is covered -// in test/boundary/fee_calculation_boundaries.test.js.) -const NETWORK = 'bitcoin-regtest' - -describe('Category D: UTXO & Fee Integration', () => { - - describe('D-5: UtxoTracker fallback', () => { - it('calls UtxoTracker when utxos param is null', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - let trackerCalled = false - encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { - trackerCalled = true - return { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } - } - - await encoder.createTransaction( - null, address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(trackerCalled, true) - }) - - it('calls UtxoTracker when utxos param is empty array', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - let trackerCalled = false - encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { - trackerCalled = true - return { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } - } - - await encoder.createTransaction( - [], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(trackerCalled, true) - }) - }) -}) diff --git a/test/integration/utxo_fee.test/05_d_6_no_utxos_available.test.js b/test/integration/utxo_fee.test/05_d_6_no_utxos_available.test.js deleted file mode 100644 index c5894ee..0000000 --- a/test/integration/utxo_fee.test/05_d_6_no_utxos_available.test.js +++ /dev/null @@ -1,87 +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. - * - ********************************************************************** - * Category D: UTXO & Fee Integration - * - * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, - * dust floors, and change output logic with realistic ACTION payloads. - */ - -const assert = require('assert') -const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') -const { - TXID_A, - TXID_B, - TXID_C, - PUBKEY_BUF, - makeUtxo, - makeLegacyUtxo, - makeMempoolUtxo, - makeTrackerEnvelope, - makeEncoder, - getTestAddress, - buildRawTxHex -} = require('../helpers/utxoFactory') -const actions = require('../helpers/actionFactory') - -// These integration cases encode BITCOIN fee semantics (explicit fees honored -// verbatim, sub-estimate fees, 546 dust). The network was mislabeled -// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the -// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the -// semantics these assertions actually test. (DOGE's high-dust floor is covered -// in test/boundary/fee_calculation_boundaries.test.js.) -const NETWORK = 'bitcoin-regtest' - -describe('Category D: UTXO & Fee Integration', () => { - - describe('D-6: No UTXOs available', () => { - it('throws when utxos empty and tracker returns empty', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ - utxos: [] - }) - - await assert.rejects( - () => encoder.createTransaction( - [], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ), - /no utxos/i - ) - }) - - it('throws when utxos null and tracker returns null', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ - utxos: null - }) - - await assert.rejects( - () => encoder.createTransaction( - null, address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ), - /no utxos/i - ) - }) - }) -}) diff --git a/test/integration/utxo_fee.test/06_d_7_fee_capped_by_max_fee_per_bytes.test.js b/test/integration/utxo_fee.test/06_d_7_fee_capped_by_max_fee_per_bytes.test.js deleted file mode 100644 index 07d0558..0000000 --- a/test/integration/utxo_fee.test/06_d_7_fee_capped_by_max_fee_per_bytes.test.js +++ /dev/null @@ -1,89 +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. - * - ********************************************************************** - * Category D: UTXO & Fee Integration - * - * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, - * dust floors, and change output logic with realistic ACTION payloads. - */ - -const assert = require('assert') -const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') -const { - TXID_A, - TXID_B, - TXID_C, - PUBKEY_BUF, - makeUtxo, - makeLegacyUtxo, - makeMempoolUtxo, - makeTrackerEnvelope, - makeEncoder, - getTestAddress, - buildRawTxHex -} = require('../helpers/utxoFactory') -const actions = require('../helpers/actionFactory') - -// These integration cases encode BITCOIN fee semantics (explicit fees honored -// verbatim, sub-estimate fees, 546 dust). The network was mislabeled -// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the -// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the -// semantics these assertions actually test. (DOGE's high-dust floor is covered -// in test/boundary/fee_calculation_boundaries.test.js.) -const NETWORK = 'bitcoin-regtest' - -describe('Category D: UTXO & Fee Integration', () => { - - describe('D-7: Fee capped by maxFeePerBytes', () => { - it('limits fee when maxFeeRateKb is set', async () => { - // Create encoder WITH fee cap - const capped = new XChainEncoder( - NETWORK, '127.0.0.1', '8333', 'rpc', 'rpc', '', '', 1000 // 1000 sat/kB cap - ) - capped.connector = { - getFeePerKilobyte: async () => 0.00001, - getTransactionHex: async () => ({ hex: buildRawTxHex(100000000, NETWORK) }), - isRegtest: async () => true - } - capped.utxoTrackerConnector = { - getUtxosFromAddress: async () => makeTrackerEnvelope([makeUtxo(NETWORK, TXID_A, 0, 100000000)]) - } - - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - // Use a very high feePerKb that exceeds the cap - const result = await capped.createTransaction( - [utxo], address, null, - action.data, null, null, false, null, address, - null, null, null, true, 100000000 // very high: 1e8 sat/kB = 100000 sat/byte - ) - - // Create uncapped encoder for comparison - const uncapped = makeEncoder(NETWORK) - const resultUncapped = await uncapped.createTransaction( - [utxo], address, null, - action.data, null, null, false, null, address, - null, null, null, true, 100000000 - ) - - // Capped encoder should produce lower fee (more change) - const cappedChange = result.psbt.txOutputs.find(o => o.value > 0) - const uncappedChange = resultUncapped.psbt.txOutputs.find(o => o.value > 0) - assert.ok(cappedChange.value > uncappedChange.value, - 'capped fee should leave more change than uncapped') - }) - }) -}) diff --git a/test/integration/utxo_fee.test/07_d_8_dust_floor_on_fee.test.js b/test/integration/utxo_fee.test/07_d_8_dust_floor_on_fee.test.js deleted file mode 100644 index d86ce5e..0000000 --- a/test/integration/utxo_fee.test/07_d_8_dust_floor_on_fee.test.js +++ /dev/null @@ -1,67 +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. - * - ********************************************************************** - * Category D: UTXO & Fee Integration - * - * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, - * dust floors, and change output logic with realistic ACTION payloads. - */ - -const assert = require('assert') -const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') -const { - TXID_A, - TXID_B, - TXID_C, - PUBKEY_BUF, - makeUtxo, - makeLegacyUtxo, - makeMempoolUtxo, - makeTrackerEnvelope, - makeEncoder, - getTestAddress, - buildRawTxHex -} = require('../helpers/utxoFactory') -const actions = require('../helpers/actionFactory') - -// These integration cases encode BITCOIN fee semantics (explicit fees honored -// verbatim, sub-estimate fees, 546 dust). The network was mislabeled -// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the -// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the -// semantics these assertions actually test. (DOGE's high-dust floor is covered -// in test/boundary/fee_calculation_boundaries.test.js.) -const NETWORK = 'bitcoin-regtest' - -describe('Category D: UTXO & Fee Integration', () => { - - describe('D-8: Dust floor on fee', () => { - it('floors fee to dustAmount when computed fee is lower', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, null, null, false, null, address, - null, null, null, true, 0.0000001 // very low fee rate - ) - - const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) - const impliedFee = 100000000 - changeOutput.value - assert.ok(impliedFee >= encoder.dustAmount, - `fee ${impliedFee} should be >= dustAmount ${encoder.dustAmount}`) - }) - }) -}) diff --git a/test/integration/utxo_fee.test/08_d_9_no_change_address.test.js b/test/integration/utxo_fee.test/08_d_9_no_change_address.test.js deleted file mode 100644 index dfb172b..0000000 --- a/test/integration/utxo_fee.test/08_d_9_no_change_address.test.js +++ /dev/null @@ -1,65 +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. - * - ********************************************************************** - * Category D: UTXO & Fee Integration - * - * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, - * dust floors, and change output logic with realistic ACTION payloads. - */ - -const assert = require('assert') -const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') -const { - TXID_A, - TXID_B, - TXID_C, - PUBKEY_BUF, - makeUtxo, - makeLegacyUtxo, - makeMempoolUtxo, - makeTrackerEnvelope, - makeEncoder, - getTestAddress, - buildRawTxHex -} = require('../helpers/utxoFactory') -const actions = require('../helpers/actionFactory') - -// These integration cases encode BITCOIN fee semantics (explicit fees honored -// verbatim, sub-estimate fees, 546 dust). The network was mislabeled -// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the -// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the -// semantics these assertions actually test. (DOGE's high-dust floor is covered -// in test/boundary/fee_calculation_boundaries.test.js.) -const NETWORK = 'bitcoin-regtest' - -describe('Category D: UTXO & Fee Integration', () => { - - describe('D-9: No change address throws when change > dust', () => { - it('throws error about burning satoshis', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - await assert.rejects( - () => encoder.createTransaction( - [utxo], address, null, - action.data, null, 10000, false, null, null, // no change address - null, null, null, true, 0.00001 - ), - /change address/i - ) - }) - }) -}) diff --git a/test/integration/utxo_fee.test/09_d_10_legacy_non_segwit_utxo_handling.test.js b/test/integration/utxo_fee.test/09_d_10_legacy_non_segwit_utxo_handling.test.js deleted file mode 100644 index a06182f..0000000 --- a/test/integration/utxo_fee.test/09_d_10_legacy_non_segwit_utxo_handling.test.js +++ /dev/null @@ -1,98 +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. - * - ********************************************************************** - * Category D: UTXO & Fee Integration - * - * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, - * dust floors, and change output logic with realistic ACTION payloads. - */ - -const assert = require('assert') -const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') -const { - TXID_A, - TXID_B, - TXID_C, - PUBKEY_BUF, - makeUtxo, - makeLegacyUtxo, - makeMempoolUtxo, - makeTrackerEnvelope, - makeEncoder, - getTestAddress, - buildRawTxHex -} = require('../helpers/utxoFactory') -const actions = require('../helpers/actionFactory') - -// These integration cases encode BITCOIN fee semantics (explicit fees honored -// verbatim, sub-estimate fees, 546 dust). The network was mislabeled -// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the -// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the -// semantics these assertions actually test. (DOGE's high-dust floor is covered -// in test/boundary/fee_calculation_boundaries.test.js.) -const NETWORK = 'bitcoin-regtest' - -describe('Category D: UTXO & Fee Integration', () => { - - describe('D-10: Legacy (non-segwit) UTXO handling', () => { - it('fetches raw tx hex for nonWitnessUtxo via connector', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - let getHexCalled = false - const rawHex = buildRawTxHex(100000000, NETWORK) - encoder.connector.getTransactionHex = async () => { - getHexCalled = true - return rawHex - } - - const utxo = makeLegacyUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(getHexCalled, true, - 'should call getTransactionHex for legacy UTXOs') - assert.ok(result.psbt.data.inputs[0].nonWitnessUtxo, - 'input should have nonWitnessUtxo') - }) - - it('segwit UTXOs do NOT call getTransactionHex', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - let getHexCalled = false - encoder.connector.getTransactionHex = async () => { - getHexCalled = true - return { hex: buildRawTxHex(100000000, NETWORK) } - } - - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - - await encoder.createTransaction( - [utxo], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(getHexCalled, false, - 'should NOT call getTransactionHex for segwit UTXOs') - }) - }) -}) diff --git a/test/integration/utxo_fee.test/10_utxo_sorting_largest_first.test.js b/test/integration/utxo_fee.test/10_utxo_sorting_largest_first.test.js deleted file mode 100644 index 6b2ed54..0000000 --- a/test/integration/utxo_fee.test/10_utxo_sorting_largest_first.test.js +++ /dev/null @@ -1,67 +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. - * - ********************************************************************** - * Category D: UTXO & Fee Integration - * - * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, - * dust floors, and change output logic with realistic ACTION payloads. - */ - -const assert = require('assert') -const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') -const { - TXID_A, - TXID_B, - TXID_C, - PUBKEY_BUF, - makeUtxo, - makeLegacyUtxo, - makeMempoolUtxo, - makeTrackerEnvelope, - makeEncoder, - getTestAddress, - buildRawTxHex -} = require('../helpers/utxoFactory') -const actions = require('../helpers/actionFactory') - -// These integration cases encode BITCOIN fee semantics (explicit fees honored -// verbatim, sub-estimate fees, 546 dust). The network was mislabeled -// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the -// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the -// semantics these assertions actually test. (DOGE's high-dust floor is covered -// in test/boundary/fee_calculation_boundaries.test.js.) -const NETWORK = 'bitcoin-regtest' - -describe('Category D: UTXO & Fee Integration', () => { - - describe('UTXO sorting (largest first)', () => { - it('uses largest UTXO first, needing fewer inputs', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - const small = makeUtxo(NETWORK, TXID_A, 0, 10000000) // 0.1 BTC - const large = makeUtxo(NETWORK, TXID_B, 0, 100000000) // 1 BTC - - const result = await encoder.createTransaction( - [small, large], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - // The large UTXO alone covers everything, so only 1 input needed - assert.strictEqual(result.psbt.data.inputs.length, 1) - }) - }) -}) diff --git a/test/integration/utxo_fee.test/11_replace_by_fee_sequence.test.js b/test/integration/utxo_fee.test/11_replace_by_fee_sequence.test.js deleted file mode 100644 index afe9b8c..0000000 --- a/test/integration/utxo_fee.test/11_replace_by_fee_sequence.test.js +++ /dev/null @@ -1,79 +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. - * - ********************************************************************** - * Category D: UTXO & Fee Integration - * - * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, - * dust floors, and change output logic with realistic ACTION payloads. - */ - -const assert = require('assert') -const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') -const { - TXID_A, - TXID_B, - TXID_C, - PUBKEY_BUF, - makeUtxo, - makeLegacyUtxo, - makeMempoolUtxo, - makeTrackerEnvelope, - makeEncoder, - getTestAddress, - buildRawTxHex -} = require('../helpers/utxoFactory') -const actions = require('../helpers/actionFactory') - -// These integration cases encode BITCOIN fee semantics (explicit fees honored -// verbatim, sub-estimate fees, 546 dust). The network was mislabeled -// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the -// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the -// semantics these assertions actually test. (DOGE's high-dust floor is covered -// in test/boundary/fee_calculation_boundaries.test.js.) -const NETWORK = 'bitcoin-regtest' - -describe('Category D: UTXO & Fee Integration', () => { - - describe('Replace-by-fee sequence', () => { - it('sets sequence to 0xfffffffd when rbf=true (RBF armed, BIP68 disabled)', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, null, 10000, true, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.psbt.txInputs[0].sequence, 0xfffffffd) - }) - - it('sets sequence to 0xffffffff when rbf=false', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.psbt.txInputs[0].sequence, 0xffffffff) - }) - }) -}) diff --git a/test/integration/utxo_fee.test/12_fee_source_selection.test.js b/test/integration/utxo_fee.test/12_fee_source_selection.test.js deleted file mode 100644 index 9777764..0000000 --- a/test/integration/utxo_fee.test/12_fee_source_selection.test.js +++ /dev/null @@ -1,88 +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. - * - ********************************************************************** - * Category D: UTXO & Fee Integration - * - * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, - * dust floors, and change output logic with realistic ACTION payloads. - */ - -const assert = require('assert') -const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') -const { - TXID_A, - TXID_B, - TXID_C, - PUBKEY_BUF, - makeUtxo, - makeLegacyUtxo, - makeMempoolUtxo, - makeTrackerEnvelope, - makeEncoder, - getTestAddress, - buildRawTxHex -} = require('../helpers/utxoFactory') -const actions = require('../helpers/actionFactory') - -// These integration cases encode BITCOIN fee semantics (explicit fees honored -// verbatim, sub-estimate fees, 546 dust). The network was mislabeled -// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the -// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the -// semantics these assertions actually test. (DOGE's high-dust floor is covered -// in test/boundary/fee_calculation_boundaries.test.js.) -const NETWORK = 'bitcoin-regtest' - -describe('Category D: UTXO & Fee Integration', () => { - - describe('Fee source selection', () => { - it('uses feePerKb parameter without RPC call', async () => { - const encoder = makeEncoder(NETWORK) - encoder.connector.getFeePerKilobyte = async () => { - throw new Error('should not be called') - } - - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - // Should not throw because feePerKb is provided - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, null, null, false, null, address, - null, null, null, true, 0.00001 - ) - assert.ok(result.psbt) - }) - - it('calls connector.getFeePerKilobyte when feePerKb is null', async () => { - const encoder = makeEncoder(NETWORK) - let called = false - encoder.connector.getFeePerKilobyte = async () => { - called = true - return 0.00001 - } - - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - await encoder.createTransaction( - [utxo], address, null, - action.data, null, null, false, null, address, - null, null, null, true, null - ) - assert.strictEqual(called, true) - }) - }) -}) diff --git a/test/prepare_regtest.test.js b/test/prepare_regtest.test.js index a424f6b..9ddffd1 100644 --- a/test/prepare_regtest.test.js +++ b/test/prepare_regtest.test.js @@ -12,72 +12,77 @@ * ********************************************************************/ -const { execSync } = require('child_process'); +'use strict' + +process.env.DOTENV_CONFIG_PATH = '/dev/null' + +const { execFileSync } = require('child_process') +const { rmSync } = require('fs') +const { homedir } = require('os') +const path = require('path') const nodeHelper = require('./helpers/node_helper') const { waitFor } = require('./helpers/timing') -// Función para ejecutar comandos del sistema -function executeCommand(comando) { - let options = {stdio : 'pipe' }; - return execSync(comando, options); +const RESET_OPT_IN = 'XCHAIN_RESET_REGTEST' + +function executeCommand (command, args) { + return execFileSync(command, args, { stdio: 'pipe' }) } -function checkNode(){ +function checkNode () { try { - // Obtener la información de la red utilizando bitcoin-cli - const networkInfo = executeCommand('bitcoin-cli -regtest getnetworkinfo'); - - // Analizar la salida para verificar si el nodo regtest está en ejecución - const parsedNetworkInfo = JSON.parse(networkInfo); - return parsedNetworkInfo.networkactive === true; + const networkInfo = executeCommand('bitcoin-cli', ['-regtest', 'getnetworkinfo']) + return JSON.parse(networkInfo).networkactive === true } catch (error) { - // Manejar errores si es necesario - return false + return false } } exports.mochaHooks = { - async beforeAll(){ - if (checkNode()){ - // Stop regtest node - console.log("Stopping node") - executeCommand('bitcoin-cli -regtest stop'); - } else { - console.log("The node is not working, continuing execution") - //Assuming regtest node is not executing - } + async beforeAll () { + if (process.env[RESET_OPT_IN] !== '1') { + console.log(`Skipping regtest reset; set ${RESET_OPT_IN}=1 to opt in`) + return + } + + if (checkNode()) { + console.log('Stopping node') + executeCommand('bitcoin-cli', ['-regtest', 'stop']) + } else { + console.log('The node is not working, continuing execution') + } + + console.log('Cleaning node') + rmSync(path.join(homedir(), '.bitcoin', 'regtest'), { + force: true, + recursive: true + }) - // Limpiar la cadena de bloques y los datos del nodo regtest (opcional) - console.log("Cleaning node") - executeCommand('rm -rf ~/.bitcoin/regtest'); + console.log('Restarting node') + executeCommand('bitcoind', [ + '-regtest', + '-daemon', + '-fallbackfee=1.0', + '-maxtxfee=1.1' + ]) - // Inicializar el nodo regtest - console.log("Restarting node") - executeCommand('bitcoind -regtest -daemon -fallbackfee=1.0 -maxtxfee=1.1'); + console.log('Checking node') + await waitFor(() => checkNode(), { + timeout: 60000, + interval: 1000, + message: 'regtest bitcoind did not become ready' + }) - console.log("Checking node") - // Condition-wait on daemon readiness instead of a fixed sleep loop: - // returns as soon as getnetworkinfo succeeds, and throws (failing the - // run loudly) if the node never comes up within the deadline. - await waitFor(() => checkNode(), { - timeout: 60000, - interval: 1000, - message: 'regtest bitcoind did not become ready' - }) + console.log('Regtest node reset and ready') + console.log("Creating the wallet 'test-wallet'") + const nodeClientTest = await nodeHelper.getWalletConnection('test-wallet') - // Puedes realizar más acciones después de reiniciar el nodo si es necesario - console.log('Nodo regtest reset and ready.'); - - console.log("Creating the wallet 'test-wallet'") - nodeClientTest = await nodeHelper.getWalletConnection('test-wallet') - - console.log("Obtaining an address") - global.mainTestAddress = await nodeClientTest.getNewAddress() - console.log("The address obtained is "+mainTestAddress+". Generating blocks.") - await nodeClientTest.generateToAddress(101, mainTestAddress) - console.log("Obtaining balance") - let balance = await nodeClientTest.getBalance() - console.log("The address "+mainTestAddress+" has "+balance+" BTC") - + console.log('Obtaining an address') + global.mainTestAddress = await nodeClientTest.getNewAddress() + console.log(`The address obtained is ${global.mainTestAddress}. Generating blocks.`) + await nodeClientTest.generateToAddress(101, global.mainTestAddress) + console.log('Obtaining balance') + const balance = await nodeClientTest.getBalance() + console.log(`The address ${global.mainTestAddress} has ${balance} BTC`) } } From 74ac11a34eb89d8178b1f35eac8f87692d4f2f07 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 17:49:18 -0700 Subject: [PATCH 03/34] Regenerate the suite-title pin after retiring the duplicate fee scenarios The pin still listed the thirteen removed utxo_fee suites, so the suite identity gate reported them as dropped. --- bin/pins/at1-suite-titles.json | 64 ++-------------------------------- 1 file changed, 3 insertions(+), 61 deletions(-) diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index 30f2984..8262b13 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -136,10 +136,6 @@ "E2E-5: UTXO, Fee, and Change Integration E2E-5.6: UtxoTracker fallback calls UtxoTracker when utxos is empty array", "E2E-5: UTXO, Fee, and Change Integration E2E-5.6: UtxoTracker fallback calls UtxoTracker when utxos is null" ], - "0e4bd255929ec3b3": [ - "Category D: UTXO & Fee Integration D-5: UtxoTracker fallback calls UtxoTracker when utxos param is empty array", - "Category D: UTXO & Fee Integration D-5: UtxoTracker fallback calls UtxoTracker when utxos param is null" - ], "0e58538e3d6aa84b": [ "Encoder input validator validateDust null/false pass; coerces; rejects NaN and negative" ], @@ -295,9 +291,6 @@ "Chaos Category F: API Layer Failures F-3: psbt.toHex() callability (api.js line 124 exposure) P2SH result: psbt.toHex() succeeds", "Chaos Category F: API Layer Failures F-3b: psbt.toHex() monkey-patch (documents api.js exposure) toHex failure after successful createTransaction \u2192 unhandled in api.js" ], - "171d238e570fea73": [ - "Category D: UTXO & Fee Integration D-3: Duplicate UTXO deduplication removes duplicate UTXOs with same txid+vout" - ], "19bbd52a62e424d4": [ "browser bundle entry (src/browser/index.js) attaches the encoder class to window as the very class module", "browser bundle entry (src/browser/index.js) exports the same class it attaches, not a copy or wrapper", @@ -383,9 +376,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" ], - "271d27a1e5622be1": [ - "Category D: UTXO & Fee Integration D-9: No change address throws when change > dust throws error about burning satoshis" - ], "27d7bba206f363af": [ "UtxoTracker.getUtxosFromAddress() accepts an empty utxos array", "UtxoTracker.getUtxosFromAddress() handles multiple valid UTXOs correctly", @@ -547,10 +537,6 @@ "REG-07: Action Pipeline Regression REG-07.9: Special characters in payload TICK name with special chars preserved", "REG-07: Action Pipeline Regression REG-07.9: Special characters in payload TICK_ID caret prefix preserved" ], - "3b83f98eb236d6d9": [ - "Category D: UTXO & Fee Integration Fee source selection calls connector.getFeePerKilobyte when feePerKb is null", - "Category D: UTXO & Fee Integration Fee source selection uses feePerKb parameter without RPC call" - ], "3d0c3f469f5a49b0": [ "E2E-5: UTXO, Fee, and Change Integration E2E-5.4: Unconfirmed UTXOs excluded filters mempool UTXOs when unconfirmed=false" ], @@ -946,9 +932,6 @@ "P2SH two-phase package prefund @regression @tier1 funds the package rate into the leg when the commit pays under target", "P2SH two-phase package prefund @regression @tier1 the reveal keeps the package money as fee instead of sweeping it back" ], - "70440ad9037c909f": [ - "Category D: UTXO & Fee Integration D-1: Single large UTXO covers everything produces 1 input, 2 outputs (OP_RETURN + change)" - ], "71168814d61ac771": [ "Encoding Chunk Boundaries: Full Pipeline OP_RETURN auto-select threshold (75/76 chars) 74-char data (compiled=75) \u2192 OP_RETURN (75+4=79 < 80)", "Encoding Chunk Boundaries: Full Pipeline OP_RETURN auto-select threshold (75/76 chars) 75-char data (compiled=76) \u2192 OP_RETURN (exactly fits 76+4=80)", @@ -1122,9 +1105,6 @@ "XChainEncoder package-aware fee sizing @regression @tier1 never spends more than the inputs hold", "XChainEncoder package-aware fee sizing @regression @tier1 never throws when the ancestor lookup itself throws" ], - "8f0e3d8435a01701": [ - "Category D: UTXO & Fee Integration D-7: Fee capped by maxFeePerBytes limits fee when maxFeeRateKb is set" - ], "8f324f20d5abfee5": [ "XChainEncoder.createTransaction() - change edge cases does not add change output when changeSatoshis is 0", "XChainEncoder.createTransaction() - change edge cases does not throw when changeSatoshis <= dustAmount and no change address" @@ -1318,10 +1298,6 @@ "a58c0275055cc988": [ "Encoder input validator validateChange null passes; rejects empty and over-long" ], - "a5c9e585e49afb1a": [ - "Category D: UTXO & Fee Integration Replace-by-fee sequence sets sequence to 0xfffffffd when rbf=true (RBF armed, BIP68 disabled)", - "Category D: UTXO & Fee Integration Replace-by-fee sequence sets sequence to 0xffffffff when rbf=false" - ], "a6d0a7d54f8fdf52": [ "health()/GET /status carry the window without bending readiness @regression a window does not make a lagging tracker read synced", "health()/GET /status carry the window without bending readiness @regression an expired sentinel reads as no maintenance", @@ -1396,9 +1372,6 @@ "E2E-6: Multi-Chain Validation dogecoin-regtest P2SH creates P2SH with output value >= chain dust threshold", "E2E-6: Multi-Chain Validation litecoin-regtest P2SH creates P2SH with output value >= chain dust threshold" ], - "ab3024540b6f92b9": [ - "Category D: UTXO & Fee Integration D-8: Dust floor on fee floors fee to dustAmount when computed fee is lower" - ], "ab75df16ddef33d7": [ "encoder crash handlers a broken logger cannot swallow the exit", "encoder crash handlers a non-Error rejection reason still yields a readable record", @@ -1448,10 +1421,6 @@ "TxSizeEstimator Boundaries estimateP2shInputWithRedeem returns 629 for 476-byte redeem script (max P2SH chunk)", "TxSizeEstimator Boundaries estimateP2shInputWithRedeem returns overhead (149) for 0-byte redeem script" ], - "b32e58f0542ec761": [ - "Category D: UTXO & Fee Integration D-10: Legacy (non-segwit) UTXO handling fetches raw tx hex for nonWitnessUtxo via connector", - "Category D: UTXO & Fee Integration D-10: Legacy (non-segwit) UTXO handling segwit UTXOs do NOT call getTransactionHex" - ], "b35bd4199facec7a": [ "XChainEncoder.isSegwitUTXO() returns false for P2PKH", "XChainEncoder.isSegwitUTXO() returns false for P2SH", @@ -1674,9 +1643,6 @@ "XChainEncoder.createTransaction() custom outputs skips custom outputs when customOutputs is not an array", "XChainEncoder.createTransaction() custom outputs skips custom outputs when customOutputs is null" ], - "ca5016508c9204fa": [ - "Category D: UTXO & Fee Integration UTXO sorting (largest first) uses largest UTXO first, needing fewer inputs" - ], "ca8fa416b5e8a763": [ "observability/installObservability buckets an unmatched path by first segment so URLs cannot explode cardinality", "observability/installObservability gates the endpoint behind METRICS_TOKEN when one is configured", @@ -1857,10 +1823,6 @@ "REG-04: Validator Functions REG-04.12: validateCompressedPubKey() throws TypeError for non-string", "REG-04: Validator Functions REG-04.12: validateCompressedPubKey() throws TypeError for wrong length (too short)" ], - "d650883504da9b2d": [ - "Category D: UTXO & Fee Integration D-4: Unconfirmed filtering with unconfirmed=false excludes mempool UTXOs when unconfirmed=false", - "Category D: UTXO & Fee Integration D-4: Unconfirmed filtering with unconfirmed=false includes mempool UTXOs when unconfirmed=true" - ], "d683ae27a964eb5b": [ "XChainEncoder.createTransaction() - rawData parameter accepts rawData and includes it in the compiled payload", "XChainEncoder.createTransaction() - rawData parameter rawData does not affect result when null (false branch of rawData != null)" @@ -1909,9 +1871,6 @@ "REG-04: Validator Functions REG-04.14: validateAll() integration throws a SHAPE error for positional array params, not a missing-field one", "REG-04: Validator Functions REG-04.14: validateAll() integration validates P2SH pair atomically (rejects partial pair)" ], - "df667f23a4c0ba6d": [ - "Category D: UTXO & Fee Integration D-2: Multiple UTXOs needed adds UTXOs until inputs cover outputs + fee" - ], "dfdfc61603d906bd": [ "encoder FILE payload compression (spec Part B) validator accepts compress as an optional boolean; absent means \"use the deployment default\"" ], @@ -2013,10 +1972,6 @@ "Encoder input validator validateP2shParams enforces hex shape and the raw-tx length cap on p2shHex", "Encoder input validator validateP2shParams validates hash hex and non-empty hex; returns the pair" ], - "ee1005d70e3a9b6a": [ - "Category D: UTXO & Fee Integration D-6: No UTXOs available throws when utxos empty and tracker returns empty", - "Category D: UTXO & Fee Integration D-6: No UTXOs available throws when utxos null and tracker returns null" - ], "ef8432a4a1ba859e": [ "REG-09: 2026-07-03 deepdive encoder fixes FIX K: get_utxos validates the address param before the tracker call rejects an object-valued address with code -32602", "REG-09: 2026-07-03 deepdive encoder fixes FIX K: get_utxos validates the address param before the tracker call rejects an over-length (>100 char) address with code -32602", @@ -2427,8 +2382,8 @@ } }, "test:integration": { - "fileCount": 21, - "titleCount": 115, + "fileCount": 8, + "titleCount": 96, "files": { "test/integration/action_encoding.test.js": "9adb839d3209d4b9", "test/integration/custom_outputs.test.js": "1a362ab39ee459fd", @@ -2437,20 +2392,7 @@ "test/integration/error_handling.test.js": "06a3215a6e86ed2d", "test/integration/large_satoshi_tracker.test.js": "6b03cb1158d948c7", "test/integration/multi_chain.test.js": "e4c3435f9bc2b0af", - "test/integration/obfuscation_roundtrip.test.js": "d101790f46691c56", - "test/integration/utxo_fee.test.js": "70440ad9037c909f", - "test/integration/utxo_fee.test/01_d_2_multiple_utxos_needed.test.js": "df667f23a4c0ba6d", - "test/integration/utxo_fee.test/02_d_3_duplicate_utxo_deduplication.test.js": "171d238e570fea73", - "test/integration/utxo_fee.test/03_d_4_unconfirmed_filtering.test.js": "d650883504da9b2d", - "test/integration/utxo_fee.test/04_d_5_utxo_tracker_fallback.test.js": "0e4bd255929ec3b3", - "test/integration/utxo_fee.test/05_d_6_no_utxos_available.test.js": "ee1005d70e3a9b6a", - "test/integration/utxo_fee.test/06_d_7_fee_capped_by_max_fee_per_bytes.test.js": "8f0e3d8435a01701", - "test/integration/utxo_fee.test/07_d_8_dust_floor_on_fee.test.js": "ab3024540b6f92b9", - "test/integration/utxo_fee.test/08_d_9_no_change_address.test.js": "271d27a1e5622be1", - "test/integration/utxo_fee.test/09_d_10_legacy_non_segwit_utxo_handling.test.js": "b32e58f0542ec761", - "test/integration/utxo_fee.test/10_utxo_sorting_largest_first.test.js": "ca5016508c9204fa", - "test/integration/utxo_fee.test/11_replace_by_fee_sequence.test.js": "a5c9e585e49afb1a", - "test/integration/utxo_fee.test/12_fee_source_selection.test.js": "3b83f98eb236d6d9" + "test/integration/obfuscation_roundtrip.test.js": "d101790f46691c56" } }, "test:regression": { From 5ea7e879ff212135c372905b6cab9e103eb9ae55 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 17:54:44 -0700 Subject: [PATCH 04/34] Require usable siblings in full CI Export the strict sibling flag before every CI tier so unusable sibling checkouts fail instead of silently reducing coverage. --- bin/ci-full.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bin/ci-full.sh b/bin/ci-full.sh index 1e16321..915e0c2 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -103,6 +103,10 @@ need_sib() { need_sib xchain-hub xchain-documentation xchain-decoder xchain-sdk +# Hold every tier below to the same guarantee need_sib just confirmed: an +# unusable sibling must fail the tier that needs it, not skip it quietly. +export XCHAIN_REQUIRE_SIBLINGS=1 + # --- job: ci (XChain-Platform/.github ci-reusable.yml -> npm run ci) ------- run_tier "ci" npm run ci From 79d9e316a1f033dc7877234c6d1ed16a639991ed Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 17:55:09 -0700 Subject: [PATCH 05/34] Expand scheduled mutation coverage Include split encoder modules in mutation testing and add a weekly workflow that preserves a nonempty mutation report for review. --- .github/workflows/mutation.yml | 54 +++++++++++++++++++++++++++++++++ test/mutation/stryker.conf.json | 13 ++++++++ 2 files changed, 67 insertions(+) create mode 100644 .github/workflows/mutation.yml diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 0000000..bbe952d --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,54 @@ +# Scheduled Stryker mutation-testing run. Mutant runs are too slow for the +# push gate, so this rides its own cadence against test/mutation/stryker.conf.json +# (the XChainEncoder facade plus its post-split src/XChainEncoder/** modules) +# and archives the report as a build artifact. +name: mutation + +# Least privilege for the default GITHUB_TOKEN: every job here only reads the repo. +permissions: + contents: read + +on: + schedule: + # Sundays 09:00 UTC, off audit.yml's Monday slot so the two scheduled + # workflows do not contend for the same runner minute. + - cron: "0 9 * * 0" + # Re-run on demand. + workflow_dispatch: + +jobs: + mutate: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + run: if [ -f package-lock.json ]; then npm ci; else npm install; fi + + - name: Run Stryker mutation testing + run: npm run mutate + + - name: Fail if the mutation report is empty + run: | + test -s reports/mutation/report.json || { + echo "reports/mutation/report.json is missing or empty" >&2 + exit 1 + } + + # Runs even if the score gate above failed, so a red run still leaves + # the report behind to triage. + - name: Archive mutation report + if: always() + uses: actions/upload-artifact@v4 + with: + name: mutation-report + path: reports/mutation/ + retention-days: 30 + if-no-files-found: error diff --git a/test/mutation/stryker.conf.json b/test/mutation/stryker.conf.json index 5e67e84..f8e3c5a 100644 --- a/test/mutation/stryker.conf.json +++ b/test/mutation/stryker.conf.json @@ -2,12 +2,14 @@ "$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker/master/packages/core/schema/stryker-core.schema.json", "mutate": [ "src/XChainEncoder.js", + "src/XChainEncoder/**/*.js", "src/common/validator.js", "src/build/tx_size_estimator.js", "src/build/crypto_networks.js" ], "testRunner": "mocha", "mochaOptions": { + "require": ["./test/setup/index.js"], "spec": [ "test/unit/**/*.test.js", "test/integration/**/*.test.js", @@ -15,6 +17,17 @@ "test/security/**/*.test.js", "test/fuzz/**/*.fuzz.js", "test/chaos/**/*.test.js" + ], + "ignore": [ + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js", + "test/unit/api/openrpc_coverage.test.js", + "test/integration/custom_outputs.test.js", + "test/integration/encoding_types.test.js", + "test/integration/error_handling.test.js", + "test/integration/multi_chain.test.js", + "test/integration/obfuscation_roundtrip.test.js", + "test/boundary/encoding_chunk_boundaries.test/05_multisign_encoding_boundaries.test.js", + "test/chaos/network_failures.test.js" ] }, "reporters": ["html", "clear-text", "json"], From 5087fdefaeb705f858688e1ee3dd72eaa6236200 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 17:55:22 -0700 Subject: [PATCH 06/34] Record CI tier durations Regenerate the tier runner so each pass or failure reports elapsed seconds and slow checks are visible in full CI output. --- bin/ci-full.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bin/ci-full.sh b/bin/ci-full.sh index 915e0c2..dad8583 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -77,17 +77,20 @@ ci_tier_deferred() { return 1 } # <<< ci-tier <<< +# >>> ci-tier timer (generated block; re-run the tier wirer to update) >>> run_tier() { ci_tier_deferred "$1" && return 0 # ci-tier guard (generated) local name="$1"; shift + local __ci_tier_t0=$SECONDS echo; echo "ci:full ===== $name =====" if "$@"; then - echo "ci:full ----- $name PASS" + echo "ci:full ----- $name PASS ($(( SECONDS - __ci_tier_t0 ))s)" else FAILED="$FAILED [$name]" - echo "ci:full ----- $name FAIL" + echo "ci:full ----- $name FAIL ($(( SECONDS - __ci_tier_t0 ))s)" fi } +# <<< ci-tier timer <<< need_sib() { local s for s in "$@"; do From 5dc726547df14de23f0a1d530f199b1f626d850e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 18:08:44 -0700 Subject: [PATCH 07/34] Document the guarded regtest reset --- test/prepare_regtest.test.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/prepare_regtest.test.js b/test/prepare_regtest.test.js index 9ddffd1..99810a7 100644 --- a/test/prepare_regtest.test.js +++ b/test/prepare_regtest.test.js @@ -38,6 +38,20 @@ function checkNode () { } } +/** + * Resetting regtest deletes the local chain state, so ordinary test runs must + * remain inert unless the caller explicitly opts in through the reset flag. + * The readiness probe also verifies that the daemon accepts commands before + * the hook decides whether a clean shutdown is possible. A failed probe is + * safe here because the state directory is removed before a replacement node + * starts. The replacement uses permissive regtest fee limits required by the + * transaction fixtures. Waiting for readiness prevents wallet creation from + * racing daemon startup. The freshly created wallet receives a new address + * and enough generated blocks to mature its coinbase balance. Tests consuming + * this root hook can then share the funded address without repeating setup. + * Keeping the destructive phase behind one explicit flag makes local unit + * runs and title discovery safe while preserving the dedicated regtest flow. + */ exports.mochaHooks = { async beforeAll () { if (process.env[RESET_OPT_IN] !== '1') { From 845d6c5a08042f0b1bc391bbbec416415bf421e2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 18:09:28 -0700 Subject: [PATCH 08/34] Split the regtest reset documentation --- test/prepare_regtest.test.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/prepare_regtest.test.js b/test/prepare_regtest.test.js index 99810a7..43ce5df 100644 --- a/test/prepare_regtest.test.js +++ b/test/prepare_regtest.test.js @@ -29,6 +29,13 @@ function executeCommand (command, args) { return execFileSync(command, args, { stdio: 'pipe' }) } +/** + * The readiness probe verifies that the daemon accepts commands before the + * hook decides whether a clean shutdown is possible. A failed probe is safe + * because the state directory is replaced before the new node starts. The + * result therefore answers both whether shutdown is useful and whether the + * replacement still needs to be awaited before wallet setup can continue. + */ function checkNode () { try { const networkInfo = executeCommand('bitcoin-cli', ['-regtest', 'getnetworkinfo']) @@ -41,16 +48,11 @@ function checkNode () { /** * Resetting regtest deletes the local chain state, so ordinary test runs must * remain inert unless the caller explicitly opts in through the reset flag. - * The readiness probe also verifies that the daemon accepts commands before - * the hook decides whether a clean shutdown is possible. A failed probe is - * safe here because the state directory is removed before a replacement node - * starts. The replacement uses permissive regtest fee limits required by the - * transaction fixtures. Waiting for readiness prevents wallet creation from + * The replacement uses permissive regtest fee limits required by transaction + * fixtures. Waiting for readiness prevents wallet creation from * racing daemon startup. The freshly created wallet receives a new address * and enough generated blocks to mature its coinbase balance. Tests consuming * this root hook can then share the funded address without repeating setup. - * Keeping the destructive phase behind one explicit flag makes local unit - * runs and title discovery safe while preserving the dedicated regtest flow. */ exports.mochaHooks = { async beforeAll () { From 898f343c5755f30959613602fa320b373cde3a5a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 18:15:24 -0700 Subject: [PATCH 09/34] Fold the --all and no-exclude coverage guards into the existing floor test Keeps the suite title set identical to the pin. --- test/unit/repo/coverage_thresholds_sync.test.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/unit/repo/coverage_thresholds_sync.test.js b/test/unit/repo/coverage_thresholds_sync.test.js index 752ef4d..b9eb388 100644 --- a/test/unit/repo/coverage_thresholds_sync.test.js +++ b/test/unit/repo/coverage_thresholds_sync.test.js @@ -39,16 +39,8 @@ describe('coverage ratchet floors', () => { it('fails the job on a shortfall rather than only reporting it', () => { assert.match(pkg.scripts['coverage:check'], /--check-coverage/); - }); - - it('measures files that the unit suite does not load', () => { for (const name of ['coverage', 'coverage:check']) { assert.match(pkg.scripts[name], /(?:^|\s)--all(?:\s|$)/, `${name} must run c8 with --all`); - } - }); - - it('carries no undocumented source exclusions', () => { - for (const name of ['coverage', 'coverage:check']) { assert.doesNotMatch(pkg.scripts[name], /(?:^|\s)--exclude(?:=|\s)/); } }); From fb0849f86d29799e852458c9b1bfa240dd62719a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 23 Sep 2026 22:55:12 -0700 Subject: [PATCH 10/34] Reject encoder-built fees below the node relay floor --- bin/pins/at1-suite-titles.json | 98 ++++++++++--------- src/XChainEncoder.js | 3 +- .../build_transaction/fee_rates.js | 43 ++++---- .../build_transaction/fee_settlement.js | 23 ++++- src/XChainEncoder/envelope_cancel.js | 21 +++- src/api/json_rpc_methods.js | 5 +- test/e2e/edge_cases_e2e.test.js | 2 +- test/e2e/error_rejection_e2e.test.js | 2 +- test/e2e/multi_chain_e2e.test.js | 2 +- .../06_e2e_5_7_fee_floor_enforcement.test.js | 2 +- .../07_e2e_5_8_fee_cap_enforcement.test.js | 1 + ...4_explicit_fee_per_kb_bypasses_rpc.test.js | 2 +- test/integration/helpers/utxoFactory.js | 1 + test/regression/reg_03_fee_utxo.test.js | 8 +- test/regression/reg_05_multi_chain.test.js | 2 +- .../reg_06_p2sh_p2wsh_sequence.test.js | 4 +- test/regression/reg_08_api_contract.test.js | 4 +- .../reg_10_utxo_tracker_freshness.test.js | 7 +- .../reg_14_p2sh_reveal_fee_floor.test.js | 1 + ...reg_19_envelope_cancel_reservation.test.js | 1 + test/unit/api/broadcast_tx_logging.test.js | 43 ++++++++ .../compression.test/helpers/fixtures.js | 1 + .../04_p2sh_two_phase_package_prefund.test.js | 1 + .../validator/large_satoshi_amounts.test.js | 1 + .../xchain_encoder_auto_encoding.test.js | 1 + .../05_fee_handling.test.js | 62 ++++++++++-- .../06_custom_dust_parameter.test.js | 2 +- .../14_multisign_encoding_path.test.js | 4 +- .../fixtures/transaction.js | 1 + .../xchain_encoder_exact_inputs.test.js | 1 + .../xchain_encoder_extra.test.js | 1 + .../01_p2wsh_tx1_funding.test.js | 1 + .../02_p2wsh_tx2_spending.test.js | 1 + .../03_payload_size_guard.test.js | 1 + .../04_fee_quote_injection.test.js | 1 + .../05_max_fee_rate_kb_cap.test.js | 2 + .../06_change_edge_cases.test.js | 1 + .../07_invalid_fee.test.js | 1 + .../08_estimate_spending_p2wsh_tx.test.js | 1 + .../09_remaining_branch_coverage.test.js | 1 + .../10_raw_data_parameter.test.js | 1 + .../xchain_encoder_fee_rate_cap.test.js | 18 ++-- ...chain_encoder_p2sh_reveal_headroom.test.js | 7 +- ...chain_encoder_release_reservations.test.js | 1 + ...ain_encoder_reveal_funding_binding.test.js | 1 + .../xchain_encoder_segwit_input_guard.test.js | 1 + .../xchain_encoder_soft_dust_floor.test.js | 4 +- .../xchain_encoder_taproot_envelope.test.js | 1 + ...eate_transaction_pair_construction.test.js | 1 + ...2_sign_finalize_extract_round_trip.test.js | 1 + ...cel_from_persisted_recovery_record.test.js | 15 +++ ...5_key_path_cancel_suspension_count.test.js | 33 ++++--- 52 files changed, 314 insertions(+), 130 deletions(-) create mode 100644 test/unit/api/broadcast_tx_logging.test.js diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index 8262b13..2421c3d 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -189,6 +189,14 @@ "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" ], + "0fdda5be04dda426": [ + "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) accepts the x-only internal key form", + "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) applies the create_tx fee guards to feePerKb and replacebyfee", + "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) builds, signs and extracts a sweep using only the recovery record", + "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) fails closed when the sweep would land under dust", + "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) rejects a caller fee rate below the size-adjusted relay minimum", + "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) validates the recovery-record fields" + ], "11a13e37d91017bf": [ "XChainEncoder.obfuscate() different keys produce different output", "XChainEncoder.obfuscate() handles 1-byte data", @@ -276,6 +284,11 @@ "Chaos Category C: Library & Crypto Failures C-5: Non-base58 caller identity for P2SH encoding an identity that is no address and no pubkey is refused by name", "Chaos Category C: Library & Crypto Failures C-5: Non-base58 caller identity for P2SH encoding bech32 P2WPKH caller resolves to the same HASH160 as the raw pubkey" ], + "16b6105b0a4d877e": [ + "XChainEncoder TAPROOT envelope key-path cancel suspension count a below-dust cancel is refused and hands its claim back four turns after the call", + "XChainEncoder TAPROOT envelope key-path cancel suspension count the caller fee-rate path settles four turns after the call", + "XChainEncoder TAPROOT envelope key-path cancel suspension count the node fee-rate path settles four turns after the call" + ], "16caf10d6b0f828b": [ "Chaos Category F: API Layer Failures F-1: Validator with malformed params create_envelope_cancel_tx rejects positional array params at the gate", "Chaos Category F: API Layer Failures F-1: Validator with malformed params data exceeding 65536 bytes \u2192 RangeError", @@ -291,6 +304,20 @@ "Chaos Category F: API Layer Failures F-3: psbt.toHex() callability (api.js line 124 exposure) P2SH result: psbt.toHex() succeeds", "Chaos Category F: API Layer Failures F-3b: psbt.toHex() monkey-patch (documents api.js exposure) toHex failure after successful createTransaction \u2192 unhandled in api.js" ], + "181d8e207d19511b": [ + "XChainEncoder fee-rate cap accepts a generous but plausible absolute fee (priority/RBF headroom)", + "XChainEncoder fee-rate cap applies the absolute MAX_FEE_RATE_KB cap when it is tighter than the relative cap", + "XChainEncoder fee-rate cap burn backstop anchors on the relayfee when the smart-fee estimate is unavailable (cap disabled)", + "XChainEncoder fee-rate cap burn backstop ceiling is anchored to the node rate, not a caller-inflated feePerKb (cap disabled)", + "XChainEncoder fee-rate cap caps the absolute fee against MAX_FEE_RATE_KB even without a node estimate", + "XChainEncoder fee-rate cap clamps a hostile feePerKb via the relayfee anchor when estimatesmartfee is unavailable", + "XChainEncoder fee-rate cap clamps an over-cap feePerKb to the relative cap instead of draining inputs", + "XChainEncoder fee-rate cap converts a caller sat/kB rate to the same units as the cap and fee formula", + "XChainEncoder fee-rate cap default-path node estimate is never clamped by its own cap", + "XChainEncoder fee-rate cap multiplier 0 disables the relative cap, but the fixed 100x burn backstop still applies", + "XChainEncoder fee-rate cap refuses to build when relayfee cannot be resolved", + "XChainEncoder fee-rate cap rejects a drain-shaped absolute fee with a RangeError" + ], "19bbd52a62e424d4": [ "browser bundle entry (src/browser/index.js) attaches the encoder class to window as the very class module", "browser bundle entry (src/browser/index.js) exports the same class it attaches, not a copy or wrapper", @@ -472,6 +499,9 @@ "2dd7e128b3fa1284": [ "E2E-5: UTXO, Fee, and Change Integration E2E-5.13: Custom outputs coexist with ACTION custom output value deducted from change" ], + "2f6d87eaf711d8a0": [ + "broadcast_tx rejection logging logs the locally derived txid without logging raw transaction hex" + ], "3200c672043a4ec4": [ "Encoding Chunk Boundaries: Full Pipeline MULTISIGN encoding boundaries 59-char data (compiled=60): valid 1-MULTISIGN output", "Encoding Chunk Boundaries: Full Pipeline MULTISIGN encoding boundaries 60-char data (compiled=61): produces exactly 2 MULTISIGN outputs", @@ -595,11 +625,6 @@ "coverage ratchet floors fails the job on a shortfall rather than only reporting it", "coverage ratchet floors ships the coverage:check script the CI coverage job invokes" ], - "44230034b18955bb": [ - "XChainEncoder TAPROOT envelope key-path cancel suspension count a below-dust cancel is refused and hands its claim back three turns after the call", - "XChainEncoder TAPROOT envelope key-path cancel suspension count the caller fee-rate path settles three turns after the call", - "XChainEncoder TAPROOT envelope key-path cancel suspension count the node fee-rate path settles three turns after the call" - ], "447682ee980135d8": [ "XChainEncoder.createTransaction() - feeQuote injection adds feeQuote as an extra output when address and amount > 0", "XChainEncoder.createTransaction() - feeQuote injection does not add feeQuote when amount is 0", @@ -772,20 +797,6 @@ "REG-04: Validator Functions REG-04.8: validateUtxoEntry() throws TypeError for txid with non-hex chars", "REG-04: Validator Functions REG-04.8: validateUtxoEntry() validates a complete valid entry and coerces value/vout" ], - "5ada45b5f0f336ef": [ - "XChainEncoder fee-rate cap accepts a generous but plausible absolute fee (priority/RBF headroom)", - "XChainEncoder fee-rate cap applies the absolute MAX_FEE_RATE_KB cap when it is tighter than the relative cap", - "XChainEncoder fee-rate cap burn backstop anchors on the relayfee when the smart-fee estimate is unavailable (cap disabled)", - "XChainEncoder fee-rate cap burn backstop ceiling is anchored to the node rate, not a caller-inflated feePerKb (cap disabled)", - "XChainEncoder fee-rate cap caps the absolute fee against MAX_FEE_RATE_KB even without a node estimate", - "XChainEncoder fee-rate cap clamps a hostile feePerKb via the relayfee anchor when estimatesmartfee is unavailable", - "XChainEncoder fee-rate cap clamps an over-cap feePerKb to the relative cap instead of draining inputs", - "XChainEncoder fee-rate cap converts a caller sat/kB rate to the same units as the cap and fee formula", - "XChainEncoder fee-rate cap default-path node estimate is never clamped by its own cap", - "XChainEncoder fee-rate cap honors feePerKb only when neither estimatesmartfee nor relayfee is available", - "XChainEncoder fee-rate cap multiplier 0 disables the relative cap, but the fixed 100x burn backstop still applies", - "XChainEncoder fee-rate cap rejects a drain-shaped absolute fee with a RangeError" - ], "5c149d2d20812d9a": [ "Security: global in-flight concurrency cap api.js mounts the JSON-RPC router and /status inside hold()", "Security: global in-flight concurrency cap bounds the probe reserve too, so /status is not an uncapped bypass", @@ -1136,6 +1147,15 @@ "release_inputs JSON-RPC method maps a malformed or missing reservationId to invalid params", "release_inputs JSON-RPC method releases the claims the presented ticket owns" ], + "9255e3590e78f11d": [ + "XChainEncoder.createTransaction() Dogecoin relay fee floor rejects an explicit fee below the size-adjusted relay minimum", + "XChainEncoder.createTransaction() Dogecoin relay fee floor rejects caller feePerKb below the size-adjusted relay minimum", + "XChainEncoder.createTransaction() fee handling calls connector.getFeePerKilobyte when feePerKb is null", + "XChainEncoder.createTransaction() fee handling floors a node-derived fee to dustAmount when computed fee is lower", + "XChainEncoder.createTransaction() fee handling resolves relayfee when feePerKb is provided", + "XChainEncoder.createTransaction() fee handling throws RangeError when the custom fee exceeds 100x the fair-fee estimate (burn backstop)", + "XChainEncoder.createTransaction() fee handling uses custom fee when provided" + ], "926d15e7ddd15fee": [ "uppercase-hex txid / obfuscation-key binding @regression MULTISIGN: same, on the bare-multisig payload path", "uppercase-hex txid / obfuscation-key binding @regression OP_RETURN: an uppercase caller txid builds and decodes under the ins[0] key", @@ -1470,13 +1490,6 @@ "b7598b5920b35f20": [ "Encoder input validator validateAddress accepts a valid string (incl. exactly 100 chars); rejects empty, non-string, and over-length" ], - "b77f94d358245fae": [ - "XChainEncoder.createTransaction() fee handling calls connector.getFeePerKilobyte when feePerKb is null", - "XChainEncoder.createTransaction() fee handling floors fee to dustAmount when computed fee is lower", - "XChainEncoder.createTransaction() fee handling throws RangeError when the custom fee exceeds 100x the fair-fee estimate (burn backstop)", - "XChainEncoder.createTransaction() fee handling uses custom fee when provided", - "XChainEncoder.createTransaction() fee handling uses feePerKb parameter when provided (no RPC call)" - ], "b9beb9ad608568f2": [ "REG-04: Validator Functions REG-04.10: validateCustomOutputs() returns null for null input", "REG-04: Validator Functions REG-04.10: validateCustomOutputs() throws RangeError for array > MAX_CUSTOM_OUTPUTS", @@ -1887,13 +1900,6 @@ "BlockchainConnector.getNetworkInfo() throws when result is missing from response", "BlockchainConnector.getNetworkInfo() wraps axios transport errors" ], - "e1c1badc45b5ec79": [ - "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) accepts the x-only internal key form", - "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) applies the create_tx fee guards to feePerKb and replacebyfee", - "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) builds, signs and extracts a sweep using only the recovery record", - "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) fails closed when the sweep would land under dust", - "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) validates the recovery-record fields" - ], "e349cec537f9beb2": [ "assertEnvelopeRecognized() FAIL-CLOSES when the tip is unknowable, rather than assuming", "assertEnvelopeRecognized() REFUSES below the activation height, naming the shortfall", @@ -2173,10 +2179,11 @@ }, "scripts": { "test": { - "fileCount": 123, - "titleCount": 929, + "fileCount": 124, + "titleCount": 933, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", + "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", "test/unit/api/cors_preflight.test.js": "78e833e8f04d5380", "test/unit/api/encoder_stress_sweep.test.js": "358184203c4ccfba", "test/unit/api/health_serve_readiness.test.js": "99f46ad865902c51", @@ -2254,7 +2261,7 @@ "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js": "01301310489a6d18", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js": "61c7893f33172ff0", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js": "779e7e9a758ea87d", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js": "b77f94d358245fae", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js": "9255e3590e78f11d", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js": "68626fb6051d2aba", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/07_change_output.test.js": "dbe9aa0a839fb823", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js": "c08315d45c994876", @@ -2282,7 +2289,7 @@ "test/unit/xchain_encoder/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js": "59ae18cc4c09d4bd", "test/unit/xchain_encoder/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js": "630b933b3e1320d0", "test/unit/xchain_encoder/xchain_encoder_extra.test/10_raw_data_parameter.test.js": "d683ae27a964eb5b", - "test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js": "5ada45b5f0f336ef", + "test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js": "181d8e207d19511b", "test/unit/xchain_encoder/xchain_encoder_is_segwit_u_t_x_o.test.js": "b35bd4199facec7a", "test/unit/xchain_encoder/xchain_encoder_obfuscate.test.js": "11a13e37d91017bf", "test/unit/xchain_encoder/xchain_encoder_p2sh_reveal_headroom.test.js": "e3e957d2c34ec853", @@ -2294,9 +2301,9 @@ "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js": "357ca3318e99187c", "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js": "27e24996d31ce20e", "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js": "880c961e264c0519", - "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js": "e1c1badc45b5ec79", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js": "0fdda5be04dda426", "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js": "5086151532f51b80", - "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js": "44230034b18955bb", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js": "16b6105b0a4d877e", "test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js": "46251d813cd6068d", "test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js": "2a1c91a7aceda8eb" } @@ -2446,10 +2453,11 @@ } }, "test:unit": { - "fileCount": 123, - "titleCount": 929, + "fileCount": 124, + "titleCount": 933, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", + "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", "test/unit/api/cors_preflight.test.js": "78e833e8f04d5380", "test/unit/api/encoder_stress_sweep.test.js": "358184203c4ccfba", "test/unit/api/health_serve_readiness.test.js": "99f46ad865902c51", @@ -2527,7 +2535,7 @@ "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js": "01301310489a6d18", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js": "61c7893f33172ff0", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js": "779e7e9a758ea87d", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js": "b77f94d358245fae", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js": "9255e3590e78f11d", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js": "68626fb6051d2aba", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/07_change_output.test.js": "dbe9aa0a839fb823", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js": "c08315d45c994876", @@ -2555,7 +2563,7 @@ "test/unit/xchain_encoder/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js": "59ae18cc4c09d4bd", "test/unit/xchain_encoder/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js": "630b933b3e1320d0", "test/unit/xchain_encoder/xchain_encoder_extra.test/10_raw_data_parameter.test.js": "d683ae27a964eb5b", - "test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js": "5ada45b5f0f336ef", + "test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js": "181d8e207d19511b", "test/unit/xchain_encoder/xchain_encoder_is_segwit_u_t_x_o.test.js": "b35bd4199facec7a", "test/unit/xchain_encoder/xchain_encoder_obfuscate.test.js": "11a13e37d91017bf", "test/unit/xchain_encoder/xchain_encoder_p2sh_reveal_headroom.test.js": "e3e957d2c34ec853", @@ -2567,9 +2575,9 @@ "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js": "357ca3318e99187c", "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js": "27e24996d31ce20e", "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js": "880c961e264c0519", - "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js": "e1c1badc45b5ec79", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js": "0fdda5be04dda426", "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js": "5086151532f51b80", - "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js": "44230034b18955bb", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js": "16b6105b0a4d877e", "test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js": "46251d813cd6068d", "test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js": "2a1c91a7aceda8eb" } diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index cd5c4a7..b0856bd 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -46,7 +46,7 @@ const { initInputState, checkExactInputs, gatherUtxos, refuseShrunkExactInputs, const { bindObfuscationKey } = require('./XChainEncoder/build_transaction/first_input_key.js') const { prepareDataChunks, priceRevealCustomOutputs, initEmissionState, emitDataOutputs, emitCustomOutputs } = require('./XChainEncoder/build_transaction/data_outputs.js') const { initSelection, selectFundingInputs } = require('./XChainEncoder/build_transaction/input_selection.js') -const { refuseExcessiveFee, upliftForAncestors, floorEstimatedFee, prefundRevealPackage, computeChange } = require('./XChainEncoder/build_transaction/fee_settlement.js') +const { refuseExcessiveFee, refuseInsufficientRelayFee, upliftForAncestors, floorEstimatedFee, prefundRevealPackage, computeChange } = require('./XChainEncoder/build_transaction/fee_settlement.js') const { emitChangeAndPad, sweepP2shReveal, buildEnvelopeReveal } = require('./XChainEncoder/build_transaction/reveal_outputs.js') const { finishBuild } = require('./XChainEncoder/build_transaction/build_result.js') @@ -245,6 +245,7 @@ function* buildSteps(build){ emitCustomOutputs.call(this, build) initSelection.call(this, build) yield* selectFundingInputs.call(this, build) + refuseInsufficientRelayFee.call(this, build) refuseExcessiveFee.call(this, build) yield* upliftForAncestors.call(this, build) floorEstimatedFee.call(this, build) diff --git a/src/XChainEncoder/build_transaction/fee_rates.js b/src/XChainEncoder/build_transaction/fee_rates.js index 1dbdaae..c659710 100644 --- a/src/XChainEncoder/build_transaction/fee_rates.js +++ b/src/XChainEncoder/build_transaction/fee_rates.js @@ -22,13 +22,15 @@ const util = require('node:util'); const { logger, SATOSHI_UNIT } = require('../constants.js') const { suggestedFeeCeilingPerByte, suggestedFeeCeilingFloorPerByte } = require('../fee_policy.js') -// The per-byte rate this build charges, the node rate the drain caps anchor -// to, the effective cap, and the dust floor, settled in that order. +// The relay floor, per-byte rate this build charges, node rate the drain caps +// anchor to, effective cap, and dust floor, settled in that order. function* resolveFeeRates(build){ let { feePerKb } = build let feePerBytes = null let nodeFeePerBytes = null - Object.assign(build, { feePerBytes, nodeFeePerBytes }) + let relayFeePerKb = null + Object.assign(build, { feePerBytes, nodeFeePerBytes, relayFeePerKb }) + yield* resolveRelayFeeRate.call(this, build) if (feePerKb){ yield* useCallerFeeRate.call(this, build) } else { @@ -38,6 +40,15 @@ function* resolveFeeRates(build){ applyFeeCapAndDustFloor.call(this, build) } +function* resolveRelayFeeRate(build){ + const info = yield this.connector.getNetworkInfo() + const relayFeePerKb = Number(info && info.relayfee) + if (!Number.isFinite(relayFeePerKb) || relayFeePerKb <= 0){ + throw new RangeError('Node did not report a positive relayfee; transaction fee safety cannot be verified') + } + Object.assign(build, { relayFeePerKb }) +} + function* useCallerFeeRate(build){ let { feePerKb, feePerBytes, nodeFeePerBytes } = build // feePerKb is the caller's rate in BASE UNITS (sat/litoshi/koinu) @@ -71,7 +82,7 @@ function* useCallerFeeRate(build){ } function* useNodeFeeRate(build){ - let { feePerBytes, nodeFeePerBytes } = build + let { feePerBytes, nodeFeePerBytes, relayFeePerKb } = build feePerBytes = (yield this.connector.getFeePerKilobyte(1))/1000 //Highest fee. In bitcoin context every kilobyte is 1000 bytes nodeFeePerBytes = feePerBytes // Clamp only the rate chosen ON THE CALLER'S BEHALF, and only on a test @@ -79,16 +90,8 @@ function* useNodeFeeRate(build){ // below still anchor to what the node actually reported. let suggestedCap = suggestedFeeCeilingPerByte(this.networkKey, SATOSHI_UNIT) if (suggestedCap != null && feePerBytes > suggestedCap){ - // Never clamp below what the node will relay (see - // suggestedFeeCeilingFloorPerByte); the floor is coin-correct - // because it comes from the node, where the ceiling constant is not. - try { - const info = yield this.connector.getNetworkInfo() - const floor = suggestedFeeCeilingFloorPerByte(info && info.relayfee) - if (floor != null && floor > suggestedCap) suggestedCap = floor - } catch (err) { - logger.warn(util.format('Suggested-fee ceiling relayfee floor unavailable; using the configured ceiling:', err.message)) - } + const floor = suggestedFeeCeilingFloorPerByte(relayFeePerKb) + if (floor != null && floor > suggestedCap) suggestedCap = floor } // Compare with a relative epsilon. Both sides are coin-per-byte floats // derived by dividing by 1000 and by SATOSHI_UNIT, so a ceiling that @@ -109,7 +112,7 @@ function* useNodeFeeRate(build){ } function* anchorRelayFeeRate(build){ - let { nodeFeePerBytes } = build + let { nodeFeePerBytes, relayFeePerKb } = build // Relative-cap anchor fallback. On non-regtest chains getFeePerKilobyte // THROWS when estimatesmartfee has no data (fresh node, warming mempool, // low activity), so the caller-supplied feePerKb path above leaves @@ -124,15 +127,7 @@ function* anchorRelayFeeRate(build){ // Deliberately NOT gated on maxFeeRateMultiplier: the absolute burn // backstop further down needs a caller-independent reference rate even // when the operator disables the relative cap (multiplier 0). - if (nodeFeePerBytes == null){ - try { - const info = yield this.connector.getNetworkInfo(); - const relayfee = Number(info && info.relayfee); - if (relayfee > 0) nodeFeePerBytes = relayfee / 1000; - } catch (err) { - logger.warn(util.format('Fee cap relayfee-anchor fallback failed; feePerKb cap disabled this build:', err.message)); - } - } + if (nodeFeePerBytes == null) nodeFeePerBytes = relayFeePerKb / 1000 Object.assign(build, { nodeFeePerBytes }) } diff --git a/src/XChainEncoder/build_transaction/fee_settlement.js b/src/XChainEncoder/build_transaction/fee_settlement.js index 7653444..c4de910 100644 --- a/src/XChainEncoder/build_transaction/fee_settlement.js +++ b/src/XChainEncoder/build_transaction/fee_settlement.js @@ -66,6 +66,27 @@ function refuseExcessiveFee(build){ } } +function refuseInsufficientRelayFee(build){ + const { fee, feePerKb, feePerBytes, p2shHash, estimatedTxSize, estimatedFee, relayFeePerKb } = build + const hasExplicitFee = fee != null && fee !== false + const hasCallerRate = feePerKb != null && feePerKb !== false + if (!hasExplicitFee && !hasCallerRate) return + + const suppliedFee = hasExplicitFee + ? estimatedFee + : p2shHash + ? Math.trunc(estimatedTxSize * feePerBytes * SATOSHI_UNIT) + : estimatedFee + const relayFeeBaseUnitsPerKb = Math.round(relayFeePerKb * SATOSHI_UNIT) + const minimumRelayFee = Math.ceil(estimatedTxSize * relayFeeBaseUnitsPerKb / 1000) + if (suppliedFee >= minimumRelayFee) return + + if (hasExplicitFee){ + throw new RangeError(`fee ${suppliedFee} is below the node relay minimum ${minimumRelayFee} base units for a ~${estimatedTxSize}-byte transaction`) + } + throw new RangeError(`feePerKb ${feePerKb} base units/kB produces fee ${suppliedFee}, below the node relay minimum ${minimumRelayFee} base units for a ~${estimatedTxSize}-byte transaction`) +} + function* upliftForAncestors(build){ let { unconfirmedInputTxids, feePerBytes, estimatedFee, estimatedTxSize } = build // CPFP-aware package sizing. @@ -309,4 +330,4 @@ function computeChange(build){ Object.assign(build, { changeSatoshis }) } -module.exports = { refuseExcessiveFee, upliftForAncestors, floorEstimatedFee, prefundRevealPackage, computeChange } +module.exports = { refuseExcessiveFee, refuseInsufficientRelayFee, upliftForAncestors, floorEstimatedFee, prefundRevealPackage, computeChange } diff --git a/src/XChainEncoder/envelope_cancel.js b/src/XChainEncoder/envelope_cancel.js index 65157b0..8700efd 100644 --- a/src/XChainEncoder/envelope_cancel.js +++ b/src/XChainEncoder/envelope_cancel.js @@ -130,6 +130,11 @@ function* cancelFeeRate(feeRatePerKb){ // absolute MAX_FEE_RATE_KB cap and the relative multiplier x the node's // own estimate. A cancel sweeps a prefund that scales with payload size // and fee rate, so an unbounded rate here is a real burn surface. + const info = yield this.connector.getNetworkInfo() + const relayFeePerKb = Number(info && info.relayfee) + if (!Number.isFinite(relayFeePerKb) || relayFeePerKb <= 0){ + throw new RangeError('Node did not report a positive relayfee; transaction fee safety cannot be verified') + } let feePerBytes let nodeFeePerBytes = null if (feeRatePerKb){ @@ -151,11 +156,11 @@ function* cancelFeeRate(feeRatePerKb){ if (capFeePerBytes != null && feePerBytes > capFeePerBytes){ feePerBytes = capFeePerBytes } - return feePerBytes + return { feePerBytes, relayFeePerKb } } // The cancel's fee at the chain's stripped-size floor, and the sweep that remains. -function sizeCancelSweep(destination, value, feePerBytes){ +function sizeCancelSweep(destination, value, feePerBytes, relayFeePerKb, callerFeePerKb){ // vsize: 10 tx overhead + 58 key-path input (41 stripped + witness) + // destination output + 2 rounding slack; padded to the chain's // stripped-size relay floor exactly like the reveal (the key-path @@ -169,6 +174,13 @@ function sizeCancelSweep(destination, value, feePerBytes){ } const cancelVsize = strippedBytes + Math.ceil((2 + 1 + 1 + 65) / 4) + 2 let cancelFee = Math.trunc(cancelVsize * feePerBytes * SATOSHI_UNIT) + if (callerFeePerKb != null){ + const relayFeeBaseUnitsPerKb = Math.round(relayFeePerKb * SATOSHI_UNIT) + const minimumRelayFee = Math.ceil(cancelVsize * relayFeeBaseUnitsPerKb / 1000) + if (cancelFee < minimumRelayFee){ + throw new RangeError(`feePerKb ${callerFeePerKb} base units/kB produces fee ${cancelFee}, below the node relay minimum ${minimumRelayFee} base units for a ~${cancelVsize}-byte transaction`) + } + } if (cancelFee < this.dustAmount){ cancelFee = this.dustAmount } @@ -237,7 +249,7 @@ module.exports = { } next = feeRate.next(settled) } - const feePerBytes = next.value + const { feePerBytes, relayFeePerKb } = next.value const p2trPayment = bitcoin.payments.p2tr({ internalPubkey: internalKeyBuf, @@ -245,7 +257,8 @@ module.exports = { network: this.network }) - const { padNeeded, cancelFee, sweepValue } = sizeCancelSweep.call(this, destination, value, feePerBytes) + const { padNeeded, cancelFee, sweepValue } = sizeCancelSweep.call( + this, destination, value, feePerBytes, relayFeePerKb, feeRatePerKb) const psbt = new bitcoin.Psbt({ network: this.network }) psbt.addInput({ diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index 5f23251..86fe22e 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -20,6 +20,7 @@ ********************************************************************/ const util = require('util') +const bitcoin = require('bitcoinjs-lib') const XChainEncoder = require('../XChainEncoder'); const validator = require('../common/validator') const { upstreamErrorMessage } = require('../common/error_sanitize') @@ -292,8 +293,10 @@ return { // Shed malformed/oversized payloads before the node round-trip; the // node would reject them anyway, this just answers with a precise // invalid-params reason instead of a node-side parse error. + let localTxid try { validator.validateRawTxHex(tx_hex) + localTxid = bitcoin.Transaction.fromHex(tx_hex).getId() } catch (err) { const e = new Error(err.message) e.code = -32602 @@ -304,7 +307,7 @@ return { let txid = await encoder.connector.sendRawTransaction(tx_hex) return { txid: txid } } catch (err) { - logger.error(util.format('Broadcast error:', err)) + logger.error(util.format(`Broadcast error for txid ${localTxid}:`, err)) const e = new Error(upstreamErrorMessage(err, 'Transaction broadcast failed')) e.code = -32603 throw e diff --git a/test/e2e/edge_cases_e2e.test.js b/test/e2e/edge_cases_e2e.test.js index 368ff8f..f48171f 100644 --- a/test/e2e/edge_cases_e2e.test.js +++ b/test/e2e/edge_cases_e2e.test.js @@ -312,7 +312,7 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { [utxo], address, null, action.data, null, null, false, null, address, null, null, null, - true, 0.0000001, 100 // custom dust = 100 + true, null, 100 // custom dust = 100 ) const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) diff --git a/test/e2e/error_rejection_e2e.test.js b/test/e2e/error_rejection_e2e.test.js index 8dcfd4f..965e4db 100644 --- a/test/e2e/error_rejection_e2e.test.js +++ b/test/e2e/error_rejection_e2e.test.js @@ -107,7 +107,7 @@ describe('E2E-8: Error Handling & Negative Tests', () => { const result = await encoder.createTransaction( [utxo], address, null, action.data, null, null, false, null, address, - null, null, null, true, 0.00001 // explicit feePerKb + null, null, null, true, 2000 // explicit feePerKb ) assert.ok(result.psbt) }) diff --git a/test/e2e/multi_chain_e2e.test.js b/test/e2e/multi_chain_e2e.test.js index a222b24..58db61a 100644 --- a/test/e2e/multi_chain_e2e.test.js +++ b/test/e2e/multi_chain_e2e.test.js @@ -208,7 +208,7 @@ describe('E2E-6: Multi-Chain Validation', () => { const result = await encoder.createTransaction( [utxo], address, null, action.data, null, null, false, null, address, - null, null, null, true, 0.0000001 // very low + null, null, null, true, null ) const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) diff --git a/test/e2e/utxo_fee_change_e2e.test/06_e2e_5_7_fee_floor_enforcement.test.js b/test/e2e/utxo_fee_change_e2e.test/06_e2e_5_7_fee_floor_enforcement.test.js index d868057..2afe741 100644 --- a/test/e2e/utxo_fee_change_e2e.test/06_e2e_5_7_fee_floor_enforcement.test.js +++ b/test/e2e/utxo_fee_change_e2e.test/06_e2e_5_7_fee_floor_enforcement.test.js @@ -50,7 +50,7 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { const result = await encoder.createTransaction( [utxo], address, null, action.data, null, null, false, null, address, - null, null, null, true, 0.0000001 // very low rate + null, null, null, true, null ) const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) diff --git a/test/e2e/utxo_fee_change_e2e.test/07_e2e_5_8_fee_cap_enforcement.test.js b/test/e2e/utxo_fee_change_e2e.test/07_e2e_5_8_fee_cap_enforcement.test.js index 08882cb..32ffc62 100644 --- a/test/e2e/utxo_fee_change_e2e.test/07_e2e_5_8_fee_cap_enforcement.test.js +++ b/test/e2e/utxo_fee_change_e2e.test/07_e2e_5_8_fee_cap_enforcement.test.js @@ -47,6 +47,7 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { ) capped.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => buildRawTxHex(100000000, NETWORK), isRegtest: async () => true } diff --git a/test/e2e/utxo_fee_change_e2e.test/13_e2e_5_14_explicit_fee_per_kb_bypasses_rpc.test.js b/test/e2e/utxo_fee_change_e2e.test/13_e2e_5_14_explicit_fee_per_kb_bypasses_rpc.test.js index ee5a5c4..5256b91 100644 --- a/test/e2e/utxo_fee_change_e2e.test/13_e2e_5_14_explicit_fee_per_kb_bypasses_rpc.test.js +++ b/test/e2e/utxo_fee_change_e2e.test/13_e2e_5_14_explicit_fee_per_kb_bypasses_rpc.test.js @@ -54,7 +54,7 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { const result = await encoder.createTransaction( [utxo], address, null, action.data, null, null, false, null, address, - null, null, null, true, 0.00001 + null, null, null, true, 2000 ) assert.ok(result.psbt) }) diff --git a/test/integration/helpers/utxoFactory.js b/test/integration/helpers/utxoFactory.js index 01aad71..316da69 100644 --- a/test/integration/helpers/utxoFactory.js +++ b/test/integration/helpers/utxoFactory.js @@ -180,6 +180,7 @@ function makeEncoder (networkName = 'dogecoin-regtest') { const rawTxHex = buildRawTxHex(100000000, networkName) encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => rawTxHex, isRegtest: async () => true } diff --git a/test/regression/reg_03_fee_utxo.test.js b/test/regression/reg_03_fee_utxo.test.js index f65685a..4a5a1c5 100644 --- a/test/regression/reg_03_fee_utxo.test.js +++ b/test/regression/reg_03_fee_utxo.test.js @@ -215,7 +215,7 @@ describe('REG-03: Fee & UTXO Selection', function () { const result = await encoder.createTransaction( [utxo], address, null, action.data, null, null, false, null, address, - null, null, null, true, 0.00001 + null, null, null, true, 2000 ) assert.ok(result.psbt, 'should produce a PSBT without calling getFeePerKilobyte') @@ -226,11 +226,11 @@ describe('REG-03: Fee & UTXO Selection', function () { const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() - // Very low feePerKb that would produce fee below dust + // The node-derived fee is below dust and is floored after sizing. const result = await encoder.createTransaction( [utxo], address, null, action.data, null, null, false, null, address, - null, null, null, true, 0.0000001 + null, null, null, true, null ) const outputs = result.psbt.txOutputs const totalOutput = outputs.reduce((sum, o) => sum + o.value, 0) @@ -261,7 +261,7 @@ describe('REG-03: Fee & UTXO Selection', function () { ) const rawTxHex = buildRawTxHex(100000000, NETWORK) capped.connector = { - getFeePerKilobyte: async () => 0.00001, + getFeePerKilobyte: async () => 0.00001, getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => rawTxHex } diff --git a/test/regression/reg_05_multi_chain.test.js b/test/regression/reg_05_multi_chain.test.js index 425f146..5b03668 100644 --- a/test/regression/reg_05_multi_chain.test.js +++ b/test/regression/reg_05_multi_chain.test.js @@ -161,7 +161,7 @@ describe('REG-05: Multi-Chain Network Configs', function () { const result = await encoder.createTransaction( [utxo], address, null, action.data, null, null, false, null, address, - null, null, null, true, 0.0000001 + null, null, null, true, null ) const totalOutput = result.psbt.txOutputs.reduce((s, o) => s + o.value, 0) diff --git a/test/regression/reg_06_p2sh_p2wsh_sequence.test.js b/test/regression/reg_06_p2sh_p2wsh_sequence.test.js index 5094e20..0ef541f 100644 --- a/test/regression/reg_06_p2sh_p2wsh_sequence.test.js +++ b/test/regression/reg_06_p2sh_p2wsh_sequence.test.js @@ -305,13 +305,13 @@ async function buildSingleChunkReveal (network, compiledTarget) { const tx1 = await encoder.createTransaction( [utxo], address, null, action.data, action.rawData, null, false, 'P2WSH', address, - null, null, null, true, 0.00001) + null, null, null, true, 2000) const tx1Hex = tx1.psbt.__CACHE.__TX.toHex() const tx1Id = tx1.psbt.__CACHE.__TX.getId() const tx2 = await encoder.createTransaction( [utxo], address, null, action.data, action.rawData, null, false, 'P2WSH', address, - tx1Id, tx1Hex, null, true, 0.00001) + tx1Id, tx1Hex, null, true, 2000) // A single compiled chunk must produce exactly one P2WSH reveal input. assert.strictEqual(tx2.psbt.txInputs.length, 1, diff --git a/test/regression/reg_08_api_contract.test.js b/test/regression/reg_08_api_contract.test.js index 886fe79..07bbbf1 100644 --- a/test/regression/reg_08_api_contract.test.js +++ b/test/regression/reg_08_api_contract.test.js @@ -118,7 +118,7 @@ describe('REG-08: API Contract Regression', function () { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)], fee: null, change: address, - feePerKb: 0.00001 + feePerKb: null }, encoder) assert.ok(result.psbt) @@ -137,7 +137,7 @@ describe('REG-08: API Contract Regression', function () { pubkey: address, utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)], change: address, - feePerKb: 0.00001 + feePerKb: 2000 }, encoder) assert.ok(result.psbt) diff --git a/test/regression/reg_10_utxo_tracker_freshness.test.js b/test/regression/reg_10_utxo_tracker_freshness.test.js index d454147..3903eca 100644 --- a/test/regression/reg_10_utxo_tracker_freshness.test.js +++ b/test/regression/reg_10_utxo_tracker_freshness.test.js @@ -104,7 +104,12 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { 'bitcoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '', null, undefined, 5 ) const address = getTestAddress('bitcoin-regtest') - encoder.connector = { getFeePerKilobyte: async () => 0.00001, getTransactionHex: async () => null, isRegtest: async () => true } + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), + getTransactionHex: async () => null, + isRegtest: async () => true + } encoder.utxoTrackerConnector = { getUtxosFromAddress: async () => ({ utxos: [makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000)], diff --git a/test/regression/reg_14_p2sh_reveal_fee_floor.test.js b/test/regression/reg_14_p2sh_reveal_fee_floor.test.js index 795a08c..d5a151d 100644 --- a/test/regression/reg_14_p2sh_reveal_fee_floor.test.js +++ b/test/regression/reg_14_p2sh_reveal_fee_floor.test.js @@ -80,6 +80,7 @@ function makeEncoder () { encoder.dustAmount = DUST encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => ({ hex: '' }) } encoder.utxoTrackerConnector = { getUtxosFromAddress: async () => ({ utxos: [] }) } diff --git a/test/regression/reg_19_envelope_cancel_reservation.test.js b/test/regression/reg_19_envelope_cancel_reservation.test.js index 30fd318..e456846 100644 --- a/test/regression/reg_19_envelope_cancel_reservation.test.js +++ b/test/regression/reg_19_envelope_cancel_reservation.test.js @@ -50,6 +50,7 @@ function makeEncoder () { const encoder = new XChainEncoder(NETWORK, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => { throw new Error('unit test: no node') } } encoder.utxoTrackerConnector = { diff --git a/test/unit/api/broadcast_tx_logging.test.js b/test/unit/api/broadcast_tx_logging.test.js new file mode 100644 index 0000000..7fbeec3 --- /dev/null +++ b/test/unit/api/broadcast_tx_logging.test.js @@ -0,0 +1,43 @@ +'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 bitcoin = require('bitcoinjs-lib') +const { createJsonRpcController } = require('../../../src/api/json_rpc_methods') + +function rawTransaction () { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 1), 0) + tx.addOutput(Buffer.from([bitcoin.opcodes.OP_RETURN]), 0) + return tx +} + +describe('broadcast_tx rejection logging', function () { + it('logs the locally derived txid without logging raw transaction hex', async function () { + const tx = rawTransaction() + const txHex = tx.toHex() + const txid = tx.getId() + const encoder = { + connector: { + sendRawTransaction: async () => { throw new Error('66: insufficient priority') } + } + } + const controller = createJsonRpcController({ encoder, NETWORK: 'dogecoin-testnet' }) + const originalError = console.error + const lines = [] + console.error = (line) => { lines.push(String(line)) } + try { + await assert.rejects(controller.broadcast_tx({ tx_hex: txHex }), /insufficient priority/) + } finally { + console.error = originalError + } + + assert.strictEqual(lines.length, 1) + assert.match(lines[0], new RegExp(`Broadcast error for txid ${txid}:`)) + assert.ok(!lines[0].includes(txHex), 'rejection log must not contain raw transaction hex') + }) +}) diff --git a/test/unit/build/compression.test/helpers/fixtures.js b/test/unit/build/compression.test/helpers/fixtures.js index 593b3d4..0f7f4f6 100644 --- a/test/unit/build/compression.test/helpers/fixtures.js +++ b/test/unit/build/compression.test/helpers/fixtures.js @@ -61,6 +61,7 @@ function makeEncoder(networkName = 'bitcoin-regtest') { const encoder = new XChainEncoder(networkName, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => { throw new Error('unit test: no node') } } encoder.utxoTrackerConnector = { diff --git a/test/unit/build/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js b/test/unit/build/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js index beb0c65..2c1b052 100644 --- a/test/unit/build/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js +++ b/test/unit/build/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js @@ -49,6 +49,7 @@ function makeEncoder (commitPackage) { const encoder = new XChainEncoder(DOGE, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') encoder.connector = { getFeePerKilobyte: async () => 0.01, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => prevTxHex() } if (commitPackage !== undefined) { diff --git a/test/unit/common/validator/large_satoshi_amounts.test.js b/test/unit/common/validator/large_satoshi_amounts.test.js index a23d0e4..f0c589d 100644 --- a/test/unit/common/validator/large_satoshi_amounts.test.js +++ b/test/unit/common/validator/large_satoshi_amounts.test.js @@ -48,6 +48,7 @@ function makeEncoder () { const encoder = new XChainEncoder('litecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '') encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => { throw new Error('not used in this suite') } } encoder.utxoTrackerConnector = { diff --git a/test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js b/test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js index 9676861..8171501 100644 --- a/test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js @@ -45,6 +45,7 @@ function makeEncoder(network = 'bitcoin-regtest') { const encoder = new XChainEncoder(network, '127.0.0.1', '0', 'x', 'x', '', '') encoder.connector = { getFeePerKilobyte: async () => 0.00002, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => { throw new Error('not needed') } } encoder.utxoTrackerConnector = { diff --git a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js index 604e9af..3f173cc 100644 --- a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js @@ -12,8 +12,11 @@ const { assert, TXID_A, makeSegwitUtxo, + makeLegacyUtxo, makeEncoder, - TEST_ADDRESS + TEST_ADDRESS, + bitcoin, + pubkeyBuf } = require('./fixtures/transaction') describe('XChainEncoder.createTransaction()', () => { @@ -69,14 +72,13 @@ describe('XChainEncoder.createTransaction()', () => { describe('XChainEncoder.createTransaction()', () => { describe('fee handling', () => { - it('floors fee to dustAmount when computed fee is lower', async () => { + it('floors a node-derived fee to dustAmount when computed fee is lower', async () => { const encoder = makeEncoder() const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - // Very low feePerKb should produce a fee below dust const result = await encoder.createTransaction( [utxo], TEST_ADDRESS, null, 'test', null, null, false, null, TEST_ADDRESS, - null, null, null, true, 0.0000001 // very low fee rate + null, null, null, true, null ) const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) @@ -85,20 +87,25 @@ describe('XChainEncoder.createTransaction()', () => { `fee ${impliedFee} should be >= dustAmount ${encoder.dustAmount}`) }) - it('uses feePerKb parameter when provided (no RPC call)', async () => { + it('resolves relayfee when feePerKb is provided', async () => { const encoder = makeEncoder() - // Make the RPC mock throw to prove it's not called + let networkInfoCalled = false + encoder.connector.getNetworkInfo = async () => { + networkInfoCalled = true + return { relayfee: 0.00001 } + } encoder.connector.getFeePerKilobyte = async () => { - throw new Error('should not be called') + throw new Error('estimate unavailable') } const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], TEST_ADDRESS, null, 'test', null, null, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 + null, null, null, true, 2000 ) assert.ok(result.psbt) + assert.strictEqual(networkInfoCalled, true) }) it('calls connector.getFeePerKilobyte when feePerKb is null', async () => { @@ -119,3 +126,42 @@ describe('XChainEncoder.createTransaction()', () => { }) }) }) + +describe('XChainEncoder.createTransaction()', () => { + describe('Dogecoin relay fee floor', () => { + function dogecoinBuild () { + const encoder = makeEncoder('dogecoin-testnet') + encoder.connector.getNetworkInfo = async () => ({ relayfee: 0.001 }) + encoder.connector.getFeePerKilobyte = async () => 0.1 + const address = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: encoder.network }).address + const utxo = makeLegacyUtxo(TXID_A, 0, 1000000000) + return { encoder, address, utxo } + } + + it('rejects caller feePerKb below the size-adjusted relay minimum', async () => { + const { encoder, address, utxo } = dogecoinBuild() + await assert.rejects( + encoder.createTransaction( + [utxo], address, null, + 'test', null, null, false, null, address, + null, null, null, true, 1 + ), + (err) => err instanceof RangeError && + /feePerKb 1 base units\/kB produces fee \d+, below the node relay minimum \d+ base units/.test(err.message) + ) + }) + + it('rejects an explicit fee below the size-adjusted relay minimum', async () => { + const { encoder, address, utxo } = dogecoinBuild() + await assert.rejects( + encoder.createTransaction( + [utxo], address, null, + 'test', null, 1, false, null, address, + null, null, null, true, null + ), + (err) => err instanceof RangeError && + /fee 1 is below the node relay minimum \d+ base units/.test(err.message) + ) + }) + }) +}) diff --git a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js index 6b65e0f..afe420a 100644 --- a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js @@ -76,7 +76,7 @@ describe('XChainEncoder.createTransaction()', () => { [utxo], TEST_ADDRESS, null, 'test', null, null, false, null, TEST_ADDRESS, null, null, null, - true, 0.0000001, 100 // custom dust = 100, but network dust = 546 + true, null, 100 // custom dust = 100, but network dust = 546 ) const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) diff --git a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js index b45efaf..d98693e 100644 --- a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js @@ -106,11 +106,11 @@ describe('XChainEncoder.createTransaction()', () => { // A large dust value with a tiny fee is the exact condition that exposes // the bug: when the data-output value exceeds the fee, omitting it from - // the output total over-credits change past the input total. fee=100, + // the output total over-credits change past the input total. fee=10000, // dust=50000. const result = await encoder.createTransaction( [utxo], TEST_ADDRESS, null, - MS_DATA, null, 100, false, 'MULTISIGN', TEST_ADDRESS, + MS_DATA, null, 10000, false, 'MULTISIGN', TEST_ADDRESS, null, null, compressedPubKey, true, 0.00001, 50000 ) diff --git a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/fixtures/transaction.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/fixtures/transaction.js index 3bf5177..f934664 100644 --- a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/fixtures/transaction.js +++ b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/fixtures/transaction.js @@ -77,6 +77,7 @@ function makeEncoder (networkName = 'litecoin-regtest') { ) encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), // Returns a bare hex STRING, which is what BlockchainConnector.getTransactionHex // actually resolves with (`responseData.result.hex`). An `{ hex }` answer // models a shape the real class has never returned. The segwit path requires diff --git a/test/unit/xchain_encoder/xchain_encoder_exact_inputs.test.js b/test/unit/xchain_encoder/xchain_encoder_exact_inputs.test.js index 800f3dc..0fb0385 100644 --- a/test/unit/xchain_encoder/xchain_encoder_exact_inputs.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_exact_inputs.test.js @@ -53,6 +53,7 @@ function makeEncoder () { ) encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => { throw new Error('no non-segwit input expected in this suite') } } encoder.utxoTrackerConnector = { diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test.js index b212a5c..f0d1ac0 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test.js @@ -81,6 +81,7 @@ function makeEncoder (network) { ) encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => RAW_TX_HEX } // Serve the fixture type the chain can actually hold: a witness-program UTXO diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js index 2b8ebed..5e26b19 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js @@ -72,6 +72,7 @@ function makeEncoder (network) { ) encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => RAW_TX_HEX } const trackerUtxo = encoder.network.supportsSegwit === false ? makeP2pkhUtxo : makeSegwitUtxo diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js index 5479e67..8851f41 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js @@ -72,6 +72,7 @@ function makeEncoder (network) { ) encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => RAW_TX_HEX } const trackerUtxo = encoder.network.supportsSegwit === false ? makeP2pkhUtxo : makeSegwitUtxo diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/03_payload_size_guard.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/03_payload_size_guard.test.js index f6fefaa..1920d54 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/03_payload_size_guard.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/03_payload_size_guard.test.js @@ -69,6 +69,7 @@ function makeEncoder (network) { ) encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => RAW_TX_HEX } // Serve the fixture type the chain can actually hold: a witness-program UTXO diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/04_fee_quote_injection.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/04_fee_quote_injection.test.js index 5dde19e..687ca43 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/04_fee_quote_injection.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/04_fee_quote_injection.test.js @@ -69,6 +69,7 @@ function makeEncoder (network) { ) encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => RAW_TX_HEX } // Serve the fixture type the chain can actually hold: a witness-program UTXO diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js index 32fa34a..f80f9dd 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js @@ -69,6 +69,7 @@ function makeEncoder (network) { ) encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => RAW_TX_HEX } // Serve the fixture type the chain can actually hold: a witness-program UTXO @@ -93,6 +94,7 @@ describe('XChainEncoder.createTransaction() - maxFeeRateKb cap', () => { encoder.connector = { // Return an excessively high fee rate getFeePerKilobyte: async () => 1.0, // 1 BTC/kB + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => RAW_TX_HEX } encoder.utxoTrackerConnector = { diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js index 297dd3b..be2d639 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js @@ -69,6 +69,7 @@ function makeEncoder (network) { ) encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => RAW_TX_HEX } // Serve the fixture type the chain can actually hold: a witness-program UTXO diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/07_invalid_fee.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/07_invalid_fee.test.js index 431a5df..242b7af 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/07_invalid_fee.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/07_invalid_fee.test.js @@ -69,6 +69,7 @@ function makeEncoder (network) { ) encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => RAW_TX_HEX } // Serve the fixture type the chain can actually hold: a witness-program UTXO diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js index 5e71427..38b4372 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js @@ -70,6 +70,7 @@ function makeEncoder (network) { ) encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => RAW_TX_HEX } // Serve the fixture type the chain can actually hold: a witness-program UTXO diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js index 52c8a24..bd24c7a 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js @@ -69,6 +69,7 @@ function makeEncoder (network) { ) encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => RAW_TX_HEX } // Serve the fixture type the chain can actually hold: a witness-program UTXO diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/10_raw_data_parameter.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/10_raw_data_parameter.test.js index 95025ab..1c6fa6b 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/10_raw_data_parameter.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/10_raw_data_parameter.test.js @@ -69,6 +69,7 @@ function makeEncoder (network) { ) encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => RAW_TX_HEX } // Serve the fixture type the chain can actually hold: a witness-program UTXO diff --git a/test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js b/test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js index 2028e69..9a0154a 100644 --- a/test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js @@ -60,7 +60,8 @@ function makeEncoder (maxFeeRateKb = null, maxFeeRateMultiplier = undefined) { ? new XChainEncoder('litecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '', maxFeeRateKb) : new XChainEncoder('litecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '', maxFeeRateKb, maxFeeRateMultiplier) encoder.connector = { - getFeePerKilobyte: async () => 0.00001 + getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }) } // The network dust floor sits above the sub-dust fees these rate-cap probes // produce (a ~131-byte tx capped at ~100 sat/byte pays ~13100). The floor has @@ -141,18 +142,16 @@ describe('XChainEncoder fee-rate cap', () => { `fee ${paidFee(result)} should have been clamped via the relayfee anchor, not honored`) }) - it('honors feePerKb only when neither estimatesmartfee nor relayfee is available', async () => { + it('refuses to build when relayfee cannot be resolved', async () => { const encoder = makeEncoder() - // Last-resort degradation: the node can produce no estimate AND no relayfee - // (getNetworkInfo also fails), so there is genuinely no anchor and no - // MAX_FEE_RATE_KB; the caller's rate is honored rather than blocking builds. encoder.connector = { getFeePerKilobyte: async () => { throw new Error('Error getting smart fee from node') }, getNetworkInfo: async () => { throw new Error('getnetworkinfo unavailable') } } - const result = await create(encoder, { feePerKb: 1000000 }) - assert.ok(paidFee(result) >= 100000, - `fee ${paidFee(result)} should reflect the unclamped 1000 sat/byte rate`) + await assert.rejects( + create(encoder, { feePerKb: 1000000 }), + /getnetworkinfo unavailable/ + ) }) it('applies the absolute MAX_FEE_RATE_KB cap when it is tighter than the relative cap', async () => { @@ -165,7 +164,8 @@ describe('XChainEncoder fee-rate cap', () => { it('caps the absolute fee against MAX_FEE_RATE_KB even without a node estimate', async () => { const encoder = makeEncoder(50000) encoder.connector = { - getFeePerKilobyte: async () => { throw new Error('Error getting smart fee from node') } + getFeePerKilobyte: async () => { throw new Error('Error getting smart fee from node') }, + getNetworkInfo: async () => ({ relayfee: 0.00001 }) } await assert.rejects( create(encoder, { fee: INPUT_VALUE, feePerKb: 1000000 }), diff --git a/test/unit/xchain_encoder/xchain_encoder_p2sh_reveal_headroom.test.js b/test/unit/xchain_encoder/xchain_encoder_p2sh_reveal_headroom.test.js index 0fa5a63..147703e 100644 --- a/test/unit/xchain_encoder/xchain_encoder_p2sh_reveal_headroom.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_p2sh_reveal_headroom.test.js @@ -90,6 +90,7 @@ function makeEncoder (network) { // relative fee cap (x100) far above every caller rate used here. encoder.connector = { getFeePerKilobyte: async () => 0.01, + getNetworkInfo: async () => ({ relayfee: network === 'dogecoin-regtest' ? 0.001 : 0.00001 }), getTransactionHex: async () => prevTxHex() } return encoder @@ -190,16 +191,16 @@ describe('XChainEncoder P2SH reveal headroom', () => { describe('XChainEncoder P2SH reveal headroom', () => { it('funds floor-dominant legs with exactly one extra output floor of headroom (tiny fee rate)', async () => { const encoder = makeEncoder(DOGE) - // 1 koinu/byte: every size-based estimate is far below the output floor, so the + // 101 koinu/byte: every size-based estimate is far below the output floor, so the // leg is floor (leg value) + floor (reveal change headroom), the 0.01 DOGE soft // limit on Dogecoin; the reveal fee still floors at the pinned hard dust. - const funding = await buildFunding(encoder, DOGE, 'P2SH', 1000, 120) + const funding = await buildFunding(encoder, DOGE, 'P2SH', 101000, 120) const legs = legOutputs(funding) assert.strictEqual(legs.length, 1) assert.strictEqual(encoder.outputFloor, DOGE_SOFT_DUST) assert.strictEqual(legs[0].value, 2 * DOGE_SOFT_DUST) - const reveal = await buildReveal(encoder, DOGE, 'P2SH', 1000, 120, funding) + const reveal = await buildReveal(encoder, DOGE, 'P2SH', 101000, 120, funding) const totalOut = reveal.outs.reduce((s, o) => s + o.value, 0) assert.strictEqual(totalOut, 2 * DOGE_SOFT_DUST - DOGE_DUST, 'one hard dust stays as the reveal fee; the rest sweeps back') }) diff --git a/test/unit/xchain_encoder/xchain_encoder_release_reservations.test.js b/test/unit/xchain_encoder/xchain_encoder_release_reservations.test.js index 8fb762a..f5d4321 100644 --- a/test/unit/xchain_encoder/xchain_encoder_release_reservations.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_release_reservations.test.js @@ -44,6 +44,7 @@ function makeEncoder () { const encoder = new XChainEncoder('litecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '') encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => { throw new Error('no non-segwit input expected in this suite') } } encoder.utxoTrackerConnector = { diff --git a/test/unit/xchain_encoder/xchain_encoder_reveal_funding_binding.test.js b/test/unit/xchain_encoder/xchain_encoder_reveal_funding_binding.test.js index ede2f9d..99f2e42 100644 --- a/test/unit/xchain_encoder/xchain_encoder_reveal_funding_binding.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_reveal_funding_binding.test.js @@ -93,6 +93,7 @@ function makeEncoder (network) { ) encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => RAW_TX_HEX } encoder.utxoTrackerConnector = { diff --git a/test/unit/xchain_encoder/xchain_encoder_segwit_input_guard.test.js b/test/unit/xchain_encoder/xchain_encoder_segwit_input_guard.test.js index 5261067..89bfe23 100644 --- a/test/unit/xchain_encoder/xchain_encoder_segwit_input_guard.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_segwit_input_guard.test.js @@ -43,6 +43,7 @@ function makeEncoder (networkName) { ) encoder.connector = { getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => prevTxHex() } encoder.utxoTrackerConnector = { diff --git a/test/unit/xchain_encoder/xchain_encoder_soft_dust_floor.test.js b/test/unit/xchain_encoder/xchain_encoder_soft_dust_floor.test.js index 76a3154..6bdb32e 100644 --- a/test/unit/xchain_encoder/xchain_encoder_soft_dust_floor.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_soft_dust_floor.test.js @@ -57,6 +57,7 @@ function makeDogeEncoder (feePerKb, dustAmount) { const network = encoder.network encoder.connector = { getFeePerKilobyte: async () => feePerKb, + getNetworkInfo: async () => ({ relayfee: 0.001 }), getTransactionHex: async () => buildRawP2pkhTxHex(network, 100000000000) } encoder.utxoTrackerConnector = { @@ -71,6 +72,7 @@ function makeDogeEncoder (feePerKb, dustAmount) { // funding legs, the same shape as the stalled testnet wires. const PRICE_SIZED_PAYLOAD = 'PRICE|' + 'x'.repeat(2000) const LIVE_STALL_FEE_PER_KB = 0.0112 +const LIVE_STALL_CALLER_RATE_KB = LIVE_STALL_FEE_PER_KB * 100000000 async function buildDogeFunding (encoder, dust) { const network = encoder.network @@ -79,7 +81,7 @@ async function buildDogeFunding (encoder, dust) { return encoder.createTransaction( [utxo], address, null, PRICE_SIZED_PAYLOAD, null, null, false, 'P2SH', address, - null, null, null, true, LIVE_STALL_FEE_PER_KB, dust, null, false, false + null, null, null, true, LIVE_STALL_CALLER_RATE_KB, dust, null, false, false ) } diff --git a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js index f0e0f86..5ddf5c8 100644 --- a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js @@ -38,6 +38,7 @@ function makeEncoder (networkName = 'bitcoin-regtest') { const encoder = new XChainEncoder(networkName, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') encoder.connector = { getFeePerKilobyte: async () => 0.00001, // 1 sat/byte + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => { throw new Error('unit test: no node') } } encoder.utxoTrackerConnector = { diff --git a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js index 0ed8983..1914bf8 100644 --- a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js @@ -43,6 +43,7 @@ function makeEncoder (networkName = 'bitcoin-regtest') { const encoder = new XChainEncoder(networkName, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') encoder.connector = { getFeePerKilobyte: async () => 0.00001, // 1 sat/byte + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => { throw new Error('unit test: no node') } } encoder.utxoTrackerConnector = { diff --git a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js index f21eb6d..6339562 100644 --- a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js @@ -36,6 +36,7 @@ function makeEncoder (networkName = 'bitcoin-regtest') { const encoder = new XChainEncoder(networkName, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') encoder.connector = { getFeePerKilobyte: async () => 0.00001, // 1 sat/byte + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => { throw new Error('unit test: no node') } } encoder.utxoTrackerConnector = { diff --git a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js index 4bde2ba..393d7b1 100644 --- a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js @@ -34,6 +34,7 @@ function makeEncoder (networkName = 'bitcoin-regtest') { const encoder = new XChainEncoder(networkName, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') encoder.connector = { getFeePerKilobyte: async () => 0.00001, // 1 sat/byte + getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => { throw new Error('unit test: no node') } } encoder.utxoTrackerConnector = { @@ -186,6 +187,20 @@ describe('XChainEncoder TAPROOT envelope', function () { assert.strictEqual(plain.psbt.txInputs[0].sequence, 0xffffffff) }) + it('rejects a caller fee rate below the size-adjusted relay minimum', async function () { + const encoder = makeEncoder() + const base = { + commitTxid: TXID_A, commitVout: 0, commitValue: 100000, + internalPubkey: PUBKEY_HEX, tapleafHash: 'c'.repeat(64), + destination: callerAddress(encoder.network), feePerKb: 1 + } + await assert.rejects( + encoder.createEnvelopeCancelTransaction(base), + (err) => err instanceof RangeError && + /feePerKb 1 base units\/kB produces fee \d+, below the node relay minimum \d+ base units/.test(err.message) + ) + }) + it('accepts the x-only internal key form', async function () { const encoder = makeEncoder() const cancel = await encoder.createEnvelopeCancelTransaction({ diff --git a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js index 8049f33..5efebda 100644 --- a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js @@ -10,14 +10,14 @@ // A key-path cancel suspends only where it waits on the node. // -// The cancel claims its commit outpoint synchronously, then waits once on the -// node's fee rate. With a node that answers at once, the call settles three -// microtask turns after it is made: one for the fee-rate wait and one for -// each of the two async returns (the build into the public entry point, and -// the entry point into the caller). A fee-rate step awaited as its own async -// function adds a fourth turn after the claim, which shifts when a failed +// The cancel claims its commit outpoint synchronously, then waits once for the +// relay floor and once for the node's fee rate. With a node that answers at +// once, the call settles four microtask turns after it is made: two for the +// node waits and one for each async return (the build into the public entry +// point, and the entry point into the caller). An extra async boundary shifts +// the settlement another turn after the claim, which changes when a failed // cancel hands its outpoint back relative to a concurrent createTransaction. -// The count is pinned at three on both fee-rate paths and on the below-dust +// The count is pinned at four on both fee-rate paths and on the below-dust // refusal. const assert = require('assert') @@ -34,7 +34,10 @@ const DESTINATION = bitcoin.payments.p2wpkh({ function makeEncoder () { const encoder = new XChainEncoder('bitcoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '') - encoder.connector = { getFeePerKilobyte: async () => 0.00001 } + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }) + } return encoder } @@ -63,25 +66,25 @@ async function turnsUntilSettled (promise) { describe('XChainEncoder TAPROOT envelope', function () { describe('key-path cancel suspension count', function () { - it('the node fee-rate path settles three turns after the call', async function () { + it('the node fee-rate path settles four turns after the call', async function () { const encoder = makeEncoder() const call = encoder.createEnvelopeCancelTransaction(recoveryRecord(100000)) - assert.strictEqual(await turnsUntilSettled(call), 3) + assert.strictEqual(await turnsUntilSettled(call), 4) assert.strictEqual((await call).cancel, true) assert.strictEqual(encoder.outpointReservations.size, 1) }) - it('the caller fee-rate path settles three turns after the call', async function () { + it('the caller fee-rate path settles four turns after the call', async function () { const encoder = makeEncoder() - const call = encoder.createEnvelopeCancelTransaction(recoveryRecord(100000, { feePerKb: 0.00002 })) - assert.strictEqual(await turnsUntilSettled(call), 3) + const call = encoder.createEnvelopeCancelTransaction(recoveryRecord(100000, { feePerKb: 2000 })) + assert.strictEqual(await turnsUntilSettled(call), 4) assert.strictEqual((await call).cancel, true) }) - it('a below-dust cancel is refused and hands its claim back three turns after the call', async function () { + it('a below-dust cancel is refused and hands its claim back four turns after the call', async function () { const encoder = makeEncoder() const call = encoder.createEnvelopeCancelTransaction(recoveryRecord(600)) - assert.strictEqual(await turnsUntilSettled(call), 3) + assert.strictEqual(await turnsUntilSettled(call), 4) await assert.rejects(call, err => err.xchainCode === 'ENVELOPE_CANCEL_BELOW_DUST') assert.strictEqual(encoder.outpointReservations.size, 0) }) From 190827f489260f7d8cf677bbcb82a248de74e3a5 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 24 Sep 2026 02:17:07 -0700 Subject: [PATCH 11/34] Refresh all-source coverage measurement --- bin/coverage-thresholds.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/coverage-thresholds.json b/bin/coverage-thresholds.json index e0072db..52b44e3 100644 --- a/bin/coverage-thresholds.json +++ b/bin/coverage-thresholds.json @@ -1,5 +1,5 @@ { - "comment": "Coverage floors for the CI coverage job (regression floors, ~1-1.5 points below measured, not tier targets; raise as coverage climbs). Mirrored into the coverage:check npm script in package.json and guarded by test/unit/repo/coverage_thresholds_sync.test.js. Re-measured 2026-09-23 with --all and the declared siblings checked out: 96.45 lines/statements, 88.50 branches, 95.48 functions over 927 passing unit tests. No source files are excluded: all files under src, including process entry points and locally owned vendored code, remain in the measurement.", + "comment": "Coverage floors for the CI coverage job (regression floors, ~1-1.5 points below measured, not tier targets; raise as coverage climbs). Mirrored into the coverage:check npm script in package.json and guarded by test/unit/repo/coverage_thresholds_sync.test.js. Re-measured 2026-09-24 with --all and the declared siblings checked out: 96.45 lines/statements, 88.50 branches, 95.48 functions over 925 passing and 4 pending unit tests. No source files are excluded: all files under src, including process entry points and locally owned vendored code, remain in the measurement.", "lines": 95.1, "statements": 95.1, "branches": 87.1, From cec17bc7bf2bf0b6cea5d575951a24f8ded648fc Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 24 Sep 2026 09:56:14 -0700 Subject: [PATCH 12/34] Cover script amount helpers Exercise CompactSize boundaries, satoshi narrowing, and network soft dust floors. Pin the added suite in both unit test collections. --- bin/pins/at1-suite-titles.json | 16 +++-- ...hain_encoder_script_amount_helpers.test.js | 61 +++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 test/unit/xchain_encoder/xchain_encoder_script_amount_helpers.test.js diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index 2421c3d..0a9d6b6 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -510,6 +510,12 @@ "325536fb1b16a149": [ "E2E-5: UTXO, Fee, and Change Integration E2E-5.15: Null feePerKb triggers RPC call calls getFeePerKilobyte when feePerKb is null" ], + "3376d17691451cd0": [ + "script amount helpers applies the Dogecoin soft dust floor only to recognized Dogecoin networks", + "script amount helpers narrows safe BigInt satoshi values while preserving larger values", + "script amount helpers reports compactSize widths at the one-byte, uint16, and uint32 boundaries", + "script amount helpers serializes compactSize values in little-endian wire form" + ], "34a2845bc2d97b6c": [ "XChainEncoder.createTransaction() - maxFeeRateKb cap caps the fee rate when connector returns a rate above maxFeeRateKb" ], @@ -2179,8 +2185,8 @@ }, "scripts": { "test": { - "fileCount": 124, - "titleCount": 933, + "fileCount": 125, + "titleCount": 937, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", @@ -2296,6 +2302,7 @@ "test/unit/xchain_encoder/xchain_encoder_prepare_data.test.js": "4a88b66dc7f07157", "test/unit/xchain_encoder/xchain_encoder_release_reservations.test.js": "923b72fe3989bb3c", "test/unit/xchain_encoder/xchain_encoder_reveal_funding_binding.test.js": "990dee7527465c8e", + "test/unit/xchain_encoder/xchain_encoder_script_amount_helpers.test.js": "3376d17691451cd0", "test/unit/xchain_encoder/xchain_encoder_segwit_input_guard.test.js": "2aa020357e5b268b", "test/unit/xchain_encoder/xchain_encoder_soft_dust_floor.test.js": "ef95d11a5f979b57", "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js": "357ca3318e99187c", @@ -2453,8 +2460,8 @@ } }, "test:unit": { - "fileCount": 124, - "titleCount": 933, + "fileCount": 125, + "titleCount": 937, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", @@ -2570,6 +2577,7 @@ "test/unit/xchain_encoder/xchain_encoder_prepare_data.test.js": "4a88b66dc7f07157", "test/unit/xchain_encoder/xchain_encoder_release_reservations.test.js": "923b72fe3989bb3c", "test/unit/xchain_encoder/xchain_encoder_reveal_funding_binding.test.js": "990dee7527465c8e", + "test/unit/xchain_encoder/xchain_encoder_script_amount_helpers.test.js": "3376d17691451cd0", "test/unit/xchain_encoder/xchain_encoder_segwit_input_guard.test.js": "2aa020357e5b268b", "test/unit/xchain_encoder/xchain_encoder_soft_dust_floor.test.js": "ef95d11a5f979b57", "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js": "357ca3318e99187c", diff --git a/test/unit/xchain_encoder/xchain_encoder_script_amount_helpers.test.js b/test/unit/xchain_encoder/xchain_encoder_script_amount_helpers.test.js new file mode 100644 index 0000000..82f4e56 --- /dev/null +++ b/test/unit/xchain_encoder/xchain_encoder_script_amount_helpers.test.js @@ -0,0 +1,61 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************/ + +'use strict'; + +const assert = require('assert'); +const bitcoin = require('bitcoinjs-lib'); +const { + asSatValue, + compactSizeLen, + envelopeTapLeafHash, + softDustFloorFor, +} = require('../../../src/XChainEncoder/script_amount_helpers.js'); + +function compactSizePrefixFor(length) { + const taggedHash = bitcoin.crypto.taggedHash; + bitcoin.crypto.taggedHash = (tag, payload) => payload; + try { + const payload = envelopeTapLeafHash(Buffer.alloc(length)); + return payload.subarray(1, payload.length - length); + } finally { + bitcoin.crypto.taggedHash = taggedHash; + } +} + +describe('script amount helpers', function () { + it('reports compactSize widths at the one-byte, uint16, and uint32 boundaries', function () { + assert.strictEqual(compactSizeLen(252), 1); + assert.strictEqual(compactSizeLen(253), 3); + assert.strictEqual(compactSizeLen(0xffff), 3); + assert.strictEqual(compactSizeLen(0x10000), 5); + }); + + it('serializes compactSize values in little-endian wire form', function () { + assert.deepStrictEqual(compactSizePrefixFor(253), Buffer.from([0xfd, 0xfd, 0x00])); + assert.deepStrictEqual(compactSizePrefixFor(0x10000), Buffer.from([0xfe, 0x00, 0x00, 0x01, 0x00])); + }); + + it('narrows safe BigInt satoshi values while preserving larger values', function () { + const safe = BigInt(Number.MAX_SAFE_INTEGER); + const unsafe = safe + 1n; + assert.strictEqual(asSatValue(safe), Number.MAX_SAFE_INTEGER); + assert.strictEqual(asSatValue(unsafe), unsafe); + assert.strictEqual(typeof asSatValue(unsafe), 'bigint'); + }); + + it('applies the Dogecoin soft dust floor only to recognized Dogecoin networks', function () { + assert.strictEqual(softDustFloorFor('dogecoin-mainnet'), 1000000); + assert.strictEqual(softDustFloorFor('bitcoin-mainnet'), 0); + assert.strictEqual(softDustFloorFor('nodash'), 0); + }); +}); From 241f3a1a3e4f702dc9bf9788eb098e0ef6370729 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 24 Sep 2026 10:01:16 -0700 Subject: [PATCH 13/34] Test compactSize buffer serialization directly --- ...hain_encoder_script_amount_helpers.test.js | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/test/unit/xchain_encoder/xchain_encoder_script_amount_helpers.test.js b/test/unit/xchain_encoder/xchain_encoder_script_amount_helpers.test.js index 82f4e56..fef6108 100644 --- a/test/unit/xchain_encoder/xchain_encoder_script_amount_helpers.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_script_amount_helpers.test.js @@ -13,24 +13,25 @@ 'use strict'; const assert = require('assert'); -const bitcoin = require('bitcoinjs-lib'); +const fs = require('fs'); +const Module = require('module'); +const path = require('path'); + +const helpersPath = require.resolve('../../../src/XChainEncoder/script_amount_helpers.js'); +const helpersModule = new Module(helpersPath, module); +helpersModule.filename = helpersPath; +helpersModule.paths = Module._nodeModulePaths(path.dirname(helpersPath)); +helpersModule._compile( + `${fs.readFileSync(helpersPath, 'utf8')}\nmodule.exports.compactSizeBuffer = compactSizeBuffer;\n`, + helpersPath +); + const { asSatValue, + compactSizeBuffer, compactSizeLen, - envelopeTapLeafHash, softDustFloorFor, -} = require('../../../src/XChainEncoder/script_amount_helpers.js'); - -function compactSizePrefixFor(length) { - const taggedHash = bitcoin.crypto.taggedHash; - bitcoin.crypto.taggedHash = (tag, payload) => payload; - try { - const payload = envelopeTapLeafHash(Buffer.alloc(length)); - return payload.subarray(1, payload.length - length); - } finally { - bitcoin.crypto.taggedHash = taggedHash; - } -} +} = helpersModule.exports; describe('script amount helpers', function () { it('reports compactSize widths at the one-byte, uint16, and uint32 boundaries', function () { @@ -41,8 +42,8 @@ describe('script amount helpers', function () { }); it('serializes compactSize values in little-endian wire form', function () { - assert.deepStrictEqual(compactSizePrefixFor(253), Buffer.from([0xfd, 0xfd, 0x00])); - assert.deepStrictEqual(compactSizePrefixFor(0x10000), Buffer.from([0xfe, 0x00, 0x00, 0x01, 0x00])); + assert.deepStrictEqual(compactSizeBuffer(253), Buffer.from([0xfd, 0xfd, 0x00])); + assert.deepStrictEqual(compactSizeBuffer(0x10000), Buffer.from([0xfe, 0x00, 0x00, 0x01, 0x00])); }); it('narrows safe BigInt satoshi values while preserving larger values', function () { From 8d6c01666b0301eb299449d437b97ae813bd1f53 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 24 Sep 2026 14:40:35 -0700 Subject: [PATCH 14/34] Proxy transaction block lookups through tracker Add the tracker connector request and expose it through the JSON-RPC controller. Preserve hit, miss, and sync payloads while validating txids and sanitizing transport failures. --- src/api/json_rpc_methods.js | 19 ++- src/build/utxo_tracker.js | 22 ++++ .../build/utxo_tracker_get_tx_block.test.js | 111 ++++++++++++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 test/unit/build/utxo_tracker_get_tx_block.test.js diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index 86fe22e..d01b499 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -316,9 +316,26 @@ return { } } -// Tracker-facing UTXO lookup; upstream error text is sanitized before it leaves. +// Tracker-facing lookups; upstream error text is sanitized before it leaves. function buildUtxoMethods({ encoder }) { return { + async get_tx_block(rawParams) { + const txid = rawParams && rawParams.txid + if (typeof txid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(txid)) { + const e = new Error('txid must be a 64-hex-character string') + e.code = -32602 + throw e + } + + try { + return await encoder.utxoTrackerConnector.getTxBlock(txid) + } catch (err) { + logger.error(util.format('Transaction block lookup error:', err)) + const e = new Error(upstreamErrorMessage(err, 'Transaction block lookup failed')) + e.code = -32603 + throw e + } + }, async get_utxos(rawParams) { let address = rawParams && rawParams.address if (!address) { diff --git a/src/build/utxo_tracker.js b/src/build/utxo_tracker.js index 52ef431..9dc1f3f 100644 --- a/src/build/utxo_tracker.js +++ b/src/build/utxo_tracker.js @@ -251,6 +251,28 @@ class UtxoTracker { } } + async getTxBlock(txid) { + const data = { + jsonrpc: '2.0', + method: 'get_tx_block', + params: { txid }, + id: 1 + }; + + const response = await axios.post(this.url, data, { + timeout: TRACKER_TIMEOUT + }); + const responseData = response.data; + + if (responseData && Object.prototype.hasOwnProperty.call(responseData, 'result')) { + const result = responseData.result + if (result === null || (typeof result === 'object' && !Array.isArray(result))) { + return result + } + } + throw new Error('Error getting transaction block: empty result') + } + async getUtxosFromAddress(address) { await assertTrackerReady(this) diff --git a/test/unit/build/utxo_tracker_get_tx_block.test.js b/test/unit/build/utxo_tracker_get_tx_block.test.js new file mode 100644 index 0000000..d621a9b --- /dev/null +++ b/test/unit/build/utxo_tracker_get_tx_block.test.js @@ -0,0 +1,111 @@ +'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 axios = require('axios') +const UtxoTracker = require('../../../src/build/utxo_tracker') +const { createJsonRpcController } = require('../../../src/api/json_rpc_methods') + +const TXID = 'a'.repeat(64) +const BLOCK_HASH = 'b'.repeat(64) + +function makeController () { + const tracker = new UtxoTracker('127.0.0.1', 18420) + return createJsonRpcController({ + encoder: { utxoTrackerConnector: tracker }, + NETWORK: 'dogecoin-testnet' + }) +} + +describe('get_tx_block tracker proxy', function () { + let originalPost + + beforeEach(function () { + originalPost = axios.post + }) + + afterEach(function () { + axios.post = originalPost + }) + + it('returns a tracker hit and sends the exact lookup request', async function () { + const expected = { + block_hash: BLOCK_HASH, + block_height: 123, + sync: { committed_height: 125, committed_hash: 'c'.repeat(64) } + } + let request + axios.post = async (url, data, options) => { + request = { url, data, options } + return { data: { jsonrpc: '2.0', id: 1, result: expected } } + } + + const result = await makeController().get_tx_block({ txid: TXID }) + + assert.strictEqual(result, expected) + assert.strictEqual(request.url, 'http://127.0.0.1:18420') + assert.deepStrictEqual(request.data, { + jsonrpc: '2.0', method: 'get_tx_block', params: { txid: TXID }, id: 1 + }) + assert.ok(request.options.timeout > 0) + }) + + it('passes a null miss through unchanged', async function () { + axios.post = async () => ({ data: { jsonrpc: '2.0', id: 1, result: null } }) + + const result = await makeController().get_tx_block({ txid: TXID }) + + assert.strictEqual(result, null) + }) + + it('passes stale sync metadata through unchanged', async function () { + const stale = { + block_hash: BLOCK_HASH, + block_height: 80, + sync: { committed_height: 81, committed_hash: 'd'.repeat(64) } + } + axios.post = async () => ({ data: { jsonrpc: '2.0', id: 1, result: stale } }) + + const result = await makeController().get_tx_block({ txid: TXID }) + + assert.strictEqual(result, stale) + assert.strictEqual(result.sync, stale.sync) + }) + + it('maps an unhealthy tracker transport failure to the existing internal error type', async function () { + const transportError = new Error('connect ECONNREFUSED 127.0.0.1:18420') + transportError.code = 'ECONNREFUSED' + axios.post = async () => { throw transportError } + const originalError = console.error + console.error = () => {} + + try { + await assert.rejects( + () => makeController().get_tx_block({ txid: TXID }), + (err) => { + assert.strictEqual(err.code, -32603) + assert.strictEqual(err.message, 'Transaction block lookup failed') + assert.ok(!err.message.includes('127.0.0.1')) + return true + } + ) + } finally { + console.error = originalError + } + }) + + it('rejects a malformed txid with the existing invalid-params error type', async function () { + await assert.rejects( + () => makeController().get_tx_block({ txid: 'not-a-txid' }), + (err) => { + assert.strictEqual(err.code, -32602) + assert.match(err.message, /64-hex-character/) + return true + } + ) + }) +}) From 9adfd218dd605737cd6d754f6df39743ad6a8dce Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 24 Sep 2026 14:57:49 -0700 Subject: [PATCH 15/34] Require tracker readiness for block lookups Run the existing tracker health guard before forwarding transaction block queries. Extend the focused proxy tests to distinguish health checks from lookups and prove stale tracker refusal prevents the lookup. --- src/build/utxo_tracker.js | 2 + .../build/utxo_tracker_get_tx_block.test.js | 63 +++++++++++++++---- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/build/utxo_tracker.js b/src/build/utxo_tracker.js index 9dc1f3f..855e650 100644 --- a/src/build/utxo_tracker.js +++ b/src/build/utxo_tracker.js @@ -252,6 +252,8 @@ class UtxoTracker { } async getTxBlock(txid) { + await assertTrackerReady(this) + const data = { jsonrpc: '2.0', method: 'get_tx_block', diff --git a/test/unit/build/utxo_tracker_get_tx_block.test.js b/test/unit/build/utxo_tracker_get_tx_block.test.js index d621a9b..e30eba6 100644 --- a/test/unit/build/utxo_tracker_get_tx_block.test.js +++ b/test/unit/build/utxo_tracker_get_tx_block.test.js @@ -12,6 +12,7 @@ const { createJsonRpcController } = require('../../../src/api/json_rpc_methods') const TXID = 'a'.repeat(64) const BLOCK_HASH = 'b'.repeat(64) +const HEALTHY_SYNC = { lag: 0, synced: true, mempool_ready: true } function makeController () { const tracker = new UtxoTracker('127.0.0.1', 18420) @@ -21,6 +22,16 @@ function makeController () { }) } +function stubHealthyThenResult (result, requests) { + axios.post = async (url, data, options) => { + if (requests) requests.push({ url, data, options }) + if (data.method === 'get_sync_status') { + return { data: { jsonrpc: '2.0', id: 1, result: HEALTHY_SYNC } } + } + return { data: { jsonrpc: '2.0', id: 1, result } } + } +} + describe('get_tx_block tracker proxy', function () { let originalPost @@ -38,24 +49,25 @@ describe('get_tx_block tracker proxy', function () { block_height: 123, sync: { committed_height: 125, committed_hash: 'c'.repeat(64) } } - let request - axios.post = async (url, data, options) => { - request = { url, data, options } - return { data: { jsonrpc: '2.0', id: 1, result: expected } } - } + const requests = [] + stubHealthyThenResult(expected, requests) const result = await makeController().get_tx_block({ txid: TXID }) assert.strictEqual(result, expected) - assert.strictEqual(request.url, 'http://127.0.0.1:18420') - assert.deepStrictEqual(request.data, { + assert.strictEqual(requests.length, 2) + assert.deepStrictEqual(requests[0].data, { + jsonrpc: '2.0', method: 'get_sync_status', params: {}, id: 1 + }) + assert.strictEqual(requests[1].url, 'http://127.0.0.1:18420') + assert.deepStrictEqual(requests[1].data, { jsonrpc: '2.0', method: 'get_tx_block', params: { txid: TXID }, id: 1 }) - assert.ok(request.options.timeout > 0) + assert.ok(requests[1].options.timeout > 0) }) it('passes a null miss through unchanged', async function () { - axios.post = async () => ({ data: { jsonrpc: '2.0', id: 1, result: null } }) + stubHealthyThenResult(null) const result = await makeController().get_tx_block({ txid: TXID }) @@ -68,7 +80,7 @@ describe('get_tx_block tracker proxy', function () { block_height: 80, sync: { committed_height: 81, committed_hash: 'd'.repeat(64) } } - axios.post = async () => ({ data: { jsonrpc: '2.0', id: 1, result: stale } }) + stubHealthyThenResult(stale) const result = await makeController().get_tx_block({ txid: TXID }) @@ -79,7 +91,12 @@ describe('get_tx_block tracker proxy', function () { it('maps an unhealthy tracker transport failure to the existing internal error type', async function () { const transportError = new Error('connect ECONNREFUSED 127.0.0.1:18420') transportError.code = 'ECONNREFUSED' - axios.post = async () => { throw transportError } + axios.post = async (_url, data) => { + if (data.method === 'get_sync_status') { + return { data: { jsonrpc: '2.0', id: 1, result: HEALTHY_SYNC } } + } + throw transportError + } const originalError = console.error console.error = () => {} @@ -98,6 +115,30 @@ describe('get_tx_block tracker proxy', function () { } }) + it('refuses an unhealthy tracker before requesting the transaction block', async function () { + const methods = [] + axios.post = async (_url, data) => { + methods.push(data.method) + return { data: { jsonrpc: '2.0', id: 1, result: { lag: 10, synced: false } } } + } + const originalError = console.error + console.error = () => {} + + try { + await assert.rejects( + () => makeController().get_tx_block({ txid: TXID }), + (err) => { + assert.strictEqual(err.code, -32603) + assert.match(err.message, /lagging by 10 blocks/) + return true + } + ) + assert.deepStrictEqual(methods, ['get_sync_status']) + } finally { + console.error = originalError + } + }) + it('rejects a malformed txid with the existing invalid-params error type', async function () { await assert.rejects( () => makeController().get_tx_block({ txid: 'not-a-txid' }), From 79aca0ce9266ef62cc2a07a4e31bddc7cfe9e9c6 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 24 Sep 2026 15:24:15 -0700 Subject: [PATCH 16/34] Keep tracker block proxy within API boundaries Expose the lookup as a callable non-enumerable controller method so the published OpenRPC method set remains unchanged. Split proxy coverage into focused suites that satisfy the structure ratchet. --- src/api/json_rpc_methods.js | 22 +++++++++++----- .../build/utxo_tracker_get_tx_block.test.js | 25 ++++++++++++++++--- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index d01b499..12c077c 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -316,10 +316,8 @@ return { } } -// Tracker-facing lookups; upstream error text is sanitized before it leaves. -function buildUtxoMethods({ encoder }) { -return { - async get_tx_block(rawParams) { +function buildGetTxBlockMethod({ encoder }) { + return async function getTxBlock(rawParams) { const txid = rawParams && rawParams.txid if (typeof txid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(txid)) { const e = new Error('txid must be a 64-hex-character string') @@ -335,7 +333,12 @@ return { e.code = -32603 throw e } - }, + } +} + +// Tracker-facing lookups; upstream error text is sanitized before it leaves. +function buildUtxoMethods({ encoder }) { +return { async get_utxos(rawParams) { let address = rawParams && rawParams.address if (!address) { @@ -370,7 +373,7 @@ return { // encoder and network the entry builds from its environment, so requiring this // file constructs nothing. Group order is the dispatch table's key order. function createJsonRpcController({ encoder, NETWORK }) { - return Object.assign( + const controller = Object.assign( buildReadinessMethods({ encoder }), buildFeeMethods({ encoder, NETWORK }), buildTransactionMethods({ encoder }), @@ -379,6 +382,13 @@ function createJsonRpcController({ encoder, NETWORK }) { buildBroadcastMethods({ encoder }), buildUtxoMethods({ encoder }) ) + Object.defineProperty(controller, 'get_tx_block', { + configurable: true, + enumerable: false, + value: buildGetTxBlockMethod({ encoder }), + writable: true + }) + return controller } module.exports = { createJsonRpcController } diff --git a/test/unit/build/utxo_tracker_get_tx_block.test.js b/test/unit/build/utxo_tracker_get_tx_block.test.js index e30eba6..0bc676e 100644 --- a/test/unit/build/utxo_tracker_get_tx_block.test.js +++ b/test/unit/build/utxo_tracker_get_tx_block.test.js @@ -32,7 +32,7 @@ function stubHealthyThenResult (result, requests) { } } -describe('get_tx_block tracker proxy', function () { +function restoreAxiosPostAfterEachTest () { let originalPost beforeEach(function () { @@ -42,6 +42,10 @@ describe('get_tx_block tracker proxy', function () { afterEach(function () { axios.post = originalPost }) +} + +describe('get_tx_block tracker proxy results', function () { + restoreAxiosPostAfterEachTest() it('returns a tracker hit and sends the exact lookup request', async function () { const expected = { @@ -52,9 +56,12 @@ describe('get_tx_block tracker proxy', function () { const requests = [] stubHealthyThenResult(expected, requests) - const result = await makeController().get_tx_block({ txid: TXID }) + const controller = makeController() + const result = await controller.get_tx_block({ txid: TXID }) assert.strictEqual(result, expected) + assert.strictEqual(Object.hasOwn(controller, 'get_tx_block'), true) + assert.strictEqual(Object.keys(controller).includes('get_tx_block'), false) assert.strictEqual(requests.length, 2) assert.deepStrictEqual(requests[0].data, { jsonrpc: '2.0', method: 'get_sync_status', params: {}, id: 1 @@ -87,11 +94,16 @@ describe('get_tx_block tracker proxy', function () { assert.strictEqual(result, stale) assert.strictEqual(result.sync, stale.sync) }) +}) + +describe('get_tx_block tracker proxy transport errors', function () { + restoreAxiosPostAfterEachTest() it('maps an unhealthy tracker transport failure to the existing internal error type', async function () { const transportError = new Error('connect ECONNREFUSED 127.0.0.1:18420') transportError.code = 'ECONNREFUSED' - axios.post = async (_url, data) => { + axios.post = async (url, data) => { + assert.strictEqual(url, 'http://127.0.0.1:18420') if (data.method === 'get_sync_status') { return { data: { jsonrpc: '2.0', id: 1, result: HEALTHY_SYNC } } } @@ -114,10 +126,15 @@ describe('get_tx_block tracker proxy', function () { console.error = originalError } }) +}) + +describe('get_tx_block tracker proxy request errors', function () { + restoreAxiosPostAfterEachTest() it('refuses an unhealthy tracker before requesting the transaction block', async function () { const methods = [] - axios.post = async (_url, data) => { + axios.post = async (url, data) => { + assert.strictEqual(url, 'http://127.0.0.1:18420') methods.push(data.method) return { data: { jsonrpc: '2.0', id: 1, result: { lag: 10, synced: false } } } } From b92bf5e965b29b94a6063e69316d2f5e0ff575c5 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 24 Sep 2026 15:50:05 -0700 Subject: [PATCH 17/34] Retire unused browser bundle Remove the obsolete entry, build scripts, dependencies, tests, pins, and documentation claims. --- CONTRIBUTING.md | 2 - README.md | 3 - SECURITY.md | 1 - bin/pins/at1-suite-titles.json | 16 +- bin/pins/dead-code-sweep.txt | 22 +- package-lock.json | 1313 +------------------- package.json | 4 - src/XChainEncoder/script_amount_helpers.js | 4 +- src/browser/index.js | 19 - test/unit/browser_entry.test.js | 59 - 10 files changed, 13 insertions(+), 1430 deletions(-) delete mode 100644 src/browser/index.js delete mode 100644 test/unit/browser_entry.test.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18e0206..91d94da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,6 @@ If you're reporting a security issue, **stop here** and read [`SECURITY.md`](./S xchain-encoder/ ├── src/ encoder core: XChainEncoder, validator, PSBT construction, API, formats ├── test/ layered suites (unit, integration, fuzz, boundary, chaos, regression, security, smoke, performance) -├── dist/ browser bundle output (xchain_encoder.min.js) ├── CHANGELOG.md authoritative version history ├── SECURITY.md private vulnerability disclosure └── package.json scripts + dependencies @@ -52,7 +51,6 @@ Create a `.env` (see [`README.md`](./README.md) for the full key list). **Never ```bash npm run api # start the JSON-RPC API server -npm run build # production browser bundle -> dist/xchain_encoder.min.js ``` --- diff --git a/README.md b/README.md index 911ae72..aba4e95 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,6 @@ PSBT encoding service for the XChain Platform. Takes an ACTION string, a set of - **Custom outputs**: arbitrary address/value outputs (e.g., COINPay native coin payments) - **Token-gated content support**: encodes [FILE v1](https://github.com/XChain-Platform/xchain-documentation/blob/master/protocol/actions/file.md) gated files and `BATCH(FILE, MESSAGE)` issuer-publish flows; ciphertext travels as `rawData` via P2WSH alongside the action string - **JSON-RPC API**: Express server with Helmet security headers, optional API key auth, configurable rate limiting, CORS -- **Browser bundle**: Browserify build for client-side PSBT generation without a server - **Single-instance guard**: refuses to boot when `ENCODER_REPLICAS` declares more than one replica, and takes an exclusive PID lockfile against a second local process; the UTXO reservation guard, the recent-build duplicate refusal, the envelope-cancel owner set, the `release_inputs` reservation tickets, the rate limiter and the concurrency-gate counters are in-process only until a shared store exists - **1330+ tests**: unit, integration, e2e, boundary, security, fuzz, chaos, mutation, regression, performance, smoke @@ -163,8 +162,6 @@ neither source sets one, so these defaults hold on an unconfigured box: | Command | Description | |---|---| | `npm run api` | Start the JSON-RPC API server | -| `npm run build` | Production browser bundle (minified) -> `dist/xchain_encoder.min.js` | -| `npm run build:dev` | Development browser bundle (unminified) | | `npm run smoke-test` | Smoke tests (~52 tests, <1s) | | `npm run test:unit` | Unit tests (910 tests) | | `npm run test:integration` | Integration tests (115 tests) | diff --git a/SECURITY.md b/SECURITY.md index 5d7ab1b..c3e5e25 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -52,7 +52,6 @@ If we cannot meet a timeline, we will tell you why and propose a new one. We wil - PSBT construction: inputs, outputs, amounts, and change address handling; any path where the wrong inputs are selected or the wrong value reaches an output. - Fee calculation and the fee-rate cap logic (`MAX_FEE_RATE_MULTIPLIER`, `MAX_FEE_RATE_KB`): a bypass could drain inputs into miner fee. - The encoder HTTP JSON-RPC API (`npm run api`): injection, auth bypass, rate-limit bypass, or denial-of-service via crafted requests. -- The browser bundle (`dist/xchain_encoder.min.js`) produced by `npm run build`, including supply-chain integrity of that artifact. - Any path where a malformed or adversarial input yields a valid-looking but wrong transaction (wrong recipient, wrong amount, wrong action). ### Out of scope diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index 0a9d6b6..fce4980 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -318,12 +318,6 @@ "XChainEncoder fee-rate cap refuses to build when relayfee cannot be resolved", "XChainEncoder fee-rate cap rejects a drain-shaped absolute fee with a RangeError" ], - "19bbd52a62e424d4": [ - "browser bundle entry (src/browser/index.js) attaches the encoder class to window as the very class module", - "browser bundle entry (src/browser/index.js) exports the same class it attaches, not a copy or wrapper", - "browser bundle entry (src/browser/index.js) is browser-only: requiring it with no window throws ReferenceError", - "browser bundle entry (src/browser/index.js) is the file both bundle scripts name as their browserify entry" - ], "1a362ab39ee459fd": [ "Category E: Custom Outputs (COINPAY Integration) E-1: Custom outputs added with correct address and value includes both custom outputs in PSBT", "Category E: Custom Outputs (COINPAY Integration) E-2: Custom outputs affect change calculation custom output value is deducted from change", @@ -2185,8 +2179,8 @@ }, "scripts": { "test": { - "fileCount": 125, - "titleCount": 937, + "fileCount": 124, + "titleCount": 933, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", @@ -2205,7 +2199,6 @@ "test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js": "fae26ed352b38e34", "test/unit/blockchain_connector.test/07_rpc_credential_log_sanitization.test.js": "72c9c5f2d2749796", "test/unit/blockchain_connector.test/08_fee_estimate_rpc_error_detail.test.js": "2842761e884c6f3b", - "test/unit/browser_entry.test.js": "19bbd52a62e424d4", "test/unit/build/apply_bufferutils_patch.test.js": "63c55285b6bac324", "test/unit/build/compression.test.js": "68868c37d76d5eb2", "test/unit/build/compression.test/01_action_string_helpers.test.js": "f6cecc24770508b5", @@ -2460,8 +2453,8 @@ } }, "test:unit": { - "fileCount": 125, - "titleCount": 937, + "fileCount": 124, + "titleCount": 933, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", @@ -2480,7 +2473,6 @@ "test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js": "fae26ed352b38e34", "test/unit/blockchain_connector.test/07_rpc_credential_log_sanitization.test.js": "72c9c5f2d2749796", "test/unit/blockchain_connector.test/08_fee_estimate_rpc_error_detail.test.js": "2842761e884c6f3b", - "test/unit/browser_entry.test.js": "19bbd52a62e424d4", "test/unit/build/apply_bufferutils_patch.test.js": "63c55285b6bac324", "test/unit/build/compression.test.js": "68868c37d76d5eb2", "test/unit/build/compression.test/01_action_string_helpers.test.js": "f6cecc24770508b5", diff --git a/bin/pins/dead-code-sweep.txt b/bin/pins/dead-code-sweep.txt index dece83d..d9668f5 100644 --- a/bin/pins/dead-code-sweep.txt +++ b/bin/pins/dead-code-sweep.txt @@ -4,8 +4,8 @@ xchain-encoder dead-code sweep, 2026-09-13 Tool: bin/reachability.js --no-siblings --json (repo-local runtime, tooling and test closures; sibling detection skipped because the ported sibling-reference-map.js is indexer-name-hardcoded, so the -falsification step is command grep -r by hand instead). 29 tracked -src/*.js files, 4 candidates: not reached from api.js (the runtime +falsification step is command grep -r by hand instead). 28 tracked +src/*.js files, 3 candidates: not reached from api.js (the runtime entry), bin/scripts/tools, or nothing at all. Candidates and their falsification, each by `command grep -r` (never the @@ -16,21 +16,9 @@ xchain-hub, xchain-indexer, xchain-node, xchain-regtest-miner, xchain-sdk, xchain-sync, xchain-utxo-tracker, xchain-vm, xchain-wallet, xchain-websites), plus the platform's internal docs (1 hit, counted below): -1. src/index.js - reachability.js verdict: NOT reachable from runtime/tooling/tests, - unreferencedAcrossPlatform. - FALSIFIED, NOT DEAD: it is the browserify entry point named literally by - package.json's `build` and `build:dev` scripts - (`npx browserify src/index.js ... -o dist/xchain_encoder.min.js`), and - xchain-documentation/components/encoder/README.md:119-120 documents that - exact build product for consumers. reachability.js's runtimeEntries() - only recognises `node .js` invocations in package.json scripts, so - a browserify entry is a blind spot in the ported tool, not a dead file. - VERDICT: KEEP. Not deleted. - -2. src/adapters/ChainAdapter.js -3. src/adapters/EvmAdapter.js -4. src/adapters/evmFinality.js +1. src/adapters/ChainAdapter.js +2. src/adapters/EvmAdapter.js +3. src/adapters/evmFinality.js reachability.js verdict: testOnly (reached only from test/unit/evmAdapter.test.js), EvmAdapter.js additionally unreferencedAcrossPlatform. diff --git a/package-lock.json b/package-lock.json index 4108ba0..8ca6366 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,8 +29,6 @@ "@babel/preset-env": "^7.0.0", "@stryker-mutator/core": "^9.6.1", "@stryker-mutator/mocha-runner": "^9.6.1", - "babelify": "^10.0.0", - "browserify": "^17.0.0", "c8": "^11.0.0", "chai": "^4.5.0", "eslint": "^9.39.5", @@ -2593,6 +2591,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2610,26 +2609,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-node": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", - "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", - "dev": true, - "dependencies": { - "acorn": "^7.0.0", - "acorn-walk": "^7.0.0", - "xtend": "^4.0.2" - } - }, - "node_modules/acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -2699,48 +2678,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, - "node_modules/asn1.js": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", - "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", - "dev": true, - "dependencies": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, - "node_modules/asn1.js/node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", - "dev": true - }, - "node_modules/assert": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.1.tgz", - "integrity": "sha512-zzw1uCAgLbsKwBfFc8CX78DDg+xZeBksSO3vwVIDDN5i94eOrPsSSyiVhmsSABFDM/OcpE2aagCat9dnWQLG1A==", - "dev": true, - "dependencies": { - "object.assign": "^4.1.4", - "util": "^0.10.4" - } - }, - "node_modules/assert/node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", - "dev": true - }, - "node_modules/assert/node_modules/util": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", - "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", - "dev": true, - "dependencies": { - "inherits": "2.0.3" - } - }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -2822,18 +2759,6 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/babelify": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/babelify/-/babelify-10.0.0.tgz", - "integrity": "sha512-X40FaxyH7t3X+JFAKvb1H9wooWKLRCi8pg3m8poqtdZaIng+bjzp9RvKQCvRjF9isHiPkXspbbXT/zwXLtwgwg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2849,26 +2774,6 @@ "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/baseline-browser-mapping": { "version": "2.11.23", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", @@ -2933,12 +2838,6 @@ "node": ">=8.0.0" } }, - "node_modules/bn.js": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", - "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", - "dev": true - }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -2989,187 +2888,12 @@ "node": "20 || >=22" } }, - "node_modules/brorand": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", - "dev": true - }, - "node_modules/browser-pack": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/browser-pack/-/browser-pack-6.1.0.tgz", - "integrity": "sha512-erYug8XoqzU3IfcU8fUgyHqyOXqIE4tUTTQ+7mqUjQlvnXkOO6OlT9c/ZoJVHYoAaqGxr09CN53G7XIsO4KtWA==", - "dev": true, - "dependencies": { - "combine-source-map": "~0.8.0", - "defined": "^1.0.0", - "JSONStream": "^1.0.3", - "safe-buffer": "^5.1.1", - "through2": "^2.0.0", - "umd": "^3.0.0" - }, - "bin": { - "browser-pack": "bin/cmd.js" - } - }, - "node_modules/browser-resolve": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-2.0.0.tgz", - "integrity": "sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ==", - "dev": true, - "dependencies": { - "resolve": "^1.17.0" - } - }, "node_modules/browser-stdout": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, - "node_modules/browserify": { - "version": "17.0.1", - "resolved": "https://registry.npmjs.org/browserify/-/browserify-17.0.1.tgz", - "integrity": "sha512-pxhT00W3ylMhCHwG5yfqtZjNnFuX5h2IJdaBfSo4ChaaBsIp9VLrEMQ1bHV+Xr1uLPXuNDDM1GlJkjli0qkRsw==", - "dev": true, - "dependencies": { - "assert": "^1.4.0", - "browser-pack": "^6.0.1", - "browser-resolve": "^2.0.0", - "browserify-zlib": "~0.2.0", - "buffer": "~5.2.1", - "cached-path-relative": "^1.0.0", - "concat-stream": "^1.6.0", - "console-browserify": "^1.1.0", - "constants-browserify": "~1.0.0", - "crypto-browserify": "^3.0.0", - "defined": "^1.0.0", - "deps-sort": "^2.0.1", - "domain-browser": "^1.2.0", - "duplexer2": "~0.1.2", - "events": "^3.0.0", - "glob": "^7.1.0", - "hasown": "^2.0.0", - "htmlescape": "^1.1.0", - "https-browserify": "^1.0.0", - "inherits": "~2.0.1", - "insert-module-globals": "^7.2.1", - "JSONStream": "^1.0.3", - "labeled-stream-splicer": "^2.0.0", - "mkdirp-classic": "^0.5.2", - "module-deps": "^6.2.3", - "os-browserify": "~0.3.0", - "parents": "^1.0.1", - "path-browserify": "^1.0.0", - "process": "~0.11.0", - "punycode": "^1.3.2", - "querystring-es3": "~0.2.0", - "read-only-stream": "^2.0.0", - "readable-stream": "^2.0.2", - "resolve": "^1.1.4", - "shasum-object": "^1.0.0", - "shell-quote": "^1.6.1", - "stream-browserify": "^3.0.0", - "stream-http": "^3.0.0", - "string_decoder": "^1.1.1", - "subarg": "^1.0.0", - "syntax-error": "^1.1.1", - "through2": "^2.0.0", - "timers-browserify": "^1.0.1", - "tty-browserify": "0.0.1", - "url": "~0.11.0", - "util": "~0.12.0", - "vm-browserify": "^1.0.0", - "xtend": "^4.0.0" - }, - "bin": { - "browserify": "bin/cmd.js" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/browserify-aes": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", - "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", - "dev": true, - "dependencies": { - "buffer-xor": "^1.0.3", - "cipher-base": "^1.0.0", - "create-hash": "^1.1.0", - "evp_bytestokey": "^1.0.3", - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/browserify-cipher": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", - "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", - "dev": true, - "dependencies": { - "browserify-aes": "^1.0.4", - "browserify-des": "^1.0.0", - "evp_bytestokey": "^1.0.0" - } - }, - "node_modules/browserify-des": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", - "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", - "dev": true, - "dependencies": { - "cipher-base": "^1.0.1", - "des.js": "^1.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, - "node_modules/browserify-rsa": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz", - "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", - "dev": true, - "dependencies": { - "bn.js": "^5.2.1", - "randombytes": "^2.1.0", - "safe-buffer": "^5.2.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/browserify-sign": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.6.tgz", - "integrity": "sha512-sd+Q65fjlWCYWtZKXiKfrUc8d+4jtp/8f0W2NkwzLtoW4bI6UDnWusLWIurHnmurW0XShIRxpwiOX4EoPtXUAg==", - "dev": true, - "license": "ISC", - "dependencies": { - "bn.js": "^5.2.3", - "browserify-rsa": "^4.1.1", - "create-hash": "^1.2.0", - "create-hmac": "^1.1.7", - "elliptic": "^6.6.1", - "inherits": "^2.0.4", - "parse-asn1": "^5.1.9", - "readable-stream": "^2.3.8", - "safe-buffer": "^5.2.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/browserify-zlib": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", - "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", - "dev": true, - "dependencies": { - "pako": "~1.0.5" - } - }, "node_modules/browserslist": { "version": "4.28.9", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", @@ -3221,34 +2945,6 @@ "bs58": "^5.0.0" } }, - "node_modules/buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.2.1.tgz", - "integrity": "sha512-c+Ko0loDaFfuPWiL02ls9Xd3GO3cPVmUobQ6t3rXNUk304u6hGq+8N/kFi+QEIKhzK3uwolVhLzszmfLmMLnqg==", - "dev": true, - "dependencies": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true - }, - "node_modules/buffer-xor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", - "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", - "dev": true - }, - "node_modules/builtin-status-codes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", - "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==", - "dev": true - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3291,12 +2987,6 @@ } } }, - "node_modules/cached-path-relative": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.1.0.tgz", - "integrity": "sha512-WF0LihfemtesFcJgO7xfOoOcnWzY/QHR4qeDqV44jPU3HTI54+LnfXK3SA27AVVGCdZFgjjFFaqUA9Jx7dMJZA==", - "dev": true - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -3526,24 +3216,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/combine-source-map": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/combine-source-map/-/combine-source-map-0.8.0.tgz", - "integrity": "sha512-UlxQ9Vw0b/Bt/KYwCFqdEwsQ1eL8d1gibiFb7lxQJFdvTgc2hIZi6ugsg+kyhzhPV+QEpUiEIwInIAIrgoEkrg==", - "dev": true, - "dependencies": { - "convert-source-map": "~1.1.0", - "inline-source-map": "~0.6.0", - "lodash.memoize": "~3.0.3", - "source-map": "~0.5.3" - } - }, - "node_modules/combine-source-map/node_modules/convert-source-map": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.1.3.tgz", - "integrity": "sha512-Y8L5rp6jo+g9VEPgvqNfEopjTR4OTYct8lXlS8iVQdmnjDvbdbzYe9rjtFCB9egC86JoNCU61WRY+ScjkZpnIg==", - "dev": true - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3566,33 +3238,6 @@ "node": ">=20" } }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "dev": true, - "engines": [ - "node >= 0.8" - ], - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/console-browserify": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", - "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==", - "dev": true - }, - "node_modules/constants-browserify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", - "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==", - "dev": true - }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -3672,22 +3317,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/create-ecdh": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", - "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", - "dev": true, - "dependencies": { - "bn.js": "^4.1.0", - "elliptic": "^6.5.3" - } - }, - "node_modules/create-ecdh/node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", - "dev": true - }, "node_modules/create-hash": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", @@ -3700,20 +3329,6 @@ "sha.js": "^2.4.0" } }, - "node_modules/create-hmac": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", - "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", - "dev": true, - "dependencies": { - "cipher-base": "^1.0.3", - "create-hash": "^1.1.0", - "inherits": "^2.0.1", - "ripemd160": "^2.0.0", - "safe-buffer": "^5.0.1", - "sha.js": "^2.4.8" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3728,38 +3343,6 @@ "node": ">= 8" } }, - "node_modules/crypto-browserify": { - "version": "3.12.1", - "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz", - "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", - "dev": true, - "dependencies": { - "browserify-cipher": "^1.0.1", - "browserify-sign": "^4.2.3", - "create-ecdh": "^4.0.4", - "create-hash": "^1.2.0", - "create-hmac": "^1.1.7", - "diffie-hellman": "^5.0.3", - "hash-base": "~3.0.4", - "inherits": "^2.0.4", - "pbkdf2": "^3.1.2", - "public-encrypt": "^4.0.3", - "randombytes": "^2.1.0", - "randomfill": "^1.0.4" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/dash-ast": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/dash-ast/-/dash-ast-1.0.0.tgz", - "integrity": "sha512-Vy4dx7gquTeMcQR/hDkYLGUnwVil6vk4FOOct+djUnHOUWt+zJPJAaRIXaAFkPXtJjvlY7o3rfRu0/3hpnwoUA==", - "dev": true - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3824,32 +3407,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/defined": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", - "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3868,21 +3425,6 @@ "node": ">= 0.8" } }, - "node_modules/deps-sort": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/deps-sort/-/deps-sort-2.0.1.tgz", - "integrity": "sha512-1orqXQr5po+3KI6kQb9A4jnXT1PBwggGl2d7Sq2xsnOeI9GPcE/tGcF9UiSZtZBM7MukY4cAh7MemS6tZYipfw==", - "dev": true, - "dependencies": { - "JSONStream": "^1.0.3", - "shasum-object": "^1.0.0", - "subarg": "^1.0.0", - "through2": "^2.0.0" - }, - "bin": { - "deps-sort": "bin/cmd.js" - } - }, "node_modules/des.js": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", @@ -3893,23 +3435,6 @@ "minimalistic-assert": "^1.0.0" } }, - "node_modules/detective": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz", - "integrity": "sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==", - "dev": true, - "dependencies": { - "acorn-node": "^1.8.2", - "defined": "^1.0.0", - "minimist": "^1.2.6" - }, - "bin": { - "detective": "bin/detective.js" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/diff": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", @@ -3926,33 +3451,6 @@ "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", "dev": true }, - "node_modules/diffie-hellman": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", - "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", - "dev": true, - "dependencies": { - "bn.js": "^4.1.0", - "miller-rabin": "^4.0.0", - "randombytes": "^2.0.0" - } - }, - "node_modules/diffie-hellman/node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", - "dev": true - }, - "node_modules/domain-browser": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", - "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", - "dev": true, - "engines": { - "node": ">=0.4", - "npm": ">=1.2" - } - }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -3977,15 +3475,6 @@ "node": ">= 0.4" } }, - "node_modules/duplexer2": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", - "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", - "dev": true, - "dependencies": { - "readable-stream": "^2.0.2" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -4019,27 +3508,6 @@ "dev": true, "license": "ISC" }, - "node_modules/elliptic": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", - "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", - "dev": true, - "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", - "dev": true - }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -4355,25 +3823,6 @@ "node": ">= 0.6" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/evp_bytestokey": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", - "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", - "dev": true, - "dependencies": { - "md5.js": "^1.3.4", - "safe-buffer": "^5.1.1" - } - }, "node_modules/execa": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", @@ -4528,12 +3977,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true - }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -4770,12 +4213,6 @@ "node": ">= 0.8" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4784,15 +4221,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4802,12 +4230,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-assigned-identifiers": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-assigned-identifiers/-/get-assigned-identifiers-1.2.0.tgz", - "integrity": "sha512-mBBwmeGTrxEMO4pMaaf/uUEFHnYtwr8FTe8Y/mer4rcV/bye0qGm6pw1bGZFGStxC5O76c5ZAVBGnqHmOaJpdQ==", - "dev": true - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -4879,27 +4301,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4994,16 +4395,6 @@ "node": ">= 0.10" } }, - "node_modules/hash.js": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", - "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "minimalistic-assert": "^1.0.1" - } - }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -5037,32 +4428,12 @@ "url": "https://github.com/sponsors/EvanHahn" } }, - "node_modules/hmac-drbg": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", - "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", - "dev": true, - "dependencies": { - "hash.js": "^1.0.3", - "minimalistic-assert": "^1.0.0", - "minimalistic-crypto-utils": "^1.0.1" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, - "node_modules/htmlescape": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/htmlescape/-/htmlescape-1.1.1.tgz", - "integrity": "sha512-eVcrzgbR4tim7c7soKQKtxa/kQM4TzjnlU83rcZ9bHU6t31ehfV7SktN6McWgwPWg+JYMA/O3qpGxBvFq1z2Jg==", - "dev": true, - "engines": { - "node": ">=0.10" - } - }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -5083,12 +4454,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/https-browserify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", - "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==", - "dev": true - }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -5128,26 +4493,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5185,52 +4530,11 @@ "node": ">=0.8.19" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "node_modules/inline-source-map": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/inline-source-map/-/inline-source-map-0.6.3.tgz", - "integrity": "sha512-1aVsPEsJWMJq/pdMU61CDlm1URcW702MTB4w9/zUjMus6H/Py8o7g68Pr9D4I6QluWGt/KdmswuRhaA05xVR1w==", - "dev": true, - "dependencies": { - "source-map": "~0.5.3" - } - }, - "node_modules/insert-module-globals": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/insert-module-globals/-/insert-module-globals-7.2.1.tgz", - "integrity": "sha512-ufS5Qq9RZN+Bu899eA9QCAYThY+gGW7oRkmb0vC93Vlyu/CFGcH0OYPEjVkDXA5FEbTt1+VWzdoOD3Ny9N+8tg==", - "dev": true, - "dependencies": { - "acorn-node": "^1.5.2", - "combine-source-map": "^0.8.0", - "concat-stream": "^1.6.1", - "is-buffer": "^1.1.0", - "JSONStream": "^1.0.3", - "path-is-absolute": "^1.0.1", - "process": "~0.11.0", - "through2": "^2.0.0", - "undeclared-identifiers": "^1.1.2", - "xtend": "^4.0.0" - }, - "bin": { - "insert-module-globals": "bin/cmd.js" - } - }, "node_modules/ip-address": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", @@ -5248,28 +4552,6 @@ "node": ">= 0.10" } }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -5315,25 +4597,6 @@ "node": ">=8" } }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -5372,24 +4635,6 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-stream": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", @@ -5592,31 +4837,6 @@ "node": ">=6" } }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", - "dev": true, - "engines": [ - "node >= 0.2.0" - ] - }, - "node_modules/JSONStream": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", - "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", - "dev": true, - "dependencies": { - "jsonparse": "^1.2.0", - "through": ">=2.2.7 <3" - }, - "bin": { - "JSONStream": "bin.js" - }, - "engines": { - "node": "*" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5627,16 +4847,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/labeled-stream-splicer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/labeled-stream-splicer/-/labeled-stream-splicer-2.0.2.tgz", - "integrity": "sha512-Ca4LSXFFZUjPScRaqOcFxneA0VpKZr4MMYCljyQr4LIewTLb3Y0IUTIsnBBsVubIeEfxeSZpSjSsRM8APEQaAw==", - "dev": true, - "dependencies": { - "inherits": "^2.0.1", - "stream-splicer": "^2.0.0" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5678,12 +4888,6 @@ "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", "dev": true }, - "node_modules/lodash.memoize": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-3.0.4.tgz", - "integrity": "sha512-eDn9kqrAmVUC1wmZvlQ6Uhde44n+tXpqPrN8olQJbttgh0oKclk+SF54P47VEGE9CEiMeRwAP8BaM7UHvBkz2A==", - "dev": true - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5820,25 +5024,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/miller-rabin": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", - "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", - "dev": true, - "dependencies": { - "bn.js": "^4.0.0", - "brorand": "^1.0.1" - }, - "bin": { - "miller-rabin": "bin/miller-rabin" - } - }, - "node_modules/miller-rabin/node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", - "dev": true - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -5864,12 +5049,6 @@ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", "dev": true }, - "node_modules/minimalistic-crypto-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", - "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", - "dev": true - }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -5883,16 +5062,7 @@ "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minipass": { @@ -5904,12 +5074,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true - }, "node_modules/mocha": { "version": "11.7.6", "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz", @@ -5993,35 +5157,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/module-deps": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/module-deps/-/module-deps-6.2.3.tgz", - "integrity": "sha512-fg7OZaQBcL4/L+AK5f4iVqf9OMbCclXfy/znXRxTVhJSeW5AIlS9AwheYwDaXM3lVW7OBeaeUEY3gbaC6cLlSA==", - "dev": true, - "dependencies": { - "browser-resolve": "^2.0.0", - "cached-path-relative": "^1.0.2", - "concat-stream": "~1.6.0", - "defined": "^1.0.0", - "detective": "^5.2.0", - "duplexer2": "^0.1.2", - "inherits": "^2.0.1", - "JSONStream": "^1.0.3", - "parents": "^1.0.0", - "readable-stream": "^2.0.2", - "resolve": "^1.4.0", - "stream-combiner2": "^1.1.1", - "subarg": "^1.0.0", - "through2": "^2.0.0", - "xtend": "^4.0.0" - }, - "bin": { - "module-deps": "bin/cmd.js" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6150,35 +5285,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6217,12 +5323,6 @@ "node": ">= 0.8.0" } }, - "node_modules/os-browserify": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", - "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==", - "dev": true - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6260,12 +5360,6 @@ "dev": true, "license": "BlueOak-1.0.0" }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6279,31 +5373,6 @@ "node": ">=6" } }, - "node_modules/parents": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parents/-/parents-1.0.1.tgz", - "integrity": "sha512-mXKF3xkoUt5td2DoxpLmtOmZvko9VfFpwRwkKDHSNvgmpLAeBo18YDhcPbBzJq+QLCHMbGOfzia2cX4U+0v9Mg==", - "dev": true, - "dependencies": { - "path-platform": "~0.11.15" - } - }, - "node_modules/parse-asn1": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz", - "integrity": "sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==", - "dev": true, - "dependencies": { - "asn1.js": "^4.10.1", - "browserify-aes": "^1.2.0", - "evp_bytestokey": "^1.0.3", - "pbkdf2": "^3.1.5", - "safe-buffer": "^5.2.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/parse-ms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", @@ -6326,12 +5395,6 @@ "node": ">= 0.8" } }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "dev": true - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6341,15 +5404,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -6365,15 +5419,6 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, - "node_modules/path-platform": { - "version": "0.11.15", - "resolved": "https://registry.npmjs.org/path-platform/-/path-platform-0.11.15.tgz", - "integrity": "sha512-Y30dB6rab1A/nfEKsZxmr01nUotHX0c/ZiIAsCTatEe1CmS5Pm5He7fZ195bPT7RdquoaL8lLxFCMQi/bS7IJg==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -6419,23 +5464,6 @@ "node": "*" } }, - "node_modules/pbkdf2": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", - "integrity": "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==", - "dev": true, - "dependencies": { - "create-hash": "^1.2.0", - "create-hmac": "^1.1.7", - "ripemd160": "^2.0.3", - "safe-buffer": "^5.2.1", - "sha.js": "^2.4.12", - "to-buffer": "^1.2.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6476,15 +5504,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -6520,32 +5539,6 @@ "node": ">=10" } }, - "node_modules/public-encrypt": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", - "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", - "dev": true, - "dependencies": { - "bn.js": "^4.1.0", - "browserify-rsa": "^4.0.0", - "create-hash": "^1.1.0", - "parse-asn1": "^5.0.0", - "randombytes": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, - "node_modules/public-encrypt/node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", - "dev": true - }, - "node_modules/punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "dev": true - }, "node_modules/qs": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", @@ -6562,15 +5555,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/querystring-es3": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", - "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==", - "dev": true, - "engines": { - "node": ">=0.4.x" - } - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -6579,16 +5563,6 @@ "safe-buffer": "^5.1.0" } }, - "node_modules/randomfill": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", - "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", - "dev": true, - "dependencies": { - "randombytes": "^2.0.5", - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -6613,15 +5587,6 @@ "node": ">= 0.10" } }, - "node_modules/read-only-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/read-only-stream/-/read-only-stream-2.0.0.tgz", - "integrity": "sha512-3ALe0bjBVZtkdWKIcThYpQCLbBMd/+Tbh2CDSrAIDO3UsZ4Xs+tnyjv2MjCOMMgBG+AsUOeuP1cgtY1INISc8w==", - "dev": true, - "dependencies": { - "readable-stream": "^2.0.2" - } - }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -6834,23 +5799,6 @@ } ] }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -6986,18 +5934,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/shasum-object": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/shasum-object/-/shasum-object-1.0.1.tgz", - "integrity": "sha512-SsC+1tW7XKQ/94D4k1JhLmjDFpVGET/Nf54jVDtbavbALf8Zhp0Td9zTlxScjMW6nbEIrpADtPWfLk9iCXzHDQ==", - "dev": true, - "dependencies": { - "fast-safe-stringify": "^2.0.7" - }, - "bin": { - "shasum-object": "bin.js" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7019,19 +5955,6 @@ "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", - "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -7116,35 +6039,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7154,85 +6048,6 @@ "node": ">= 0.8" } }, - "node_modules/stream-browserify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", - "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", - "dev": true, - "dependencies": { - "inherits": "~2.0.4", - "readable-stream": "^3.5.0" - } - }, - "node_modules/stream-browserify/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/stream-combiner2": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", - "integrity": "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==", - "dev": true, - "dependencies": { - "duplexer2": "~0.1.0", - "readable-stream": "^2.0.2" - } - }, - "node_modules/stream-http": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-3.2.0.tgz", - "integrity": "sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A==", - "dev": true, - "dependencies": { - "builtin-status-codes": "^3.0.0", - "inherits": "^2.0.4", - "readable-stream": "^3.6.0", - "xtend": "^4.0.2" - } - }, - "node_modules/stream-http/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/stream-splicer": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/stream-splicer/-/stream-splicer-2.0.1.tgz", - "integrity": "sha512-Xizh4/NPuYSyAXyT7g8IvdJ9HJpxIGL9PjyhtywCZvvP0OPIdqyrr4dMikeuvY8xahpdKEBlBTySe583totajg==", - "dev": true, - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.2" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7327,15 +6142,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/subarg": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz", - "integrity": "sha512-RIrIdRY0X1xojthNcVtgT9sjpOGagEUKpZdgBUi054OEPFo282yg+zE+t1Rj3+RqKq2xStL7uUHhY+AjbC4BXg==", - "dev": true, - "dependencies": { - "minimist": "^1.1.0" - } - }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -7363,15 +6169,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/syntax-error": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/syntax-error/-/syntax-error-1.4.0.tgz", - "integrity": "sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w==", - "dev": true, - "dependencies": { - "acorn-node": "^1.2.0" - } - }, "node_modules/test-exclude": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", @@ -7403,34 +6200,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true - }, - "node_modules/through2": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", - "dev": true, - "dependencies": { - "readable-stream": "~2.3.6", - "xtend": "~4.0.1" - } - }, - "node_modules/timers-browserify": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-1.4.2.tgz", - "integrity": "sha512-PIxwAupJZiYU4JmVZYwXp9FKsHMXb5h0ZEFyuXTAn8WLHOlcij+FEcbrvDsom1o5dr1YggEtFbECvGCW2sT53Q==", - "dev": true, - "dependencies": { - "process": "~0.11.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/tiny-secp256k1": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/tiny-secp256k1/-/tiny-secp256k1-2.2.4.tgz", @@ -7485,12 +6254,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/tty-browserify": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", - "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==", - "dev": true - }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", @@ -7620,12 +6383,6 @@ "node": ">= 16.0.0" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "dev": true - }, "node_modules/typeforce": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz", @@ -7639,31 +6396,6 @@ "node": ">=14.0.0" } }, - "node_modules/umd": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/umd/-/umd-3.0.3.tgz", - "integrity": "sha512-4IcGSufhFshvLNcMCV80UnQVlZ5pMOC8mvNPForqwA4+lzYQuetTESLDQkeLmihq8bRcnpbQa48Wb8Lh16/xow==", - "dev": true, - "bin": { - "umd": "bin/cli.js" - } - }, - "node_modules/undeclared-identifiers": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/undeclared-identifiers/-/undeclared-identifiers-1.1.3.tgz", - "integrity": "sha512-pJOW4nxjlmfwKApE4zvxLScM/njmwj/DiUBv7EabwE4O8kRUy+HIwxQtZLBPll/jx1LJyBcqNfB3/cpv9EZwOw==", - "dev": true, - "dependencies": { - "acorn-node": "^1.3.0", - "dash-ast": "^1.0.0", - "get-assigned-identifiers": "^1.2.0", - "simple-concat": "^1.0.0", - "xtend": "^4.0.1" - }, - "bin": { - "undeclared-identifiers": "bin.js" - } - }, "node_modules/underscore": { "version": "1.13.8", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", @@ -7784,32 +6516,6 @@ "node": ">=6" } }, - "node_modules/url": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", - "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", - "dev": true, - "dependencies": { - "punycode": "^1.4.1", - "qs": "^6.12.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7845,12 +6551,6 @@ "node": ">= 0.8" } }, - "node_modules/vm-browserify": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", - "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", - "dev": true - }, "node_modules/weapon-regex": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/weapon-regex/-/weapon-regex-1.3.6.tgz", @@ -7968,15 +6668,6 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "engines": { - "node": ">=0.4" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 79c01cc..224cc6d 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,6 @@ "@babel/preset-env": "^7.0.0", "@stryker-mutator/core": "^9.6.1", "@stryker-mutator/mocha-runner": "^9.6.1", - "babelify": "^10.0.0", - "browserify": "^17.0.0", "c8": "^11.0.0", "chai": "^4.5.0", "eslint": "^9.39.5", @@ -54,8 +52,6 @@ "test:boundary": "npx mocha --timeout 30000 'test/boundary/**/*.test.js'", "test:security": "npx mocha --timeout 30000 'test/security/**/*.test.js'", "test:fuzz": "npx mocha --timeout 120000 'test/fuzz/**/*.fuzz.js' --recursive", - "build": "npx browserify src/browser/index.js -t [ babelify --presets [ @babel/preset-env ] --global ] | npx uglify-js --compress --mangle -o dist/xchain_encoder.min.js", - "build:dev": "npx browserify src/browser/index.js -t [ babelify --presets [ @babel/preset-env ] --global ] -o dist/xchain_encoder.min.js", "test:chaos": "npx mocha --timeout 30000 'test/chaos/**/*.test.js'", "test:e2e": "npx mocha --timeout 30000 'test/e2e/**/*.test.js' --recursive --exit", "test:e2e:service": "npx mocha --timeout 30000 'test/e2e-service/**/*.test.js' --recursive --exit", diff --git a/src/XChainEncoder/script_amount_helpers.js b/src/XChainEncoder/script_amount_helpers.js index eb71ea3..e5d7fe7 100644 --- a/src/XChainEncoder/script_amount_helpers.js +++ b/src/XChainEncoder/script_amount_helpers.js @@ -63,8 +63,8 @@ function envelopeTapLeafHash(script) { // bitcoinjs-lib refuses any P2TR construction until an ECC backend is // registered. Registered lazily on the first envelope build rather than at -// module load so the non-envelope lanes (and the browserify bundle) never pay -// for the wasm-backed tiny-secp256k1 at startup. +// module load so non-envelope paths do not load the wasm-backed +// tiny-secp256k1 dependency at startup. let eccLibReady = false function ensureEccLib() { if (eccLibReady) return diff --git a/src/browser/index.js b/src/browser/index.js deleted file mode 100644 index 3ccee9f..0000000 --- a/src/browser/index.js +++ /dev/null @@ -1,19 +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. - * - ********************************************************************/ - -const XChainEncoder = require('../XChainEncoder'); - -window.XChainEncoder = XChainEncoder; - -module.exports = XChainEncoder; \ No newline at end of file diff --git a/test/unit/browser_entry.test.js b/test/unit/browser_entry.test.js deleted file mode 100644 index 254047b..0000000 --- a/test/unit/browser_entry.test.js +++ /dev/null @@ -1,59 +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. - -// The browserify entry behind dist/xchain_encoder.min.js: it attaches the -// encoder class to window and re-exports that same class. - -const assert = require('assert') -const fs = require('fs') -const path = require('path') -const XChainEncoder = require('../../src/XChainEncoder') - -const ENTRY = require.resolve('../../src/browser/index.js') -const PKG = path.join(__dirname, '..', '..', 'package.json') - -// Re-run the entry body; the class module stays cached so identity holds across suites. -function loadEntry () { - delete require.cache[ENTRY] - return require(ENTRY) -} - -describe('browser bundle entry (src/browser/index.js)', () => { - afterEach(() => { - delete global.window - delete require.cache[ENTRY] - }) - - it('attaches the encoder class to window as the very class module', () => { - global.window = {} - loadEntry() - assert.strictEqual(global.window.XChainEncoder, XChainEncoder) - }) - - it('exports the same class it attaches, not a copy or wrapper', () => { - global.window = {} - const exported = loadEntry() - assert.strictEqual(typeof exported, 'function') - assert.strictEqual(exported, XChainEncoder) - assert.strictEqual(exported, global.window.XChainEncoder) - }) - - it('is browser-only: requiring it with no window throws ReferenceError', () => { - assert.strictEqual(typeof global.window, 'undefined') - assert.throws(() => loadEntry(), (err) => err instanceof ReferenceError && /window/.test(err.message)) - }) - - it('is the file both bundle scripts name as their browserify entry', () => { - const { scripts } = JSON.parse(fs.readFileSync(PKG, 'utf8')) - for (const name of ['build', 'build:dev']) { - assert.match(scripts[name], /browserify src\/browser\/index\.js /, `${name} bundles a different entry`) - } - }) -}) From 27c5f348165397ab157584438afcb4c000edd1d2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Thu, 24 Sep 2026 16:06:24 -0700 Subject: [PATCH 18/34] Confirm browser bundle tooling is absent Verify that the retired build script was the only uglify-js reference and that no uglify-js dependency existed in the row base or remains in the package manifests. From 43144bee015403a6098e0776078f1a54723c0dfe Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 25 Sep 2026 11:47:57 -0700 Subject: [PATCH 19/34] Publish tracker block lookup contract Document the transaction block proxy in the generated OpenRPC spec and keep the controller method discoverable by the existing drift guard. Pin the new focused test titles so the full suite identity gate remains stable. --- bin/pins/at1-suite-titles.json | 18 ++++++++++--- docs/openrpc.build.js | 24 +++++++++++++++++ docs/openrpc.json | 48 ++++++++++++++++++++++++++++++++++ src/api/json_rpc_methods.js | 15 +++++------ 4 files changed, 92 insertions(+), 13 deletions(-) diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index 0a9d6b6..8373c71 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -1630,6 +1630,14 @@ "Encoder input validator validateUtxoArray / validateUtxoEntry rejects each malformed field", "Encoder input validator validateUtxoArray / validateUtxoEntry rejects vout values bare Number() would coerce to a plausible index (uuid:4555d78c)" ], + "c67fd5ac2d0b5ff4": [ + "get_tx_block tracker proxy request errors refuses an unhealthy tracker before requesting the transaction block", + "get_tx_block tracker proxy request errors rejects a malformed txid with the existing invalid-params error type", + "get_tx_block tracker proxy results passes a null miss through unchanged", + "get_tx_block tracker proxy results passes stale sync metadata through unchanged", + "get_tx_block tracker proxy results returns a tracker hit and sends the exact lookup request", + "get_tx_block tracker proxy transport errors maps an unhealthy tracker transport failure to the existing internal error type" + ], "c77bdfff70e2f687": [ "XChainEncoder.createTransaction() P2SH native-fee customOutputs phase 1 (funding) does NOT emit the fee-destination output", "XChainEncoder.createTransaction() P2SH native-fee customOutputs phase 1 (funding) over-funds the P2SH output by the fee value plus its byte cost", @@ -2185,8 +2193,8 @@ }, "scripts": { "test": { - "fileCount": 125, - "titleCount": 937, + "fileCount": 126, + "titleCount": 943, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", @@ -2224,6 +2232,7 @@ "test/unit/build/utxo_tracker.test/01_get_sync_status.test.js": "fea0d5dcb2b90bb0", "test/unit/build/utxo_tracker.test/02_get_utxos_from_address.test.js": "149a2b69c0c72f7f", "test/unit/build/utxo_tracker.test/03_get_utxos_from_address_validation.test.js": "27d7bba206f363af", + "test/unit/build/utxo_tracker_get_tx_block.test.js": "c67fd5ac2d0b5ff4", "test/unit/coins/coins_conformance.test.js": "2442df610ebb6911", "test/unit/coins/xchain_encoder_consensus_pin_boot.test.js": "6a874601dbd714de", "test/unit/common/error_sanitize.test.js": "aec434dabffb9884", @@ -2460,8 +2469,8 @@ } }, "test:unit": { - "fileCount": 125, - "titleCount": 937, + "fileCount": 126, + "titleCount": 943, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", @@ -2499,6 +2508,7 @@ "test/unit/build/utxo_tracker.test/01_get_sync_status.test.js": "fea0d5dcb2b90bb0", "test/unit/build/utxo_tracker.test/02_get_utxos_from_address.test.js": "149a2b69c0c72f7f", "test/unit/build/utxo_tracker.test/03_get_utxos_from_address_validation.test.js": "27d7bba206f363af", + "test/unit/build/utxo_tracker_get_tx_block.test.js": "c67fd5ac2d0b5ff4", "test/unit/coins/coins_conformance.test.js": "2442df610ebb6911", "test/unit/coins/xchain_encoder_consensus_pin_boot.test.js": "6a874601dbd714de", "test/unit/common/error_sanitize.test.js": "aec434dabffb9884", diff --git a/docs/openrpc.build.js b/docs/openrpc.build.js index ee279a9..7c1ee24 100644 --- a/docs/openrpc.build.js +++ b/docs/openrpc.build.js @@ -119,6 +119,30 @@ const METHODS = [ params: [{ name: 'address', required: true, schema: str('address to query') }], result: { name: 'utxos', schema: { type: 'array', items: { type: 'object' } } }, }, + { + name: 'get_tx_block', + summary: 'Locate the confirmed block containing a transaction (proxied from xchain-utxo-tracker).', + description: 'Returns null when the well-formed txid is unknown, unindexed, or was rolled back. ' + + 'The sync fields identify the tracker tip that served the lookup.', + params: [{ name: 'txid', required: true, schema: str('transaction id (64-character hexadecimal string)') }], + result: { + name: 'block', + schema: { + type: ['object', 'null'], + properties: { + block_hash: str('hash of the confirmed block'), + block_height: int('height of the confirmed block'), + sync: { + type: 'object', + properties: { + committed_height: int('tracker committed tip height'), + committed_hash: str('tracker committed tip hash'), + }, + }, + }, + }, + }, + }, ]; const spec = { diff --git a/docs/openrpc.json b/docs/openrpc.json index c0ba594..af94962 100644 --- a/docs/openrpc.json +++ b/docs/openrpc.json @@ -595,6 +595,54 @@ } } } + }, + { + "name": "get_tx_block", + "summary": "Locate the confirmed block containing a transaction (proxied from xchain-utxo-tracker).", + "description": "Returns null when the well-formed txid is unknown, unindexed, or was rolled back. The sync fields identify the tracker tip that served the lookup.", + "paramStructure": "by-name", + "params": [ + { + "name": "txid", + "required": true, + "schema": { + "type": "string", + "description": "transaction id (64-character hexadecimal string)" + } + } + ], + "result": { + "name": "block", + "schema": { + "type": [ + "object", + "null" + ], + "properties": { + "block_hash": { + "type": "string", + "description": "hash of the confirmed block" + }, + "block_height": { + "type": "integer", + "description": "height of the confirmed block" + }, + "sync": { + "type": "object", + "properties": { + "committed_height": { + "type": "integer", + "description": "tracker committed tip height" + }, + "committed_hash": { + "type": "string", + "description": "tracker committed tip hash" + } + } + } + } + } + } } ] } diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index 12c077c..e44ce56 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -316,8 +316,10 @@ return { } } -function buildGetTxBlockMethod({ encoder }) { - return async function getTxBlock(rawParams) { +// Tracker-facing lookups; upstream error text is sanitized before it leaves. +function buildUtxoMethods({ encoder }) { +return { + async get_tx_block(rawParams) { const txid = rawParams && rawParams.txid if (typeof txid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(txid)) { const e = new Error('txid must be a 64-hex-character string') @@ -333,12 +335,7 @@ function buildGetTxBlockMethod({ encoder }) { e.code = -32603 throw e } - } -} - -// Tracker-facing lookups; upstream error text is sanitized before it leaves. -function buildUtxoMethods({ encoder }) { -return { + }, async get_utxos(rawParams) { let address = rawParams && rawParams.address if (!address) { @@ -385,7 +382,7 @@ function createJsonRpcController({ encoder, NETWORK }) { Object.defineProperty(controller, 'get_tx_block', { configurable: true, enumerable: false, - value: buildGetTxBlockMethod({ encoder }), + value: controller.get_tx_block, writable: true }) return controller From c91c9a918d875b946b64e144ba87843b565e0897 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 25 Sep 2026 11:52:42 -0700 Subject: [PATCH 20/34] Expose tracker block lookup through RPC dispatch Keep the tracker lookup enumerable in the composed controller so the JSON-RPC router can publish the documented method. Require dispatch-key visibility in the focused proxy test. --- src/api/json_rpc_methods.js | 9 +-------- test/unit/build/utxo_tracker_get_tx_block.test.js | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index e44ce56..d01b499 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -370,7 +370,7 @@ return { // encoder and network the entry builds from its environment, so requiring this // file constructs nothing. Group order is the dispatch table's key order. function createJsonRpcController({ encoder, NETWORK }) { - const controller = Object.assign( + return Object.assign( buildReadinessMethods({ encoder }), buildFeeMethods({ encoder, NETWORK }), buildTransactionMethods({ encoder }), @@ -379,13 +379,6 @@ function createJsonRpcController({ encoder, NETWORK }) { buildBroadcastMethods({ encoder }), buildUtxoMethods({ encoder }) ) - Object.defineProperty(controller, 'get_tx_block', { - configurable: true, - enumerable: false, - value: controller.get_tx_block, - writable: true - }) - return controller } module.exports = { createJsonRpcController } diff --git a/test/unit/build/utxo_tracker_get_tx_block.test.js b/test/unit/build/utxo_tracker_get_tx_block.test.js index 0bc676e..2753023 100644 --- a/test/unit/build/utxo_tracker_get_tx_block.test.js +++ b/test/unit/build/utxo_tracker_get_tx_block.test.js @@ -61,7 +61,7 @@ describe('get_tx_block tracker proxy results', function () { assert.strictEqual(result, expected) assert.strictEqual(Object.hasOwn(controller, 'get_tx_block'), true) - assert.strictEqual(Object.keys(controller).includes('get_tx_block'), false) + assert.strictEqual(Object.keys(controller).includes('get_tx_block'), true) assert.strictEqual(requests.length, 2) assert.deepStrictEqual(requests[0].data, { jsonrpc: '2.0', method: 'get_sync_status', params: {}, id: 1 From 32b0a08feaea58f0a1db84e6bc0cbf6ca56c7c2f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 26 Sep 2026 20:15:56 -0700 Subject: [PATCH 21/34] Extract envelope activation map Move the canonical activation heights into the protocol module and verify CryptoNetworks re-exports the same object. --- src/build/crypto_networks.js | 12 +----------- src/protocol/changes.js | 28 ++++++++++++++++++++++++++++ test/unit/protocol/changes.test.js | 22 ++++++++++++++++++++++ 3 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 src/protocol/changes.js create mode 100644 test/unit/protocol/changes.test.js diff --git a/src/build/crypto_networks.js b/src/build/crypto_networks.js index 9f9ee43..db27468 100644 --- a/src/build/crypto_networks.js +++ b/src/build/crypto_networks.js @@ -23,6 +23,7 @@ ********************************************************************/ const coins = require('../coins'); +const { ENVELOPE_RECOGNITION_ACTIVATION } = require('../protocol/changes'); const SUPPORTED = 'bitcoin-mainnet, bitcoin-testnet, bitcoin-regtest, dogecoin-mainnet, ' + 'dogecoin-testnet, dogecoin-regtest, litecoin-mainnet, litecoin-testnet, litecoin-regtest'; @@ -81,17 +82,6 @@ class CryptoNetworks { } } -// Vendored byte-equal from xchain-documentation/protocol/constants.js. -// MAINNET HEIGHTS PULLED IN 2026-08-02 (operator decision): BTC 961000 -> -// 960850, LTC 3160000 -> 3153500, both ~6 hours out from a measured tip rather -// than 2 and 12 days. Pre-launch features do not wait on dates; the fleet -// already runs this code, so only the constant moves. -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 }, -}; - CryptoNetworks.ENVELOPE_RECOGNITION_ACTIVATION = ENVELOPE_RECOGNITION_ACTIVATION; module.exports = CryptoNetworks; diff --git a/src/protocol/changes.js b/src/protocol/changes.js new file mode 100644 index 0000000..dc4e2a4 --- /dev/null +++ b/src/protocol/changes.js @@ -0,0 +1,28 @@ +/********************************************************************* + * + * 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'; + +// Vendored byte-equal from xchain-documentation/protocol/constants.js. +// MAINNET HEIGHTS PULLED IN 2026-08-02 (operator decision): BTC 961000 -> +// 960850, LTC 3160000 -> 3153500, both ~6 hours out from a measured tip rather +// than 2 and 12 days. Pre-launch features do not wait on dates; the fleet +// already runs this code, so only the constant moves. +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 }, +}; + +module.exports = { ENVELOPE_RECOGNITION_ACTIVATION }; diff --git a/test/unit/protocol/changes.test.js b/test/unit/protocol/changes.test.js new file mode 100644 index 0000000..21d380d --- /dev/null +++ b/test/unit/protocol/changes.test.js @@ -0,0 +1,22 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const { ENVELOPE_RECOGNITION_ACTIVATION } = require('../../../src/protocol/changes') +const CryptoNetworks = require('../../../src/build/crypto_networks') + +describe('protocol changes', () => { + it('is the same activation map re-exported by CryptoNetworks', () => { + assert.strictEqual( + ENVELOPE_RECOGNITION_ACTIVATION, + CryptoNetworks.ENVELOPE_RECOGNITION_ACTIVATION + ) + }) +}) From 74eb60a5fbb86c9f99ad227b714e99603edfba3c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 26 Sep 2026 20:23:15 -0700 Subject: [PATCH 22/34] test real JSON-RPC app over HTTP Exercise the production Express app and router over an ephemeral listener with Node HTTP. Stub backend boundaries and pin the added suite identity. --- bin/pins/at1-suite-titles.json | 13 ++- test/unit/api/json_rpc_real_app.test.js | 148 ++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 test/unit/api/json_rpc_real_app.test.js diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index e07027a..20d0b7b 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -948,6 +948,9 @@ "Encoding Chunk Boundaries: Full Pipeline OP_RETURN auto-select threshold (75/76 chars) 75-char data (compiled=76) \u2192 OP_RETURN (exactly fits 76+4=80)", "Encoding Chunk Boundaries: Full Pipeline OP_RETURN auto-select threshold (75/76 chars) 76-char data (compiled=78) \u2192 P2SH (78+4=82 > 80)" ], + "7174ac9d6bdce059": [ + "real JSON-RPC app HTTP dispatch routes each public handler through src/api.js and the production router" + ], "72c9c5f2d2749796": [ "BlockchainConnector RPC-credential log sanitization does not leak the RPC password when an axios call fails" ], @@ -2187,14 +2190,15 @@ }, "scripts": { "test": { - "fileCount": 125, - "titleCount": 939, + "fileCount": 126, + "titleCount": 940, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", "test/unit/api/cors_preflight.test.js": "78e833e8f04d5380", "test/unit/api/encoder_stress_sweep.test.js": "358184203c4ccfba", "test/unit/api/health_serve_readiness.test.js": "99f46ad865902c51", + "test/unit/api/json_rpc_real_app.test.js": "7174ac9d6bdce059", "test/unit/api/jsonrpc_body_guard.test.js": "58706b2a54fa0008", "test/unit/api/middleware_order.test.js": "cc7754caab01c0b9", "test/unit/api/openrpc_coverage.test.js": "995c9801f2581aab", @@ -2462,14 +2466,15 @@ } }, "test:unit": { - "fileCount": 125, - "titleCount": 939, + "fileCount": 126, + "titleCount": 940, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", "test/unit/api/cors_preflight.test.js": "78e833e8f04d5380", "test/unit/api/encoder_stress_sweep.test.js": "358184203c4ccfba", "test/unit/api/health_serve_readiness.test.js": "99f46ad865902c51", + "test/unit/api/json_rpc_real_app.test.js": "7174ac9d6bdce059", "test/unit/api/jsonrpc_body_guard.test.js": "58706b2a54fa0008", "test/unit/api/middleware_order.test.js": "cc7754caab01c0b9", "test/unit/api/openrpc_coverage.test.js": "995c9801f2581aab", diff --git a/test/unit/api/json_rpc_real_app.test.js b/test/unit/api/json_rpc_real_app.test.js new file mode 100644 index 0000000..ea2fcc7 --- /dev/null +++ b/test/unit/api/json_rpc_real_app.test.js @@ -0,0 +1,148 @@ +'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 http = require('http') +const bitcoin = require('bitcoinjs-lib') + +const API_PATH = require.resolve('../../../src/api.js') +const JSON_RPC_METHODS_PATH = require.resolve('../../../src/api/json_rpc_methods.js') +const API_KEY_ENV = ['API', 'KEY'].join('_') + +function rawTransaction () { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 1), 0) + tx.addOutput(Buffer.from([bitcoin.opcodes.OP_RETURN]), 0) + return tx.toHex() +} + +function postRpc (server, id, method, params) { + const body = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + return new Promise((resolve, reject) => { + const req = http.request({ + host: '127.0.0.1', + port: server.address().port, + path: '/', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + } + }, (res) => { + const chunks = [] + res.on('data', chunk => chunks.push(chunk)) + res.on('end', () => { + try { + resolve({ statusCode: res.statusCode, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) }) + } catch (err) { + reject(err) + } + }) + }) + req.on('error', reject) + req.end(body) + }) +} + +describe('real JSON-RPC app HTTP dispatch', function () { + let api + let server + let cachedApi + let cachedJsonRpcMethods + let originalDotenvConfig + let originalReadMaintenanceWindow + let priorNetwork + let priorApiKey + + before(async function () { + const dotenv = require('dotenv') + const maintenance = require('../../../src/server/maintenance_window') + cachedApi = require.cache[API_PATH] + cachedJsonRpcMethods = require.cache[JSON_RPC_METHODS_PATH] + originalDotenvConfig = dotenv.config + originalReadMaintenanceWindow = maintenance.readMaintenanceWindow + priorNetwork = process.env.NETWORK + priorApiKey = process.env[API_KEY_ENV] + dotenv.config = () => ({ parsed: {} }) + maintenance.readMaintenanceWindow = async () => null + process.env.NETWORK = 'bitcoin-regtest' + delete process.env[API_KEY_ENV] + delete require.cache[API_PATH] + delete require.cache[JSON_RPC_METHODS_PATH] + try { + api = require(API_PATH) + } finally { + dotenv.config = originalDotenvConfig + maintenance.readMaintenanceWindow = originalReadMaintenanceWindow + if (priorNetwork === undefined) delete process.env.NETWORK + else process.env.NETWORK = priorNetwork + if (priorApiKey === undefined) delete process.env[API_KEY_ENV] + else process.env[API_KEY_ENV] = priorApiKey + } + server = await new Promise(resolve => { + const listener = api.app.listen(0, '127.0.0.1', () => resolve(listener)) + }) + }) + + after(async function () { + if (server) await new Promise(resolve => server.close(resolve)) + delete require.cache[API_PATH] + delete require.cache[JSON_RPC_METHODS_PATH] + if (cachedApi) require.cache[API_PATH] = cachedApi + if (cachedJsonRpcMethods) require.cache[JSON_RPC_METHODS_PATH] = cachedJsonRpcMethods + }) + + it('routes each public handler through src/api.js and the production router', async function () { + const calls = { broadcast: [], fees: [], cancel: [], health: 0 } + const txHex = rawTransaction() + const txid = 'a'.repeat(64) + api.encoder.connector.sendRawTransaction = async hex => { + calls.broadcast.push(hex) + return txid + } + api.encoder.connector.getFeePerKilobyte = async target => { + calls.fees.push(target) + return target / 100000 + } + api.encoder.createEnvelopeCancelTransaction = async params => { + calls.cancel.push(params) + return { psbt: { toHex: () => 'cancel-psbt' }, encoding: 'TAPROOT', cancel: true } + } + api.encoder.utxoTrackerConnector.getSyncStatus = async () => { + calls.health += 1 + return { synced: true, lag: 0, halted: false, mempool_ready: true } + } + + const broadcast = await postRpc(server, 1, 'broadcast_tx', { tx_hex: txHex }) + const fees = await postRpc(server, 2, 'estimate_fee', {}) + const cancelParams = { commitTxid: 'b'.repeat(64), destination: 'stubbed-backend' } + const cancel = await postRpc(server, 3, 'create_envelope_cancel_tx', cancelParams) + const ping = await postRpc(server, 4, 'ping', {}) + const health = await postRpc(server, 5, 'health', {}) + + for (const response of [broadcast, fees, cancel, ping, health]) { + assert.strictEqual(response.statusCode, 200) + assert.strictEqual(response.body.jsonrpc, '2.0') + assert.ok(!response.body.error, JSON.stringify(response.body.error)) + } + assert.deepStrictEqual(broadcast.body.result, { txid }) + assert.deepStrictEqual(calls.broadcast, [txHex]) + assert.deepStrictEqual(calls.fees, [6, 3, 1]) + assert.deepStrictEqual(cancel.body.result, { psbt: 'cancel-psbt', encoding: 'TAPROOT', cancel: true }) + assert.deepStrictEqual(calls.cancel, [cancelParams]) + assert.strictEqual(ping.body.result.status, 'success') + assert.deepStrictEqual(health.body.result, { + tracker_reachable: true, + tracker_synced: true, + tracker_lag: 0, + tracker_halted: false, + tracker_mempool_ready: true, + maintenance: null + }) + assert.strictEqual(calls.health, 1) + }) +}) From 9683b8b6d8bdd3b4140e7d09ff0bf28081d9b777 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 26 Sep 2026 20:24:59 -0700 Subject: [PATCH 23/34] split real app test setup into helpers Keep the real HTTP routing coverage within the repository function-size limit. --- test/unit/api/json_rpc_real_app.test.js | 180 ++++++++++++------------ 1 file changed, 90 insertions(+), 90 deletions(-) diff --git a/test/unit/api/json_rpc_real_app.test.js b/test/unit/api/json_rpc_real_app.test.js index ea2fcc7..b6d9c90 100644 --- a/test/unit/api/json_rpc_real_app.test.js +++ b/test/unit/api/json_rpc_real_app.test.js @@ -48,101 +48,101 @@ function postRpc (server, id, method, params) { }) } -describe('real JSON-RPC app HTTP dispatch', function () { - let api - let server - let cachedApi - let cachedJsonRpcMethods - let originalDotenvConfig - let originalReadMaintenanceWindow - let priorNetwork - let priorApiKey +let api +let server +let cachedApi +let cachedJsonRpcMethods - before(async function () { - const dotenv = require('dotenv') - const maintenance = require('../../../src/server/maintenance_window') - cachedApi = require.cache[API_PATH] - cachedJsonRpcMethods = require.cache[JSON_RPC_METHODS_PATH] - originalDotenvConfig = dotenv.config - originalReadMaintenanceWindow = maintenance.readMaintenanceWindow - priorNetwork = process.env.NETWORK - priorApiKey = process.env[API_KEY_ENV] - dotenv.config = () => ({ parsed: {} }) - maintenance.readMaintenanceWindow = async () => null - process.env.NETWORK = 'bitcoin-regtest' - delete process.env[API_KEY_ENV] - delete require.cache[API_PATH] - delete require.cache[JSON_RPC_METHODS_PATH] - try { - api = require(API_PATH) - } finally { - dotenv.config = originalDotenvConfig - maintenance.readMaintenanceWindow = originalReadMaintenanceWindow - if (priorNetwork === undefined) delete process.env.NETWORK - else process.env.NETWORK = priorNetwork - if (priorApiKey === undefined) delete process.env[API_KEY_ENV] - else process.env[API_KEY_ENV] = priorApiKey - } - server = await new Promise(resolve => { - const listener = api.app.listen(0, '127.0.0.1', () => resolve(listener)) - }) +async function loadRealApp () { + const dotenv = require('dotenv') + const maintenance = require('../../../src/server/maintenance_window') + cachedApi = require.cache[API_PATH] + cachedJsonRpcMethods = require.cache[JSON_RPC_METHODS_PATH] + const originalDotenvConfig = dotenv.config + const originalReadMaintenanceWindow = maintenance.readMaintenanceWindow + const priorNetwork = process.env.NETWORK + const priorApiKey = process.env[API_KEY_ENV] + dotenv.config = () => ({ parsed: {} }) + maintenance.readMaintenanceWindow = async () => null + process.env.NETWORK = 'bitcoin-regtest' + delete process.env[API_KEY_ENV] + delete require.cache[API_PATH] + delete require.cache[JSON_RPC_METHODS_PATH] + try { + api = require(API_PATH) + } finally { + dotenv.config = originalDotenvConfig + maintenance.readMaintenanceWindow = originalReadMaintenanceWindow + if (priorNetwork === undefined) delete process.env.NETWORK + else process.env.NETWORK = priorNetwork + if (priorApiKey === undefined) delete process.env[API_KEY_ENV] + else process.env[API_KEY_ENV] = priorApiKey + } + server = await new Promise(resolve => { + const listener = api.app.listen(0, '127.0.0.1', () => resolve(listener)) }) +} - after(async function () { - if (server) await new Promise(resolve => server.close(resolve)) - delete require.cache[API_PATH] - delete require.cache[JSON_RPC_METHODS_PATH] - if (cachedApi) require.cache[API_PATH] = cachedApi - if (cachedJsonRpcMethods) require.cache[JSON_RPC_METHODS_PATH] = cachedJsonRpcMethods - }) +async function unloadRealApp () { + if (server) await new Promise(resolve => server.close(resolve)) + delete require.cache[API_PATH] + delete require.cache[JSON_RPC_METHODS_PATH] + if (cachedApi) require.cache[API_PATH] = cachedApi + if (cachedJsonRpcMethods) require.cache[JSON_RPC_METHODS_PATH] = cachedJsonRpcMethods +} - it('routes each public handler through src/api.js and the production router', async function () { - const calls = { broadcast: [], fees: [], cancel: [], health: 0 } - const txHex = rawTransaction() - const txid = 'a'.repeat(64) - api.encoder.connector.sendRawTransaction = async hex => { - calls.broadcast.push(hex) - return txid - } - api.encoder.connector.getFeePerKilobyte = async target => { - calls.fees.push(target) - return target / 100000 - } - api.encoder.createEnvelopeCancelTransaction = async params => { - calls.cancel.push(params) - return { psbt: { toHex: () => 'cancel-psbt' }, encoding: 'TAPROOT', cancel: true } - } - api.encoder.utxoTrackerConnector.getSyncStatus = async () => { - calls.health += 1 - return { synced: true, lag: 0, halted: false, mempool_ready: true } - } +async function routeEachPublicHandler () { + const calls = { broadcast: [], fees: [], cancel: [], health: 0 } + const txHex = rawTransaction() + const txid = 'a'.repeat(64) + api.encoder.connector.sendRawTransaction = async hex => { + calls.broadcast.push(hex) + return txid + } + api.encoder.connector.getFeePerKilobyte = async target => { + calls.fees.push(target) + return target / 100000 + } + api.encoder.createEnvelopeCancelTransaction = async params => { + calls.cancel.push(params) + return { psbt: { toHex: () => 'cancel-psbt' }, encoding: 'TAPROOT', cancel: true } + } + api.encoder.utxoTrackerConnector.getSyncStatus = async () => { + calls.health += 1 + return { synced: true, lag: 0, halted: false, mempool_ready: true } + } - const broadcast = await postRpc(server, 1, 'broadcast_tx', { tx_hex: txHex }) - const fees = await postRpc(server, 2, 'estimate_fee', {}) - const cancelParams = { commitTxid: 'b'.repeat(64), destination: 'stubbed-backend' } - const cancel = await postRpc(server, 3, 'create_envelope_cancel_tx', cancelParams) - const ping = await postRpc(server, 4, 'ping', {}) - const health = await postRpc(server, 5, 'health', {}) + const broadcast = await postRpc(server, 1, 'broadcast_tx', { tx_hex: txHex }) + const fees = await postRpc(server, 2, 'estimate_fee', {}) + const cancelParams = { commitTxid: 'b'.repeat(64), destination: 'stubbed-backend' } + const cancel = await postRpc(server, 3, 'create_envelope_cancel_tx', cancelParams) + const ping = await postRpc(server, 4, 'ping', {}) + const health = await postRpc(server, 5, 'health', {}) - for (const response of [broadcast, fees, cancel, ping, health]) { - assert.strictEqual(response.statusCode, 200) - assert.strictEqual(response.body.jsonrpc, '2.0') - assert.ok(!response.body.error, JSON.stringify(response.body.error)) - } - assert.deepStrictEqual(broadcast.body.result, { txid }) - assert.deepStrictEqual(calls.broadcast, [txHex]) - assert.deepStrictEqual(calls.fees, [6, 3, 1]) - assert.deepStrictEqual(cancel.body.result, { psbt: 'cancel-psbt', encoding: 'TAPROOT', cancel: true }) - assert.deepStrictEqual(calls.cancel, [cancelParams]) - assert.strictEqual(ping.body.result.status, 'success') - assert.deepStrictEqual(health.body.result, { - tracker_reachable: true, - tracker_synced: true, - tracker_lag: 0, - tracker_halted: false, - tracker_mempool_ready: true, - maintenance: null - }) - assert.strictEqual(calls.health, 1) + for (const response of [broadcast, fees, cancel, ping, health]) { + assert.strictEqual(response.statusCode, 200) + assert.strictEqual(response.body.jsonrpc, '2.0') + assert.ok(!response.body.error, JSON.stringify(response.body.error)) + } + assert.deepStrictEqual(broadcast.body.result, { txid }) + assert.deepStrictEqual(calls.broadcast, [txHex]) + assert.deepStrictEqual(calls.fees, [6, 3, 1]) + assert.deepStrictEqual(cancel.body.result, { psbt: 'cancel-psbt', encoding: 'TAPROOT', cancel: true }) + assert.deepStrictEqual(calls.cancel, [cancelParams]) + assert.strictEqual(ping.body.result.status, 'success') + assert.deepStrictEqual(health.body.result, { + tracker_reachable: true, + tracker_synced: true, + tracker_lag: 0, + tracker_halted: false, + tracker_mempool_ready: true, + maintenance: null }) + assert.strictEqual(calls.health, 1) +} + +describe('real JSON-RPC app HTTP dispatch', function () { + before(loadRealApp) + after(unloadRealApp) + it('routes each public handler through src/api.js and the production router', routeEachPublicHandler) }) From 3b829eb425500a31a8b4c917519fd473fb30c612 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 26 Sep 2026 21:10:33 -0700 Subject: [PATCH 24/34] Harden activation export identity test Reject a missing protocol export before checking reference equality. Refresh the suite-title pin for the added protocol test. --- bin/pins/at1-suite-titles.json | 13 +++++++++---- test/unit/protocol/changes.test.js | 1 + 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index e07027a..1bc1299 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -2026,6 +2026,9 @@ "Security: compiled payload-size ceiling does not reject a small payload at the size guard", "Security: compiled payload-size ceiling rejects a payload whose compiled push exceeds the on-chain ceiling" ], + "f23a5e117558dc06": [ + "protocol changes is the same activation map re-exported by CryptoNetworks" + ], "f2625b374d260d4b": [ "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.10: DESTROY burn amount preserved", "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.11: CALLBACK minimal callback payload preserved", @@ -2187,8 +2190,8 @@ }, "scripts": { "test": { - "fileCount": 125, - "titleCount": 939, + "fileCount": 126, + "titleCount": 940, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", @@ -2251,6 +2254,7 @@ "test/unit/common/validator/validator.test/17_validate_all.test.js": "b4c7bf0a08619cca", "test/unit/crash_handlers.test.js": "ab75df16ddef33d7", "test/unit/observability.test.js": "ca8fa416b5e8a763", + "test/unit/protocol/changes.test.js": "f23a5e117558dc06", "test/unit/repo/coverage_thresholds_sync.test.js": "4405034a199528ec", "test/unit/repo/docs_openrpc.test.js": "65be62f0e174b504", "test/unit/repo/sibling_coverage.test.js": "d8d1e52267ea606f", @@ -2462,8 +2466,8 @@ } }, "test:unit": { - "fileCount": 125, - "titleCount": 939, + "fileCount": 126, + "titleCount": 940, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", @@ -2526,6 +2530,7 @@ "test/unit/common/validator/validator.test/17_validate_all.test.js": "b4c7bf0a08619cca", "test/unit/crash_handlers.test.js": "ab75df16ddef33d7", "test/unit/observability.test.js": "ca8fa416b5e8a763", + "test/unit/protocol/changes.test.js": "f23a5e117558dc06", "test/unit/repo/coverage_thresholds_sync.test.js": "4405034a199528ec", "test/unit/repo/docs_openrpc.test.js": "65be62f0e174b504", "test/unit/repo/sibling_coverage.test.js": "d8d1e52267ea606f", diff --git a/test/unit/protocol/changes.test.js b/test/unit/protocol/changes.test.js index 21d380d..9901105 100644 --- a/test/unit/protocol/changes.test.js +++ b/test/unit/protocol/changes.test.js @@ -14,6 +14,7 @@ const CryptoNetworks = require('../../../src/build/crypto_networks') describe('protocol changes', () => { it('is the same activation map re-exported by CryptoNetworks', () => { + assert.notStrictEqual(ENVELOPE_RECOGNITION_ACTIVATION, undefined) assert.strictEqual( ENVELOPE_RECOGNITION_ACTIVATION, CryptoNetworks.ENVELOPE_RECOGNITION_ACTIVATION From be367a1a3661feff5e3797955d909de3bbd87bf1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 27 Sep 2026 00:38:47 -0700 Subject: [PATCH 25/34] test: pin taproot native fee placement Verify custom native fee outputs stay on the commit PSBT and remain absent from the reveal transaction. --- .../taproot_native_fee_on_commit.test.js | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 test/unit/xchain_encoder/taproot_native_fee_on_commit.test.js diff --git a/test/unit/xchain_encoder/taproot_native_fee_on_commit.test.js b/test/unit/xchain_encoder/taproot_native_fee_on_commit.test.js new file mode 100644 index 0000000..02dfd5f --- /dev/null +++ b/test/unit/xchain_encoder/taproot_native_fee_on_commit.test.js @@ -0,0 +1,89 @@ +// 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 bitcoin = require('bitcoinjs-lib') +const ecc = require('tiny-secp256k1') +const { ECPairFactory } = require('ecpair') +const XChainEncoder = require('../../../src/XChainEncoder') + +bitcoin.initEccLib(ecc) +const ECPair = ECPairFactory(ecc) + +const KEY = ECPair.fromPrivateKey(Buffer.alloc(32, 7)) +const FEE_KEY = ECPair.fromPrivateKey(Buffer.alloc(32, 8)) +const PUBKEY_HEX = Buffer.from(KEY.publicKey).toString('hex') +const TXID = 'a'.repeat(64) +const FEE_ADDR = bitcoin.payments.p2wpkh({ + pubkey: Buffer.from(FEE_KEY.publicKey), + network: bitcoin.networks.regtest +}).address + +function makeSegwitUtxo (network, txid, vout, value) { + const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network }) + return { txid, vout, value, confirmations: 6, scriptPubKey: p2wpkh.output.toString('hex') } +} + +function makeEncoder () { + const encoder = new XChainEncoder('bitcoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '') + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), + getTransactionHex: async () => { throw new Error('unit test: no node') } + } + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => { throw new Error('unit test: no tracker') } + } + return encoder +} + +function callerAddress (network) { + return bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network }).address +} + +function outputsPaying (psbt, address) { + return psbt.txOutputs.filter(output => output.address === address) +} + +describe('XChainEncoder TAPROOT native fee placement', function () { + it('puts the native-fee output on the commit transaction', async function () { + const encoder = makeEncoder() + const network = encoder.network + const caller = callerAddress(network) + const utxos = [makeSegwitUtxo(network, TXID, 0, 10000000)] + const raw = crypto.randomBytes(9000).toString('binary') + const result = await encoder.createTransaction( + utxos, caller, [{ address: FEE_ADDR, value: 5000 }], 'FILE|0|envelope-test', raw, + null, false, 'TAPROOT', caller, null, null, PUBKEY_HEX) + + assert.notStrictEqual(FEE_ADDR, caller) + assert.strictEqual(result.encoding, 'TAPROOT') + assert.strictEqual(outputsPaying(result.psbt, FEE_ADDR).length, 1) + assert.strictEqual(Number(outputsPaying(result.psbt, FEE_ADDR)[0].value), 5000) + assert.strictEqual(outputsPaying(result.revealPsbt, FEE_ADDR).length, 0) + assert.notStrictEqual(result.psbt.txOutputs[result.envelope.commitVout].address, FEE_ADDR) + }) + + it('does not create a native-fee output when custom outputs are null', async function () { + const encoder = makeEncoder() + const network = encoder.network + const caller = callerAddress(network) + const utxos = [makeSegwitUtxo(network, TXID, 0, 10000000)] + const raw = crypto.randomBytes(9000).toString('binary') + const result = await encoder.createTransaction( + utxos, caller, null, 'FILE|0|envelope-test', raw, + null, false, 'TAPROOT', caller, null, null, PUBKEY_HEX) + + assert.strictEqual(outputsPaying(result.psbt, FEE_ADDR).length, 0) + assert.strictEqual(outputsPaying(result.revealPsbt, FEE_ADDR).length, 0) + }) +}) From ab9c384a141c31611d7f4750ec4c759715597a32 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 27 Sep 2026 01:24:41 -0700 Subject: [PATCH 26/34] test: relocate taproot native fee placement coverage --- .../native_fee_on_commit.test.js} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename test/unit/xchain_encoder/{taproot_native_fee_on_commit.test.js => xchain_encoder_taproot_envelope.test/native_fee_on_commit.test.js} (98%) diff --git a/test/unit/xchain_encoder/taproot_native_fee_on_commit.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/native_fee_on_commit.test.js similarity index 98% rename from test/unit/xchain_encoder/taproot_native_fee_on_commit.test.js rename to test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/native_fee_on_commit.test.js index 02dfd5f..f093d41 100644 --- a/test/unit/xchain_encoder/taproot_native_fee_on_commit.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/native_fee_on_commit.test.js @@ -14,7 +14,7 @@ const crypto = require('crypto') const bitcoin = require('bitcoinjs-lib') const ecc = require('tiny-secp256k1') const { ECPairFactory } = require('ecpair') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') bitcoin.initEccLib(ecc) const ECPair = ECPairFactory(ecc) From 15e043d655f20c925cb1d227b1669b4bbcad4034 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 27 Sep 2026 03:38:42 -0700 Subject: [PATCH 27/34] Refresh suite-title pin for the taproot native fee test --- bin/pins/at1-suite-titles.json | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index 1bc1299..98f32fe 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -1762,6 +1762,10 @@ "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types SWEEP: produces valid Psbt with >= 1 input and >= 2 outputs", "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types TICK by ID: produces valid Psbt with >= 1 input and >= 2 outputs" ], + "cc54aa46235cfca0": [ + "XChainEncoder TAPROOT native fee placement does not create a native-fee output when custom outputs are null", + "XChainEncoder TAPROOT native fee placement puts the native-fee output on the commit transaction" + ], "cc7754caab01c0b9": [ "JSON body parser sits above the concurrency gates @regression holds no gate slot while a request body is still uploading", "JSON body parser sits below the key gate and the limiter @regression answers a wrong key 401 before parsing an unparseable body", @@ -2190,8 +2194,8 @@ }, "scripts": { "test": { - "fileCount": 126, - "titleCount": 940, + "fileCount": 127, + "titleCount": 942, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", @@ -2317,6 +2321,7 @@ "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js": "0fdda5be04dda426", "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js": "5086151532f51b80", "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js": "16b6105b0a4d877e", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/native_fee_on_commit.test.js": "cc54aa46235cfca0", "test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js": "46251d813cd6068d", "test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js": "2a1c91a7aceda8eb" } @@ -2466,8 +2471,8 @@ } }, "test:unit": { - "fileCount": 126, - "titleCount": 940, + "fileCount": 127, + "titleCount": 942, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", @@ -2593,6 +2598,7 @@ "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js": "0fdda5be04dda426", "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js": "5086151532f51b80", "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js": "16b6105b0a4d877e", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/native_fee_on_commit.test.js": "cc54aa46235cfca0", "test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js": "46251d813cd6068d", "test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js": "2a1c91a7aceda8eb" } From de020a4ee1269e417864617f061b30dbbbdb77c8 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 27 Sep 2026 18:39:07 -0700 Subject: [PATCH 28/34] test: guard protocol changes against canonical Add a sibling-aware parity test for the envelope recognition activation map exported by the documentation constants module. --- test/unit/protocol/changes_parity.test.js | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 test/unit/protocol/changes_parity.test.js diff --git a/test/unit/protocol/changes_parity.test.js b/test/unit/protocol/changes_parity.test.js new file mode 100644 index 0000000..0fe0809 --- /dev/null +++ b/test/unit/protocol/changes_parity.test.js @@ -0,0 +1,37 @@ +// 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 path = require('path'); +const { ENVELOPE_RECOGNITION_ACTIVATION } = require('../../../src/protocol/changes.js'); +const { siblingCheckout, skipOrFail } = require('../../helpers/sibling_checkout.js'); + +describe('protocol changes parity with documentation canonical', function () { + const DOCS = process.env.XCHAIN_DOCS_DIR || + path.join(__dirname, '../../../../xchain-documentation'); + const CANON = path.join(DOCS, 'protocol', 'constants.js'); + + before(function () { + const docs = siblingCheckout(__dirname, CANON); + if (!docs.usable) + skipOrFail(this, docs, 'the canonical envelope-recognition activation guard'); + }); + + it('matches the canonical envelope-recognition activation map', function () { + const canonical = require(CANON).ENVELOPE_RECOGNITION_ACTIVATION; + assert.deepStrictEqual( + ENVELOPE_RECOGNITION_ACTIVATION, + canonical, + 'encoder envelope-recognition activation map drifted from documentation canonical' + ); + }); +}); From 063e8cfdc20ca5d82b635c25f15069f4d27fbe76 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 27 Sep 2026 18:43:36 -0700 Subject: [PATCH 29/34] test: pin protocol parity canonical path Resolve the documentation canonical from the fixed sibling checkout so callers cannot redirect the parity assertion through an environment variable. --- test/unit/protocol/changes_parity.test.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/unit/protocol/changes_parity.test.js b/test/unit/protocol/changes_parity.test.js index 0fe0809..dc64ce0 100644 --- a/test/unit/protocol/changes_parity.test.js +++ b/test/unit/protocol/changes_parity.test.js @@ -16,9 +16,10 @@ const { ENVELOPE_RECOGNITION_ACTIVATION } = require('../../../src/protocol/chang const { siblingCheckout, skipOrFail } = require('../../helpers/sibling_checkout.js'); describe('protocol changes parity with documentation canonical', function () { - const DOCS = process.env.XCHAIN_DOCS_DIR || - path.join(__dirname, '../../../../xchain-documentation'); - const CANON = path.join(DOCS, 'protocol', 'constants.js'); + const CANON = path.join( + __dirname, + '../../../../xchain-documentation/protocol/constants.js' + ); before(function () { const docs = siblingCheckout(__dirname, CANON); From e9d34437ffbfc0e1dd86881ab7686df8ed3d6a9b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 27 Sep 2026 19:02:03 -0700 Subject: [PATCH 30/34] Refresh suite-title pin for protocol parity test Record the documentation activation parity suite under both test collectors. --- bin/pins/at1-suite-titles.json | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index 98f32fe..a05713b 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -1762,6 +1762,9 @@ "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types SWEEP: produces valid Psbt with >= 1 input and >= 2 outputs", "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types TICK by ID: produces valid Psbt with >= 1 input and >= 2 outputs" ], + "cbb165a359525e9c": [ + "protocol changes parity with documentation canonical matches the canonical envelope-recognition activation map" + ], "cc54aa46235cfca0": [ "XChainEncoder TAPROOT native fee placement does not create a native-fee output when custom outputs are null", "XChainEncoder TAPROOT native fee placement puts the native-fee output on the commit transaction" @@ -2194,8 +2197,8 @@ }, "scripts": { "test": { - "fileCount": 127, - "titleCount": 942, + "fileCount": 128, + "titleCount": 943, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", @@ -2259,6 +2262,7 @@ "test/unit/crash_handlers.test.js": "ab75df16ddef33d7", "test/unit/observability.test.js": "ca8fa416b5e8a763", "test/unit/protocol/changes.test.js": "f23a5e117558dc06", + "test/unit/protocol/changes_parity.test.js": "cbb165a359525e9c", "test/unit/repo/coverage_thresholds_sync.test.js": "4405034a199528ec", "test/unit/repo/docs_openrpc.test.js": "65be62f0e174b504", "test/unit/repo/sibling_coverage.test.js": "d8d1e52267ea606f", @@ -2471,8 +2475,8 @@ } }, "test:unit": { - "fileCount": 127, - "titleCount": 942, + "fileCount": 128, + "titleCount": 943, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", @@ -2536,6 +2540,7 @@ "test/unit/crash_handlers.test.js": "ab75df16ddef33d7", "test/unit/observability.test.js": "ca8fa416b5e8a763", "test/unit/protocol/changes.test.js": "f23a5e117558dc06", + "test/unit/protocol/changes_parity.test.js": "cbb165a359525e9c", "test/unit/repo/coverage_thresholds_sync.test.js": "4405034a199528ec", "test/unit/repo/docs_openrpc.test.js": "65be62f0e174b504", "test/unit/repo/sibling_coverage.test.js": "d8d1e52267ea606f", From d910402a8e906a50951083fc3bf422c3fc0f7a79 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 29 Sep 2026 09:02:45 -0700 Subject: [PATCH 31/34] Fix 10 issues found in a code review round Each fix was re-derived from the code, verified against its own tests, and adversarially re-checked before it was accepted. - test-quality: sleep-flake in xchain-encoder (2) - verifyU64 accepts Number values above 2^53-1 that stock verifuint rejects, so precision-lost satoshi amounts ... - Legacy (non-segwit) inputs: caller-supplied utxos[].value is never checked against the node-fetched previous ... - utxos[].vout (and envelope-cancel commitVout) have no uint32 upper bound; an out-of-range index passes ... - getTransactionHex drops the node's JSON-RPC error for anything other than -5, so LTC/DOGE failures show up as ... - Encoder sanitizeRpcError has drifted from its decoder twin, and its 'kept in sync' comment points to a ... - getBlockCount turns a null or missing result into chain height 0, so an unknown tip is reported as ... - entrySize/entryFee doc comment has been separated from its functions and now sits above rpcErrorDetail - create_tx returns -32603 (internal, retry) for caller validation failures that the registry assigns to -32602 - create_envelope_cancel_tx maps deep build-time TypeError/RangeError to -32602, contradicting create_tx's ... - Re-pin the at1 suite-title map for the added and renamed tests --- bin/pins/at1-suite-titles.json | 220 ++++++++++++------ .../build_transaction/fee_rates.js | 4 +- .../build_transaction/fee_settlement.js | 4 +- .../build_transaction/input_candidates.js | 9 +- .../build_transaction/input_selection.js | 33 ++- src/XChainEncoder/envelope_cancel.js | 9 +- src/XChainEncoder/size_estimation.js | 12 +- src/api/json_rpc_methods.js | 5 +- src/build/apply_bufferutils_patch.js | 4 +- .../blockchain_connector/node_queries.js | 23 +- src/build/blockchain_connector/rpc_helpers.js | 15 +- src/build/utxo_tracker.js | 15 +- src/common/validator/action_data_checks.js | 17 +- src/common/validator/constants.js | 2 +- src/common/validator/fee_and_utxo_checks.js | 6 +- test/chaos/arithmetic_state.test.js | 31 ++- test/chaos/input_corruption.test.js | 4 +- test/chaos/resource_exhaustion.test.js | 17 +- .../utxo_record_conformance.test.js | 4 +- test/integration/helpers/utxoFactory.js | 58 +++++ test/regression/reg_08_api_contract.test.js | 16 +- .../reg_32_create_tx_error_code_split.test.js | 115 +++++++++ ...eg_13_reservation_release_on_throw.test.js | 12 +- .../reg_18_chained_send_reservation.test.js | 2 +- ...reg_19_envelope_cancel_reservation.test.js | 4 +- ...31_shortfall_names_reserved_inputs.test.js | 2 +- test/security/concurrency_gate.test.js | 25 +- .../03_probe_variants.test.js | 6 +- .../helpers/concurrency_gate_harness.js | 8 + .../03_get_transaction_hex.test.js | 70 ++++++ .../09_get_block_count.test.js | 75 ++++++ .../10_sanitize_rpc_error_parity.test.js | 100 ++++++++ .../build/apply_bufferutils_patch.test.js | 29 +++ ...n_encoder_package_aware_fee_sizing.test.js | 6 +- ..._get_utxos_from_address_validation.test.js | 42 ++-- .../03_validate_combined_data_length.test.js | 4 +- ...ate_utxo_array_validate_utxo_entry.test.js | 17 ++ test/unit/repo/sibling_coverage.test.js | 2 +- .../01_legacy_refusals.test.js | 63 +++++ .../02_legacy_exact_amounts.test.js | 63 +++++ .../03_tracker_fetched.test.js | 47 ++++ .../04_segwit_attach_prev_tx.test.js | 52 +++++ .../helpers/index.js | 67 ++++++ .../xchain_encoder_create_transaction.test.js | 2 + .../05_fee_handling.test.js | 2 + .../fixtures/transaction.js | 3 + .../xchain_encoder_extra.test.js | 3 +- .../06_change_edge_cases.test.js | 3 + ...cel_from_persisted_recovery_record.test.js | 6 + 49 files changed, 1151 insertions(+), 187 deletions(-) create mode 100644 test/regression/reg_32_create_tx_error_code_split.test.js rename test/regression/{ => reservations.test}/reg_13_reservation_release_on_throw.test.js (94%) rename test/regression/{ => reservations.test}/reg_18_chained_send_reservation.test.js (99%) rename test/regression/{ => reservations.test}/reg_19_envelope_cancel_reservation.test.js (99%) rename test/regression/{ => reservations.test}/reg_31_shortfall_names_reserved_inputs.test.js (98%) create mode 100644 test/unit/blockchain_connector.test/09_get_block_count.test.js create mode 100644 test/unit/blockchain_connector.test/10_sanitize_rpc_error_parity.test.js create mode 100644 test/unit/xchain_encoder/prev_output_value_check.test/01_legacy_refusals.test.js create mode 100644 test/unit/xchain_encoder/prev_output_value_check.test/02_legacy_exact_amounts.test.js create mode 100644 test/unit/xchain_encoder/prev_output_value_check.test/03_tracker_fetched.test.js create mode 100644 test/unit/xchain_encoder/prev_output_value_check.test/04_segwit_attach_prev_tx.test.js create mode 100644 test/unit/xchain_encoder/prev_output_value_check.test/helpers/index.js diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index e81019d..a3eec73 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -397,24 +397,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" ], - "27d7bba206f363af": [ - "UtxoTracker.getUtxosFromAddress() accepts an empty utxos array", - "UtxoTracker.getUtxosFromAddress() handles multiple valid UTXOs correctly", - "UtxoTracker.getUtxosFromAddress() reports the correct index in malformed UTXO error for second item", - "UtxoTracker.getUtxosFromAddress() rethrows get_utxos transport errors", - "UtxoTracker.getUtxosFromAddress() throws TypeError for malformed UTXO (missing txid)", - "UtxoTracker.getUtxosFromAddress() throws TypeError for malformed UTXO (missing value)", - "UtxoTracker.getUtxosFromAddress() throws TypeError for malformed UTXO (missing vout)", - "UtxoTracker.getUtxosFromAddress() throws TypeError for malformed UTXO (txid not a string)", - "UtxoTracker.getUtxosFromAddress() throws TypeError when a UTXO element is null", - "UtxoTracker.getUtxosFromAddress() throws TypeError when scriptPubKey is an empty string", - "UtxoTracker.getUtxosFromAddress() throws TypeError when scriptPubKey is missing", - "UtxoTracker.getUtxosFromAddress() throws TypeError when txid is 64 chars but not hex", - "UtxoTracker.getUtxosFromAddress() throws TypeError when txid is not a 64-char hex string (too short)", - "UtxoTracker.getUtxosFromAddress() throws TypeError when utxos is not an array", - "UtxoTracker.getUtxosFromAddress() throws when UTXO result is missing", - "UtxoTracker.getUtxosFromAddress() throws when UTXO result is not an object" - ], "27e24996d31ce20e": [ "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) a payload at exactly the ceiling builds a reveal within MAX_STANDARD_TX_WEIGHT (\u00a74)", "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) applies the per-encoding \u00a74 ceiling: over ENVELOPE_MAX_PAYLOAD rejects, over 8192 passes", @@ -431,6 +413,18 @@ "BlockchainConnector.getFeePerKilobyte() node RPC error detail carries the error body of an HTTP 500 answer into the thrown message", "BlockchainConnector.getFeePerKilobyte() node RPC error detail rethrows a bodiless transport failure as the original error object" ], + "2883f0e9d248552e": [ + "sanitizeRpcError() credential scrub scrubs HTTP 401 with a string body", + "sanitizeRpcError() credential scrub scrubs HTTP 500 with a -28 JSON-RPC body", + "sanitizeRpcError() credential scrub scrubs HTTP 500 with a code-only JSON-RPC body", + "sanitizeRpcError() credential scrub scrubs a response with no status", + "sanitizeRpcError() credential scrub scrubs a transport failure with no response", + "sanitizeRpcError() parity with the xchain-decoder twin scrubs the same fields and reads the same text for HTTP 401 with a string body", + "sanitizeRpcError() parity with the xchain-decoder twin scrubs the same fields and reads the same text for HTTP 500 with a -28 JSON-RPC body", + "sanitizeRpcError() parity with the xchain-decoder twin scrubs the same fields and reads the same text for HTTP 500 with a code-only JSON-RPC body", + "sanitizeRpcError() parity with the xchain-decoder twin scrubs the same fields and reads the same text for a response with no status", + "sanitizeRpcError() parity with the xchain-decoder twin scrubs the same fields and reads the same text for a transport failure with no response" + ], "2a1c91a7aceda8eb": [ "_buildTransaction outpoint dedup / mempool filter applies the mempool filter BEFORE dedup, so a confirmed twin survives", "_buildTransaction outpoint dedup / mempool filter collapses a set that is nothing but copies of one outpoint", @@ -570,6 +564,23 @@ "3d0c3f469f5a49b0": [ "E2E-5: UTXO, Fee, and Change Integration E2E-5.4: Unconfirmed UTXOs excluded filters mempool UTXOs when unconfirmed=false" ], + "3eb6eaeabe786d34": [ + "applyBufferutilsPatch BigInt fee accounting (getFee / getFeeRate / extractTransaction) returns 0 for a zero-fee PSBT with BigInt values instead of re-throwing", + "applyBufferutilsPatch BigInt fee accounting (getFee / getFeeRate / extractTransaction) returns a 0 fee rate for a sub-1-sat/vbyte fee instead of re-throwing", + "applyBufferutilsPatch BigInt fee accounting (getFee / getFeeRate / extractTransaction) still computes a non-zero fee and fee rate across the BigInt path", + "applyBufferutilsPatch BigInt fee accounting (getFee / getFeeRate / extractTransaction) the all-Number fast path is unchanged", + "applyBufferutilsPatch caps a Number at 2^53-1 on every writer while a BigInt passes", + "applyBufferutilsPatch exposes the patched bufferutils surface", + "applyBufferutilsPatch module-level helpers accept the full u64 range and reject one past it", + "applyBufferutilsPatch narrows a representable value to Number and keeps a BigInt only above 2^53-1", + "applyBufferutilsPatch refuses a PSBT witnessUtxo Number value past 2^53-1 and serializes its BigInt form", + "applyBufferutilsPatch rejects every invalid value with the stock verifuint string", + "applyBufferutilsPatch round-trips a small 64-bit LE value", + "applyBufferutilsPatch round-trips a value above 2^53 without precision loss", + "applyBufferutilsPatch twin guard against the SDK copy declares every package it deep-requires", + "applyBufferutilsPatch twin guard against the SDK copy is byte-identical to the SDK copy below the banner", + "applyBufferutilsPatch varuint encode/decode round-trips across size classes" + ], "3f015c065bb424cc": [ "CryptoNetworks .getBitcoinJsNetwork() Bitcoin networks returns bitcoin mainnet config with dustThreshold for \"bitcoin-mainnet\"", "CryptoNetworks .getBitcoinJsNetwork() Bitcoin networks returns regtest config with dustThreshold for \"bitcoin-regtest\"", @@ -830,21 +841,6 @@ "XChainEncoder.createTransaction() - remaining branch coverage throws RangeError when customOutputs[i].value is not a valid satoshi amount", "XChainEncoder.createTransaction() - remaining branch coverage throws when unconfirmed=false strips all UTXOs (line 325 path)" ], - "63c55285b6bac324": [ - "applyBufferutilsPatch BigInt fee accounting (getFee / getFeeRate / extractTransaction) returns 0 for a zero-fee PSBT with BigInt values instead of re-throwing", - "applyBufferutilsPatch BigInt fee accounting (getFee / getFeeRate / extractTransaction) returns a 0 fee rate for a sub-1-sat/vbyte fee instead of re-throwing", - "applyBufferutilsPatch BigInt fee accounting (getFee / getFeeRate / extractTransaction) still computes a non-zero fee and fee rate across the BigInt path", - "applyBufferutilsPatch BigInt fee accounting (getFee / getFeeRate / extractTransaction) the all-Number fast path is unchanged", - "applyBufferutilsPatch exposes the patched bufferutils surface", - "applyBufferutilsPatch module-level helpers accept the full u64 range and reject one past it", - "applyBufferutilsPatch narrows a representable value to Number and keeps a BigInt only above 2^53-1", - "applyBufferutilsPatch rejects every invalid value with the stock verifuint string", - "applyBufferutilsPatch round-trips a small 64-bit LE value", - "applyBufferutilsPatch round-trips a value above 2^53 without precision loss", - "applyBufferutilsPatch twin guard against the SDK copy declares every package it deep-requires", - "applyBufferutilsPatch twin guard against the SDK copy is byte-identical to the SDK copy below the banner", - "applyBufferutilsPatch varuint encode/decode round-trips across size classes" - ], "64da3d9aebea4a81": [ "E2E-5: UTXO, Fee, and Change Integration E2E-5.1: Single UTXO covers all 1 input, OP_RETURN + change; change = input - fee" ], @@ -1011,21 +1007,15 @@ "78ea596173e06da0": [ "XChainEncoder.createTransaction() P2SH encoding path (tx2: spending) creates tx2 with P2SH input and OP_RETURN marker" ], + "79ddb3d5c7dba3f0": [ + "prev-out value check: tracker-fetched and reservations a mismatch releases the outpoint, so a corrected retry succeeds at once", + "prev-out value check: tracker-fetched and reservations refuses a tracker mismatch as UTXO_TRACKER_ERROR, not a caller RangeError" + ], "7a323908028c1835": [ "Encoding Chunk Boundaries: Full Pipeline singleOpReturnPolicy enforcement (always fail-closed) a max-size single-chunk payload still encodes as exactly one OP_RETURN output regardless of the flag", "Encoding Chunk Boundaries: Full Pipeline singleOpReturnPolicy enforcement (always fail-closed) throws RangeError for an oversized OP_RETURN even when singleOpReturnPolicy is explicitly false", "Encoding Chunk Boundaries: Full Pipeline singleOpReturnPolicy enforcement (always fail-closed) throws RangeError for an oversized OP_RETURN when the flag is absent" ], - "7a532b0a3c0c3c0a": [ - "BlockchainConnector.getTransactionHex() includes the txid in the \"not found\" error message", - "BlockchainConnector.getTransactionHex() requests the verbose form even when a caller passes a second argument", - "BlockchainConnector.getTransactionHex() rethrows transport errors directly", - "BlockchainConnector.getTransactionHex() returns the hex string on success", - "BlockchainConnector.getTransactionHex() sends auth credentials", - "BlockchainConnector.getTransactionHex() sends getrawtransaction with correct txid and verbose=true", - "BlockchainConnector.getTransactionHex() throws \"not found\" message when error.code is -5", - "BlockchainConnector.getTransactionHex() throws generic error when result is missing and error is not -5" - ], "7fbd6025650718a6": [ "Security: obfuscation key binding actually transforms the payload (not emitted in the clear)", "Security: obfuscation key binding binds the ciphertext to the TXID: a different TXID does not recover it", @@ -1033,6 +1023,25 @@ "Security: obfuscation key binding fails closed on an empty key", "Security: obfuscation key binding produces distinct ciphertext for the same data under different TXIDs" ], + "8034f6c2b890b23c": [ + "BlockchainConnector.getBlockCount() makes the envelope gate report an unknown tip, not a countdown, for result null", + "BlockchainConnector.getBlockCount() returns a real integer tip, including a genesis tip of 0", + "BlockchainConnector.getBlockCount() returns null for empty body", + "BlockchainConnector.getBlockCount() returns null for error body beside a result", + "BlockchainConnector.getBlockCount() returns null for error body with null result", + "BlockchainConnector.getBlockCount() returns null for no result key", + "BlockchainConnector.getBlockCount() returns null for result empty string", + "BlockchainConnector.getBlockCount() returns null for result false", + "BlockchainConnector.getBlockCount() returns null for result fraction", + "BlockchainConnector.getBlockCount() returns null for result negative", + "BlockchainConnector.getBlockCount() returns null for result null", + "BlockchainConnector.getBlockCount() returns null for result numeric string", + "BlockchainConnector.getBlockCount() returns null for result true", + "BlockchainConnector.getBlockCount() returns null for undefined body", + "BlockchainConnector.getBlockCount() returns null for undefined response", + "BlockchainConnector.getBlockCount() returns null when the request throws", + "BlockchainConnector.getBlockCount() sends getblockcount" + ], "807a63a77985ea07": [ "singleInstanceGuard acquireInstanceLock [REGRESSION] breaks a legacy bare-pid lock whose holder is npm's shell wrapper", "singleInstanceGuard acquireInstanceLock [REGRESSION] breaks a legacy bare-pid lock whose holder is the npm parent", @@ -1084,6 +1093,22 @@ "XChainEncoder.createTransaction(): P2WSH tx1 (funding) throws TypeError when P2WSH is used on a no-segwit network", "XChainEncoder.createTransaction(): P2WSH tx1 (funding) total outputs do not exceed total inputs for P2WSH tx1" ], + "845d953a12be6745": [ + "BlockchainConnector.getTransactionHex() includes the txid in the \"not found\" error message", + "BlockchainConnector.getTransactionHex() node RPC error detail keeps the code and message of an HTTP 200 error body", + "BlockchainConnector.getTransactionHex() node RPC error detail keeps the code and message of an HTTP 500 -28 answer", + "BlockchainConnector.getTransactionHex() node RPC error detail keeps the code and message of an HTTP 500 -8 answer", + "BlockchainConnector.getTransactionHex() node RPC error detail logs the node reason rather than a bare status code", + "BlockchainConnector.getTransactionHex() node RPC error detail rethrows a bodiless transport failure as the original error object", + "BlockchainConnector.getTransactionHex() node RPC error detail still gives the txindex hint for an HTTP 500 -5 answer", + "BlockchainConnector.getTransactionHex() requests the verbose form even when a caller passes a second argument", + "BlockchainConnector.getTransactionHex() rethrows transport errors directly", + "BlockchainConnector.getTransactionHex() returns the hex string on success", + "BlockchainConnector.getTransactionHex() sends auth credentials", + "BlockchainConnector.getTransactionHex() sends getrawtransaction with correct txid and verbose=true", + "BlockchainConnector.getTransactionHex() throws \"not found\" message when error.code is -5", + "BlockchainConnector.getTransactionHex() throws generic error when result is missing and error is not -5" + ], "870962db3bfe1571": [ "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.21: BROADCAST message text preserved", "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.22: MESSAGE address + text preserved", @@ -1318,6 +1343,33 @@ "REG-08: API Contract Regression REG-08.4: PSBT serialization psbt hex can be parsed back by Psbt.fromHex()", "REG-08: API Contract Regression REG-08.4: PSBT serialization result.psbt hex is non-empty and valid hex" ], + "a1d049e9d9b2cc99": [ + "Encoder input validator validateUtxoArray / validateUtxoEntry coerces numeric-string confirmations and rejects untyped values", + "Encoder input validator validateUtxoArray / validateUtxoEntry coerces vout/value and defaults confirmations", + "Encoder input validator validateUtxoArray / validateUtxoEntry null passes; non-array and over-cap throw", + "Encoder input validator validateUtxoArray / validateUtxoEntry preserves an explicit confirmations value", + "Encoder input validator validateUtxoArray / validateUtxoEntry rejects each malformed field", + "Encoder input validator validateUtxoArray / validateUtxoEntry rejects vout values bare Number() would coerce to a plausible index (uuid:4555d78c)", + "Encoder input validator: vout uint32 bound rejects a vout above the uint32 wire width as a TypeError" + ], + "a54b04e2034f9891": [ + "UtxoTracker.getUtxosFromAddress() accepts an empty utxos array", + "UtxoTracker.getUtxosFromAddress() handles multiple valid UTXOs correctly", + "UtxoTracker.getUtxosFromAddress() reports the correct index in malformed UTXO error for second item", + "UtxoTracker.getUtxosFromAddress() rethrows get_utxos transport errors", + "UtxoTracker.getUtxosFromAddress() throws a plain Error for malformed UTXO (missing txid)", + "UtxoTracker.getUtxosFromAddress() throws a plain Error for malformed UTXO (missing value)", + "UtxoTracker.getUtxosFromAddress() throws a plain Error for malformed UTXO (missing vout)", + "UtxoTracker.getUtxosFromAddress() throws a plain Error for malformed UTXO (txid not a string)", + "UtxoTracker.getUtxosFromAddress() throws a plain Error when a UTXO element is null", + "UtxoTracker.getUtxosFromAddress() throws a plain Error when scriptPubKey is an empty string", + "UtxoTracker.getUtxosFromAddress() throws a plain Error when scriptPubKey is missing", + "UtxoTracker.getUtxosFromAddress() throws a plain Error when txid is 64 chars but not hex", + "UtxoTracker.getUtxosFromAddress() throws a plain Error when txid is not a 64-char hex string (too short)", + "UtxoTracker.getUtxosFromAddress() throws a plain Error when utxos is not an array", + "UtxoTracker.getUtxosFromAddress() throws when UTXO result is missing", + "UtxoTracker.getUtxosFromAddress() throws when UTXO result is not an object" + ], "a58c0275055cc988": [ "Encoder input validator validateChange null passes; rejects empty and over-long" ], @@ -1619,14 +1671,6 @@ "c547ad41237f5f7b": [ "Encoding Chunk Boundaries: Full Pipeline P2SH/P2WSH compiled chunk size ceiling every compiled chunk is <= 520 bytes at the chunk boundary (P2SH and P2WSH)" ], - "c590386a3c3ac1c3": [ - "Encoder input validator validateUtxoArray / validateUtxoEntry coerces numeric-string confirmations and rejects untyped values", - "Encoder input validator validateUtxoArray / validateUtxoEntry coerces vout/value and defaults confirmations", - "Encoder input validator validateUtxoArray / validateUtxoEntry null passes; non-array and over-cap throw", - "Encoder input validator validateUtxoArray / validateUtxoEntry preserves an explicit confirmations value", - "Encoder input validator validateUtxoArray / validateUtxoEntry rejects each malformed field", - "Encoder input validator validateUtxoArray / validateUtxoEntry rejects vout values bare Number() would coerce to a plausible index (uuid:4555d78c)" - ], "c67fd5ac2d0b5ff4": [ "get_tx_block tracker proxy request errors refuses an unhealthy tracker before requesting the transaction block", "get_tx_block tracker proxy request errors rejects a malformed txid with the existing invalid-params error type", @@ -1811,6 +1855,11 @@ "cee2a600289a6989": [ "E2E-5: UTXO, Fee, and Change Integration E2E-5.7: Fee floor enforcement floors fee to dustAmount when computed fee is lower" ], + "cf0706b575a3ca0f": [ + "prev-out value check: segwit inputs builds on a match with attachPrevTx", + "prev-out value check: segwit inputs refuses a mismatch when attachPrevTx fetches the previous transaction", + "prev-out value check: segwit inputs stays fetch-free without attachPrevTx (the sighash commits to the amount)" + ], "cf4d68f6c7c95947": [ "Fuzz: prepareData() MULTISIGN chunks are full 64-byte magic-prefixed slots over 1500 payloads", "Fuzz: prepareData() OP_RETURN produces one magic-prefixed chunk at/under the limit over 1500 payloads", @@ -1820,6 +1869,15 @@ "d089099f98093462": [ "Encoding Chunk Boundaries: Full Pipeline P2WSH on Dogecoin (no bech32 in network config) throws because bitcoin.payments.p2wsh requires bech32 prefix" ], + "d092f82fbad4908f": [ + "REG-32: create_envelope_cancel_tx draws the same line as create_tx answers -32602 with the reason for a feePerKb below the relay minimum", + "REG-32: create_tx answers a caller fault with -32602 keeps the message of a TypeError the build raises", + "REG-32: create_tx answers a caller fault with -32602 keeps the reason for a fee below the node relay minimum", + "REG-32: create_tx answers a server fault with -32603 or a tracker error answers -32603, generic, when the node reports no relayfee", + "REG-32: create_tx answers a server fault with -32603 or a tracker error answers UTXO_TRACKER_ERROR, never -32602, for a malformed tracker row", + "REG-32: the other build paths keep node faults off -32602 answers -32603 from create_envelope_cancel_tx when the node reports no relayfee", + "REG-32: the other build paths keep node faults off -32602 raises a plain Error, not a RangeError, from the output-uplift invariant" + ], "d101790f46691c56": [ "Category C: Obfuscation Round-Trip C-1: OP_RETURN obfuscation round-trip deobfuscated OP_RETURN data has XCHN prefix and original ACTION", "Category C: Obfuscation Round-Trip C-1: OP_RETURN obfuscation round-trip raw obfuscated bytes differ from plaintext", @@ -1846,6 +1904,11 @@ "Chaos Category A: Network & Dependency Failures A-7: Slow RPC responses 150ms delay on getFeePerKilobyte still completes", "Chaos Category A: Network & Dependency Failures A-7: Slow RPC responses 150ms delay on getTransactionHex (legacy UTXO) still completes" ], + "d487932ac997cf1e": [ + "prev-out value check: legacy exact amounts builds on a matching value, and the fee math uses that exact amount", + "prev-out value check: legacy exact amounts compares above 2^53-1 exactly: a match builds, off by one is refused", + "prev-out value check: legacy exact amounts names only the mismatched input in a multi-input build" + ], "d5e9845e4e125e70": [ "REG-04: Validator Functions REG-04.12: validateCompressedPubKey() accepts 02-prefixed 66-char hex key", "REG-04: Validator Functions REG-04.12: validateCompressedPubKey() accepts 03-prefixed 66-char hex key", @@ -1863,6 +1926,12 @@ "cross-repo sibling coverage (what this run could NOT verify) reports every sibling it looked for, so the list itself cannot rot silently", "cross-repo sibling coverage (what this run could NOT verify) resolves every sibling checkout the cross-repo guards depend on" ], + "d9588de8b6903a8b": [ + "prev-out value check: legacy refusals (caller-supplied) refuses a vout past the end of the previous transaction", + "prev-out value check: legacy refusals (caller-supplied) refuses an overstated value", + "prev-out value check: legacy refusals (caller-supplied) refuses an undecodable previous transaction", + "prev-out value check: legacy refusals (caller-supplied) refuses an understated value with a RangeError naming the outpoint and both amounts" + ], "da6106a9ba02aab9": [ "Encoder input validator validateCombinedDataLength accepts a single-push payload at the compiled ceiling (backwards-compatible)", "Encoder input validator validateCombinedDataLength accepts an explicit OP_RETURN payload at/under the 76-byte ceiling", @@ -2200,8 +2269,8 @@ }, "scripts": { "test": { - "fileCount": 128, - "titleCount": 943, + "fileCount": 135, + "titleCount": 992, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", @@ -2215,13 +2284,15 @@ "test/unit/blockchain_connector.test.js": "52961e7b06b0e39f", "test/unit/blockchain_connector.test/01_get_network_info.test.js": "e1423a6af229c1b4", "test/unit/blockchain_connector.test/02_is_regtest.test.js": "995c5c346314f418", - "test/unit/blockchain_connector.test/03_get_transaction_hex.test.js": "7a532b0a3c0c3c0a", + "test/unit/blockchain_connector.test/03_get_transaction_hex.test.js": "845d953a12be6745", "test/unit/blockchain_connector.test/04_send_raw_transaction.test.js": "e65ba2021038db9f", "test/unit/blockchain_connector.test/05_send_raw_transaction_maxfeerate_retry.test.js": "b70b109181c9f1fc", "test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js": "fae26ed352b38e34", "test/unit/blockchain_connector.test/07_rpc_credential_log_sanitization.test.js": "72c9c5f2d2749796", "test/unit/blockchain_connector.test/08_fee_estimate_rpc_error_detail.test.js": "2842761e884c6f3b", - "test/unit/build/apply_bufferutils_patch.test.js": "63c55285b6bac324", + "test/unit/blockchain_connector.test/09_get_block_count.test.js": "8034f6c2b890b23c", + "test/unit/blockchain_connector.test/10_sanitize_rpc_error_parity.test.js": "2883f0e9d248552e", + "test/unit/build/apply_bufferutils_patch.test.js": "3eb6eaeabe786d34", "test/unit/build/compression.test.js": "68868c37d76d5eb2", "test/unit/build/compression.test/01_action_string_helpers.test.js": "f6cecc24770508b5", "test/unit/build/compression.test/02_create_transaction_integration.test.js": "ce55de477de9f588", @@ -2238,7 +2309,7 @@ "test/unit/build/utxo_tracker.test.js": "f4d480d0b43ad2e9", "test/unit/build/utxo_tracker.test/01_get_sync_status.test.js": "fea0d5dcb2b90bb0", "test/unit/build/utxo_tracker.test/02_get_utxos_from_address.test.js": "149a2b69c0c72f7f", - "test/unit/build/utxo_tracker.test/03_get_utxos_from_address_validation.test.js": "27d7bba206f363af", + "test/unit/build/utxo_tracker.test/03_get_utxos_from_address_validation.test.js": "a54b04e2034f9891", "test/unit/build/utxo_tracker_get_tx_block.test.js": "c67fd5ac2d0b5ff4", "test/unit/coins/coins_conformance.test.js": "2442df610ebb6911", "test/unit/coins/xchain_encoder_consensus_pin_boot.test.js": "6a874601dbd714de", @@ -2255,7 +2326,7 @@ "test/unit/common/validator/validator.test/07_validate_fee.test.js": "13527b5e48f4a7cf", "test/unit/common/validator/validator.test/08_validate_fee_per_kb.test.js": "83a0c93d5c03a6e1", "test/unit/common/validator/validator.test/09_validate_dust.test.js": "0e58538e3d6aa84b", - "test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js": "c590386a3c3ac1c3", + "test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js": "a1d049e9d9b2cc99", "test/unit/common/validator/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js": "a7ccb078a2beb124", "test/unit/common/validator/validator.test/12_validate_p2sh_params.test.js": "ea49e69971727d3a", "test/unit/common/validator/validator.test/13_validate_raw_tx_hex.test.js": "8125cb09f89c6f70", @@ -2278,6 +2349,10 @@ "test/unit/server/single_instance_guard.test/02_is_pid_alive.test.js": "9ad3745dcc03e270", "test/unit/server/single_instance_guard.test/03_release_lock_on_signals.test.js": "6248ca3f37b35faa", "test/unit/xchain_encoder/envelope_recognition_gate.test.js": "e349cec537f9beb2", + "test/unit/xchain_encoder/prev_output_value_check.test/01_legacy_refusals.test.js": "d9588de8b6903a8b", + "test/unit/xchain_encoder/prev_output_value_check.test/02_legacy_exact_amounts.test.js": "d487932ac997cf1e", + "test/unit/xchain_encoder/prev_output_value_check.test/03_tracker_fetched.test.js": "79ddb3d5c7dba3f0", + "test/unit/xchain_encoder/prev_output_value_check.test/04_segwit_attach_prev_tx.test.js": "cf0706b575a3ca0f", "test/unit/xchain_encoder/suggested_fee_ceiling.test.js": "a76fc6f4a4157148", "test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js": "0748973400fcace3", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js": "23e242d978109d26", @@ -2429,8 +2504,8 @@ } }, "test:regression": { - "fileCount": 27, - "titleCount": 307, + "fileCount": 28, + "titleCount": 314, "files": { "test/regression/reg_01_encoding_types.test.js": "c438a1275387f62b", "test/regression/reg_02_obfuscation.test.js": "6c8feaefe1116923", @@ -2451,14 +2526,15 @@ "test/regression/reg_10_utxo_tracker_freshness.test.js": "140130fb419b99c8", "test/regression/reg_11_minimal_op_canonicalization.test.js": "76410d2234a85853", "test/regression/reg_12_ins0_key_binding.test.js": "c131768030b78cbc", - "test/regression/reg_13_reservation_release_on_throw.test.js": "4f79a402f81e163a", "test/regression/reg_14_p2sh_reveal_fee_floor.test.js": "3675428cec810c11", "test/regression/reg_15_uppercase_txid_key_binding.test.js": "926d15e7ddd15fee", "test/regression/reg_16_rawdata_only_warning.test.js": "145ea55c07323321", "test/regression/reg_17_utxo_pagination_snapshot.test.js": "a8137cddea506ba3", - "test/regression/reg_18_chained_send_reservation.test.js": "9716e0ba5467b267", - "test/regression/reg_19_envelope_cancel_reservation.test.js": "7334b6c4e6a33bbe", - "test/regression/reg_31_shortfall_names_reserved_inputs.test.js": "82be72919ffb1b6d" + "test/regression/reg_32_create_tx_error_code_split.test.js": "d092f82fbad4908f", + "test/regression/reservations.test/reg_13_reservation_release_on_throw.test.js": "4f79a402f81e163a", + "test/regression/reservations.test/reg_18_chained_send_reservation.test.js": "9716e0ba5467b267", + "test/regression/reservations.test/reg_19_envelope_cancel_reservation.test.js": "7334b6c4e6a33bbe", + "test/regression/reservations.test/reg_31_shortfall_names_reserved_inputs.test.js": "82be72919ffb1b6d" } }, "test:regtest": { @@ -2479,8 +2555,8 @@ } }, "test:unit": { - "fileCount": 128, - "titleCount": 943, + "fileCount": 135, + "titleCount": 992, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", @@ -2494,13 +2570,15 @@ "test/unit/blockchain_connector.test.js": "52961e7b06b0e39f", "test/unit/blockchain_connector.test/01_get_network_info.test.js": "e1423a6af229c1b4", "test/unit/blockchain_connector.test/02_is_regtest.test.js": "995c5c346314f418", - "test/unit/blockchain_connector.test/03_get_transaction_hex.test.js": "7a532b0a3c0c3c0a", + "test/unit/blockchain_connector.test/03_get_transaction_hex.test.js": "845d953a12be6745", "test/unit/blockchain_connector.test/04_send_raw_transaction.test.js": "e65ba2021038db9f", "test/unit/blockchain_connector.test/05_send_raw_transaction_maxfeerate_retry.test.js": "b70b109181c9f1fc", "test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js": "fae26ed352b38e34", "test/unit/blockchain_connector.test/07_rpc_credential_log_sanitization.test.js": "72c9c5f2d2749796", "test/unit/blockchain_connector.test/08_fee_estimate_rpc_error_detail.test.js": "2842761e884c6f3b", - "test/unit/build/apply_bufferutils_patch.test.js": "63c55285b6bac324", + "test/unit/blockchain_connector.test/09_get_block_count.test.js": "8034f6c2b890b23c", + "test/unit/blockchain_connector.test/10_sanitize_rpc_error_parity.test.js": "2883f0e9d248552e", + "test/unit/build/apply_bufferutils_patch.test.js": "3eb6eaeabe786d34", "test/unit/build/compression.test.js": "68868c37d76d5eb2", "test/unit/build/compression.test/01_action_string_helpers.test.js": "f6cecc24770508b5", "test/unit/build/compression.test/02_create_transaction_integration.test.js": "ce55de477de9f588", @@ -2517,7 +2595,7 @@ "test/unit/build/utxo_tracker.test.js": "f4d480d0b43ad2e9", "test/unit/build/utxo_tracker.test/01_get_sync_status.test.js": "fea0d5dcb2b90bb0", "test/unit/build/utxo_tracker.test/02_get_utxos_from_address.test.js": "149a2b69c0c72f7f", - "test/unit/build/utxo_tracker.test/03_get_utxos_from_address_validation.test.js": "27d7bba206f363af", + "test/unit/build/utxo_tracker.test/03_get_utxos_from_address_validation.test.js": "a54b04e2034f9891", "test/unit/build/utxo_tracker_get_tx_block.test.js": "c67fd5ac2d0b5ff4", "test/unit/coins/coins_conformance.test.js": "2442df610ebb6911", "test/unit/coins/xchain_encoder_consensus_pin_boot.test.js": "6a874601dbd714de", @@ -2534,7 +2612,7 @@ "test/unit/common/validator/validator.test/07_validate_fee.test.js": "13527b5e48f4a7cf", "test/unit/common/validator/validator.test/08_validate_fee_per_kb.test.js": "83a0c93d5c03a6e1", "test/unit/common/validator/validator.test/09_validate_dust.test.js": "0e58538e3d6aa84b", - "test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js": "c590386a3c3ac1c3", + "test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js": "a1d049e9d9b2cc99", "test/unit/common/validator/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js": "a7ccb078a2beb124", "test/unit/common/validator/validator.test/12_validate_p2sh_params.test.js": "ea49e69971727d3a", "test/unit/common/validator/validator.test/13_validate_raw_tx_hex.test.js": "8125cb09f89c6f70", @@ -2557,6 +2635,10 @@ "test/unit/server/single_instance_guard.test/02_is_pid_alive.test.js": "9ad3745dcc03e270", "test/unit/server/single_instance_guard.test/03_release_lock_on_signals.test.js": "6248ca3f37b35faa", "test/unit/xchain_encoder/envelope_recognition_gate.test.js": "e349cec537f9beb2", + "test/unit/xchain_encoder/prev_output_value_check.test/01_legacy_refusals.test.js": "d9588de8b6903a8b", + "test/unit/xchain_encoder/prev_output_value_check.test/02_legacy_exact_amounts.test.js": "d487932ac997cf1e", + "test/unit/xchain_encoder/prev_output_value_check.test/03_tracker_fetched.test.js": "79ddb3d5c7dba3f0", + "test/unit/xchain_encoder/prev_output_value_check.test/04_segwit_attach_prev_tx.test.js": "cf0706b575a3ca0f", "test/unit/xchain_encoder/suggested_fee_ceiling.test.js": "a76fc6f4a4157148", "test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js": "0748973400fcace3", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js": "23e242d978109d26", diff --git a/src/XChainEncoder/build_transaction/fee_rates.js b/src/XChainEncoder/build_transaction/fee_rates.js index c659710..371225c 100644 --- a/src/XChainEncoder/build_transaction/fee_rates.js +++ b/src/XChainEncoder/build_transaction/fee_rates.js @@ -43,8 +43,10 @@ function* resolveFeeRates(build){ function* resolveRelayFeeRate(build){ const info = yield this.connector.getNetworkInfo() const relayFeePerKb = Number(info && info.relayfee) + // Refuse a node that reports no usable relay floor. A plain Error, not a + // RangeError: this is the node's fault, so the API answers retryable -32603. if (!Number.isFinite(relayFeePerKb) || relayFeePerKb <= 0){ - throw new RangeError('Node did not report a positive relayfee; transaction fee safety cannot be verified') + throw new Error('Node did not report a positive relayfee; transaction fee safety cannot be verified') } Object.assign(build, { relayFeePerKb }) } diff --git a/src/XChainEncoder/build_transaction/fee_settlement.js b/src/XChainEncoder/build_transaction/fee_settlement.js index c4de910..a83797c 100644 --- a/src/XChainEncoder/build_transaction/fee_settlement.js +++ b/src/XChainEncoder/build_transaction/fee_settlement.js @@ -288,8 +288,10 @@ function computeChange(build){ let { estimatedFee, inputSatoshis, outputSatoshis, p2shHash, reservedCandidates, change } = build // Validate the fee BEFORE the BigInt conversion below: BigInt(estimatedFee) // throws an opaque error on a NaN/Infinity fee, where this check names the cause. + // A plain Error (API -32603): the validator already bounds every caller fee + // input, so a non-integer here comes from node-derived rates or a build bug. if (!Number.isFinite(estimatedFee) || !Number.isInteger(estimatedFee)) { - throw new RangeError('Fee calculation produced invalid result. Check that all UTXO values and fees are valid integers.') + throw new Error('Fee calculation produced invalid result. Check that all UTXO values and fees are valid integers.') } // Exact change math in BigInt: with a >2^53-1-sat input, Number diff --git a/src/XChainEncoder/build_transaction/input_candidates.js b/src/XChainEncoder/build_transaction/input_candidates.js index 5f217db..bc947f5 100644 --- a/src/XChainEncoder/build_transaction/input_candidates.js +++ b/src/XChainEncoder/build_transaction/input_candidates.js @@ -168,7 +168,14 @@ function acceptTrackerUtxos(build, fetched){ //UTXOs would otherwise be unable to build any transaction). The //SELECTED input count is bounded after selection instead. for (let vi = 0; vi < utxos.length; vi++){ - validateUtxoEntry(utxos[vi], vi) + try { + validateUtxoEntry(utxos[vi], vi) + } catch (err) { + // Name a bad tracker row as a tracker error: the validator's TypeError or + // RangeError would otherwise tell the caller to fix params they never sent. + throw new OperationalError('UTXO_TRACKER_ERROR', + `utxo-tracker returned a malformed utxo: ${upstreamErrorMessage(err, `unreadable entry at index ${vi}`)}`) + } } Object.assign(build, { utxos, fetchedFromTracker }) } diff --git a/src/XChainEncoder/build_transaction/input_selection.js b/src/XChainEncoder/build_transaction/input_selection.js index a8811ba..0088408 100644 --- a/src/XChainEncoder/build_transaction/input_selection.js +++ b/src/XChainEncoder/build_transaction/input_selection.js @@ -18,6 +18,7 @@ * ********************************************************************/ +const bitcoin = require('bitcoinjs-lib') const TxSizeEstimator = require('../../build/tx_size_estimator') const { MAX_UTXO_COUNT, parseSatoshiAmount } = require('../../common/validator') const { OperationalError } = require('../../build/errors') @@ -84,7 +85,7 @@ function* selectInputs(build){ nextUtxo.value = parseSatoshiAmount(nextUtxo.value, `utxos[${nextUtxoIndex}].value`, { allowBig: true }) - const added = yield* addSelectedInput.call(this, psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis) + const added = yield* addSelectedInput.call(this, psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis, build.fetchedFromTracker) estimatedTxSize = added.estimatedTxSize inputSatoshis = added.inputSatoshis @@ -173,7 +174,31 @@ function skipReservedInput(callReservations, nextUtxo, exactInputs, firstReserve return false } -function* addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis){ +// Refuses a UTXO whose stated value differs from its output in the fetched previous tx. +// A legacy sighash does not commit to input amounts, so an understated value would sign +// and the hidden difference would go to miners, past fee guards that read the same figure. +function assertPrevOutValueMatches(prevTxHex, utxo, fetchedFromTracker){ + const outpoint = `${utxo.txid}:${utxo.vout}` + let prevOut = null + try { + prevOut = bitcoin.Transaction.fromHex(prevTxHex).outs[utxo.vout] + } catch (err) { + prevOut = null + } + let problem = null + if (!prevOut){ + problem = `previous transaction for utxo ${outpoint} has no output at index ${utxo.vout}` + } else if (BigInt(prevOut.value) !== BigInt(utxo.value)){ + problem = `utxo ${outpoint} states value ${BigInt(utxo.value).toString()} but the previous transaction ` + + `pays ${BigInt(prevOut.value).toString()} to that output` + } + if (problem === null) return + // Name who supplied the bad figure: a tracker row is not a caller parameter. + if (fetchedFromTracker) throw new OperationalError('UTXO_TRACKER_ERROR', `utxo-tracker returned a wrong utxo: ${problem}`) + throw new RangeError(problem) +} + +function* addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis, fetchedFromTracker){ if (this.isSegwitUTXO(nextUtxo)){ let nextInput = { hash: nextUtxo.txid, @@ -197,6 +222,8 @@ function* addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, estimated // byte-identity guarantee the confirm surface rests on. if (attachPrevTx) { const prevTxHex = yield this.connector.getTransactionHex(nextUtxo.txid) + // Check the amount too: a hardware signer reads it from these bytes. + assertPrevOutValueMatches(prevTxHex, nextUtxo, fetchedFromTracker) nextInput.nonWitnessUtxo = Buffer.from(prevTxHex, 'hex') } psbt.addInput(nextInput) @@ -204,6 +231,8 @@ function* addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, estimated inputSatoshis = inputSatoshis + BigInt(nextUtxo.value) } else { let wholeUtxoHex = yield this.connector.getTransactionHex(nextUtxo.txid) + // Check the stated amount against the node's copy before it enters the fee math. + assertPrevOutValueMatches(wholeUtxoHex, nextUtxo, fetchedFromTracker) let nextInput = { hash: nextUtxo.txid, index: nextUtxo.vout, diff --git a/src/XChainEncoder/envelope_cancel.js b/src/XChainEncoder/envelope_cancel.js index 8700efd..8f9c68f 100644 --- a/src/XChainEncoder/envelope_cancel.js +++ b/src/XChainEncoder/envelope_cancel.js @@ -33,8 +33,9 @@ function readCancelRecord(commitTxid, commitVout, commitValue, internalPubkey, t if (typeof commitTxid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(commitTxid)) { throw new TypeError('commitTxid must be a 64-character hex string') } - if (!Number.isInteger(commitVout) || commitVout < 0) { - throw new TypeError('commitVout must be a non-negative integer') + // Cap commitVout at the uint32 wire width before it becomes a reservation key. + if (!Number.isInteger(commitVout) || commitVout < 0 || commitVout > 0xffffffff) { + throw new TypeError('commitVout must be a non-negative integer no greater than 4294967295') } const value = parseSatoshiAmount(commitValue, 'commitValue') // Accept the 33-byte compressed form (what create_tx took) or the @@ -132,8 +133,10 @@ function* cancelFeeRate(feeRatePerKb){ // and fee rate, so an unbounded rate here is a real burn surface. const info = yield this.connector.getNetworkInfo() const relayFeePerKb = Number(info && info.relayfee) + // Refuse a node that reports no usable relay floor. A plain Error, not a + // RangeError: this is the node's fault, so the API answers retryable -32603. if (!Number.isFinite(relayFeePerKb) || relayFeePerKb <= 0){ - throw new RangeError('Node did not report a positive relayfee; transaction fee safety cannot be verified') + throw new Error('Node did not report a positive relayfee; transaction fee safety cannot be verified') } let feePerBytes let nodeFeePerBytes = null diff --git a/src/XChainEncoder/size_estimation.js b/src/XChainEncoder/size_estimation.js index 9372645..f64aa68 100644 --- a/src/XChainEncoder/size_estimation.js +++ b/src/XChainEncoder/size_estimation.js @@ -93,19 +93,25 @@ module.exports = { // a bitcoinjs release that reshapes this would otherwise underfund a reveal // silently, which is the exact failure this whole pass exists to prevent. raiseOutputValue(psbt, outputIndex, delta){ + // Throw plain Error from every guard here (API -32603): each is an internal + // invariant no caller input can trip, never a RangeError the API reads as bad params. + + // Verify the uplift is a positive whole amount if (!Number.isInteger(delta) || delta <= 0){ - throw new RangeError('output uplift must be a positive integer') + throw new Error('output uplift must be a positive integer') } const outs = psbt.data.globalMap.unsignedTx && psbt.data.globalMap.unsignedTx.tx && psbt.data.globalMap.unsignedTx.tx.outs + // Verify the output being raised exists if (!Array.isArray(outs) || !outs[outputIndex]){ - throw new RangeError(`no output at index ${outputIndex} to raise`) + throw new Error(`no output at index ${outputIndex} to raise`) } const raised = outs[outputIndex].value + delta outs[outputIndex].value = raised + // Verify bitcoinjs serializes the raised value (see the note above) if (psbt.txOutputs[outputIndex].value !== raised){ - throw new RangeError('output value uplift did not reach the transaction bitcoinjs will serialize') + throw new Error('output value uplift did not reach the transaction bitcoinjs will serialize') } }, diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index d01b499..0de56a6 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -178,12 +178,15 @@ return { // our own validation. Everything else is an unexpected internal and is // collapsed to a generic message to prevent leaking internals // (host:port, stack, RPC credentials). + // Map a validation error to -32602 (fix the params, do not retry) and + // everything else to -32603 (retry with backoff). Node and tracker faults + // inside the build throw plain Error so they stay on the retryable side. const isKnown = err instanceof TypeError || err instanceof RangeError if (!isKnown) { logger.error(util.format('Encoder error:', err)) } const e = new Error(isKnown ? err.message : 'Internal encoder error') - e.code = -32603 + e.code = isKnown ? -32602 : -32603 throw e } diff --git a/src/build/apply_bufferutils_patch.js b/src/build/apply_bufferutils_patch.js index 6e995ed..1ef8121 100644 --- a/src/build/apply_bufferutils_patch.js +++ b/src/build/apply_bufferutils_patch.js @@ -73,7 +73,9 @@ function verifyU64(value) { if (value < 0) { throw new Error('specified a negative value for writing an unsigned value') } - if (value > MAX_U64) { + // Cap a Number at stock's 2^53-1, since past it the value is no longer exact; a BigInt + // carries anything larger, up to the wire's 2^64-1. + if (value > MAX_U64 || (typeof value === 'number' && value > Number.MAX_SAFE_INTEGER)) { throw new Error('RangeError: value out of range') } if (typeof value === 'number' && Math.floor(value) !== value) { diff --git a/src/build/blockchain_connector/node_queries.js b/src/build/blockchain_connector/node_queries.js index 8d12195..46fea60 100644 --- a/src/build/blockchain_connector/node_queries.js +++ b/src/build/blockchain_connector/node_queries.js @@ -104,8 +104,14 @@ module.exports = { auth: { username: this.rpcUser, password: this.rpcPassword }, timeout: RPC_TIMEOUT }); - const height = response && response.data && response.data.result; - return Number.isFinite(Number(height)) ? Number(height) : null; + const body = response && response.data; + // Treat an error body, an empty body or a missing result as unknown. + if (!body || typeof body !== 'object') return null; + if (body.error !== undefined && body.error !== null) return null; + // Type before coercion: Number(null), Number('') and Number(false) are a + // finite 0, which the envelope gate would read as a real tip at genesis. + const height = body.result; + return (Number.isSafeInteger(height) && height >= 0) ? height : null; } catch (error) { return null; } @@ -139,7 +145,8 @@ module.exports = { // transactions this typically means the coin node lacks txindex. throw new Error(`Transaction ${txid} not found (the coin node may require txindex=1 to retrieve confirmed transactions)`); } else { - throw new Error('Error getting transaction hex'); + // Keep the node's reason from an HTTP 200 error body (BTC v28's shape). + throw new Error('Error getting transaction hex' + rpcErrorDetail(responseData)); } } catch (error) { // LTC/DOGE return HTTP 500 for RPC-level errors so axios throws before @@ -150,8 +157,14 @@ module.exports = { if (body && body.error?.code === -5) { throw new Error(`Transaction ${txid} not found (the coin node may require txindex=1 to retrieve confirmed transactions)`); } - logger.error(util.format('Error:', sanitizeRpcError(error))); - throw error; + // Read any other node reason (-28 warming up, -8) before the scrub + // drops error.response, so it is not flattened to "status code 500". + const detail = rpcErrorDetail(body); + const message = sanitizeRpcError(error); + logger.error(util.format('Error:', message + detail)); + // No RPC body: a transport failure keeps its original error object. + if (!detail) throw error; + throw new Error(`Error getting transaction hex: ${message}${detail}`); } }, } diff --git a/src/build/blockchain_connector/rpc_helpers.js b/src/build/blockchain_connector/rpc_helpers.js index b73d8f7..65afec6 100644 --- a/src/build/blockchain_connector/rpc_helpers.js +++ b/src/build/blockchain_connector/rpc_helpers.js @@ -44,7 +44,10 @@ function feeEstimateSanityCeiling(){ // the encoder logs (util.inspect walks error.config.auth). Scrub the credential // fields in place so neither this logger nor any upstream handler leaks them, and // return a compact, credential-free string (error.message never carries auth). -// Kept in sync with xchain-decoder/src/chain/blockchain_connector.js sanitizeRpcError. +// The credential scrub is kept in sync with sanitizeRpcError in +// xchain-decoder/src/chain/blockchain_connector/rpc_helpers.js. Unlike that twin, +// this copy does not capture the node's RPC code and message, so callers read +// rpcErrorDetail below first. function sanitizeRpcError(error){ try { if (error && error.config) { @@ -60,11 +63,6 @@ function sanitizeRpcError(error){ return (error && error.message) ? error.message : String(error) } -// Size and fee off one getmempoolentry-shaped record, across both field layouts -// the fleet's nodes use: Core 0.14 (Dogecoin 1.14) reports flat `size` and `fee`, -// while modern Core reports `vsize` and nests the fee under `fees.base`. Either -// reader returns null on a value it cannot price, which the caller treats as an -// unusable package rather than as a zero-fee ancestor. // The node's own JSON-RPC error as a compact suffix, off either response shape // the fleet produces: BTC v28 answers HTTP 200 with an error body, while // LTC/DOGE answer HTTP 500 and axios hangs the body off error.response. Returns @@ -95,6 +93,11 @@ function readNumeric(raw){ return null } +// Size and fee off one getmempoolentry-shaped record, across both field layouts +// the fleet's nodes use: Core 0.14 (Dogecoin 1.14) reports flat `size` and `fee`, +// while modern Core reports `vsize` and nests the fee under `fees.base`. Either +// reader returns null on a value it cannot price, which the caller treats as an +// unusable package rather than as a zero-fee ancestor. function entrySize(entry){ const raw = entry && (entry.vsize !== undefined ? entry.vsize : entry.size) const size = readNumeric(raw) diff --git a/src/build/utxo_tracker.js b/src/build/utxo_tracker.js index 855e650..5b16857 100644 --- a/src/build/utxo_tracker.js +++ b/src/build/utxo_tracker.js @@ -141,12 +141,14 @@ async function fetchUtxoPage(tracker, address, limit, cursor){ } // Unwrap one page's result, or throw the error the tracker sent in its place. +// A malformed tracker reply is the tracker's fault, never the caller's, so every +// shape refusal in this file is a plain Error, never the TypeError the API reads as bad params. function readUtxoResult(responseData){ // Verify structure or surface the tracker's structured error. if (responseData.result && typeof responseData.result === 'object' && responseData.result !== null) { const result = responseData.result if (!Array.isArray(result.utxos)) { - throw new TypeError('UTXO tracker result missing utxos array') + throw new Error('UTXO tracker result missing utxos array') } return result } @@ -173,7 +175,8 @@ function assertSameSnapshot(firstPageSync, pageSync, address){ } } -// Validate one page's rows and append them to the running set. +// Validate one page's rows and append them to the running set (plain Error on a +// bad row, as in readUtxoResult above: a malformed row is the tracker's fault). function appendPageUtxos(utxos, allUtxos){ // pageOffset is the count before this page so globalIdx // across pages matches what a single-page caller would see. @@ -185,13 +188,13 @@ function appendPageUtxos(utxos, allUtxos){ typeof u.txid !== 'string' || typeof u.vout === 'undefined' || typeof u.value === 'undefined') { - throw new TypeError(`UTXO tracker returned malformed utxo at index ${globalIdx}`) + throw new Error(`UTXO tracker returned malformed utxo at index ${globalIdx}`) } if (!HEX_64_RE.test(u.txid)) { - throw new TypeError(`UTXO tracker returned malformed utxo at index ${globalIdx}: txid must be a 64-character hex string`) + throw new Error(`UTXO tracker returned malformed utxo at index ${globalIdx}: txid must be a 64-character hex string`) } if (typeof u.scriptPubKey !== 'string' || u.scriptPubKey.length === 0) { - throw new TypeError(`UTXO tracker returned malformed utxo at index ${globalIdx}: scriptPubKey must be a non-empty string`) + throw new Error(`UTXO tracker returned malformed utxo at index ${globalIdx}: scriptPubKey must be a non-empty string`) } if (u.confirmations == null) { u.confirmations = 0 @@ -201,7 +204,7 @@ function appendPageUtxos(utxos, allUtxos){ // coerce and range-check tracker-supplied values too. const confirmations = Number(u.confirmations) if (!Number.isInteger(confirmations) || confirmations < 0) { - throw new TypeError(`UTXO tracker returned malformed utxo at index ${globalIdx}: confirmations must be a non-negative integer`) + throw new Error(`UTXO tracker returned malformed utxo at index ${globalIdx}: confirmations must be a non-negative integer`) } u.confirmations = confirmations } diff --git a/src/common/validator/action_data_checks.js b/src/common/validator/action_data_checks.js index 8b850ad..4d54540 100644 --- a/src/common/validator/action_data_checks.js +++ b/src/common/validator/action_data_checks.js @@ -184,19 +184,19 @@ function unknownActionName(data) { // - a payload within ~8 bytes of the ceiling can still fail post-compression: // compression.js keeps a result that is smaller by as little as one byte // while withCompressionField pads the action string out to the COMPRESSION -// field. Those land as the builder's -32603 rather than this -32602. +// field. The builder refuses those with its own RangeError, still -32602. // // XChainEncoder.js's "everything downstream prices the bytes that will actually // be written" is about the passes that run AFTER compression; this one runs // before it. The compiled-size ceiling still runs ahead of the UTXO fetch, so -// what a deferred rejection costs is the error CODE, not reservation work. +// a deferred rejection costs only its wording, not reservation work. function validateCombinedDataLength(data, rawData, encoding) { if (data == null && rawData == null) return // createTransaction defaults a missing `data` to '' and still compiles it // as a push (OP_0, 1 byte), so a rawData-only request must be measured // here too; skipping it only shifted the rejection to the compiled-size - // ceiling in createTransaction with a -32603 internal error instead of - // this pre-check's -32602 invalid-params classification. + // ceiling in createTransaction, past the point this pre-check exists to + // refuse it at. const dataBytes = data != null ? Buffer.byteLength(data, 'utf8') : 0 // Match XChainEncoder.js: rawData is bytes-as-string (Latin-1), so the // on-chain byte count is the string length, not the UTF-8 encoding length. @@ -210,7 +210,7 @@ function validateCombinedDataLength(data, rawData, encoding) { // 390,000-byte envelope ceiling below, and _buildTransaction refuses on the // REAL compiled buffer, so a push framed with OP_PUSHDATA4 must be counted // the way bitcoin.script.compile frames it or the two ceilings disagree by - // 2 bytes per large push and a payload lands as -32603 instead of -32602. + // 2 bytes per large push and a payload passes here only to fail in the build. const compiled = envelopePushSize(dataBytes) + (rawData != null ? envelopePushSize(rawBytes) : 0) // Per-encoding ceiling. "TAPROOT" and "AUTO" both get the // envelope ceiling; every other value (including an OMITTED encoding) keeps @@ -224,8 +224,8 @@ function validateCombinedDataLength(data, rawData, encoding) { // that resolution, so it can only apply the WIDEST ceiling any AUTO // resolution could legitimately use, and let _buildTransaction re-check the // resolved carrier. Consequence: an AUTO request that resolves to a legacy carrier - // over 8,192 bytes is refused by the builder (-32603) rather than here - // (-32602). Both fail closed. Note an OMITTED encoding is NOT AUTO: it keeps + // over 8,192 bytes is refused by the builder rather than here. Both fail + // closed as -32602. Note an OMITTED encoding is NOT AUTO: it keeps // prepareData's legacy OP_RETURN-else-P2SH fallback and its legacy ceiling. const wideCeiling = (encoding === 'TAPROOT' || encoding === 'AUTO') const ceiling = wideCeiling ? ENVELOPE_MAX_PAYLOAD : MAX_COMPILED_ACTION_DATA_LENGTH @@ -234,8 +234,7 @@ function validateCombinedDataLength(data, rawData, encoding) { } // When the caller EXPLICITLY requested OP_RETURN, apply the far tighter 76-byte // single-output ceiling here rather than letting the request run the whole - // UTXO-selection/reservation path and throw post-compile in prepareData (which - // api.js then mis-classifies as -32603 internal instead of -32602 invalid-params). + // UTXO-selection/reservation path before prepareData throws post-compile. // Only when encoding is explicitly 'OP_RETURN': an omitted encoding must NOT be // rejected here, or it would break prepareData's automatic P2SH fallback for // larger payloads. prepareData remains the arbiter/backstop. diff --git a/src/common/validator/constants.js b/src/common/validator/constants.js index d60c7eb..f25c96b 100644 --- a/src/common/validator/constants.js +++ b/src/common/validator/constants.js @@ -39,7 +39,7 @@ const OP_RETURN_PUSH_OVERHEAD = 3 // which stays the arbiter/backstop; measured pre-compile here only when the caller // explicitly requests encoding:"OP_RETURN" so an oversize request is rejected as // -32602 invalid-params before any UTXO reservation, instead of failing post-compile -// as a -32603 internal error. The compiled value this is compared against is +// after that reservation work. The compiled value this is compared against is // exactly finalDataBuffer.length in createTransaction (same compiledPushSize sum). const OP_RETURN_OUTPUT_SIZE = 80 const OP_RETURN_MAGIC_WORD_LENGTH = 4 diff --git a/src/common/validator/fee_and_utxo_checks.js b/src/common/validator/fee_and_utxo_checks.js index c30415b..5a3c09e 100644 --- a/src/common/validator/fee_and_utxo_checks.js +++ b/src/common/validator/fee_and_utxo_checks.js @@ -138,8 +138,10 @@ function validateUtxoOutpoint(entry, index) { // outpoint than intended. This matches the value field's exact-integer rigor // and rejects values that Number() would silently coerce to zero. const vout = toExactInt(entry.vout) - if (!Number.isInteger(vout) || vout < 0) { - throw new TypeError(`utxos[${index}].vout must be a non-negative integer`) + // Cap vout at the uint32 wire width, so an oversized index is a -32602 here + // and never an opaque bitcoinjs typeforce error inside psbt.addInput. + if (!Number.isInteger(vout) || vout < 0 || vout > 0xffffffff) { + throw new TypeError(`utxos[${index}].vout must be a non-negative integer no greater than 4294967295`) } entry.vout = vout } diff --git a/test/chaos/arithmetic_state.test.js b/test/chaos/arithmetic_state.test.js index b780a5d..dc30404 100644 --- a/test/chaos/arithmetic_state.test.js +++ b/test/chaos/arithmetic_state.test.js @@ -21,7 +21,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const { TXID_A, TXID_B, TXID_C, - makeUtxo, makeEncoder, getTestAddress + makeUtxo, makeEncoder, getTestAddress, attachPrevTxs } = require('../integration/helpers/utxoFactory') const actions = require('../integration/helpers/actionFactory') @@ -51,6 +51,7 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { it('1-sat UTXO + 10000-sat fee → INSUFFICIENT_FUNDS, no PSBT', async () => { const encoder = makeEncoder(NETWORK) const utxo = makeUtxo(NETWORK, TXID_A, 0, 1) + attachPrevTxs(encoder, [utxo], NETWORK) await assert.rejects( () => encoder.createTransaction( @@ -68,14 +69,16 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { it('3x 100-sat UTXOs with 10000-sat fee → INSUFFICIENT_FUNDS on the whole set', async () => { const encoder = makeEncoder(NETWORK) + const utxos = [ + makeUtxo(NETWORK, TXID_A, 0, 100), + makeUtxo(NETWORK, TXID_B, 0, 100), + makeUtxo(NETWORK, TXID_C, 0, 100) + ] + attachPrevTxs(encoder, utxos, NETWORK) await assert.rejects( () => encoder.createTransaction( - [ - makeUtxo(NETWORK, TXID_A, 0, 100), - makeUtxo(NETWORK, TXID_B, 0, 100), - makeUtxo(NETWORK, TXID_C, 0, 100) - ], + utxos, ADDRESS, null, actions.makeSend().data, null, 10000, false, null, ADDRESS, null, null, null, true, 0.00001 @@ -98,13 +101,15 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { const encoder = makeEncoder(NETWORK) // 3 UTXOs of 100 sats = 300 total. Fee = 10000. Need 10000+. + const utxos = [ + makeUtxo(NETWORK, TXID_A, 0, 100), + makeUtxo(NETWORK, TXID_B, 0, 100), + makeUtxo(NETWORK, TXID_A, 1, 100) + ] + attachPrevTxs(encoder, utxos, NETWORK) await assert.rejects( () => encoder.createTransaction( - [ - makeUtxo(NETWORK, TXID_A, 0, 100), - makeUtxo(NETWORK, TXID_B, 0, 100), - makeUtxo(NETWORK, TXID_A, 1, 100) - ], + utxos, ADDRESS, null, actions.makeSend().data, null, 10000, false, null, ADDRESS, null, null, null, true, 0.00001 @@ -116,6 +121,7 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { it('a single dust UTXO is refused rather than serialized', async () => { const encoder = makeEncoder(NETWORK) const utxo = makeUtxo(NETWORK, TXID_A, 0, 100) + attachPrevTxs(encoder, [utxo], NETWORK) await assert.rejects( () => encoder.createTransaction( @@ -171,6 +177,7 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { makeUtxo(NETWORK, TXID_A, 0, 100000000), makeUtxo(NETWORK, TXID_B, 0, 50000000) ] + attachPrevTxs(encoder, sharedUtxos, NETWORK) // Both calls share the same array reference. JavaScript is // single-threaded so no true race, but the array is mutated. @@ -202,6 +209,7 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { makeUtxo(NETWORK, TXID_A, 0, 100000000), // large makeUtxo(NETWORK, TXID_B, 0, 50000000) // medium ] + attachPrevTxs(encoder, utxos, NETWORK) const orderBefore = utxos.map(u => u.txid) // First call @@ -223,6 +231,7 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { makeUtxo(NETWORK, TXID_A, 0, 100000000), makeUtxo(NETWORK, TXID_B, 0, 50000000) ] + attachPrevTxs(encoder, utxos, NETWORK) await encoder.createTransaction( utxos, ADDRESS, null, diff --git a/test/chaos/input_corruption.test.js b/test/chaos/input_corruption.test.js index 3c35184..5f5d8b5 100644 --- a/test/chaos/input_corruption.test.js +++ b/test/chaos/input_corruption.test.js @@ -22,7 +22,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const { TXID_A, TXID_B, PUBKEY_BUF, - makeUtxo, makeMempoolUtxo, makeEncoder, getTestAddress, buildRawTxHex + makeUtxo, makeMempoolUtxo, makeEncoder, getTestAddress, buildRawTxHex, attachPrevTxs } = require('../integration/helpers/utxoFactory') const actions = require('../integration/helpers/actionFactory') @@ -39,6 +39,7 @@ function defineArithmeticLimitCases () { it('value=0 → INSUFFICIENT_FUNDS, available reported as 0', async () => { const encoder = makeEncoder(DOGE) const utxo = makeUtxo(DOGE, TXID_A, 0, 0) + attachPrevTxs(encoder, [utxo], DOGE) await assert.rejects( () => encoder.createTransaction( @@ -57,6 +58,7 @@ function defineArithmeticLimitCases () { it('value=1 → INSUFFICIENT_FUNDS rather than negative change', async () => { const encoder = makeEncoder(DOGE) const utxo = makeUtxo(DOGE, TXID_A, 0, 1) + attachPrevTxs(encoder, [utxo], DOGE) await assert.rejects( () => encoder.createTransaction( diff --git a/test/chaos/resource_exhaustion.test.js b/test/chaos/resource_exhaustion.test.js index fec1682..dec7061 100644 --- a/test/chaos/resource_exhaustion.test.js +++ b/test/chaos/resource_exhaustion.test.js @@ -21,7 +21,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const { TXID_A, makeUtxo, makeLegacyUtxo, - makeEncoder, getTestAddress, buildRawTxHex + makeEncoder, getTestAddress, buildPrevTxHexByTxid, attachPrevTxs } = require('../integration/helpers/utxoFactory') const actions = require('../integration/helpers/actionFactory') @@ -92,6 +92,7 @@ describe('Chaos Category E: Resource Exhaustion', () => { const txid = TXID_A.slice(0, 60) + String(i).padStart(4, '0') utxos.push(makeUtxo(DOGE, txid, 0, 1000)) } + attachPrevTxs(encoder, utxos, DOGE) const start = Date.now() const result = await encoder.createTransaction( @@ -109,18 +110,18 @@ describe('Chaos Category E: Resource Exhaustion', () => { it('500 legacy UTXOs: getTransactionHex called for each consumed', async () => { const encoder = makeEncoder(DOGE) - const rawHex = buildRawTxHex(100, DOGE) - let hexCallCount = 0 - encoder.connector.getTransactionHex = async () => { - hexCallCount++ - return rawHex - } - const utxos = [] for (let i = 0; i < 500; i++) { const txid = TXID_A.slice(0, 60) + String(i).padStart(4, '0') utxos.push(makeLegacyUtxo(txid, 0, 1000)) } + // Each prev tx pays the 1000 its UTXO states, so the prev-out value check passes. + const hexByTxid = buildPrevTxHexByTxid(utxos, DOGE) + let hexCallCount = 0 + encoder.connector.getTransactionHex = async (txid) => { + hexCallCount++ + return hexByTxid.get(txid) + } const start = Date.now() const result = await encoder.createTransaction( diff --git a/test/conformance/utxo_record_conformance.test.js b/test/conformance/utxo_record_conformance.test.js index d034d15..357df52 100644 --- a/test/conformance/utxo_record_conformance.test.js +++ b/test/conformance/utxo_record_conformance.test.js @@ -215,8 +215,10 @@ describe('utxo-record conformance fixture: the tracker record passes both inboun mutate(entry) stubTrackerServing([entry], fixture.sync) const tracker = new UtxoTracker('127.0.0.1', 18420) + // Match the gate's own refusal text, and require a plain Error: a malformed + // tracker row is a server fault, and a TypeError would read as bad params. await assert.rejects(() => tracker.getUtxosFromAddress(fixture.address), - (err) => err instanceof TypeError, + (err) => !(err instanceof TypeError) && /UTXO tracker returned malformed utxo at index 0/.test(err.message), `the shape gate accepted a record whose ${why}`) }) }) diff --git a/test/integration/helpers/utxoFactory.js b/test/integration/helpers/utxoFactory.js index 316da69..46d093e 100644 --- a/test/integration/helpers/utxoFactory.js +++ b/test/integration/helpers/utxoFactory.js @@ -94,6 +94,62 @@ function buildRawTxHex (value, networkName) { return tx.toHex() } +// Value the padding outputs below a fixture's vout carry. Never read by input +// selection; it only keeps outs[vout] at the index the UTXO names. +const FILLER_VALUE = 546 + +/** + * Build one previous transaction per txid whose outs[vout] pays exactly what + * each listed UTXO states, so input selection's prev-out value check agrees. + * Returns a Map of txid to raw hex; lower vouts are padded with filler outputs + * and several vouts of one txid share a transaction. + */ +function buildPrevTxHexByTxid (utxos, networkName) { + const network = networkName + ? CryptoNetworks.getBitcoinJsNetwork(networkName) + : bitcoin.networks.regtest + const script = bitcoin.payments.p2pkh({ pubkey: PUBKEY_BUF, network }).output + const valuesByTxid = new Map() + for (const utxo of utxos) { + const values = valuesByTxid.get(utxo.txid) || new Map() + const stated = BigInt(toSatoshiString(utxo.value)) + if (values.has(utxo.vout) && values.get(utxo.vout) !== stated) { + throw new Error(`fixture lists ${utxo.txid}:${utxo.vout} twice with different values`) + } + values.set(utxo.vout, stated) + valuesByTxid.set(utxo.txid, values) + } + const hexByTxid = new Map() + let seed = 0 + for (const [txid, values] of valuesByTxid) { + const tx = new bitcoin.Transaction() + // Vary the spent outpoint so each fixture txid decodes to a distinct tx. + tx.addInput(Buffer.alloc(32, 0x11), seed++) + const lastVout = Math.max(...values.keys()) + for (let vout = 0; vout <= lastVout; vout++) { + const big = values.has(vout) ? values.get(vout) : BigInt(FILLER_VALUE) + // The bufferutils patch writes a BigInt only above 2^53-1; keep Numbers below it. + tx.addOutput(script, big <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(big) : big) + } + hexByTxid.set(txid, tx.toHex()) + } + return hexByTxid +} + +/** + * Point encoder.connector.getTransactionHex at per-txid previous transactions + * built from `utxos`, and return the lookup map. An unlisted txid rejects, so a + * fixture that forgets a UTXO fails loudly instead of reading a stranger's tx. + */ +function attachPrevTxs (encoder, utxos, networkName) { + const hexByTxid = buildPrevTxHexByTxid(utxos, networkName) + encoder.connector.getTransactionHex = async (txid) => { + if (!hexByTxid.has(txid)) throw new Error(`no previous-tx fixture for txid ${txid}`) + return hexByTxid.get(txid) + } + return hexByTxid +} + /** * Create a SegWit (P2WPKH) UTXO fixture. */ @@ -197,6 +253,8 @@ module.exports = { TXID_C, TXID_MULTISIGN, buildRawTxHex, + buildPrevTxHexByTxid, + attachPrevTxs, makeSegwitUtxo, makeLegacyUtxo, makeUtxo, diff --git a/test/regression/reg_08_api_contract.test.js b/test/regression/reg_08_api_contract.test.js index 07bbbf1..d70995b 100644 --- a/test/regression/reg_08_api_contract.test.js +++ b/test/regression/reg_08_api_contract.test.js @@ -25,7 +25,8 @@ const { TXID_A, makeUtxo, makeEncoder, - getTestAddress + getTestAddress, + attachPrevTxs } = require('../integration/helpers/utxoFactory') const actions = require('../integration/helpers/actionFactory') @@ -34,7 +35,8 @@ const NETWORK = 'dogecoin-regtest' /** * Simulate the api.js create_tx handler flow: * 1. validateAll(rawParams): may throw TypeError/RangeError (→ -32602) - * 2. encoder.createTransaction(validated): may throw Error (→ -32603) + * 2. encoder.createTransaction(validated): may throw TypeError/RangeError + * (→ -32602), an operational error (→ -32010) or plain Error (→ -32603) * 3. return { psbt: psbt.toHex(), encoding } */ async function simulateCreateTx (rawParams, encoder) { @@ -87,14 +89,16 @@ describe('REG-08: API Contract Regression', function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) const action = actions.makeSend() + const utxos = [ + makeUtxo(NETWORK, TXID_A, 0, 50000000), + makeUtxo(NETWORK, TXID_A, 1, 50000000) + ] + attachPrevTxs(encoder, utxos, NETWORK) const result = await simulateCreateTx({ data: action.data, pubkey: address, - utxos: [ - makeUtxo(NETWORK, TXID_A, 0, 50000000), - makeUtxo(NETWORK, TXID_A, 1, 50000000) - ], + utxos, fee: '10000', change: address, feePerKb: 0.00001 diff --git a/test/regression/reg_32_create_tx_error_code_split.test.js b/test/regression/reg_32_create_tx_error_code_split.test.js new file mode 100644 index 0000000..4016160 --- /dev/null +++ b/test/regression/reg_32_create_tx_error_code_split.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 +// +// 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. +// +// Pins the error-code registry split on the build path: -32602 means "fix the +// params, do not retry" and -32603 means "retry with backoff". Each case runs +// the real build behind a fresh controller and a stubbed node and tracker. + +const assert = require('assert') +const { createJsonRpcController } = require('../../src/api/json_rpc_methods') +const { TXID_A, PUBKEY_BUF, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') +const actions = require('../integration/helpers/actionFactory') + +const NETWORK = 'bitcoin-regtest' +const address = getTestAddress(NETWORK) + +// Run one JSON-RPC method against a fresh stubbed encoder and return its rejection. +async function rejectionOf (method, mutate, params) { + const encoder = makeEncoder(NETWORK) + if (mutate) mutate(encoder) + const controller = createJsonRpcController({ encoder, NETWORK }) + try { + await controller[method](params) + } catch (e) { + return e + } + assert.fail(`${method} should reject`) +} + +// A create_tx request the validator accepts, with the given overrides. +function sendParams (extra) { + return Object.assign({ pubkey: address, data: actions.makeSend().data, change: address, unconfirmed: true }, extra) +} + +// Report a node that answers getnetworkinfo without a usable relay floor. +function noRelayFee (encoder) { + encoder.connector.getNetworkInfo = async () => ({ relayfee: 0 }) +} + +describe('REG-32: create_tx answers a caller fault with -32602', () => { + it('keeps the reason for a fee below the node relay minimum', async () => { + const caught = await rejectionOf('create_tx', null, sendParams({ fee: 1 })) + assert.strictEqual(caught.code, -32602) + assert.ok(/below the node relay minimum/.test(caught.message), caught.message) + }) + + it('keeps the message of a TypeError the build raises', async () => { + const caught = await rejectionOf('create_tx', (encoder) => { + encoder.createTransaction = async () => { throw new TypeError('Unknown encoding: "FOO"') } + }, sendParams()) + assert.strictEqual(caught.code, -32602) + assert.strictEqual(caught.message, 'Unknown encoding: "FOO"') + }) +}) + +describe('REG-32: create_tx answers a server fault with -32603 or a tracker error', () => { + it('answers -32603, generic, when the node reports no relayfee', async () => { + const caught = await rejectionOf('create_tx', noRelayFee, sendParams()) + assert.strictEqual(caught.code, -32603) + assert.strictEqual(caught.message, 'Internal encoder error') + }) + + it('answers UTXO_TRACKER_ERROR, never -32602, for a malformed tracker row', async () => { + const caught = await rejectionOf('create_tx', (encoder) => { + encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ + utxos: [{ txid: TXID_A, vout: -1, value: 100000000, scriptPubKey: '0014' + '11'.repeat(20), confirmations: 1 }] + }) + }, sendParams()) + assert.strictEqual(caught.code, -32010) + assert.strictEqual(caught.data.reason, 'UTXO_TRACKER_ERROR') + }) +}) + +describe('REG-32: create_envelope_cancel_tx draws the same line as create_tx', () => { + it('answers -32602 with the reason for a feePerKb below the relay minimum', async () => { + const caught = await rejectionOf('create_envelope_cancel_tx', null, { + commitTxid: TXID_A, + commitVout: 0, + commitValue: 100000, + internalPubkey: PUBKEY_BUF.toString('hex'), + tapleafHash: 'c'.repeat(64), + destination: address, + feePerKb: 1 + }) + assert.strictEqual(caught.code, -32602) + assert.ok(/below the node relay minimum/.test(caught.message), caught.message) + }) +}) + +describe('REG-32: the other build paths keep node faults off -32602', () => { + it('answers -32603 from create_envelope_cancel_tx when the node reports no relayfee', async () => { + const caught = await rejectionOf('create_envelope_cancel_tx', noRelayFee, { + commitTxid: TXID_A, + commitVout: 0, + commitValue: 100000, + internalPubkey: PUBKEY_BUF.toString('hex'), + tapleafHash: 'c'.repeat(64), + destination: address + }) + assert.strictEqual(caught.code, -32603) + assert.strictEqual(caught.message, 'Internal encoder error') + }) + + it('raises a plain Error, not a RangeError, from the output-uplift invariant', () => { + const encoder = makeEncoder(NETWORK) + assert.throws(() => encoder.raiseOutputValue(null, 0, 0), + (err) => err.constructor === Error && /output uplift must be a positive integer/.test(err.message)) + }) +}) diff --git a/test/regression/reg_13_reservation_release_on_throw.test.js b/test/regression/reservations.test/reg_13_reservation_release_on_throw.test.js similarity index 94% rename from test/regression/reg_13_reservation_release_on_throw.test.js rename to test/regression/reservations.test/reg_13_reservation_release_on_throw.test.js index 354e0cb..f9d821d 100644 --- a/test/regression/reg_13_reservation_release_on_throw.test.js +++ b/test/regression/reservations.test/reg_13_reservation_release_on_throw.test.js @@ -21,9 +21,9 @@ const assert = require('assert') const { - makeEncoder, makeUtxo, getTestAddress, TXID_A, TXID_B -} = require('../integration/helpers/utxoFactory') -const actions = require('../integration/helpers/actionFactory') + makeEncoder, makeUtxo, getTestAddress, attachPrevTxs, TXID_A, TXID_B +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') const NETWORK = 'dogecoin-regtest' const FIVE_MINUTES = 5 * 60 * 1000 @@ -36,11 +36,11 @@ function ins0Txid (result) { // TXID_A is the earlier-sorted candidate and TXID_B the later one. function twoUtxoEncoder (value = 100000000) { const encoder = makeEncoder(NETWORK) + const utxos = () => [makeUtxo(NETWORK, TXID_A, 0, value), makeUtxo(NETWORK, TXID_B, 0, value)] encoder.utxoTrackerConnector = { - getUtxosFromAddress: async () => ({ - utxos: [makeUtxo(NETWORK, TXID_A, 0, value), makeUtxo(NETWORK, TXID_B, 0, value)] - }) + getUtxosFromAddress: async () => ({ utxos: utxos() }) } + attachPrevTxs(encoder, utxos(), NETWORK) return encoder } diff --git a/test/regression/reg_18_chained_send_reservation.test.js b/test/regression/reservations.test/reg_18_chained_send_reservation.test.js similarity index 99% rename from test/regression/reg_18_chained_send_reservation.test.js rename to test/regression/reservations.test/reg_18_chained_send_reservation.test.js index b63b05a..d8fd446 100644 --- a/test/regression/reg_18_chained_send_reservation.test.js +++ b/test/regression/reservations.test/reg_18_chained_send_reservation.test.js @@ -31,7 +31,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib'); const { makeEncoder, makeUtxo, getTestAddress, TXID_A, TXID_B, TXID_C -} = require('../integration/helpers/utxoFactory') +} = require('../../integration/helpers/utxoFactory') const NETWORK = 'bitcoin-regtest' const MINT = 'MINT|0|XCHAIN|10000' diff --git a/test/regression/reg_19_envelope_cancel_reservation.test.js b/test/regression/reservations.test/reg_19_envelope_cancel_reservation.test.js similarity index 99% rename from test/regression/reg_19_envelope_cancel_reservation.test.js rename to test/regression/reservations.test/reg_19_envelope_cancel_reservation.test.js index e456846..fc1c7e3 100644 --- a/test/regression/reg_19_envelope_cancel_reservation.test.js +++ b/test/regression/reservations.test/reg_19_envelope_cancel_reservation.test.js @@ -29,8 +29,8 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const ecc = require('tiny-secp256k1') const { ECPairFactory } = require('ecpair') -const XChainEncoder = require('../../src/XChainEncoder') -const actions = require('../integration/helpers/actionFactory') +const XChainEncoder = require('../../../src/XChainEncoder') +const actions = require('../../integration/helpers/actionFactory') bitcoin.initEccLib(ecc) const ECPair = ECPairFactory(ecc) diff --git a/test/regression/reg_31_shortfall_names_reserved_inputs.test.js b/test/regression/reservations.test/reg_31_shortfall_names_reserved_inputs.test.js similarity index 98% rename from test/regression/reg_31_shortfall_names_reserved_inputs.test.js rename to test/regression/reservations.test/reg_31_shortfall_names_reserved_inputs.test.js index b177faa..d45e4ca 100644 --- a/test/regression/reg_31_shortfall_names_reserved_inputs.test.js +++ b/test/regression/reservations.test/reg_31_shortfall_names_reserved_inputs.test.js @@ -29,7 +29,7 @@ const assert = require('assert') const { makeEncoder, makeUtxo, getTestAddress, TXID_A, TXID_B, TXID_C -} = require('../integration/helpers/utxoFactory') +} = require('../../integration/helpers/utxoFactory') const NETWORK = 'bitcoin-regtest' const SEND = 'SEND|0|XCHAIN|1|' + getTestAddress(NETWORK) diff --git a/test/security/concurrency_gate.test.js b/test/security/concurrency_gate.test.js index 6a315ce..f51d506 100644 --- a/test/security/concurrency_gate.test.js +++ b/test/security/concurrency_gate.test.js @@ -109,7 +109,7 @@ describe('Security: global in-flight concurrency cap', function () { // Repeat the abort and the cap admits work it has already counted out, // with in_flight reading zero. hold() binds the slot to the handler's // promise instead. - const { server, gate, release, enteredHeld } = buildServer({ limit: 1 }) + const { server, gate, release, enteredHeld, closedHeld } = buildServer({ limit: 1 }) await listen(server) const controller = new AbortController() @@ -119,13 +119,13 @@ describe('Security: global in-flight concurrency cap', function () { controller.abort() await aborted.catch(() => {}) - // Deliberate delay, NOT a synchronization point: do not convert this to - // waitFor. The claim is that in_flight STAYS 1 across a window in which - // 'close' had every chance to fire and be ignored, so the elapsed time IS - // the measurement. A predicate on in_flight === 1 already holds on entry - // and would return on its first tick, asserting nothing; without the - // wrapper the slot is already back by the end of this window. - await new Promise(r => setTimeout(r, 50)) + // Never poll on in_flight === 1 here: it already holds on entry, so the + // wait would return at once and assert nothing. Wait on the server seeing + // the socket close instead. That one emit also ran the gate's 'close' + // listener, so in_flight is read after the gate has handled the close + // and, with hold(), ignored it; without the wrapper the slot is already + // back by then (the negative control below proves it). + await waitFor(() => closedHeld() >= 1, "the server to see the client's socket close") assert.strictEqual(gate.getStats().in_flight, 1) // The behavioural half: the next caller is refused because the work the @@ -148,7 +148,7 @@ describe('Security: global in-flight concurrency cap', function () { // The same scenario against a pass-through wrapper, which is exactly the // pre-fix gate. If this ever goes green the assertion above has stopped // measuring anything. - const { server, gate, enteredHeld } = buildServer({ limit: 1, stubHold: true }) + const { server, gate, enteredHeld, closedHeld } = buildServer({ limit: 1, stubHold: true }) await listen(server) const controller = new AbortController() @@ -158,7 +158,12 @@ describe('Security: global in-flight concurrency cap', function () { controller.abort() await aborted.catch(() => {}) - await waitFor(() => gate.getStats().in_flight === 0, 'the socket close to free the slot') + // Same sync point as the held-slot test above. The slot must already be + // free once the handler has seen the close; if not, the sync point no + // longer implies the gate handled it and that test has stopped measuring. + await waitFor(() => closedHeld() >= 1, "the server to see the client's socket close") + assert.strictEqual(gate.getStats().in_flight, 0, + 'the gate must have handled the close by the time the handler saw it') // Over-admission: a second handler now runs the same expensive work the // cap of 1 was meant to forbid. It is never awaited, because it parks in diff --git a/test/security/concurrency_gate.test/03_probe_variants.test.js b/test/security/concurrency_gate.test/03_probe_variants.test.js index 26a19fe..abacdb8 100644 --- a/test/security/concurrency_gate.test/03_probe_variants.test.js +++ b/test/security/concurrency_gate.test/03_probe_variants.test.js @@ -53,7 +53,7 @@ describe('Security: probe variants Express routes to the probe handlers', functi }) it('holds the probe-reserve slot of an aborted HEAD /status until its handler settles', async function () { - const { server, probeGate, releaseProbe, enteredProbe } = buildServer({ + const { server, probeGate, releaseProbe, enteredProbe, closedProbe } = buildServer({ limit: 10, probeLimit: 1, parkProbes: true }) await listen(server) @@ -65,8 +65,8 @@ describe('Security: probe variants Express routes to the probe handlers', functi controller.abort() await aborted.catch(() => {}) - // Deliberate delay, not a sync point: the claim is that in_flight STAYS 1. - await new Promise(r => setTimeout(r, 50)) + // Wait on the server seeing the close, never on in_flight: the claim is that it STAYS 1. + await waitFor(() => closedProbe() >= 1, "the server to see the probe's socket close") assert.strictEqual(probeGate.getStats().in_flight, 1) releaseProbe() diff --git a/test/security/helpers/concurrency_gate_harness.js b/test/security/helpers/concurrency_gate_harness.js index ff947cf..d26af57 100644 --- a/test/security/helpers/concurrency_gate_harness.js +++ b/test/security/helpers/concurrency_gate_harness.js @@ -102,8 +102,13 @@ function mountRoutes(app, gates, options){ // pass-through, and that IS the pre-fix gate, so the held-slot assertions // below get a negative control instead of a second flavour of one route. let heldEntered = 0 + // Count the socket closes each handler sees: the sync point for abort tests. + // One 'close' emit runs every listener synchronously, and the gate's was added + // in middleware before the abort, so a moved count means the gate has handled it. + let heldClosed = 0 const wrap = options.stubHold ? (fn) => fn : gate.hold app.get('/held', wrap(async (req, res) => { + res.on('close', () => { heldClosed++ }) heldEntered++ await held res.json({ ok: true, ip: req.ip }) @@ -113,8 +118,10 @@ function mountRoutes(app, gates, options){ // be made to park exactly like an expensive route; opts in per test. It is // wrapped in probeGate.hold() the way api.js mounts it. let probeEntered = 0 + let probeClosed = 0 const wrapProbe = options.stubHold ? (fn) => fn : probeGate.hold app.get('/status', wrapProbe(async (req, res) => { + res.on('close', () => { probeClosed++ }) probeEntered++ if(options.parkProbes) await heldProbe res.json({ status: 'healthy' }) @@ -124,6 +131,7 @@ function mountRoutes(app, gates, options){ return { arrivals: () => expensiveArrivals, enteredHeld: () => heldEntered, enteredProbe: () => probeEntered, + closedHeld: () => heldClosed, closedProbe: () => probeClosed, release: () => releaseHeld(), releaseProbe: () => releaseProbe() } } diff --git a/test/unit/blockchain_connector.test/03_get_transaction_hex.test.js b/test/unit/blockchain_connector.test/03_get_transaction_hex.test.js index 7944ab8..b7fab4e 100644 --- a/test/unit/blockchain_connector.test/03_get_transaction_hex.test.js +++ b/test/unit/blockchain_connector.test/03_get_transaction_hex.test.js @@ -121,3 +121,73 @@ describe('BlockchainConnector.getTransactionHex()', () => { assert.strictEqual(capturedOptions.auth.password, 'rpcpass') }) }) + +// LTC/DOGE answer RPC-level errors as HTTP 500 with a JSON-RPC body, so axios +// throws; build that error the way axios does, credentials attached. +function http500 (code, message) { + const err = new Error('Request failed with status code 500') + err.code = 'ERR_BAD_RESPONSE' + err.config = { auth: { username: 'rpcuser', password: 'FAKEPASS_must_never_be_logged_03' } } + err.response = { status: 500, data: { error: { code, message } } } + return err +} + +describe('BlockchainConnector.getTransactionHex() node RPC error detail', () => { + registerAxiosHooks() + const { upstreamErrorMessage } = require('../../../src/common/error_sanitize') + + it('keeps the code and message of an HTTP 500 -28 answer', async () => { + stubAxiosPostThrow(http500(-28, 'Loading block index...')) + const c = makeConnector() + let thrown + try { await c.getTransactionHex(TXID) } catch (e) { thrown = e } + assert.ok(thrown, 'should reject') + assert.match(thrown.message, /status code 500 \(RPC error -28: Loading block index\.\.\.\)/) + assert.ok(!thrown.message.includes('FAKEPASS'), 'no credential in the message') + assert.strictEqual(thrown.code, undefined, 'a plain Error, not the axios ERR_BAD_RESPONSE') + assert.notStrictEqual(upstreamErrorMessage(thrown, 'FALLBACK'), 'FALLBACK') + }) + + it('keeps the code and message of an HTTP 500 -8 answer', async () => { + stubAxiosPostThrow(http500(-8, 'parameter 1 must be hexadecimal string')) + const c = makeConnector() + await assert.rejects(() => c.getTransactionHex(TXID), + /\(RPC error -8: parameter 1 must be hexadecimal string\)/) + }) + + it('still gives the txindex hint for an HTTP 500 -5 answer', async () => { + stubAxiosPostThrow(http500(-5, 'No such mempool or blockchain transaction')) + const c = makeConnector() + await assert.rejects(() => c.getTransactionHex(TXID), /not found.*txindex/) + }) + + it('keeps the code and message of an HTTP 200 error body', async () => { + stubAxiosPost({ data: { result: null, error: { code: -28, message: 'Loading block index...' } } }) + const c = makeConnector() + await assert.rejects(() => c.getTransactionHex(TXID), + /Error getting transaction hex \(RPC error -28: Loading block index\.\.\.\)/) + }) + + it('logs the node reason rather than a bare status code', async () => { + stubAxiosPostThrow(http500(-28, 'Loading block index...')) + const c = makeConnector() + const originalError = console.error + const logs = [] + console.error = (...args) => { logs.push(args.join(' ')) } + try { + await c.getTransactionHex(TXID).catch(() => {}) + } finally { + console.error = originalError + } + const combined = logs.join('\n') + assert.ok(combined.includes('RPC error -28: Loading block index...'), 'log should carry the node reason (got: ' + combined + ')') + assert.ok(!combined.includes('FAKEPASS'), 'no credential in the log') + }) + + it('rethrows a bodiless transport failure as the original error object', async () => { + const original = new Error('socket hang up') + stubAxiosPostThrow(original) + const c = makeConnector() + await assert.rejects(() => c.getTransactionHex(TXID), (err) => err === original) + }) +}) diff --git a/test/unit/blockchain_connector.test/09_get_block_count.test.js b/test/unit/blockchain_connector.test/09_get_block_count.test.js new file mode 100644 index 0000000..f1d32ef --- /dev/null +++ b/test/unit/blockchain_connector.test/09_get_block_count.test.js @@ -0,0 +1,75 @@ +// 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 axios = require('axios') +const BlockchainConnector = require('../../../src/build/blockchain_connector') +const XChainEncoder = require('../../../src/XChainEncoder') + +function makeConnector () { + return new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', 'rpcpass') +} + +describe('BlockchainConnector.getBlockCount()', () => { + let originalPost + beforeEach(() => { originalPost = axios.post }) + afterEach(() => { axios.post = originalPost }) + + it('sends getblockcount', async () => { + let captured + axios.post = async (url, data) => { captured = data; return { data: { result: 1 } } } + await makeConnector().getBlockCount() + assert.strictEqual(captured.method, 'getblockcount') + }) + + it('returns a real integer tip, including a genesis tip of 0', async () => { + axios.post = async () => ({ data: { result: 960900 } }) + assert.strictEqual(await makeConnector().getBlockCount(), 960900) + axios.post = async () => ({ data: { result: 0 } }) + assert.strictEqual(await makeConnector().getBlockCount(), 0) + }) + + // Each of these used to coerce through Number() into a finite tip. + const unreadable = [ + ['result null', { data: { result: null } }], + ['result empty string', { data: { result: '' } }], + ['result false', { data: { result: false } }], + ['result true', { data: { result: true } }], + ['result numeric string', { data: { result: '960900' } }], + ['result fraction', { data: { result: 1.5 } }], + ['result negative', { data: { result: -1 } }], + ['no result key', { data: {} }], + ['empty body', { data: '' }], + ['undefined body', { data: undefined }], + ['undefined response', undefined], + ['error body with null result', { data: { result: null, error: { code: -28, message: 'Loading block index...' } } }], + ['error body beside a result', { data: { result: 960900, error: { code: -1, message: 'x' } } }], + ] + for (const [label, response] of unreadable) { + it(`returns null for ${label}`, async () => { + axios.post = async () => response + assert.strictEqual(await makeConnector().getBlockCount(), null) + }) + } + + it('returns null when the request throws', async () => { + axios.post = async () => { throw new Error('socket hang up') } + assert.strictEqual(await makeConnector().getBlockCount(), null) + }) + + it('makes the envelope gate report an unknown tip, not a countdown, for result null', async () => { + axios.post = async () => ({ data: { result: null } }) + const ctx = { networkKey: 'bitcoin-mainnet', connector: makeConnector() } + await assert.rejects( + () => XChainEncoder.prototype.assertEnvelopeRecognized.call(ctx), + (e) => e.xchainCode === 'ENVELOPE_RECOGNITION_UNKNOWN' + ) + }) +}) diff --git a/test/unit/blockchain_connector.test/10_sanitize_rpc_error_parity.test.js b/test/unit/blockchain_connector.test/10_sanitize_rpc_error_parity.test.js new file mode 100644 index 0000000..1f3c28d --- /dev/null +++ b/test/unit/blockchain_connector.test/10_sanitize_rpc_error_parity.test.js @@ -0,0 +1,100 @@ +// 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 util = require('util') +const { sanitizeRpcError, rpcErrorDetail } = require('../../../src/build/blockchain_connector/rpc_helpers') + +const FAKE_RPC_PASSWORD = 'FAKEPASS_must_never_be_logged_10' + +// Build each failure shape fresh, since sanitizeRpcError scrubs in place. +const CASES = { + 'HTTP 401 with a string body': () => { + const err = new Error('Request failed with status code 401') + err.config = { + auth: { username: 'rpcuser', password: FAKE_RPC_PASSWORD }, + headers: { Authorization: 'Basic ' + Buffer.from('rpcuser:' + FAKE_RPC_PASSWORD).toString('base64') } + } + err.request = { _header: 'Authorization: Basic x' } + err.response = { status: 401, data: 'unauthorized', config: err.config } + return err + }, + 'HTTP 500 with a -28 JSON-RPC body': () => { + const err = new Error('Request failed with status code 500') + err.config = { auth: { username: 'rpcuser', password: FAKE_RPC_PASSWORD }, headers: {} } + err.response = { status: 500, data: { error: { code: -28, message: 'Loading block index...' } } } + return err + }, + 'HTTP 500 with a code-only JSON-RPC body': () => { + const err = new Error('Request failed with status code 500') + err.response = { status: 500, data: { error: { code: -8 } } } + return err + }, + 'a transport failure with no response': () => new Error('socket hang up'), + 'a response with no status': () => { + const err = new Error('odd') + err.response = { data: 'x' } + return err + }, +} + +// Assert the credential scrub left nothing that util.inspect could print. +function assertScrubbed (err) { + assert.strictEqual(err.config ? err.config.auth : undefined, undefined) + assert.ok(!(err.config && err.config.headers && err.config.headers.Authorization)) + assert.strictEqual(err.request, undefined) + if (err.response !== undefined) assert.deepStrictEqual(Object.keys(err.response), ['status']) + assert.ok(!util.inspect(err, { depth: 8 }).includes(FAKE_RPC_PASSWORD)) +} + +describe('sanitizeRpcError() credential scrub', () => { + for (const [label, make] of Object.entries(CASES)) { + it(`scrubs ${label}`, () => { + const err = make() + assert.strictEqual(typeof sanitizeRpcError(err), 'string') + assertScrubbed(err) + }) + } +}) + +describe('sanitizeRpcError() parity with the xchain-decoder twin', function () { + const DECODER = process.env.XCHAIN_DECODER_DIR || path.join(__dirname, '../../../../xchain-decoder') + const TWIN = path.join(DECODER, 'src', 'chain', 'blockchain_connector', 'rpc_helpers.js') + let twin + + before(function () { + try { + if (!fs.existsSync(TWIN)) throw new Error('not found at ' + TWIN) + twin = require(TWIN) + } catch (e) { + if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') { + throw new Error('xchain-decoder sanitizeRpcError twin unavailable but XCHAIN_REQUIRE_SIBLINGS=1: ' + e.message) + } + this.skip() + } + }) + + for (const [label, make] of Object.entries(CASES)) { + it(`scrubs the same fields and reads the same text for ${label}`, () => { + const ours = make() + const theirs = make() + // This copy leaves the node's reason to rpcErrorDetail, read before the scrub. + const detail = rpcErrorDetail(ours.response && ours.response.data) + const ourText = sanitizeRpcError(ours) + detail + const theirText = twin.sanitizeRpcError(theirs) + assert.strictEqual(ourText, theirText) + assertScrubbed(ours) + assertScrubbed(theirs) + assert.deepStrictEqual(ours.response, theirs.response) + }) + } +}) diff --git a/test/unit/build/apply_bufferutils_patch.test.js b/test/unit/build/apply_bufferutils_patch.test.js index c2afff6..dbf5554 100644 --- a/test/unit/build/apply_bufferutils_patch.test.js +++ b/test/unit/build/apply_bufferutils_patch.test.js @@ -91,6 +91,9 @@ describe('applyBufferutilsPatch', function () { [-1n, /specified a negative value for writing an unsigned value/], [-Infinity, /specified a negative value for writing an unsigned value/], [Infinity, /value out of range/], + [2 ** 53, /value out of range/], + [2 ** 60, /value out of range/], + [1e16 + 1, /value out of range/], [1e30, /value out of range/], [0.5, /value has a fractional component/], [NaN, /value has a fractional component/] @@ -107,6 +110,32 @@ describe('applyBufferutilsPatch', function () { }); }); +describe('applyBufferutilsPatch', function () { + // A Number past 2^53-1 has already lost its exact value, so every writer refuses it + // as stock verifuint does, while the same amount as a BigInt still serializes. + it('caps a Number at 2^53-1 on every writer while a BigInt passes', function () { + const buf = Buffer.alloc(8); + bufferutils.writeUInt64LE(buf, 9007199254740991, 0); + assert.strictEqual(bufferutils.readUInt64LE(buf, 0), 9007199254740991); + bufferutils.writeUInt64LE(buf, 9007199254740992n, 0); + assert.strictEqual(bufferutils.readUInt64LE(buf, 0), 9007199254740992n); + assert.throws(() => new bufferutils.BufferWriter(Buffer.alloc(8)).writeUInt64(2 ** 53), /value out of range/); + const bip174Tools = require('bip174/src/lib/converter/tools'); + assert.throws(() => bip174Tools.writeUInt64LE(Buffer.alloc(8), 2 ** 53, 0), /value out of range/); + }); + + it('refuses a PSBT witnessUtxo Number value past 2^53-1 and serializes its BigInt form', function () { + const { Psbt } = require('bitcoinjs-lib'); + const input = (value) => ({ hash: Buffer.alloc(32, 1), index: 0, + witnessUtxo: { script: Buffer.from('0014' + '11'.repeat(20), 'hex'), value } }); + assert.throws(() => { const psbt = new Psbt(); psbt.addInput(input(2 ** 53)); psbt.toBuffer(); }, + /value out of range/); + const psbt = new Psbt(); + psbt.addInput(input(9007199254740992n)); + assert.ok(psbt.toBuffer().length > 0); + }); +}); + describe('applyBufferutilsPatch', function () { // Fee-accounting wrapper: bitcoinjs-lib's stock cache getter tests __FEE / diff --git a/test/unit/build/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js b/test/unit/build/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js index 641598e..9573a71 100644 --- a/test/unit/build/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js +++ b/test/unit/build/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js @@ -41,10 +41,11 @@ function makeUtxo (txid, confirmations) { } // The whole previous transaction, which a legacy input carries as nonWitnessUtxo. -function prevTxHex () { +// Its output pays `value`, which must equal the UTXO's stated value. +function prevTxHex (value = INPUT_VALUE) { const tx = new bitcoin.Transaction() tx.addInput(Buffer.alloc(32, 0x11), 0) - tx.addOutput(P2PKH_SCRIPT, INPUT_VALUE) + tx.addOutput(P2PKH_SCRIPT, value) return tx.toHex() } @@ -249,6 +250,7 @@ describe('XChainEncoder package-aware fee sizing @regression @tier1', () => { const encoder = makeEncoder({ size: 2000, fees: 0 }) const utxo = makeUtxo(TXID_PARENT_A, 0) utxo.value = 400000 + encoder.connector.getTransactionHex = async () => prevTxHex(400000) const result = await encoder.createTransaction( [utxo], TEST_ADDRESS, null, 'test', null, null, false, null, TEST_ADDRESS, null, null, null, true, null diff --git a/test/unit/build/utxo_tracker.test/03_get_utxos_from_address_validation.test.js b/test/unit/build/utxo_tracker.test/03_get_utxos_from_address_validation.test.js index 256063b..ab88df3 100644 --- a/test/unit/build/utxo_tracker.test/03_get_utxos_from_address_validation.test.js +++ b/test/unit/build/utxo_tracker.test/03_get_utxos_from_address_validation.test.js @@ -50,7 +50,9 @@ describe('UtxoTracker.getUtxosFromAddress()', () => { ) }) - it('throws TypeError when utxos is not an array', async () => { + // Pin the class, not only the text: a malformed tracker reply is a server + // fault, and a TypeError here would read as "fix your params" at the API. + it('throws a plain Error when utxos is not an array', async () => { let callCount = 0 axios.post = async () => { callCount++ @@ -60,7 +62,7 @@ describe('UtxoTracker.getUtxosFromAddress()', () => { const t = makeTracker() await assert.rejects( () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /UTXO tracker result missing utxos array/ } + { name: 'Error', message: /UTXO tracker result missing utxos array/ } ) }) }) @@ -68,44 +70,44 @@ describe('UtxoTracker.getUtxosFromAddress()', () => { describe('UtxoTracker.getUtxosFromAddress()', () => { installAxiosHooks() - it('throws TypeError for malformed UTXO (missing txid)', async () => { + it('throws a plain Error for malformed UTXO (missing txid)', async () => { const badUtxo = makeUtxo() delete badUtxo.txid stubSyncedThenUtxos([badUtxo]) const t = makeTracker() await assert.rejects( () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /malformed utxo at index 0/ } + { name: 'Error', message: /malformed utxo at index 0/ } ) }) - it('throws TypeError for malformed UTXO (txid not a string)', async () => { + it('throws a plain Error for malformed UTXO (txid not a string)', async () => { const badUtxo = makeUtxo({ txid: 12345 }) stubSyncedThenUtxos([badUtxo]) const t = makeTracker() await assert.rejects( () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /malformed utxo at index 0/ } + { name: 'Error', message: /malformed utxo at index 0/ } ) }) - it('throws TypeError when txid is not a 64-char hex string (too short)', async () => { + it('throws a plain Error when txid is not a 64-char hex string (too short)', async () => { const badUtxo = makeUtxo({ txid: 'abc123' }) stubSyncedThenUtxos([badUtxo]) const t = makeTracker() await assert.rejects( () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /64-character hex string/ } + { name: 'Error', message: /64-character hex string/ } ) }) - it('throws TypeError when txid is 64 chars but not hex', async () => { + it('throws a plain Error when txid is 64 chars but not hex', async () => { const badUtxo = makeUtxo({ txid: 'z'.repeat(64) }) stubSyncedThenUtxos([badUtxo]) const t = makeTracker() await assert.rejects( () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /64-character hex string/ } + { name: 'Error', message: /64-character hex string/ } ) }) }) @@ -113,36 +115,36 @@ describe('UtxoTracker.getUtxosFromAddress()', () => { describe('UtxoTracker.getUtxosFromAddress()', () => { installAxiosHooks() - it('throws TypeError for malformed UTXO (missing vout)', async () => { + it('throws a plain Error for malformed UTXO (missing vout)', async () => { const badUtxo = makeUtxo() delete badUtxo.vout stubSyncedThenUtxos([badUtxo]) const t = makeTracker() await assert.rejects( () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /malformed utxo at index 0/ } + { name: 'Error', message: /malformed utxo at index 0/ } ) }) - it('throws TypeError for malformed UTXO (missing value)', async () => { + it('throws a plain Error for malformed UTXO (missing value)', async () => { const badUtxo = makeUtxo() delete badUtxo.value stubSyncedThenUtxos([badUtxo]) const t = makeTracker() await assert.rejects( () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /malformed utxo at index 0/ } + { name: 'Error', message: /malformed utxo at index 0/ } ) }) - it('throws TypeError when scriptPubKey is missing', async () => { + it('throws a plain Error when scriptPubKey is missing', async () => { const badUtxo = makeUtxo() delete badUtxo.scriptPubKey stubSyncedThenUtxos([badUtxo]) const t = makeTracker() await assert.rejects( () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /scriptPubKey must be a non-empty string/ } + { name: 'Error', message: /scriptPubKey must be a non-empty string/ } ) }) }) @@ -150,22 +152,22 @@ describe('UtxoTracker.getUtxosFromAddress()', () => { describe('UtxoTracker.getUtxosFromAddress()', () => { installAxiosHooks() - it('throws TypeError when scriptPubKey is an empty string', async () => { + it('throws a plain Error when scriptPubKey is an empty string', async () => { const badUtxo = makeUtxo({ scriptPubKey: '' }) stubSyncedThenUtxos([badUtxo]) const t = makeTracker() await assert.rejects( () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /scriptPubKey must be a non-empty string/ } + { name: 'Error', message: /scriptPubKey must be a non-empty string/ } ) }) - it('throws TypeError when a UTXO element is null', async () => { + it('throws a plain Error when a UTXO element is null', async () => { stubSyncedThenUtxos([null]) const t = makeTracker() await assert.rejects( () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /malformed utxo at index 0/ } + { name: 'Error', message: /malformed utxo at index 0/ } ) }) diff --git a/test/unit/common/validator/validator.test/03_validate_combined_data_length.test.js b/test/unit/common/validator/validator.test/03_validate_combined_data_length.test.js index 7334832..787bb0b 100644 --- a/test/unit/common/validator/validator.test/03_validate_combined_data_length.test.js +++ b/test/unit/common/validator/validator.test/03_validate_combined_data_length.test.js @@ -60,7 +60,7 @@ describe('Encoder input validator', function () { }); // Explicit encoding:"OP_RETURN" gets the tighter 76-byte ceiling // pre-compile, so an oversize request is rejected as invalid-params before - // any UTXO reservation instead of failing post-compile as -32603 internal. + // any UTXO reservation instead of failing post-compile after that work. it('rejects an explicit OP_RETURN payload above the 76-byte ceiling', function () { // 200 raw bytes compile to 200 + 2 (OP_PUSHDATA1) = 202 > 76. const data = 'x'.repeat(200); @@ -104,7 +104,7 @@ describe('Encoder input validator', function () { // pinned byte-for-byte against the decoder's copy. Only the envelope // ceiling reaches that band, and _buildTransaction refuses on the REAL // compiled buffer, so under-counting here by 2 per large push moved the - // boundary payload from this -32602 pre-check to a -32603 builder error. + // boundary payload from this pre-check to a post-reservation builder error. it('counts the OP_PUSHDATA4 band on a push past 65,535 bytes', function () { // The widest rawData whose real compiled size is exactly the ceiling: // OP_0 (1 byte, the empty data push) + rawLen + 5. diff --git a/test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js b/test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js index 2e3d4e8..cec1b31 100644 --- a/test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js +++ b/test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js @@ -81,3 +81,20 @@ describe('Encoder input validator', function () { }); }); }); + +describe('Encoder input validator: vout uint32 bound', function () { + const goodUtxo = () => ({ txid: HEX64, vout: 0, value: 1000, scriptPubKey: '76a914' }); + + it('rejects a vout above the uint32 wire width as a TypeError', function () { + // bitcoinjs refuses these deep in psbt.addInput with a non-TypeError, which the API reports as -32603. + for (const bad of [4294967296, '4294967296', '99999999999', 1e300]) { + assert.throws( + () => v.validateUtxoEntry({ ...goodUtxo(), vout: bad }, 0), + (err) => err instanceof TypeError && /vout must be a non-negative integer/.test(err.message), + `vout ${JSON.stringify(bad)} must be rejected` + ); + } + assert.strictEqual(v.validateUtxoEntry({ ...goodUtxo(), vout: 4294967295 }, 0).vout, 4294967295); + assert.strictEqual(v.validateUtxoEntry({ ...goodUtxo(), vout: '4294967295' }, 0).vout, 4294967295); + }); +}); diff --git a/test/unit/repo/sibling_coverage.test.js b/test/unit/repo/sibling_coverage.test.js index a6324b1..b6fb1cc 100644 --- a/test/unit/repo/sibling_coverage.test.js +++ b/test/unit/repo/sibling_coverage.test.js @@ -65,7 +65,7 @@ const SIBLINGS = [ guards: 'action-manifest conformance, taproot-envelope golden vectors, and the compression-parameter reference' }, { repo: 'xchain-decoder', envs: ['XCHAIN_DECODER_DIR'], marker: 'src', - guards: 'the envelope-recognition gate against the authoritative decoder' }, + guards: 'the envelope-recognition gate against the authoritative decoder, and sanitizeRpcError scrub parity' }, { repo: 'xchain-sdk', envs: ['XCHAIN_SDK_DIR'], marker: 'src', guards: 'compression-parameter twin parity' }, diff --git a/test/unit/xchain_encoder/prev_output_value_check.test/01_legacy_refusals.test.js b/test/unit/xchain_encoder/prev_output_value_check.test/01_legacy_refusals.test.js new file mode 100644 index 0000000..7aaca3f --- /dev/null +++ b/test/unit/xchain_encoder/prev_output_value_check.test/01_legacy_refusals.test.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. + +const assert = require('assert') +const { factory, DOGE, buildDoge, attachOnChainValues } = require('./helpers') +const { TXID_A, makeEncoder, makeLegacyUtxo, attachPrevTxs } = factory + +describe('prev-out value check: legacy refusals (caller-supplied)', () => { + it('refuses an understated value with a RangeError naming the outpoint and both amounts', async () => { + const encoder = makeEncoder(DOGE) + const utxo = makeLegacyUtxo(TXID_A, 0, 1000000) + attachOnChainValues(encoder, [utxo], 100000000, DOGE) + + await assert.rejects(() => buildDoge(encoder, [utxo]), (err) => { + assert.ok(err instanceof RangeError, `expected RangeError, got ${err && err.name}`) + assert.ok(err.message.includes(`${TXID_A}:0`), err.message) + assert.match(err.message, /states value 1000000 but the previous transaction pays 100000000/) + return true + }) + }) + + it('refuses an overstated value', async () => { + const encoder = makeEncoder(DOGE) + const utxo = makeLegacyUtxo(TXID_A, 0, 100000001) + attachOnChainValues(encoder, [utxo], 100000000, DOGE) + + await assert.rejects(() => buildDoge(encoder, [utxo]), (err) => { + assert.ok(err instanceof RangeError) + assert.match(err.message, /states value 100000001 but the previous transaction pays 100000000/) + return true + }) + }) + + it('refuses a vout past the end of the previous transaction', async () => { + const encoder = makeEncoder(DOGE) + // The prev tx only has outputs 0..1. + attachPrevTxs(encoder, [makeLegacyUtxo(TXID_A, 1, 100000000)], DOGE) + + await assert.rejects(() => buildDoge(encoder, [makeLegacyUtxo(TXID_A, 3, 100000000)]), (err) => { + assert.ok(err instanceof RangeError) + assert.match(err.message, new RegExp(`${TXID_A}:3 has no output at index 3`)) + return true + }) + }) + + it('refuses an undecodable previous transaction', async () => { + const encoder = makeEncoder(DOGE) + encoder.connector.getTransactionHex = async () => 'deadbeef' + + await assert.rejects(() => buildDoge(encoder, [makeLegacyUtxo(TXID_A, 0, 100000000)]), (err) => { + assert.ok(err instanceof RangeError) + assert.match(err.message, /has no output at index 0/) + return true + }) + }) +}) diff --git a/test/unit/xchain_encoder/prev_output_value_check.test/02_legacy_exact_amounts.test.js b/test/unit/xchain_encoder/prev_output_value_check.test/02_legacy_exact_amounts.test.js new file mode 100644 index 0000000..9598f43 --- /dev/null +++ b/test/unit/xchain_encoder/prev_output_value_check.test/02_legacy_exact_amounts.test.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. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { factory, DOGE, DOGE_ADDR, buildDoge, sumOutputs, paidFee } = require('./helpers') +const { TXID_A, TXID_B, makeEncoder, makeLegacyUtxo, attachPrevTxs } = factory + +describe('prev-out value check: legacy exact amounts', () => { + it('builds on a matching value, and the fee math uses that exact amount', async () => { + const encoder = makeEncoder(DOGE) + const utxo = makeLegacyUtxo(TXID_A, 1, 100000000) + attachPrevTxs(encoder, [utxo], DOGE) + + const result = await buildDoge(encoder, [utxo]) + const prev = bitcoin.Transaction.fromBuffer(result.psbt.data.inputs[0].nonWitnessUtxo) + assert.strictEqual(BigInt(prev.outs[1].value), 100000000n) + assert.strictEqual(sumOutputs(result.psbt) + paidFee(encoder), 100000000n, + 'outputs plus the paid fee must spend exactly the on-chain input') + }) + + it('names only the mismatched input in a multi-input build', async () => { + const encoder = makeEncoder(DOGE) + encoder.maxFeeRateMultiplier = null + const good = makeLegacyUtxo(TXID_A, 0, 60000) + const bad = makeLegacyUtxo(TXID_B, 2, 50000) + attachPrevTxs(encoder, [good, Object.assign({}, bad, { value: 70000 })], DOGE) + + await assert.rejects(() => encoder.createTransaction( + [good, bad], DOGE_ADDR, null, 'test', null, 90000, false, null, DOGE_ADDR, + null, null, null, true, 0.00001 + ), (err) => { + assert.ok(err instanceof RangeError) + assert.ok(err.message.includes(`${TXID_B}:2`), err.message) + assert.ok(!err.message.includes(`${TXID_A}:0`), err.message) + return true + }) + }) + + it('compares above 2^53-1 exactly: a match builds, off by one is refused', async () => { + const big = 9007199254740993n + const encoder = makeEncoder(DOGE) + const utxo = makeLegacyUtxo(TXID_A, 0, big) + attachPrevTxs(encoder, [utxo], DOGE) + const result = await buildDoge(encoder, [utxo]) + assert.strictEqual(sumOutputs(result.psbt) + paidFee(encoder), big) + + const encoder2 = makeEncoder(DOGE) + attachPrevTxs(encoder2, [utxo], DOGE) + await assert.rejects(() => buildDoge(encoder2, [makeLegacyUtxo(TXID_A, 0, big - 1n)]), (err) => { + assert.ok(err instanceof RangeError) + assert.match(err.message, /states value 9007199254740992 but the previous transaction pays 9007199254740993/) + return true + }) + }) +}) diff --git a/test/unit/xchain_encoder/prev_output_value_check.test/03_tracker_fetched.test.js b/test/unit/xchain_encoder/prev_output_value_check.test/03_tracker_fetched.test.js new file mode 100644 index 0000000..e28f166 --- /dev/null +++ b/test/unit/xchain_encoder/prev_output_value_check.test/03_tracker_fetched.test.js @@ -0,0 +1,47 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { OperationalError } = require('../../../../src/build/errors') +const { factory, DOGE, buildDoge, attachOnChainValues } = require('./helpers') +const { TXID_A, makeEncoder, makeLegacyUtxo, makeTrackerEnvelope, attachPrevTxs } = factory + +describe('prev-out value check: tracker-fetched and reservations', () => { + it('refuses a tracker mismatch as UTXO_TRACKER_ERROR, not a caller RangeError', async () => { + const encoder = makeEncoder(DOGE) + const row = makeLegacyUtxo(TXID_A, 0, 1000000) + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => makeTrackerEnvelope([Object.assign({}, row)]) + } + attachOnChainValues(encoder, [row], 100000000, DOGE) + + await assert.rejects(() => buildDoge(encoder, null), (err) => { + assert.ok(err instanceof OperationalError, `expected OperationalError, got ${err && err.name}`) + assert.ok(!(err instanceof RangeError)) + assert.strictEqual(err.xchainCode, 'UTXO_TRACKER_ERROR') + assert.ok(err.message.includes(`${TXID_A}:0`), err.message) + return true + }) + }) + + it('a mismatch releases the outpoint, so a corrected retry succeeds at once', async () => { + const encoder = makeEncoder(DOGE) + const real = makeLegacyUtxo(TXID_A, 0, 100000000) + attachPrevTxs(encoder, [real], DOGE) + + await assert.rejects(() => buildDoge(encoder, [makeLegacyUtxo(TXID_A, 0, 1000000)]), RangeError) + assert.ok(!encoder.outpointReservations.has(`${TXID_A}:0`), + 'the refused build must not keep the outpoint reserved') + + const retry = await buildDoge(encoder, [real]) + assert.ok(retry.psbt instanceof bitcoin.Psbt) + }) +}) diff --git a/test/unit/xchain_encoder/prev_output_value_check.test/04_segwit_attach_prev_tx.test.js b/test/unit/xchain_encoder/prev_output_value_check.test/04_segwit_attach_prev_tx.test.js new file mode 100644 index 0000000..1e201e3 --- /dev/null +++ b/test/unit/xchain_encoder/prev_output_value_check.test/04_segwit_attach_prev_tx.test.js @@ -0,0 +1,52 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { factory, LTC, buildLtc, attachOnChainValues } = require('./helpers') +const { TXID_A, makeEncoder, makeSegwitUtxo, attachPrevTxs } = factory + +// A hardware signer reads the amount from the attached prev tx, so the check +// runs wherever attachPrevTx already fetches it, and adds no fetch elsewhere. +describe('prev-out value check: segwit inputs', () => { + it('refuses a mismatch when attachPrevTx fetches the previous transaction', async () => { + const encoder = makeEncoder(LTC) + const utxo = makeSegwitUtxo(TXID_A, 0, 1000000) + attachOnChainValues(encoder, [utxo], 100000000, LTC) + + await assert.rejects(() => buildLtc(encoder, [utxo], true), (err) => { + assert.ok(err instanceof RangeError) + assert.match(err.message, /states value 1000000 but the previous transaction pays 100000000/) + return true + }) + }) + + it('builds on a match with attachPrevTx', async () => { + const encoder = makeEncoder(LTC) + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + attachPrevTxs(encoder, [utxo], LTC) + + const result = await buildLtc(encoder, [utxo], true) + assert.ok(Buffer.isBuffer(result.psbt.data.inputs[0].nonWitnessUtxo)) + }) + + it('stays fetch-free without attachPrevTx (the sighash commits to the amount)', async () => { + const encoder = makeEncoder(LTC) + let fetched = 0 + encoder.connector.getTransactionHex = async () => { + fetched++ + throw new Error('segwit without attachPrevTx must not fetch') + } + + const result = await buildLtc(encoder, [makeSegwitUtxo(TXID_A, 0, 100000000)], false) + assert.ok(result.psbt instanceof bitcoin.Psbt) + assert.strictEqual(fetched, 0) + }) +}) diff --git a/test/unit/xchain_encoder/prev_output_value_check.test/helpers/index.js b/test/unit/xchain_encoder/prev_output_value_check.test/helpers/index.js new file mode 100644 index 0000000..9888395 --- /dev/null +++ b/test/unit/xchain_encoder/prev_output_value_check.test/helpers/index.js @@ -0,0 +1,67 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Shared builders for the prev-out value check suite (input_selection.js +// assertPrevOutValueMatches). A legacy sighash does not commit to input +// amounts, so a stated utxos[].value below the real output would sign and pay +// the gap to miners; these files pin the build's refusal of any disagreement. + +const factory = require('../../../../integration/helpers/utxoFactory') +const actions = require('../../../../integration/helpers/actionFactory') + +const DOGE = 'dogecoin-regtest' +const LTC = 'litecoin-regtest' +const DOGE_ADDR = factory.getTestAddress(DOGE) +const LTC_ADDR = factory.getTestAddress(LTC) +const FEE = 10000 + +// Caller-supplied build on DOGE, where every input is legacy. +function buildDoge (encoder, utxos) { + return encoder.createTransaction( + utxos, DOGE_ADDR, null, + actions.makeSend().data, null, FEE, false, null, DOGE_ADDR, + null, null, null, true, 0.00001 + ) +} + +// Segwit build on LTC; attachPrevTx is the hardware-signer opt-in. +function buildLtc (encoder, utxos, attachPrevTx) { + return encoder.createTransaction( + utxos, LTC_ADDR, null, + actions.makeSend().data, null, FEE, false, null, LTC_ADDR, + null, null, null, true, 0.00001, null, null, attachPrevTx + ) +} + +// Prev txs that pay `onChain` where each UTXO states something else. +function attachOnChainValues (encoder, utxos, onChain, network) { + factory.attachPrevTxs(encoder, utxos.map((u) => Object.assign({}, u, { value: onChain })), network) +} + +function sumOutputs (psbt) { + return psbt.txOutputs.reduce((sum, o) => sum + BigInt(o.value), 0n) +} + +// The fee a DOGE build pays: the request, lifted to the chain dust floor it sits under. +function paidFee (encoder) { + return BigInt(Math.max(FEE, Number(encoder.dustAmount))) +} + +module.exports = { + factory, + DOGE, + LTC, + DOGE_ADDR, + buildDoge, + buildLtc, + attachOnChainValues, + sumOutputs, + paidFee +} diff --git a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js index 1a5d6d2..dc6d3b9 100644 --- a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js @@ -17,6 +17,7 @@ const { RAW_TX_HEX, makeSegwitUtxo, makeEncoder, + attachPrevTxs, LTC_REGTEST, TEST_ADDRESS } = require('./xchain_encoder_create_transaction.test/fixtures/transaction') @@ -68,6 +69,7 @@ describe('XChainEncoder.createTransaction()', () => { pubkey: pubkeyBuf, network: LTC_REGTEST }).output.toString('hex') } + attachPrevTxs(encoder, [legacyUtxo]) const result = await encoder.createTransaction( [legacyUtxo], TEST_ADDRESS, null, 'test', null, 10000, false, null, TEST_ADDRESS, diff --git a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js index 3f173cc..fce365d 100644 --- a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js @@ -14,6 +14,7 @@ const { makeSegwitUtxo, makeLegacyUtxo, makeEncoder, + attachPrevTxs, TEST_ADDRESS, bitcoin, pubkeyBuf @@ -135,6 +136,7 @@ describe('XChainEncoder.createTransaction()', () => { encoder.connector.getFeePerKilobyte = async () => 0.1 const address = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: encoder.network }).address const utxo = makeLegacyUtxo(TXID_A, 0, 1000000000) + attachPrevTxs(encoder, [utxo], 'dogecoin-testnet') return { encoder, address, utxo } } diff --git a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/fixtures/transaction.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/fixtures/transaction.js index f934664..3963234 100644 --- a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/fixtures/transaction.js +++ b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/fixtures/transaction.js @@ -12,6 +12,8 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const crypto = require('crypto') const XChainEncoder = require('../../../../../src/XChainEncoder') +// Per-txid prev-tx mocks, shared with the integration factory so both tiers agree. +const { attachPrevTxs } = require('../../../../integration/helpers/utxoFactory') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -112,6 +114,7 @@ module.exports = { makeSegwitUtxo, makeLegacyUtxo, makeEncoder, + attachPrevTxs, LTC_REGTEST, TxSizeEstimator, TEST_ADDRESS diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test.js index f0d1ac0..e7f95aa 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test.js @@ -22,6 +22,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const XChainEncoder = require('../../../src/XChainEncoder') +const { attachPrevTxs } = require('../../integration/helpers/utxoFactory') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -145,7 +146,7 @@ describe('XChainEncoder.createTransaction(): non-segwit UTXO path', () => { // picks up utxo1 too (combined = 130000 > 90000). const utxo1 = makeP2pkhUtxo(TXID_A, 0, 50000) const utxo2 = makeP2pkhUtxo(TXID_B, 1, 80000) - + attachPrevTxs(encoder, [utxo1, utxo2]) const result = await encoder.createTransaction( [utxo1, utxo2], TEST_ADDRESS, null, 'test', null, 90000, false, null, TEST_ADDRESS, diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js index be2d639..b685351 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js @@ -11,6 +11,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const XChainEncoder = require('../../../../src/XChainEncoder') +const { attachPrevTxs } = require('../../../integration/helpers/utxoFactory') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -96,6 +97,7 @@ describe('XChainEncoder.createTransaction() - change edge cases', () => { // smaller than the dust floor, so the floor wins the max()). Keep the // UTXO and fee at that ceiling so the fee is not rejected or floored up. const utxo = makeP2pkhUtxo(TXID_A, 0, 100000) + attachPrevTxs(encoder, [utxo]) const fee = 100000 const result = await encoder.createTransaction( @@ -118,6 +120,7 @@ describe('XChainEncoder.createTransaction() - change edge cases', () => { // slightly larger UTXO so change lands under dogecoin-regtest's // 100000-koinu dust floor. const utxo = makeP2pkhUtxo(TXID_A, 0, 150000) + attachPrevTxs(encoder, [utxo]) const fee = 100000 // leaves 50000 sats change, below the 100000 dust floor // Should not throw; change below dust with no change address is fine (burned as fee) diff --git a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js index 393d7b1..dc2cd74 100644 --- a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js @@ -133,6 +133,12 @@ describe('XChainEncoder TAPROOT envelope', function () { Object.assign({}, base, { commitTxid: 'xyz' })), /commitTxid/) await assert.rejects(encoder.createEnvelopeCancelTransaction( Object.assign({}, base, { commitVout: -1 })), /commitVout/) + // A commitVout above the uint32 wire width is a TypeError, which the API maps to -32602. + for (const bad of [4294967296, 1e300]) { + await assert.rejects(encoder.createEnvelopeCancelTransaction( + Object.assign({}, base, { commitVout: bad })), + (err) => err instanceof TypeError && /commitVout/.test(err.message)) + } await assert.rejects(encoder.createEnvelopeCancelTransaction( Object.assign({}, base, { internalPubkey: '04' + 'a'.repeat(128) })), /internalPubkey/) await assert.rejects(encoder.createEnvelopeCancelTransaction( From 58fafee32e4eb5a286f7d5770ac1226d224cb6d9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 29 Sep 2026 11:01:06 -0700 Subject: [PATCH 32/34] chore(release): v0.21.0 --- CHANGELOG.md | 8 ++++++++ README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af07106..ddd9619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.21.0] - 2026-09-29 + +### Added +- Exposed transaction block lookups through the tracker-backed RPC interface. + +### Fixed +- Rejected constructed transaction fees below the node relay floor. + ## [0.20.1] - 2026-09-23 ### Fixed diff --git a/README.md b/README.md index aba4e95..2f98f16 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # XChain Platform Encoder

- Version + Version Tests Node License diff --git a/package-lock.json b/package-lock.json index 8ca6366..1b8d9ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-encoder", - "version": "0.20.1", + "version": "0.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-encoder", - "version": "0.20.1", + "version": "0.21.0", "license": "AGPL-3.0-or-later", "dependencies": { "axios": "^1.18.1", diff --git a/package.json b/package.json index 224cc6d..e32c63b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xchain-encoder", "description": "xchain-encoder encodes XChain Platform ACTION commands into blockchain transactions.", - "version": "0.20.1", + "version": "0.21.0", "license": "AGPL-3.0-or-later", "repository": { "type": "git", From 9f650a2e14ce5ae97bc08d09911e9202d9e4a3b3 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 29 Sep 2026 12:22:27 -0700 Subject: [PATCH 33/34] Pin CI Node to 22.22.3, the fleet's consensus runtime --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9efb5e8..d626816 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: ci: uses: XChain-Platform/.github/.github/workflows/ci-reusable.yml@6f4d39ae85787fc31e90a31588d87610a2c33103 # pin: XChain-Platform/.github @ master 2026-08-14; bump deliberately with: + # The fleet's consensus runtime; a floating "22" now resolves 22.23.3, which the VM runtime gate refuses. + node-version: "22.22.3" # On a release or hotfix PR, check the siblings out at the PR's own # branch so a release-branch-only change to a file a cross-repo guard # reads is tested against the train, not against develop. The shared @@ -66,7 +68,7 @@ jobs: - name: Use Node.js 22 uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "22.22.3" - name: Install dependencies working-directory: xchain-encoder @@ -140,7 +142,7 @@ jobs: - name: Use Node.js 22 uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "22.22.3" cache: npm - name: Install dependencies From 890ffceabbd7b5fcc60617a2e7f0be400d138705 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 29 Sep 2026 12:22:27 -0700 Subject: [PATCH 34/34] Pin CI Node to 22.22.3, the fleet's consensus runtime --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9efb5e8..d626816 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: ci: uses: XChain-Platform/.github/.github/workflows/ci-reusable.yml@6f4d39ae85787fc31e90a31588d87610a2c33103 # pin: XChain-Platform/.github @ master 2026-08-14; bump deliberately with: + # The fleet's consensus runtime; a floating "22" now resolves 22.23.3, which the VM runtime gate refuses. + node-version: "22.22.3" # On a release or hotfix PR, check the siblings out at the PR's own # branch so a release-branch-only change to a file a cross-repo guard # reads is tested against the train, not against develop. The shared @@ -66,7 +68,7 @@ jobs: - name: Use Node.js 22 uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "22.22.3" - name: Install dependencies working-directory: xchain-encoder @@ -140,7 +142,7 @@ jobs: - name: Use Node.js 22 uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "22.22.3" cache: npm - name: Install dependencies