From 4af41f7a93936361c987c4373d81a3335399427b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 11:27:13 -0700 Subject: [PATCH 01/22] Fix 7 review findings in xchain-encoder - create_tx `pubkey` given as compressed hex is never checked to be on the curve, so chunk-lane and sweep outputs can be locked to a key that cannot exi - Unresolvable params.pubkey identity is rejected with a plain Error deep in the build, returning -32603 internal instead of -32602 - create_tx comment says build faults "stay retryable" but the SDK never retries any JSON-RPC error body - Encoder promises retryable -32603 for transient node faults, but the SDK never retries any JSON-RPC body error - Stale comment in anchorRelayFeeRate says getFeePerKilobyte throws on every non-regtest chain when estimatesmartfee has no data, but only mainnet throw - feePerKb has no upper bound in the validator, unlike fee/dust/feeQuote; an oversized rate is quietly clamped instead of rejected - Comments about the container's PID 1 contradict the Dockerfile's tini ENTRYPOINT (compose ends up running two inits) --- docker-compose.yml | 9 +- .../build_transaction/fee_rates.js | 32 ++--- .../build_transaction/input_candidates.js | 4 +- .../build_transaction/reveal_outputs.js | 12 +- src/XChainEncoder/envelope_cancel.js | 2 +- src/XChainEncoder/request_resolution.js | 26 ++-- src/api/json_rpc_methods.js | 3 +- src/common/validator/constants.js | 3 +- src/common/validator/fee_and_utxo_checks.js | 5 + src/common/validator/value_checks.js | 9 +- src/server/single_instance_guard.js | 17 ++- test/security/input_validation.test.js | 5 + .../caller_pubkey_resolution.test.js | 122 ++++++++++++++++++ .../08_validate_fee_per_kb.test.js | 8 ++ .../03_release_lock_on_signals.test.js | 10 +- .../xchain_encoder_fee_rate_cap.test.js | 3 +- 16 files changed, 218 insertions(+), 52 deletions(-) create mode 100644 test/unit/XChainEncoder/caller_pubkey_resolution.test.js diff --git a/docker-compose.yml b/docker-compose.yml index 96ff9110..8d72b50b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,10 +3,11 @@ version: '3' services: xchain_encoder: build: . - # tini as PID 1 so it, not node, reaps zombies left by healthcheck - # probes; node itself never reaps orphaned children. Dockerfile's - # exec-form node CMD stays PID 1's direct child and still gets SIGTERM - # straight from tini's forwarding, so that signal path is unchanged. + # Docker's own init as PID 1, on top of the image's tini ENTRYPOINT, the + # same tree xchain-node's `docker run --init` gives production: + # docker-init -> tini -> node. docker-init reaps zombies left by + # healthcheck probes (node never reaps orphaned children), and SIGTERM + # reaches node forwarded through both inits. init: true # The image bakes no .env (Dockerfile); the README's .env is handed to the # container here instead. diff --git a/src/XChainEncoder/build_transaction/fee_rates.js b/src/XChainEncoder/build_transaction/fee_rates.js index 7a88c07c..2c0ddb73 100644 --- a/src/XChainEncoder/build_transaction/fee_rates.js +++ b/src/XChainEncoder/build_transaction/fee_rates.js @@ -44,7 +44,7 @@ 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. + // RangeError: the node is at fault, not the input, so the API answers -32603. if (!Number.isFinite(relayFeePerKb) || relayFeePerKb <= 0){ throw new Error('Node did not report a positive relayfee; transaction fee safety cannot be verified') } @@ -73,11 +73,11 @@ function* useCallerFeeRate(build){ try { nodeFeePerBytes = (yield this.connector.getFeePerKilobyte(1))/1000 } catch (err) { - // The node cannot produce an estimate (e.g. a quiet testnet, - // exactly the case where callers must pass feePerKb to begin - // with). The relative cap has no anchor; fall back to the - // relayfee anchor below and the absolute MAX_FEE_RATE_KB cap, - // if configured. + // No estimate: mainnet with an empty or failed estimatesmartfee, + // or any chain whose connector relayfee fallback fails (a test + // chain without an estimate gets relayfee x multiplier instead). + // The relative cap has no anchor; fall back to the relayfee + // anchor below and the absolute MAX_FEE_RATE_KB cap, if configured. logger.warn(util.format('Relative fee cap skipped: node fee estimate unavailable:', err.message)) } Object.assign(build, { feePerBytes, nodeFeePerBytes }) @@ -115,15 +115,17 @@ function* useNodeFeeRate(build){ function* anchorRelayFeeRate(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 - // nodeFeePerBytes null. Without an anchor the relative cap can't bound - // feePerKb, and if no absolute MAX_FEE_RATE_KB is set either, the whole - // fee-drain guard is OFF exactly when a caller supplies its own rate: a - // hostile feePerKb then drains every input into miner fee. Fall back to - // the node's min-relay fee (always available, coin-correct) as the cap - // anchor, matching the regtest path in getFeePerKilobyte. This anchors + // Relative-cap anchor fallback. getFeePerKilobyte THROWS on mainnet when + // estimatesmartfee has no data (fresh node, warming mempool, low + // activity) or errors, and on any chain when the connector's relayfee + // fallback fails, so the caller-supplied feePerKb path above leaves + // nodeFeePerBytes null; test chains normally get relayfee x multiplier + // (bare relayfee on regtest) instead. Without an anchor the relative cap + // can't bound feePerKb, and if no absolute MAX_FEE_RATE_KB is set either, + // the whole fee-drain guard is OFF exactly when a caller supplies its own + // rate: a hostile feePerKb then drains every input into miner fee. Fall + // back to the node's min-relay fee (always available, coin-correct) as the + // cap anchor, matching the regtest path in getFeePerKilobyte. This anchors // ONLY the ceiling, never the fee actually charged; a legitimate rate on // an empty mempool sits well under relayfee x multiplier anyway. // Deliberately NOT gated on maxFeeRateMultiplier: the absolute burn diff --git a/src/XChainEncoder/build_transaction/input_candidates.js b/src/XChainEncoder/build_transaction/input_candidates.js index 84cddd7e..d34a8fd1 100644 --- a/src/XChainEncoder/build_transaction/input_candidates.js +++ b/src/XChainEncoder/build_transaction/input_candidates.js @@ -102,9 +102,11 @@ function* gatherUtxos(build){ function* fetchTrackerUtxos(build){ let { pubkey } = build + // Resolved outside the try so an unresolvable caller identity stays a -32602, not a tracker fault. + const callerAddress = resolveCallerAddress(pubkey, this.network) let fetched try { - fetched = (yield this.utxoTrackerConnector.getUtxosFromAddress(resolveCallerAddress(pubkey, this.network))) + fetched = (yield this.utxoTrackerConnector.getUtxosFromAddress(callerAddress)) } catch (err) { if (isRemoteProfile(this.trackerProfile)){ throw err.operational ? err : trackerUnreachableError(err) diff --git a/src/XChainEncoder/build_transaction/reveal_outputs.js b/src/XChainEncoder/build_transaction/reveal_outputs.js index 04d0e3c2..cc178ca9 100644 --- a/src/XChainEncoder/build_transaction/reveal_outputs.js +++ b/src/XChainEncoder/build_transaction/reveal_outputs.js @@ -61,8 +61,9 @@ function emitChangeAndPad(build){ // (see the P2WSH funding branch) so the output's value is available. let strippedFloor = this.network.minStandardTxNonWitnessSize if (p2shHash && preparedData["encoding"] === Encoding.P2WSH && strippedFloor){ - let padAddress = change || resolveCallerAddress(pubkey, this.network) - if (padAddress && this.strippedTxSize(psbt) < strippedFloor){ + // Resolved only when the pad is added, so an unused caller identity is never refused here. + let padAddress = this.strippedTxSize(psbt) < strippedFloor ? (change || resolveCallerAddress(pubkey, this.network)) : null + if (padAddress){ psbt.addOutput({ address: padAddress, value: this.dustAmount @@ -103,9 +104,10 @@ function* sweepP2shReveal(build){ revealFeeKept = (yield* upliftRevealForCommit.call(this, build, revealSizeForFee, revealFeeKept)) let revealSurplus = phaseLegInputSatoshis - outputSatoshis - BigInt(revealFeeKept) - let sweepAddress = change || resolveCallerAddress(pubkey, this.network) - // The sweep is an authored output: below the relay floor the surplus stays as fee. - if (sweepAddress && revealSurplus >= BigInt(this.outputFloor)){ + // The sweep is an authored output: below the relay floor the surplus stays as fee, + // and the caller identity is resolved only when the sweep is added. + let sweepAddress = revealSurplus >= BigInt(this.outputFloor) ? (change || resolveCallerAddress(pubkey, this.network)) : null + if (sweepAddress){ psbt.addOutput({ address: sweepAddress, value: asSatValue(revealSurplus) diff --git a/src/XChainEncoder/envelope_cancel.js b/src/XChainEncoder/envelope_cancel.js index 8b707605..6857d1d7 100644 --- a/src/XChainEncoder/envelope_cancel.js +++ b/src/XChainEncoder/envelope_cancel.js @@ -133,7 +133,7 @@ function* cancelFeeRate(feeRatePerKb){ 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. + // RangeError: the node is at fault, not the input, so the API answers -32603. if (!Number.isFinite(relayFeePerKb) || relayFeePerKb <= 0){ throw new Error('Node did not report a positive relayfee; transaction fee safety cannot be verified') } diff --git a/src/XChainEncoder/request_resolution.js b/src/XChainEncoder/request_resolution.js index 3b9ac91a..17f3a1b3 100644 --- a/src/XChainEncoder/request_resolution.js +++ b/src/XChainEncoder/request_resolution.js @@ -21,7 +21,7 @@ const bitcoin = require('bitcoinjs-lib'); const config = require('../common/config'); const { safeUpstreamReason } = require('../common/error_sanitize'); -const { validateAddress } = require('../common/validator'); +const { validateAddress, assertPubkeyOnCurve } = require('../common/validator'); const { ensureEccLib } = require('./script_amount_helpers.js'); const { ParamTypeError } = require('../build/errors'); @@ -139,7 +139,10 @@ function resolveCallerHash160(pubKey) { // addresses do not start with "0"), so ordering it first is safe and never // changes legacy behavior. if (typeof pubKey === 'string' && /^(0[23][0-9a-fA-F]{64}|04[0-9a-fA-F]{128})$/.test(pubKey)) { - return bitcoin.crypto.hash160(Buffer.from(pubKey, 'hex')) + // An off-curve key has no signer, so its HASH160 would lock every chunk output. + const pubkeyBuf = Buffer.from(pubKey, 'hex') + assertPubkeyOnCurve(pubkeyBuf, 'pubkey') + return bitcoin.crypto.hash160(pubkeyBuf) } // Base58 P2PKH/P2SH address: byte-identical to the original behavior. try { return bitcoin.address.fromBase58Check(pubKey).hash } catch (_) { /* not base58 */ } @@ -147,11 +150,12 @@ function resolveCallerHash160(pubKey) { try { const dec = bitcoin.address.fromBech32(pubKey) if (dec.version === 0 && dec.data.length === 20) return dec.data - throw new Error(`caller "${pubKey}" decodes to a v${dec.version} / ${dec.data.length}-byte witness program; the chunk-lane P2PKH gate needs a 20-byte HASH160 (v0 P2WPKH)`) + throw new ParamTypeError(`caller "${pubKey}" decodes to a v${dec.version} / ${dec.data.length}-byte witness program; the chunk-lane P2PKH gate needs a 20-byte HASH160 (v0 P2WPKH)`) } catch (e) { if (e && typeof e.message === 'string' && e.message.indexOf('witness program') !== -1) throw e } - throw new Error(`prepareData: cannot resolve a 20-byte caller HASH160 from identity "${pubKey}" (expected a base58 P2PKH/P2SH address, a compressed/uncompressed pubkey hex, or a v0 bech32 P2WPKH address)`) + // A caller-input refusal, so it is typed to map to -32602 rather than an internal error. + throw new ParamTypeError(`prepareData: cannot resolve a 20-byte caller HASH160 from identity "${pubKey}" (expected a base58 P2PKH/P2SH address, a compressed/uncompressed pubkey hex, or a v0 bech32 P2WPKH address)`) } // Sibling of resolveCallerHash160, same "any identity in" contract, but for @@ -171,19 +175,23 @@ function resolveCallerHash160(pubKey) { // default is the best a single guess can do. function resolveCallerAddress(pubKey, network) { if (typeof pubKey !== 'string' || pubKey.length === 0) return pubKey - // Already a valid address on this network: pass through unchanged. + // Already a valid address on this network: pass through unchanged. The ECC + // library is loaded first so a Taproot (bech32m) address decodes here too. + ensureEccLib() try { bitcoin.address.toOutputScript(pubKey, network); return pubKey } catch (_) { /* not a valid address here */ } // Raw compressed/uncompressed pubkey hex: derive the network's default address type. if (/^(0[23][0-9a-fA-F]{64}|04[0-9a-fA-F]{128})$/.test(pubKey)) { const pubkeyBuf = Buffer.from(pubKey, 'hex') + // bitcoinjs accepts any 02/03 x below p, so an off-curve key would yield an unspendable address. + assertPubkeyOnCurve(pubkeyBuf, 'pubkey') return (network.supportsSegwit === false ? bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network }) : bitcoin.payments.p2wpkh({ pubkey: pubkeyBuf, network }) ).address } - // Anything else: pass through unchanged and let the downstream call - // (tracker / bitcoinjs-lib) surface its own, more specific error. - return pubKey + // Anything else fails downstream either way (the tracker and bitcoinjs decode + // with this same network), so refuse it here as caller input, naming pubkey. + throw new ParamTypeError('pubkey is neither a valid address for this network nor a public key hex') } // Deployment default for transparent FILE compression. ON unless @@ -216,7 +224,7 @@ function assertRevealFundingTxMatches(p2shHash, fundingTxid){ // Refuse an address that does not decode on this network with a TypeError, so // the API answers -32602 before node, tracker or reservation work. bitcoinjs throws -// a plain Error late (a retryable -32603), or never on a P2SH/P2WSH funding call. +// a plain Error late (a generic -32603), or never on a P2SH/P2WSH funding call. function assertAddressOnNetwork(address, network, label){ validateAddress(address, label) // Decoding a bech32m (Taproot) address needs the ECC library, which this diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index 3160e65b..1ca3350b 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -196,7 +196,8 @@ return { params.compressedPubKey, params.unconfirmed, params.feePerKb, params.dust, params.feeQuote, params.attachPrevTx, params.compress, params.options) } catch (err) { - // Node and tracker faults inside the build throw plain Error, so they stay retryable + // Map the fault: a tracker refusal is operational (-32010), a plain node Error a + // generic -32603; the SDK retries neither body error, so any retry is the caller's throw toRpcError(err) } diff --git a/src/common/validator/constants.js b/src/common/validator/constants.js index d58e9680..e618ddd8 100644 --- a/src/common/validator/constants.js +++ b/src/common/validator/constants.js @@ -60,7 +60,8 @@ const MAX_OP_RETURN_COMPILED_LENGTH = OP_RETURN_OUTPUT_SIZE - OP_RETURN_MAGIC_WO const ENVELOPE_MAX_PAYLOAD = 390_000 const MAX_UTXO_COUNT = 500 const MAX_CUSTOM_OUTPUTS = 100 -// Sanity ceiling on the fee/dust/feeQuote money fields: 21,000 BTC in satoshis. +// Sanity ceiling on the fee/dust/feeQuote money fields and the feePerKb rate +// (base units per kB): 21,000 BTC in satoshis. // This is deliberately far tighter than the 21M-BTC supply, and the value is the // contract, not a rounding of it. Do NOT "repair" it to 2_100_000_000_000_000 to // chase a supply figure: that loosens every fee-path ceiling 1000x. Large output diff --git a/src/common/validator/fee_and_utxo_checks.js b/src/common/validator/fee_and_utxo_checks.js index 3eb2f23b..53fce973 100644 --- a/src/common/validator/fee_and_utxo_checks.js +++ b/src/common/validator/fee_and_utxo_checks.js @@ -94,6 +94,11 @@ function validateFeePerKb(feePerKb) { if (num <= 0) { throw new ParamRangeError('feePerKb must be positive') } + // Reject an absurd rate like the other money fields instead of leaving it to + // the build's clamp: above this, one kB alone costs more than the fee ceiling. + if (num > MAX_FEE_SATOSHIS) { + throw new ParamRangeError(`feePerKb (${num}) exceeds maximum (${MAX_FEE_SATOSHIS})`) + } return num } diff --git a/src/common/validator/value_checks.js b/src/common/validator/value_checks.js index 79984607..8af9dcfa 100644 --- a/src/common/validator/value_checks.js +++ b/src/common/validator/value_checks.js @@ -12,9 +12,10 @@ * **********************************************************************/ -const { MAX_SATOSHI_U64 } = require('./constants') +const { MAX_SATOSHI_U64, COMPRESSED_PUBKEY_RE } = require('./constants') const { ParamTypeError, ParamRangeError } = require('../../build/errors') const validateAddress = (...args) => require('./transaction_checks').validateAddress(...args) +const assertPubkeyOnCurve = (...args) => require('./transaction_checks').assertPubkeyOnCurve(...args) // Exact-integer parse for satoshi/fee money fields. Unlike parseInt, this // rejects decimals, scientific notation, hex, and trailing garbage ("1e8" -> @@ -107,7 +108,11 @@ function validatePubkey(pubkey) { throw new ParamTypeError('pubkey: uncompressed public keys (04 + 128 hex) are not supported; pass the compressed 02/03 + 64 hex form or a sender address') } // Hold everything else to the same shared bound as every address-shaped field. - return validateAddress(pubkey, 'pubkey') + validateAddress(pubkey, 'pubkey') + // A compressed key must also be a curve point: its HASH160 gates the chunk + // outputs and its address takes the reveal change, so an off-curve key locks both. + if (COMPRESSED_PUBKEY_RE.test(pubkey)) assertPubkeyOnCurve(Buffer.from(pubkey, 'hex'), 'pubkey') + return pubkey } // Index of the first code unit a latin-1 byte cannot hold, or -1. Written as a scan diff --git a/src/server/single_instance_guard.js b/src/server/single_instance_guard.js index a28f842e..7a0944f5 100644 --- a/src/server/single_instance_guard.js +++ b/src/server/single_instance_guard.js @@ -342,7 +342,8 @@ function acquireInstanceLock(lockPath, env = config, deps = {}) { const SIGNAL_EXIT_STATUS = { SIGTERM: 143, SIGINT: 130 } // How long to wait for the re-raise below to actually kill us before forcing -// the exit ourselves. Only the PID 1 case ever waits this long. +// the exit ourselves. Only a node running as PID 1 ever waits this long, which +// the supported container deploys never do (tini sits in front of node). const RERAISE_GRACE_MS = 50 // Release the instance lock when the process is SIGNALLED, not only when it @@ -362,12 +363,14 @@ function releaseLockOnSignals(release, proc = process) { // Re-raise so the process still dies OF the signal, leaving exit // status and supervision semantics exactly as they were. proc.kill(proc.pid, signal) - // ...except as PID 1, which is what the container CMD makes this - // process: the kernel drops signals PID 1 has installed no handler - // for, so the re-raise above is silently a no-op there and the - // process would sit until Docker's 10s SIGKILL. If we are still - // alive a moment later, exit with the status that signal would have - // produced, which is what the supervisor was going to record anyway. + // ...except as PID 1: the kernel drops signals PID 1 has installed + // no handler for, so the re-raise above is silently a no-op there + // and the process would sit until Docker's 10s SIGKILL. The + // supported deploys run node under the image's tini, so the + // re-raise normally kills it and this timer is a safety net for a + // run with no init in front of node. If we are still alive a moment + // later, exit with the status that signal would have produced, + // which is what the supervisor was going to record anyway. const timer = setTimeout(() => proc.exit(SIGNAL_EXIT_STATUS[signal]), RERAISE_GRACE_MS) if (typeof timer.unref === 'function') timer.unref() } diff --git a/test/security/input_validation.test.js b/test/security/input_validation.test.js index 76fd6ffa..6ab7dea0 100644 --- a/test/security/input_validation.test.js +++ b/test/security/input_validation.test.js @@ -185,6 +185,11 @@ describe('Security: validateFee / validateFeePerKb / validateDust', () => { assert.throws(() => V.validateDust('xyz'), TypeError) }) + it('rejects an over-max feePerKb (parity with the fee ceiling)', () => { + assert.throws(() => V.validateFeePerKb(V.MAX_FEE_SATOSHIS + 1), RangeError) + assert.strictEqual(V.validateFeePerKb(2000), 2000) + }) + it('rejects over-max dust (parity with the fee ceiling)', () => { assert.throws(() => V.validateDust(V.MAX_FEE_SATOSHIS + 1), RangeError) assert.strictEqual(V.validateDust(546), 546) diff --git a/test/unit/XChainEncoder/caller_pubkey_resolution.test.js b/test/unit/XChainEncoder/caller_pubkey_resolution.test.js new file mode 100644 index 00000000..28e022b0 --- /dev/null +++ b/test/unit/XChainEncoder/caller_pubkey_resolution.test.js @@ -0,0 +1,122 @@ +// 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 caller pubkey is refused as caller input (-32602) when it is off the curve +// or resolves to no address, on the validator, both resolvers and the build. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const secp = require('tiny-secp256k1') +const v = require('../../../src/common/validator.js') +const { resolveCallerHash160, resolveCallerAddress } = require('../../../src/XChainEncoder/request_resolution.js') +const { TXID_A, PUBKEY_BUF, makeUtxo, makeEncoder, getTestAddress } = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') +const { createJsonRpcController } = require('../../../src/api/json_rpc_methods') + +// x = 5 has no secp256k1 point; the shape regexes and bitcoinjs both accept it. +const OFF_CURVE = '02' + '0'.repeat(63) + '5' +const ON_CURVE = PUBKEY_BUF.toString('hex') +const DOGE = 'dogecoin-regtest' + +function isCallerInput (pattern) { + return (err) => err instanceof TypeError && err.invalidParams === true && pattern.test(err.message) +} + +describe('caller pubkey: curve check and caller-input refusals', () => { + it('uses a fixture key that really is off the curve and passes the shape checks', () => { + assert.strictEqual(secp.isPoint(Buffer.from(OFF_CURVE, 'hex')), false) + assert.strictEqual(secp.isPoint(PUBKEY_BUF), true) + assert.ok(/^(02|03)[0-9a-fA-F]{64}$/.test(OFF_CURVE)) + }) + + it('validatePubkey refuses an off-curve compressed key and keeps a real one', () => { + assert.throws(() => v.validatePubkey(OFF_CURVE), isCallerInput(/pubkey is not a valid secp256k1 point/)) + assert.strictEqual(v.validatePubkey(ON_CURVE), ON_CURVE) + }) + + it('resolveCallerHash160 refuses an off-curve key instead of hashing it into the chunk gate', () => { + assert.throws(() => resolveCallerHash160(OFF_CURVE), isCallerInput(/not a valid secp256k1 point/)) + assert.deepStrictEqual(resolveCallerHash160(ON_CURVE), bitcoin.crypto.hash160(PUBKEY_BUF)) + }) + + it('resolveCallerHash160 types an unresolvable identity as caller input', () => { + assert.throws(() => resolveCallerHash160('deadbeefdeadbeef'), isCallerInput(/cannot resolve a 20-byte caller HASH160/)) + }) + + it('resolveCallerAddress refuses an off-curve key and an unresolvable identity', () => { + const network = bitcoin.networks.regtest + assert.throws(() => resolveCallerAddress(OFF_CURVE, network), isCallerInput(/not a valid secp256k1 point/)) + assert.throws(() => resolveCallerAddress('deadbeefdeadbeef', network), isCallerInput(/pubkey is neither a valid address/)) + }) + + it('resolveCallerAddress still passes an on-network address through, Taproot included', () => { + const network = bitcoin.networks.regtest + const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: PUBKEY_BUF, network }).address + const p2tr = bitcoin.address.toBech32(Buffer.alloc(32, 1), 1, network.bech32) + assert.strictEqual(resolveCallerAddress(p2wpkh, network), p2wpkh) + assert.strictEqual(resolveCallerAddress(p2tr, network), p2tr) + assert.strictEqual(resolveCallerAddress(ON_CURVE, network), p2wpkh) + }) + + it('a P2SH build with an off-curve pubkey and caller utxos is refused before any output is built', async () => { + const encoder = makeEncoder(DOGE) + await assert.rejects( + () => encoder.createTransaction( + [makeUtxo(DOGE, TXID_A, 0, 100000000)], OFF_CURVE, null, + actions.makeIssueFull().data, null, 10000, false, null, getTestAddress(DOGE), + null, null, null, true, 0.00001 + ), + isCallerInput(/not a valid secp256k1 point/) + ) + }) +}) + +describe('caller pubkey: the tracker lane answers an unresolvable identity with -32602', () => { + const NETWORK = 'bitcoin-regtest' + const address = getTestAddress(NETWORK) + + // Run create_tx with no caller utxos, so the build fetches from the tracker. + async function createTx (pubkey) { + const encoder = makeEncoder(NETWORK) + const trackerCalls = [] + const fetch = encoder.utxoTrackerConnector.getUtxosFromAddress + encoder.utxoTrackerConnector.getUtxosFromAddress = (...a) => { trackerCalls.push(a[0]); return fetch(...a) } + const controller = createJsonRpcController({ encoder, NETWORK }) + const params = { pubkey, data: actions.makeSend().data, change: address, unconfirmed: true } + try { + return { result: await controller.create_tx(params), trackerCalls } + } catch (caught) { + return { caught, trackerCalls } + } + } + + function assertPubkeyFault (outcome) { + assert.ok(outcome.caught, 'the request should have been refused') + assert.strictEqual(outcome.caught.code, -32602, outcome.caught.message) + assert.ok(outcome.caught.message.includes('pubkey'), outcome.caught.message) + assert.deepStrictEqual(outcome.trackerCalls, [], 'the tracker was asked for an unresolvable identity') + } + + it('a hex string that is no pubkey and no address', async () => { + assertPubkeyFault(await createTx('deadbeefdeadbeef')) + }) + + it('a mainnet address on regtest', async () => { + assertPubkeyFault(await createTx('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa')) + }) + + it('a regtest address and a compressed pubkey hex still reach the tracker and build', async () => { + for (const pubkey of [address, ON_CURVE]) { + const outcome = await createTx(pubkey) + assert.ok(!outcome.caught, outcome.caught && outcome.caught.message) + assert.strictEqual(outcome.trackerCalls.length, 1) + } + }) +}) diff --git a/test/unit/common/validator/validator.test/08_validate_fee_per_kb.test.js b/test/unit/common/validator/validator.test/08_validate_fee_per_kb.test.js index 1788033b..c9812484 100644 --- a/test/unit/common/validator/validator.test/08_validate_fee_per_kb.test.js +++ b/test/unit/common/validator/validator.test/08_validate_fee_per_kb.test.js @@ -39,5 +39,13 @@ describe('Encoder input validator', function () { assert.throws(() => v.validateFeePerKb([50]), TypeError); // array -> 50 assert.throws(() => v.validateFeePerKb({}), TypeError); // object -> NaN }); + it('rejects a rate above the money-field ceiling on both the number and string paths', function () { + const max = v.MAX_FEE_SATOSHIS; + assert.strictEqual(v.validateFeePerKb(max), max); + assert.strictEqual(v.validateFeePerKb(String(max)), max); + assert.throws(() => v.validateFeePerKb(max + 1), RangeError); + assert.throws(() => v.validateFeePerKb(String(max + 1)), RangeError); + assert.throws(() => v.validateFeePerKb(1e300), RangeError); + }); }); }); diff --git a/test/unit/server/single_instance_guard.test/03_release_lock_on_signals.test.js b/test/unit/server/single_instance_guard.test/03_release_lock_on_signals.test.js index c9fd67e3..6ae998b6 100644 --- a/test/unit/server/single_instance_guard.test/03_release_lock_on_signals.test.js +++ b/test/unit/server/single_instance_guard.test/03_release_lock_on_signals.test.js @@ -110,11 +110,11 @@ describe('singleInstanceGuard', function () { describe('singleInstanceGuard', function () { describe('releaseLockOnSignals', function () { - // PID 1 is what the container CMD makes this process, and the kernel - // drops signals PID 1 has no handler for, so the re-raise above is a - // no-op and nothing would ever exit: docker stop would hang out its - // full 10s timeout and SIGKILL, which is the one death that cannot - // release anything. + // The safety-net case: node running as PID 1 with no init in front of + // it. The kernel drops signals PID 1 has no handler for, so the + // re-raise above is a no-op and nothing would ever exit: docker stop + // would hang out its full 10s timeout and SIGKILL, which is the one + // death that cannot release anything. it('forces exit 143 when a re-raised SIGTERM is ignored (PID 1)', async function () { const proc = fakeProcess({ diesOnReraise: false }) releaseLockOnSignals(() => {}, proc) 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 7aabd0a4..bd31f234 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 @@ -19,7 +19,8 @@ // own estimatesmartfee(1) estimate (default 100×). feePerKb is clamped; an // explicit absolute fee is rejected with a RangeError (silently lowering an // exact amount would change what the caller signs). The cap degrades -// gracefully when the node cannot produce an estimate (quiet testnets). +// gracefully when the node cannot produce an estimate (mainnet with no +// estimatesmartfee data, or a failed relayfee fallback query). const assert = require('assert') const bitcoin = require('bitcoinjs-lib') From fa3e0e9b448b73fb1ad331a863347c6068744d0b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 11:56:28 -0700 Subject: [PATCH 02/22] Port genesis identity helpers from decoder Add focused coverage for mismatches, unpinned state, and deliberate fail-open behavior. --- .../blockchain_connector/chain_identity.js | 28 ++++++-- test/unit/chain_identity_genesis.test.js | 70 +++++++++++++++++++ 2 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 test/unit/chain_identity_genesis.test.js diff --git a/src/build/blockchain_connector/chain_identity.js b/src/build/blockchain_connector/chain_identity.js index 89b1eeae..e53e9308 100644 --- a/src/build/blockchain_connector/chain_identity.js +++ b/src/build/blockchain_connector/chain_identity.js @@ -14,10 +14,9 @@ * * XChain Encoder - coin-node chain-tier identity * - * The tier half of xchain-decoder's src/protocol/chain_identity.js, kept in - * step with it. getblockchaininfo's `chain` names the network TIER, never the - * coin, and `test`/`testnet3`/`testnet4` collapse to one tier, so this can - * prove "wrong tier" and nothing more. + * The pure identity decisions from xchain-decoder's + * src/protocol/chain_identity.js, kept in step with it. The reported chain + * proves tier identity, while the block-0 hash proves coin and chain identity. * ********************************************************************/ @@ -44,4 +43,23 @@ function chainTierMismatch(consensusNetwork, reportedChain){ ') but this encoder is configured for ' + consensusNetwork; } -module.exports = { CHAIN_TO_NETWORK, chainTierMismatch }; +// Return a reason string when two usable block-0 hashes differ, else null. +function chainGenesisMismatch(pinnedHash, reportedHash){ + if (typeof pinnedHash !== 'string' || pinnedHash === '') return null; + if (typeof reportedHash !== 'string' || reportedHash === '') return null; + if (pinnedHash.toLowerCase() === reportedHash.toLowerCase()) return null; + return 'node block-0 hash is ' + reportedHash + ', but this encoder is pinned to ' + + pinnedHash + ', so the endpoint is serving a different chain'; +} + +// Return true when no usable block-0 hash is pinned. +function chainGenesisUnpinned(pinnedHash){ + return typeof pinnedHash !== 'string' || pinnedHash === ''; +} + +module.exports = { + CHAIN_TO_NETWORK, + chainTierMismatch, + chainGenesisMismatch, + chainGenesisUnpinned, +}; diff --git a/test/unit/chain_identity_genesis.test.js b/test/unit/chain_identity_genesis.test.js new file mode 100644 index 00000000..95c271ad --- /dev/null +++ b/test/unit/chain_identity_genesis.test.js @@ -0,0 +1,70 @@ +/********************************************************************* + * + * 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 { + chainGenesisMismatch, + chainGenesisUnpinned, +} = require('../../src/build/blockchain_connector/chain_identity.js'); + +const BTC_MAINNET = '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f'; +const DOGE_MAINNET = '1a91e3dace36e2be3bf030a65679fe821aa1d6ef92e7c9902eb318182c355691'; +const TESTNET3 = '3'.repeat(64); +const TESTNET4 = '4'.repeat(64); + +describe('chain identity genesis helpers', function () { + it('catches a same-tier cross-coin pairing', function () { + const reason = chainGenesisMismatch(BTC_MAINNET, DOGE_MAINNET); + assert.ok(reason); + assert.ok(reason.includes(BTC_MAINNET)); + assert.ok(reason.includes(DOGE_MAINNET)); + }); + + it('catches a testnet3 versus testnet4 pairing', function () { + const reason = chainGenesisMismatch(TESTNET3, TESTNET4); + assert.ok(reason); + assert.ok(reason.includes(TESTNET3)); + assert.ok(reason.includes(TESTNET4)); + }); + + it('accepts an agreeing hash regardless of case', function () { + assert.strictEqual(chainGenesisMismatch(BTC_MAINNET, BTC_MAINNET), null); + assert.strictEqual(chainGenesisMismatch(BTC_MAINNET.toUpperCase(), BTC_MAINNET), null); + assert.strictEqual(chainGenesisMismatch(BTC_MAINNET, BTC_MAINNET.toUpperCase()), null); + }); + + it('fails open when no usable hash is pinned', function () { + assert.strictEqual(chainGenesisMismatch(null, DOGE_MAINNET), null); + assert.strictEqual(chainGenesisMismatch(undefined, DOGE_MAINNET), null); + assert.strictEqual(chainGenesisMismatch('', DOGE_MAINNET), null); + assert.strictEqual(chainGenesisMismatch(42, DOGE_MAINNET), null); + }); + + it('fails open when the node reports no usable hash', function () { + assert.strictEqual(chainGenesisMismatch(BTC_MAINNET, null), null); + assert.strictEqual(chainGenesisMismatch(BTC_MAINNET, undefined), null); + assert.strictEqual(chainGenesisMismatch(BTC_MAINNET, ''), null); + assert.strictEqual(chainGenesisMismatch(BTC_MAINNET, 42), null); + }); + + it('reports whether the genesis pin is absent', function () { + assert.strictEqual(chainGenesisUnpinned(null), true); + assert.strictEqual(chainGenesisUnpinned(undefined), true); + assert.strictEqual(chainGenesisUnpinned(''), true); + assert.strictEqual(chainGenesisUnpinned(42), true); + assert.strictEqual(chainGenesisUnpinned(BTC_MAINNET), false); + }); +}); From a4e882b17d2f1d6f01cb2265ecc855bd62432c12 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 11:56:35 -0700 Subject: [PATCH 03/22] Expose node tier mismatch in status readiness Retain the connector mismatch reason when the node tier gate refuses a request. Mark the status endpoint unhealthy and publish the reason, with regression coverage for mismatch and normal readiness states. --- src/api.js | 5 +- .../blockchain_connector/node_queries.js | 5 +- test/unit/api/status_node_tier.test.js | 82 +++++++++++++++++++ 3 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 test/unit/api/status_node_tier.test.js diff --git a/src/api.js b/src/api.js index 4f89b0aa..e71f0253 100644 --- a/src/api.js +++ b/src/api.js @@ -288,7 +288,8 @@ app.get('/status', probeGate.hold(async (req, res) => { // tracker_halted and tracker_mempool_ready travel alongside so the board names WHY // an unhealthy encoder is unhealthy; both already fold into tracker_synced above. const { tracker_reachable, tracker_synced, tracker_lag, tracker_halted, tracker_mempool_ready, maintenance } = await jsonRpcController.health() - const healthy = tracker_reachable && tracker_synced + const nodeTierMismatch = encoder.connector.nodeTierMismatch || null + const healthy = tracker_reachable && tracker_synced && !nodeTierMismatch // A declared window does NOT move the code. The encoder still cannot serve, // and every load balancer and uptime monitor keyed on this 503 must keep // seeing it; the window is context for whoever reads the body, not a way to @@ -305,7 +306,7 @@ app.get('/status', probeGate.hold(async (req, res) => { // request_gate exposes the global concurrency cap and how many requests it // has shed; a climbing shed count is the only outward sign that a // distinct-IP stampede is being refused. - res.status(code).json({ status: healthy ? 'healthy' : 'unhealthy', tracker_reachable, tracker_synced, tracker_lag, tracker_halted, tracker_mempool_ready, tracker_max_lag_blocks, maintenance, request_gate: requestGate.getStats(), probe_gate: probeGate.getStats() }) + res.status(code).json({ status: healthy ? 'healthy' : 'unhealthy', tracker_reachable, tracker_synced, tracker_lag, tracker_halted, tracker_mempool_ready, tracker_max_lag_blocks, node_tier_mismatch: nodeTierMismatch, maintenance, request_gate: requestGate.getStats(), probe_gate: probeGate.getStats() }) })) // Machine-readable API spec (OpenRPC 1.3.2). Regenerated by docs/openrpc.build.js; diff --git a/src/build/blockchain_connector/node_queries.js b/src/build/blockchain_connector/node_queries.js index 6d07228a..01f9f358 100644 --- a/src/build/blockchain_connector/node_queries.js +++ b/src/build/blockchain_connector/node_queries.js @@ -33,7 +33,10 @@ module.exports = { return; } const reason = chainTierMismatch(this.consensusNetwork, chain); - if (reason) throw new OperationalError('NODE_CHAIN_MISMATCH', 'Coin node is on the wrong network: ' + reason); + if (reason) { + this.nodeTierMismatch = reason; + throw new OperationalError('NODE_CHAIN_MISMATCH', 'Coin node is on the wrong network: ' + reason); + } this.nodeTierOk = true; }, diff --git a/test/unit/api/status_node_tier.test.js b/test/unit/api/status_node_tier.test.js new file mode 100644 index 00000000..a1c7d057 --- /dev/null +++ b/test/unit/api/status_node_tier.test.js @@ -0,0 +1,82 @@ +// 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 { app, encoder } = require('../../../src/api') +const { assertNodeTier } = require('../../../src/build/blockchain_connector/node_queries') + +async function statusResponse () { + const server = await new Promise((resolve) => { + const listening = app.listen(0, '127.0.0.1', () => resolve(listening)) + }) + try { + const response = await fetch(`http://127.0.0.1:${server.address().port}/status`) + return { code: response.status, body: await response.json() } + } finally { + await new Promise((resolve) => server.close(resolve)) + } +} + +describe('assertNodeTier mismatch state', function () { + it('records the mismatch reason on the connector before refusing the node', async function () { + const connector = { + consensusNetwork: 'testnet', + chainName: async () => 'main' + } + + await assert.rejects(assertNodeTier.call(connector), (error) => { + assert.strictEqual(error.xchainCode, 'NODE_CHAIN_MISMATCH') + return true + }) + assert.strictEqual( + connector.nodeTierMismatch, + 'node reports chain="main" (mainnet) but this encoder is configured for testnet' + ) + }) +}) + +describe('GET /status node-tier readiness', function () { + let originalSyncStatus + let originalMismatch + + beforeEach(function () { + originalSyncStatus = encoder.utxoTrackerConnector.getSyncStatus + originalMismatch = encoder.connector.nodeTierMismatch + encoder.utxoTrackerConnector.getSyncStatus = async () => ({ synced: true, lag: 0 }) + delete encoder.connector.nodeTierMismatch + }) + + afterEach(function () { + encoder.utxoTrackerConnector.getSyncStatus = originalSyncStatus + if (originalMismatch === undefined) delete encoder.connector.nodeTierMismatch + else encoder.connector.nodeTierMismatch = originalMismatch + }) + + it('returns 503 and publishes a recorded node-tier mismatch reason', async function () { + const reason = 'node reports chain="main" (mainnet) but this encoder is configured for testnet' + encoder.connector.nodeTierMismatch = reason + + const response = await statusResponse() + + assert.strictEqual(response.code, 503) + assert.strictEqual(response.body.status, 'unhealthy') + assert.strictEqual(response.body.node_tier_mismatch, reason) + }) + + it('returns 200 with a null mismatch when no mismatch has been recorded', async function () { + const response = await statusResponse() + + assert.strictEqual(response.code, 200) + assert.strictEqual(response.body.status, 'healthy') + assert.strictEqual(response.body.node_tier_mismatch, null) + }) +}) From c159b4cd4ea336523bc75fbba65282651faade8f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 12 Oct 2026 11:57:25 -0700 Subject: [PATCH 04/22] test: guard encoder decoder chain identity parity Compare the complete exported chain-to-network maps so key additions, removals, and value drift fail the encoder suite. --- test/unit/chain_identity_parity.test.js | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 test/unit/chain_identity_parity.test.js diff --git a/test/unit/chain_identity_parity.test.js b/test/unit/chain_identity_parity.test.js new file mode 100644 index 00000000..05be281e --- /dev/null +++ b/test/unit/chain_identity_parity.test.js @@ -0,0 +1,39 @@ +/********************************************************************* + * + * 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 { CHAIN_TO_NETWORK } = require('../../src/build/blockchain_connector/chain_identity'); +const { siblingCheckout, skipOrFail } = require('../helpers/sibling_checkout'); + +const DECODER_DIR = process.env.XCHAIN_DECODER_DIR || path.join(__dirname, '../../../xchain-decoder'); +const DECODER_IDENTITY = path.join(DECODER_DIR, 'src', 'protocol', 'chain_identity.js'); + +describe('chain identity map parity with xchain-decoder', function(){ + let decoderChainToNetwork; + + before(function(){ + const decoder = siblingCheckout(__dirname, DECODER_IDENTITY); + if(!decoder.usable) + return skipOrFail(this, decoder, 'the encoder/decoder chain identity parity guard'); + ({ CHAIN_TO_NETWORK: decoderChainToNetwork } = require(DECODER_IDENTITY)); + }); + + it('matches every decoder chain key and network value', function(){ + assert.deepStrictEqual(CHAIN_TO_NETWORK, decoderChainToNetwork); + }); +}); From caf9335c80518e08d6dd8f7c12acdecbac5d3de3 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 08:14:57 -0700 Subject: [PATCH 05/22] Bump axios to 1.20.0 and refresh fast-uri, ip-address, proxy-addr and brace-expansion to resolve security advisories --- package-lock.json | 39 ++++++++++++++++++++++----------------- package.json | 2 +- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5547dc19..62232c45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.23.0", "license": "AGPL-3.0-or-later", "dependencies": { - "axios": "^1.18.1", + "axios": "^1.20.0", "bip174": "2.1.1", "bip32": "4.0.0", "bip39": "^3.1.0", @@ -2709,13 +2709,13 @@ } }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -2876,9 +2876,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3995,9 +3995,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -4536,9 +4536,9 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ip-address": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", - "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", "license": "MIT", "engines": { "node": ">= 12" @@ -5519,15 +5519,20 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { diff --git a/package.json b/package.json index 2c177f24..c557ad6e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "url": "https://github.com/XChain-Platform/xchain-encoder.git" }, "dependencies": { - "axios": "^1.18.1", + "axios": "^1.20.0", "bip174": "2.1.1", "bip32": "4.0.0", "bip39": "^3.1.0", From 86c39579abc26980697e98b5b655a280f1f5c5a7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 18:19:10 +0000 Subject: [PATCH 06/22] Refuse non-validator create_tx on mainnet before launch A mainnet encoder now answers create_tx for any action outside the validator set (STAKE, UNSTAKE, DELEGATE, SLASH, NODEPROOF, ROLLCALL) with -32010 and reason MAINNET_PRELAUNCH until unix time 1798761600. ENCODER_MAINNET_PRELAUNCH_OVERRIDE (1/true/yes/on) lifts it, read live through config.js and documented in the README. Tests drive the real environment variable through the controller factory. --- README.md | 1 + src/api.js | 5 +- src/api/json_rpc_methods.js | 44 ++++++- src/common/config.js | 2 + .../api/mainnet_prelaunch_refusal.test.js | 108 ++++++++++++++++++ test/unit/api/node_tier_gate.test.js | 11 ++ 6 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 test/unit/api/mainnet_prelaunch_refusal.test.js diff --git a/README.md b/README.md index 5b6f3384..ab2c86c2 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ npm run api | `ENCODER_MAX_CONCURRENT_REQUESTS` | No | `50` | Global cap on requests served at once across all client IPs; excess gets an immediate 429 + `Retry-After` instead of queueing. `/status` and `/openrpc.json` are exempt, over `GET` or `HEAD`, in any letter case and with or without a trailing slash; `0` disables | | `ENCODER_MAX_CONCURRENT_PROBES` | No | `16` | Private concurrency reserve for the two exempt probe routes, so healthchecks stay answerable while the cap above sheds without becoming an uncapped bypass; `0` disables | | `ENCODER_TRUST_PROXY` | No | `loopback, uniquelocal` | Express `trust proxy` setting; controls which hop the per-IP rate limiter keys the client IP on. `false`, a hop count, or an address/CIDR list per the Express docs | +| `ENCODER_MAINNET_PRELAUNCH_OVERRIDE` | No | Unset | On a mainnet encoder, `create_tx` refuses every non-validator action (anything but `STAKE`, `UNSTAKE`, `DELEGATE`, `SLASH`, `NODEPROOF`, `ROLLCALL`) before unix time `1798761600` with `-32010` and `data.reason` `MAINNET_PRELAUNCH` (`data.opens_at` carries the time). Set `1`, `true`, `yes` or `on` to lift the refusal; read live on every call, any other value leaves it in force. Testnet and regtest are never refused | | `ENCODER_MAINTENANCE_FILE` | No | `/tmp/xchain-encoder-maintenance.json` | Where the encoder looks for an operator-declared scheduled-maintenance window. `health` and `GET /status` report it as `maintenance` beside the readiness fields, so a status board can tell a planned outage from a fault; it never changes a readiness field or the 503. See [Scheduled maintenance](#scheduled-maintenance) | | `CORS_ORIGIN` | No | Disabled | Allowed CORS origin(s): `*` for any, one origin, or a comma-separated allowlist matched per-origin (browser wallet shells each send a different origin). A stray `*` inside a list is not a wildcard, so the grant fails closed | diff --git a/src/api.js b/src/api.js index e71f0253..1184bfa0 100644 --- a/src/api.js +++ b/src/api.js @@ -26,7 +26,7 @@ const config = require('./common/config') // immediately below are exactly the ones an operator needs levelled and // timestamped, and installObservability does not run until ~190 lines further // down. -const { patchConsole } = require('./observability'); +const { patchConsole, getLogger } = require('./observability'); patchConsole({ service: 'xchain-encoder', version: require('../package.json').version, @@ -271,6 +271,9 @@ installObservability(app, { // close over the encoder instance and network built above, so the part hands // back the controller from a factory rather than a module-level literal. const jsonRpcController = require('./api/json_rpc_methods.js').createJsonRpcController({ encoder, NETWORK }) +if (encoder.consensusNetwork === 'mainnet' && config.ENCODER_MAINNET_PRELAUNCH_OVERRIDE) { + getLogger().warn('ENCODER_MAINNET_PRELAUNCH_OVERRIDE=' + config.ENCODER_MAINNET_PRELAUNCH_OVERRIDE + ' on a mainnet encoder: the prelaunch refusal is lifted only when it is 1, true, yes or on') +} // GET /status: returns 200 when the encoder's hard dependencies are reachable // and the UTXO tracker is synced, or 503 when not. Distinct from the JSON-RPC diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index 1ca3350b..93b4fce3 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -28,8 +28,42 @@ const { ParamTypeError, isInvalidParams } = require('../build/errors') const { getLogger } = require('../observability'); const { version: ENCODER_VERSION } = require('../../package.json') const { readMaintenanceWindow } = require('../server/maintenance_window') // operator-declared scheduled outage, reported beside readiness +const config = require('../common/config') +const { ACTION_ALIASES } = require('../common/validator') const logger = getLogger(); +// Unix time (2027-01-01T00:00:00Z) before which a mainnet encoder refuses to +// build anything but validator-set actions. +const MAINNET_LAUNCH_TS = 1798761600 +// The only actions a mainnet encoder builds before launch. +const VALIDATOR_ACTIONS = new Set(['STAKE', 'UNSTAKE', 'DELEGATE', 'SLASH', 'NODEPROOF', 'ROLLCALL']) +const OVERRIDE_TRUE = new Set(['1', 'true', 'yes', 'on']) + +// Operator override, read live from ENCODER_MAINNET_PRELAUNCH_OVERRIDE (1/true/yes/on). +function prelaunchOverrideFromEnv() { + return OVERRIDE_TRUE.has(String(config.ENCODER_MAINNET_PRELAUNCH_OVERRIDE || '').trim().toLowerCase()) +} + +// Canonical leading ACTION name of `data`, or null when there is none. +function actionNameOf(data) { + if (typeof data !== 'string' && !Buffer.isBuffer(data)) return null + const raw = (Buffer.isBuffer(data) ? data.toString('utf8') : data).split('|')[0] + return raw ? (ACTION_ALIASES[raw] ?? raw) : null +} + +// Refuse a non-validator build on a mainnet encoder before launch, unless the operator overrides. +function assertNotMainnetPrelaunch(encoder, params, { now, prelaunchOverride }) { + if (encoder.consensusNetwork !== 'mainnet') return + const nowSec = Math.floor(now() / 1000) + if (nowSec >= MAINNET_LAUNCH_TS) return + if (prelaunchOverride()) return + if (VALIDATOR_ACTIONS.has(actionNameOf(params.data))) return + const e = new Error('Mainnet is not open yet: only validator actions can be built before launch') + e.code = -32010 + e.data = { reason: 'MAINNET_PRELAUNCH', opens_at: MAINNET_LAUNCH_TS } + throw e +} + // Each builder closes one group of methods over the encoder (and, for fees, // the network name); the factory composes the groups into one controller. The // method text keeps its entry indentation so the openrpc guard still matches. @@ -174,7 +208,7 @@ function toRpcError(err) { } // The build itself; every error class it can raise maps to one JSON-RPC code. -function buildTransactionMethods({ encoder }) { +function buildTransactionMethods({ encoder, now, prelaunchOverride }) { return { async create_tx(rawParams) { let params @@ -186,6 +220,7 @@ return { throw e } + assertNotMainnetPrelaunch(encoder, params, { now, prelaunchOverride }) await assertNodeTierOrRpcError(encoder) let psbt try { @@ -383,11 +418,12 @@ return { // Made here rather than at module scope because the methods close over the // 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 }) { +// now and prelaunchOverride default to the wall clock and the live env override; tests inject both. +function createJsonRpcController({ encoder, NETWORK, now = Date.now, prelaunchOverride = prelaunchOverrideFromEnv }) { return Object.assign( buildReadinessMethods({ encoder }), buildFeeMethods({ encoder, NETWORK }), - buildTransactionMethods({ encoder }), + buildTransactionMethods({ encoder, now, prelaunchOverride }), buildCancelMethods({ encoder }), buildReleaseMethods({ encoder }), buildBroadcastMethods({ encoder }), @@ -395,4 +431,4 @@ function createJsonRpcController({ encoder, NETWORK }) { ) } -module.exports = { createJsonRpcController } +module.exports = { createJsonRpcController, MAINNET_LAUNCH_TS, VALIDATOR_ACTIONS } diff --git a/src/common/config.js b/src/common/config.js index 0fc7e017..4617bea4 100644 --- a/src/common/config.js +++ b/src/common/config.js @@ -67,6 +67,8 @@ const config = { get ENCODER_MAX_CONCURRENT_PROBES() { return process.env.ENCODER_MAX_CONCURRENT_PROBES; }, get ENCODER_MAX_CONCURRENT_REQUESTS() { return process.env.ENCODER_MAX_CONCURRENT_REQUESTS; }, get ENCODER_MAX_RPC_BATCH() { return process.env.ENCODER_MAX_RPC_BATCH; }, + // Operator override of the mainnet prelaunch refusal (src/api/json_rpc_methods.js); raw string, parsed at the call site. + get ENCODER_MAINNET_PRELAUNCH_OVERRIDE() { return process.env.ENCODER_MAINNET_PRELAUNCH_OVERRIDE; }, }; module.exports = config; diff --git a/test/unit/api/mainnet_prelaunch_refusal.test.js b/test/unit/api/mainnet_prelaunch_refusal.test.js new file mode 100644 index 00000000..80cf2af4 --- /dev/null +++ b/test/unit/api/mainnet_prelaunch_refusal.test.js @@ -0,0 +1,108 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A mainnet encoder refuses create_tx for non-validator actions before 1798761600, +// with MAINNET_PRELAUNCH, unless the operator sets ENCODER_MAINNET_PRELAUNCH_OVERRIDE. + +const assert = require('assert') +const { createJsonRpcController, MAINNET_LAUNCH_TS } = require('../../../src/api/json_rpc_methods') +const { makeEncoder, getTestAddress } = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +const ENV = 'ENCODER_MAINNET_PRELAUNCH_OVERRIDE' +const BEFORE = () => (MAINNET_LAUNCH_TS - 60) * 1000 +const AT = () => MAINNET_LAUNCH_TS * 1000 + +function setup (network, extra = {}) { + const encoder = makeEncoder(network) + let built = 0 + encoder.createTransaction = async () => { + built++ + return { psbt: { toHex: () => 'aa' } } + } + const address = getTestAddress(network) + const controller = createJsonRpcController({ encoder, NETWORK: network, now: BEFORE, ...extra }) + return { controller, address, built: () => built } +} +const params = (address, data) => ({ pubkey: address, data, change: address, unconfirmed: true, feePerKb: 5000 }) + +describe('mainnet prelaunch refusal', () => { + let saved + beforeEach(() => { saved = process.env[ENV]; delete process.env[ENV] }) + afterEach(() => { if (saved === undefined) delete process.env[ENV]; else process.env[ENV] = saved }) + + it('refuses a non-validator action before launch as -32010 MAINNET_PRELAUNCH', async () => { + const { controller, address, built } = setup('bitcoin-mainnet') + await assert.rejects(controller.create_tx(params(address, actions.makeSend().data)), (err) => { + assert.strictEqual(err.code, -32010) + assert.strictEqual(err.data.reason, 'MAINNET_PRELAUNCH') + assert.strictEqual(err.data.opens_at, MAINNET_LAUNCH_TS) + return true + }) + assert.strictEqual(built(), 0) + }) + + it('refuses an alias of a non-validator action (TRANSFER)', async () => { + const { controller, address } = setup('bitcoin-mainnet') + await assert.rejects(controller.create_tx(params(address, 'TRANSFER|0|JDOG|1|' + address)), + (err) => err.data.reason === 'MAINNET_PRELAUNCH') + }) + + for (const name of ['STAKE', 'UNSTAKE', 'DELEGATE', 'SLASH', 'NODEPROOF', 'ROLLCALL']) { + it(`lets ${name} through before launch`, async () => { + const { controller, address, built } = setup('bitcoin-mainnet') + let refusal = null + try { await controller.create_tx(params(address, name + '|x')) } catch (err) { refusal = err } + // Either the stub build ran, or a later param check spoke; never the prelaunch gate. + assert.ok(built() === 1 || (refusal && !(refusal.data && refusal.data.reason === 'MAINNET_PRELAUNCH'))) + }) + } + + it('builds a non-validator action at and after launch', async () => { + const { controller, address, built } = setup('bitcoin-mainnet', { now: AT }) + await controller.create_tx(params(address, actions.makeSend().data)) + assert.strictEqual(built(), 1) + }) + + it('never refuses a testnet encoder', async () => { + const { controller, address, built } = setup('bitcoin-testnet') + await controller.create_tx(params(address, actions.makeSend().data)) + assert.strictEqual(built(), 1) + }) + + describe('operator override via the environment variable', () => { + for (const value of ['1', 'true', 'YES', 'on']) { + it(`${ENV}=${value} lifts the refusal with no factory option`, async () => { + const { controller, address, built } = setup('bitcoin-mainnet') + process.env[ENV] = value + await controller.create_tx(params(address, actions.makeSend().data)) + assert.strictEqual(built(), 1) + }) + } + + it('is read live: unsetting it restores the refusal on the same controller', async () => { + const { controller, address } = setup('bitcoin-mainnet') + process.env[ENV] = '1' + await controller.create_tx(params(address, actions.makeSend().data)) + delete process.env[ENV] + await assert.rejects(controller.create_tx(params(address, actions.makeSend().data)), + (err) => err.data.reason === 'MAINNET_PRELAUNCH') + }) + + for (const value of ['0', 'false', '', 'maybe']) { + it(`${ENV}=${JSON.stringify(value)} leaves the refusal in force`, async () => { + const { controller, address } = setup('bitcoin-mainnet') + process.env[ENV] = value + await assert.rejects(controller.create_tx(params(address, actions.makeSend().data)), + (err) => err.data.reason === 'MAINNET_PRELAUNCH') + }) + } + }) +}) diff --git a/test/unit/api/node_tier_gate.test.js b/test/unit/api/node_tier_gate.test.js index d8bd80b4..7d0b2b33 100644 --- a/test/unit/api/node_tier_gate.test.js +++ b/test/unit/api/node_tier_gate.test.js @@ -79,4 +79,15 @@ describe('JSON-RPC node tier gate', () => { assert.strictEqual(typeof result.psbt, 'string') assert.ok(entered.includes('build')) }) + + it('the mainnet prelaunch refusal fires before the node tier check', async () => { + const { encoder, entered } = testnetEncoderOn('main') + encoder.consensusNetwork = 'mainnet' + const controller = createJsonRpcController({ encoder, NETWORK, now: () => 0 }) + await assert.rejects(controller.create_tx(sendParams()), (err) => { + assert.strictEqual(err.data.reason, 'MAINNET_PRELAUNCH') + return true + }) + assert.deepStrictEqual(entered, []) + }) }) From d4a0803af470567a9a37977d397934bb7e145cb3 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 18:25:47 +0000 Subject: [PATCH 07/22] Move the prelaunch gate into config and split its tests json_rpc_methods.js is back to 400 lines: the gate, its launch time and the validator action set now live in src/common/config.js beside the override it reads, and create_tx makes one call to it. The clock is stubbed through Date.now in the tests, which are split into four describe blocks so none passes 60 lines. --- src/api/json_rpc_methods.js | 44 +----- src/common/config.js | 31 ++++- .../api/mainnet_prelaunch_refusal.test.js | 128 ++++++++++-------- test/unit/api/node_tier_gate.test.js | 14 +- 4 files changed, 115 insertions(+), 102 deletions(-) diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index 93b4fce3..a865f4f5 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -29,41 +29,8 @@ const { getLogger } = require('../observability'); const { version: ENCODER_VERSION } = require('../../package.json') const { readMaintenanceWindow } = require('../server/maintenance_window') // operator-declared scheduled outage, reported beside readiness const config = require('../common/config') -const { ACTION_ALIASES } = require('../common/validator') const logger = getLogger(); -// Unix time (2027-01-01T00:00:00Z) before which a mainnet encoder refuses to -// build anything but validator-set actions. -const MAINNET_LAUNCH_TS = 1798761600 -// The only actions a mainnet encoder builds before launch. -const VALIDATOR_ACTIONS = new Set(['STAKE', 'UNSTAKE', 'DELEGATE', 'SLASH', 'NODEPROOF', 'ROLLCALL']) -const OVERRIDE_TRUE = new Set(['1', 'true', 'yes', 'on']) - -// Operator override, read live from ENCODER_MAINNET_PRELAUNCH_OVERRIDE (1/true/yes/on). -function prelaunchOverrideFromEnv() { - return OVERRIDE_TRUE.has(String(config.ENCODER_MAINNET_PRELAUNCH_OVERRIDE || '').trim().toLowerCase()) -} - -// Canonical leading ACTION name of `data`, or null when there is none. -function actionNameOf(data) { - if (typeof data !== 'string' && !Buffer.isBuffer(data)) return null - const raw = (Buffer.isBuffer(data) ? data.toString('utf8') : data).split('|')[0] - return raw ? (ACTION_ALIASES[raw] ?? raw) : null -} - -// Refuse a non-validator build on a mainnet encoder before launch, unless the operator overrides. -function assertNotMainnetPrelaunch(encoder, params, { now, prelaunchOverride }) { - if (encoder.consensusNetwork !== 'mainnet') return - const nowSec = Math.floor(now() / 1000) - if (nowSec >= MAINNET_LAUNCH_TS) return - if (prelaunchOverride()) return - if (VALIDATOR_ACTIONS.has(actionNameOf(params.data))) return - const e = new Error('Mainnet is not open yet: only validator actions can be built before launch') - e.code = -32010 - e.data = { reason: 'MAINNET_PRELAUNCH', opens_at: MAINNET_LAUNCH_TS } - throw e -} - // Each builder closes one group of methods over the encoder (and, for fees, // the network name); the factory composes the groups into one controller. The // method text keeps its entry indentation so the openrpc guard still matches. @@ -208,7 +175,7 @@ function toRpcError(err) { } // The build itself; every error class it can raise maps to one JSON-RPC code. -function buildTransactionMethods({ encoder, now, prelaunchOverride }) { +function buildTransactionMethods({ encoder }) { return { async create_tx(rawParams) { let params @@ -220,7 +187,7 @@ return { throw e } - assertNotMainnetPrelaunch(encoder, params, { now, prelaunchOverride }) + config.assertNotMainnetPrelaunch(encoder, params) await assertNodeTierOrRpcError(encoder) let psbt try { @@ -418,12 +385,11 @@ return { // Made here rather than at module scope because the methods close over the // encoder and network the entry builds from its environment, so requiring this // file constructs nothing. Group order is the dispatch table's key order. -// now and prelaunchOverride default to the wall clock and the live env override; tests inject both. -function createJsonRpcController({ encoder, NETWORK, now = Date.now, prelaunchOverride = prelaunchOverrideFromEnv }) { +function createJsonRpcController({ encoder, NETWORK }) { return Object.assign( buildReadinessMethods({ encoder }), buildFeeMethods({ encoder, NETWORK }), - buildTransactionMethods({ encoder, now, prelaunchOverride }), + buildTransactionMethods({ encoder }), buildCancelMethods({ encoder }), buildReleaseMethods({ encoder }), buildBroadcastMethods({ encoder }), @@ -431,4 +397,4 @@ function createJsonRpcController({ encoder, NETWORK, now = Date.now, prelaunchOv ) } -module.exports = { createJsonRpcController, MAINNET_LAUNCH_TS, VALIDATOR_ACTIONS } +module.exports = { createJsonRpcController } diff --git a/src/common/config.js b/src/common/config.js index 4617bea4..a0aae1c7 100644 --- a/src/common/config.js +++ b/src/common/config.js @@ -67,8 +67,37 @@ const config = { get ENCODER_MAX_CONCURRENT_PROBES() { return process.env.ENCODER_MAX_CONCURRENT_PROBES; }, get ENCODER_MAX_CONCURRENT_REQUESTS() { return process.env.ENCODER_MAX_CONCURRENT_REQUESTS; }, get ENCODER_MAX_RPC_BATCH() { return process.env.ENCODER_MAX_RPC_BATCH; }, - // Operator override of the mainnet prelaunch refusal (src/api/json_rpc_methods.js); raw string, parsed at the call site. + // Operator override of the mainnet prelaunch refusal; raw string, parsed by assertNotMainnetPrelaunch below. get ENCODER_MAINNET_PRELAUNCH_OVERRIDE() { return process.env.ENCODER_MAINNET_PRELAUNCH_OVERRIDE; }, }; +// Unix time (2027-01-01T00:00:00Z) before which a mainnet encoder builds only validator actions. +const MAINNET_LAUNCH_TS = 1798761600 +const VALIDATOR_ACTIONS = new Set(['STAKE', 'UNSTAKE', 'DELEGATE', 'SLASH', 'NODEPROOF', 'ROLLCALL']) +const OVERRIDE_TRUE = new Set(['1', 'true', 'yes', 'on']) + +// Canonical leading ACTION name of `data`, or null when there is none. +function actionNameOf(data) { + if (typeof data !== 'string' && !Buffer.isBuffer(data)) return null + const raw = (Buffer.isBuffer(data) ? data.toString('utf8') : data).split('|')[0] + return raw ? (require('./validator').ACTION_ALIASES[raw] ?? raw) : null +} + +// create_tx guard: a mainnet encoder refuses a non-validator build before launch (-32010 +// MAINNET_PRELAUNCH) unless ENCODER_MAINNET_PRELAUNCH_OVERRIDE is 1/true/yes/on, read live. +function assertNotMainnetPrelaunch(encoder, params, now = Date.now) { + if (encoder.consensusNetwork !== 'mainnet' || Math.floor(now() / 1000) >= MAINNET_LAUNCH_TS) return + if (OVERRIDE_TRUE.has(String(config.ENCODER_MAINNET_PRELAUNCH_OVERRIDE || '').trim().toLowerCase())) return + if (VALIDATOR_ACTIONS.has(actionNameOf(params.data))) return + const e = new Error('Mainnet is not open yet: only validator actions can be built before launch') + e.code = -32010 + e.data = { reason: 'MAINNET_PRELAUNCH', opens_at: MAINNET_LAUNCH_TS } + throw e +} + module.exports = config; +Object.defineProperties(config, { + MAINNET_LAUNCH_TS: { value: MAINNET_LAUNCH_TS }, + VALIDATOR_ACTIONS: { value: VALIDATOR_ACTIONS }, + assertNotMainnetPrelaunch: { value: assertNotMainnetPrelaunch }, +}) diff --git a/test/unit/api/mainnet_prelaunch_refusal.test.js b/test/unit/api/mainnet_prelaunch_refusal.test.js index 80cf2af4..e0c6e7f7 100644 --- a/test/unit/api/mainnet_prelaunch_refusal.test.js +++ b/test/unit/api/mainnet_prelaunch_refusal.test.js @@ -12,97 +12,111 @@ // with MAINNET_PRELAUNCH, unless the operator sets ENCODER_MAINNET_PRELAUNCH_OVERRIDE. const assert = require('assert') -const { createJsonRpcController, MAINNET_LAUNCH_TS } = require('../../../src/api/json_rpc_methods') +const { createJsonRpcController } = require('../../../src/api/json_rpc_methods') +const { MAINNET_LAUNCH_TS } = require('../../../src/common/config') const { makeEncoder, getTestAddress } = require('../../integration/helpers/utxoFactory') const actions = require('../../integration/helpers/actionFactory') const ENV = 'ENCODER_MAINNET_PRELAUNCH_OVERRIDE' -const BEFORE = () => (MAINNET_LAUNCH_TS - 60) * 1000 -const AT = () => MAINNET_LAUNCH_TS * 1000 +const BEFORE = (MAINNET_LAUNCH_TS - 60) * 1000 +const AT = MAINNET_LAUNCH_TS * 1000 -function setup (network, extra = {}) { +let built, address, controller, savedEnv, realNow + +function setup (network, clock = BEFORE) { const encoder = makeEncoder(network) - let built = 0 + built = 0 encoder.createTransaction = async () => { built++ return { psbt: { toHex: () => 'aa' } } } - const address = getTestAddress(network) - const controller = createJsonRpcController({ encoder, NETWORK: network, now: BEFORE, ...extra }) - return { controller, address, built: () => built } + address = getTestAddress(network) + controller = createJsonRpcController({ encoder, NETWORK: network }) + Date.now = () => clock +} +const params = (data) => ({ pubkey: address, data, change: address, unconfirmed: true, feePerKb: 5000 }) +const send = () => controller.create_tx(params(actions.makeSend().data)) +const isPrelaunch = (err) => err.data.reason === 'MAINNET_PRELAUNCH' + +function isolateClockAndEnv () { + beforeEach(() => { savedEnv = process.env[ENV]; realNow = Date.now; delete process.env[ENV] }) + afterEach(() => { + Date.now = realNow + if (savedEnv === undefined) delete process.env[ENV]; else process.env[ENV] = savedEnv + }) } -const params = (address, data) => ({ pubkey: address, data, change: address, unconfirmed: true, feePerKb: 5000 }) describe('mainnet prelaunch refusal', () => { - let saved - beforeEach(() => { saved = process.env[ENV]; delete process.env[ENV] }) - afterEach(() => { if (saved === undefined) delete process.env[ENV]; else process.env[ENV] = saved }) + isolateClockAndEnv() it('refuses a non-validator action before launch as -32010 MAINNET_PRELAUNCH', async () => { - const { controller, address, built } = setup('bitcoin-mainnet') - await assert.rejects(controller.create_tx(params(address, actions.makeSend().data)), (err) => { + setup('bitcoin-mainnet') + await assert.rejects(send(), (err) => { assert.strictEqual(err.code, -32010) assert.strictEqual(err.data.reason, 'MAINNET_PRELAUNCH') assert.strictEqual(err.data.opens_at, MAINNET_LAUNCH_TS) return true }) - assert.strictEqual(built(), 0) + assert.strictEqual(built, 0) }) it('refuses an alias of a non-validator action (TRANSFER)', async () => { - const { controller, address } = setup('bitcoin-mainnet') - await assert.rejects(controller.create_tx(params(address, 'TRANSFER|0|JDOG|1|' + address)), - (err) => err.data.reason === 'MAINNET_PRELAUNCH') + setup('bitcoin-mainnet') + await assert.rejects(controller.create_tx(params('TRANSFER|0|JDOG|1|' + address)), isPrelaunch) }) + it('builds a non-validator action at and after launch', async () => { + setup('bitcoin-mainnet', AT) + await send() + assert.strictEqual(built, 1) + }) + + it('never refuses a testnet encoder', async () => { + setup('bitcoin-testnet') + await send() + assert.strictEqual(built, 1) + }) +}) + +describe('mainnet prelaunch validator actions', () => { + isolateClockAndEnv() + for (const name of ['STAKE', 'UNSTAKE', 'DELEGATE', 'SLASH', 'NODEPROOF', 'ROLLCALL']) { it(`lets ${name} through before launch`, async () => { - const { controller, address, built } = setup('bitcoin-mainnet') + setup('bitcoin-mainnet') let refusal = null - try { await controller.create_tx(params(address, name + '|x')) } catch (err) { refusal = err } + try { await controller.create_tx(params(name + '|x')) } catch (err) { refusal = err } // Either the stub build ran, or a later param check spoke; never the prelaunch gate. - assert.ok(built() === 1 || (refusal && !(refusal.data && refusal.data.reason === 'MAINNET_PRELAUNCH'))) + assert.ok(built === 1 || (refusal && !(refusal.data && isPrelaunch(refusal)))) }) } +}) - it('builds a non-validator action at and after launch', async () => { - const { controller, address, built } = setup('bitcoin-mainnet', { now: AT }) - await controller.create_tx(params(address, actions.makeSend().data)) - assert.strictEqual(built(), 1) - }) +describe('mainnet prelaunch operator override', () => { + isolateClockAndEnv() - it('never refuses a testnet encoder', async () => { - const { controller, address, built } = setup('bitcoin-testnet') - await controller.create_tx(params(address, actions.makeSend().data)) - assert.strictEqual(built(), 1) - }) - - describe('operator override via the environment variable', () => { - for (const value of ['1', 'true', 'YES', 'on']) { - it(`${ENV}=${value} lifts the refusal with no factory option`, async () => { - const { controller, address, built } = setup('bitcoin-mainnet') - process.env[ENV] = value - await controller.create_tx(params(address, actions.makeSend().data)) - assert.strictEqual(built(), 1) - }) - } - - it('is read live: unsetting it restores the refusal on the same controller', async () => { - const { controller, address } = setup('bitcoin-mainnet') - process.env[ENV] = '1' - await controller.create_tx(params(address, actions.makeSend().data)) - delete process.env[ENV] - await assert.rejects(controller.create_tx(params(address, actions.makeSend().data)), - (err) => err.data.reason === 'MAINNET_PRELAUNCH') + for (const value of ['1', 'true', 'YES', 'on']) { + it(`${ENV}=${value} lifts the refusal`, async () => { + setup('bitcoin-mainnet') + process.env[ENV] = value + await send() + assert.strictEqual(built, 1) }) + } - for (const value of ['0', 'false', '', 'maybe']) { - it(`${ENV}=${JSON.stringify(value)} leaves the refusal in force`, async () => { - const { controller, address } = setup('bitcoin-mainnet') - process.env[ENV] = value - await assert.rejects(controller.create_tx(params(address, actions.makeSend().data)), - (err) => err.data.reason === 'MAINNET_PRELAUNCH') - }) - } + it('is read live: unsetting it restores the refusal on the same controller', async () => { + setup('bitcoin-mainnet') + process.env[ENV] = '1' + await send() + delete process.env[ENV] + await assert.rejects(send(), isPrelaunch) }) + + for (const value of ['0', 'false', '', 'maybe']) { + it(`${ENV}=${JSON.stringify(value)} leaves the refusal in force`, async () => { + setup('bitcoin-mainnet') + process.env[ENV] = value + await assert.rejects(send(), isPrelaunch) + }) + } }) diff --git a/test/unit/api/node_tier_gate.test.js b/test/unit/api/node_tier_gate.test.js index 7d0b2b33..41ab4b05 100644 --- a/test/unit/api/node_tier_gate.test.js +++ b/test/unit/api/node_tier_gate.test.js @@ -81,13 +81,17 @@ describe('JSON-RPC node tier gate', () => { }) it('the mainnet prelaunch refusal fires before the node tier check', async () => { + const realNow = Date.now + Date.now = () => 0 const { encoder, entered } = testnetEncoderOn('main') encoder.consensusNetwork = 'mainnet' - const controller = createJsonRpcController({ encoder, NETWORK, now: () => 0 }) - await assert.rejects(controller.create_tx(sendParams()), (err) => { - assert.strictEqual(err.data.reason, 'MAINNET_PRELAUNCH') - return true - }) + const controller = createJsonRpcController({ encoder, NETWORK }) + try { + await assert.rejects(controller.create_tx(sendParams()), (err) => { + assert.strictEqual(err.data.reason, 'MAINNET_PRELAUNCH') + return true + }) + } finally { Date.now = realNow } assert.deepStrictEqual(entered, []) }) }) From c4985d91611a856b51655f2214a5efc7ab741f32 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 13 Oct 2026 13:40:42 -0700 Subject: [PATCH 08/22] Route contributions to develop and guard master against non-release pull requests Contributors now branch off develop and open pull requests against it, as the docs and PR template say. A new pull_request_target check fails any pull request into master that is not this repository's own develop, release/* or hotfix/* branch, and CI now runs on pull requests into develop. --- .github/pull_request_template.md | 4 +++ .github/workflows/base-branch-guard.yml | 40 +++++++++++++++++++++++++ .github/workflows/ci.yml | 2 +- CONTRIBUTING.md | 2 +- 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/base-branch-guard.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6241305c..061bdbb5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,9 @@ +## Base branch + +- [ ] This pull request targets `develop`. `master` only takes release merges, and a pull request into it from a fork cannot be merged. + ## What this changes diff --git a/.github/workflows/base-branch-guard.yml b/.github/workflows/base-branch-guard.yml new file mode 100644 index 00000000..ad3e8dab --- /dev/null +++ b/.github/workflows/base-branch-guard.yml @@ -0,0 +1,40 @@ +# Fails any pull request into master that does not come from this repository's +# own develop, release/* or hotfix/* branch. master is what production runs, so +# outside contributions land on develop first and reach master only in a release +# merge (hotfix/* is the documented emergency path, see the release process). +# master's branch protection requires this check, so a fork pull request aimed +# at master cannot be merged by mistake. +# +# pull_request_target reads this file from the repository's own branch, never +# from the pull request, so a pull request cannot edit the guard away. The job +# reads only event metadata and never checks out pull request code. +name: Base branch guard + +on: + pull_request_target: + branches: [master] + # edited fires when an open pull request is retargeted onto master. + types: [opened, reopened, synchronize, edited] + +permissions: {} + +jobs: + base-branch-guard: + name: base-branch guard + runs-on: ubuntu-latest + steps: + - name: Require develop, release/* or hotfix/* from this repository + # Event fields go through env, never inline, so a branch name cannot inject shell. + env: + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + THIS_REPO: ${{ github.repository }} + run: | + if [ "$HEAD_REPO" != "$THIS_REPO" ]; then + echo "::error::Pull requests from forks go to develop, not master. Change the base branch to develop." + exit 1 + fi + case "$HEAD_REF" in + develop|release/*|hotfix/*) echo "ok: $HEAD_REF into master" ;; + *) echo "::error::master only accepts develop, release/* or hotfix/*; retarget $HEAD_REF to develop."; exit 1 ;; + esac diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6268163..d0ca3a8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ on: # so the merge commit a release tag points at has its own green run. branches: [master, develop] pull_request: - branches: [master] + branches: [master, develop] jobs: ci: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3162a8b1..659acfba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,7 +90,7 @@ Run the no-external-services tiers before every commit (`npm run ci`, `npm run t Match the existing log style: a concise subject line, then a short body explaining what changed and why. -- Branch off `master` and keep history linear (rebase, don't merge). +- Fork the repository, branch off `develop`, and open your pull request against `develop`. Keep history linear (rebase, don't merge). `master` is what production runs and only ever receives release merges from `develop`; a pull request into `master` from a fork or any other branch fails the `base-branch guard` check and cannot be merged. - One logical change per commit; don't batch unrelated work. - **No `Co-Authored-By` trailers.** This is a project policy. - **Never `--no-verify`.** If a hook fails, fix the cause; don't bypass it. From 4f7a5f7ff43883aa708d4758bee399213e927d0c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 14 Oct 2026 02:34:08 -0700 Subject: [PATCH 09/22] Price transactions at their full size, select inputs against the final fee and refuse sub-dust payments --- src/XChainEncoder.js | 5 + .../build_transaction/data_outputs.js | 19 ++- .../build_transaction/fee_rates.js | 6 + .../build_transaction/fee_settlement.js | 24 +++- .../build_transaction/input_candidates.js | 28 ++++- .../build_transaction/input_selection.js | 54 ++++++-- .../build_transaction/payload_checks.js | 26 +++- .../build_transaction/script_hash_legs.js | 26 +++- src/XChainEncoder/size_estimation.js | 20 +-- src/api/json_rpc_methods.js | 2 +- src/common/validator.js | 8 +- src/common/validator/fee_and_utxo_checks.js | 27 ++-- src/common/validator/transaction_checks.js | 10 +- .../01_relay_floor_size_estimate.test.js | 46 +++++++ .../02_fee_floor_selection.test.js | 61 +++++++++ .../03_deterministic_order.test.js | 60 +++++++++ .../04_p2wsh_reveal_pad_pricing.test.js | 53 ++++++++ .../05_payment_dust_floor.test.js | 88 +++++++++++++ .../06_missing_confirmations.test.js | 56 +++++++++ .../helpers/build_fixture.js | 118 ++++++++++++++++++ ...chain_encoder_p2sh_reveal_headroom.test.js | 15 ++- 21 files changed, 690 insertions(+), 62 deletions(-) create mode 100644 test/unit/xchain_encoder/build_correctness.test/01_relay_floor_size_estimate.test.js create mode 100644 test/unit/xchain_encoder/build_correctness.test/02_fee_floor_selection.test.js create mode 100644 test/unit/xchain_encoder/build_correctness.test/03_deterministic_order.test.js create mode 100644 test/unit/xchain_encoder/build_correctness.test/04_p2wsh_reveal_pad_pricing.test.js create mode 100644 test/unit/xchain_encoder/build_correctness.test/05_payment_dust_floor.test.js create mode 100644 test/unit/xchain_encoder/build_correctness.test/06_missing_confirmations.test.js create mode 100644 test/unit/xchain_encoder/build_correctness.test/helpers/build_fixture.js diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index a316b4a9..0ea0ec75 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -151,6 +151,11 @@ class XChainEncoder { softDustFloorFor(network), (Number.isFinite(operatorDust) && operatorDust > 0) ? Math.floor(operatorDust) : 0 ) + // The relay dust threshold a caller's payment output must reach: the pinned hard + // threshold (BTC 546, LTC 5460, DOGE 100000). Not the DOGE 0.01 soft limit: wallet + // native-fee outputs are padded only to the hard threshold, and the smallest fees sit + // under the soft limit. Nor the operator DUST_AMOUNT, a floor on authored outputs. + this.paymentOutputFloor = this.dustAmount // Maximum fee rate in BTC/byte (null = no cap). Prevents runaway estimates // (e.g. regtest feedback loop) from producing fees that the node will reject. // MAX_FEE_RATE_KB is in sat/kB, convert to BTC/byte to match feePerBytes units. diff --git a/src/XChainEncoder/build_transaction/data_outputs.js b/src/XChainEncoder/build_transaction/data_outputs.js index 8149cdd6..36cd3693 100644 --- a/src/XChainEncoder/build_transaction/data_outputs.js +++ b/src/XChainEncoder/build_transaction/data_outputs.js @@ -20,7 +20,7 @@ const bitcoin = require('bitcoinjs-lib'); const TxSizeEstimator = require('../../build/tx_size_estimator') -const { parseSatoshiAmount } = require('../../common/validator') +const { parseSatoshiAmount, assertPaymentOverDust } = require('../../common/validator') const { SATOSHI_UNIT, Encoding } = require('../constants.js') const { resolveCallerAddress } = require('../request_resolution.js') const { emitP2shChunk, emitP2wshChunk } = require('./script_hash_legs.js') @@ -123,7 +123,12 @@ function initEmissionState(build){ // revealFee - the fee that output currently prefunds let revealPrefund = null - let estimatedTxSize = 0 + // The transaction header: 4 version + 1 input-count varint + 1 output-count + // varint + 4 locktime. Every output and input below adds only its own bytes, so + // starting at 0 left each single-transaction fee 10 vB short, enough to put a + // many-input build at the relay rate under 1 sat/vB. The segwit marker and flag, + // and wider count varints, are added by input selection. + let estimatedTxSize = 10 Object.assign(build, { outputSatoshis, voutPsbtIndex, obfuscatedData, p2shRevealHeadroomFunded, phaseLegInputSatoshis, p2shTx, envelopeContext, revealPrefund, estimatedTxSize }) } @@ -282,7 +287,7 @@ function emitEnvelopeCommit(build, nextDataBuffer){ } function emitCustomOutputs(build){ - let { p2shHash, isP2shFamily, customOutputs, psbt, outputSatoshis, estimatedTxSize } = build + let { p2shHash, isP2shFamily, customOutputs, feeQuoteOutput, psbt, outputSatoshis, estimatedTxSize } = build // Process custom outputs (e.g., COINPay native coin payment outputs, or // the native-fee protocol-fee output). On a P2SH/P2WSH FUNDING tx // (p2shHash null) these are NOT emitted here: their value was folded into @@ -300,11 +305,15 @@ function emitCustomOutputs(build){ // the effector too (not just the validator boundary), matching the // native-fee output which is always positive (guarded at injection // above), so this never rejects a legitimate sub-dust FEE_DESTINATION - // value. Interim safe rule: reject only value <= 0, not a full - // per-network dust floor. + // value. if (outputValue <= 0) { throw new ParamRangeError(`customOutputs[${i}].value must be a positive integer (satoshis)`) } + // A caller payment also has to reach the coin's relay dust threshold + // (checkPaymentDust refused it already; this holds the effector to the + // same rule). The feeQuote protocol-fee output keeps only the positive + // rule above: its amount is priced by the hub, not chosen by the caller. + if (output !== feeQuoteOutput) assertPaymentOverDust(outputValue, i, this.paymentOutputFloor) psbt.addOutput({ address: output.address, value: outputValue diff --git a/src/XChainEncoder/build_transaction/fee_rates.js b/src/XChainEncoder/build_transaction/fee_rates.js index 2c0ddb73..aa3520d1 100644 --- a/src/XChainEncoder/build_transaction/fee_rates.js +++ b/src/XChainEncoder/build_transaction/fee_rates.js @@ -110,6 +110,12 @@ function* useNodeFeeRate(build){ } feePerBytes = suggestedCap } + // Never price a build under the node's own relay floor. An estimate can sit + // under relayfee (a stale or custom estimator, a misconfigured ceiling), and a + // caller who left the rate to the node has nothing to fix, so lift the rate here, + // before selection, rather than refuse the build afterwards. + const relayFeePerBytes = relayFeePerKb / 1000 + if (feePerBytes < relayFeePerBytes) feePerBytes = relayFeePerBytes Object.assign(build, { feePerBytes, nodeFeePerBytes }) } diff --git a/src/XChainEncoder/build_transaction/fee_settlement.js b/src/XChainEncoder/build_transaction/fee_settlement.js index 8c51fc03..8a3d1c56 100644 --- a/src/XChainEncoder/build_transaction/fee_settlement.js +++ b/src/XChainEncoder/build_transaction/fee_settlement.js @@ -71,17 +71,26 @@ 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 + const rateFee = hasExplicitFee ? estimatedFee : p2shHash ? feeForVsize(estimatedTxSize, feePerBytes, SATOSHI_UNIT) : estimatedFee + // A caller's fee or rate is judged as supplied. The node path is judged on the fee + // it will charge, which floorEstimatedFee never lets fall under the dust threshold. + const isNodePath = !hasExplicitFee && !hasCallerRate + const suppliedFee = isNodePath ? Math.max(rateFee, this.dustAmount) : rateFee const relayFeeBaseUnitsPerKb = Math.round(relayFeePerKb * SATOSHI_UNIT) const minimumRelayFee = Math.ceil(estimatedTxSize * relayFeeBaseUnitsPerKb / 1000) if (suppliedFee >= minimumRelayFee) return + // The node-estimate path: resolveFeeRates lifted the rate to the relay floor, so a + // fee under it here is an estimator or operator-cap fault, not a caller error. A + // plain Error (API -32603) refuses the unrelayable build without blaming the params. + if (isNodePath){ + throw new Error(`fee ${suppliedFee} from the node fee estimate is below the node relay minimum ${minimumRelayFee} base units for a ~${estimatedTxSize}-byte transaction`) + } if (hasExplicitFee){ throw new ParamRangeError(`fee ${suppliedFee} is below the node relay minimum ${minimumRelayFee} base units for a ~${estimatedTxSize}-byte transaction`) } @@ -286,7 +295,7 @@ if (allowed > 0){ } function computeChange(build){ - let { estimatedFee, inputSatoshis, outputSatoshis, p2shHash, reservedCandidates, change } = build + let { estimatedFee, inputSatoshis, outputSatoshis, p2shHash, reservedCandidates, change, requestedFee } = 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 @@ -320,10 +329,15 @@ function computeChange(build){ ? `; ${reservedCandidates} candidate input(s) are reserved by a transaction built in the last ` + `${Math.round(RESERVATION_TTL_MS / 60000)} minutes; broadcast that transaction or wait for the reservation to lapse` : '' + // The fee named is the one this build charges. When the floor raised an + // explicit fee, say so: the caller asked for less and should see why more is required. + const raised = (requestedFee != null && requestedFee !== estimatedFee) + const raisedNote = raised ? `, raised from the requested fee ${requestedFee}` : '' throw new OperationalError( 'INSUFFICIENT_FUNDS', - `insufficient funds: selected inputs total ${inputSatoshis} but ${required} is required (outputs ${outputSatoshis} + fee ${estimatedFee})${held}`, - { required: jsonSafeSat(required), available: jsonSafeSat(inputSatoshis), outputs: jsonSafeSat(outputSatoshis), fee: estimatedFee, reservedCandidates } + `insufficient funds: selected inputs total ${inputSatoshis} but ${required} is required (outputs ${outputSatoshis} + fee ${estimatedFee}${raisedNote})${held}`, + { required: jsonSafeSat(required), available: jsonSafeSat(inputSatoshis), outputs: jsonSafeSat(outputSatoshis), fee: estimatedFee, + ...(raised ? { requestedFee } : {}), reservedCandidates } ) } diff --git a/src/XChainEncoder/build_transaction/input_candidates.js b/src/XChainEncoder/build_transaction/input_candidates.js index d34a8fd1..731066a2 100644 --- a/src/XChainEncoder/build_transaction/input_candidates.js +++ b/src/XChainEncoder/build_transaction/input_candidates.js @@ -24,6 +24,14 @@ const { upstreamErrorMessage } = require('../../common/error_sanitize') const { resolveCallerAddress } = require('../request_resolution.js') const { isRemoteProfile, throwIfRefused, trackerUnreachableError } = require('../trackerless_profile.js') +// Whether a candidate is still in the mempool. A missing confirmations field counts +// as 0, the default validateUtxoEntry writes on the API and tracker paths; a library +// caller skips that validator, and its UTXO objects are read here, never rewritten. +// Loose == 0 keeps the validator's coercion ('0' and 0 alike). +function isUnconfirmedUtxo(utxo){ + return utxo.confirmations == null || utxo.confirmations == 0 +} + function initInputState(build){ let { replacebyfee, p2shHash } = build let psbt = null @@ -192,7 +200,7 @@ function refuseShrunkExactInputs(build){ // hand back a child that descends from nothing and still does not mine. if (exactInputs){ if (!unconfirmed){ - const mempoolInput = utxos.find((u) => u.confirmations == 0) + const mempoolInput = utxos.find(isUnconfirmedUtxo) if (mempoolInput){ throw new ParamTypeError( `options.exactInputs names unconfirmed utxo ${mempoolInput.txid}:${mempoolInput.vout}, ` + @@ -236,7 +244,7 @@ function filterAndOrderUtxos(build){ for (let utxoReadIndex = 0; utxoReadIndex < utxos.length; utxoReadIndex++){ const nextUtxo = utxos[utxoReadIndex] // if the tx is in the mempool, drop it if unconfirmed is false - if (!unconfirmed && (nextUtxo.confirmations == 0)) continue + if (!unconfirmed && isUnconfirmedUtxo(nextUtxo)) continue // Loose-equality txid/vout matching became string-key matching: both sides // are concatenated through the same String() coercion, so a numeric 0 and a // string '0' vout still collide exactly as `==` made them collide. @@ -263,8 +271,20 @@ function filterAndOrderUtxos(build){ // lands at ins[0] - and on the OP_RETURN/MULTISIGN path that outpoint is the // obfuscation key, which coin-control callers pick deliberately. if (!exactInputs){ - utxos.sort((a,b)=> a.value < b.value ? 1 : a.value > b.value ? -1 : 0) + utxos.sort(compareCandidates) } } -module.exports = { initInputState, checkExactInputs, gatherUtxos, refuseShrunkExactInputs, filterAndOrderUtxos } +// Value descending, then txid and vout ascending. The tie-break makes the input set +// and its order a function of the UTXO set alone, so the same set listed in another +// order (a tracker page, a wallet cache) builds the same transaction. +function compareCandidates(a, b){ + if (a.value < b.value) return 1 + if (a.value > b.value) return -1 + const txidA = String(a.txid).toLowerCase() + const txidB = String(b.txid).toLowerCase() + if (txidA !== txidB) return txidA < txidB ? -1 : 1 + return Number(a.vout) - Number(b.vout) +} + +module.exports = { initInputState, checkExactInputs, gatherUtxos, refuseShrunkExactInputs, filterAndOrderUtxos, isUnconfirmedUtxo } diff --git a/src/XChainEncoder/build_transaction/input_selection.js b/src/XChainEncoder/build_transaction/input_selection.js index 2dc24186..a0ccbce8 100644 --- a/src/XChainEncoder/build_transaction/input_selection.js +++ b/src/XChainEncoder/build_transaction/input_selection.js @@ -25,12 +25,20 @@ const { OperationalError, ParamTypeError, ParamRangeError } = require('../../bui const { SATOSHI_UNIT, RESERVATION_TTL_MS, Encoding } = require('../constants.js') const { jsonSafeSat } = require('../script_amount_helpers.js') const { feeForVsize } = require('../fee_policy.js') +const { isUnconfirmedUtxo } = require('./input_candidates.js') function initSelection(build){ - let { estimatedTxSize, fee } = build + let { estimatedTxSize, fee, psbt } = build estimatedTxSize = estimatedTxSize + 43 // change output (worst case: taproot) + // The output-count varint is 1 byte up to 252 outputs and 3 bytes past that; the + // 10-byte header counted in initEmissionState assumed 1. A MULTISIGN payload plus + // the customOutputs cap can pass 252 outputs. + if (psbt && psbt.txOutputs.length + 1 > 252) estimatedTxSize = estimatedTxSize + 2 let estimatedFee = 0 + // The caller's explicit fee as asked, before the dust floor can raise it; errors + // name both when they differ. + let requestedFee = null if (fee != null && fee !== false) { // Re-parse with the same exact-integer arbiter the validator uses // (toExactInt-based), not parseInt: parseInt silently truncates a @@ -39,20 +47,43 @@ function initSelection(build){ // ("fee must be a non-negative integer") on any non-exact or negative // value, preserving the previous error contract. estimatedFee = parseSatoshiAmount(fee, 'fee') + requestedFee = estimatedFee } let selectedInputCount = 0 // Txids of the SELECTED inputs that are still in the mempool. Their - // ancestor package is what CPFP sizing below has to pay for; an entry - // with no confirmations field is treated as confirmed, so a UTXO source - // that omits it degrades to per-transaction sizing rather than to a - // guess about someone else's fee. + // ancestor package is what CPFP sizing below has to pay for. An entry with no + // confirmations field counts as unconfirmed (isUnconfirmedUtxo), so it is sized as + // a mempool parent; a confirmed one reports an empty ancestor package and asks for nothing. const unconfirmedInputTxids = [] // Candidates the loop skipped because another build holds them. Reported // when nothing could be selected, so the caller learns the inputs exist // and are spoken for rather than that the address is empty. let reservedCandidates = 0 - Object.assign(build, { estimatedTxSize, estimatedFee, selectedInputCount, unconfirmedInputTxids, reservedCandidates }) + // Whether the segwit marker and flag have been counted; see framingBytesForInput. + const segwitMarkerCounted = false + Object.assign(build, { estimatedTxSize, estimatedFee, requestedFee, selectedInputCount, unconfirmedInputTxids, + reservedCandidates, segwitMarkerCounted }) +} + +// The fee selection has to cover: the fee the build will finally charge, which the +// post-selection floor (floorEstimatedFee) never lets fall under the dust threshold. +// Stopping at the unfloored fee left a fundable spend one input short. +function selectionTargetFee(estimatedFee){ + return Math.max(estimatedFee, this.dustAmount) +} + +// Transaction framing an input adds beyond its own estimated size: the segwit marker +// and flag (2 weight units, rounded up to 1 vB) with the first witness input, and two +// more bytes of input-count varint when the 253rd input is added. +function framingBytesForInput(build, utxo, selectedInputCount){ + let bytes = 0 + if (!build.segwitMarkerCounted && this.isSegwitUTXO(utxo)){ + build.segwitMarkerCounted = true + bytes = bytes + 1 + } + if (selectedInputCount === 253) bytes = bytes + 2 + return bytes } function* selectFundingInputs(build){ @@ -92,8 +123,9 @@ function* selectInputs(build){ inputSatoshis = added.inputSatoshis selectedInputCount = selectedInputCount + 1 + estimatedTxSize = estimatedTxSize + framingBytesForInput.call(this, build, nextUtxo, selectedInputCount) - if (nextUtxo.confirmations == 0){ + if (isUnconfirmedUtxo(nextUtxo)){ unconfirmedInputTxids.push(nextUtxo.txid) } @@ -107,7 +139,7 @@ function* selectInputs(build){ // at sufficiency is exactly the behaviour that made a stuck batch // unrescuable through this API. Any surplus goes to change, which // the shortfall/change math below already handles. - if (!exactInputs && inputSatoshis > outputSatoshis + BigInt(estimatedFee)){ + if (!exactInputs && inputSatoshis > outputSatoshis + BigInt(selectionTargetFee.call(this, estimatedFee))){ break } @@ -133,7 +165,7 @@ function refuseCappedSelection(build, nextUtxoIndex, inputSatoshis, estimatedFee function remainingCandidatesCanFund(build, nextUtxoIndex, inputSatoshis, estimatedFee, now){ const { utxos, exactInputs, firstReservedOutpoint, outputSatoshis } = build - const required = outputSatoshis + BigInt(estimatedFee) + const required = outputSatoshis + BigInt(selectionTargetFee.call(this, estimatedFee)) let available = inputSatoshis for (let index = nextUtxoIndex; index < utxos.length; index++){ const candidate = utxos[index] @@ -280,7 +312,9 @@ function* addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, estimated } function refuseEmptySelection(build){ - let { selectedInputCount, reservedCandidates, outputSatoshis, estimatedFee } = build + let { selectedInputCount, reservedCandidates, outputSatoshis } = build + // Report the fee the build would charge, floor included. + const estimatedFee = selectionTargetFee.call(this, build.estimatedFee) // The caller-facing MAX_UTXO_COUNT cap is intentionally NOT // applied to the fetched set before selection (a rich address must // stay spendable). Bound the SELECTED input count instead: a tx that diff --git a/src/XChainEncoder/build_transaction/payload_checks.js b/src/XChainEncoder/build_transaction/payload_checks.js index 06185125..c767a408 100644 --- a/src/XChainEncoder/build_transaction/payload_checks.js +++ b/src/XChainEncoder/build_transaction/payload_checks.js @@ -19,7 +19,7 @@ ********************************************************************/ const bitcoin = require('bitcoinjs-lib'); -const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD, validateDataParam, validateActionPushDecodability, unknownActionName } = require('../../common/validator') +const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD, validateDataParam, validateActionPushDecodability, unknownActionName, parseSatoshiAmount, assertPaymentOverDust } = require('../../common/validator') const { compressPayloadForAction } = require('../../build/compression') const { Encoding } = require('../constants.js') const { ensureEccLib } = require('../script_amount_helpers.js') @@ -75,13 +75,31 @@ function checkPayloadInput(build){ const unknownAction = unknownActionName(data) checkOutputAddresses.call(this, build) + checkPaymentDust.call(this, build) - // If feeQuote is provided, inject it as a custom output + // If feeQuote is provided, inject it as a custom output. It is remembered so the + // emission step can tell the protocol-fee output from the caller's own payments. + let feeQuoteOutput = null if(feeQuote && feeQuote.address && feeQuote.amount > 0){ if(!customOutputs) customOutputs = []; - customOutputs.push({ address: feeQuote.address, value: feeQuote.amount }); + feeQuoteOutput = { address: feeQuote.address, value: feeQuote.amount } + customOutputs.push(feeQuoteOutput); } - Object.assign(build, { unknownAction, customOutputs }) + Object.assign(build, { unknownAction, customOutputs, feeQuoteOutput }) +} + +// Refuse a caller payment under the coin's relay dust threshold in the first build +// step. On a P2SH/P2WSH funding call the payments are only priced into the first leg +// and emitted on the reveal, so a check at emission alone would let the commit confirm +// with a reveal that can never relay. Runs before the feeQuote output is injected. +function checkPaymentDust(build){ + const { customOutputs } = build + if (!Array.isArray(customOutputs)) return + customOutputs.forEach((output, i) => { + const value = parseSatoshiAmount(output && output.value, `customOutputs[${i}].value`, { allowBig: true }) + if (value <= 0) throw new ParamRangeError(`customOutputs[${i}].value must be a positive integer (satoshis)`) + assertPaymentOverDust(value, i, this.paymentOutputFloor) + }) } // Verify every caller output address decodes on this network, inside the first diff --git a/src/XChainEncoder/build_transaction/script_hash_legs.js b/src/XChainEncoder/build_transaction/script_hash_legs.js index d077e5cd..e17252d2 100644 --- a/src/XChainEncoder/build_transaction/script_hash_legs.js +++ b/src/XChainEncoder/build_transaction/script_hash_legs.js @@ -249,7 +249,7 @@ function* spendP2wshLeg(build, nextDataBuffer){ } function fundP2wshLeg(build, nextDataBuffer){ - let { feePerBytes, finalDust, preparedData, revealCustomOutputsValue, revealCustomOutputsFee, psbt, outputSatoshis, estimatedTxSize } = build + let { feePerBytes, finalDust, preparedData, revealCustomOutputsValue, revealCustomOutputsFee, psbt, outputSatoshis, estimatedTxSize, change } = build // Size this P2WSH data output to fund its share of the // reveal (spending) transaction's fee, mirroring the // P2SH branch above. A flat per-output dust value leaves @@ -259,7 +259,16 @@ function fundP2wshLeg(build, nextDataBuffer){ // floor), so the broadcast is rejected. The estimate uses // witness-discounted sizing because P2WSH // reveal data lives in the (÷4-weighted) witness. - let spendingP2wshEstimatedSize = this.estimateSpendingP2wshTx(nextDataBuffer) + // A reveal under the stripped-size floor carries a pad output (see + // fundRevealFloorPad), and its bytes are part of the reveal's fee. The pad goes + // to the change address, or to the caller's own address when there is none; + // that is resolved only on the reveal, so price it at the largest standard + // output here rather than resolve the caller now. A reveal over the floor has + // no pad and keeps the plain floored estimate. + const padOutputBytes = revealNeedsFloorPad.call(this, preparedData) + ? TxSizeEstimator.estimateOutputSizeForAddress(change || null, this.network) + : 0 + let spendingP2wshEstimatedSize = this.estimateSpendingP2wshTx(nextDataBuffer, padOutputBytes) let spendingP2wshEstimatedFee = feeForVsize(spendingP2wshEstimatedSize, feePerBytes, SATOSHI_UNIT) if (spendingP2wshEstimatedFee < finalDust){ @@ -308,10 +317,7 @@ function fundRevealFloorPad(preparedData, spendingP2wshEstimatedFee){ // and addRevealSizeFloorPadding below), so fund that // output's value here (one dust); otherwise the reveal // has no satoshis left to create it after the fee floor. - let strippedFloor = this.network.minStandardTxNonWitnessSize - let chunkCount = preparedData["dataBufferArray"].length - let predictedRevealStripped = 30 + (41 * chunkCount) - if (strippedFloor && predictedRevealStripped < strippedFloor){ + if (revealNeedsFloorPad.call(this, preparedData)){ // Fund the reveal's stripped-size floor-padding output with // this.dustAmount, the SAME constant the reveal side spends // (see addRevealSizeFloorPadding below). Using finalDust here @@ -322,4 +328,12 @@ function fundRevealFloorPad(preparedData, spendingP2wshEstimatedFee){ return spendingP2wshEstimatedFee } +// Whether the P2WSH reveal's stripped size (10 header + 41 per chunk input + ~20 +// marker output) lands under the chain's relay floor, so the reveal adds a pad output. +function revealNeedsFloorPad(preparedData){ + const strippedFloor = this.network.minStandardTxNonWitnessSize + const predictedRevealStripped = 30 + (41 * preparedData["dataBufferArray"].length) + return !!(strippedFloor && predictedRevealStripped < strippedFloor) +} + module.exports = { emitP2shChunk, emitP2wshChunk } diff --git a/src/XChainEncoder/size_estimation.js b/src/XChainEncoder/size_estimation.js index f64aa687..38bed1cd 100644 --- a/src/XChainEncoder/size_estimation.js +++ b/src/XChainEncoder/size_estimation.js @@ -162,7 +162,10 @@ module.exports = { + extraOutputsBytes }, - estimateSpendingP2wshTx(witnessData){ + // padOutputBytes is the serialized size of the pad output the reveal adds when it + // sits under the stripped-size floor; the funding side passes the change address's + // size, and the default is the largest standard output. + estimateSpendingP2wshTx(witnessData, padOutputBytes = 43){ // Per-chunk embedded value sized to cover the P2WSH reveal tx's worst // case at 1 sat/vbyte. A native-segwit input keeps its scriptSig empty // and carries the reveal payload (sig + compressed pubkey + witness @@ -195,15 +198,16 @@ module.exports = { Buffer.from("p2wsh",'utf8') ])) - // A single-chunk reveal's stripped size (71 B) is below Litecoin's - // tx-size-small floor, so the reveal builder pads it up to the chain's - // minStandardTxNonWitnessSize with one extra output. Reflect that padded - // stripped size here so the fee estimate covers the larger reveal. (In - // normal fee regimes the dust floor dominates the embedded value, but - // this keeps the estimate honest on high-fee chains.) + // A single-chunk reveal's stripped size (71 B) is below the chain's + // tx-size-small floor, so the reveal builder adds one pad output to clear + // minStandardTxNonWitnessSize. That output is a whole output, not just the + // bytes up to the floor, so charge its full size, then keep the floor as a + // lower bound, as the P2SH and envelope reveal estimates price their + // outputs. Flooring to the threshold alone left a 34-byte pad unpriced and + // the reveal ~10% under the requested rate on high-fee chains. let strippedFloor = this.network.minStandardTxNonWitnessSize if (strippedFloor && nonWitnessBytes < strippedFloor){ - nonWitnessBytes = strippedFloor + nonWitnessBytes = Math.max(nonWitnessBytes + padOutputBytes, strippedFloor) } // vsize = ceil(total weight / 4) = nonWitnessBytes + ceil(witnessBytes/4) diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index a865f4f5..a7efd2a2 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -180,7 +180,7 @@ return { async create_tx(rawParams) { let params try { - params = validator.validateAll(rawParams) + params = validator.validateAll(rawParams, { paymentOutputFloor: encoder.paymentOutputFloor }) // sub-dust payment: -32602 } catch (err) { const e = new Error(err.message) e.code = -32602 diff --git a/src/common/validator.js b/src/common/validator.js index 2f9a5d82..8870a864 100644 --- a/src/common/validator.js +++ b/src/common/validator.js @@ -58,7 +58,8 @@ const { validateOutpoint, validateUtxoArray, validateUtxoEntry, - validateCustomOutputs + validateCustomOutputs, + assertPaymentOverDust } = require('./validator/fee_and_utxo_checks') const { validateP2shParams, @@ -101,7 +102,7 @@ const COMPRESSION_MAX_RATIO = 150 // service (it holds an exclusive lockfile), so compression is async and bounded. const COMPRESSION_MAX_INPUT_BYTES = 16 * 1024 * 1024 -function validateAll(params) { +function validateAll(params, context) { // API contract coverage tracks these validator inputs: // params.utxos, params.pubkey, params.customOutputs, params.data, // params.rawData, params.fee, params.rbf, params.encoding, params.change, @@ -109,7 +110,7 @@ function validateAll(params) { // params.unconfirmed, params.feePerKb, params.dust, params.feeQuote, // params.attachPrevTx, params.compress, params.options return { - ...validateAllFields(params) + ...validateAllFields(params, context) } } @@ -142,6 +143,7 @@ module.exports = { validateUtxoArray, validateUtxoEntry, validateCustomOutputs, + assertPaymentOverDust, validateP2shParams, validateRawTxHex, validateCompressedPubKey, diff --git a/src/common/validator/fee_and_utxo_checks.js b/src/common/validator/fee_and_utxo_checks.js index 53fce973..c700b652 100644 --- a/src/common/validator/fee_and_utxo_checks.js +++ b/src/common/validator/fee_and_utxo_checks.js @@ -216,7 +216,17 @@ function validateUtxoArray(utxos) { return utxos } -function validateCustomOutput(output, index) { +// Refuse a payment output under the coin's hard relay dust threshold: a node will not +// relay it, so the caller would sign a transaction that never reaches a miner. `floor` +// is the encoder's paymentOutputFloor; a falsy floor (a network-agnostic caller) leaves +// only the positive-amount rule. +function assertPaymentOverDust(value, index, floor) { + if (floor && value < floor) { + throw new ParamRangeError(`customOutputs[${index}].value ${value} is below the ${floor} base-unit relay dust threshold for this coin`) + } +} + +function validateCustomOutput(output, index, paymentOutputFloor) { if (typeof output !== 'object' || output === null || Array.isArray(output)) { throw new ParamTypeError(`customOutputs[${index}] must be an object`) } @@ -227,18 +237,20 @@ function validateCustomOutput(output, index) { // A caller-supplied output of 0 sats is consensus-valid but relay-rejected // as an unspendable/dust output, so the caller signs a PSBT that can never // broadcast. Reject it at the trust boundary, matching validateFeeQuote's - // positive-amount contract. Interim safe rule: reject only value <= 0 (not a - // full per-network dust floor). The native-fee FEE_DESTINATION output is + // positive-amount contract. The per-coin dust floor below applies when the + // caller passes one (create_tx passes the encoder's). A feeQuote output is // injected into customOutputs AFTER validateCustomOutputs runs (see - // XChainEncoder.createTransaction), so it never passes through here and its - // sub-dust-but-positive DOGE fee values keep working. + // checkPayloadInput), so it keeps only its own positive-amount rule; a native-fee + // output a client appends to customOutputs itself (the SDK does) is a caller + // output here and is held to the floor like any payment. if (output.value <= 0) { throw new ParamRangeError(`customOutputs[${index}].value must be a positive integer (satoshis)`) } + assertPaymentOverDust(output.value, index, paymentOutputFloor) return output } -function validateCustomOutputs(customOutputs) { +function validateCustomOutputs(customOutputs, paymentOutputFloor) { if (customOutputs == null) return null if (!Array.isArray(customOutputs)) { throw new ParamTypeError('customOutputs must be an array') @@ -247,7 +259,7 @@ function validateCustomOutputs(customOutputs) { throw new ParamRangeError(`customOutputs length (${customOutputs.length}) exceeds maximum (${MAX_CUSTOM_OUTPUTS})`) } for (let i = 0; i < customOutputs.length; i++) { - validateCustomOutput(customOutputs[i], i) + validateCustomOutput(customOutputs[i], i, paymentOutputFloor) } return customOutputs } @@ -283,6 +295,7 @@ module.exports = { validateOutpoint, validateUtxoEntry, validateUtxoArray, + assertPaymentOverDust, validateCustomOutput, validateCustomOutputs, validateFeeQuote diff --git a/src/common/validator/transaction_checks.js b/src/common/validator/transaction_checks.js index aaa75d0f..3662b4d5 100644 --- a/src/common/validator/transaction_checks.js +++ b/src/common/validator/transaction_checks.js @@ -151,13 +151,13 @@ function validateActionFields(params) { return { data, rawData, encoding } } -function validatePaymentFields(params) { +function validatePaymentFields(params, context) { const pubkey = validatePubkey(params.pubkey) const fee = validateFee(params.fee) const feePerKb = validateFeePerKb(params.feePerKb) const dust = validateDust(params.dust) const utxos = validateUtxoArray(params.utxos) - const customOutputs = validateCustomOutputs(params.customOutputs) + const customOutputs = validateCustomOutputs(params.customOutputs, context && context.paymentOutputFloor) const feeQuote = validateFeeQuote(params.feeQuote) const { p2shHash, p2shHex } = validateP2shParams(params.p2shHash, params.p2shHex) const compressedPubKey = validateCompressedPubKey(params.compressedPubKey) @@ -241,12 +241,14 @@ function validatePolicyFields(params) { /** * Validate and coerce every createTransaction parameter in one pass. * @param {object} params - the raw JSON-RPC params object for create_tx. + * @param {object} [context] - what the serving encoder knows about its coin: + * { paymentOutputFloor }, the relay dust threshold every customOutputs value must reach. * @returns {object} the same fields, each coerced to its checked form * (amounts to exact integers, hex strings to lowercase, etc). * @throws {TypeError} a field is missing, the wrong type, or the wrong shape. * @throws {RangeError} a field is the right type but outside its allowed bound. */ -function validateAll(params) { +function validateAll(params, context) { // Array.isArray, because typeof [] is 'object': a JSON-RPC call with POSITIONAL // params cleared this gate and died 50 lines later on 'pubkey is required', // reporting a missing field for what is a shape error. Same guard the @@ -260,7 +262,7 @@ function validateAll(params) { const { pubkey, fee, feePerKb, dust, utxos, customOutputs, feeQuote, p2shHash, p2shHex, compressedPubKey, change - } = validatePaymentFields(params) + } = validatePaymentFields(params, context) validateEncodingRequirements(encoding, compressedPubKey, p2shHash, pubkey) const { rbf, unconfirmed, attachPrevTx, compress, options diff --git a/test/unit/xchain_encoder/build_correctness.test/01_relay_floor_size_estimate.test.js b/test/unit/xchain_encoder/build_correctness.test/01_relay_floor_size_estimate.test.js new file mode 100644 index 00000000..d1b99ed8 --- /dev/null +++ b/test/unit/xchain_encoder/build_correctness.test/01_relay_floor_size_estimate.test.js @@ -0,0 +1,46 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// The single-transaction size estimate has to count the 10-byte header and, with a +// segwit input, the marker and flag. Without them a many-input build at the relay +// floor paid a few satoshis under 1 sat/vB and the node refused it. + +'use strict' + +const assert = require('assert') +const F = require('./helpers/build_fixture') + +async function buildSegwitSpend (coin, inputCount, feePerKb, encoderOver) { + const utxos = [] + for (let i = 0; i < inputCount; i++) utxos.push(F.makeUtxo(coin, 'p2wpkh', 100000)) + const res = await F.create(F.makeEncoder(coin, encoderOver), { + utxos, pubkey: F.p2pkhAddr(coin), data: 'BROADCAST|0|0|x', encoding: 'OP_RETURN', change: F.p2wshAddr(coin), + customOutputs: [{ address: F.p2wshAddr(coin), value: 100000 * inputCount - 60000 }], feePerKb + }) + return F.account(res.psbt) +} + +describe('single-transaction size estimate covers the header and segwit marker', () => { + // LTC's 5460-litoshi fee floor masks the undercount at 1 lit/vB, so it is measured at 5. + const cases = [['btc', 10, 1000], ['btc', 20, 1000], ['ltc', 20, 5000]] + for (const [coin, inputCount, feePerKb] of cases) { + it(`${coin}: ${inputCount} P2WPKH inputs at ${feePerKb / 1000} sat/vB pay the rate on the real vsize`, async () => { + const a = await buildSegwitSpend(coin, inputCount, feePerKb) + assert.strictEqual(a.ins, a.outs + a.fee, 'value is conserved') + assert.ok(Number(a.fee) >= a.vsize * feePerKb / 1000, `fee ${a.fee} is under ${a.vsize} vB at ${feePerKb / 1000} sat/vB`) + }) + } + + it('a node estimate under the relay floor is lifted to the floor rather than built under it', async () => { + // 0.5 sat/vB quoted by the node against a 1 sat/vB relayfee. + const a = await buildSegwitSpend('btc', 20, null, { est: 0.000005 }) + assert.ok(Number(a.fee) >= a.vsize, `fee ${a.fee} is under ${a.vsize} vB at the relay floor`) + }) +}) diff --git a/test/unit/xchain_encoder/build_correctness.test/02_fee_floor_selection.test.js b/test/unit/xchain_encoder/build_correctness.test/02_fee_floor_selection.test.js new file mode 100644 index 00000000..133d2a95 --- /dev/null +++ b/test/unit/xchain_encoder/build_correctness.test/02_fee_floor_selection.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. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// The fee is floored at the coin's dust threshold, and input selection has to aim at +// that floored fee. Selecting against the unfloored fee stopped one input short and a +// fundable spend failed with insufficient funds. + +'use strict' + +const assert = require('assert') +const F = require('./helpers/build_fixture') + +function twoInputSpend (coin, kind, a, b, pay, extra) { + return F.create(F.makeEncoder(coin, extra.encoderOver), { + utxos: [F.makeUtxo(coin, kind, a), F.makeUtxo(coin, kind, b)], pubkey: F.p2pkhAddr(coin), + data: 'BROADCAST|0|0|x', encoding: 'OP_RETURN', change: F.p2pkhAddr(coin), + customOutputs: [{ address: F.otherAddr(coin), value: pay }], fee: extra.fee, feePerKb: extra.feePerKb + }) +} + +describe('input selection targets the floored fee', () => { + const cases = [ + { label: 'btc caller rate 1 sat/vB', coin: 'btc', kind: 'p2wpkh', a: 10000, b: 5000, pay: 9600, feePerKb: 1000 }, + { label: 'btc explicit fee under the dust floor', coin: 'btc', kind: 'p2wpkh', a: 10000, b: 5000, pay: 9500, fee: 300 }, + { label: 'ltc caller rate 1 lit/vB', coin: 'ltc', kind: 'p2wpkh', a: 100000, b: 50000, pay: 99000, feePerKb: 1000 }, + { label: 'ltc node estimate of 2 lit/vB', coin: 'ltc', kind: 'p2wpkh', a: 100000, b: 50000, pay: 99000, encoderOver: { est: 0.00002 } }, + { label: 'doge caller rate at the relay floor', coin: 'doge', kind: 'p2pkh', a: 5000000, b: 3000000, pay: 4950000, feePerKb: 100000 } + ] + for (const c of cases) { + it(`${c.label}: a spend the two inputs fund builds with both`, async () => { + const res = await twoInputSpend(c.coin, c.kind, c.a, c.b, c.pay, c) + const a = F.account(res.psbt) + assert.strictEqual(res.psbt.inputCount, 2) + assert.strictEqual(a.ins, a.outs + a.fee, 'value is conserved') + assert.ok(Number(a.fee) >= F.makeEncoder(c.coin).dustAmount, 'the fee is at least the dust floor') + }) + } + + it('an unfundable spend names the fee the build needs and the fee that was requested', async () => { + let caught = null + try { + await F.create(F.makeEncoder('btc'), { + utxos: [F.makeUtxo('btc', 'p2wpkh', 10000)], pubkey: F.p2pkhAddr('btc'), data: 'BROADCAST|0|0|x', + encoding: 'OP_RETURN', change: F.p2pkhAddr('btc'), customOutputs: [{ address: F.otherAddr('btc'), value: 9500 }], fee: 300 + }) + } catch (err) { caught = err } + assert.ok(caught, 'the build is refused') + assert.strictEqual(caught.xchainCode, 'INSUFFICIENT_FUNDS') + assert.strictEqual(caught.details.fee, 546) + assert.strictEqual(caught.details.requestedFee, 300) + assert.match(caught.message, /fee 546/) + assert.match(caught.message, /requested fee 300/) + }) +}) diff --git a/test/unit/xchain_encoder/build_correctness.test/03_deterministic_order.test.js b/test/unit/xchain_encoder/build_correctness.test/03_deterministic_order.test.js new file mode 100644 index 00000000..f2c89015 --- /dev/null +++ b/test/unit/xchain_encoder/build_correctness.test/03_deterministic_order.test.js @@ -0,0 +1,60 @@ +// 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. + +// Candidates are ordered by value, then txid, then vout, so the same UTXO set gives the +// same transaction whatever order the caller or the tracker listed it in. + +'use strict' + +const assert = require('assert') +const F = require('./helpers/build_fixture') +const { filterAndOrderUtxos } = require('../../../../src/XChainEncoder/build_transaction/input_candidates.js') + +function spend (utxos) { + return F.create(F.makeEncoder('btc'), { + utxos, pubkey: F.p2pkhAddr('btc'), data: 'BROADCAST|0|0|x', encoding: 'OP_RETURN', change: F.p2pkhAddr('btc'), + customOutputs: [{ address: F.otherAddr('btc'), value: 1000000 }] + }) +} + +function unsignedHex (res) { return res.psbt.data.globalMap.unsignedTx.toBuffer().toString('hex') } + +describe('candidate order is deterministic on equal values', () => { + it('two equal-value UTXOs in either order build the same transaction', async () => { + const a = F.makeUtxo('btc', 'p2wpkh', 5000000) + const b = F.makeUtxo('btc', 'p2wpkh', 5000000) + const first = await spend([{ ...a }, { ...b }]) + const second = await spend([{ ...b }, { ...a }]) + assert.strictEqual(unsignedHex(first), unsignedHex(second)) + assert.strictEqual(F.inputTxids(first.psbt)[0], [a.txid, b.txid].sort()[0], 'the lower txid leads a tie') + }) + + it('the order is value descending, then txid, then vout', () => { + const t1 = '1'.repeat(64) + const t2 = '2'.repeat(64) + const build = { + utxos: [ + { txid: t2, vout: 0, value: 700 }, { txid: t1, vout: 3, value: 700 }, { txid: t1, vout: 1, value: 700 }, + { txid: t2, vout: 5, value: 900 }, { txid: t1, vout: 0, value: 100 } + ], + unconfirmed: true, isReveal: false, exactInputs: false + } + filterAndOrderUtxos(build) + assert.deepStrictEqual(build.utxos.map((u) => u.txid[0] + ':' + u.vout), ['2:5', '1:1', '1:3', '2:0', '1:0']) + }) + + it('every permutation of a set with ties builds one transaction', async () => { + const base = [5, 5, 3, 5].map((v) => F.makeUtxo('btc', 'p2wpkh', v * 1000000)) + const orders = [[0, 1, 2, 3], [3, 2, 1, 0], [1, 3, 0, 2], [2, 0, 3, 1], [3, 1, 2, 0]] + const seen = new Set() + for (const order of orders) seen.add(unsignedHex(await spend(order.map((i) => ({ ...base[i] }))))) + assert.strictEqual(seen.size, 1) + }) +}) diff --git a/test/unit/xchain_encoder/build_correctness.test/04_p2wsh_reveal_pad_pricing.test.js b/test/unit/xchain_encoder/build_correctness.test/04_p2wsh_reveal_pad_pricing.test.js new file mode 100644 index 00000000..56ebb041 --- /dev/null +++ b/test/unit/xchain_encoder/build_correctness.test/04_p2wsh_reveal_pad_pricing.test.js @@ -0,0 +1,53 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A single-chunk P2WSH reveal sits under the stripped-size floor and carries one pad +// output to clear it. The funding leg has to price that output's bytes; flooring the +// estimate at the floor alone left the reveal several sat/vB under the asked rate. + +'use strict' + +const assert = require('assert') +const F = require('./helpers/build_fixture') + +async function revealAt (coin, feePerKb, payloadLength, change) { + const data = 'BROADCAST|0|0|' + 'w'.repeat(payloadLength - 14) + const funding = await F.create(F.makeEncoder(coin), { + utxos: [F.makeUtxo(coin, 'p2wpkh', 5000000)], pubkey: F.p2pkhAddr(coin), data, encoding: 'P2WSH', change, feePerKb + }) + const fundingTx = F.account(funding.psbt).tx + const reveal = await F.create(F.makeEncoder(coin), { + pubkey: F.p2pkhAddr(coin), data, encoding: 'P2WSH', change, feePerKb, p2shHash: fundingTx.getId(), p2shHex: fundingTx.toHex() + }) + return { a: F.account(reveal.psbt), outputs: reveal.psbt.txOutputs.length } +} + +describe('P2WSH reveal funding prices the stripped-size pad output', () => { + for (const coin of ['btc', 'ltc']) { + for (const feePerKb of [10000, 50000, 100000]) { + for (const payloadLength of [30, 470]) { + it(`${coin}: a ${payloadLength}-byte single-chunk reveal at ${feePerKb / 1000} sat/vB pays the rate`, async () => { + const { a, outputs } = await revealAt(coin, feePerKb, payloadLength, F.p2pkhAddr(coin)) + assert.strictEqual(outputs, 2, 'the marker and the pad output') + assert.ok(a.stripped >= F.net(coin).minStandardTxNonWitnessSize, 'the pad clears the stripped floor') + const rate = Number(a.fee) / a.vsize + assert.ok(rate >= feePerKb / 1000, `reveal pays ${rate.toFixed(3)} sat/vB of ${feePerKb / 1000}`) + }) + } + } + } + + it('with no change address the pad is priced at the largest standard output', async () => { + const encoder = F.makeEncoder('btc') + const bare = encoder.estimateSpendingP2wshTx(Buffer.alloc(40), 34) + const worst = encoder.estimateSpendingP2wshTx(Buffer.alloc(40)) + assert.strictEqual(worst - bare, 43 - 34) + }) +}) diff --git a/test/unit/xchain_encoder/build_correctness.test/05_payment_dust_floor.test.js b/test/unit/xchain_encoder/build_correctness.test/05_payment_dust_floor.test.js new file mode 100644 index 00000000..725b1552 --- /dev/null +++ b/test/unit/xchain_encoder/build_correctness.test/05_payment_dust_floor.test.js @@ -0,0 +1,88 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A caller payment output under the coin's hard relay dust threshold makes the +// transaction unrelayable, so it is refused before anything is built. Dogecoin's 0.01 +// DOGE soft limit is not a refusal floor: wallet native-fee outputs are padded only to +// the hard threshold, so a payment between the two still builds. + +'use strict' + +const assert = require('assert') +const F = require('./helpers/build_fixture') +const validator = require('../../../../src/common/validator') +const { createJsonRpcController } = require('../../../../src/api/json_rpc_methods') + +function pay (coin, value, extra = {}) { + const kind = coin === 'doge' ? 'p2pkh' : 'p2wpkh' + return F.create(F.makeEncoder(coin), { + utxos: [F.makeUtxo(coin, kind, coin === 'doge' ? 500000000 : 5000000)], pubkey: F.p2pkhAddr(coin), + data: 'BROADCAST|0|0|x', encoding: extra.encoding || 'OP_RETURN', change: F.p2pkhAddr(coin), + customOutputs: [{ address: F.otherAddr(coin), value }], feeQuote: extra.feeQuote + }) +} + +describe('caller payment outputs respect the coin relay dust threshold', () => { + const floors = { btc: 546, ltc: 5460, doge: 100000 } + + for (const coin of Object.keys(floors)) { + it(`${coin}: the encoder exposes ${floors[coin]} as its payment floor`, () => { + assert.strictEqual(F.makeEncoder(coin).paymentOutputFloor, floors[coin]) + }) + it(`${coin}: one unit under the floor is refused`, async () => { + await assert.rejects(pay(coin, floors[coin] - 1), (err) => err instanceof RangeError && /dust/.test(err.message)) + }) + it(`${coin}: exactly the floor builds`, async () => { + const res = await pay(coin, floors[coin]) + assert.ok(res.psbt.txOutputs.some((o) => Number(o.value) === floors[coin])) + }) + } + + it('doge: a 0.005 DOGE payment between the hard and soft limits builds', async () => { + const res = await pay('doge', 500000) + assert.ok(res.psbt.txOutputs.some((o) => Number(o.value) === 500000)) + }) + + it('a P2WSH funding call refuses a sub-dust payment before the commit is built', async () => { + await assert.rejects(pay('btc', 545, { encoding: 'P2WSH' }), /dust/) + }) + + it('the protocol-fee output injected from feeQuote keeps its own positive-amount rule', async () => { + const res = await pay('btc', 546, { feeQuote: { address: F.otherAddr('btc'), amount: 300 } }) + assert.ok(res.psbt.txOutputs.some((o) => Number(o.value) === 300)) + }) + + it('create_tx answers a sub-dust payment with -32602 before any node call', async () => { + const encoder = F.makeEncoder('btc') + const calls = [] + const info = encoder.connector.getNetworkInfo + encoder.connector.getNetworkInfo = (...args) => { calls.push('networkinfo'); return info(...args) } + const controller = createJsonRpcController({ encoder, NETWORK: 'bitcoin-regtest' }) + const params = { pubkey: F.p2pkhAddr('btc'), data: 'BROADCAST|0|0|x', change: F.p2pkhAddr('btc'), + customOutputs: [{ address: F.otherAddr('btc'), value: 545 }] } + await assert.rejects(controller.create_tx(params), (err) => err.code === -32602 && /dust/.test(err.message)) + assert.deepStrictEqual(calls, []) + }) + + describe('validateAll at the API boundary', () => { + const base = { pubkey: 'addr', data: 'BROADCAST|0|0|x' } + it('refuses an output under the floor the caller passes', () => { + assert.throws(() => validator.validateAll({ ...base, customOutputs: [{ address: 'a', value: 545 }] }, { paymentOutputFloor: 546 }), + (err) => err instanceof RangeError && /customOutputs\[0\]\.value 545 is below the 546/.test(err.message)) + }) + it('accepts an output at the floor', () => { + const r = validator.validateAll({ ...base, customOutputs: [{ address: 'a', value: 546 }] }, { paymentOutputFloor: 546 }) + assert.strictEqual(r.customOutputs[0].value, 546) + }) + it('keeps the positive-amount rule when no floor is passed', () => { + assert.strictEqual(validator.validateAll({ ...base, customOutputs: [{ address: 'a', value: 1 }] }).customOutputs[0].value, 1) + }) + }) +}) diff --git a/test/unit/xchain_encoder/build_correctness.test/06_missing_confirmations.test.js b/test/unit/xchain_encoder/build_correctness.test/06_missing_confirmations.test.js new file mode 100644 index 00000000..03d73f78 --- /dev/null +++ b/test/unit/xchain_encoder/build_correctness.test/06_missing_confirmations.test.js @@ -0,0 +1,56 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A library caller that hands over UTXOs without a confirmations field gets the same +// treatment the API validator gives them: the field defaults to 0, so unconfirmed=false +// never spends an output whose confirmation state nobody stated. + +'use strict' + +const assert = require('assert') +const F = require('./helpers/build_fixture') + +function spend (utxos, unconfirmed) { + return F.create(F.makeEncoder('btc'), { + utxos, unconfirmed, pubkey: F.p2pkhAddr('btc'), data: 'BROADCAST|0|0|x', encoding: 'OP_RETURN', + change: F.p2pkhAddr('btc'), customOutputs: [{ address: F.otherAddr('btc'), value: 500000 }] + }) +} + +describe('a UTXO with no confirmations field counts as unconfirmed', () => { + it('unconfirmed=false skips it and spends a confirmed output instead', async () => { + const unstated = F.makeUtxo('btc', 'p2wpkh', 9000000) + delete unstated.confirmations + const confirmed = F.makeUtxo('btc', 'p2wpkh', 1000000) + const res = await spend([unstated, confirmed], false) + assert.deepStrictEqual(F.inputTxids(res.psbt), [confirmed.txid]) + }) + + it('unconfirmed=false with only such outputs reports no spendable UTXOs', async () => { + const unstated = F.makeUtxo('btc', 'p2wpkh', 9000000) + delete unstated.confirmations + await assert.rejects(spend([unstated], false), (err) => err.xchainCode === 'NO_UTXOS') + }) + + it('leaves the caller UTXO objects as they were handed over', async () => { + const unstated = F.makeUtxo('btc', 'p2wpkh', 9000000) + delete unstated.confirmations + const confirmed = F.makeUtxo('btc', 'p2wpkh', 1000000) + await spend([unstated, confirmed], false) + assert.ok(!('confirmations' in unstated), 'no confirmations field was written onto the caller object') + }) + + it('unconfirmed=true still spends it', async () => { + const unstated = F.makeUtxo('btc', 'p2wpkh', 9000000) + delete unstated.confirmations + const res = await spend([unstated], true) + assert.deepStrictEqual(F.inputTxids(res.psbt), [unstated.txid]) + }) +}) diff --git a/test/unit/xchain_encoder/build_correctness.test/helpers/build_fixture.js b/test/unit/xchain_encoder/build_correctness.test/helpers/build_fixture.js new file mode 100644 index 00000000..be10d20a --- /dev/null +++ b/test/unit/xchain_encoder/build_correctness.test/helpers/build_fixture.js @@ -0,0 +1,118 @@ +// 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 fixture for the build-correctness suites: an encoder with a stubbed node, +// UTXOs backed by real previous transactions, and value accounting read straight off +// the PSBT after every input is filled with a worst-case signature, so a test measures +// the size and fee rate the network will actually see. + +'use strict' + +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../../../src/XChainEncoder') +const CryptoNetworks = require('../../../../../src/build/crypto_networks') + +// Generator point G (private key 1); only its public half is needed. +const PUB = Buffer.from('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex') +const PUB_HEX = PUB.toString('hex') +// A 72-byte DER signature plus sighash byte, the longest a low-S signature gets. +const SIG72 = Buffer.concat([Buffer.from([0x30, 0x45]), Buffer.alloc(69, 0x11), Buffer.from([0x01])]) + +// Node rates per coin in coin/kB: relayfee, and the estimate the node quotes. +const NETS = { + btc: { key: 'bitcoin-regtest', relay: 0.00001, est: 0.0001 }, + ltc: { key: 'litecoin-regtest', relay: 0.00001, est: 0.0001 }, + doge: { key: 'dogecoin-regtest', relay: 0.001, est: 0.01 } +} + +const PREV = new Map() +let prevSeq = 0 + +function net (coin) { return CryptoNetworks.getBitcoinJsNetwork(NETS[coin].key) } +function p2pkhAddr (coin) { return bitcoin.payments.p2pkh({ pubkey: PUB, network: net(coin) }).address } +function p2wpkhAddr (coin) { return bitcoin.payments.p2wpkh({ pubkey: PUB, network: net(coin) }).address } +function otherAddr (coin) { return bitcoin.payments.p2pkh({ hash: Buffer.alloc(20, 0x22), network: net(coin) }).address } +function p2wshAddr (coin) { return bitcoin.payments.p2wsh({ hash: Buffer.alloc(32, 0x44), network: net(coin) }).address } + +// A UTXO whose txid is a real previous transaction paying `value` at vout 0. +function makeUtxo (coin, kind, value, extra = {}) { + const network = net(coin) + const script = kind === 'p2wpkh' + ? bitcoin.payments.p2wpkh({ pubkey: PUB, network }).output + : bitcoin.payments.p2pkh({ pubkey: PUB, network }).output + const tx = new bitcoin.Transaction() + const seed = Buffer.alloc(32, 0) + seed.writeUInt32BE(++prevSeq, 0) + tx.addInput(seed, 0) + tx.addOutput(script, value) + PREV.set(tx.getId(), tx.toHex()) + return { txid: tx.getId(), vout: 0, value, confirmations: 6, scriptPubKey: script.toString('hex'), ...extra } +} + +function makeEncoder (coin, over = {}) { + const rates = NETS[coin] + const encoder = new XChainEncoder(rates.key, '127.0.0.1', '18443', 'rpc', 'rpc', '', '') + encoder.connector = { + getFeePerKilobyte: async () => (over.est != null ? over.est : rates.est), + getNetworkInfo: async () => ({ relayfee: over.relay != null ? over.relay : rates.relay }), + getTransactionHex: async (txid) => { + if (!PREV.has(txid)) throw new Error('no previous transaction ' + txid) + return PREV.get(txid) + }, + getBlockCount: async () => 1000000 + } + encoder.utxoTrackerConnector = { getUtxosFromAddress: async () => { throw new Error('tracker not stubbed') } } + return encoder +} + +// createTransaction with named arguments. +function create (encoder, a) { + return encoder.createTransaction( + a.utxos === undefined ? null : a.utxos, a.pubkey, a.customOutputs || null, a.data, null, + a.fee == null ? null : a.fee, false, a.encoding == null ? null : a.encoding, a.change == null ? null : a.change, + a.p2shHash || null, a.p2shHex || null, a.compressedPubKey || null, + a.unconfirmed === undefined ? true : a.unconfirmed, a.feePerKb == null ? null : a.feePerKb, + null, a.feeQuote || null, false, false, null) +} + +// The unsigned transaction with every input carrying a worst-case signature. +function finalizedTx (psbt) { + const tx = bitcoin.Transaction.fromBuffer(psbt.data.globalMap.unsignedTx.toBuffer()) + psbt.data.inputs.forEach((input, i) => { + if (input.witnessScript) tx.ins[i].witness = [SIG72, PUB, input.witnessScript] + else if (input.redeemScript) tx.ins[i].script = bitcoin.script.compile([SIG72, PUB, input.redeemScript]) + else if (input.witnessUtxo) tx.ins[i].witness = [SIG72, PUB] + else tx.ins[i].script = bitcoin.script.compile([SIG72, PUB]) + }) + return tx +} + +function inputValue (psbt, i) { + const input = psbt.data.inputs[i] + if (input.witnessUtxo) return BigInt(input.witnessUtxo.value) + return BigInt(bitcoin.Transaction.fromBuffer(input.nonWitnessUtxo).outs[psbt.txInputs[i].index].value) +} + +// Inputs, outputs, fee and the broadcast vsize of a built PSBT. +function account (psbt) { + let ins = 0n + for (let i = 0; i < psbt.data.inputs.length; i++) ins += inputValue(psbt, i) + let outs = 0n + for (const o of psbt.txOutputs) outs += BigInt(o.value) + const tx = finalizedTx(psbt) + const fee = ins - outs + return { ins, outs, fee, vsize: tx.virtualSize(), stripped: tx.byteLength(false), tx } +} + +function inputTxids (psbt) { + return psbt.txInputs.map((i) => Buffer.from(i.hash).reverse().toString('hex')) +} + +module.exports = { PUB_HEX, net, p2pkhAddr, p2wpkhAddr, otherAddr, p2wshAddr, makeUtxo, makeEncoder, create, account, inputTxids } 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 813eced8..f8d82a28 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 @@ -248,12 +248,17 @@ describe('XChainEncoder P2SH reveal headroom', () => { // XChainEncoder, so any drift on these lanes fails loudly. The BTC funding // change is one base unit lower (feeForVsize rounds a fee up), so the BTC // reveal spends a new txid and re-keys its obfuscated marker; values hold. + // Re-pinned when the single-transaction size estimate gained the 10-byte header + // and the segwit marker, and the P2WSH funding leg began pricing the reveal's pad + // output: BTC change fell 340 (11 vB + the 23-byte pad delta at 10 sat/vB, which + // also lifts the leg 230), LTC change 3100 (leg +2000) and DOGE change 10000 (the + // header at 1000 koinu/B). The reveals keep their values and re-key the marker. const PRE_FIX = { - btcP2wshFunding: '0200000001aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000ffffffff02020900000000000022002050efb27b1cb5812e1bb95788a3ffd8550464371c1d57a36e4649e9b5ae8ed1fefad1f505000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', - ltcP2wshFunding: '0200000001aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000ffffffff02405b00000000000022002050efb27b1cb5812e1bb95788a3ffd8550464371c1d57a36e4649e9b5ae8ed1fe9849f505000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', - btcP2wshReveal: '02000000018e1823ac37c2755d15f8a2b8a7865a7d6143d07eae6152191b64fae949bc0f850000000000ffffffff0200000000000000000b6a0975819111a938ed57e522020000000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', - ltcP2wshReveal: '0200000001e681017eda2e84c8f80a5d543af89041236fc9e7549630025e3d1588ef60e5c60000000000ffffffff0200000000000000000b6a09e56b6a299a4ddadb6654150000000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', - dogeOpReturn: '0200000001aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000ffffffff0200000000000000002f6a2d09cb8e09635af4c5aa3a916a26c6652b18482f3a1ec3636bebdfa9f4d1599a8385426040f9baaff90aa6b137f0289ff105000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000' + btcP2wshFunding: '0200000001aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000ffffffff02e80900000000000022002050efb27b1cb5812e1bb95788a3ffd8550464371c1d57a36e4649e9b5ae8ed1fea6d0f505000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', + ltcP2wshFunding: '0200000001aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000ffffffff02106300000000000022002050efb27b1cb5812e1bb95788a3ffd8550464371c1d57a36e4649e9b5ae8ed1fe7c3df505000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', + btcP2wshReveal: '0200000001d89df09a778e91773ab1a19027134f65f51e42bc7f0ec80ae8f15ed8e29308e70000000000ffffffff0200000000000000000b6a094bf148797506b449e022020000000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', + ltcP2wshReveal: '02000000010964edb1b808477b18d6126f863f88a86ebbaae1ab18ec09f9d607cb999773590000000000ffffffff0200000000000000000b6a094341679d879db7f9e654150000000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', + dogeOpReturn: '0200000001aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000ffffffff0200000000000000002f6a2d09cb8e09635af4c5aa3a916a26c6652b18482f3a1ec3636bebdfa9f4d1599a8385426040f9baaff90aa6b137f01878f105000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000' } it('keeps the BTC default two-phase lane (P2WSH) byte-identical, funding and reveal', async () => { From b0b4c1180c9ae332eaf236560935d24f4567daed Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 14 Oct 2026 06:57:17 -0700 Subject: [PATCH 10/22] Fund multi-chunk P2WSH reveals with first-leg headroom and a surplus sweep, keep single transactions above the Core relay size, add the DOGE soft-dust fee per sub-limit output, build nested segwit inputs, and copy the caller's custom outputs --- src/XChainEncoder.js | 2 + .../build_transaction/data_outputs.js | 13 ++- .../build_transaction/fee_settlement.js | 11 ++- .../build_transaction/input_selection.js | 32 +++--- .../build_transaction/nested_segwit.js | 55 +++++++++++ .../build_transaction/payload_checks.js | 17 +++- .../build_transaction/reveal_outputs.js | 32 ++++-- .../build_transaction/script_hash_legs.js | 42 +++++--- .../build_transaction/stripped_floor.js | 79 +++++++++++++++ src/XChainEncoder/fee_policy.js | 15 ++- src/XChainEncoder/size_estimation.js | 24 +++++ src/build/tx_size_estimator.js | 5 + .../change_address_boundaries.test.js | 5 +- test/helpers/psbt_build_fixtures.js | 88 +++++++++++++++++ test/integration/custom_outputs.test.js | 8 +- .../custom_outputs_not_mutated.test.js | 36 +++++++ .../doge_soft_dust_surcharge.test.js | 87 +++++++++++++++++ .../nested_segwit_input.test.js | 79 +++++++++++++++ .../p2wsh_reveal_headroom.test.js | 74 ++++++++++++++ .../stripped_size_floor.test.js | 97 +++++++++++++++++++ 20 files changed, 754 insertions(+), 47 deletions(-) create mode 100644 src/XChainEncoder/build_transaction/nested_segwit.js create mode 100644 src/XChainEncoder/build_transaction/stripped_floor.js create mode 100644 test/helpers/psbt_build_fixtures.js create mode 100644 test/unit/xchain_encoder/psbt_conformance/custom_outputs_not_mutated.test.js create mode 100644 test/unit/xchain_encoder/psbt_conformance/doge_soft_dust_surcharge.test.js create mode 100644 test/unit/xchain_encoder/psbt_conformance/nested_segwit_input.test.js create mode 100644 test/unit/xchain_encoder/psbt_conformance/p2wsh_reveal_headroom.test.js create mode 100644 test/unit/xchain_encoder/psbt_conformance/stripped_size_floor.test.js diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index 0ea0ec75..6e998626 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -49,6 +49,7 @@ const { prepareDataChunks, priceRevealCustomOutputs, initEmissionState, emitData const { initSelection, selectFundingInputs } = require('./XChainEncoder/build_transaction/input_selection.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 { refuseUnderStrippedFloor } = require('./XChainEncoder/build_transaction/stripped_floor.js') const { finishBuild } = require('./XChainEncoder/build_transaction/build_result.js') @@ -266,6 +267,7 @@ function* buildSteps(build){ prefundRevealPackage.call(this, build) computeChange.call(this, build) emitChangeAndPad.call(this, build) + refuseUnderStrippedFloor.call(this, build) const outputsBeforeSweep = build.psbt.txOutputs.length yield* sweepP2shReveal.call(this, build) noteUndersizedRevealLeg.call(this, build, outputsBeforeSweep) diff --git a/src/XChainEncoder/build_transaction/data_outputs.js b/src/XChainEncoder/build_transaction/data_outputs.js index 36cd3693..320710c3 100644 --- a/src/XChainEncoder/build_transaction/data_outputs.js +++ b/src/XChainEncoder/build_transaction/data_outputs.js @@ -24,7 +24,8 @@ const { parseSatoshiAmount, assertPaymentOverDust } = require('../../common/vali const { SATOSHI_UNIT, Encoding } = require('../constants.js') const { resolveCallerAddress } = require('../request_resolution.js') const { emitP2shChunk, emitP2wshChunk } = require('./script_hash_legs.js') -const { feeForVsize } = require('../fee_policy.js') +const { feeForVsize, softDustSurchargeFor } = require('../fee_policy.js') +const { softDustFloorFor } = require('../script_amount_helpers.js') const { ParamRangeError } = require('../../build/errors') function prepareDataChunks(build){ @@ -85,6 +86,11 @@ function priceRevealCustomOutputs(build){ let revealCustomOutputsFee = (revealCustomOutputsBytes > 0 && feePerBytes > 0) ? feeForVsize(revealCustomOutputsBytes, feePerBytes, SATOSHI_UNIT) : 0 + // A reveal payment under the coin's soft dust limit also costs the reveal that + // limit in relay fee, and only the first leg can carry it. + if (revealCustomOutputsBytes > 0){ + revealCustomOutputsFee = revealCustomOutputsFee + softDustSurchargeFor(customOutputs.map(o => o.value), softDustFloorFor(this.networkKey)) + } Object.assign(build, { isP2shFamily, revealCustomOutputsValue, revealCustomOutputsBytes, revealCustomOutputsFee }) } @@ -322,7 +328,10 @@ function emitCustomOutputs(build){ estimatedTxSize += 43 // Taproot output size estimate (most expensive) } } - Object.assign(build, { outputSatoshis, estimatedTxSize }) + // Every value output is emitted by now except change, which is never under the + // soft limit, so this is the transaction's whole soft-dust relay surcharge. + const softDustSurcharge = softDustSurchargeFor(psbt.txOutputs.map(o => o.value), softDustFloorFor(this.networkKey)) + Object.assign(build, { outputSatoshis, estimatedTxSize, softDustSurcharge }) } module.exports = { prepareDataChunks, priceRevealCustomOutputs, initEmissionState, emitDataOutputs, emitCustomOutputs } diff --git a/src/XChainEncoder/build_transaction/fee_settlement.js b/src/XChainEncoder/build_transaction/fee_settlement.js index 8a3d1c56..b65e4324 100644 --- a/src/XChainEncoder/build_transaction/fee_settlement.js +++ b/src/XChainEncoder/build_transaction/fee_settlement.js @@ -26,7 +26,9 @@ const { maxCpfpUpliftSat, packageFeeUpliftSatoshis, feeForVsize } = require('../ // The caller-fee ceilings: the rate cap, then the absolute burn backstop. function refuseExcessiveFee(build){ - let { fee, capFeePerBytes, estimatedTxSize, estimatedFee, nodeFeePerBytes, feePerBytes } = build + let { fee, capFeePerBytes, estimatedTxSize, nodeFeePerBytes, feePerBytes } = build + // The rate ceilings judge the caller's own fee; the soft-dust surcharge is policy. + const estimatedFee = build.estimatedFee - (build.softDustSurcharge || 0) // Reject a caller-supplied absolute fee whose effective rate exceeds the // fee-rate cap for a transaction of this estimated size. Unlike feePerKb // (clamped above), an explicit fee is an exact amount the caller believes @@ -71,12 +73,15 @@ function refuseInsufficientRelayFee(build){ const { fee, feePerKb, feePerBytes, p2shHash, estimatedTxSize, estimatedFee, relayFeePerKb } = build const hasExplicitFee = fee != null && fee !== false const hasCallerRate = feePerKb != null && feePerKb !== false + // The node adds the soft-dust surcharge to its relay minimum, and every fee here + // already carries it on top, so the size-based check reads the fee without it. + const surcharge = build.softDustSurcharge || 0 const rateFee = hasExplicitFee - ? estimatedFee + ? estimatedFee - surcharge : p2shHash ? feeForVsize(estimatedTxSize, feePerBytes, SATOSHI_UNIT) - : estimatedFee + : estimatedFee - surcharge // A caller's fee or rate is judged as supplied. The node path is judged on the fee // it will charge, which floorEstimatedFee never lets fall under the dust threshold. const isNodePath = !hasExplicitFee && !hasCallerRate diff --git a/src/XChainEncoder/build_transaction/input_selection.js b/src/XChainEncoder/build_transaction/input_selection.js index a0ccbce8..5ac80add 100644 --- a/src/XChainEncoder/build_transaction/input_selection.js +++ b/src/XChainEncoder/build_transaction/input_selection.js @@ -26,9 +26,11 @@ const { SATOSHI_UNIT, RESERVATION_TTL_MS, Encoding } = require('../constants.js' const { jsonSafeSat } = require('../script_amount_helpers.js') const { feeForVsize } = require('../fee_policy.js') const { isUnconfirmedUtxo } = require('./input_candidates.js') +const { nestedP2wpkhRedeem } = require('./nested_segwit.js') +const { clearsStrippedFloor } = require('./stripped_floor.js') function initSelection(build){ - let { estimatedTxSize, fee, psbt } = build + let { estimatedTxSize, fee, psbt, softDustSurcharge } = build estimatedTxSize = estimatedTxSize + 43 // change output (worst case: taproot) // The output-count varint is 1 byte up to 252 outputs and 3 bytes past that; the // 10-byte header counted in initEmissionState assumed 1. A MULTISIGN payload plus @@ -46,8 +48,9 @@ function initSelection(build){ // bypass validateFee. parseSatoshiAmount throws RangeError // ("fee must be a non-negative integer") on any non-exact or negative // value, preserving the previous error contract. - estimatedFee = parseSatoshiAmount(fee, 'fee') - requestedFee = estimatedFee + requestedFee = parseSatoshiAmount(fee, 'fee') + // An explicit fee pays for size; the soft-dust relay surcharge rides on top. + estimatedFee = requestedFee + (softDustSurcharge || 0) } let selectedInputCount = 0 @@ -76,9 +79,9 @@ function selectionTargetFee(estimatedFee){ // Transaction framing an input adds beyond its own estimated size: the segwit marker // and flag (2 weight units, rounded up to 1 vB) with the first witness input, and two // more bytes of input-count varint when the 253rd input is added. -function framingBytesForInput(build, utxo, selectedInputCount){ +function framingBytesForInput(build, utxo, selectedInputCount, nested){ let bytes = 0 - if (!build.segwitMarkerCounted && this.isSegwitUTXO(utxo)){ + if (!build.segwitMarkerCounted && (nested || this.isSegwitUTXO(utxo))){ build.segwitMarkerCounted = true bytes = bytes + 1 } @@ -100,7 +103,7 @@ function* selectFundingInputs(build){ function* selectInputs(build){ let { utxos, preparedData, exactInputs, firstReservedOutpoint, callReservations, psbt, utxoSequence, attachPrevTx, fee, feePerBytes, outputSatoshis, estimatedTxSize, estimatedFee, inputSatoshis, - selectedInputCount, unconfirmedInputTxids, reservedCandidates } = build + selectedInputCount, unconfirmedInputTxids, reservedCandidates, softDustSurcharge } = build const now = Date.now() this.evictExpiredReservations(now) let nextUtxoIndex = 0 @@ -118,19 +121,21 @@ 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, build.fetchedFromTracker) + const nestedRedeem = nestedP2wpkhRedeem.call(this, build, nextUtxo) + const added = yield* addSelectedInput.call(this, psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis, build.fetchedFromTracker, nestedRedeem) estimatedTxSize = added.estimatedTxSize inputSatoshis = added.inputSatoshis selectedInputCount = selectedInputCount + 1 - estimatedTxSize = estimatedTxSize + framingBytesForInput.call(this, build, nextUtxo, selectedInputCount) + estimatedTxSize = estimatedTxSize + framingBytesForInput.call(this, build, nextUtxo, selectedInputCount, !!nestedRedeem) if (isUnconfirmedUtxo(nextUtxo)){ unconfirmedInputTxids.push(nextUtxo.txid) } + // A rate-priced fee also carries the soft-dust relay surcharge (0 off Dogecoin). if (fee == null || fee === false) { - estimatedFee = feeForVsize(estimatedTxSize, feePerBytes, SATOSHI_UNIT) + estimatedFee = feeForVsize(estimatedTxSize, feePerBytes, SATOSHI_UNIT) + (softDustSurcharge || 0) } // Exact-input mode never stops early: the caller named this set @@ -139,7 +144,8 @@ function* selectInputs(build){ // at sufficiency is exactly the behaviour that made a stuck batch // unrescuable through this API. Any surplus goes to change, which // the shortfall/change math below already handles. - if (!exactInputs && inputSatoshis > outputSatoshis + BigInt(selectionTargetFee.call(this, estimatedFee))){ + const targetFee = selectionTargetFee.call(this, estimatedFee) + if (!exactInputs && inputSatoshis > outputSatoshis + BigInt(targetFee) && clearsStrippedFloor.call(this, build, inputSatoshis, targetFee)){ break } @@ -263,8 +269,9 @@ function assertPrevOutValueMatches(prevTxHex, utxo, fetchedFromTracker){ throw new ParamRangeError(problem) } -function* addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis, fetchedFromTracker){ - if (this.isSegwitUTXO(nextUtxo)){ +// nestedRedeem is the P2WPKH redeem script of a P2SH-P2WPKH input, signed as segwit. +function* addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis, fetchedFromTracker, nestedRedeem){ + if (nestedRedeem || this.isSegwitUTXO(nextUtxo)){ let nextInput = { hash: nextUtxo.txid, index: nextUtxo.vout, @@ -274,6 +281,7 @@ function* addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, estimated value: nextUtxo.value, } } + if (nestedRedeem) nextInput.redeemScript = nestedRedeem // A hardware signer needs the FULL previous // transaction even for a segwit input, because Ledger takes // the outpoint it signs from those bytes rather than from diff --git a/src/XChainEncoder/build_transaction/nested_segwit.js b/src/XChainEncoder/build_transaction/nested_segwit.js new file mode 100644 index 00000000..db876a27 --- /dev/null +++ b/src/XChainEncoder/build_transaction/nested_segwit.js @@ -0,0 +1,55 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * XChain Encoder - Encoder Class + * + * This file recognises P2SH-wrapped P2WPKH (nested segwit) funding inputs + * + ********************************************************************/ + +const bitcoin = require('bitcoinjs-lib'); + +// Only a compressed key has a standard P2WPKH script. +const COMPRESSED_PUBKEY_RE = /^0[23][0-9a-fA-F]{64}$/ + +// The P2WPKH redeem script of the caller's key, or null when the key has none. +function p2wpkhRedeemFor(keyHex, network){ + try { + return bitcoin.payments.p2wpkh({ pubkey: Buffer.from(keyHex, 'hex'), network }).output + } catch (e) { + return null + } +} + +// The redeem script of a P2SH-P2WPKH funding UTXO, or null for any other script. +// A P2SH scriptPubKey is only a hash, so the redeem script comes from the public key +// the request carries (pubkey or compressedPubKey) and counts only when it hashes to +// the UTXO's script. A P2SH no supplied key matches, or one built with no key at all +// (a bare P2SH address does not say whether it is nested segwit or, say, multisig), +// stays a legacy input with its whole previous transaction. +function nestedP2wpkhRedeem(build, utxo){ + // Verify the chain has segwit: elsewhere a P2SH output is only ever legacy + if (this.network.supportsSegwit === false) return null + const script = Buffer.from(utxo.scriptPubKey, 'hex') + // Verify the output is P2SH (OP_HASH160 <20> OP_EQUAL) + if (script.length !== 23 || script[0] !== 0xa9 || script[1] !== 0x14 || script[22] !== 0x87) return null + const keys = [build.pubkey, build.compressedPubKey].filter(k => typeof k === 'string' && COMPRESSED_PUBKEY_RE.test(k)) + for (const key of keys){ + const redeem = p2wpkhRedeemFor(key, this.network) + if (redeem && bitcoin.payments.p2sh({ redeem: { output: redeem }, network: this.network }).output.equals(script)) return redeem + } + return null +} + +module.exports = { nestedP2wpkhRedeem } diff --git a/src/XChainEncoder/build_transaction/payload_checks.js b/src/XChainEncoder/build_transaction/payload_checks.js index c767a408..2476d370 100644 --- a/src/XChainEncoder/build_transaction/payload_checks.js +++ b/src/XChainEncoder/build_transaction/payload_checks.js @@ -29,7 +29,7 @@ const { ParamTypeError, ParamRangeError } = require('../../build/errors') // The caller's payload as handed over: wire-safety re-checks, the ACTION-name // read, and the fee-quote output injected ahead of every other output. function checkPayloadInput(build){ - let { data, rawData, feeQuote, customOutputs } = build + let { data, rawData } = build // Re-check what the wire encodings can actually carry, for direct library // callers. api.js runs validateAll before createTransaction, but // createTransaction is a supported library entry point of its own and @@ -76,16 +76,23 @@ function checkPayloadInput(build){ checkOutputAddresses.call(this, build) checkPaymentDust.call(this, build) + injectFeeQuoteOutput(build) + Object.assign(build, { unknownAction }) +} - // If feeQuote is provided, inject it as a custom output. It is remembered so the - // emission step can tell the protocol-fee output from the caller's own payments. +// If feeQuote is provided, inject it as a custom output. It is remembered so the +// emission step can tell the protocol-fee output from the caller's own payments. +function injectFeeQuoteOutput(build){ + let { feeQuote, customOutputs } = build let feeQuoteOutput = null if(feeQuote && feeQuote.address && feeQuote.amount > 0){ - if(!customOutputs) customOutputs = []; + // Push onto a copy: the array is the caller's, and an in-process caller that + // reuses it for a second build would otherwise carry the first build's fee output. + customOutputs = Array.isArray(customOutputs) ? customOutputs.slice() : []; feeQuoteOutput = { address: feeQuote.address, value: feeQuote.amount } customOutputs.push(feeQuoteOutput); } - Object.assign(build, { unknownAction, customOutputs, feeQuoteOutput }) + Object.assign(build, { customOutputs, feeQuoteOutput }) } // Refuse a caller payment under the coin's relay dust threshold in the first build diff --git a/src/XChainEncoder/build_transaction/reveal_outputs.js b/src/XChainEncoder/build_transaction/reveal_outputs.js index cc178ca9..3b1b6c98 100644 --- a/src/XChainEncoder/build_transaction/reveal_outputs.js +++ b/src/XChainEncoder/build_transaction/reveal_outputs.js @@ -25,6 +25,7 @@ const { logger, TAPROOT_LEAF_VERSION, SATOSHI_UNIT, Encoding } = require('../con const { envelopeTapLeafHash, asSatValue } = require('../script_amount_helpers.js') const { packageFeeUpliftSatoshis, maxCpfpUpliftSat, feeForVsize } = require('../fee_policy.js') const { resolveCallerAddress } = require('../request_resolution.js') +const { revealNeedsFloorPad } = require('./script_hash_legs.js') // The change output, then the P2WSH reveal's stripped-size floor pad. function emitChangeAndPad(build){ @@ -74,7 +75,7 @@ function emitChangeAndPad(build){ function* sweepP2shReveal(build){ let { p2shHash, preparedData, phaseLegInputSatoshis, customOutputs, feePerBytes, estimatedFee, outputSatoshis, - change, pubkey, psbt } = build + change, pubkey, psbt, softDustSurcharge } = build // Sweep the P2SH reveal's leg surplus back to the // caller. The funding tx (P2SH funding branch above) tops the first leg // up by the reveal's own fee plus one dust precisely so this output can @@ -88,18 +89,24 @@ function* sweepP2shReveal(build){ // everything above it returns to `change` or the caller's own address. // Legs funded before this fix carry no headroom, leave no surplus at or // above dust, and produce a byte-identical (still outputless) reveal. - if (p2shHash && preparedData["encoding"] === Encoding.P2SH && phaseLegInputSatoshis > 0n){ + // A multi-chunk P2WSH reveal is swept the same way: its first leg carries the same + // headroom. A single-chunk one was funded a floor pad instead and is not swept. + if (p2shHash && sweepsRevealSurplus.call(this, build) && phaseLegInputSatoshis > 0n){ let revealEmittedOutputBytes = 43 // change output (worst case), matching the reserve above if (customOutputs && Array.isArray(customOutputs)){ for (let i = 0; i < customOutputs.length; i++){ revealEmittedOutputBytes = revealEmittedOutputBytes + TxSizeEstimator.estimateOutputSizeForAddress(customOutputs[i].address, this.network) } } - const revealSizeForFee = this.estimateP2shRevealTx(preparedData["dataBufferArray"], revealEmittedOutputBytes) + const revealSizeForFee = preparedData["encoding"] === Encoding.P2SH + ? this.estimateP2shRevealTx(preparedData["dataBufferArray"], revealEmittedOutputBytes) + : this.estimateP2wshRevealTx(preparedData["dataBufferArray"], revealEmittedOutputBytes) let revealFeeKept = feeForVsize(revealSizeForFee, feePerBytes, SATOSHI_UNIT) if (revealFeeKept < estimatedFee){ revealFeeKept = estimatedFee } + // The soft-dust relay surcharge for the reveal's own sub-limit payments is fee too. + revealFeeKept = revealFeeKept + (softDustSurcharge || 0) revealFeeKept = (yield* upliftRevealForCommit.call(this, build, revealSizeForFee, revealFeeKept)) @@ -116,6 +123,14 @@ function* sweepP2shReveal(build){ } } +// Which chunk reveals end with a surplus sweep back to the caller: those whose first +// funding leg was topped up with reveal headroom (see topUpFirstLeg). +function sweepsRevealSurplus(build){ + const { preparedData } = build + const encoding = preparedData["encoding"] + return encoding === Encoding.P2SH || (encoding === Encoding.P2WSH && !revealNeedsFloorPad.call(this, preparedData)) +} + function* upliftRevealForCommit(build, revealSizeForFee, revealFeeKept){ let { feePerBytes, p2shHash } = build // Keep the package prefund as FEE instead of sweeping it home. @@ -128,11 +143,12 @@ function* upliftRevealForCommit(build, revealSizeForFee, revealFeeKept){ // leaving the package exactly as under-target as it was. // // The commit's fee is read from the node's mempool rather than - // re-derived: the commit is a broadcast transaction by now, its ACTUAL - // size and fee are what the miner sees, and this also picks up any - // unconfirmed ancestors it inherited. An already-confirmed commit - // reports an empty package and asks for nothing, which is correct - a - // confirmed parent is no longer part of anyone's package. Any failure + // re-derived: a broadcast commit's ACTUAL size and fee are what the + // miner sees, and this also picks up any unconfirmed ancestors it + // inherited. A commit the node does not hold reports an empty package + // and asks for nothing: one already confirmed is in no package, and one + // not yet broadcast (the SDK builds its reveal before broadcasting the + // commit, so it can refuse first) gets no uplift. Any failure // (no method, unreachable node, RPC error) leaves the fee exactly as // it was; the reveal is then priced per-transaction, as before. const revealUpliftBound = maxCpfpUpliftSat() diff --git a/src/XChainEncoder/build_transaction/script_hash_legs.js b/src/XChainEncoder/build_transaction/script_hash_legs.js index e17252d2..7fd0daad 100644 --- a/src/XChainEncoder/build_transaction/script_hash_legs.js +++ b/src/XChainEncoder/build_transaction/script_hash_legs.js @@ -20,7 +20,7 @@ const bitcoin = require('bitcoinjs-lib'); const TxSizeEstimator = require('../../build/tx_size_estimator') -const { MAGIC_WORD, SATOSHI_UNIT } = require('../constants.js') +const { MAGIC_WORD, SATOSHI_UNIT, Encoding } = require('../constants.js') const { asSatValue } = require('../script_amount_helpers.js') const { assertRevealFundingTxMatches } = require('../request_resolution.js') const { feeForVsize } = require('../fee_policy.js') @@ -121,7 +121,6 @@ function fundP2shLeg(build, nextDataBuffer){ } function topUpFirstP2shLeg(build, spendingP2shEstimatedFee){ - let { p2shRevealHeadroomFunded, revealCustomOutputsBytes, preparedData, feePerBytes, finalDust, psbt, revealPrefund } = build // The reveal's ONLY inputs are these // funding legs (the reveal path runs no input selection), // so every satoshi the reveal spends - its miner fee AND @@ -155,40 +154,52 @@ function topUpFirstP2shLeg(build, spendingP2shEstimatedFee){ // output emittable is conditional. With customOutputs the // surplus then lands at zero, below the sweep's dust // threshold, and the reveal keeps exactly its two outputs. + return topUpFirstLeg.call(this, build, spendingP2shEstimatedFee, Encoding.P2SH) +} + +// The one-time first-leg top-up both chunk carriers share; the rationale is above. +function topUpFirstLeg(build, spendingEstimatedFee, encoding){ + let { p2shRevealHeadroomFunded, revealCustomOutputsBytes, preparedData, feePerBytes, finalDust, psbt, revealPrefund } = build if (!p2shRevealHeadroomFunded){ p2shRevealHeadroomFunded = true let needsChangeOutput = (revealCustomOutputsBytes === 0) - let { baseLegsTotal, revealFeeNeeded } = sumP2shRevealLegs.call(this, preparedData, feePerBytes, finalDust) + let { baseLegsTotal, revealFeeNeeded, revealSize } = sumRevealLegs.call(this, preparedData, feePerBytes, finalDust, encoding) let revealShortfall = Math.max(0, revealFeeNeeded - baseLegsTotal) // The reveal's change is an authored output, so its headroom is the relay floor. let changeHeadroom = needsChangeOutput ? BigInt(this.outputFloor) : 0n - spendingP2shEstimatedFee = asSatValue(BigInt(spendingP2shEstimatedFee) + BigInt(revealShortfall) + changeHeadroom) + spendingEstimatedFee = asSatValue(BigInt(spendingEstimatedFee) + BigInt(revealShortfall) + changeHeadroom) // This leg is where the reveal's fee money lives, so it is // the one the package-prefund pass tops up. revealPrefund = { outputIndex: psbt.txOutputs.length, - revealSize: this.estimateP2shRevealTx(preparedData["dataBufferArray"], 43), + revealSize, revealFee: revealFeeNeeded } } Object.assign(build, { p2shRevealHeadroomFunded, revealPrefund }) - return spendingP2shEstimatedFee + return spendingEstimatedFee } // What the legs fund at their own per-chunk sizing, and what the whole reveal needs. -function sumP2shRevealLegs(preparedData, feePerBytes, finalDust){ +// A P2WSH leg reaching here has no floor pad, so its per-chunk size carries none. +function sumRevealLegs(preparedData, feePerBytes, finalDust, encoding){ + const isP2sh = encoding === Encoding.P2SH let baseLegsTotal = 0 for (const chunkBuffer of preparedData["dataBufferArray"]){ - let chunkLegFee = feeForVsize(this.estimateSpendingP2shTx(chunkBuffer), feePerBytes, SATOSHI_UNIT) + const chunkSize = isP2sh ? this.estimateSpendingP2shTx(chunkBuffer) : this.estimateSpendingP2wshTx(chunkBuffer, 0) + let chunkLegFee = feeForVsize(chunkSize, feePerBytes, SATOSHI_UNIT) baseLegsTotal = baseLegsTotal + Math.max(chunkLegFee, finalDust) } - let revealFeeNeeded = feeForVsize(this.estimateP2shRevealTx(preparedData["dataBufferArray"], 43), feePerBytes, SATOSHI_UNIT) + const revealSize = isP2sh + ? this.estimateP2shRevealTx(preparedData["dataBufferArray"], 43) + : this.estimateP2wshRevealTx(preparedData["dataBufferArray"], 43) + let revealFeeNeeded = feeForVsize(revealSize, feePerBytes, SATOSHI_UNIT) if (revealFeeNeeded < this.dustAmount){ // Mirrors the reveal path's own dust floor on // estimatedFee, so both phases price the same fee. revealFeeNeeded = this.dustAmount } - return { baseLegsTotal, revealFeeNeeded } + return { baseLegsTotal, revealFeeNeeded, revealSize } } // A P2WSH chunk: a funding output on the commit, or an input spending it on the reveal. @@ -209,7 +220,7 @@ function* emitP2wshChunk(build, nextDataBuffer){ } function* spendP2wshLeg(build, nextDataBuffer){ - let { psbt, txidFirstInput, p2shTx, voutPsbtIndex, utxoSequence, p2shHash, estimatedTxSize } = build + let { psbt, txidFirstInput, p2shTx, voutPsbtIndex, utxoSequence, p2shHash, estimatedTxSize, phaseLegInputSatoshis } = build if (!psbt){ psbt = new bitcoin.Psbt({ network: this.network }) psbt.addOutput({ @@ -244,8 +255,10 @@ function* spendP2wshLeg(build, nextDataBuffer){ psbt.addInput(nextInput) estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateInputSize(nextInput) + // Leg value this reveal input consumes, for the surplus sweep, as on the P2SH reveal. + phaseLegInputSatoshis = phaseLegInputSatoshis + BigInt(p2shTx.outs[voutPsbtIndex].value) voutPsbtIndex = voutPsbtIndex + 1 - Object.assign(build, { psbt, estimatedTxSize, voutPsbtIndex }) + Object.assign(build, { psbt, estimatedTxSize, phaseLegInputSatoshis, voutPsbtIndex }) } function fundP2wshLeg(build, nextDataBuffer){ @@ -287,6 +300,9 @@ function fundP2wshLeg(build, nextDataBuffer){ revealCustomOutputsFee = 0 } + // A reveal with no floor pad leaves no value output of its own: fund it as on P2SH. + if (!padOutputBytes) spendingP2wshEstimatedFee = topUpFirstLeg.call(this, build, spendingP2wshEstimatedFee, Encoding.P2WSH) + psbt.addOutput({ address: bitcoin.payments.p2wsh({ redeem: {output:nextDataBuffer}, network:this.network}).address, value:spendingP2wshEstimatedFee @@ -336,4 +352,4 @@ function revealNeedsFloorPad(preparedData){ return !!(strippedFloor && predictedRevealStripped < strippedFloor) } -module.exports = { emitP2shChunk, emitP2wshChunk } +module.exports = { emitP2shChunk, emitP2wshChunk, revealNeedsFloorPad } diff --git a/src/XChainEncoder/build_transaction/stripped_floor.js b/src/XChainEncoder/build_transaction/stripped_floor.js new file mode 100644 index 00000000..87897ea1 --- /dev/null +++ b/src/XChainEncoder/build_transaction/stripped_floor.js @@ -0,0 +1,79 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * XChain Encoder - Encoder Class + * + * This file holds single-transaction builds to the chain's stripped-size relay floor + * + ********************************************************************/ + +const TxSizeEstimator = require('../../build/tx_size_estimator') +const { OperationalError } = require('../../build/errors') +const coins = require('../../coins') + +// The relay floor on a transaction's stripped size that the coin's node software +// enforces, MIN_STANDARD_TX_NONWITNESS_SIZE in src/policy/policy.h: Bitcoin Core 65 +// (v25.0 on), Litecoin Core 82. Dogecoin Core has none. These are not the coin +// registry's minStandardTxNonWitnessSize, which pins the reveal pad's target. +const CORE_MIN_STANDARD_TX_NONWITNESS_SIZE = { BTC: 65, LTC: 82 } + +// The node floor for this encoder's coin, or 0 where the node has none. +function coreStrippedFloor(networkKey){ + const key = String(networkKey || '') + const tick = coins.FULL_NAME_TO_TICK[key.slice(0, Math.max(0, key.lastIndexOf('-')))] + return CORE_MIN_STANDARD_TX_NONWITNESS_SIZE[tick] || 0 +} + +// Fewest scriptSig bytes an input carries once signed: none for native segwit, the +// redeem-script push for P2SH-P2WPKH, and a P2PKH signature and key for a legacy input. +function scriptSigLowerBound(input){ + if (input.witnessUtxo) return input.redeemScript ? 1 + input.redeemScript.length : 0 + return 106 +} + +// The stripped size the signed transaction will have, plus changeOutputBytes. +function predictedStrippedSize(build, changeOutputBytes){ + let size = this.strippedTxSize(build.psbt) + changeOutputBytes + for (const input of build.psbt.data.inputs) size = size + scriptSigLowerBound(input) + return size +} + +// Whether a single-transaction build, as selected so far, clears the node's stripped +// floor. Under it selection keeps going, since one more input or a funded change +// output lifts it over; the reveal pads itself instead. +function clearsStrippedFloor(build, inputSatoshis, selectionFee){ + const floor = coreStrippedFloor(this.networkKey) + if (!floor || build.p2shHash || !build.change) return true + const changeSatoshis = inputSatoshis - build.outputSatoshis - BigInt(selectionFee) + const changeBytes = changeSatoshis >= BigInt(this.outputFloor) + ? TxSizeEstimator.estimateOutputSizeForAddress(build.change, this.network) + : 0 + return predictedStrippedSize.call(this, build, changeBytes) >= floor +} + +// Refuse a finished single-transaction build that is still under the floor: a node +// will not relay it, and no input was left to fund the change output that lifts it. +function refuseUnderStrippedFloor(build){ + const floor = coreStrippedFloor(this.networkKey) + if (!floor || build.p2shHash) return + const strippedSize = predictedStrippedSize.call(this, build, 0) + if (strippedSize >= floor) return + throw new OperationalError('TX_BELOW_MIN_STANDARD_SIZE', + `the transaction would be ${strippedSize} non-witness bytes, under this chain's ${floor}-byte relay ` + + `minimum, and its inputs leave too little for a change output to lift it: leave at least ` + + `${this.outputFloor} base units plus its fee for change, or add an input`, + { strippedSize, minimum: floor }) +} + +module.exports = { CORE_MIN_STANDARD_TX_NONWITNESS_SIZE, clearsStrippedFloor, refuseUnderStrippedFloor } diff --git a/src/XChainEncoder/fee_policy.js b/src/XChainEncoder/fee_policy.js index 308bd048..e0e85c4c 100644 --- a/src/XChainEncoder/fee_policy.js +++ b/src/XChainEncoder/fee_policy.js @@ -136,4 +136,17 @@ function feeForVsize(vsize, feePerBytes, satoshiUnit){ return Math.ceil(exact - Math.abs(exact) * 1e-12) } -module.exports = { isTestNetworkKey, suggestedFeeCeilingPerByte, suggestedFeeCeilingFloorPerByte, maxCpfpUpliftSat, packageFeeUpliftSatoshis, capCallerFeeRate, clampLimitLabel, feeForVsize } +// The relay surcharge the soft dust rule (SOFT_DUST_FLOOR_BY_COIN) puts on a transaction: +// the whole soft limit once for every value output under it. A zero-value data carrier +// is not a payment output and adds nothing; a coin with no soft limit adds nothing. +function softDustSurchargeFor(outputValues, softDustFloor){ + if (!(softDustFloor > 0)) return 0 + let surcharge = 0 + for (const value of outputValues){ + const v = BigInt(value) + if (v > 0n && v < BigInt(softDustFloor)) surcharge = surcharge + softDustFloor + } + return surcharge +} + +module.exports = { isTestNetworkKey, suggestedFeeCeilingPerByte, suggestedFeeCeilingFloorPerByte, maxCpfpUpliftSat, packageFeeUpliftSatoshis, capCallerFeeRate, clampLimitLabel, feeForVsize, softDustSurchargeFor } diff --git a/src/XChainEncoder/size_estimation.js b/src/XChainEncoder/size_estimation.js index 38bed1cd..920763ff 100644 --- a/src/XChainEncoder/size_estimation.js +++ b/src/XChainEncoder/size_estimation.js @@ -217,4 +217,28 @@ module.exports = { return sizeEstimated }, + + // Whole-tx vsize of the P2WSH reveal: every chunk input with its witness (sig, + // pubkey, witness script), the OP_RETURN marker, plus extraOutputsBytes for the + // value outputs it emits. The P2WSH twin of estimateP2shRevealTx, and both phases + // price the reveal with it for the same reason given there. + estimateP2wshRevealTx(dataBufferArray, extraOutputsBytes = 0){ + let witnessBytes = 2 // marker + flag + for (const witnessData of dataBufferArray){ + witnessBytes = witnessBytes + 1 + (1 + 72) + (1 + 33) + + compactSizeLen(witnessData.length) + witnessData.length + } + let nonWitnessBytes = 10 // 4 version + 1 inputs count + 1 outputs count + 4 locktime + + (36 + 1 + 4) * dataBufferArray.length + + TxSizeEstimator.estimateOpReturnOutput(Buffer.concat([ + Buffer.from(MAGIC_WORD,'utf8'), + Buffer.from("p2wsh",'utf8') + ])) + + extraOutputsBytes + // A reveal under the chain's stripped-size floor is padded up to it. + const strippedFloor = this.network.minStandardTxNonWitnessSize + if (strippedFloor && nonWitnessBytes < strippedFloor) nonWitnessBytes = strippedFloor + return nonWitnessBytes + Math.ceil(witnessBytes / 4) + + 8 // safety margin for DER-sig length jitter, as in the per-chunk estimate + }, } diff --git a/src/build/tx_size_estimator.js b/src/build/tx_size_estimator.js index f40be6ca..3ef5abf0 100644 --- a/src/build/tx_size_estimator.js +++ b/src/build/tx_size_estimator.js @@ -53,6 +53,11 @@ function inputSizeForScript(scriptPubKey, isSegwit) { // P2WSH: 0020{32-byte hash} - Assuming 2-3 Multisig return 105 } + if (scriptHex.length === 46 && scriptHex.startsWith('a914') && scriptHex.endsWith('87')) { + // P2SH-P2WPKH: 64 non-witness bytes (outpoint, sequence, 23-byte scriptSig + // pushing the redeem script) plus a 108-weight sig and key witness = 91 vB. + return 91 + } if (scriptHex.startsWith('5120')) { // P2TR: 5120{32-byte x-only key}. Key-path spend = 1 Schnorr // witness (~57.5 vbytes incl. prevout + sequence). Without this it diff --git a/test/boundary/change_address_boundaries.test.js b/test/boundary/change_address_boundaries.test.js index 9471fef4..65cb81e0 100644 --- a/test/boundary/change_address_boundaries.test.js +++ b/test/boundary/change_address_boundaries.test.js @@ -36,6 +36,9 @@ const { // sub-100000 fees, so this suite runs on bitcoin-regtest; LTC dust (5460) has // its own tests below using a local litecoin-regtest encoder. const NETWORK = 'bitcoin-regtest' +// One input, an OP_RETURN and no change output is 79 stripped bytes with 'SEND|0|X|1|a', +// under Litecoin Core's 82-byte relay floor, so the Litecoin case carries a longer action. +const LTC_FOLDING_ACTION = 'SEND|0|XCHAIN|1|abcdef' const BTC_DUST = 546 const LTC_DUST = 5460 @@ -183,7 +186,7 @@ describe('Change Address Boundaries', () => { const result = await ltcEncoder.createTransaction( [utxo], ltcAddress, null, - 'SEND|0|X|1|a', null, fee, false, null, null, + LTC_FOLDING_ACTION, null, fee, false, null, null, null, null, null, true, 0.00001 ) diff --git a/test/helpers/psbt_build_fixtures.js b/test/helpers/psbt_build_fixtures.js new file mode 100644 index 00000000..23c82bdc --- /dev/null +++ b/test/helpers/psbt_build_fixtures.js @@ -0,0 +1,88 @@ +// 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. + +// Fixtures for PSBT conformance suites: one test key (private key 1, so its public +// key is the curve generator), funding UTXOs of each script type with their whole +// previous transactions, an encoder over a stubbed node, and named-argument builds. + +const crypto = require('crypto') +const bitcoin = require('bitcoinjs-lib') +const ecc = require('tiny-secp256k1') +const { ECPairFactory } = require('ecpair') +const XChainEncoder = require('../../src/XChainEncoder') +const CryptoNetworks = require('../../src/build/crypto_networks') + +const ECPair = ECPairFactory(ecc) +const PRIVATE_KEY = Buffer.concat([Buffer.alloc(31), Buffer.from([1])]) +const PUBKEY = Buffer.from(ECPair.fromPrivateKey(PRIVATE_KEY).publicKey) +const PUBKEY_HEX = PUBKEY.toString('hex') + +function networkOf (key) { + return CryptoNetworks.getBitcoinJsNetwork(key) +} + +// The output script of `kind` for `pubkey` (default: the test key) on `network`. +function paymentFor (kind, network, pubkey = PUBKEY) { + const net = networkOf(network) + if (kind === 'p2wpkh') return bitcoin.payments.p2wpkh({ pubkey, network: net }) + if (kind === 'p2pkh') return bitcoin.payments.p2pkh({ pubkey, network: net }) + if (kind === 'p2sh-p2wpkh') return bitcoin.payments.p2sh({ redeem: bitcoin.payments.p2wpkh({ pubkey, network: net }), network: net }) + throw new Error('unknown kind ' + kind) +} + +// A UTXO paying `script`, plus its previous transaction registered in `prevTxs`. +function utxoFor (prevTxs, script, value, seed) { + const tx = new bitcoin.Transaction() + tx.addInput(crypto.createHash('sha256').update('fixture:' + seed).digest(), 0) + tx.addOutput(script, value) + prevTxs.set(tx.getId(), tx.toHex()) + return { txid: tx.getId(), vout: 0, value, confirmations: 6, scriptPubKey: script.toString('hex') } +} + +// An encoder whose node answers fee, relay and previous-transaction calls from memory. +function makeEncoder (network, { estimate, relayfee, prevTxs } = {}) { + const encoder = new XChainEncoder(network, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') + encoder.connector = { + getFeePerKilobyte: async () => estimate, + getNetworkInfo: async () => ({ relayfee }), + getTransactionHex: async (txid) => { + if (!prevTxs || !prevTxs.has(txid)) throw new Error('no previous transaction ' + txid) + return prevTxs.get(txid) + } + } + return encoder +} + +// createTransaction with named arguments, in payload_preparation.js's positional order. +function build (encoder, a) { + return encoder.createTransaction( + a.utxos, a.pubkey, a.customOutputs || null, a.data == null ? null : a.data, null, + a.fee == null ? null : a.fee, false, a.encoding || null, a.change || null, + a.p2shHash || null, a.p2shHex || null, a.compressedPubKey || null, true, + a.feePerKb == null ? null : a.feePerKb, null, a.feeQuote || null) +} + +function unsignedTx (result) { + return bitcoin.Transaction.fromBuffer(result.psbt.data.globalMap.unsignedTx.toBuffer()) +} + +// Input total minus output total of a built PSBT, in base units. +function feeOf (psbt) { + let total = 0n + psbt.data.inputs.forEach((input, i) => { + total += input.witnessUtxo + ? BigInt(input.witnessUtxo.value) + : BigInt(bitcoin.Transaction.fromBuffer(input.nonWitnessUtxo).outs[psbt.txInputs[i].index].value) + }) + for (const out of psbt.txOutputs) total -= BigInt(out.value) + return total +} + +module.exports = { ECPair, PRIVATE_KEY, PUBKEY, PUBKEY_HEX, networkOf, paymentFor, utxoFor, makeEncoder, build, unsignedTx, feeOf } diff --git a/test/integration/custom_outputs.test.js b/test/integration/custom_outputs.test.js index aa14f821..4f3ece0e 100644 --- a/test/integration/custom_outputs.test.js +++ b/test/integration/custom_outputs.test.js @@ -123,8 +123,12 @@ describe('Category E: Custom Outputs (COINPAY Integration)', () => { const changeWithCustom = resultWithCustom.psbt.txOutputs .reduce((max, o) => o.value > max ? o.value : max, 0) - assert.strictEqual(changeNoCustom - changeWithCustom, 500000, - 'change should decrease by exactly the custom output value') + // On Dogecoin a 500000-koinu payment sits under the 0.01 DOGE soft dust limit, so + // its fee carries the whole 1000000 limit on top of the explicit 10000 (1010000), + // where the build without it pays the 100000 dust floor. Change drops by the + // payment plus that 910000 fee difference. + assert.strictEqual(changeNoCustom - changeWithCustom, 500000 + (10000 + 1000000 - 100000), + 'change should decrease by the custom output value plus its soft-dust surcharge') }) }) }) diff --git a/test/unit/xchain_encoder/psbt_conformance/custom_outputs_not_mutated.test.js b/test/unit/xchain_encoder/psbt_conformance/custom_outputs_not_mutated.test.js new file mode 100644 index 00000000..7a91af4b --- /dev/null +++ b/test/unit/xchain_encoder/psbt_conformance/custom_outputs_not_mutated.test.js @@ -0,0 +1,36 @@ +// 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 hub feeQuote output is added to the build's own output list. An in-process +// caller that reuses its customOutputs array for a second build must get the same +// transaction shape, not one extra fee output per earlier build. + +const assert = require('assert') +const { paymentFor, utxoFor, makeEncoder, build } = require('../../../helpers/psbt_build_fixtures.js') + +describe('XChainEncoder leaves the caller\'s customOutputs array alone', function () { + it('builds twice from one array with one fee output each time', async function () { + const prevTxs = new Map() + const network = 'bitcoin-regtest' + const pay = paymentFor('p2wpkh', network) + const hub = paymentFor('p2pkh', network) + const encoder = makeEncoder(network, { estimate: 0.00002, relayfee: 0.00001, prevTxs }) + const customOutputs = [{ address: pay.address, value: 10000 }] + const request = (seed) => ({ + utxos: [utxoFor(prevTxs, pay.output, 1000000, seed)], pubkey: pay.address, data: 'BROADCAST|0|hi', + encoding: 'OP_RETURN', change: pay.address, customOutputs, feeQuote: { address: hub.address, amount: 5000 } + }) + const first = await build(encoder, request('first')) + const second = await build(encoder, request('second')) + assert.strictEqual(customOutputs.length, 1, 'the caller\'s array keeps its one payment') + assert.strictEqual(second.psbt.txOutputs.length, first.psbt.txOutputs.length) + assert.strictEqual(second.psbt.txOutputs.filter(o => o.script.equals(hub.output)).length, 1) + }) +}) diff --git a/test/unit/xchain_encoder/psbt_conformance/doge_soft_dust_surcharge.test.js b/test/unit/xchain_encoder/psbt_conformance/doge_soft_dust_surcharge.test.js new file mode 100644 index 00000000..f118e2fd --- /dev/null +++ b/test/unit/xchain_encoder/psbt_conformance/doge_soft_dust_surcharge.test.js @@ -0,0 +1,87 @@ +// 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. + +// Dogecoin relays an output under its 0.01 DOGE soft dust limit only when the whole +// limit is added to the fee, once per such output. A payment between the hard limit +// (100000 koinu) and the soft limit (1000000) is allowed, so the fee carries that +// surcharge; a payment at the soft limit carries none. + +const assert = require('assert') +const { paymentFor, utxoFor, makeEncoder, build, unsignedTx, feeOf } = require('../../../helpers/psbt_build_fixtures.js') + +const DOGE = 'dogecoin-regtest' +const SOFT_DUST = 1000000n +const RELAY_PER_KB = 100000 // 0.001 DOGE/kB, the node estimate as well + +function dogeEncoder (prevTxs) { + return makeEncoder(DOGE, { estimate: 0.001, relayfee: 0.001, prevTxs }) +} + +// Relay minimum on the signed size: each P2PKH input gains a ~107-byte scriptSig. +function relayMinimum (res) { + const size = unsignedTx(res).byteLength() + 107 * res.psbt.txInputs.length + return BigInt(Math.ceil(size * RELAY_PER_KB / 1000)) +} + +async function payment (value, extra = {}) { + const prevTxs = new Map() + const pay = paymentFor('p2pkh', DOGE) + return build(dogeEncoder(prevTxs), Object.assign({ + utxos: [utxoFor(prevTxs, pay.output, 500000000, 'doge-pay' + value + JSON.stringify(extra))], pubkey: pay.address, + customOutputs: [{ address: pay.address, value }], change: pay.address + }, extra)) +} + +describe('XChainEncoder Dogecoin soft-dust relay surcharge', function () { + for (const value of [100000, 500000, 999999]) { + it(`adds the whole soft limit to the fee for a ${value}-koinu payment`, async function () { + const res = await payment(value) + assert.ok(feeOf(res.psbt) >= relayMinimum(res) + SOFT_DUST, + `fee ${feeOf(res.psbt)} lacks the ${SOFT_DUST} surcharge over ${relayMinimum(res)}`) + }) + } + + it('adds nothing for a payment at the soft limit', async function () { + const res = await payment(1000000) + assert.ok(feeOf(res.psbt) < SOFT_DUST, `fee ${feeOf(res.psbt)} carries a surcharge it does not owe`) + }) + + it('surcharges a hub feeQuote output under the soft limit', async function () { + const prevTxs = new Map() + const pay = paymentFor('p2pkh', DOGE) + const res = await build(dogeEncoder(prevTxs), { + utxos: [utxoFor(prevTxs, pay.output, 500000000, 'doge-quote')], pubkey: pay.address, data: 'BROADCAST|0|hi', + encoding: 'OP_RETURN', change: pay.address, feeQuote: { address: pay.address, amount: 300000 } + }) + assert.ok(feeOf(res.psbt) >= relayMinimum(res) + SOFT_DUST) + }) + + it('adds the surcharge on top of an explicit fee', async function () { + const res = await payment(500000, { fee: 200000 }) + assert.strictEqual(feeOf(res.psbt), 200000n + SOFT_DUST) + }) + + it('prefunds the surcharge for a sub-limit payment on a P2SH reveal', async function () { + const prevTxs = new Map() + const pay = paymentFor('p2pkh', DOGE) + const encoder = dogeEncoder(prevTxs) + const common = { pubkey: pay.address, data: 'x'.repeat(600), encoding: 'P2SH', change: pay.address, + customOutputs: [{ address: pay.address, value: 500000 }] } + const funding = await build(encoder, Object.assign({ utxos: [utxoFor(prevTxs, pay.output, 500000000, 'doge-p2sh')] }, common)) + const fundingTx = unsignedTx(funding) + const reveal = await build(encoder, Object.assign({ utxos: [], p2shHash: fundingTx.getId(), p2shHex: fundingTx.toHex() }, common)) + // A P2SH reveal input carries its whole redeem script plus a signature and key. + const signedSize = unsignedTx(reveal).byteLength() + + reveal.psbt.data.inputs.reduce((n, input) => n + input.redeemScript.length + 110, 0) + const fee = feeOf(reveal.psbt) + assert.ok(fee >= BigInt(Math.ceil(signedSize * RELAY_PER_KB / 1000)) + SOFT_DUST, + `reveal fee ${fee} lacks the surcharge for its 500000-koinu payment`) + }) +}) diff --git a/test/unit/xchain_encoder/psbt_conformance/nested_segwit_input.test.js b/test/unit/xchain_encoder/psbt_conformance/nested_segwit_input.test.js new file mode 100644 index 00000000..e1e55478 --- /dev/null +++ b/test/unit/xchain_encoder/psbt_conformance/nested_segwit_input.test.js @@ -0,0 +1,79 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A P2SH-P2WPKH (nested segwit) funding UTXO is a segwit spend: it needs witnessUtxo +// and its P2WPKH redeemScript to be signable, and it costs about 91 vbytes, not the +// 289 a legacy multisig P2SH input is priced at. The redeem script comes from the +// public key the request carries; without one the input stays legacy. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { ECPair, PRIVATE_KEY, PUBKEY, PUBKEY_HEX, networkOf, paymentFor, utxoFor, makeEncoder, build, feeOf } = + require('../../../helpers/psbt_build_fixtures.js') + +const BTC = 'bitcoin-regtest' +const FEE_PER_KB = 2000 + +async function nestedBuild (request) { + const prevTxs = new Map() + const nested = paymentFor('p2sh-p2wpkh', BTC) + const encoder = makeEncoder(BTC, { estimate: 0.00002, relayfee: 0.00001, prevTxs }) + const res = await build(encoder, Object.assign({ + utxos: [utxoFor(prevTxs, nested.output, 1000000, 'nested'), utxoFor(prevTxs, nested.output, 300000, 'nested2')], + data: 'BROADCAST|0|hi', encoding: 'OP_RETURN', change: nested.address, feePerKb: FEE_PER_KB, + customOutputs: [{ address: paymentFor('p2wpkh', BTC).address, value: 1100000 }] + }, request)) + return { res, nested } +} + +describe('XChainEncoder P2SH-P2WPKH funding inputs', function () { + it('builds each input with witnessUtxo and the key\'s P2WPKH redeemScript', async function () { + const { res, nested } = await nestedBuild({ pubkey: PUBKEY_HEX }) + for (const input of res.psbt.data.inputs) { + assert.ok(input.witnessUtxo, 'witnessUtxo carries the value a segwit sighash commits to') + assert.ok(input.redeemScript && input.redeemScript.equals(nested.redeem.output)) + } + }) + + it('prices the inputs at their signed size, so the fee lands at the requested rate', async function () { + const { res } = await nestedBuild({ pubkey: PUBKEY_HEX }) + const psbt = bitcoin.Psbt.fromHex(res.psbt.toHex(), { network: networkOf(BTC) }) + psbt.signAllInputs(ECPair.fromPrivateKey(PRIVATE_KEY)) + psbt.finalizeAllInputs() + const vsize = psbt.extractTransaction(true).virtualSize() + const rate = Number(feeOf(res.psbt)) / vsize + assert.ok(rate >= FEE_PER_KB / 1000 && rate < 1.25 * FEE_PER_KB / 1000, `fee rate ${rate} per vbyte`) + }) + + it('takes the key from compressedPubKey when pubkey is the P2SH address', async function () { + const nested = paymentFor('p2sh-p2wpkh', BTC) + const { res } = await nestedBuild({ pubkey: nested.address, compressedPubKey: PUBKEY_HEX }) + assert.ok(res.psbt.data.inputs.every(input => input.redeemScript && input.witnessUtxo)) + }) + + it('builds a P2SH address given with no public key as a legacy input', async function () { + const nested = paymentFor('p2sh-p2wpkh', BTC) + const { res } = await nestedBuild({ pubkey: nested.address }) + assert.ok(res.psbt.data.inputs.every(input => input.nonWitnessUtxo && !input.witnessUtxo && !input.redeemScript)) + }) + + it('leaves a P2SH input the key does not match as a legacy input', async function () { + const prevTxs = new Map() + const net = networkOf(BTC) + const other = bitcoin.payments.p2sh({ redeem: bitcoin.payments.p2ms({ m: 1, pubkeys: [PUBKEY], network: net }), network: net }) + const pay = paymentFor('p2wpkh', BTC) + const res = await build(makeEncoder(BTC, { estimate: 0.00002, relayfee: 0.00001, prevTxs }), { + utxos: [utxoFor(prevTxs, other.output, 1000000, 'legacy-p2sh')], pubkey: PUBKEY_HEX, + data: 'BROADCAST|0|hi', encoding: 'OP_RETURN', change: pay.address, feePerKb: FEE_PER_KB + }) + const input = res.psbt.data.inputs[0] + assert.ok(input.nonWitnessUtxo && !input.witnessUtxo && !input.redeemScript) + }) +}) diff --git a/test/unit/xchain_encoder/psbt_conformance/p2wsh_reveal_headroom.test.js b/test/unit/xchain_encoder/psbt_conformance/p2wsh_reveal_headroom.test.js new file mode 100644 index 00000000..2a2e3184 --- /dev/null +++ b/test/unit/xchain_encoder/psbt_conformance/p2wsh_reveal_headroom.test.js @@ -0,0 +1,74 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A P2WSH action of two or more chunks has no floor pad on its reveal, so the reveal +// leaves a value output only if its legs carry more than its fee. The first leg is +// topped up to the whole reveal's fee plus one output floor, and the reveal sweeps +// the surplus back to the caller: never a lone zero-value OP_RETURN, which the +// signer refuses as a full burn after the commit is already on chain. + +const assert = require('assert') +const { PUBKEY, paymentFor, utxoFor, makeEncoder, build, unsignedTx } = require('../../../helpers/psbt_build_fixtures.js') + +const FEE_PER_KB = 2000 // base units per kB: 2 per vbyte + +async function fundAndReveal (network, payloadLength) { + const prevTxs = new Map() + const encoder = makeEncoder(network, { estimate: 0.00002, relayfee: 0.00001, prevTxs }) + const pay = paymentFor('p2wpkh', network) + const data = 'x'.repeat(payloadLength) + const funding = await build(encoder, { + utxos: [utxoFor(prevTxs, pay.output, 5000000, network + payloadLength)], pubkey: pay.address, + data, encoding: 'P2WSH', change: pay.address, feePerKb: FEE_PER_KB + }) + const fundingTx = unsignedTx(funding) + const reveal = await build(encoder, { + utxos: [], pubkey: pay.address, data, encoding: 'P2WSH', change: pay.address, + p2shHash: fundingTx.getId(), p2shHex: fundingTx.toHex(), feePerKb: FEE_PER_KB + }) + return { encoder, pay, fundingTx, reveal } +} + +// The reveal's vsize once signed: a 72-byte signature, the key and the witness script per input. +function signedRevealVsize (reveal) { + const tx = unsignedTx(reveal) + reveal.psbt.data.inputs.forEach((input, i) => { + tx.ins[i].witness = [Buffer.alloc(72, 1), PUBKEY, input.witnessScript] + }) + return tx.virtualSize() +} + +function legTotal (reveal) { + return reveal.psbt.data.inputs.reduce((sum, input) => sum + BigInt(input.witnessUtxo.value), 0n) +} + +describe('XChainEncoder multi-chunk P2WSH reveal headroom', function () { + for (const network of ['bitcoin-regtest', 'litecoin-regtest']) { + for (const payloadLength of [600, 1500]) { + it(`${network} ${payloadLength}-byte payload: the reveal returns change at or above the output floor`, async function () { + const { encoder, pay, reveal } = await fundAndReveal(network, payloadLength) + assert.ok(reveal.psbt.data.inputs.length >= 2, 'the payload spans at least two chunks') + const change = reveal.psbt.txOutputs.filter(o => o.script.equals(pay.output)) + assert.strictEqual(change.length, 1, 'one change output back to the caller') + assert.ok(BigInt(change[0].value) >= BigInt(encoder.outputFloor), + `change ${change[0].value} is under the output floor ${encoder.outputFloor}`) + }) + + it(`${network} ${payloadLength}-byte payload: the reveal still pays its target rate on its signed size`, async function () { + const { reveal } = await fundAndReveal(network, payloadLength) + let outputs = 0n + for (const out of reveal.psbt.txOutputs) outputs += BigInt(out.value) + const fee = legTotal(reveal) - outputs + const needed = BigInt(Math.ceil(signedRevealVsize(reveal) * FEE_PER_KB / 1000)) + assert.ok(fee >= needed, `reveal fee ${fee} is under ${needed} for its signed size`) + }) + } + } +}) diff --git a/test/unit/xchain_encoder/psbt_conformance/stripped_size_floor.test.js b/test/unit/xchain_encoder/psbt_conformance/stripped_size_floor.test.js new file mode 100644 index 00000000..78985e61 --- /dev/null +++ b/test/unit/xchain_encoder/psbt_conformance/stripped_size_floor.test.js @@ -0,0 +1,97 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A single-transaction build whose change folds into the fee can serialize under the +// node's MIN_STANDARD_TX_NONWITNESS_SIZE (Litecoin Core 82, Bitcoin Core 65), and the +// node will not relay it. Selection then takes another input to lift it over the +// floor, and a build with no input left to do that is refused instead of returned. + +const assert = require('assert') +const { paymentFor, utxoFor, makeEncoder, build, unsignedTx, feeOf } = require('../../../helpers/psbt_build_fixtures.js') + +const FEE_PER_KB = 2000 +const LTC = 'litecoin-regtest' + +function encoderFor (network, prevTxs) { + return makeEncoder(network, { estimate: 0.00002, relayfee: 0.00001, prevTxs }) +} + +// The fee an OP_RETURN action costs from one large p2wpkh input on `network`. +async function opReturnFee (network, data) { + const prevTxs = new Map() + const pay = paymentFor('p2wpkh', network) + const res = await build(encoderFor(network, prevTxs), { + utxos: [utxoFor(prevTxs, pay.output, 1000000, 'probe' + network + data)], pubkey: pay.address, + data, encoding: 'OP_RETURN', change: pay.address, feePerKb: FEE_PER_KB + }) + return Number(feeOf(res.psbt)) +} + +async function opReturnBuild (network, data, values) { + const prevTxs = new Map() + const pay = paymentFor('p2wpkh', network) + const utxos = values.map((v, i) => utxoFor(prevTxs, pay.output, v, network + data + i + ':' + v)) + return build(encoderFor(network, prevTxs), { + utxos, pubkey: pay.address, data, encoding: 'OP_RETURN', change: pay.address, feePerKb: FEE_PER_KB + }) +} + +// A one-input p2wpkh payment to one p2wpkh output whose change folds: 82 stripped bytes. +async function sweepBuild (network) { + const prevTxs = new Map() + const pay = paymentFor('p2wpkh', network) + const value = 1000000 + const probe = await build(encoderFor(network, prevTxs), { + utxos: [utxoFor(prevTxs, pay.output, value, 'sweep-probe' + network)], pubkey: pay.address, + customOutputs: [{ address: pay.address, value: 500000 }], change: pay.address, feePerKb: FEE_PER_KB + }) + const sweepValue = value - Number(feeOf(probe.psbt)) - 10 + return build(encoderFor(network, prevTxs), { + utxos: [utxoFor(prevTxs, pay.output, value, 'sweep' + network)], pubkey: pay.address, + customOutputs: [{ address: pay.address, value: sweepValue }], change: pay.address, feePerKb: FEE_PER_KB + }) +} + +describe('XChainEncoder stripped-size floor on single-transaction builds', function () { + for (const data of ['BROADCAST|0|hi', 'ADDRESS|0|1|0']) { + it(`litecoin-regtest ${data}: refuses an OP_RETURN-only build its one input cannot lift over 82 bytes`, async function () { + const fee = await opReturnFee(LTC, data) + await assert.rejects(opReturnBuild(LTC, data, [fee + 100]), + (e) => e.xchainCode === 'TX_BELOW_MIN_STANDARD_SIZE' && e.details.minimum === 82 && e.details.strippedSize < 82) + }) + + it(`litecoin-regtest ${data}: takes a second input to lift an OP_RETURN-only build over 82 bytes`, async function () { + const fee = await opReturnFee(LTC, data) + const res = await opReturnBuild(LTC, data, [fee + 100, fee + 50]) + const stripped = unsignedTx(res).byteLength(false) + assert.strictEqual(res.psbt.txInputs.length, 2) + assert.ok(stripped >= 82, `stripped size ${stripped} is under 82`) + }) + } + + it('bitcoin-regtest: builds a one-input OP_RETURN-only transaction, which clears 65 bytes', async function () { + const fee = await opReturnFee('bitcoin-regtest', 'BROADCAST|0|hi') + const res = await opReturnBuild('bitcoin-regtest', 'BROADCAST|0|hi', [fee + 100]) + assert.strictEqual(res.psbt.txInputs.length, 1) + assert.ok(unsignedTx(res).byteLength(false) >= 65) + }) + + it('litecoin-regtest: builds a full sweep to one p2wpkh payment, 82 bytes at the 82-byte floor', async function () { + const res = await sweepBuild(LTC) + assert.strictEqual(res.psbt.txOutputs.length, 1) + assert.strictEqual(unsignedTx(res).byteLength(false), 82) + }) + + it('leaves a build that returns change untouched by the floor', async function () { + const res = await opReturnBuild(LTC, 'BROADCAST|0|hi', [1000000]) + assert.strictEqual(res.psbt.txInputs.length, 1) + assert.strictEqual(res.psbt.txOutputs.length, 2) + }) +}) From 5d66dee37cfc8123b7e9705c0e15f1f4f8dc3112 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 14 Oct 2026 09:57:13 -0700 Subject: [PATCH 11/22] Fix review round findings for xchain-encoder (2026-10-14) Fixes the defects, doc drift and test gaps this repo carried on the review board and the test-gap board, 21 files. --- .ci-siblings | 2 + README.md | 2 +- docs/openrpc.build.js | 3 +- docs/openrpc.json | 2 +- src/api.js | 12 +- src/api/json_rpc_methods.js | 4 +- src/common/config.js | 20 +-- src/common/validator/action_data_checks.js | 8 +- src/common/validator/constants.js | 11 +- src/common/validator/fee_and_utxo_checks.js | 7 +- src/common/validator/transaction_checks.js | 13 +- src/common/validator/value_checks.js | 14 +- src/protocol/validator_wire_actions.js | 86 ++++++++++ src/server/crash_handlers.js | 9 +- test/unit/api/inherited_method_names.test.js | 159 ++++++++++++++++++ .../api/mainnet_prelaunch_refusal.test.js | 46 ++++- .../validator/message_truncation.test.js | 72 ++++++++ .../18_validate_all_unknown_params.test.js | 59 +++++++ test/unit/crash_handlers.test.js | 8 + .../crash_handlers.test/03_boot_order.test.js | 73 ++++++++ test/unit/repo/sibling_coverage.test.js | 3 + 21 files changed, 569 insertions(+), 44 deletions(-) create mode 100644 src/protocol/validator_wire_actions.js create mode 100644 test/unit/api/inherited_method_names.test.js create mode 100644 test/unit/common/validator/message_truncation.test.js create mode 100644 test/unit/common/validator/validator.test/18_validate_all_unknown_params.test.js create mode 100644 test/unit/crash_handlers.test/03_boot_order.test.js diff --git a/.ci-siblings b/.ci-siblings index 38506112..a5a41f38 100644 --- a/.ci-siblings +++ b/.ci-siblings @@ -28,3 +28,5 @@ xchain-sdk # The inbound UTXO-record contract, the one seam carrying records INTO the # encoder rather than out of it. xchain-utxo-tracker +# The mainnet prelaunch validator wire-format set against the consensus copy. +xchain-indexer diff --git a/README.md b/README.md index ab2c86c2..c1b2ccc5 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ npm run api | `ENCODER_MAX_CONCURRENT_REQUESTS` | No | `50` | Global cap on requests served at once across all client IPs; excess gets an immediate 429 + `Retry-After` instead of queueing. `/status` and `/openrpc.json` are exempt, over `GET` or `HEAD`, in any letter case and with or without a trailing slash; `0` disables | | `ENCODER_MAX_CONCURRENT_PROBES` | No | `16` | Private concurrency reserve for the two exempt probe routes, so healthchecks stay answerable while the cap above sheds without becoming an uncapped bypass; `0` disables | | `ENCODER_TRUST_PROXY` | No | `loopback, uniquelocal` | Express `trust proxy` setting; controls which hop the per-IP rate limiter keys the client IP on. `false`, a hop count, or an address/CIDR list per the Express docs | -| `ENCODER_MAINNET_PRELAUNCH_OVERRIDE` | No | Unset | On a mainnet encoder, `create_tx` refuses every non-validator action (anything but `STAKE`, `UNSTAKE`, `DELEGATE`, `SLASH`, `NODEPROOF`, `ROLLCALL`) before unix time `1798761600` with `-32010` and `data.reason` `MAINNET_PRELAUNCH` (`data.opens_at` carries the time). Set `1`, `true`, `yes` or `on` to lift the refusal; read live on every call, any other value leaves it in force. Testnet and regtest are never refused | +| `ENCODER_MAINNET_PRELAUNCH_OVERRIDE` | No | Unset | On a mainnet encoder, `create_tx` refuses every action outside the consensus validator set before unix time `1798761600` with `-32010` and `data.reason` `MAINNET_PRELAUNCH` (`data.opens_at` carries the time). The set is read by name and format, exactly as the indexer's launch gate reads it: `ANCHOR` formats 0 to 3, `PRICE` format 0, `NODEPROOF` format 0, `ROLLCALL` formats 0 and 1, and the quorum `ATTEST` formats 1, 3, 4, 5 and 6. `PRICE` format 1, `ATTEST` formats 0 and 2, `STAKE`, `UNSTAKE`, `DELEGATE`, `SLASH` and every other action are refused. Set `1`, `true`, `yes` or `on` to lift the refusal for every action; read live on every call, any other value leaves it in force. Set it only on the foundation's own bootstrap encoder that builds the seed sources' pre-launch actions (the encoder cannot see the pinned seed sources), never on a public encoder. Testnet and regtest are never refused | | `ENCODER_MAINTENANCE_FILE` | No | `/tmp/xchain-encoder-maintenance.json` | Where the encoder looks for an operator-declared scheduled-maintenance window. `health` and `GET /status` report it as `maintenance` beside the readiness fields, so a status board can tell a planned outage from a fault; it never changes a readiness field or the 503. See [Scheduled maintenance](#scheduled-maintenance) | | `CORS_ORIGIN` | No | Disabled | Allowed CORS origin(s): `*` for any, one origin, or a comma-separated allowlist matched per-origin (browser wallet shells each send a different origin). A stray `*` inside a list is not a wildcard, so the grant fails closed | diff --git a/docs/openrpc.build.js b/docs/openrpc.build.js index 3b24339c..b193298d 100644 --- a/docs/openrpc.build.js +++ b/docs/openrpc.build.js @@ -51,7 +51,8 @@ const METHODS = [ + 'record ({commitTxid, commitVout, commitValue, commitAddress, internalPubkey, tapleafHash, ' + 'controlBlock, revealFee}) that the wallet must persist durably BEFORE broadcasting the commit; ' + 'sign both, broadcast commit then reveal. TAPROOT requires compressedPubKey (the envelope internal ' - + 'key), segwit-only inputs, and accepts payloads up to the 400,000-byte envelope ceiling.', + + 'key), segwit-only inputs, and accepts payloads up to the 400,000-byte envelope ceiling. ' + + 'An unknown top-level param is refused with -32602 rather than ignored, as unknown options keys are.', params: [ { name: 'pubkey', required: true, schema: str('caller identity: sender address (base58 or bech32) or compressed public key hex (02/03 + 64 hex); uncompressed (04...) keys are rejected') }, { name: 'data', schema: str('ACTION payload string (pipe-delimited, from xchain-sdk createAction)') }, diff --git a/docs/openrpc.json b/docs/openrpc.json index 0a5552a9..5846dd5d 100644 --- a/docs/openrpc.json +++ b/docs/openrpc.json @@ -142,7 +142,7 @@ { "name": "create_tx", "summary": "Build an unsigned PSBT embedding an XChain ACTION payload. The caller signs and broadcasts.", - "description": "Encoding auto-selects OP_RETURN for payloads ≤76 bytes, else P2SH (two-transaction flow: call once for the funding tx, then again with p2shHash/p2shHex to build the reveal tx). rawData is decoded as Latin-1 so arbitrary bytes round-trip losslessly. Dogecoin has no segwit: P2WSH and TAPROOT encodings are rejected on DOGE networks. encoding:\"TAPROOT\" (BTC/LTC, explicit only) is a single-call two-transaction flow: the result carries the commit PSBT (psbt), the pre-built reveal PSBT (revealPsbt) and an `envelope` recovery record ({commitTxid, commitVout, commitValue, commitAddress, internalPubkey, tapleafHash, controlBlock, revealFee}) that the wallet must persist durably BEFORE broadcasting the commit; sign both, broadcast commit then reveal. TAPROOT requires compressedPubKey (the envelope internal key), segwit-only inputs, and accepts payloads up to the 400,000-byte envelope ceiling.", + "description": "Encoding auto-selects OP_RETURN for payloads ≤76 bytes, else P2SH (two-transaction flow: call once for the funding tx, then again with p2shHash/p2shHex to build the reveal tx). rawData is decoded as Latin-1 so arbitrary bytes round-trip losslessly. Dogecoin has no segwit: P2WSH and TAPROOT encodings are rejected on DOGE networks. encoding:\"TAPROOT\" (BTC/LTC, explicit only) is a single-call two-transaction flow: the result carries the commit PSBT (psbt), the pre-built reveal PSBT (revealPsbt) and an `envelope` recovery record ({commitTxid, commitVout, commitValue, commitAddress, internalPubkey, tapleafHash, controlBlock, revealFee}) that the wallet must persist durably BEFORE broadcasting the commit; sign both, broadcast commit then reveal. TAPROOT requires compressedPubKey (the envelope internal key), segwit-only inputs, and accepts payloads up to the 400,000-byte envelope ceiling. An unknown top-level param is refused with -32602 rather than ignored, as unknown options keys are.", "paramStructure": "by-name", "params": [ { diff --git a/src/api.js b/src/api.js index 1184bfa0..69ed4bfd 100644 --- a/src/api.js +++ b/src/api.js @@ -33,6 +33,12 @@ patchConsole({ network: config.NETWORK }); +// Install the crash handlers before the singleton encoder below is built, so its +// boot throws (unknown NETWORK, consensus-pin mismatch) leave a CRASH record. +// Entry point only: a test that requires this file must not get them. +const { installCrashHandlers } = require('./server/crash_handlers') +if (require.main === module) installCrashHandlers() + const bitcoin = require('bitcoinjs-lib'); const express = require('express'); const bodyParser = require('body-parser'); @@ -64,7 +70,6 @@ const { assertSingleInstance, acquireInstanceLock, releaseLockOnSignals } = requ const { parseCorsOrigin } = require('./server/cors_origin') const { version: ENCODER_VERSION } = require('../package.json') const { installObservability } = require('./observability'); // default-off /metrics + structured log shim -const { installCrashHandlers } = require('./server/crash_handlers') const NETWORK = config.NETWORK_RAW @@ -356,9 +361,8 @@ if (require.main === module) { // boot if the deploy declares replicas > 1 (ENCODER_REPLICAS) or another // encoder process on this host already holds the instance lock. See // src/server/single_instance_guard.js. - // Before the instance guard, so a throw inside it is still a CRASH record - // rather than node's bare stderr dump. - installCrashHandlers() + // The crash handlers are already installed at the top of this file, so a + // throw from the instance guard is still a CRASH record. assertSingleInstance() const releaseInstanceLock = acquireInstanceLock() process.on('exit', releaseInstanceLock) diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index a7efd2a2..520103bc 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -214,7 +214,6 @@ return { } } - function buildCancelMethods({ encoder }) { return { // Key-path cancel of an unrevealed TAPROOT envelope commit: rebuilds the @@ -242,7 +241,6 @@ return { } } - function buildReleaseMethods({ encoder }) { return { // Hand back the input reservations ONE earlier build took, named by the @@ -386,7 +384,9 @@ 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 }) { + // No prototype, so the router's `in` check cannot dispatch an inherited name (constructor, toString). return Object.assign( + Object.create(null), buildReadinessMethods({ encoder }), buildFeeMethods({ encoder, NETWORK }), buildTransactionMethods({ encoder }), diff --git a/src/common/config.js b/src/common/config.js index a0aae1c7..ce2472f1 100644 --- a/src/common/config.js +++ b/src/common/config.js @@ -29,6 +29,8 @@ */ 'use strict'; +const { VALIDATOR_WIRE_FORMATS, isValidatorWireAction } = require('../protocol/validator_wire_actions') + const config = { // codemod:env-entries get ENCODER_MAINTENANCE_FILE() { return process.env.ENCODER_MAINTENANCE_FILE; }, @@ -73,22 +75,16 @@ const config = { // Unix time (2027-01-01T00:00:00Z) before which a mainnet encoder builds only validator actions. const MAINNET_LAUNCH_TS = 1798761600 -const VALIDATOR_ACTIONS = new Set(['STAKE', 'UNSTAKE', 'DELEGATE', 'SLASH', 'NODEPROOF', 'ROLLCALL']) const OVERRIDE_TRUE = new Set(['1', 'true', 'yes', 'on']) -// Canonical leading ACTION name of `data`, or null when there is none. -function actionNameOf(data) { - if (typeof data !== 'string' && !Buffer.isBuffer(data)) return null - const raw = (Buffer.isBuffer(data) ? data.toString('utf8') : data).split('|')[0] - return raw ? (require('./validator').ACTION_ALIASES[raw] ?? raw) : null -} - -// create_tx guard: a mainnet encoder refuses a non-validator build before launch (-32010 -// MAINNET_PRELAUNCH) unless ENCODER_MAINNET_PRELAUNCH_OVERRIDE is 1/true/yes/on, read live. +// create_tx guard: a mainnet encoder refuses a build before launch (-32010 MAINNET_PRELAUNCH) unless +// its data is a validator wire action by name and format, the set consensus admits pre-launch, or +// ENCODER_MAINNET_PRELAUNCH_OVERRIDE is 1/true/yes/on, read live. function assertNotMainnetPrelaunch(encoder, params, now = Date.now) { if (encoder.consensusNetwork !== 'mainnet' || Math.floor(now() / 1000) >= MAINNET_LAUNCH_TS) return if (OVERRIDE_TRUE.has(String(config.ENCODER_MAINNET_PRELAUNCH_OVERRIDE || '').trim().toLowerCase())) return - if (VALIDATOR_ACTIONS.has(actionNameOf(params.data))) return + const data = Buffer.isBuffer(params.data) ? params.data.toString('utf8') : params.data + if (isValidatorWireAction(data)) return const e = new Error('Mainnet is not open yet: only validator actions can be built before launch') e.code = -32010 e.data = { reason: 'MAINNET_PRELAUNCH', opens_at: MAINNET_LAUNCH_TS } @@ -98,6 +94,6 @@ function assertNotMainnetPrelaunch(encoder, params, now = Date.now) { module.exports = config; Object.defineProperties(config, { MAINNET_LAUNCH_TS: { value: MAINNET_LAUNCH_TS }, - VALIDATOR_ACTIONS: { value: VALIDATOR_ACTIONS }, + VALIDATOR_WIRE_FORMATS: { value: VALIDATOR_WIRE_FORMATS }, assertNotMainnetPrelaunch: { value: assertNotMainnetPrelaunch }, }) diff --git a/src/common/validator/action_data_checks.js b/src/common/validator/action_data_checks.js index 9121e072..c14dd4fa 100644 --- a/src/common/validator/action_data_checks.js +++ b/src/common/validator/action_data_checks.js @@ -21,7 +21,7 @@ const { ENVELOPE_MAX_PAYLOAD, VALID_ENCODINGS } = require('./constants') -const { firstNonLatin1 } = require('./value_checks') +const { firstNonLatin1, truncateForMessage } = require('./value_checks') const { ParamTypeError, ParamRangeError } = require('../../build/errors') // Gate `data` and `rawData` on what their wire encodings can actually carry, not @@ -140,7 +140,7 @@ function validateActionName(data) { const rawActionName = unknownActionName(data) if (rawActionName == null) return throw new ParamRangeError( - `data has unknown ACTION name '${rawActionName.slice(0, 32)}'; ` + + `data has unknown ACTION name '${truncateForMessage(rawActionName)}'; ` + 'the decoder rejects any leading token that is not a canonical action ' + 'name or alias, silently dropping the ACTION on a fee-paid transaction') } @@ -249,7 +249,9 @@ function validateCombinedDataLength(data, rawData, encoding) { function validateEncoding(encoding) { if (encoding == null) return null if (typeof encoding !== 'string' || !VALID_ENCODINGS.has(encoding)) { - throw new ParamTypeError(`Invalid encoding: "${encoding}". Valid values: ${[...VALID_ENCODINGS].join(', ')}`) + // Quote a string's prefix only, and name any other type rather than print [object Object] + const shown = typeof encoding === 'string' ? `"${truncateForMessage(encoding)}"` : `a value of type ${typeof encoding}` + throw new ParamTypeError(`Invalid encoding: ${shown}. Valid values: ${[...VALID_ENCODINGS].join(', ')}`) } return encoding } diff --git a/src/common/validator/constants.js b/src/common/validator/constants.js index e618ddd8..1c16218e 100644 --- a/src/common/validator/constants.js +++ b/src/common/validator/constants.js @@ -145,6 +145,14 @@ const MAX_SATOSHI_U64 = 0xffffffffffffffffn // signer down the P2WSH path at 2x the cost with no signal, and a misspelled one // that silently meant "yes" would be worse. const VALID_CREATE_TX_OPTIONS = new Set(['signerSupportsTapscript', 'exactInputs']) +// The create_tx top-level params, refused when unknown for the options-bag reason +// above: a misspelled unconfirmed, feePerKb or rbf would silently build under the +// default policy. Must equal the create_tx params in docs/openrpc.build.js. +const VALID_CREATE_TX_PARAMS = new Set([ + 'utxos', 'pubkey', 'customOutputs', 'data', 'rawData', 'fee', 'rbf', + 'encoding', 'change', 'p2shHash', 'p2shHex', 'compressedPubKey', + 'unconfirmed', 'feePerKb', 'dust', 'feeQuote', 'attachPrevTx', 'compress', 'options' +]) module.exports = { MAX_COMPILED_ACTION_DATA_LENGTH, @@ -164,5 +172,6 @@ module.exports = { VALID_ACTION_NAMES, ACTION_ALIASES, MAX_SATOSHI_U64, - VALID_CREATE_TX_OPTIONS + VALID_CREATE_TX_OPTIONS, + VALID_CREATE_TX_PARAMS } diff --git a/src/common/validator/fee_and_utxo_checks.js b/src/common/validator/fee_and_utxo_checks.js index c700b652..5967d635 100644 --- a/src/common/validator/fee_and_utxo_checks.js +++ b/src/common/validator/fee_and_utxo_checks.js @@ -24,7 +24,8 @@ const { const { validateOptionalBoolean, toExactInt, - parseSatoshiAmount + parseSatoshiAmount, + truncateForMessage } = require('./value_checks') const validateAddress = (...args) => require('./transaction_checks').validateAddress(...args) const { ParamTypeError, ParamRangeError } = require('../../build/errors') @@ -36,7 +37,7 @@ function validateCreateTxOptions(options) { } for (const key of Object.keys(options)) { if (!VALID_CREATE_TX_OPTIONS.has(key)) { - throw new ParamTypeError(`Unknown options key: "${key}". Valid keys: ${[...VALID_CREATE_TX_OPTIONS].join(', ')}`) + throw new ParamTypeError(`Unknown options key: "${truncateForMessage(key)}". Valid keys: ${[...VALID_CREATE_TX_OPTIONS].join(', ')}`) } } if (options.signerSupportsTapscript !== undefined) { @@ -55,7 +56,7 @@ function validateFee(fee) { if (fee == null || fee === false) return null const num = toExactInt(fee) if (isNaN(num)) { - throw new ParamTypeError(`fee must be a valid integer, got: ${typeof fee === 'string' ? fee : typeof fee}`) + throw new ParamTypeError(`fee must be a valid integer, got: ${typeof fee === 'string' ? truncateForMessage(fee) : typeof fee}`) } if (num < 0) { throw new ParamRangeError('fee must be non-negative') diff --git a/src/common/validator/transaction_checks.js b/src/common/validator/transaction_checks.js index 3662b4d5..34569da4 100644 --- a/src/common/validator/transaction_checks.js +++ b/src/common/validator/transaction_checks.js @@ -17,11 +17,13 @@ const { RAW_TX_HEX_RE, HEX_64_RE, MAX_BROADCAST_TX_HEX_LENGTH, - COMPRESSED_PUBKEY_RE + COMPRESSED_PUBKEY_RE, + VALID_CREATE_TX_PARAMS } = require('./constants') const { validateOptionalBoolean, - validatePubkey + validatePubkey, + truncateForMessage } = require('./value_checks') const { validateDataParam, @@ -257,6 +259,13 @@ function validateAll(params, context) { if (typeof params !== 'object' || params === null || Array.isArray(params)) { throw new ParamTypeError('Request params must be an object') } + // Refuse an unknown top-level param before any field check, so a misspelled + // money-path setting is named instead of silently falling back to its default. + for (const key of Object.keys(params)) { + if (!VALID_CREATE_TX_PARAMS.has(key)) { + throw new ParamTypeError(`Unknown create_tx param: "${truncateForMessage(key)}". Valid params: ${[...VALID_CREATE_TX_PARAMS].join(', ')}`) + } + } const { data, rawData, encoding } = validateActionFields(params) const { diff --git a/src/common/validator/value_checks.js b/src/common/validator/value_checks.js index 8af9dcfa..169e634d 100644 --- a/src/common/validator/value_checks.js +++ b/src/common/validator/value_checks.js @@ -17,6 +17,13 @@ const { ParamTypeError, ParamRangeError } = require('../../build/errors') const validateAddress = (...args) => require('./transaction_checks').validateAddress(...args) const assertPubkeyOnCurve = (...args) => require('./transaction_checks').assertPubkeyOnCurve(...args) +// Quote at most 32 characters of a caller string in an error message, so a -32602 +// never sends multi-megabyte junk back whole; a cut value carries its full length. +function truncateForMessage(value, max = 32) { + const s = String(value) + return s.length > max ? `${s.slice(0, max)}...(${s.length} chars)` : s +} + // Exact-integer parse for satoshi/fee money fields. Unlike parseInt, this // rejects decimals, scientific notation, hex, and trailing garbage ("1e8" -> // 1, "100.5" -> 100, "5abc" -> 5, "0x20" -> 0) instead of silently truncating @@ -40,7 +47,7 @@ function toExactInt(raw) { function validateOptionalBoolean(raw, label) { if (raw === undefined || raw === null) return undefined if (typeof raw !== 'boolean') { - throw new ParamTypeError(`${label} must be a boolean (got ${typeof raw}${typeof raw === 'string' ? ` "${raw}"` : ''}); string "false" would coerce to true`) + throw new ParamTypeError(`${label} must be a boolean (got ${typeof raw}${typeof raw === 'string' ? ` "${truncateForMessage(raw)}"` : ''}); string "false" would coerce to true`) } return raw } @@ -87,11 +94,11 @@ function parseSatoshiAmount(raw, label, opts) { // guaranteed non-negative, so this BigInt parse cannot throw. const big = BigInt(raw.trim()) if (big > MAX_SATOSHI_U64) { - throw new ParamRangeError(`${label} (${raw.trim()}) exceeds the maximum 64-bit satoshi amount (${MAX_SATOSHI_U64})`) + throw new ParamRangeError(`${label} (${truncateForMessage(raw.trim())}) exceeds the maximum 64-bit satoshi amount (${MAX_SATOSHI_U64})`) } return big } - throw new ParamRangeError(`${label} (${typeof raw === 'string' ? raw : num}) exceeds the maximum safe satoshi amount (${Number.MAX_SAFE_INTEGER}) and cannot be represented without precision loss${allowBig ? '; pass amounts above it as an exact decimal string' : ''}`) + throw new ParamRangeError(`${label} (${typeof raw === 'string' ? truncateForMessage(raw) : num}) exceeds the maximum safe satoshi amount (${Number.MAX_SAFE_INTEGER}) and cannot be represented without precision loss${allowBig ? '; pass amounts above it as an exact decimal string' : ''}`) } // Uncompressed public key hex: 04 then the 64-byte X and Y coordinates. @@ -123,6 +130,7 @@ function firstNonLatin1(str) { } module.exports = { + truncateForMessage, toExactInt, validateOptionalBoolean, parseSatoshiAmount, diff --git a/src/protocol/validator_wire_actions.js b/src/protocol/validator_wire_actions.js new file mode 100644 index 00000000..fa5a53ac --- /dev/null +++ b/src/protocol/validator_wire_actions.js @@ -0,0 +1,86 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * + * Validator wire actions: the name and format set the mainnet launch gate + * admits before launch. + * + * Consensus skips every pre-launch mainnet transaction that is not a + * validator wire action or from a pinned seed source, so the encoder's + * prelaunch refusal must admit exactly this set: a wider set lets a caller + * pay a fee for an action the indexer drops, and a narrower one refuses the + * hub's own oracle and anchor publishes. The set, the name and format reading + * and the verdict are pinned to the indexer's copy (src/consensus/gates/ + * validator_wire_actions.js) by test/unit/protocol/ + * validator_wire_actions_conformance.test.js. + * + ********************************************************************/ + +'use strict'; + +// ATTEST batch head and continuation formats, value-pinned to the indexer's attest batch wire constants. +const ATTEST_BATCH_HEAD_VERSION = 5; +const ATTEST_BATCH_CONTINUATION_VERSION = 6; + +// Largest format number the dispatcher reads; anything larger names no format. +const MAX_FORMAT = 255; + +// Hub-broadcast wire formats by action name. PRICE format 1 is the user oracle sharing the name, so it is absent. +const VALIDATOR_WIRE_FORMATS = Object.freeze({ + ANCHOR: Object.freeze([0, 1, 2, 3]), + PRICE: Object.freeze([0]), + NODEPROOF: Object.freeze([0]), + ROLLCALL: Object.freeze([0, 1]), + ATTEST: Object.freeze([1, 3, 4, ATTEST_BATCH_HEAD_VERSION, ATTEST_BATCH_CONTINUATION_VERSION]) +}); + +// Format of the second field, or null when it names no format; a missing or empty field means 0. +function parseFormat(field) { + if (field === undefined) + return 0; + const text = field.trim().replace(/"|'/g, ''); + if (text === '') + return 0; + // Refuse anything but a whole decimal number, so a signed, fractional or hex format names none. + if (!/^[0-9]+$/.test(text)) + return null; + const format = parseInt(text, 10); + return format <= MAX_FORMAT ? format : null; +} + +/** + * True when the raw action string is a validator wire action: its trimmed, + * upper-cased first field names a validator action and its second field a + * format that action broadcasts. Aliases are not expanded, as consensus does not. + * @param {*} data + * @returns {boolean} + */ +function isValidatorWireAction(data) { + // Refuse anything but a non-empty string, the only shape a wire action arrives in. + if (typeof data !== 'string' || data === '') + return false; + const parts = data.split('|'); + const name = parts[0].trim().toUpperCase(); + // Refuse a name outside the validator set, own keys only so an inherited name never matches. + if (!Object.prototype.hasOwnProperty.call(VALIDATOR_WIRE_FORMATS, name)) + return false; + const format = parseFormat(parts[1]); + return format !== null && VALIDATOR_WIRE_FORMATS[name].includes(format); +} + +module.exports = { + VALIDATOR_WIRE_FORMATS, + ATTEST_BATCH_HEAD_VERSION, + ATTEST_BATCH_CONTINUATION_VERSION, + isValidatorWireAction +}; diff --git a/src/server/crash_handlers.js b/src/server/crash_handlers.js index f5a3af5a..93b7e459 100644 --- a/src/server/crash_handlers.js +++ b/src/server/crash_handlers.js @@ -26,10 +26,11 @@ * a supervised restart. An unhandled rejection logs and CONTINUES: attaching * the listener turns off node's default exit on an unhandled rejection. * - * They are installed from the entry-point guard in api.js, never at module - * scope: several suites require api.js in-process under mocha, which installs - * its own handlers, and a module-scope handler that exits would abort the whole - * run instead of failing one test. + * They are installed behind an entry-point guard near the top of api.js, before + * the singleton encoder is built, never at module scope: several suites require + * api.js in-process under mocha, which installs its own handlers, and a + * module-scope handler that exits would abort the whole run instead of failing + * one test. * ********************************************************************/ diff --git a/test/unit/api/inherited_method_names.test.js b/test/unit/api/inherited_method_names.test.js new file mode 100644 index 00000000..5e7f6ce9 --- /dev/null +++ b/test/unit/api/inherited_method_names.test.js @@ -0,0 +1,159 @@ +/********************************************************************* + * + * 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 JSON-RPC router decides a method exists with `method in controller`, + * which also finds names inherited from Object.prototype. These tests pin that + * the encoder's dispatch table holds only its own methods, so `constructor`, + * `toString` and the rest answer -32601 Method not found like any unknown name. + * + ********************************************************************/ + +'use strict' + +const assert = require('assert') +const http = require('http') + +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('_') +const { createJsonRpcController } = require('../../../src/api/json_rpc_methods.js') + +// Every name a plain object inherits; none of them is an encoder method. +const INHERITED = [ + 'constructor', 'valueOf', 'toString', 'toLocaleString', 'hasOwnProperty', + 'isPrototypeOf', 'propertyIsEnumerable', '__proto__', '__defineGetter__', + '__defineSetter__', '__lookupGetter__', '__lookupSetter__' +] + +// POST one JSON body to the bound app and parse the JSON reply. +function postRpc (server, payload) { + const body = JSON.stringify(payload) + 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(JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch (err) { reject(err) } + }) + }) + req.on('error', reject) + req.end(body) + }) +} + +describe('JSON-RPC controller holds only its own methods', function () { + it('finds no inherited name in the dispatch table', function () { + const controller = createJsonRpcController({ encoder: {}, NETWORK: 'bitcoin-regtest' }) + for (const name of INHERITED) { + assert.ok(!(name in controller), `${name} is reachable through the router's \`in\` check`) + } + }) + + it('still carries the real methods as own functions', function () { + const controller = createJsonRpcController({ encoder: {}, NETWORK: 'bitcoin-regtest' }) + for (const name of ['ping', 'health', 'create_tx', 'broadcast_tx']) { + assert.ok(Object.hasOwn(controller, name), `${name} must be an own key`) + assert.strictEqual(typeof controller[name], 'function') + } + }) +}) + +// Load the real app in open mode (no key gate) with dotenv and the maintenance file stubbed out. +async function startOpenApp() { + const dotenv = require('dotenv') + const maintenance = require('../../../src/server/maintenance_window') + const state = { cachedApi: require.cache[API_PATH], cachedMethods: 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] + let api + 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 + } + state.server = await new Promise(resolve => { + const listener = api.app.listen(0, '127.0.0.1', () => resolve(listener)) + }) + return state +} + +// Close the listener and put the module cache back as it was. +async function stopOpenApp(state) { + if (!state) return + if (state.server) { + if (typeof state.server.closeAllConnections === 'function') state.server.closeAllConnections() + await new Promise(resolve => state.server.close(resolve)) + } + delete require.cache[API_PATH] + delete require.cache[JSON_RPC_METHODS_PATH] + if (state.cachedApi) require.cache[API_PATH] = state.cachedApi + if (state.cachedMethods) require.cache[JSON_RPC_METHODS_PATH] = state.cachedMethods +} + +describe('JSON-RPC controller holds only its own methods over HTTP', function () { + this.timeout(10000) + let app + let server + + before(async function () { + app = await startOpenApp() + server = app.server + }) + + after(async function () { + await stopOpenApp(app) + }) + + it('answers -32601 for constructor instead of echoing the params', async function () { + const body = await postRpc(server, { jsonrpc: '2.0', id: 1, method: 'constructor', params: { probe: 1 } }) + assert.strictEqual(body.result, undefined, `constructor dispatched: ${JSON.stringify(body)}`) + assert.strictEqual(body.error.code, -32601) + assert.strictEqual(body.id, 1) + }) + + it('answers -32601 for every inherited name in one batch', async function () { + const batch = INHERITED.map((method, i) => ({ jsonrpc: '2.0', id: i + 1, method, params: {} })) + const replies = await postRpc(server, batch) + assert.strictEqual(replies.length, INHERITED.length) + for (const reply of replies) { + const name = INHERITED[reply.id - 1] + assert.strictEqual(reply.result, undefined, `${name} dispatched: ${JSON.stringify(reply)}`) + assert.strictEqual(reply.error.code, -32601, `${name}: ${JSON.stringify(reply.error)}`) + } + }) + + it('still dispatches a real method', async function () { + const body = await postRpc(server, { jsonrpc: '2.0', id: 99, method: 'ping' }) + assert.strictEqual(body.result.status, 'success') + }) +}) diff --git a/test/unit/api/mainnet_prelaunch_refusal.test.js b/test/unit/api/mainnet_prelaunch_refusal.test.js index e0c6e7f7..83acbe1d 100644 --- a/test/unit/api/mainnet_prelaunch_refusal.test.js +++ b/test/unit/api/mainnet_prelaunch_refusal.test.js @@ -13,7 +13,7 @@ const assert = require('assert') const { createJsonRpcController } = require('../../../src/api/json_rpc_methods') -const { MAINNET_LAUNCH_TS } = require('../../../src/common/config') +const { MAINNET_LAUNCH_TS, assertNotMainnetPrelaunch } = require('../../../src/common/config') const { makeEncoder, getTestAddress } = require('../../integration/helpers/utxoFactory') const actions = require('../../integration/helpers/actionFactory') @@ -78,18 +78,50 @@ describe('mainnet prelaunch refusal', () => { }) }) +// The validator wire set by name and format, the set consensus admits on mainnet before launch. +const ADMITTED = ['ANCHOR|0', 'ANCHOR|1', 'ANCHOR|2', 'ANCHOR|3', 'PRICE|0', 'NODEPROOF|0', + 'ROLLCALL|0', 'ROLLCALL|1', 'ATTEST|1', 'ATTEST|3', 'ATTEST|4', 'ATTEST|5', 'ATTEST|6'] +// User actions consensus skips before launch, including the user formats that share a validator name. +const REFUSED = ['PRICE|1', 'ATTEST|0', 'ATTEST|2', 'ANCHOR|4', 'STAKE|0', 'UNSTAKE|0', 'DELEGATE|0', 'SLASH|0'] + describe('mainnet prelaunch validator actions', () => { isolateClockAndEnv() - for (const name of ['STAKE', 'UNSTAKE', 'DELEGATE', 'SLASH', 'NODEPROOF', 'ROLLCALL']) { - it(`lets ${name} through before launch`, async () => { + for (const head of ADMITTED) { + it(`lets ${head} through before launch`, async () => { setup('bitcoin-mainnet') - let refusal = null - try { await controller.create_tx(params(name + '|x')) } catch (err) { refusal = err } - // Either the stub build ran, or a later param check spoke; never the prelaunch gate. - assert.ok(built === 1 || (refusal && !(refusal.data && isPrelaunch(refusal)))) + await controller.create_tx(params(head + '|x')) + assert.strictEqual(built, 1) }) } + + it('reads the name the way consensus does: trimmed and upper-cased, format optional, Buffer decoded', () => { + const guard = (data) => assertNotMainnetPrelaunch({ consensusNetwork: 'mainnet' }, { data }, () => BEFORE) + for (const data of [' anchor |0|x', 'NODEPROOF', 'PRICE|"0"|x', Buffer.from('ATTEST|5|x')]) { + assert.doesNotThrow(() => guard(data), `refused ${data}`) + } + for (const data of ['PRICE|01x', 'ATTEST|5.0', 'ANCHOR|256', null, '']) { + assert.throws(() => guard(data), isPrelaunch, `admitted ${data}`) + } + }) +}) + +describe('mainnet prelaunch user actions sharing or missing the validator set', () => { + isolateClockAndEnv() + + for (const head of REFUSED) { + it(`refuses ${head} before launch`, async () => { + setup('bitcoin-mainnet') + await assert.rejects(controller.create_tx(params(head + '|x')), isPrelaunch) + assert.strictEqual(built, 0) + }) + } + + it('builds a user format of a validator name at launch', async () => { + setup('bitcoin-mainnet', AT) + await controller.create_tx(params('PRICE|1|x')) + assert.strictEqual(built, 1) + }) }) describe('mainnet prelaunch operator override', () => { diff --git a/test/unit/common/validator/message_truncation.test.js b/test/unit/common/validator/message_truncation.test.js new file mode 100644 index 00000000..46fed79f --- /dev/null +++ b/test/unit/common/validator/message_truncation.test.js @@ -0,0 +1,72 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Validator -32602 messages quote at most a short prefix of a caller string, + * the rule validateActionName set first, so a multi-megabyte junk value is + * never sent back whole in the error. + ********************************************************************/ + +'use strict'; + +const assert = require('assert'); +const v = require('../../../../src/common/validator.js'); +const { validateCreateTxOptions, validateFee } = require('../../../../src/common/validator/fee_and_utxo_checks.js'); +const { validateOptionalBoolean, parseSatoshiAmount, truncateForMessage } = require('../../../../src/common/validator/value_checks.js'); +const { ParamTypeError, ParamRangeError } = require('../../../../src/build/errors'); + +const LONG = 'Z'.repeat(100000); +const LONG_DIGITS = '9'.repeat(100000); +const MAX_MESSAGE = 400; + +// Assert fn throws errClass with a short message that does not carry the input. +function assertShortRefusal(fn, errClass, input) { + assert.throws(fn, (err) => { + assert.ok(err instanceof errClass, `expected ${errClass.name}, got ${err && err.constructor.name}`); + assert.ok(err.message.length < MAX_MESSAGE, `message is ${err.message.length} chars`); + assert.ok(!err.message.includes(input), 'message echoes the whole input'); + return true; + }); +} + +describe('Validator messages truncate caller strings', function () { + it('truncates an oversized encoding, fee, options key and boolean string', function () { + assertShortRefusal(() => v.validateEncoding(LONG), ParamTypeError, LONG); + assertShortRefusal(() => validateFee(LONG), ParamTypeError, LONG); + assertShortRefusal(() => validateCreateTxOptions({ [LONG]: true }), ParamTypeError, LONG); + assertShortRefusal(() => validateOptionalBoolean(LONG, 'rbf'), ParamTypeError, LONG); + }); + + it('truncates an oversized all-digit satoshi amount, with and without allowBig', function () { + assertShortRefusal(() => parseSatoshiAmount(LONG_DIGITS, 'value'), ParamRangeError, LONG_DIGITS); + assertShortRefusal(() => parseSatoshiAmount(LONG_DIGITS, 'value', { allowBig: true }), ParamRangeError, LONG_DIGITS); + }); + + it('truncates an oversized unknown create_tx param name', function () { + assertShortRefusal(() => v.validateAll({ data: 'SEND', pubkey: '02ab', [LONG]: 1 }), ParamTypeError, LONG); + }); + + it('names the type of a non-string encoding instead of [object Object]', function () { + assert.throws(() => v.validateEncoding({}), (err) => { + assert.ok(err instanceof ParamTypeError); + assert.match(err.message, /object/); + assert.ok(!err.message.includes('[object Object]'), err.message); + return true; + }); + }); + + it('leaves a short value whole and marks a cut one with its length', function () { + assert.strictEqual(truncateForMessage('exactInput'), 'exactInput'); + assert.strictEqual(truncateForMessage('x'.repeat(32)), 'x'.repeat(32)); + assert.strictEqual(truncateForMessage('x'.repeat(33)), `${'x'.repeat(32)}...(33 chars)`); + }); +}); diff --git a/test/unit/common/validator/validator.test/18_validate_all_unknown_params.test.js b/test/unit/common/validator/validator.test/18_validate_all_unknown_params.test.js new file mode 100644 index 00000000..ea1b0170 --- /dev/null +++ b/test/unit/common/validator/validator.test/18_validate_all_unknown_params.test.js @@ -0,0 +1,59 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * create_tx refuses an unknown top-level param, the same rule the options + * bag follows: a misspelled money-path setting (unconfirmed, feePerKb, rbf, + * compressedPubKey) must fail loudly rather than fall back to a default. + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../../../src/common/validator.js'); +const { VALID_CREATE_TX_PARAMS } = require('../../../../../src/common/validator/constants.js'); +const { ParamTypeError } = require('../../../../../src/build/errors'); +const { spec } = require('../../../../../docs/openrpc.build.js'); + +const BASE = { data: 'SEND', pubkey: '02ab' }; + +describe('Encoder input validator', function () { + describe('validateAll unknown top-level params', function () { + it('refuses each misspelled money-path setting and names the key', function () { + for (const key of ['unconfirmd', 'feePerKB', 'RBF', 'compressedPubkey', 'source']) { + assert.throws(() => v.validateAll({ ...BASE, [key]: false }), (err) => { + assert.ok(err instanceof ParamTypeError, `${key}: ${err && err.constructor.name}`); + assert.match(err.message, new RegExp(`Unknown create_tx param: "${key}"`)); + return true; + }); + } + }); + + it('reports the unknown key ahead of any other field error', function () { + assert.throws(() => v.validateAll({ ...BASE, fee: 'not-a-number', unconfirmd: false }), + /Unknown create_tx param: "unconfirmd"/); + }); + + it('still accepts every documented param, null values included', function () { + const nulled = {}; + for (const key of VALID_CREATE_TX_PARAMS) nulled[key] = null; + const result = v.validateAll({ ...nulled, data: 'SEND', pubkey: '02ab' }); + assert.strictEqual(result.pubkey, '02ab'); + assert.strictEqual(result.unconfirmed, undefined); + }); + + it('keeps the allow-list equal to the published create_tx params', function () { + const createTx = spec.methods.find((m) => m.name === 'create_tx'); + const declared = createTx.params.map((p) => p.name).sort(); + assert.ok(declared.length > 10, `extraction too small: ${declared.length}`); + assert.deepStrictEqual([...VALID_CREATE_TX_PARAMS].sort(), declared); + }); + }); +}); diff --git a/test/unit/crash_handlers.test.js b/test/unit/crash_handlers.test.js index f5d47d80..94fc5d56 100644 --- a/test/unit/crash_handlers.test.js +++ b/test/unit/crash_handlers.test.js @@ -16,8 +16,10 @@ // execute without mocha's own handlers or a live process.exit taking part. const assert = require('assert') +const childProcess = require('child_process') const { EventEmitter } = require('events') const fs = require('fs') +const os = require('os') const path = require('path') const crash = require('../../src/server/crash_handlers') @@ -25,6 +27,7 @@ const observability = require('../../src/observability') const registerSignalTests = require('./crash_handlers.test/01_signal_registration.test.js') const registerExitPathTests = require('./crash_handlers.test/02_exit_paths.test.js') +const registerBootOrderTests = require('./crash_handlers.test/03_boot_order.test.js') describe('encoder crash handlers', function () { let sink @@ -62,3 +65,8 @@ describe('encoder crash handlers', function () { registerSignalTests({ assert, crash, fakeProc, lines, crashCount }) registerExitPathTests({ assert, crash, observability, fakeProc, fs, path, __dirname }) }) + +// Kept out of the block above: its hooks reset the shim this process does not use here. +describe('encoder crash handlers', function () { + registerBootOrderTests({ assert, childProcess, fs, os, path, dirname: __dirname }) +}) diff --git a/test/unit/crash_handlers.test/03_boot_order.test.js b/test/unit/crash_handlers.test/03_boot_order.test.js new file mode 100644 index 00000000..dfb28a36 --- /dev/null +++ b/test/unit/crash_handlers.test/03_boot_order.test.js @@ -0,0 +1,73 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const API = '../../src/api.js' +const COINS = '../../src/coins' + +// Boot api.js as its own process from an empty directory, so no local .env is read. +function bootApi({ childProcess, fs, os, path, dirname }, network, preload) { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'encoder-boot-')) + const env = Object.assign({}, process.env, { + NETWORK: network, + ENCODER_API_PORT: '0', + ENCODER_INSTANCE_LOCK_FILE: path.join(cwd, 'instance.lock') + }) + // The mocha bootstrap turns the console patch off; the child needs the real boot. + delete env.XCHAIN_LOG_PATCH + const args = [] + if (preload) { + const file = path.join(cwd, 'preload.js') + fs.writeFileSync(file, preload) + args.push('-r', file) + } + args.push(path.join(dirname, API)) + const run = childProcess.spawnSync(process.execPath, args, { cwd, env, encoding: 'utf8', timeout: 15000 }) + return { run, out: String(run.stdout || '') + String(run.stderr || '') } +} + +// A boot throw counts only if it left the structured record and the exit code. +function assertCrashRecord(assert, { run, out }, text) { + assert.strictEqual(run.signal, null, 'boot did not fail fast; it was still running at the timeout') + assert.strictEqual(run.status, 1) + const crash = out.split('\n').filter(l => l.includes('CRASH')) + assert.strictEqual(crash.length, 1, 'expected one CRASH record, got:\n' + out) + assert.ok(crash[0].includes('uncaughtException'), crash[0]) + assert.ok(crash[0].includes(text), crash[0]) +} + +function registerBootOrderTests(deps) { + const { assert, fs, path } = deps + + it('an unknown NETWORK at module load leaves a CRASH record, not a bare stack dump', function () { + this.timeout(20000) + assertCrashRecord(assert, bootApi(deps, 'nope-regtest'), 'Unknown network') + }) + + it('a consensus-pin mismatch at module load leaves a CRASH record, not a bare stack dump', function () { + this.timeout(20000) + const coins = JSON.stringify(path.join(deps.dirname, COINS)) + const preload = 'require(' + coins + ').verifyConsensusPin = () => {' + + " throw new Error('CONSENSUS CONFIG PIN MISMATCH for boot-order test') }\n" + assertCrashRecord(assert, bootApi(deps, 'bitcoin-regtest', preload), 'CONSENSUS CONFIG PIN MISMATCH') + }) + + // Read off the file as well: the singleton is built at module load. + it('api.js installs the handlers before it builds the singleton encoder', function () { + const src = fs.readFileSync(path.join(deps.dirname, API), 'utf8') + const install = src.indexOf('installCrashHandlers()') + assert.ok(install > 0, 'installCrashHandlers() is called') + assert.strictEqual(src.indexOf('installCrashHandlers()', install + 1), -1, 'installed exactly once') + assert.ok(install < src.indexOf('new XChainEncoder('), 'installed before new XChainEncoder(') + }) +} + +module.exports = registerBootOrderTests; diff --git a/test/unit/repo/sibling_coverage.test.js b/test/unit/repo/sibling_coverage.test.js index b6fb1ccc..edbe5191 100644 --- a/test/unit/repo/sibling_coverage.test.js +++ b/test/unit/repo/sibling_coverage.test.js @@ -72,6 +72,9 @@ const SIBLINGS = [ { repo: 'xchain-utxo-tracker', envs: ['XCHAIN_UTXO_TRACKER_DIR'], marker: 'src', guards: 'the inbound UTXO-record contract, the one seam carrying records INTO the encoder' }, + { repo: 'xchain-indexer', envs: ['XCHAIN_INDEXER_DIR'], + marker: path.join('src', 'consensus', 'gates'), + guards: 'the mainnet prelaunch validator wire-format set against the consensus copy' }, ]; function resolve(entry) { From 79f549065f032cd5420de4204f6364acff3a9846 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 14 Oct 2026 20:41:07 -0700 Subject: [PATCH 12/22] Size bare-multisig dust from each coin's relay rate and refuse a reveal that does not spend its funded scripts --- bin/pins/at1-suite-titles.json | 2 +- .../build_transaction/data_outputs.js | 14 ++-- .../build_transaction/script_hash_legs.js | 14 ++++ src/XChainEncoder/constants.js | 12 +++- src/XChainEncoder/script_amount_helpers.js | 19 ++++-- test/e2e/multi_chain_e2e.test.js | 6 +- test/integration/multi_chain.test.js | 23 ++++--- .../bare_multisig_dust_relay_rate.test.js | 52 ++++++++++++++ .../reveal_funding_pairing.test.js | 68 +++++++++++++++++++ .../06_custom_dust_parameter.test.js | 14 ++-- .../02_p2wsh_tx2_spending.test.js | 44 ++++++------ 11 files changed, 217 insertions(+), 51 deletions(-) create mode 100644 test/unit/xchain_encoder/psbt_conformance/bare_multisig_dust_relay_rate.test.js create mode 100644 test/unit/xchain_encoder/psbt_conformance/reveal_funding_pairing.test.js diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index 75ad953f..39d0cc4b 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -1396,7 +1396,7 @@ "E2E-6: Multi-Chain Validation E2E-6.3: litecoin-regtest OP_RETURN creates valid OP_RETURN PSBT with correct dust threshold", "E2E-6: Multi-Chain Validation E2E-6.7: Bitcoin P2WSH creates P2WSH witness output on bitcoin-regtest", "E2E-6: Multi-Chain Validation E2E-6.8: Bitcoin MULTISIGN (dust=546) multisig output at 546 sats", - "E2E-6: Multi-Chain Validation E2E-6.9: Litecoin MULTISIGN (dust=5460) multisig output at 5460 sats", + "E2E-6: Multi-Chain Validation E2E-6.9: Litecoin MULTISIGN (dust=5460) multisig output at 7860 sats", "E2E-6: Multi-Chain Validation Fee floor uses chain-specific dust Litecoin fee floor is 5460, not 546", "E2E-6: Multi-Chain Validation bitcoin-regtest P2SH creates P2SH with output value >= chain dust threshold", "E2E-6: Multi-Chain Validation dogecoin-regtest P2SH creates P2SH with output value >= chain dust threshold", diff --git a/src/XChainEncoder/build_transaction/data_outputs.js b/src/XChainEncoder/build_transaction/data_outputs.js index 320710c3..6b7d410c 100644 --- a/src/XChainEncoder/build_transaction/data_outputs.js +++ b/src/XChainEncoder/build_transaction/data_outputs.js @@ -25,7 +25,7 @@ const { SATOSHI_UNIT, Encoding } = require('../constants.js') const { resolveCallerAddress } = require('../request_resolution.js') const { emitP2shChunk, emitP2wshChunk } = require('./script_hash_legs.js') const { feeForVsize, softDustSurchargeFor } = require('../fee_policy.js') -const { softDustFloorFor } = require('../script_amount_helpers.js') +const { softDustFloorFor, dustRelayFeePerByteFor } = require('../script_amount_helpers.js') const { ParamRangeError } = require('../../build/errors') function prepareDataChunks(build){ @@ -212,11 +212,13 @@ function* emitMultisignChunk(build, nextDataBuffer){ // below the node's relay dust threshold and the broadcast is // rejected with {"code":-26,"message":"dust"}. Never hard-code a // flat 546 here: DOGE shares this path at 100000. Size the floor - // from the actual output script using Bitcoin Core's dust formula: - // (output_bytes + spend_input_bytes) * 3 sat/byte. The spend cost - // assumes a 148-byte P2PKH-style input. For standard 1-of-3 - // compressed-key scripts (105 bytes) this is ~786 sat. - let bareMultisigDust = Math.ceil((8 + 1 + multisignScript.output.length + 148) * 3) + // from the actual output script using the node's dust formula: + // (output_bytes + spend_input_bytes) * the coin's dust relay rate + // (3 per byte on Bitcoin, 30 on Litecoin). The spend cost assumes a + // 148-byte P2PKH-style input. For standard 1-of-3 compressed-key + // scripts (105 bytes) this is 786 sat on Bitcoin and 7860 litoshi on + // Litecoin, where the flat 5460 would be relay dust. + let bareMultisigDust = Math.ceil((8 + 1 + multisignScript.output.length + 148) * dustRelayFeePerByteFor(this.networkKey)) let multisigOutputValue = Math.max(finalDust, bareMultisigDust) psbt.addOutput({ diff --git a/src/XChainEncoder/build_transaction/script_hash_legs.js b/src/XChainEncoder/build_transaction/script_hash_legs.js index 7fd0daad..c046e63a 100644 --- a/src/XChainEncoder/build_transaction/script_hash_legs.js +++ b/src/XChainEncoder/build_transaction/script_hash_legs.js @@ -69,6 +69,7 @@ function* spendP2shLeg(build, nextDataBuffer){ if (!p2shTx || !p2shTx.outs || voutPsbtIndex >= p2shTx.outs.length) { throw new ParamRangeError(`p2shHex transaction does not have output at index ${voutPsbtIndex}`) } + assertLegFundsScript.call(this, p2shTx, voutPsbtIndex, Encoding.P2SH, nextDataBuffer) let nextInput = { sequence: utxoSequence, hash:p2shHash, @@ -202,6 +203,18 @@ function sumRevealLegs(preparedData, feePerBytes, finalDust, encoding){ return { baseLegsTotal, revealFeeNeeded, revealSize } } +// The reveal rebuilds each chunk script from its own payload, so a payload that differs +// from the funded one yields a script that does not hash to the output it spends and a +// reveal no node will accept. Refuse it here rather than hand back an unspendable PSBT. +function assertLegFundsScript(p2shTx, vout, encoding, script){ + const payment = encoding === Encoding.P2SH ? bitcoin.payments.p2sh : bitcoin.payments.p2wsh + const expected = payment({ redeem: { output: script }, network: this.network }).output + if (!p2shTx.outs[vout].script.equals(expected)){ + throw new ParamRangeError(`${encoding} reveal chunk ${vout} does not match the funded output at index ${vout}: ` + + 'the reveal payload differs from the one the funding transaction committed to') + } +} + // A P2WSH chunk: a funding output on the commit, or an input spending it on the reveal. function* emitP2wshChunk(build, nextDataBuffer){ let { p2shHex, p2shTx, txidFirstInput, p2shHash } = build @@ -242,6 +255,7 @@ function* spendP2wshLeg(build, nextDataBuffer){ if (!p2shTx || !p2shTx.outs || voutPsbtIndex >= p2shTx.outs.length) { throw new ParamRangeError(`p2shHex transaction does not have output at index ${voutPsbtIndex}`) } + assertLegFundsScript.call(this, p2shTx, voutPsbtIndex, Encoding.P2WSH, nextDataBuffer) let nextInput = { sequence: utxoSequence, hash:p2shHash, diff --git a/src/XChainEncoder/constants.js b/src/XChainEncoder/constants.js index e8240611..3e64c5bc 100644 --- a/src/XChainEncoder/constants.js +++ b/src/XChainEncoder/constants.js @@ -58,6 +58,16 @@ const SOFT_DUST_FLOOR_BY_COIN = { DOGE: 1000000 } +// Dust relay rate per coin in base units per byte, the node's DUST_RELAY_TX_FEE over 1000: +// an output is relay dust when spending it costs more than it holds at this rate. A coin +// not listed takes Bitcoin Core's 3000 sat/kB (src/policy/policy.h). Litecoin Core sets +// DUST_RELAY_TX_FEE = 30'000 in src/policy/policy.h, which is also where LTC's 5460 +// (182 bytes x 30) comes from. Relay policy only; nothing here is consensus-pinned. +const DEFAULT_DUST_RELAY_FEE_PER_BYTE = 3 +const DUST_RELAY_FEE_PER_BYTE_BY_COIN = { + LTC: 30 +} + // Ceiling on the SUGGESTED fee rate (base units per vByte) for a caller that // supplies none, applied on test chains only. estimatesmartfee needs a populated // fee market to mean anything; a quiet test chain returns a large fallback at @@ -118,4 +128,4 @@ const Encoding = { AUTO: "AUTO" } -module.exports = { logger, OP_RETURN_SIZE, P2SH_SIZE, PW2SH_SIZE, MULTISIGN_SIZE, MAGIC_WORD, TAPROOT_ENVELOPE_CHUNK_SIZE, TAPROOT_LEAF_VERSION, TAPROOT_ENVELOPE_FORMAT_V0, SATOSHI_UNIT, MAX_SAFE_SATOSHI_BIG, SOFT_DUST_FLOOR_BY_COIN, DEFAULT_SUGGESTED_FEE_MAX_PER_VBYTE, TEST_NETWORK_SUFFIXES, SUGGESTED_FEE_CEILING_RELAY_MULTIPLIER, DEFAULT_MAX_CPFP_UPLIFT_SAT, BURN_BACKSTOP_MULTIPLIER, RESERVATION_TTL_MS, Encoding } +module.exports = { logger, OP_RETURN_SIZE, P2SH_SIZE, PW2SH_SIZE, MULTISIGN_SIZE, MAGIC_WORD, TAPROOT_ENVELOPE_CHUNK_SIZE, TAPROOT_LEAF_VERSION, TAPROOT_ENVELOPE_FORMAT_V0, SATOSHI_UNIT, MAX_SAFE_SATOSHI_BIG, SOFT_DUST_FLOOR_BY_COIN, DEFAULT_DUST_RELAY_FEE_PER_BYTE, DUST_RELAY_FEE_PER_BYTE_BY_COIN, DEFAULT_SUGGESTED_FEE_MAX_PER_VBYTE, TEST_NETWORK_SUFFIXES, SUGGESTED_FEE_CEILING_RELAY_MULTIPLIER, DEFAULT_MAX_CPFP_UPLIFT_SAT, BURN_BACKSTOP_MULTIPLIER, RESERVATION_TTL_MS, Encoding } diff --git a/src/XChainEncoder/script_amount_helpers.js b/src/XChainEncoder/script_amount_helpers.js index e5d7fe74..8f0eb218 100644 --- a/src/XChainEncoder/script_amount_helpers.js +++ b/src/XChainEncoder/script_amount_helpers.js @@ -20,15 +20,26 @@ const bitcoin = require('bitcoinjs-lib'); const coins = require('../coins') -const { SOFT_DUST_FLOOR_BY_COIN, TAPROOT_LEAF_VERSION, MAX_SAFE_SATOSHI_BIG } = require('./constants.js') +const { SOFT_DUST_FLOOR_BY_COIN, DEFAULT_DUST_RELAY_FEE_PER_BYTE, DUST_RELAY_FEE_PER_BYTE_BY_COIN, TAPROOT_LEAF_VERSION, MAX_SAFE_SATOSHI_BIG } = require('./constants.js') + +// The coin tick of a "-" key, or undefined. +function tickOf(networkKey){ + const fullName = String(networkKey || '').slice(0, Math.max(0, String(networkKey || '').lastIndexOf('-'))) + return coins.FULL_NAME_TO_TICK[fullName] +} // Keyed on the coin, not the network: the soft limit is node policy on every Dogecoin chain. function softDustFloorFor(networkKey){ - const fullName = String(networkKey || '').slice(0, Math.max(0, String(networkKey || '').lastIndexOf('-'))) - const tick = coins.FULL_NAME_TO_TICK[fullName] + const tick = tickOf(networkKey) return (tick && SOFT_DUST_FLOOR_BY_COIN[tick]) || 0 } +// The coin's dust relay rate in base units per byte, keyed on the coin like the soft limit. +function dustRelayFeePerByteFor(networkKey){ + const tick = tickOf(networkKey) + return (tick && DUST_RELAY_FEE_PER_BYTE_BY_COIN[tick]) || DEFAULT_DUST_RELAY_FEE_PER_BYTE +} + // Byte width of the compactSize varint that prefixes a length on the wire. // Distinct from compiledPushSize: that models bitcoin.script.compile's PUSH // OPCODE framing (direct push / OP_PUSHDATA1 / OP_PUSHDATA2) and is correct @@ -89,4 +100,4 @@ function jsonSafeSat(v) { return v <= MAX_SAFE_SATOSHI_BIG ? Number(v) : v.toString() } -module.exports = { softDustFloorFor, compactSizeLen, envelopeTapLeafHash, ensureEccLib, asSatValue, jsonSafeSat } +module.exports = { softDustFloorFor, dustRelayFeePerByteFor, compactSizeLen, envelopeTapLeafHash, ensureEccLib, asSatValue, jsonSafeSat } diff --git a/test/e2e/multi_chain_e2e.test.js b/test/e2e/multi_chain_e2e.test.js index 58db61ab..f6ac0b76 100644 --- a/test/e2e/multi_chain_e2e.test.js +++ b/test/e2e/multi_chain_e2e.test.js @@ -160,7 +160,7 @@ describe('E2E-6: Multi-Chain Validation', () => { describe('E2E-6: Multi-Chain Validation', () => { describe('E2E-6.9: Litecoin MULTISIGN (dust=5460)', () => { - it('multisig output at 5460 sats', async () => { + it('multisig output at 7860 sats', async () => { const encoder = makeEncoder('litecoin-regtest') const address = getTestAddress('litecoin-regtest') const utxo = makeUtxo('litecoin-regtest', TXID_MULTISIGN, 0, 100000000) @@ -172,8 +172,8 @@ describe('E2E-6: Multi-Chain Validation', () => { ) assert.strictEqual(result.encoding, 'MULTISIGN') - const msOutput = result.psbt.txOutputs.find(o => o.value === 5460) - assert.ok(msOutput, 'Litecoin multisig output should be 5460 sats') + const msOutput = result.psbt.txOutputs.find(o => o.value === 7860) + assert.ok(msOutput, 'Litecoin multisig output should be 7860 sats, the bare-multisig relay floor at 30 sat/byte') }) }) }) diff --git a/test/integration/multi_chain.test.js b/test/integration/multi_chain.test.js index 36346774..92cd8dff 100644 --- a/test/integration/multi_chain.test.js +++ b/test/integration/multi_chain.test.js @@ -38,14 +38,16 @@ const CHAINS = [ const MS_DATA = 'A'.repeat(59) // A bare 1-of-3 multisig output is far larger than a P2PKH, so its relay dust -// floor is Bitcoin Core's size formula (output_bytes + 148) * 3 sat/byte, not -// the chain's flat P2PKH dustThreshold. The encoder takes the MAX of the two. -// On Bitcoin the size formula wins (786 > 546); on Litecoin and Dogecoin the +// floor is the node's size formula (output_bytes + 148) * the coin's dust relay +// rate (DUST_RELAY_TX_FEE / 1000: 3 on Bitcoin, 30 on Litecoin), not the chain's +// flat P2PKH dustThreshold. The encoder takes the MAX of the two. On Bitcoin +// (786 > 546) and Litecoin (7860 > 5460) the size formula wins; on Dogecoin the // chain threshold wins. Looking the multisig output up by -// `value === encoder.dustAmount` silently fails to match on Bitcoin under -// that max(), so such a lookup asserts nothing on either chain. -function bareMultisigDust (scriptLength) { - return Math.ceil((8 + 1 + scriptLength + 148) * 3) +// `value === encoder.dustAmount` silently fails to match under that max(), so +// such a lookup asserts nothing. +const DUST_RELAY_PER_BYTE = { 'bitcoin-regtest': 3, 'litecoin-regtest': 30 } +function bareMultisigDust (scriptLength, networkName = 'bitcoin-regtest') { + return Math.ceil((8 + 1 + scriptLength + 148) * (DUST_RELAY_PER_BYTE[networkName] || 3)) } async function buildMultisign (networkName) { @@ -125,7 +127,7 @@ describe('Category F: Multi-Chain Network Configs', () => { it('MULTISIGN output clears both the chain dust floor and the bare-multisig relay floor', async () => { for (const chain of CHAINS) { const { encoder, output } = await buildMultisign(chain.name) - const relayFloor = bareMultisigDust(output.script.length) + const relayFloor = bareMultisigDust(output.script.length, chain.name) assert.strictEqual(encoder.dustAmount, chain.dustThreshold) assert.strictEqual(output.value, Math.max(chain.dustThreshold, relayFloor), `${chain.name} MULTISIGN output should sit at max(dustThreshold, bare-multisig relay floor)`) @@ -140,8 +142,9 @@ describe('Category F: Multi-Chain Network Configs', () => { // Bitcoin's 546 floor is below the relay formula, so the formula binds. assert.strictEqual(btc.output.value, bareMultisigDust(btc.output.script.length)) - // Litecoin's 5460 floor is above it, so the chain threshold binds. - assert.strictEqual(ltc.output.value, 5460) + // Litecoin relays dust at 30 per byte, so the same formula binds above its 5460 threshold. + assert.strictEqual(ltc.output.value, bareMultisigDust(ltc.output.script.length, 'litecoin-regtest')) + assert.strictEqual(ltc.output.value, 7860) assert.ok(ltc.output.value > btc.output.value, 'Litecoin dust should be higher than Bitcoin') }) diff --git a/test/unit/xchain_encoder/psbt_conformance/bare_multisig_dust_relay_rate.test.js b/test/unit/xchain_encoder/psbt_conformance/bare_multisig_dust_relay_rate.test.js new file mode 100644 index 00000000..fde20448 --- /dev/null +++ b/test/unit/xchain_encoder/psbt_conformance/bare_multisig_dust_relay_rate.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. + +// A bare 1-of-3 multisig carrier output is sized at the coin's own dust relay rate. +// Litecoin Core relays dust at 30000 litoshi/kB (src/policy/policy.h DUST_RELAY_TX_FEE), +// ten times Bitcoin Core's 3000 sat/kB, so the 262-byte output-plus-spend is 7860 +// litoshi there; the chain's flat 5460 P2PKH threshold sits under it and is relay dust. +// Bitcoin and Dogecoin values are pinned byte for byte. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { PUBKEY_HEX, paymentFor, utxoFor, makeEncoder, build } = require('../../../helpers/psbt_build_fixtures.js') + +const CASES = [ + { network: 'bitcoin-regtest', kind: 'p2wpkh', estimate: 0.00001, relayfee: 0.00001, value: 100000000, expected: 786 }, + { network: 'litecoin-regtest', kind: 'p2wpkh', estimate: 0.0001, relayfee: 0.0001, value: 100000000, expected: 7860 }, + { network: 'dogecoin-regtest', kind: 'p2pkh', estimate: 0.01, relayfee: 0.001, value: 100000000000, expected: 1000000 } +] + +async function multisigValues (c) { + const prevTxs = new Map() + const encoder = makeEncoder(c.network, { estimate: c.estimate, relayfee: c.relayfee, prevTxs }) + const pay = paymentFor(c.kind, c.network) + const result = await build(encoder, { + utxos: [utxoFor(prevTxs, pay.output, c.value, 'ms-dust:' + c.network)], pubkey: pay.address, + data: 'FILE|0|b.bin|application/octet-stream|' + 'A'.repeat(300), encoding: 'MULTISIGN', + change: pay.address, compressedPubKey: PUBKEY_HEX + }) + assert.strictEqual(result.encoding, 'MULTISIGN') + const outs = result.psbt.txOutputs.filter(o => o.script[o.script.length - 1] === bitcoin.opcodes.OP_CHECKMULTISIG) + assert.ok(outs.length >= 2, 'the payload spans several multisig outputs') + return outs +} + +describe('XChainEncoder bare-multisig carrier dust per coin relay rate', function () { + for (const c of CASES) { + it(`${c.network}: every multisig carrier output is ${c.expected}`, async function () { + const outs = await multisigValues(c) + for (const o of outs) { + assert.strictEqual(o.script.length, 105, 'a 1-of-3 compressed-key script') + assert.strictEqual(o.value, c.expected) + } + }) + } +}) diff --git a/test/unit/xchain_encoder/psbt_conformance/reveal_funding_pairing.test.js b/test/unit/xchain_encoder/psbt_conformance/reveal_funding_pairing.test.js new file mode 100644 index 00000000..f9988a74 --- /dev/null +++ b/test/unit/xchain_encoder/psbt_conformance/reveal_funding_pairing.test.js @@ -0,0 +1,68 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A chunk reveal spends the funding outputs with the scripts rebuilt from the reveal +// call's own payload. When that payload differs from the funded one at the same chunk +// count, the scripts do not hash to the outputs they claim to spend and the reveal can +// never be valid, so the encoder refuses it instead of returning an unspendable PSBT. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { paymentFor, utxoFor, makeEncoder, build, unsignedTx } = require('../../../helpers/psbt_build_fixtures.js') + +const FEE_PER_KB = 2000 + +async function fund (network, encoding, data) { + const prevTxs = new Map() + const encoder = makeEncoder(network, { estimate: 0.00002, relayfee: 0.00001, prevTxs }) + const pay = paymentFor('p2wpkh', network) + const funding = await build(encoder, { + utxos: [utxoFor(prevTxs, pay.output, 5000000, network + encoding + data.length)], pubkey: pay.address, + data, encoding, change: pay.address, feePerKb: FEE_PER_KB + }) + return { encoder, pay, fundingTx: unsignedTx(funding) } +} + +function reveal (encoder, pay, fundingTx, encoding, data) { + return build(encoder, { + utxos: [], pubkey: pay.address, data, encoding, change: pay.address, + p2shHash: fundingTx.getId(), p2shHex: fundingTx.toHex(), feePerKb: FEE_PER_KB + }) +} + +describe('XChainEncoder chunk reveal refuses scripts that do not match the funded outputs', function () { + for (const encoding of ['P2SH', 'P2WSH']) { + for (const length of [100, 600, 1500]) { + it(`${encoding} ${length}-byte payload: a reveal of the funded payload is built`, async function () { + const data = 'x'.repeat(length) + const { encoder, pay, fundingTx } = await fund('bitcoin-regtest', encoding, data) + const r = await reveal(encoder, pay, fundingTx, encoding, data) + const tx = unsignedTx(r) + for (const input of tx.ins) assert.strictEqual(Buffer.from(input.hash).reverse().toString('hex'), fundingTx.getId()) + }) + + it(`${encoding} ${length}-byte payload: a reveal of a different same-length payload is refused`, async function () { + const { encoder, pay, fundingTx } = await fund('bitcoin-regtest', encoding, 'x'.repeat(length)) + await assert.rejects(reveal(encoder, pay, fundingTx, encoding, 'y'.repeat(length)), + (err) => err instanceof RangeError && /does not match the funded output/.test(err.message)) + }) + } + } + + it('P2WSH: a reveal whose payload needs more chunks than were funded is refused', async function () { + const { encoder, pay, fundingTx } = await fund('bitcoin-regtest', 'P2WSH', 'x'.repeat(600)) + const fundedLegs = fundingTx.outs.filter(o => { + const d = bitcoin.script.decompile(o.script) + return d && d.length === 2 && d[0] === bitcoin.opcodes.OP_0 && d[1].length === 32 + }).length + assert.ok(fundedLegs >= 2) + await assert.rejects(reveal(encoder, pay, fundingTx, 'P2WSH', 'x'.repeat(1500)), RangeError) + }) +}) 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 afe420ae..1b53ab30 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 @@ -8,6 +8,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. +const bitcoin = require('bitcoinjs-lib') const { assert, pubkeyBuf, @@ -24,8 +25,9 @@ describe('XChainEncoder.createTransaction()', () => { const encoder = makeEncoder() const utxo = makeSegwitUtxo(TXID_MS, 0, 100000000) const compressedPubKey = pubkeyBuf.toString('hex') - // Above the LTC output floor (5460), so the caller's value is honoured. - const customDust = 6000 + // Above the LTC output floor (5460) and the 1-of-3 bare-multisig relay floor + // (262 bytes at Litecoin's 30 per byte = 7860), so the caller's value is honoured. + const customDust = 9000 assert.ok(customDust > encoder.outputFloor) const MS_DATA = 'A'.repeat(59) @@ -38,7 +40,7 @@ describe('XChainEncoder.createTransaction()', () => { ) const msOutput = result.psbt.txOutputs.find(o => o.value === customDust) - assert.ok(msOutput, 'MULTISIGN output should use custom dust value of 6000') + assert.ok(msOutput, 'MULTISIGN output should use custom dust value of 9000') }) }) }) @@ -63,7 +65,11 @@ describe('XChainEncoder.createTransaction()', () => { ) assert.ok(!result.psbt.txOutputs.find(o => o.value === customDust), 'no output at the sub-floor value') - assert.ok(result.psbt.txOutputs.find(o => o.value === encoder.outputFloor), 'the data output sits at the floor') + // The data output sits at the larger of the output floor and the bare-multisig + // relay floor, which on Litecoin is 7860 (262 bytes at 30 per byte). + const msOutputs = result.psbt.txOutputs.filter(o => o.script[o.script.length - 1] === bitcoin.opcodes.OP_CHECKMULTISIG) + assert.ok(msOutputs.length > 0, 'a multisig data output') + for (const o of msOutputs) assert.strictEqual(o.value, Math.max(encoder.outputFloor, 7860), 'the data output sits at the floor') }) it('does NOT override the fee floor (fee floor uses this.dustAmount)', async () => { 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 8851f41f..3f52d33d 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 @@ -65,6 +65,20 @@ const BTC_REGTEST_ADDR = bitcoin.payments.p2pkh({ const RAW_TX_HEX = buildRawP2pkhTxHex(100000000) +// The real funding transaction for `data`, cut down to its first output: the first +// chunk leg matches the reveal's first script, and every later chunk has no output. +async function fundingWithFirstLegOnly (encoder, utxo, address, data, encoding) { + const funding = await encoder.createTransaction( + [utxo], address, null, data, null, 10000, false, encoding, address, + null, null, null, true, 0.00001) + const full = funding.psbt.__CACHE.__TX + const cut = new bitcoin.Transaction() + cut.version = full.version + for (const input of full.ins) cut.addInput(input.hash, input.index, input.sequence) + cut.addOutput(full.outs[0].script, full.outs[0].value) + return cut +} + function makeEncoder (network) { const net = network || 'dogecoin-regtest' const encoder = new XChainEncoder( @@ -121,21 +135,13 @@ describe('XChainEncoder.createTransaction(): P2WSH tx2 (spending)', () => { const encoder = makeEncoder('bitcoin-regtest') const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - // Build a minimal tx with just ONE output but request it for two chunks - // (The data will be split into 2 chunks but the funding tx only has 1 output) - const smallTx = new bitcoin.Transaction() - smallTx.addInput(Buffer.alloc(32, 0x11), 0) - const script = bitcoin.payments.p2pkh({ - pubkey: pubkeyBuf, network: bitcoin.networks.regtest - }).output - smallTx.addOutput(script, 10000) // only 1 P2WSH-sized output at index 0 + // Data that will produce 2 chunks: each P2WSH chunk holds 476 bytes, so 480 + // forces 2. The funding tx keeps only its first leg, so chunk 1 has no output. + const bigData = 'x'.repeat(480) + const smallTx = await fundingWithFirstLegOnly(encoder, utxo, BTC_REGTEST_ADDR, bigData, 'P2WSH') const smallTxHex = smallTx.toHex() const smallTxId = smallTx.getId() - // Data that will produce 2 chunks (> 76 bytes for OP_RETURN, so P2WSH means 2 chunks) - // Each P2WSH chunk uses 476 bytes of data; let's use data > 476 to force 2 chunks - const bigData = 'x'.repeat(480) - await assert.rejects( () => encoder.createTransaction( [utxo], BTC_REGTEST_ADDR, null, @@ -154,19 +160,13 @@ describe('XChainEncoder.createTransaction(): P2WSH tx2 (spending)', () => { const encoder = makeEncoder() // dogecoin-regtest (P2SH, no segwit) const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) - // Funding tx with a SINGLE output, but a payload that splits into >=2 chunks. - const smallTx = new bitcoin.Transaction() - smallTx.addInput(Buffer.alloc(32, 0x11), 0) - const script = bitcoin.payments.p2pkh({ - pubkey: pubkeyBuf, network: bitcoin.networks.regtest - }).output - smallTx.addOutput(script, 10000) // only 1 P2SH-sized output at index 0 + // >476 compiled bytes forces 2 P2SH chunks, but the funding tx keeps only its first leg. + // The second chunk's index is past the funding outputs, so the bounds check refuses it. + const bigData = 'x'.repeat(480) + const smallTx = await fundingWithFirstLegOnly(encoder, utxo, TEST_ADDRESS, bigData, 'P2SH') const smallTxHex = smallTx.toHex() const smallTxId = smallTx.getId() - // >476 compiled bytes forces 2 P2SH chunks, but the funding tx has 1 output. - const bigData = 'x'.repeat(480) - await assert.rejects( () => encoder.createTransaction( [utxo], TEST_ADDRESS, null, From 03d00a553492108b1fbfd90d31967c985c90c036 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Fri, 16 Oct 2026 22:42:24 -0700 Subject: [PATCH 13/22] Re-vendor coin registry for the mainnet genesis re-pin Synced from xchain-hub/src/coins by bin/sync-coins.sh: the BTC and DOGE mainnet genesis ledgerHash and dumpHash move after BASE, DASH, HBAR, HOOD, HYPE and NEAR left the genesis manifests. No testnet or regtest value moves. The vendored-file identity pin is re-taken for the new coin file bytes. --- bin/pins/at1-identity.json | 6 +++--- src/coins/BTC.js | 13 +++++++++++-- src/coins/DOGE.js | 13 +++++++++++-- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/bin/pins/at1-identity.json b/bin/pins/at1-identity.json index 0c2565e8..1d3d65fe 100644 --- a/bin/pins/at1-identity.json +++ b/bin/pins/at1-identity.json @@ -1,9 +1,9 @@ { - "capturedAt": "2026-10-09T19:55:43.652Z", + "capturedAt": "2026-10-17T05:41:32.673Z", "coins": { - "src/coins/BTC.js": "182baf5173e3846b94a5edc1ae15913a54fb266addfc45a3cf9a013bd9ec16c6", + "src/coins/BTC.js": "6868afc2d18911ba112234c980c094fda71cfc3e744a00f493def781c7610f8d", "src/coins/LTC.js": "6155195bfa5fdfeab77dec3802ce67b5cc47a77695e81a4a5545d980e4c421dd", - "src/coins/DOGE.js": "4659b45fb7a5c959ce2777ab9d5e7c193f3515f03cdd8cd23bb6b9c4d012fc11", + "src/coins/DOGE.js": "29ac02299b0c779f4e3eda579eeaa9f673c454b73cfe9978a35f03f0e9aed33c", "src/coins/index.js": "08b678d934fa647d3c7d14dc5ccdaeb86722ee32a3662de69e4a31cd7b09234c", "src/coins/consensus_pin.js": "342bf7af224a19ea0a98ff9cfb2c9a70b3fd5be75de7ecef2db05936f82baa63" }, diff --git a/src/coins/BTC.js b/src/coins/BTC.js index b50f3d4a..8dfbdfbc 100644 --- a/src/coins/BTC.js +++ b/src/coins/BTC.js @@ -162,10 +162,19 @@ module.exports = { // plus airdropSetHash - the sha256 over the canonical `name:hash:amount` lines, // which genesis.js verifies before it credits anything - followed by a // bin/sync-coins.sh re-vendoring wave. + // Both hashes re-pinned 2026-10-13: the name registrations BASE, + // DASH, HBAR, HOOD, HYPE and NEAR left the CSV manifest so the bridge owns + // those chain roots, six rows out of 121,716. They were supply-0, balance-free + // name rows with no subassets and no post-genesis action on either chain, so + // nothing else in the manifest or the dump moved: the dump loses only those six + // names' rows and its ledger and contracts block hashes are unchanged. + // The six stay reserved names (RESERVED_FUTURE_ROOTS in xchain-indexer, operator + // ruling 2026-10-17): with no row left, a new ISSUE of any of them is refused + // wherever TICK_NAMESPACE_ACTIVATION binds (unarmed on mainnet today). genesis: { block: 950000, - ledgerHash: 'f347a0499654b128dd0461441ed6341ac27a24b909d6ff6e3995db4e69dc23e5', - dumpHash: 'e416c382dd6951ce5c4da7e053f6dbb407ca3d966a513209eb59886dbe95e738', + ledgerHash: '13aeeebb9f2e27d507ca96fb7c54a1bfa98b9cc80c3feebd5d1ad022ce7e2b73', + dumpHash: '85431c2c41d6442e38aa78af804148dd6ead207aca036f6e44fbf2bee8c2846e', airdropPaths: [], airdropHashes: [], airdropAmounts: [], diff --git a/src/coins/DOGE.js b/src/coins/DOGE.js index 787327f3..41e51c0f 100644 --- a/src/coins/DOGE.js +++ b/src/coins/DOGE.js @@ -93,10 +93,19 @@ module.exports = { // plus airdropSetHash - the sha256 over the canonical `name:hash:amount` lines, // which genesis.js verifies before it credits anything - followed by a // bin/sync-coins.sh re-vendoring wave. + // Both hashes re-pinned 2026-10-13: the name registrations BASE, + // DASH, HBAR, HOOD, HYPE and NEAR left the CSV manifest so the bridge owns + // those chain roots, six rows out of 42,704. They were supply-0, balance-free + // name rows with no subassets and no post-genesis action on either chain, so + // nothing else in the manifest or the dump moved: the dump loses only those six + // names' rows and its ledger and contracts block hashes are unchanged. + // The six stay reserved names (RESERVED_FUTURE_ROOTS in xchain-indexer, operator + // ruling 2026-10-17): with no row left, a new ISSUE of any of them is refused + // wherever TICK_NAMESPACE_ACTIVATION binds (unarmed on mainnet today). genesis: { block: 6240000, - ledgerHash: '87abac0b03cbd24694f7c5e666425bdae6f9a9c51b826826bd4848bd4ade991b', - dumpHash: '81d3d2cb4714bc40ec12c2d1c8573040858ddf66e4ba89a05d44d86d3bffa0f1', + ledgerHash: '888fc0be89d4ac58dff24ef5b02c8ccfb947cef9c3d4bc3b28db2202e68da5df', + dumpHash: '9aae4cdf2bc14de119e089c267d442aec4671e01f6139aff486460798bb96a92', airdropPaths: [], airdropHashes: [], airdropAmounts: [], From 603ac2b4c599dd8ce2975386e71115b97db6958e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 01:11:17 -0700 Subject: [PATCH 14/22] Add transaction status lookup RPC Classify transactions through the node mempool and tracker chain index while preserving lookup failures as errors. Publish the OpenRPC contract and cover each status and failure path. --- bin/pins/at1-suite-titles.json | 23 ++- docs/openrpc.build.js | 17 ++ docs/openrpc.json | 36 ++++ src/api/json_rpc_methods.js | 23 ++- .../blockchain_connector/node_queries.js | 41 ++++ test/unit/api/get_tx_status.test.js | 176 ++++++++++++++++++ 6 files changed, 311 insertions(+), 5 deletions(-) create mode 100644 test/unit/api/get_tx_status.test.js diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index 39d0cc4b..b4cca08e 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -1071,6 +1071,19 @@ "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.27: LIST comma-separated items encoded successfully", "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.27: LIST full addresses auto-select P2SH" ], + "87462cdada9ac477": [ + "BlockchainConnector getMempoolEntry returns null for an HTTP 200 RPC -5 mempool miss", + "BlockchainConnector getMempoolEntry returns null for an HTTP 500 RPC -5 mempool miss", + "BlockchainConnector getMempoolEntry sends getmempoolentry with the txid and returns the entry", + "BlockchainConnector getMempoolEntry throws a transport error instead of treating it as absent", + "BlockchainConnector getMempoolEntry throws an unexpected RPC error instead of treating it as absent", + "get_tx_status transaction location rejects a malformed txid before either lookup", + "get_tx_status transaction location returns chain after a mempool miss when get_tx_block finds the txid", + "get_tx_status transaction location returns mempool and does not ask the tracker when getmempoolentry finds the txid", + "get_tx_status transaction location returns unknown only when both the mempool and chain lookups miss", + "get_tx_status transaction location throws rather than returning unknown when the node lookup fails", + "get_tx_status transaction location throws rather than returning unknown when the tracker lookup fails" + ], "880c961e264c0519": [ "XChainEncoder TAPROOT envelope sign/finalize/extract round trip (commit -> reveal -> estimator accuracy) signs both halves; the signed commit txid matches the pre-built reveal outpoint" ], @@ -2298,13 +2311,14 @@ }, "scripts": { "test": { - "fileCount": 135, - "titleCount": 1020, + "fileCount": 136, + "titleCount": 1031, "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/get_tx_status.test.js": "87462cdada9ac477", "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", @@ -2584,13 +2598,14 @@ } }, "test:unit": { - "fileCount": 135, - "titleCount": 1020, + "fileCount": 136, + "titleCount": 1031, "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/get_tx_status.test.js": "87462cdada9ac477", "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", diff --git a/docs/openrpc.build.js b/docs/openrpc.build.js index b193298d..78f8fc6c 100644 --- a/docs/openrpc.build.js +++ b/docs/openrpc.build.js @@ -120,6 +120,23 @@ const METHODS = [ params: [{ name: 'address', required: true, schema: str('address to query') }], result: { name: 'utxos', schema: { type: 'array', items: { type: 'object' } } }, }, + { + name: 'get_tx_status', + summary: 'Classify a transaction as present in the node mempool, confirmed on chain, or unknown.', + description: 'Checks the coin node mempool first, then the UTXO tracker chain index. ' + + 'A lookup failure is an error and is never reported as an unknown transaction.', + params: [{ name: 'txid', required: true, schema: str('transaction id (64-character hexadecimal string)') }], + result: { + name: 'transaction_status', + schema: { + type: 'object', + properties: { + status: str('transaction location', { enum: ['mempool', 'chain', 'unknown'] }), + }, + required: ['status'], + }, + }, + }, { name: 'get_tx_block', summary: 'Locate the confirmed block containing a transaction (proxied from xchain-utxo-tracker).', diff --git a/docs/openrpc.json b/docs/openrpc.json index 5846dd5d..ef7f2cc7 100644 --- a/docs/openrpc.json +++ b/docs/openrpc.json @@ -596,6 +596,42 @@ } } }, + { + "name": "get_tx_status", + "summary": "Classify a transaction as present in the node mempool, confirmed on chain, or unknown.", + "description": "Checks the coin node mempool first, then the UTXO tracker chain index. A lookup failure is an error and is never reported as an unknown transaction.", + "paramStructure": "by-name", + "params": [ + { + "name": "txid", + "required": true, + "schema": { + "type": "string", + "description": "transaction id (64-character hexadecimal string)" + } + } + ], + "result": { + "name": "transaction_status", + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "transaction location", + "enum": [ + "mempool", + "chain", + "unknown" + ] + } + }, + "required": [ + "status" + ] + } + } + }, { "name": "get_tx_block", "summary": "Locate the confirmed block containing a transaction (proxied from xchain-utxo-tracker).", diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index 520103bc..5bd754c9 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -330,9 +330,30 @@ return { } } -// Tracker-facing lookups; upstream error text is sanitized before it leaves. +// Transaction and UTXO lookups; upstream error text is sanitized before it leaves. function buildUtxoMethods({ encoder }) { return { + async get_tx_status(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 { + const mempoolEntry = await encoder.connector.getMempoolEntry(txid) + if (mempoolEntry !== null) return { status: 'mempool' } + + const block = await encoder.utxoTrackerConnector.getTxBlock(txid) + return { status: block === null ? 'unknown' : 'chain' } + } catch (err) { + logger.error(util.format('Transaction status lookup error:', err)) + const e = new Error(upstreamErrorMessage(err, 'Transaction status lookup failed')) + e.code = -32603 + throw e + } + }, async get_tx_block(rawParams) { const txid = rawParams && rawParams.txid if (typeof txid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(txid)) { diff --git a/src/build/blockchain_connector/node_queries.js b/src/build/blockchain_connector/node_queries.js index 01f9f358..91231a37 100644 --- a/src/build/blockchain_connector/node_queries.js +++ b/src/build/blockchain_connector/node_queries.js @@ -19,6 +19,12 @@ const { sanitizeRpcError, rpcErrorDetail } = require('./rpc_helpers'); const { chainTierMismatch } = require('./chain_identity'); const { OperationalError } = require('../errors'); +function mempoolEntryAbsent(body) { + const error = body && body.error; + if (!error) return false; + return error.code === -5 || /not in mempool|No such mempool/i.test(error.message || ''); +} + module.exports = { // Refuse a node whose getblockchaininfo chain is a recognized tier other than the configured network. // (Fails open, warning once, when no network was configured or the chain cannot be read; a pass is cached.) @@ -190,4 +196,39 @@ module.exports = { throw new Error(`Error getting transaction hex: ${message}${detail}`); } }, + + // Return null only for the node's normal "not in mempool" answer. A node + // or transport failure must remain distinguishable from an absent txid so + // callers never classify an outage as a dropped transaction. + async getMempoolEntry(txid) { + try { + const response = await axios.post(this.url, { + jsonrpc: '2.0', + method: 'getmempoolentry', + params: [txid], + id: 1, + }, { + auth: { + username: this.rpcUser, + password: this.rpcPassword, + }, + timeout: RPC_TIMEOUT + }); + const body = response && response.data; + + if (mempoolEntryAbsent(body)) return null; + if (body && body.result && typeof body.result === 'object' && !Array.isArray(body.result)) { + return body.result; + } + throw new Error('Error getting mempool entry' + rpcErrorDetail(body)); + } catch (error) { + const body = error && error.response && error.response.data; + if (mempoolEntryAbsent(body)) return null; + const detail = rpcErrorDetail(body); + const message = sanitizeRpcError(error); + logger.error(util.format('Error:', message + detail)); + if (!detail) throw error; + throw new Error(`Error getting mempool entry: ${message}${detail}`); + } + }, } diff --git a/test/unit/api/get_tx_status.test.js b/test/unit/api/get_tx_status.test.js new file mode 100644 index 00000000..fd24821c --- /dev/null +++ b/test/unit/api/get_tx_status.test.js @@ -0,0 +1,176 @@ +'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 BlockchainConnector = require('../../../src/build/blockchain_connector') +const { createJsonRpcController } = require('../../../src/api/json_rpc_methods') + +const TXID = 'a'.repeat(64) + +function controllerWith ({ mempoolEntry = null, block = null, nodeError, trackerError } = {}) { + const calls = [] + const encoder = { + connector: { + async getMempoolEntry (txid) { + calls.push(['mempool', txid]) + if (nodeError) throw nodeError + return mempoolEntry + } + }, + utxoTrackerConnector: { + async getTxBlock (txid) { + calls.push(['chain', txid]) + if (trackerError) throw trackerError + return block + } + } + } + return { + calls, + controller: createJsonRpcController({ encoder, NETWORK: 'dogecoin-testnet' }) + } +} + +describe('get_tx_status transaction location', function () { + it('returns mempool and does not ask the tracker when getmempoolentry finds the txid', async function () { + const { controller, calls } = controllerWith({ mempoolEntry: { vsize: 250 } }) + + assert.deepStrictEqual(await controller.get_tx_status({ txid: TXID }), { status: 'mempool' }) + assert.deepStrictEqual(calls, [['mempool', TXID]]) + }) + + it('returns chain after a mempool miss when get_tx_block finds the txid', async function () { + const { controller, calls } = controllerWith({ block: { block_height: 123 } }) + + assert.deepStrictEqual(await controller.get_tx_status({ txid: TXID }), { status: 'chain' }) + assert.deepStrictEqual(calls, [['mempool', TXID], ['chain', TXID]]) + }) + + it('returns unknown only when both the mempool and chain lookups miss', async function () { + const { controller, calls } = controllerWith() + + assert.deepStrictEqual(await controller.get_tx_status({ txid: TXID }), { status: 'unknown' }) + assert.deepStrictEqual(calls, [['mempool', TXID], ['chain', TXID]]) + }) + + it('throws rather than returning unknown when the node lookup fails', async function () { + const { controller, calls } = controllerWith({ nodeError: new Error('node warming up') }) + const originalError = console.error + console.error = () => {} + + try { + await assert.rejects( + () => controller.get_tx_status({ txid: TXID }), + (err) => err.code === -32603 && /node warming up/.test(err.message) + ) + assert.deepStrictEqual(calls, [['mempool', TXID]]) + } finally { + console.error = originalError + } + }) + + it('throws rather than returning unknown when the tracker lookup fails', async function () { + const { controller } = controllerWith({ trackerError: new Error('tracker unavailable') }) + const originalError = console.error + console.error = () => {} + + try { + await assert.rejects( + () => controller.get_tx_status({ txid: TXID }), + (err) => err.code === -32603 && /tracker unavailable/.test(err.message) + ) + } finally { + console.error = originalError + } + }) + + it('rejects a malformed txid before either lookup', async function () { + const { controller, calls } = controllerWith() + + await assert.rejects( + () => controller.get_tx_status({ txid: 'not-a-txid' }), + (err) => err.code === -32602 && /64-hex-character/.test(err.message) + ) + assert.deepStrictEqual(calls, []) + }) +}) + +describe('BlockchainConnector getMempoolEntry', function () { + let originalPost + + beforeEach(function () { + originalPost = axios.post + }) + + afterEach(function () { + axios.post = originalPost + }) + + function connector () { + return new BlockchainConnector('127.0.0.1', 22555, 'rpcuser', 'rpcpass') + } + + it('sends getmempoolentry with the txid and returns the entry', async function () { + const expected = { vsize: 250 } + let request + axios.post = async (url, data, options) => { + request = { url, data, options } + return { data: { result: expected } } + } + + assert.strictEqual(await connector().getMempoolEntry(TXID), expected) + assert.strictEqual(request.url, 'http://127.0.0.1:22555') + assert.deepStrictEqual(request.data, { + jsonrpc: '2.0', method: 'getmempoolentry', params: [TXID], id: 1 + }) + assert.deepStrictEqual(request.options.auth, { username: 'rpcuser', password: 'rpcpass' }) + assert.ok(request.options.timeout > 0) + }) + + it('returns null for an HTTP 200 RPC -5 mempool miss', async function () { + axios.post = async () => ({ data: { error: { code: -5, message: 'Transaction not in mempool' } } }) + + assert.strictEqual(await connector().getMempoolEntry(TXID), null) + }) + + it('returns null for an HTTP 500 RPC -5 mempool miss', async function () { + axios.post = async () => { + const err = new Error('Request failed with status code 500') + err.response = { data: { error: { code: -5, message: 'Transaction not in mempool' } } } + throw err + } + + assert.strictEqual(await connector().getMempoolEntry(TXID), null) + }) + + it('throws an unexpected RPC error instead of treating it as absent', async function () { + const originalError = console.error + console.error = () => {} + axios.post = async () => ({ data: { error: { code: -28, message: 'Loading block index' } } }) + + try { + await assert.rejects(() => connector().getMempoolEntry(TXID), /Loading block index/) + } finally { + console.error = originalError + } + }) + + it('throws a transport error instead of treating it as absent', async function () { + const originalError = console.error + console.error = () => {} + const transport = new Error('connect ECONNREFUSED 127.0.0.1:22555') + transport.code = 'ECONNREFUSED' + axios.post = async () => { throw transport } + + try { + await assert.rejects(() => connector().getMempoolEntry(TXID), (err) => err === transport) + } finally { + console.error = originalError + } + }) +}) From d317416ac1ea9a299c304705061dbea57611f804 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 01:12:59 -0700 Subject: [PATCH 15/22] Keep status lookup within structure limits Extract shared txid and status helpers, rewrap retained readiness prose, and split the focused suites into bounded callbacks. --- src/api/json_rpc_methods.js | 115 ++++++++++++---------------- test/unit/api/get_tx_status.test.js | 22 +++++- 2 files changed, 65 insertions(+), 72 deletions(-) diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index 5bd754c9..330fb4d4 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -31,43 +31,31 @@ const { readMaintenanceWindow } = require('../server/maintenance_window') // o const config = require('../common/config') const logger = getLogger(); -// Each builder closes one group of methods over the encoder (and, for fees, -// the network name); the factory composes the groups into one controller. The -// method text keeps its entry indentation so the openrpc guard still matches. +// Each builder closes one group of methods over the encoder (and, for fees, the network name); the factory composes +// the groups into one controller. The method text keeps its entry indentation so the openrpc guard still matches. -// Serve-readiness probe shared by the JSON-RPC health() method and GET -// /status, so the two endpoints can never drift apart: probes the -// UTXO tracker and returns its reachability / sync state. Fields: -// tracker_reachable (bool), tracker_synced (bool), tracker_lag (number|null), -// tracker_halted (bool), tracker_mempool_ready (bool), maintenance (object|null). +// Serve-readiness probe shared by the JSON-RPC health() method and GET /status, so the two endpoints can never drift +// apart: probes the UTXO tracker and returns its reachability / sync state. Fields: tracker_reachable (bool), +// tracker_synced (bool), tracker_lag (number|null), tracker_halted (bool), tracker_mempool_ready (bool), maintenance (object|null). // -// maintenance is the operator's DECLARED scheduled-outage window (see -// src/server/maintenance_window.js), carried alongside the readiness fields and never -// folded into them: it cannot make an unready encoder read ready, and it does -// not move the 503. A planned tracker stop and a broken tracker are the same -// readiness verdict - the difference is only that somebody meant one of them, -// which is exactly the distinction the public board had no way to draw. +// maintenance is the operator's DECLARED scheduled-outage window (see src/server/maintenance_window.js), carried +// alongside the readiness fields and never folded into them: it cannot make an unready encoder read ready, and it does +// not move the 503. A planned tracker stop and a broken tracker are the same readiness verdict - the difference is only +// that somebody meant one of them, which is exactly the distinction the public board had no way to draw. // -// tracker_synced is SERVE-readiness, not the tracker's raw verdict: it -// applies the same maxUtxoTrackerLagBlocks ceiling create_tx enforces -// (deliberately tighter than the tracker's own SYNCED_THRESHOLD, money -// safety). Without the extra gate a 3-block lag read synced:true here while -// create_tx refused UTXO_TRACKER_STALE, so the status board painted Online -// on an un-serveable encoder. Null lag fails open, exactly like -// create_tx's overLag gate. +// tracker_synced is SERVE-readiness, not the tracker's raw verdict: it applies the same maxUtxoTrackerLagBlocks ceiling +// create_tx enforces (deliberately tighter than the tracker's own SYNCED_THRESHOLD, money safety). Without the extra gate +// a 3-block lag read synced:true here while create_tx refused UTXO_TRACKER_STALE, so the status board painted Online on an +// un-serveable encoder. Null lag fails open, exactly like create_tx's overLag gate. // -// A halted tracker (stopped polling on an unrecoverable reorg) and a negative -// lag (its committed tip sits above the node's, so its outputs are orphaned) -// are both un-serveable regardless of what `synced` says, and both were painting -// Online here because only the upper lag bound was checked. A tracker whose -// mempool has not reconverged is un-serveable for the same reason: create_tx -// refuses it UTXO_TRACKER_NOT_READY, so leaving it out of this probe recreated -// the same board-versus-encoder divergence the probe exists to prevent. +// A halted tracker (stopped polling on an unrecoverable reorg) and a negative lag (its committed tip sits above the node's, +// so its outputs are orphaned) are both un-serveable regardless of what `synced` says, and both were painting Online here +// because only the upper lag bound was checked. A tracker whose mempool has not reconverged is un-serveable for the same +// reason: create_tx refuses it UTXO_TRACKER_NOT_READY, so leaving it out of this probe recreated the same board-versus-encoder divergence the probe exists to prevent. function buildReadinessMethods({ encoder }) { async function getServeReadiness() { - // Read first and independently of the tracker probe: the window is what - // explains a tracker that is deliberately down, so it must survive the - // catch below rather than depend on the probe it annotates. + // Read first and independently of the tracker probe: the window is what explains a deliberately down tracker, so it must + // survive the catch below rather than depend on the probe it annotates. const maintenance = await readMaintenanceWindow() let tracker_reachable = false let tracker_synced = false @@ -77,13 +65,10 @@ async function getServeReadiness() { try { const status = await encoder.utxoTrackerConnector.getSyncStatus() tracker_reachable = true - // This probe applies the SAME freshness classifier create_tx refuses a - // tracker with, against this endpoint's payload, so the board and the - // encoder cannot drift apart: both read getSyncStatus() through - // classifyTrackerFreshness instead of each re-deriving over-lag, - // behind-node, halted and mempool-ready. The remaining asymmetry is - // intentional: readiness needs the tracker's POSITIVE synced assertion - // (syncedClaimed), while the create_tx gate refuses only on negatives. + // This probe applies the SAME freshness classifier create_tx refuses a tracker with, against this endpoint's payload, so the board and encoder + // cannot drift apart: both read getSyncStatus() through classifyTrackerFreshness instead of each re-deriving + // over-lag, behind-node, halted and mempool-ready. The remaining asymmetry is intentional: readiness needs the + // tracker's POSITIVE synced assertion (syncedClaimed), while the create_tx gate refuses only on negatives. const freshness = XChainEncoder.classifyTrackerFreshness(status, encoder.maxUtxoTrackerLagBlocks) tracker_halted = freshness.halted tracker_mempool_ready = freshness.mempoolReady @@ -99,10 +84,8 @@ return { async ping() { return {status:"success", version: ENCODER_VERSION}; }, - // Probes hard dependencies (UTXO tracker) and returns their reachability / - // sync state. Unlike ping, a health failure means the encoder cannot serve - // requests correctly. See getServeReadiness() for the readiness gate, - // shared with GET /status below. + // Probes hard dependencies (UTXO tracker) and returns their reachability / sync state. Unlike ping, a health failure means the encoder + // cannot serve requests correctly. See getServeReadiness() for the readiness gate shared with GET /status below. async health() { return getServeReadiness() }, @@ -329,39 +312,35 @@ return { }, } } +function validateLookupTxid(rawParams) { + const txid = rawParams && rawParams.txid + if (typeof txid === 'string' && /^[0-9a-fA-F]{64}$/.test(txid)) return txid + const e = new Error('txid must be a 64-hex-character string') + e.code = -32602 + throw e +} +async function getTxStatus(encoder, rawParams) { + const txid = validateLookupTxid(rawParams) + try { + if (await encoder.connector.getMempoolEntry(txid) !== null) return { status: 'mempool' } + const block = await encoder.utxoTrackerConnector.getTxBlock(txid) + return { status: block === null ? 'unknown' : 'chain' } + } catch (err) { + logger.error(util.format('Transaction status lookup error:', err)) + const e = new Error(upstreamErrorMessage(err, 'Transaction status lookup failed')) + e.code = -32603 + throw e + } +} // Transaction and UTXO lookups; upstream error text is sanitized before it leaves. function buildUtxoMethods({ encoder }) { return { async get_tx_status(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 { - const mempoolEntry = await encoder.connector.getMempoolEntry(txid) - if (mempoolEntry !== null) return { status: 'mempool' } - - const block = await encoder.utxoTrackerConnector.getTxBlock(txid) - return { status: block === null ? 'unknown' : 'chain' } - } catch (err) { - logger.error(util.format('Transaction status lookup error:', err)) - const e = new Error(upstreamErrorMessage(err, 'Transaction status lookup failed')) - e.code = -32603 - throw e - } + return getTxStatus(encoder, rawParams) }, 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 - } - + const txid = validateLookupTxid(rawParams) try { return await encoder.utxoTrackerConnector.getTxBlock(txid) } catch (err) { diff --git a/test/unit/api/get_tx_status.test.js b/test/unit/api/get_tx_status.test.js index fd24821c..347bf5ef 100644 --- a/test/unit/api/get_tx_status.test.js +++ b/test/unit/api/get_tx_status.test.js @@ -36,6 +36,10 @@ function controllerWith ({ mempoolEntry = null, block = null, nodeError, tracker } } +function connector () { + return new BlockchainConnector('127.0.0.1', 22555, 'rpcuser', 'rpcpass') +} + describe('get_tx_status transaction location', function () { it('returns mempool and does not ask the tracker when getmempoolentry finds the txid', async function () { const { controller, calls } = controllerWith({ mempoolEntry: { vsize: 250 } }) @@ -57,7 +61,9 @@ describe('get_tx_status transaction location', function () { assert.deepStrictEqual(await controller.get_tx_status({ txid: TXID }), { status: 'unknown' }) assert.deepStrictEqual(calls, [['mempool', TXID], ['chain', TXID]]) }) +}) +describe('get_tx_status transaction location', function () { it('throws rather than returning unknown when the node lookup fails', async function () { const { controller, calls } = controllerWith({ nodeError: new Error('node warming up') }) const originalError = console.error @@ -111,10 +117,6 @@ describe('BlockchainConnector getMempoolEntry', function () { axios.post = originalPost }) - function connector () { - return new BlockchainConnector('127.0.0.1', 22555, 'rpcuser', 'rpcpass') - } - it('sends getmempoolentry with the txid and returns the entry', async function () { const expected = { vsize: 250 } let request @@ -147,6 +149,18 @@ describe('BlockchainConnector getMempoolEntry', function () { assert.strictEqual(await connector().getMempoolEntry(TXID), null) }) +}) + +describe('BlockchainConnector getMempoolEntry', function () { + let originalPost + + beforeEach(function () { + originalPost = axios.post + }) + + afterEach(function () { + axios.post = originalPost + }) it('throws an unexpected RPC error instead of treating it as absent', async function () { const originalError = console.error From 83764a8359d9683b1785a0fd82fb29873bd22161 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 01:19:20 -0700 Subject: [PATCH 16/22] Require structured mempool miss codes Treat only RPC code -5 as an absent mempool entry, retain all other node errors, and cover misleading error text. Restore pre-existing readiness commentary while keeping transaction lookup helpers within the structure ratchet. --- src/api/json_rpc_methods.js | 101 ++++++++++-------- .../blockchain_connector/node_queries.js | 3 +- test/unit/api/get_tx_status.test.js | 4 +- 3 files changed, 58 insertions(+), 50 deletions(-) diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index 330fb4d4..e3c420e3 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -31,31 +31,43 @@ const { readMaintenanceWindow } = require('../server/maintenance_window') // o const config = require('../common/config') const logger = getLogger(); -// Each builder closes one group of methods over the encoder (and, for fees, the network name); the factory composes -// the groups into one controller. The method text keeps its entry indentation so the openrpc guard still matches. +// Each builder closes one group of methods over the encoder (and, for fees, +// the network name); the factory composes the groups into one controller. The +// method text keeps its entry indentation so the openrpc guard still matches. -// Serve-readiness probe shared by the JSON-RPC health() method and GET /status, so the two endpoints can never drift -// apart: probes the UTXO tracker and returns its reachability / sync state. Fields: tracker_reachable (bool), -// tracker_synced (bool), tracker_lag (number|null), tracker_halted (bool), tracker_mempool_ready (bool), maintenance (object|null). +// Serve-readiness probe shared by the JSON-RPC health() method and GET +// /status, so the two endpoints can never drift apart: probes the +// UTXO tracker and returns its reachability / sync state. Fields: +// tracker_reachable (bool), tracker_synced (bool), tracker_lag (number|null), +// tracker_halted (bool), tracker_mempool_ready (bool), maintenance (object|null). // -// maintenance is the operator's DECLARED scheduled-outage window (see src/server/maintenance_window.js), carried -// alongside the readiness fields and never folded into them: it cannot make an unready encoder read ready, and it does -// not move the 503. A planned tracker stop and a broken tracker are the same readiness verdict - the difference is only -// that somebody meant one of them, which is exactly the distinction the public board had no way to draw. +// maintenance is the operator's DECLARED scheduled-outage window (see +// src/server/maintenance_window.js), carried alongside the readiness fields and never +// folded into them: it cannot make an unready encoder read ready, and it does +// not move the 503. A planned tracker stop and a broken tracker are the same +// readiness verdict - the difference is only that somebody meant one of them, +// which is exactly the distinction the public board had no way to draw. // -// tracker_synced is SERVE-readiness, not the tracker's raw verdict: it applies the same maxUtxoTrackerLagBlocks ceiling -// create_tx enforces (deliberately tighter than the tracker's own SYNCED_THRESHOLD, money safety). Without the extra gate -// a 3-block lag read synced:true here while create_tx refused UTXO_TRACKER_STALE, so the status board painted Online on an -// un-serveable encoder. Null lag fails open, exactly like create_tx's overLag gate. +// tracker_synced is SERVE-readiness, not the tracker's raw verdict: it +// applies the same maxUtxoTrackerLagBlocks ceiling create_tx enforces +// (deliberately tighter than the tracker's own SYNCED_THRESHOLD, money +// safety). Without the extra gate a 3-block lag read synced:true here while +// create_tx refused UTXO_TRACKER_STALE, so the status board painted Online +// on an un-serveable encoder. Null lag fails open, exactly like +// create_tx's overLag gate. // -// A halted tracker (stopped polling on an unrecoverable reorg) and a negative lag (its committed tip sits above the node's, -// so its outputs are orphaned) are both un-serveable regardless of what `synced` says, and both were painting Online here -// because only the upper lag bound was checked. A tracker whose mempool has not reconverged is un-serveable for the same -// reason: create_tx refuses it UTXO_TRACKER_NOT_READY, so leaving it out of this probe recreated the same board-versus-encoder divergence the probe exists to prevent. +// A halted tracker (stopped polling on an unrecoverable reorg) and a negative +// lag (its committed tip sits above the node's, so its outputs are orphaned) +// are both un-serveable regardless of what `synced` says, and both were painting +// Online here because only the upper lag bound was checked. A tracker whose +// mempool has not reconverged is un-serveable for the same reason: create_tx +// refuses it UTXO_TRACKER_NOT_READY, so leaving it out of this probe recreated +// the same board-versus-encoder divergence the probe exists to prevent. function buildReadinessMethods({ encoder }) { async function getServeReadiness() { - // Read first and independently of the tracker probe: the window is what explains a deliberately down tracker, so it must - // survive the catch below rather than depend on the probe it annotates. + // Read first and independently of the tracker probe: the window is what + // explains a tracker that is deliberately down, so it must survive the + // catch below rather than depend on the probe it annotates. const maintenance = await readMaintenanceWindow() let tracker_reachable = false let tracker_synced = false @@ -65,10 +77,13 @@ async function getServeReadiness() { try { const status = await encoder.utxoTrackerConnector.getSyncStatus() tracker_reachable = true - // This probe applies the SAME freshness classifier create_tx refuses a tracker with, against this endpoint's payload, so the board and encoder - // cannot drift apart: both read getSyncStatus() through classifyTrackerFreshness instead of each re-deriving - // over-lag, behind-node, halted and mempool-ready. The remaining asymmetry is intentional: readiness needs the - // tracker's POSITIVE synced assertion (syncedClaimed), while the create_tx gate refuses only on negatives. + // This probe applies the SAME freshness classifier create_tx refuses a + // tracker with, against this endpoint's payload, so the board and the + // encoder cannot drift apart: both read getSyncStatus() through + // classifyTrackerFreshness instead of each re-deriving over-lag, + // behind-node, halted and mempool-ready. The remaining asymmetry is + // intentional: readiness needs the tracker's POSITIVE synced assertion + // (syncedClaimed), while the create_tx gate refuses only on negatives. const freshness = XChainEncoder.classifyTrackerFreshness(status, encoder.maxUtxoTrackerLagBlocks) tracker_halted = freshness.halted tracker_mempool_ready = freshness.mempoolReady @@ -84,8 +99,10 @@ return { async ping() { return {status:"success", version: ENCODER_VERSION}; }, - // Probes hard dependencies (UTXO tracker) and returns their reachability / sync state. Unlike ping, a health failure means the encoder - // cannot serve requests correctly. See getServeReadiness() for the readiness gate shared with GET /status below. + // Probes hard dependencies (UTXO tracker) and returns their reachability / + // sync state. Unlike ping, a health failure means the encoder cannot serve + // requests correctly. See getServeReadiness() for the readiness gate, + // shared with GET /status below. async health() { return getServeReadiness() }, @@ -315,39 +332,31 @@ return { function validateLookupTxid(rawParams) { const txid = rawParams && rawParams.txid if (typeof txid === 'string' && /^[0-9a-fA-F]{64}$/.test(txid)) return txid - const e = new Error('txid must be a 64-hex-character string') - e.code = -32602 - throw e + throw Object.assign(new Error('txid must be a 64-hex-character string'), { code: -32602 }) } - -async function getTxStatus(encoder, rawParams) { - const txid = validateLookupTxid(rawParams) - try { - if (await encoder.connector.getMempoolEntry(txid) !== null) return { status: 'mempool' } - const block = await encoder.utxoTrackerConnector.getTxBlock(txid) - return { status: block === null ? 'unknown' : 'chain' } - } catch (err) { - logger.error(util.format('Transaction status lookup error:', err)) - const e = new Error(upstreamErrorMessage(err, 'Transaction status lookup failed')) - e.code = -32603 - throw e - } +function lookupFailure(err, label, fallback) { + logger.error(util.format(`${label} error:`, err)) + return Object.assign(new Error(upstreamErrorMessage(err, fallback)), { code: -32603 }) } // Transaction and UTXO lookups; upstream error text is sanitized before it leaves. function buildUtxoMethods({ encoder }) { return { async get_tx_status(rawParams) { - return getTxStatus(encoder, rawParams) + const txid = validateLookupTxid(rawParams) + try { + if (await encoder.connector.getMempoolEntry(txid) !== null) return { status: 'mempool' } + const block = await encoder.utxoTrackerConnector.getTxBlock(txid) + return { status: block === null ? 'unknown' : 'chain' } + } catch (err) { + throw lookupFailure(err, 'Transaction status lookup', 'Transaction status lookup failed') + } }, async get_tx_block(rawParams) { const txid = validateLookupTxid(rawParams) 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 + throw lookupFailure(err, 'Transaction block lookup', 'Transaction block lookup failed') } }, async get_utxos(rawParams) { diff --git a/src/build/blockchain_connector/node_queries.js b/src/build/blockchain_connector/node_queries.js index 91231a37..5dd35e80 100644 --- a/src/build/blockchain_connector/node_queries.js +++ b/src/build/blockchain_connector/node_queries.js @@ -21,8 +21,7 @@ const { OperationalError } = require('../errors'); function mempoolEntryAbsent(body) { const error = body && body.error; - if (!error) return false; - return error.code === -5 || /not in mempool|No such mempool/i.test(error.message || ''); + return Boolean(error && error.code === -5); } module.exports = { diff --git a/test/unit/api/get_tx_status.test.js b/test/unit/api/get_tx_status.test.js index 347bf5ef..d90f14cf 100644 --- a/test/unit/api/get_tx_status.test.js +++ b/test/unit/api/get_tx_status.test.js @@ -165,10 +165,10 @@ describe('BlockchainConnector getMempoolEntry', function () { it('throws an unexpected RPC error instead of treating it as absent', async function () { const originalError = console.error console.error = () => {} - axios.post = async () => ({ data: { error: { code: -28, message: 'Loading block index' } } }) + axios.post = async () => ({ data: { error: { code: -28, message: 'Transaction not in mempool' } } }) try { - await assert.rejects(() => connector().getMempoolEntry(TXID), /Loading block index/) + await assert.rejects(() => connector().getMempoolEntry(TXID), /Transaction not in mempool/) } finally { console.error = originalError } From d7f39c5a5f44f24540d21b9dcd7a02613a228ba7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 01:20:58 -0700 Subject: [PATCH 17/22] Keep transaction lookups within source limits Condense only the new lookup helpers and handlers so the API module remains at the 400-line structure limit without rewriting existing comments or methods. --- src/api/json_rpc_methods.js | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index e3c420e3..964c8cd6 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -329,14 +329,12 @@ return { }, } } -function validateLookupTxid(rawParams) { - const txid = rawParams && rawParams.txid +function validateLookupTxid(rawParams) { const txid = rawParams && rawParams.txid if (typeof txid === 'string' && /^[0-9a-fA-F]{64}$/.test(txid)) return txid throw Object.assign(new Error('txid must be a 64-hex-character string'), { code: -32602 }) } -function lookupFailure(err, label, fallback) { - logger.error(util.format(`${label} error:`, err)) - return Object.assign(new Error(upstreamErrorMessage(err, fallback)), { code: -32603 }) +function lookupFailure(err, name) { + logger.error(util.format(`${name} lookup error:`, err)); return Object.assign(new Error(upstreamErrorMessage(err, `${name} lookup failed`)), { code: -32603 }) } // Transaction and UTXO lookups; upstream error text is sanitized before it leaves. function buildUtxoMethods({ encoder }) { @@ -345,19 +343,12 @@ return { const txid = validateLookupTxid(rawParams) try { if (await encoder.connector.getMempoolEntry(txid) !== null) return { status: 'mempool' } - const block = await encoder.utxoTrackerConnector.getTxBlock(txid) - return { status: block === null ? 'unknown' : 'chain' } - } catch (err) { - throw lookupFailure(err, 'Transaction status lookup', 'Transaction status lookup failed') - } + return { status: await encoder.utxoTrackerConnector.getTxBlock(txid) === null ? 'unknown' : 'chain' } + } catch (err) { throw lookupFailure(err, 'Transaction status') } }, async get_tx_block(rawParams) { const txid = validateLookupTxid(rawParams) - try { - return await encoder.utxoTrackerConnector.getTxBlock(txid) - } catch (err) { - throw lookupFailure(err, 'Transaction block lookup', 'Transaction block lookup failed') - } + try { return await encoder.utxoTrackerConnector.getTxBlock(txid) } catch (err) { throw lookupFailure(err, 'Transaction block') } }, async get_utxos(rawParams) { let address = rawParams && rawParams.address From 4ef8cf98be869eb898b6577453d1a44d79028031 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 05:15:21 -0700 Subject: [PATCH 18/22] Keep fee tiers ordered by confirmation speed Floor each faster fee quote at the preceding slower quote. Add focused controller coverage and verify the ordered payload through the real JSON-RPC app. --- src/api/json_rpc_methods.js | 3 ++ test/unit/api/estimate_fee_tier_order.test.js | 38 +++++++++++++++++++ test/unit/api/json_rpc_real_app.test.js | 1 + 3 files changed, 42 insertions(+) create mode 100644 test/unit/api/estimate_fee_tier_order.test.js diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index 964c8cd6..f87a7c41 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -120,6 +120,7 @@ return { async estimate_fee() { const targets = { low: 6, medium: 3, high: 1 }; const out = {}; + let slowerTierRate = 1; // Same test-chain ceiling createTx applies to a caller who supplies no // rate, so the quote matches what the build would actually charge. const capPerByte = XChainEncoder.suggestedFeeCeilingPerByte(NETWORK, 100000000); @@ -130,7 +131,9 @@ return { const feerate = await encoder.connector.getFeePerKilobyte(targets[tier]); // coin/kB let perVbyte = Math.max(1, Math.round(Number(feerate) * 100000)); // -> base-unit/byte if (capPerVbyte != null && perVbyte > capPerVbyte) perVbyte = capPerVbyte; + perVbyte = Math.max(perVbyte, slowerTierRate); out[tier] = perVbyte; + slowerTierRate = perVbyte; } } catch (err) { logger.error(util.format('Fee estimation error:', err)) diff --git a/test/unit/api/estimate_fee_tier_order.test.js b/test/unit/api/estimate_fee_tier_order.test.js new file mode 100644 index 00000000..f9b7fd3d --- /dev/null +++ b/test/unit/api/estimate_fee_tier_order.test.js @@ -0,0 +1,38 @@ +'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 { createJsonRpcController } = require('../../../src/api/json_rpc_methods') + +function controllerFor (rates, calls = []) { + const encoder = { + connector: { + assertNodeTier: async () => {}, + getFeePerKilobyte: async target => { + calls.push(target) + return rates[target] + } + } + } + return createJsonRpcController({ encoder, NETWORK: 'bitcoin-mainnet' }) +} + +describe('estimate_fee tier ordering', function () { + it('lifts faster tiers to the slower tier when node estimates decrease', async function () { + const calls = [] + const controller = controllerFor({ 6: 0.00005, 3: 0.00001, 1: 0.00001 }, calls) + + assert.deepStrictEqual(await controller.estimate_fee(), { low: 5, medium: 5, high: 5 }) + assert.deepStrictEqual(calls, [6, 3, 1]) + }) + + it('preserves increases while lifting a later dip', async function () { + const controller = controllerFor({ 6: 0.00001, 3: 0.00003, 1: 0.00002 }) + + assert.deepStrictEqual(await controller.estimate_fee(), { low: 1, medium: 3, high: 3 }) + }) +}) diff --git a/test/unit/api/json_rpc_real_app.test.js b/test/unit/api/json_rpc_real_app.test.js index b6d9c902..806a7388 100644 --- a/test/unit/api/json_rpc_real_app.test.js +++ b/test/unit/api/json_rpc_real_app.test.js @@ -126,6 +126,7 @@ async function routeEachPublicHandler () { } assert.deepStrictEqual(broadcast.body.result, { txid }) assert.deepStrictEqual(calls.broadcast, [txHex]) + assert.deepStrictEqual(fees.body.result, { low: 6, medium: 6, high: 6 }) assert.deepStrictEqual(calls.fees, [6, 3, 1]) assert.deepStrictEqual(cancel.body.result, { psbt: 'cancel-psbt', encoding: 'TAPROOT', cancel: true }) assert.deepStrictEqual(calls.cancel, [cancelParams]) From a8dfcf693a1ff83f54c709de64472231e609707c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 05:16:00 -0700 Subject: [PATCH 19/22] Keep fee ordering within source limit Fold the cumulative fee floor into the existing tier assignment so the controller remains at the repository line limit. --- src/api/json_rpc_methods.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index f87a7c41..1460242f 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -120,7 +120,6 @@ return { async estimate_fee() { const targets = { low: 6, medium: 3, high: 1 }; const out = {}; - let slowerTierRate = 1; // Same test-chain ceiling createTx applies to a caller who supplies no // rate, so the quote matches what the build would actually charge. const capPerByte = XChainEncoder.suggestedFeeCeilingPerByte(NETWORK, 100000000); @@ -131,9 +130,7 @@ return { const feerate = await encoder.connector.getFeePerKilobyte(targets[tier]); // coin/kB let perVbyte = Math.max(1, Math.round(Number(feerate) * 100000)); // -> base-unit/byte if (capPerVbyte != null && perVbyte > capPerVbyte) perVbyte = capPerVbyte; - perVbyte = Math.max(perVbyte, slowerTierRate); - out[tier] = perVbyte; - slowerTierRate = perVbyte; + out[tier] = Math.max(perVbyte, ...Object.values(out)); } } catch (err) { logger.error(util.format('Fee estimation error:', err)) From 3609492d14bf25daad32dbdaaa2c9f41e84cbeb1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 05:16:08 -0700 Subject: [PATCH 20/22] Use relay fallback for implausible test-chain fees Treat estimates above the sanity ceiling as unavailable on non-mainnet chains while preserving the mainnet clamp. Add focused regression coverage and refresh the suite-title pin. --- bin/pins/at1-suite-titles.json | 972 +++++++++++++----- .../blockchain_connector/fee_estimation.js | 14 +- .../06_get_fee_per_kilobyte.test.js | 6 +- ...13_test_chain_implausible_estimate.test.js | 45 + 4 files changed, 770 insertions(+), 267 deletions(-) create mode 100644 test/unit/blockchain_connector.test/13_test_chain_implausible_estimate.test.js diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index b4cca08e..917f7626 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -4,6 +4,23 @@ "XChainEncoder.createTransaction() unconfirmed filtering excludes mempool UTXOs when unconfirmed=false", "XChainEncoder.createTransaction() unconfirmed filtering keeps mempool UTXOs when unconfirmed=true" ], + "03e0966ef3abab8e": [ + "NODE_RPC_TIMEOUT parsing RPC_TIMEOUT defaults to 30000 when NODE_RPC_TIMEOUT is unset", + "NODE_RPC_TIMEOUT parsing RPC_TIMEOUT honours a valid NODE_RPC_TIMEOUT", + "NODE_RPC_TIMEOUT parsing RPC_TIMEOUT is always a positive integer, so axios always installs a timer", + "NODE_RPC_TIMEOUT parsing RPC_TIMEOUT keeps the 30000 default for a unit-suffixed NODE_RPC_TIMEOUT instead of truncating", + "NODE_RPC_TIMEOUT parsing RPC_TIMEOUT keeps the 30000 default for an empty NODE_RPC_TIMEOUT instead of NaN", + "NODE_RPC_TIMEOUT parsing envInt accepts a clean positive integer, trimmed", + "NODE_RPC_TIMEOUT parsing envInt falls back to 30000 with a warning for \" \"", + "NODE_RPC_TIMEOUT parsing envInt falls back to 30000 with a warning for \"\"", + "NODE_RPC_TIMEOUT parsing envInt falls back to 30000 with a warning for \"-5\"", + "NODE_RPC_TIMEOUT parsing envInt falls back to 30000 with a warning for \"0\"", + "NODE_RPC_TIMEOUT parsing envInt falls back to 30000 with a warning for \"1.5\"", + "NODE_RPC_TIMEOUT parsing envInt falls back to 30000 with a warning for \"30s\"", + "NODE_RPC_TIMEOUT parsing envInt falls back to 30000 with a warning for \"NaN\"", + "NODE_RPC_TIMEOUT parsing envInt falls back to 30000 with a warning for \"abc\"", + "NODE_RPC_TIMEOUT parsing envInt returns the fallback without a warning when the value is unset" + ], "0402af4b590d4603": [ "XChainEncoder create_tx options.exactInputs errors rather than silently deduplicating a repeated outpoint", "XChainEncoder create_tx options.exactInputs errors rather than silently dropping a named unconfirmed outpoint", @@ -34,6 +51,11 @@ "Encoder input validator validateActionName (encoder ACTION-name gate) rejects an action newer than the deployed decoder", "Encoder input validator validateActionName (encoder ACTION-name gate) validateAll still accepts a valid canonical action end-to-end" ], + "04aeb439e25ca4ed": [ + "bindObfuscationKey binds the key to the available outpoint moved into the first-input slot", + "bindObfuscationKey exact inputs raises OperationalError when exact mode cannot claim its first input", + "bindObfuscationKey reservation lifetime reserves the first input for the configured TTL" + ], "04c80607df6faac2": [ "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.28: BATCH semicolon-separated actions preserved", "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.29: Large BATCH forces P2SH 5+ actions in batch exceeds OP_RETURN and auto-selects P2SH", @@ -98,6 +120,16 @@ "XChainEncoder.createTransaction() OP_RETURN encoding path OP_RETURN output script starts with OP_RETURN opcode (0x6a)", "XChainEncoder.createTransaction() OP_RETURN encoding path auto-selects OP_RETURN for small data and returns correct encoding" ], + "09319c2cb6e64dd7": [ + "candidate order is deterministic on equal values every permutation of a set with ties builds one transaction", + "candidate order is deterministic on equal values the order is value descending, then txid, then vout", + "candidate order is deterministic on equal values two equal-value UTXOs in either order build the same transaction" + ], + "0c349b0447588e62": [ + "GET /status node-tier readiness returns 200 with a null mismatch when no mismatch has been recorded", + "GET /status node-tier readiness returns 503 and publishes a recorded node-tier mismatch reason", + "assertNodeTier mismatch state records the mismatch reason on the connector before refusing the node" + ], "0c5deea0cdab64b5": [ "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 counts a shared ancestor once when two inputs both descend from it", "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 dedupes repeated input txids before it asks the node", @@ -211,6 +243,13 @@ "CORS_ORIGIN allowlist parsing what a caller actually receives resolves CORS_ORIGIN `*` to the parser sentinel that cors() treats as \"any origin\"", "CORS_ORIGIN allowlist parsing what a caller actually receives sends no ACAO at all when CORS is disabled" ], + "128129d7e17e2e21": [ + "ACTION manifest conformance: encoder validateActionName gate @regression ACTION_ALIASES exactly equals the manifest aliases (and the decoder ACTION_ALIASES)", + "ACTION manifest conformance: encoder validateActionName gate @regression VALID_ACTION_NAMES exactly equals the manifest wireDecoded slice (the decoder arbiter set)", + "ACTION manifest conformance: encoder validateActionName gate @regression byte-identity to canonical manifest vendored test/fixtures/action-manifest.json is byte-identical to canonical", + "ACTION manifest conformance: encoder validateActionName gate @regression keeps settlement anchors outside the encoder ACTION-name gate", + "ACTION manifest conformance: encoder validateActionName gate @regression records BET version 4 as user-encodable" + ], "13527b5e48f4a7cf": [ "Encoder input validator validateFee null/false pass; coerces; rejects NaN, negative, over-max" ], @@ -331,6 +370,21 @@ "Chaos Category B: Input & Data Corruption B-6: Binary/NUL content in ACTION data NUL bytes in data produce valid PSBT", "Chaos Category B: Input & Data Corruption B-6: Binary/NUL content in ACTION data emoji/high-Unicode in data produce valid PSBT" ], + "1d2df4e0c840c80e": [ + "P2WSH reveal funding prices the stripped-size pad output btc: a 30-byte single-chunk reveal at 10 sat/vB pays the rate", + "P2WSH reveal funding prices the stripped-size pad output btc: a 30-byte single-chunk reveal at 100 sat/vB pays the rate", + "P2WSH reveal funding prices the stripped-size pad output btc: a 30-byte single-chunk reveal at 50 sat/vB pays the rate", + "P2WSH reveal funding prices the stripped-size pad output btc: a 470-byte single-chunk reveal at 10 sat/vB pays the rate", + "P2WSH reveal funding prices the stripped-size pad output btc: a 470-byte single-chunk reveal at 100 sat/vB pays the rate", + "P2WSH reveal funding prices the stripped-size pad output btc: a 470-byte single-chunk reveal at 50 sat/vB pays the rate", + "P2WSH reveal funding prices the stripped-size pad output ltc: a 30-byte single-chunk reveal at 10 sat/vB pays the rate", + "P2WSH reveal funding prices the stripped-size pad output ltc: a 30-byte single-chunk reveal at 100 sat/vB pays the rate", + "P2WSH reveal funding prices the stripped-size pad output ltc: a 30-byte single-chunk reveal at 50 sat/vB pays the rate", + "P2WSH reveal funding prices the stripped-size pad output ltc: a 470-byte single-chunk reveal at 10 sat/vB pays the rate", + "P2WSH reveal funding prices the stripped-size pad output ltc: a 470-byte single-chunk reveal at 100 sat/vB pays the rate", + "P2WSH reveal funding prices the stripped-size pad output ltc: a 470-byte single-chunk reveal at 50 sat/vB pays the rate", + "P2WSH reveal funding prices the stripped-size pad output with no change address the pad is priced at the largest standard output" + ], "1e9678e996275889": [ "XChainEncoder.createTransaction() payload guards (library boundary) does not warn on a canonical ACTION name", "XChainEncoder.createTransaction() payload guards (library boundary) does not warn on a known ACTION alias", @@ -347,6 +401,10 @@ "XChainEncoder.createTransaction() payload guards (library boundary) warns a library caller whose data leads with an unknown ACTION name", "XChainEncoder.createTransaction() payload guards (library boundary) warns when an unknown ACTION name arrives as a Buffer" ], + "2089fa0000e3be71": [ + "Encoder input validator validateCompressedPubKey null passes; enforces the 02/03 + 64-hex shape", + "Encoder input validator validateCompressedPubKey refuses a well-shaped key whose x is not on the curve, for both prefixes" + ], "214613c17899118f": [ "XChainEncoder.createTransaction() payment-only emits NO OP_RETURN output when there is no action payload", "XChainEncoder.createTransaction() payment-only still emits a nulldata output for a rawData-only payload", @@ -386,11 +444,6 @@ "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) refuses the p2shHash reveal flow for TAPROOT", "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) returns {commit psbt, revealPsbt, envelope} with the commit output at vout 0" ], - "2842761e884c6f3b": [ - "BlockchainConnector.getFeePerKilobyte() node RPC error detail carries the error body of an HTTP 200 answer into the thrown message", - "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", @@ -465,6 +518,10 @@ "2dd7e128b3fa1284": [ "E2E-5: UTXO, Fee, and Change Integration E2E-5.13: Custom outputs coexist with ACTION custom output value deducted from change" ], + "2f28bf0d76b56ce6": [ + "suite title comparison additive growth prints added files and titles without failing the compare CLI", + "suite title comparison additive growth prints dropped files and titles and fails the compare CLI" + ], "2f6d87eaf711d8a0": [ "broadcast_tx rejection logging logs the locally derived txid without logging raw transaction hex" ], @@ -476,6 +533,10 @@ "325536fb1b16a149": [ "E2E-5: UTXO, Fee, and Change Integration E2E-5.15: Null feePerKb triggers RPC call calls getFeePerKilobyte when feePerKb is null" ], + "331f46bf5c424942": [ + "XChainEncoder published Taproot envelope vectors matches chunk_rebalance through prepareData", + "XChainEncoder published Taproot envelope vectors matches envelope_chunking through prepareData" + ], "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", @@ -499,6 +560,14 @@ "encoder stress-sweep unit @regression MULTISIGN requires compressedPubKey validateAll throws when encoding is MULTISIGN and compressedPubKey is absent", "encoder stress-sweep unit @regression UtxoTracker pagination is bounded aborts on a repeated pagination cursor (stalled/hostile tracker)" ], + "360eb4295df65345": [ + "chain identity genesis helpers accepts an agreeing hash regardless of case", + "chain identity genesis helpers catches a same-tier cross-coin pairing", + "chain identity genesis helpers catches a testnet3 versus testnet4 pairing", + "chain identity genesis helpers fails open when no usable hash is pinned", + "chain identity genesis helpers fails open when the node reports no usable hash", + "chain identity genesis helpers reports whether the genesis pin is absent" + ], "361338400dd1a0a7": [ "REG-04: Validator Functions REG-04.11: validateP2shParams() accepts valid pair", "REG-04: Validator Functions REG-04.11: validateP2shParams() returns nulls when both absent", @@ -524,27 +593,6 @@ "REG-14: P2SH/P2WSH reveal clears the min-relay fee floor P2WSH funds a reveal with no customOutputs", "REG-14: P2SH/P2WSH reveal clears the min-relay fee floor charges the funding tx exactly the fee output byte cost it forgot before" ], - "36a860097c5c6a21": [ - "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 cap disabled: a plausible feePerKb is honored, not clamped", - "XChainEncoder fee-rate cap cap disabled: the burn backstop anchors a feePerKb-only clamp on relayfee with no estimate", - "XChainEncoder fee-rate cap cap disabled: the burn backstop clamps a feePerKb-only drain to 100x the node rate", - "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 envelope cancel: anchors the relative cap on relayfee when the node has no estimate", - "XChainEncoder fee-rate cap envelope cancel: cap disabled and no estimate, the burn backstop anchors on relayfee", - "XChainEncoder fee-rate cap envelope cancel: cap disabled, a plausible feePerKb is honored", - "XChainEncoder fee-rate cap envelope cancel: cap disabled, the burn backstop keeps a drain-shaped feePerKb off the commit", - "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" - ], "393ef05040e4ada2": [ "REG-07: Action Pipeline Regression REG-07.1: SEND baseline round-trip SEND with memo field preserved", "REG-07: Action Pipeline Regression REG-07.1: SEND baseline round-trip SEND|0|JDOG|1| encodes and decodes exactly", @@ -580,6 +628,14 @@ "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" ], + "3edfd81d1bf84723": [ + "JSON-RPC node tier gate broadcast_tx refuses a mainnet node under a testnet encoder as -32010 NODE_CHAIN_MISMATCH", + "JSON-RPC node tier gate create_envelope_cancel_tx refuses a mainnet node under a testnet encoder as -32010 NODE_CHAIN_MISMATCH", + "JSON-RPC node tier gate create_tx refuses a mainnet node under a testnet encoder as -32010 NODE_CHAIN_MISMATCH", + "JSON-RPC node tier gate create_tx with a caller-supplied feePerKb still builds on a matching testnet node", + "JSON-RPC node tier gate estimate_fee refuses a mainnet node under a testnet encoder as -32010 NODE_CHAIN_MISMATCH", + "JSON-RPC node tier gate the mainnet prelaunch refusal fires before the node tier check" + ], "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\"", @@ -608,6 +664,20 @@ "CryptoNetworks .getFirstBlock() returns 67847500 for dogecoin-testnet", "CryptoNetworks .getFirstBlock() returns 950000 for bitcoin-mainnet" ], + "41e23c0843778aa7": [ + "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES a FILE of another version", + "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES a non-FILE action (nowhere to record the marker)", + "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES a payload over the pre-compression input cap", + "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES a token-gated FILE (\u00a75.4: the field means inflate-after-decrypt)", + "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES to re-compress when the caller already declared a codec", + "encoder FILE payload compression (spec Part B) compressPayloadForAction a gated FILE passes its own COMPRESSION field through untouched", + "encoder FILE payload compression (spec Part B) compressPayloadForAction compresses a public FILE payload and appends the COMPRESSION field", + "encoder FILE payload compression (spec Part B) compressPayloadForAction emits RAW when the payload compresses beyond the ratio guard", + "encoder FILE payload compression (spec Part B) compressPayloadForAction keeps already-compressed media raw, leaving the action byte-identical", + "encoder FILE payload compression (spec Part B) compressPayloadForAction passes a payload-free action straight through", + "encoder FILE payload compression (spec Part B) compressPayloadForAction the marker and the bytes always move together", + "encoder FILE payload compression: refusal error class compressPayloadForAction marks every explicit refusal as invalid params" + ], "4405034a199528ec": [ "coverage ratchet floors enforces every declared floor, at the declared value", "coverage ratchet floors fails the job on a shortfall rather than only reporting it", @@ -618,6 +688,14 @@ "XChainEncoder.createTransaction() - feeQuote injection does not add feeQuote when amount is 0", "XChainEncoder.createTransaction() - feeQuote injection does not add feeQuote when feeQuote has no address" ], + "4498c030666a1756": [ + "input selection targets the floored fee an unfundable spend names the fee the build needs and the fee that was requested", + "input selection targets the floored fee btc caller rate 1 sat/vB: a spend the two inputs fund builds with both", + "input selection targets the floored fee btc explicit fee under the dust floor: a spend the two inputs fund builds with both", + "input selection targets the floored fee doge caller rate at the relay floor: a spend the two inputs fund builds with both", + "input selection targets the floored fee ltc caller rate 1 lit/vB: a spend the two inputs fund builds with both", + "input selection targets the floored fee ltc node estimate of 2 lit/vB: a spend the two inputs fund builds with both" + ], "4526c9e9c70c2999": [ "Custom Output Boundaries custom output value exceeding inputs total custom output > UTXO throws INSUFFICIENT_FUNDS", "Custom Output Boundaries custom output with value=0 value=0 output is rejected as a non-positive custom output", @@ -684,6 +762,9 @@ "Encoder input validator validateDataParam rejects a rawData code point above U+00FF (latin-1 would truncate it)", "Encoder input validator validateDataParam still accepts every value the wire encodings carry losslessly" ], + "4d2fee8572bf0a2f": [ + "BlockchainConnector.getFeePerKilobyte() test-chain implausible estimate uses the no-estimate fallback instead of clamping an estimate above the sanity ceiling" + ], "4dd0d30bb214ff45": [ "two-phase reveal package prefund @regression @tier1 MAX_CPFP_UPLIFT_SAT=0 turns the prefund off entirely", "two-phase reveal package prefund @regression @tier1 adds nothing when the commit already pays the target rate on its own bytes", @@ -693,6 +774,14 @@ "two-phase reveal package prefund @regression @tier1 lifts the prefund so a commit paying under the target rate still clears it as a package", "two-phase reveal package prefund @regression @tier1 never prefunds more than the commit inputs hold" ], + "4e34a41823538af8": [ + "BlockchainConnector.getFeePerKilobyte() node RPC error detail adds no suffix for a feerate -1 body or result.errors with no usable string", + "BlockchainConnector.getFeePerKilobyte() node RPC error detail carries a modern node result.errors reason into the thrown message", + "BlockchainConnector.getFeePerKilobyte() node RPC error detail carries the error body of an HTTP 200 answer into the thrown message", + "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", + "BlockchainConnector.getFeePerKilobyte() node RPC error detail still takes the testnet relay fallback for a modern no-estimate body" + ], "4e788c4ec94c6c63": [ "Encoder input validator MAX_FEE_SATOSHIS is pinned at 21,000 BTC in satoshis, not 21M BTC" ], @@ -717,16 +806,49 @@ "encoder FILE payload compression (spec Part B) constants conformance the local values are the pinned ones", "encoder FILE payload compression (spec Part B) constants conformance they equal the canonical declaration (skips without the docs sibling)" ], + "527c9a64750185e0": [ + "multi-chunk payload through a single P2SH fund tx @regression @tier1 carries every chunk as its own leg of one funding transaction", + "multi-chunk payload through a single P2SH fund tx @regression @tier1 funds a distinct script per chunk so each leg can be revealed on its own", + "multi-chunk payload through a single P2SH fund tx @regression @tier1 is deterministic for the same inputs", + "multi-chunk payload through a single P2SH fund tx @regression @tier1 refuses a payload over the compiled size ceiling instead of splitting without bound", + "multi-chunk payload through a single P2SH fund tx @regression @tier1 reveals by spending every funded leg in one transaction and keeping a value output" + ], "52961e7b06b0e39f": [ "BlockchainConnector constructor builds the correct URL from host and port", "BlockchainConnector constructor stores rpcUser and rpcPassword" ], + "542e2ed5169dba02": [ + "XChainEncoder TAPROOT envelope caller public keys must be secp256k1 points refuses an off-curve TAPROOT compressedPubKey at the envelope builder", + "XChainEncoder TAPROOT envelope caller public keys must be secp256k1 points refuses an off-curve cancel internalPubkey in both forms before any reservation or fee RPC", + "XChainEncoder TAPROOT envelope key-path cancel fee-rate cap without a node estimate caps a caller feePerKb on the relayfee anchor when the node estimate is unavailable", + "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", + "XChainEncoder TAPROOT envelope key-path cancel outpoint shares the create_tx outpoint rule accepts a decimal-string vout and an uppercase txid, building canonical forms", + "XChainEncoder TAPROOT envelope key-path cancel outpoint shares the create_tx outpoint rule keys a zero-padded vout as its integer, so it collides with create_tx", + "XChainEncoder TAPROOT envelope key-path cancel outpoint shares the create_tx outpoint rule still rejects every non-integer vout spelling as a typed commitVout error" + ], + "55106b3a8b9dd7c7": [ + "chain identity map parity with xchain-decoder matches every decoder chain key and network value" + ], "56b53ed53845ff32": [ "Chaos Category E: Resource Exhaustion E-1: Large P2WSH encoding (memory stress) 8189-byte payload (compiled 8192, at limit) with P2WSH completes in <5s", "Chaos Category E: Resource Exhaustion E-1: Large P2WSH encoding (memory stress) 8193-byte payload exceeds limit \u2192 RangeError", "Chaos Category E: Resource Exhaustion E-2: High UTXO count processing 500 legacy UTXOs: getTransactionHex called for each consumed", "Chaos Category E: Resource Exhaustion E-2: High UTXO count processing 500 segwit UTXOs, all consumed, completes in <10s" ], + "57394c6893888d79": [ + "capCallerFeeRate: operator cap against the burn backstop MAX_FEE_RATE_KB tighter than 100x: that cap binds", + "capCallerFeeRate: operator cap against the burn backstop a rate under every limit is honored unchanged", + "capCallerFeeRate: operator cap against the burn backstop both caps off: the backstop binds and capFeePerBytes stays null", + "capCallerFeeRate: operator cap against the burn backstop default multiplier 100: clamps to the cap and names the cap, as before", + "capCallerFeeRate: operator cap against the burn backstop loose MAX_FEE_RATE_KB with the multiplier off: the backstop binds", + "capCallerFeeRate: operator cap against the burn backstop multiplier 1000: the backstop binds while capFeePerBytes keeps the operator value", + "capCallerFeeRate: operator cap against the burn backstop no anchor and no cap: nothing to clamp against" + ], "57b96d6f511dbf43": [ "Encoder input validator validateEncoding accepts the valid set, null passes, unknown throws" ], @@ -775,6 +897,42 @@ "61c7893f33172ff0": [ "XChainEncoder.createTransaction() UTXO sorting (largest first) uses the largest UTXO first" ], + "61d96b51460a6591": [ + "single-transaction size estimate covers the header and segwit marker a node estimate under the relay floor is lifted to the floor rather than built under it", + "single-transaction size estimate covers the header and segwit marker btc: 10 P2WPKH inputs at 1 sat/vB pay the rate on the real vsize", + "single-transaction size estimate covers the header and segwit marker btc: 20 P2WPKH inputs at 1 sat/vB pay the rate on the real vsize", + "single-transaction size estimate covers the header and segwit marker ltc: 20 P2WPKH inputs at 5 sat/vB pay the rate on the real vsize" + ], + "6205fd9c842af35b": [ + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid bitcoin-mainnet has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid bitcoin-regtest has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid bitcoin-testnet has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid dogecoin-mainnet has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid dogecoin-regtest has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid dogecoin-testnet has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid litecoin-mainnet has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid litecoin-regtest has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid litecoin-testnet has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.1: bitcoin-regtest OP_RETURN creates valid OP_RETURN PSBT with correct dust threshold", + "E2E-6: Multi-Chain Validation E2E-6.2: dogecoin-regtest OP_RETURN creates valid OP_RETURN PSBT with correct dust threshold", + "E2E-6: Multi-Chain Validation E2E-6.3: litecoin-regtest OP_RETURN creates valid OP_RETURN PSBT with correct dust threshold", + "E2E-6: Multi-Chain Validation E2E-6.7: Bitcoin P2WSH creates P2WSH witness output on bitcoin-regtest", + "E2E-6: Multi-Chain Validation E2E-6.8: Bitcoin MULTISIGN (dust=546) multisig output at 546 sats", + "E2E-6: Multi-Chain Validation E2E-6.9: Litecoin MULTISIGN (dust=5460) multisig output at 7860 sats", + "E2E-6: Multi-Chain Validation Fee floor uses chain-specific dust Litecoin fee floor is 5460, not 546", + "E2E-6: Multi-Chain Validation bitcoin-regtest P2SH creates P2SH with output value >= chain dust threshold", + "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" + ], + "62430dae2c4e7b28": [ + "XChainEncoder Dogecoin soft-dust relay surcharge adds nothing for a payment at the soft limit", + "XChainEncoder Dogecoin soft-dust relay surcharge adds the surcharge on top of an explicit fee", + "XChainEncoder Dogecoin soft-dust relay surcharge adds the whole soft limit to the fee for a 100000-koinu payment", + "XChainEncoder Dogecoin soft-dust relay surcharge adds the whole soft limit to the fee for a 500000-koinu payment", + "XChainEncoder Dogecoin soft-dust relay surcharge adds the whole soft limit to the fee for a 999999-koinu payment", + "XChainEncoder Dogecoin soft-dust relay surcharge prefunds the surcharge for a sub-limit payment on a P2SH reveal", + "XChainEncoder Dogecoin soft-dust relay surcharge surcharges a hub feeQuote output under the soft limit" + ], "6248ca3f37b35faa": [ "singleInstanceGuard releaseLockOnSignals defaults to the real process when none is injected", "singleInstanceGuard releaseLockOnSignals deregisters itself before re-raising, or the re-raise re-enters the handler", @@ -831,19 +989,6 @@ "XChainEncoder.createTransaction() custom dust parameter does NOT override the fee floor (fee floor uses this.dustAmount)", "XChainEncoder.createTransaction() custom dust parameter overrides finalDust for MULTISIGN output value when it raises the floor" ], - "68868c37d76d5eb2": [ - "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES a FILE of another version", - "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES a non-FILE action (nowhere to record the marker)", - "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES a payload over the pre-compression input cap", - "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES a token-gated FILE (\u00a75.4: the field means inflate-after-decrypt)", - "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES to re-compress when the caller already declared a codec", - "encoder FILE payload compression (spec Part B) compressPayloadForAction a gated FILE passes its own COMPRESSION field through untouched", - "encoder FILE payload compression (spec Part B) compressPayloadForAction compresses a public FILE payload and appends the COMPRESSION field", - "encoder FILE payload compression (spec Part B) compressPayloadForAction emits RAW when the payload compresses beyond the ratio guard", - "encoder FILE payload compression (spec Part B) compressPayloadForAction keeps already-compressed media raw, leaving the action byte-identical", - "encoder FILE payload compression (spec Part B) compressPayloadForAction passes a payload-free action straight through", - "encoder FILE payload compression (spec Part B) compressPayloadForAction the marker and the bytes always move together" - ], "6a7197d4097f3c0c": [ "XChainEncoder.createTransaction(): non-segwit UTXO path calls connector.getTransactionHex for a P2PKH UTXO", "XChainEncoder.createTransaction(): non-segwit UTXO path stores the nonWitnessUtxo buffer from the hex for legacy inputs", @@ -885,6 +1030,13 @@ "REG-04: Validator Functions REG-04.13: validateChange() throws TypeError for non-string", "REG-04: Validator Functions REG-04.13: validateChange() throws TypeError for string > 100 chars" ], + "6ed81952aa49c09d": [ + "XChainEncoder P2SH-P2WPKH funding inputs builds a P2SH address given with no public key as a legacy input", + "XChainEncoder P2SH-P2WPKH funding inputs builds each input with witnessUtxo and the key's P2WPKH redeemScript", + "XChainEncoder P2SH-P2WPKH funding inputs leaves a P2SH input the key does not match as a legacy input", + "XChainEncoder P2SH-P2WPKH funding inputs prices the inputs at their signed size, so the fee lands at the requested rate", + "XChainEncoder P2SH-P2WPKH funding inputs takes the key from compressedPubKey when pubkey is the P2SH address" + ], "6f8680c56c04f0ef": [ "P2SH two-phase package prefund @regression @tier1 a confirmed commit asks the reveal for nothing", "P2SH two-phase package prefund @regression @tier1 degrades to the per-transaction fee when the commit lookup throws", @@ -896,6 +1048,44 @@ "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)" ], + "714506268452508a": [ + "mainnet prelaunch operator override ENCODER_MAINNET_PRELAUNCH_OVERRIDE=\"\" leaves the refusal in force", + "mainnet prelaunch operator override ENCODER_MAINNET_PRELAUNCH_OVERRIDE=\"0\" leaves the refusal in force", + "mainnet prelaunch operator override ENCODER_MAINNET_PRELAUNCH_OVERRIDE=\"false\" leaves the refusal in force", + "mainnet prelaunch operator override ENCODER_MAINNET_PRELAUNCH_OVERRIDE=\"maybe\" leaves the refusal in force", + "mainnet prelaunch operator override ENCODER_MAINNET_PRELAUNCH_OVERRIDE=1 lifts the refusal", + "mainnet prelaunch operator override ENCODER_MAINNET_PRELAUNCH_OVERRIDE=YES lifts the refusal", + "mainnet prelaunch operator override ENCODER_MAINNET_PRELAUNCH_OVERRIDE=on lifts the refusal", + "mainnet prelaunch operator override ENCODER_MAINNET_PRELAUNCH_OVERRIDE=true lifts the refusal", + "mainnet prelaunch operator override is read live: unsetting it restores the refusal on the same controller", + "mainnet prelaunch refusal builds a non-validator action at and after launch", + "mainnet prelaunch refusal never refuses a testnet encoder", + "mainnet prelaunch refusal refuses a non-validator action before launch as -32010 MAINNET_PRELAUNCH", + "mainnet prelaunch refusal refuses an alias of a non-validator action (TRANSFER)", + "mainnet prelaunch user actions sharing or missing the validator set builds a user format of a validator name at launch", + "mainnet prelaunch user actions sharing or missing the validator set refuses ANCHOR|4 before launch", + "mainnet prelaunch user actions sharing or missing the validator set refuses ATTEST|0 before launch", + "mainnet prelaunch user actions sharing or missing the validator set refuses ATTEST|2 before launch", + "mainnet prelaunch user actions sharing or missing the validator set refuses DELEGATE|0 before launch", + "mainnet prelaunch user actions sharing or missing the validator set refuses PRICE|1 before launch", + "mainnet prelaunch user actions sharing or missing the validator set refuses SLASH|0 before launch", + "mainnet prelaunch user actions sharing or missing the validator set refuses STAKE|0 before launch", + "mainnet prelaunch user actions sharing or missing the validator set refuses UNSTAKE|0 before launch", + "mainnet prelaunch validator actions lets ANCHOR|0 through before launch", + "mainnet prelaunch validator actions lets ANCHOR|1 through before launch", + "mainnet prelaunch validator actions lets ANCHOR|2 through before launch", + "mainnet prelaunch validator actions lets ANCHOR|3 through before launch", + "mainnet prelaunch validator actions lets ATTEST|1 through before launch", + "mainnet prelaunch validator actions lets ATTEST|3 through before launch", + "mainnet prelaunch validator actions lets ATTEST|4 through before launch", + "mainnet prelaunch validator actions lets ATTEST|5 through before launch", + "mainnet prelaunch validator actions lets ATTEST|6 through before launch", + "mainnet prelaunch validator actions lets NODEPROOF|0 through before launch", + "mainnet prelaunch validator actions lets PRICE|0 through before launch", + "mainnet prelaunch validator actions lets ROLLCALL|0 through before launch", + "mainnet prelaunch validator actions lets ROLLCALL|1 through before launch", + "mainnet prelaunch validator actions reads the name the way consensus does: trimmed and upper-cased, format optional, Buffer decoded" + ], "7174ac9d6bdce059": [ "real JSON-RPC app HTTP dispatch routes each public handler through src/api.js and the production router" ], @@ -917,6 +1107,34 @@ "envelope-cancel outpoint reservation @regression the first build's ticket still releases after a failed retry", "envelope-cancel outpoint reservation @regression three overlapping builds: an out-of-order failure frees nothing" ], + "73f78b4564a3a7e3": [ + "XChainEncoder multi-chunk P2WSH reveal headroom bitcoin-regtest 1500-byte payload: the reveal returns change at or above the output floor", + "XChainEncoder multi-chunk P2WSH reveal headroom bitcoin-regtest 1500-byte payload: the reveal still pays its target rate on its signed size", + "XChainEncoder multi-chunk P2WSH reveal headroom bitcoin-regtest 600-byte payload: the reveal returns change at or above the output floor", + "XChainEncoder multi-chunk P2WSH reveal headroom bitcoin-regtest 600-byte payload: the reveal still pays its target rate on its signed size", + "XChainEncoder multi-chunk P2WSH reveal headroom litecoin-regtest 1500-byte payload: the reveal returns change at or above the output floor", + "XChainEncoder multi-chunk P2WSH reveal headroom litecoin-regtest 1500-byte payload: the reveal still pays its target rate on its signed size", + "XChainEncoder multi-chunk P2WSH reveal headroom litecoin-regtest 600-byte payload: the reveal returns change at or above the output floor", + "XChainEncoder multi-chunk P2WSH reveal headroom litecoin-regtest 600-byte payload: the reveal still pays its target rate on its signed size" + ], + "7511bcaabf35f784": [ + "caller payment outputs respect the coin relay dust threshold a P2WSH funding call refuses a sub-dust payment before the commit is built", + "caller payment outputs respect the coin relay dust threshold btc: exactly the floor builds", + "caller payment outputs respect the coin relay dust threshold btc: one unit under the floor is refused", + "caller payment outputs respect the coin relay dust threshold btc: the encoder exposes 546 as its payment floor", + "caller payment outputs respect the coin relay dust threshold create_tx answers a sub-dust payment with -32602 before any node call", + "caller payment outputs respect the coin relay dust threshold doge: a 0.005 DOGE payment between the hard and soft limits builds", + "caller payment outputs respect the coin relay dust threshold doge: exactly the floor builds", + "caller payment outputs respect the coin relay dust threshold doge: one unit under the floor is refused", + "caller payment outputs respect the coin relay dust threshold doge: the encoder exposes 100000 as its payment floor", + "caller payment outputs respect the coin relay dust threshold ltc: exactly the floor builds", + "caller payment outputs respect the coin relay dust threshold ltc: one unit under the floor is refused", + "caller payment outputs respect the coin relay dust threshold ltc: the encoder exposes 5460 as its payment floor", + "caller payment outputs respect the coin relay dust threshold the protocol-fee output injected from feeQuote keeps its own positive-amount rule", + "caller payment outputs respect the coin relay dust threshold validateAll at the API boundary accepts an output at the floor", + "caller payment outputs respect the coin relay dust threshold validateAll at the API boundary keeps the positive-amount rule when no floor is passed", + "caller payment outputs respect the coin relay dust threshold validateAll at the API boundary refuses an output under the floor the caller passes" + ], "76410d2234a85853": [ "minimal-op canonicalization guards (empty-data shape flag-day scoped) empty-data shapes remain intentionally supported (flag-day scoped) does NOT reject empty data-only (payment-only / no-ACTION tx)", "minimal-op canonicalization guards (empty-data shape flag-day scoped) empty-data shapes remain intentionally supported (flag-day scoped) does NOT reject the deliberately-supported rawData-only shape", @@ -959,15 +1177,58 @@ "78ea596173e06da0": [ "XChainEncoder.createTransaction() P2SH encoding path (tx2: spending) creates tx2 with P2SH input and OP_RETURN marker" ], + "79c5479ff8297918": [ + "UtxoTracker.getUtxosFromAddress() tracker error text caps a long tracker message at 120 characters", + "UtxoTracker.getUtxosFromAddress() tracker error text cleans or drops a hostile string code and keeps the message", + "UtxoTracker.getUtxosFromAddress() tracker error text drops a tracker message that names an internal endpoint", + "UtxoTracker.getUtxosFromAddress() tracker error text drops an oversized tracker message to the generic text", + "UtxoTracker.getUtxosFromAddress() tracker error text keeps a normal tracker code and message unchanged", + "UtxoTracker.getUtxosFromAddress() tracker error text keeps a numeric JSON-RPC code", + "UtxoTracker.getUtxosFromAddress() tracker error text reads a non-string message as unknown error, not [object Object]", + "UtxoTracker.getUtxosFromAddress() tracker error text replaces control characters in the tracker message" + ], "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" ], + "79f70c9b2a22cdc8": [ + "XChainEncoder bare-multisig carrier dust per coin relay rate bitcoin-regtest: every multisig carrier output is 786", + "XChainEncoder bare-multisig carrier dust per coin relay rate dogecoin-regtest: every multisig carrier output is 1000000", + "XChainEncoder bare-multisig carrier dust per coin relay rate litecoin-regtest: every multisig carrier output is 7860" + ], + "7a10c5e46972a773": [ + "tracker-funded selection at the input cap preserves the range error when additional inputs could fund the transaction", + "tracker-funded selection at the input cap returns a retryable operational error for an all-confirmed selection at the cap", + "tracker-funded selection at the input cap returns the same operational error when unconfirmed outputs are excluded", + "tracker-funded selection at the input cap uses a large unconfirmed output before 1,082 confirmed dust outputs when allowed" + ], "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" ], + "7b0dd058f5e2f3a4": [ + "Security: global in-flight concurrency cap api.js wiring exempts the spec from the API key through the shared predicate, not an exact match", + "Security: global in-flight concurrency cap api.js wiring mounts a bounded reserve for the exempt readiness probes", + "Security: global in-flight concurrency cap api.js wiring mounts the gate on the app with an env-overridable cap", + "Security: global in-flight concurrency cap api.js wiring reports the gate stats so a stampede is visible to operators" + ], + "7d9862c885ce6b83": [ + "broadcast_tx node rejection codes answers a mempool duplicate as TX_ALREADY_IN_CHAIN by message", + "broadcast_tx node rejection codes answers a node rejection as -32010 NODE_REJECTED with the node code", + "broadcast_tx node rejection codes answers an already-known tx as TX_ALREADY_IN_CHAIN by node code", + "broadcast_tx node rejection codes keeps a node error without a numeric code at -32603", + "broadcast_tx node rejection codes keeps a transport fault at -32603 with no data", + "broadcast_tx node rejection codes through the real connector keeps a transport fault at -32603", + "broadcast_tx node rejection codes through the real connector propagates the node code from a node error body to -32010", + "broadcast_tx node rejection codes through the real connector propagates the node code from an HTTP 500 body" + ], + "7e10c19605f33fbd": [ + "a UTXO with no confirmations field counts as unconfirmed leaves the caller UTXO objects as they were handed over", + "a UTXO with no confirmations field counts as unconfirmed unconfirmed=false skips it and spends a confirmed output instead", + "a UTXO with no confirmations field counts as unconfirmed unconfirmed=false with only such outputs reports no spendable UTXOs", + "a UTXO with no confirmations field counts as unconfirmed unconfirmed=true still spends it" + ], "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", @@ -1026,6 +1287,9 @@ "Encoder input validator validateRawTxHex rejects hex above MAX_BROADCAST_TX_HEX_LENGTH", "Encoder input validator validateRawTxHex rejects non-strings, empty, odd-length and non-hex input" ], + "81ecf756db9b39a4": [ + "XChainEncoder leaves the caller's customOutputs array alone builds twice from one array with one fee output each time" + ], "82bd252980196b0b": [ "E2E-5: UTXO, Fee, and Change Integration E2E-5.12: RBF sequence flag rbf=false sets sequence 0xffffffff", "E2E-5: UTXO, Fee, and Change Integration E2E-5.12: RBF sequence flag rbf=true sets sequence 0xfffffffd (RBF armed, BIP68 disabled)" @@ -1035,16 +1299,17 @@ "REG-31: a shortfall caused by reserved inputs names them @regression the failed third build released its own dust claim, leaving only the two live holds", "REG-31: a shortfall caused by reserved inputs names them @regression third build after two un-broadcast builds: dust-only selection, reserved candidates named" ], - "83a0c93d5c03a6e1": [ - "Encoder input validator validateFeePerKb null/false pass; rejects non-finite and non-positive", - "Encoder input validator validateFeePerKb rejects hex, scientific, boolean, array, and object forms Number() would coerce" - ], "83ca670b30f24208": [ "XChainEncoder.createTransaction(): P2WSH tx1 (funding) P2WSH output value covers at least the dust floor", "XChainEncoder.createTransaction(): P2WSH tx1 (funding) creates P2WSH output for small data on a segwit-capable network", "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" ], + "840b30c99abe79b8": [ + "XChainEncoder fee-rate cap sign guard keeps the default multiplier for a negative or NaN MAX_FEE_RATE_MULTIPLIER", + "XChainEncoder fee-rate cap sign guard prices a node-rated build the same under a negative cap as under the defaults", + "XChainEncoder fee-rate cap sign guard treats a zero or negative MAX_FEE_RATE_KB as no absolute cap" + ], "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", @@ -1061,6 +1326,32 @@ "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" ], + "847236b040aee4d9": [ + "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 cap disabled: a plausible feePerKb is honored, not clamped", + "XChainEncoder fee-rate cap cap disabled: the burn backstop anchors a feePerKb-only clamp on relayfee with no estimate", + "XChainEncoder fee-rate cap cap disabled: the burn backstop clamps a feePerKb-only drain to 100x the node rate", + "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 envelope cancel: anchors the relative cap on relayfee when the node has no estimate", + "XChainEncoder fee-rate cap envelope cancel: cap disabled and no estimate, the burn backstop anchors on relayfee", + "XChainEncoder fee-rate cap envelope cancel: cap disabled, a plausible feePerKb is honored", + "XChainEncoder fee-rate cap envelope cancel: cap disabled, the burn backstop keeps a drain-shaped feePerKb off the commit", + "XChainEncoder fee-rate cap envelope cancel: loose multiplier, the burn backstop keeps a drain-shaped feePerKb off the commit", + "XChainEncoder fee-rate cap loose MAX_FEE_RATE_KB alone: the burn backstop clamps a 500x feePerKb", + "XChainEncoder fee-rate cap loose multiplier: a plausible feePerKb is honored, not clamped", + "XChainEncoder fee-rate cap loose multiplier: an explicit 500x fee is still refused, matching the feePerKb clamp", + "XChainEncoder fee-rate cap loose multiplier: the burn backstop clamps a 500x feePerKb to 100x the node rate", + "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" + ], "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", @@ -1109,10 +1400,32 @@ "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" ], + "8c32d904d7276991": [ + "Encoder input validator validateFeePerKb null/false pass; rejects non-finite and non-positive", + "Encoder input validator validateFeePerKb rejects a rate above the money-field ceiling on both the number and string paths", + "Encoder input validator validateFeePerKb rejects hex, scientific, boolean, array, and object forms Number() would coerce" + ], "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" ], + "90700e68ae785acd": [ + "BlockchainConnector.assertNodeTier() chain matching passes a mainnet encoder on a node reporting chain \"main\"", + "BlockchainConnector.assertNodeTier() chain matching passes a regtest encoder on a node reporting chain \"regtest\"", + "BlockchainConnector.assertNodeTier() chain matching passes a testnet encoder on a node reporting chain \"test\"", + "BlockchainConnector.assertNodeTier() chain matching passes a testnet encoder on a node reporting chain \"testnet3\"", + "BlockchainConnector.assertNodeTier() chain matching passes a testnet encoder on a node reporting chain \"testnet4\"", + "BlockchainConnector.assertNodeTier() chain matching refuses a mainnet encoder on a node reporting chain \"regtest\" with NODE_CHAIN_MISMATCH", + "BlockchainConnector.assertNodeTier() chain matching refuses a mainnet encoder on a node reporting chain \"signet\" with NODE_CHAIN_MISMATCH", + "BlockchainConnector.assertNodeTier() chain matching refuses a mainnet encoder on a node reporting chain \"test\" with NODE_CHAIN_MISMATCH", + "BlockchainConnector.assertNodeTier() chain matching refuses a regtest encoder on a node reporting chain \"testnet4\" with NODE_CHAIN_MISMATCH", + "BlockchainConnector.assertNodeTier() chain matching refuses a testnet encoder on a node reporting chain \"main\" with NODE_CHAIN_MISMATCH", + "BlockchainConnector.assertNodeTier() pass-through and caching costs no further RPC once a matching node has passed", + "BlockchainConnector.assertNodeTier() pass-through and caching lets a response with no chain field through, warning once across calls", + "BlockchainConnector.assertNodeTier() pass-through and caching lets a transport error through", + "BlockchainConnector.assertNodeTier() pass-through and caching lets an unrecognized chain string through", + "BlockchainConnector.assertNodeTier() pass-through and caching skips the check when no consensus network was given" + ], "920e3050fd4107ed": [ "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.14: AIRDROP list index reference preserved", "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.15: DIVIDEND two-tick relationship preserved", @@ -1155,11 +1468,6 @@ "REG-18: chained sends reserve caller-supplied inputs @regression the measured shape: a second send re-supplying the spent input is refused, not rebuilt", "REG-18: chained sends reserve caller-supplied inputs @regression three chained sends over a fresh view each produce three DISTINCT transactions" ], - "9856f6ee77509f9c": [ - "Security: global in-flight concurrency cap api.js wiring mounts a bounded reserve for the exempt readiness probes", - "Security: global in-flight concurrency cap api.js wiring mounts the gate on the app with an env-overridable cap", - "Security: global in-flight concurrency cap api.js wiring reports the gate stats so a stampede is visible to operators" - ], "990dee7527465c8e": [ "XChainEncoder reveal: p2shHash must be the id of p2shHex accepts an uppercase p2shHash that names the same funding tx", "XChainEncoder reveal: p2shHash must be the id of p2shHex rejects a P2SH reveal whose p2shHash is not the funding tx id", @@ -1186,6 +1494,22 @@ "openrpc.json method coverage extracts a sane validateAll param list", "openrpc.json method coverage spec methods === controller methods" ], + "99abb4fef6f98fbc": [ + "REG-32: an unmarked TypeError or RangeError from the build is an internal error create_envelope_cancel_tx answers -32603, generic, for a native RangeError", + "REG-32: an unmarked TypeError or RangeError from the build is an internal error create_envelope_cancel_tx answers -32603, generic, for a native TypeError", + "REG-32: an unmarked TypeError or RangeError from the build is an internal error create_tx answers -32603, generic, for a native RangeError", + "REG-32: an unmarked TypeError or RangeError from the build is an internal error create_tx answers -32603, generic, for a native TypeError", + "REG-32: an unmarked TypeError or RangeError from the build is an internal error release_inputs answers -32603, generic, for a native RangeError", + "REG-32: an unmarked TypeError or RangeError from the build is an internal error release_inputs answers -32603, generic, for a native TypeError", + "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 caller fault with -32602 keeps the reason for an explicit compress request on a non-FILE action", + "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" + ], "99f2a27c2274c9bc": [ "TxSizeEstimator .estimateInputSize(utxo) Legacy inputs (nonWitnessUtxo) returns 180 for P2PKH nonWitnessUtxo", "TxSizeEstimator .estimateInputSize(utxo) Legacy inputs (nonWitnessUtxo) returns 289 for P2SH nonWitnessUtxo", @@ -1311,6 +1635,61 @@ "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" ], + "9fbc06b5565c3384": [ + "firstNonLatin1 returns minus one when every character fits in Latin-1", + "firstNonLatin1 returns the index of the first character outside Latin-1", + "parseSatoshiAmount rejects negative and non-integer values", + "parseSatoshiAmount rejects values above the unsigned 64-bit maximum", + "parseSatoshiAmount requires allowBig for a bigint above the safe integer limit", + "parseSatoshiAmount returns an exact bigint for a large decimal string with allowBig", + "parseSatoshiAmount returns numbers for safe integer strings and bigints", + "toExactInt accepts exact integer numbers and decimal strings", + "toExactInt rejects fractional and non-numeric values", + "validateOptionalBoolean passes through omitted values and booleans", + "validateOptionalBoolean rejects a string false with a precise coercion warning" + ], + "a0863ade61747ae3": [ + "Security: validateAll end-to-end rejects a hostile field outright and never returns attacker strings raw", + "Security: validateAll end-to-end rejects a request object that is not an object", + "Security: validateCombinedDataLength (payload-size DoS guard) accepts a payload at exactly the limit", + "Security: validateCombinedDataLength (payload-size DoS guard) counts multi-byte UTF-8 by bytes, not characters (no smuggling past the cap)", + "Security: validateCombinedDataLength (payload-size DoS guard) includes rawData (Latin-1 bytes) in the combined total", + "Security: validateCombinedDataLength (payload-size DoS guard) rejects a payload one byte over the limit", + "Security: validateCustomOutputs / validateFeeQuote rejects a feeQuote with non-positive or over-max amount (via validateAll, feeQuote is internal-only)", + "Security: validateCustomOutputs / validateFeeQuote rejects an over-limit custom-output set", + "Security: validateCustomOutputs / validateFeeQuote rejects custom output with over-length address or negative value", + "Security: validateEncoding accepts each canonical encoding exactly", + "Security: validateEncoding rejects an unknown/garbage encoding (no injection into the switch)", + "Security: validateFee / validateFeePerKb / validateDust rejects a fully non-numeric fee", + "Security: validateFee / validateFeePerKb / validateDust rejects a numeric-prefixed fee string outright (no truncated passthrough)", + "Security: validateFee / validateFeePerKb / validateDust rejects an over-max feePerKb (parity with the fee ceiling)", + "Security: validateFee / validateFeePerKb / validateDust rejects negative and over-max fee", + "Security: validateFee / validateFeePerKb / validateDust rejects negative dust", + "Security: validateFee / validateFeePerKb / validateDust rejects non-finite and non-positive feePerKb", + "Security: validateFee / validateFeePerKb / validateDust rejects over-max dust (parity with the fee ceiling)", + "Security: validateP2shParams (paired-secret integrity) rejects a non-64-hex p2shHash", + "Security: validateP2shParams (paired-secret integrity) rejects supplying only one of p2shHash / p2shHex", + "Security: validateP2shParams (paired-secret integrity) returns nulls when both omitted", + "Security: validatePubkey / validateChange / validateCompressedPubKey passes null through (optional param) without throwing", + "Security: validatePubkey / validateChange / validateCompressedPubKey rejects a compressedPubKey that is not 02/03 + 64 hex", + "Security: validatePubkey / validateChange / validateCompressedPubKey rejects an over-length / injection-shaped change address", + "Security: validatePubkey / validateChange / validateCompressedPubKey rejects an over-length pubkey (shares the validateAddress 100-char bound)", + "Security: validatePubkey / validateChange / validateCompressedPubKey rejects non-string and empty pubkey", + "Security: validateUtxoArray / validateUtxoEntry accepts exactly MAX_UTXO_COUNT (off-by-one upper edge)", + "Security: validateUtxoArray / validateUtxoEntry rejects a malformed (non-hex / odd-length) scriptPubKey", + "Security: validateUtxoArray / validateUtxoEntry rejects a non-array utxos", + "Security: validateUtxoArray / validateUtxoEntry rejects a numeric-prefixed injection value outright (never coerces trailing garbage)", + "Security: validateUtxoArray / validateUtxoEntry rejects a txid that is not 64 hex chars (injection-shaped)", + "Security: validateUtxoArray / validateUtxoEntry rejects an empty/missing scriptPubKey", + "Security: validateUtxoArray / validateUtxoEntry rejects an over-limit UTXO set (DoS via huge input)", + "Security: validateUtxoArray / validateUtxoEntry rejects array/null entries masquerading as objects (prototype confusion)", + "Security: validateUtxoArray / validateUtxoEntry rejects negative / non-integer vout and value", + "Security: validateUtxoArray / validateUtxoEntry rejects non-exact numeric amounts (scientific notation, decimals, hex)", + "Security: validation integrity (regex anchors + set membership) accepts each canonical encoding by exact name and rejects the empty string", + "Security: validation integrity (regex anchors + set membership) anchors the 64-hex txid regex (rejects 64 hex embedded in a longer string)", + "Security: validation integrity (regex anchors + set membership) anchors the compressedPubKey regex (rejects leading and trailing junk)", + "Security: validation integrity (regex anchors + set membership) anchors the p2shHash regex the same way" + ], "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", @@ -1394,27 +1773,6 @@ "paginated utxo fetch is one chain-state snapshot @regression refuses when the reorg counter moves at an unchanged height", "paginated utxo fetch is one chain-state snapshot @regression still merges for a tracker that publishes no per-page sync sibling" ], - "a845505c93fa3021": [ - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid bitcoin-mainnet has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid bitcoin-regtest has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid bitcoin-testnet has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid dogecoin-mainnet has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid dogecoin-regtest has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid dogecoin-testnet has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid litecoin-mainnet has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid litecoin-regtest has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid litecoin-testnet has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.1: bitcoin-regtest OP_RETURN creates valid OP_RETURN PSBT with correct dust threshold", - "E2E-6: Multi-Chain Validation E2E-6.2: dogecoin-regtest OP_RETURN creates valid OP_RETURN PSBT with correct dust threshold", - "E2E-6: Multi-Chain Validation E2E-6.3: litecoin-regtest OP_RETURN creates valid OP_RETURN PSBT with correct dust threshold", - "E2E-6: Multi-Chain Validation E2E-6.7: Bitcoin P2WSH creates P2WSH witness output on bitcoin-regtest", - "E2E-6: Multi-Chain Validation E2E-6.8: Bitcoin MULTISIGN (dust=546) multisig output at 546 sats", - "E2E-6: Multi-Chain Validation E2E-6.9: Litecoin MULTISIGN (dust=5460) multisig output at 7860 sats", - "E2E-6: Multi-Chain Validation Fee floor uses chain-specific dust Litecoin fee floor is 5460, not 546", - "E2E-6: Multi-Chain Validation bitcoin-regtest P2SH creates P2SH with output value >= chain dust threshold", - "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" - ], "aa6df06a6ffcd257": [ "Security: rate-limit refusal counter/logger limitedHandler logs nothing more for refusals inside the same window", "Security: rate-limit refusal counter/logger limitedHandler logs one line at the first refusal of a window, with count 1", @@ -1424,12 +1782,20 @@ "Security: rate-limit refusal counter/logger src/api.js wiring routes the limiter through limitedHandler and carries no bare message: option", "Security: rate-limit refusal counter/logger wired into a real express-rate-limit instance carries the draft-6 RateLimit-Policy/Limit and Retry-After headers on the 429 this handler sends" ], - "ab75df16ddef33d7": [ - "encoder crash handlers a broken logger cannot swallow the exit", - "encoder crash handlers a non-Error rejection reason still yields a readable record", - "encoder crash handlers an uncaught exception emits one CRASH record and exits non-zero", - "encoder crash handlers an unhandled rejection emits CRASH and lets the process continue", - "encoder crash handlers api.js installs them from the entry-point guard, not at module scope" + "ac90eb5a2fb0989f": [ + "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2SH 100-byte payload: a reveal of a different same-length payload is refused", + "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2SH 100-byte payload: a reveal of the funded payload is built", + "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2SH 1500-byte payload: a reveal of a different same-length payload is refused", + "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2SH 1500-byte payload: a reveal of the funded payload is built", + "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2SH 600-byte payload: a reveal of a different same-length payload is refused", + "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2SH 600-byte payload: a reveal of the funded payload is built", + "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2WSH 100-byte payload: a reveal of a different same-length payload is refused", + "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2WSH 100-byte payload: a reveal of the funded payload is built", + "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2WSH 1500-byte payload: a reveal of a different same-length payload is refused", + "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2WSH 1500-byte payload: a reveal of the funded payload is built", + "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2WSH 600-byte payload: a reveal of a different same-length payload is refused", + "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2WSH 600-byte payload: a reveal of the funded payload is built", + "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2WSH: a reveal whose payload needs more chunks than were funded is refused" ], "aec434dabffb9884": [ "errorSanitize.safeUpstreamReason caps length and strips control characters", @@ -1460,6 +1826,16 @@ "UTXO Value Boundaries string-typed UTXO values (parseInt coercion) \"1000000\" string value \u2192 treated as 1000000 sats correctly", "UTXO Value Boundaries unconfirmed filtering exhaustion all UTXOs mempool + unconfirmed=false \u2192 falls to UtxoTracker" ], + "b0f5daa791aa1a25": [ + "encoder crash handlers a broken logger cannot swallow the exit", + "encoder crash handlers a consensus-pin mismatch at module load leaves a CRASH record, not a bare stack dump", + "encoder crash handlers a non-Error rejection reason still yields a readable record", + "encoder crash handlers an uncaught exception emits one CRASH record and exits non-zero", + "encoder crash handlers an unhandled rejection emits CRASH and lets the process continue", + "encoder crash handlers an unknown NETWORK at module load leaves a CRASH record, not a bare stack dump", + "encoder crash handlers api.js installs the handlers before it builds the singleton encoder", + "encoder crash handlers api.js installs them from the entry-point guard, not at module scope" + ], "b2c94f7368f553aa": [ "TxSizeEstimator Boundaries conservative fallback in pipeline missing UTXO data uses 350-byte fallback \u2192 fee includes input estimate", "TxSizeEstimator Boundaries estimateInputSize unknown script type returns 350-byte fallback for unrecognized script", @@ -1484,6 +1860,12 @@ "XChainEncoder.isSegwitUTXO() returns true for P2WPKH (version 0, 20-byte hash)", "XChainEncoder.isSegwitUTXO() returns true for P2WSH (version 0, 32-byte hash)" ], + "b3bb61858e37f56c": [ + "Encoder input validator validateAll unknown top-level params keeps the allow-list equal to the published create_tx params", + "Encoder input validator validateAll unknown top-level params refuses each misspelled money-path setting and names the key", + "Encoder input validator validateAll unknown top-level params reports the unknown key ahead of any other field error", + "Encoder input validator validateAll unknown top-level params still accepts every documented param, null values included" + ], "b3f82aa329a90693": [ "XChainEncoder.createTransaction(): P2WSH tx2 (spending) creates P2WSH input with witnessScript and OP_RETURN marker", "XChainEncoder.createTransaction(): P2WSH tx2 (spending) throws RangeError when p2shHex has no output at the expected voutPsbtIndex", @@ -1498,6 +1880,13 @@ "Encoder input validator validateAll runs the combined-length pre-check on rawData-only requests", "Encoder input validator validateAll throws when params is not an object" ], + "b50f47cceb49ac60": [ + "Validator messages truncate caller strings leaves a short value whole and marks a cut one with its length", + "Validator messages truncate caller strings names the type of a non-string encoding instead of [object Object]", + "Validator messages truncate caller strings truncates an oversized all-digit satoshi amount, with and without allowBig", + "Validator messages truncate caller strings truncates an oversized encoding, fee, options key and boolean string", + "Validator messages truncate caller strings truncates an oversized unknown create_tx param name" + ], "b66602bc925af7a5": [ "Change Address Boundaries changeSatoshis = 0 (exact spend) does not throw even without change address when changeSatoshis = 0", "Change Address Boundaries changeSatoshis = 0 (exact spend) no change output added when change is exactly 0", @@ -1519,9 +1908,35 @@ "BlockchainConnector.sendRawTransaction() maxfeerate retry propagates the retry failure if the maxfeerate=0 retry is also rejected", "BlockchainConnector.sendRawTransaction() maxfeerate retry retries once with maxfeerate=0 on the fee-cap error when chain is regtest" ], + "b7508c7d593792ce": [ + "API-key gate exempts every spec request shape @regression serves the spec without a key for GET, HEAD, a trailing slash and any case", + "API-key gate exempts every spec request shape @regression still answers 401 without a key everywhere else, /status included", + "isSpecRequest matches only the spec route accepts GET and HEAD on the spec path in any case, with or without a trailing slash", + "isSpecRequest matches only the spec route leaves the probe predicate covering /status as before", + "isSpecRequest matches only the spec route refuses /status, other methods and lookalike paths" + ], "b7598b5920b35f20": [ "Encoder input validator validateAddress accepts a valid string (incl. exactly 100 chars); rejects empty, non-string, and over-length" ], + "b8ca9bc2aab3b6ff": [ + "Encoder input validator validatePubkey passes null/valid through, throws on bad input", + "Encoder input validator validatePubkey refuses an uncompressed key by its form, not as an oversized address" + ], + "b906f7728955c7e9": [ + "BlockchainConnector.sendRawTransaction() JSON.stringify fallback in HTTP-500 error body when error.message is absent", + "BlockchainConnector.sendRawTransaction() falls back to JSON.stringify when error has no message", + "BlockchainConnector.sendRawTransaction() node code carries nodeCode from an HTTP 500 error body", + "BlockchainConnector.sendRawTransaction() node code carries the node RPC code as nodeCode on a rejection", + "BlockchainConnector.sendRawTransaction() node code leaves nodeCode unset on a transport fault", + "BlockchainConnector.sendRawTransaction() rethrows transport errors that have no response body", + "BlockchainConnector.sendRawTransaction() returns the txid on success", + "BlockchainConnector.sendRawTransaction() sends sendrawtransaction with the tx hex as param", + "BlockchainConnector.sendRawTransaction() surfaces bitcoind HTTP-500 error body when axios throws", + "BlockchainConnector.sendRawTransaction() throws generic error when result is missing and no error", + "BlockchainConnector.sendRawTransaction() throws when JSON-RPC response contains an error field", + "BlockchainConnector.sendRawTransaction() uses error.message from JSON-RPC error when present", + "BlockchainConnector.sendRawTransaction() uses the message from error.response.data.error when code is -26" + ], "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", @@ -1545,14 +1960,6 @@ "Category B: Encoding Type Integration B-7: Forced encoding override uses P2SH when explicitly requested despite data fitting OP_RETURN", "Category B: Encoding Type Integration B-8: Oversized OP_RETURN rejected rejects forced OP_RETURN when data exceeds a single output" ], - "babd2db450e2e6a1": [ - "ACTION manifest conformance: encoder validateActionName gate @regression ACTION_ALIASES exactly equals the manifest aliases (and the decoder ACTION_ALIASES)", - "ACTION manifest conformance: encoder validateActionName gate @regression VALID_ACTION_NAMES exactly equals the manifest wireDecoded slice (the decoder arbiter set)", - "ACTION manifest conformance: encoder validateActionName gate @regression byte-identity to canonical manifest vendored test/fixtures/action-manifest.json is byte-identical to canonical" - ], - "bae2f2e4bfcbbd48": [ - "Encoder input validator validatePubkey passes null/valid through, throws on bad input" - ], "bb074808e7081443": [ "E2E-7: Complex Parameter & Edge Case Handling E2E-7.10: DISPENSER with all fields all 8 dispenser fields including trailing zeros preserved", "E2E-7: Complex Parameter & Edge Case Handling E2E-7.11: ORDER with zero expiration expiration=0 (no expiry) preserved", @@ -1589,6 +1996,18 @@ "singleInstanceGuard assertSingleInstance throws on non-integer values", "singleInstanceGuard assertSingleInstance throws when ENCODER_REPLICAS > 1" ], + "be87ec7e82b5c79d": [ + "caller pubkey: curve check and caller-input refusals a P2SH build with an off-curve pubkey and caller utxos is refused before any output is built", + "caller pubkey: curve check and caller-input refusals resolveCallerAddress refuses an off-curve key and an unresolvable identity", + "caller pubkey: curve check and caller-input refusals resolveCallerAddress still passes an on-network address through, Taproot included", + "caller pubkey: curve check and caller-input refusals resolveCallerHash160 refuses an off-curve key instead of hashing it into the chunk gate", + "caller pubkey: curve check and caller-input refusals resolveCallerHash160 types an unresolvable identity as caller input", + "caller pubkey: curve check and caller-input refusals uses a fixture key that really is off the curve and passes the shape checks", + "caller pubkey: curve check and caller-input refusals validatePubkey refuses an off-curve compressed key and keeps a real one", + "caller pubkey: the tracker lane answers an unresolvable identity with -32602 a hex string that is no pubkey and no address", + "caller pubkey: the tracker lane answers an unresolvable identity with -32602 a mainnet address on regtest", + "caller pubkey: the tracker lane answers an unresolvable identity with -32602 a regtest address and a compressed pubkey hex still reach the tracker and build" + ], "c08315d45c994876": [ "XChainEncoder.createTransaction() no UTXOs available falls back to utxoTracker when utxos param is null", "XChainEncoder.createTransaction() no UTXOs available resolves a raw pubkey (not an address) to P2WPKH before querying the tracker on a segwit network", @@ -1648,18 +2067,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)" ], - "c5c3639ee7e9a648": [ - "XChainEncoder TAPROOT envelope key-path cancel fee-rate cap without a node estimate caps a caller feePerKb on the relayfee anchor when the node estimate is unavailable", - "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", - "XChainEncoder TAPROOT envelope key-path cancel outpoint shares the create_tx outpoint rule accepts a decimal-string vout and an uppercase txid, building canonical forms", - "XChainEncoder TAPROOT envelope key-path cancel outpoint shares the create_tx outpoint rule keys a zero-padded vout as its integer, so it collides with create_tx", - "XChainEncoder TAPROOT envelope key-path cancel outpoint shares the create_tx outpoint rule still rejects every non-integer vout spelling as a typed commitVout error" - ], "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", @@ -1678,6 +2085,10 @@ "Encoding Chunk Boundaries: Full Pipeline P2WSH real-world boundary (bitcoin-regtest only) 474-char data (compiled=477) \u2192 2 P2WSH outputs (crosses chunk boundary)", "Encoding Chunk Boundaries: Full Pipeline P2WSH real-world boundary (bitcoin-regtest only) small P2WSH data (500 chars) works correctly" ], + "c902501b3d758dbd": [ + "caller-input errors carry the -32602 marker flags a class passed by reference, and ignores comments, strings and instanceof", + "caller-input errors carry the -32602 marker src raises no bare TypeError or RangeError, only the Param classes or a plain Error" + ], "c91ea7fe4f2edf18": [ "E2E-8: Error Handling & Negative Tests E2E-8.10: Null data parameter handles null data gracefully", "E2E-8: Error Handling & Negative Tests E2E-8.11: Negative fee value does not produce negative-value outputs", @@ -1858,15 +2269,6 @@ "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", @@ -1965,6 +2367,46 @@ "Chaos Category D: Arithmetic & State Corruption D-4: UTXO array mutation across calls createTransaction mutates the caller's utxos array in-place", "Chaos Category D: Arithmetic & State Corruption D-4: UTXO array mutation across calls second call with same pre-sorted array still succeeds" ], + "dec04c307d2060b7": [ + "UTXO_TRACKER_PROFILE=remote fails closed UtxoTracker.assertTrackerReady types a transport failure in the remote profile", + "UTXO_TRACKER_PROFILE=remote fails closed classifies an absent sync as SYNC_MISSING only in the remote profile", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a empty status tracker with UTXO_TRACKER_SYNC_MISSING", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a halted tracker with UTXO_TRACKER_HALTED", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a not ready tracker with UTXO_TRACKER_NOT_READY", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a stale tracker with UTXO_TRACKER_STALE", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a status without lag tracker with UTXO_TRACKER_SYNC_MISSING", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a unreachable tracker with UTXO_TRACKER_UNREACHABLE", + "UTXO_TRACKER_PROFILE=remote fails closed keeps the default profile failing open on a missing sync field", + "UTXO_TRACKER_PROFILE=remote fails closed passes a healthy remote tracker", + "UTXO_TRACKER_PROFILE=remote fails closed refuses a get_utxos response that carries no sync field", + "UTXO_TRACKER_PROFILE=remote fails closed refuses construction without a tracker url or port", + "UTXO_TRACKER_PROFILE=remote fails closed resolves the profile and rejects unknown values", + "UTXO_TRACKER_PROFILE=remote fails closed sends the registered lag and heights on a remote STALE for an unasserted synced", + "UTXO_TRACKER_PROFILE=remote fails closed wraps a get_utxos transport failure as UNREACHABLE and leaks no host", + "classifyTrackerFreshness(): the halt reason is tracker-authored bounds a long halt reason and keeps an absent one null", + "classifyTrackerFreshness(): the halt reason is tracker-authored collapses a halt reason carrying an endpoint in both the message and the details", + "classifyTrackerFreshness(): the single tracker-freshness verdict does not mutate the sync object it is handed", + "classifyTrackerFreshness(): the single tracker-freshness verdict fails open on every field it is not explicitly told is bad", + "classifyTrackerFreshness(): the single tracker-freshness verdict names the halt, not the staleness, when a halted tracker is ALSO stale", + "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a halted tracker even at lag 0, and carries the halt reason", + "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a lag above the supplied ceiling and names the ceiling it used", + "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a tracker that de-asserts synced, whatever the lag", + "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a tracker whose mempool has not reconverged", + "classifyTrackerFreshness(): the single tracker-freshness verdict refuses an orphaned view (tracker committed above the node) and says so", + "classifyTrackerFreshness(): the single tracker-freshness verdict reports no refusal, and present:false, for a tracker with no freshness surface", + "classifyTrackerFreshness(): the single tracker-freshness verdict separates the tracker's positive synced claim from the refusal verdict", + "classifyTrackerFreshness(): the single tracker-freshness verdict serves at exactly the ceiling (lag == max is not \"above\")", + "classifyTrackerFreshness(): the single tracker-freshness verdict takes the ceiling from its argument, not from a baked-in constant", + "classifyTrackerFreshness(): the single tracker-freshness verdict treats a non-numeric lag as unknown rather than comparing it", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: halted", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: healthy", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: lag at the ceiling", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: lag one over the ceiling", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: mempool not reconverged", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: orphaned view", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: tracker de-asserts synced", + "create_tx and health() reach the same verdict on the same tracker @regression reports the halt and the mempool state alongside the verdict" + ], "df4be611311f4206": [ "REG-04: Validator Functions REG-04.14: validateAll() integration rejects non-boolean rbf and unconfirmed, passes real booleans through", "REG-04: Validator Functions REG-04.14: validateAll() integration returns sanitized object with all fields for valid input", @@ -1990,6 +2432,39 @@ "BlockchainConnector.getNetworkInfo() throws when result is missing from response", "BlockchainConnector.getNetworkInfo() wraps axios transport errors" ], + "e1b10cc67b1fee13": [ + "BlockchainConnector.getFeePerKilobyte() does NOT fall back to the relayfee floor on mainnet", + "BlockchainConnector.getFeePerKilobyte() falls back to 10x the relayfee floor on testnet when estimatesmartfee has no data", + "BlockchainConnector.getFeePerKilobyte() falls back to the regtest default when relayfee is unavailable", + "BlockchainConnector.getFeePerKilobyte() honours FEE_NO_ESTIMATE_RELAY_MULTIPLIER for the no-estimate fallback", + "BlockchainConnector.getFeePerKilobyte() ignores a non-positive or unparseable multiplier and keeps the default", + "BlockchainConnector.getFeePerKilobyte() no-estimate reported as an RPC error rather than feerate:-1 does NOT invent a rate on mainnet when estimatesmartfee throws", + "BlockchainConnector.getFeePerKilobyte() no-estimate reported as an RPC error rather than feerate:-1 falls back to 10x the relayfee floor on testnet when estimatesmartfee throws", + "BlockchainConnector.getFeePerKilobyte() no-estimate reported as an RPC error rather than feerate:-1 keeps a node-down failure a failure", + "BlockchainConnector.getFeePerKilobyte() no-estimate reported as an RPC error rather than feerate:-1 still rejects on testnet when the node reports no relayfee either", + "BlockchainConnector.getFeePerKilobyte() rethrows transport errors", + "BlockchainConnector.getFeePerKilobyte() returns feerate when present (non-regtest)", + "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate clamps a spiked mainnet estimate to the coin-default ceiling and logs loudly", + "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate honours FEE_ESTIMATE_SANITY_CEILING as an override", + "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate ignores a non-positive or unparseable override and keeps the coin default", + "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate passes through an estimate under the ceiling unchanged", + "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate treats a spiked testnet estimate as unavailable and uses the relayfee fallback", + "BlockchainConnector.getFeePerKilobyte() sends estimatesmartfee with the blocksNumber param (non-regtest)", + "BlockchainConnector.getFeePerKilobyte() still prefers the estimate over the relayfee floor on testnet when one exists", + "BlockchainConnector.getFeePerKilobyte() throws on testnet when neither an estimate nor a relayfee is available", + "BlockchainConnector.getFeePerKilobyte() throws when feerate is missing and chain is not regtest", + "BlockchainConnector.getFeePerKilobyte() throws when result is entirely missing (non-regtest)", + "BlockchainConnector.getFeePerKilobyte() uses the relayfee floor on regtest and never consults estimatesmartfee" + ], + "e29fba7018c99cf3": [ + "XChainEncoder stripped-size floor on single-transaction builds bitcoin-regtest: builds a one-input OP_RETURN-only transaction, which clears 65 bytes", + "XChainEncoder stripped-size floor on single-transaction builds leaves a build that returns change untouched by the floor", + "XChainEncoder stripped-size floor on single-transaction builds litecoin-regtest ADDRESS|0|1|0: refuses an OP_RETURN-only build its one input cannot lift over 82 bytes", + "XChainEncoder stripped-size floor on single-transaction builds litecoin-regtest ADDRESS|0|1|0: takes a second input to lift an OP_RETURN-only build over 82 bytes", + "XChainEncoder stripped-size floor on single-transaction builds litecoin-regtest BROADCAST|0|hi: refuses an OP_RETURN-only build its one input cannot lift over 82 bytes", + "XChainEncoder stripped-size floor on single-transaction builds litecoin-regtest BROADCAST|0|hi: takes a second input to lift an OP_RETURN-only build over 82 bytes", + "XChainEncoder stripped-size floor on single-transaction builds litecoin-regtest: builds a full sweep to one p2wpkh payment, 82 bytes at the 82-byte floor" + ], "e349cec537f9beb2": [ "assertEnvelopeRecognized() FAIL-CLOSES when the tip is unknowable, rather than assuming", "assertEnvelopeRecognized() REFUSES below the activation height, naming the shortfall", @@ -2012,45 +2487,6 @@ "XChainEncoder P2SH reveal headroom scales the leg with the fee rate and with the payload size", "XChainEncoder P2SH reveal headroom the DOGE reveal is no longer a full burn: it leaves a >= dust output to the caller" ], - "e3f640b2cf4c0689": [ - "UTXO_TRACKER_PROFILE=remote fails closed UtxoTracker.assertTrackerReady types a transport failure in the remote profile", - "UTXO_TRACKER_PROFILE=remote fails closed classifies an absent sync as SYNC_MISSING only in the remote profile", - "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a empty status tracker with UTXO_TRACKER_SYNC_MISSING", - "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a halted tracker with UTXO_TRACKER_HALTED", - "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a not ready tracker with UTXO_TRACKER_NOT_READY", - "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a stale tracker with UTXO_TRACKER_STALE", - "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a status without lag tracker with UTXO_TRACKER_SYNC_MISSING", - "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a unreachable tracker with UTXO_TRACKER_UNREACHABLE", - "UTXO_TRACKER_PROFILE=remote fails closed keeps the default profile failing open on a missing sync field", - "UTXO_TRACKER_PROFILE=remote fails closed passes a healthy remote tracker", - "UTXO_TRACKER_PROFILE=remote fails closed refuses a get_utxos response that carries no sync field", - "UTXO_TRACKER_PROFILE=remote fails closed refuses construction without a tracker url or port", - "UTXO_TRACKER_PROFILE=remote fails closed resolves the profile and rejects unknown values", - "UTXO_TRACKER_PROFILE=remote fails closed wraps a get_utxos transport failure as UNREACHABLE and leaks no host", - "classifyTrackerFreshness(): the halt reason is tracker-authored bounds a long halt reason and keeps an absent one null", - "classifyTrackerFreshness(): the halt reason is tracker-authored collapses a halt reason carrying an endpoint in both the message and the details", - "classifyTrackerFreshness(): the single tracker-freshness verdict does not mutate the sync object it is handed", - "classifyTrackerFreshness(): the single tracker-freshness verdict fails open on every field it is not explicitly told is bad", - "classifyTrackerFreshness(): the single tracker-freshness verdict names the halt, not the staleness, when a halted tracker is ALSO stale", - "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a halted tracker even at lag 0, and carries the halt reason", - "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a lag above the supplied ceiling and names the ceiling it used", - "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a tracker that de-asserts synced, whatever the lag", - "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a tracker whose mempool has not reconverged", - "classifyTrackerFreshness(): the single tracker-freshness verdict refuses an orphaned view (tracker committed above the node) and says so", - "classifyTrackerFreshness(): the single tracker-freshness verdict reports no refusal, and present:false, for a tracker with no freshness surface", - "classifyTrackerFreshness(): the single tracker-freshness verdict separates the tracker's positive synced claim from the refusal verdict", - "classifyTrackerFreshness(): the single tracker-freshness verdict serves at exactly the ceiling (lag == max is not \"above\")", - "classifyTrackerFreshness(): the single tracker-freshness verdict takes the ceiling from its argument, not from a baked-in constant", - "classifyTrackerFreshness(): the single tracker-freshness verdict treats a non-numeric lag as unknown rather than comparing it", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: halted", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: healthy", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: lag at the ceiling", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: lag one over the ceiling", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: mempool not reconverged", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: orphaned view", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: tracker de-asserts synced", - "create_tx and health() reach the same verdict on the same tracker @regression reports the halt and the mempool state alongside the verdict" - ], "e4c3435f9bc2b0af": [ "Category F: Multi-Chain Network Configs CryptoNetworks completeness bitcoin-mainnet returns valid config with dustThreshold", "Category F: Multi-Chain Network Configs CryptoNetworks completeness bitcoin-regtest returns valid config with dustThreshold", @@ -2075,21 +2511,6 @@ "Category F: Multi-Chain Network Configs litecoin-regtest: basic PSBT construction creates valid P2SH transaction", "Category F: Multi-Chain Network Configs litecoin-regtest: basic PSBT construction uses correct dustThreshold of 5460" ], - "e65ba2021038db9f": [ - "BlockchainConnector.sendRawTransaction() JSON.stringify fallback in HTTP-500 error body when error.message is absent", - "BlockchainConnector.sendRawTransaction() falls back to JSON.stringify when error has no message", - "BlockchainConnector.sendRawTransaction() rethrows transport errors that have no response body", - "BlockchainConnector.sendRawTransaction() returns the txid on success", - "BlockchainConnector.sendRawTransaction() sends sendrawtransaction with the tx hex as param", - "BlockchainConnector.sendRawTransaction() surfaces bitcoind HTTP-500 error body when axios throws", - "BlockchainConnector.sendRawTransaction() throws generic error when result is missing and no error", - "BlockchainConnector.sendRawTransaction() throws when JSON-RPC response contains an error field", - "BlockchainConnector.sendRawTransaction() uses error.message from JSON-RPC error when present", - "BlockchainConnector.sendRawTransaction() uses the message from error.response.data.error when code is -26" - ], - "e6a6ea744a94dc4a": [ - "Encoder input validator validateCompressedPubKey null passes; enforces the 02/03 + 64-hex shape" - ], "e855d976bf5194b0": [ "Data/Payload Boundaries UTF-8 multi-byte at byte boundary 75-byte UTF-8 string (37 two-byte chars + 1 ASCII) \u2192 OP_RETURN", "Data/Payload Boundaries UTF-8 multi-byte at byte boundary 76-byte UTF-8 string (38 two-byte chars) \u2192 P2SH", @@ -2107,6 +2528,13 @@ "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" ], + "ed4f134b2377cc02": [ + "identity pin file lists exports the complete ordered identity file lists", + "identity pin values builds hashes for every tracked identity file", + "identity pin values compares a freshly built pin equal to itself", + "identity pin values normalizes comparable values in deterministic key order", + "identity pin values reports exactly one changed coin digest" + ], "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", @@ -2169,6 +2597,15 @@ "UtxoTracker constructor builds the correct URL from host and port", "UtxoTracker constructor stores the port" ], + "f557fd05ee550b78": [ + "REG-33: create_envelope_cancel_tx refuses a network-invalid destination a mainnet destination on regtest", + "REG-33: create_tx refuses a network-invalid address as a caller fault a P2SH funding call, where the custom output is only priced and never emitted", + "REG-33: create_tx refuses a network-invalid address as a caller fault a change address with a bad checksum", + "REG-33: create_tx refuses a network-invalid address as a caller fault a mainnet customOutputs address on regtest", + "REG-33: create_tx refuses a network-invalid address as a caller fault a mainnet feeQuote address on regtest", + "REG-33: valid addresses still build a regtest Taproot custom output on a default create_tx", + "REG-33: valid addresses still build assertAddressOnNetwork accepts regtest P2PKH and P2TR and labels a refusal" + ], "f5a56bfe7b90389c": [ "encoder FILE payload compression (spec Part B) the compression report carries the bytes that were written a reveal REBUILT FROM THE REPORT reproduces the commit carrier byte for byte", "encoder FILE payload compression (spec Part B) the compression report carries the bytes that were written reports the written action string and the stored payload", @@ -2184,6 +2621,13 @@ "f7313ee5d4a1e4d5": [ "Fuzz: dataToPubkey() holds the pubkey-shape invariant over 2000 random chunks" ], + "f7bb31daba1bd6bb": [ + "JSON-RPC controller holds only its own methods finds no inherited name in the dispatch table", + "JSON-RPC controller holds only its own methods over HTTP answers -32601 for constructor instead of echoing the params", + "JSON-RPC controller holds only its own methods over HTTP answers -32601 for every inherited name in one batch", + "JSON-RPC controller holds only its own methods over HTTP still dispatches a real method", + "JSON-RPC controller holds only its own methods still carries the real methods as own functions" + ], "fa31a66c65833973": [ "REG-04: Validator Functions REG-04.1: validatePubkey() accepts a non-empty string", "REG-04: Validator Functions REG-04.1: validatePubkey() accepts string exactly 100 chars", @@ -2229,30 +2673,6 @@ "REG-04: Validator Functions REG-04.7: validateDust() throws RangeError for negative value", "REG-04: Validator Functions REG-04.7: validateDust() throws TypeError for non-integer string" ], - "fae26ed352b38e34": [ - "BlockchainConnector.getFeePerKilobyte() does NOT fall back to the relayfee floor on mainnet", - "BlockchainConnector.getFeePerKilobyte() falls back to 10x the relayfee floor on testnet when estimatesmartfee has no data", - "BlockchainConnector.getFeePerKilobyte() falls back to the regtest default when relayfee is unavailable", - "BlockchainConnector.getFeePerKilobyte() honours FEE_NO_ESTIMATE_RELAY_MULTIPLIER for the no-estimate fallback", - "BlockchainConnector.getFeePerKilobyte() ignores a non-positive or unparseable multiplier and keeps the default", - "BlockchainConnector.getFeePerKilobyte() no-estimate reported as an RPC error rather than feerate:-1 does NOT invent a rate on mainnet when estimatesmartfee throws", - "BlockchainConnector.getFeePerKilobyte() no-estimate reported as an RPC error rather than feerate:-1 falls back to 10x the relayfee floor on testnet when estimatesmartfee throws", - "BlockchainConnector.getFeePerKilobyte() no-estimate reported as an RPC error rather than feerate:-1 keeps a node-down failure a failure", - "BlockchainConnector.getFeePerKilobyte() no-estimate reported as an RPC error rather than feerate:-1 still rejects on testnet when the node reports no relayfee either", - "BlockchainConnector.getFeePerKilobyte() rethrows transport errors", - "BlockchainConnector.getFeePerKilobyte() returns feerate when present (non-regtest)", - "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate clamps a spiked mainnet estimate to the coin-default ceiling and logs loudly", - "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate clamps a spiked testnet estimate using the dogecoin-scale default when NETWORK says dogecoin", - "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate honours FEE_ESTIMATE_SANITY_CEILING as an override", - "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate ignores a non-positive or unparseable override and keeps the coin default", - "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate passes through an estimate under the ceiling unchanged", - "BlockchainConnector.getFeePerKilobyte() sends estimatesmartfee with the blocksNumber param (non-regtest)", - "BlockchainConnector.getFeePerKilobyte() still prefers the estimate over the relayfee floor on testnet when one exists", - "BlockchainConnector.getFeePerKilobyte() throws on testnet when neither an estimate nor a relayfee is available", - "BlockchainConnector.getFeePerKilobyte() throws when feerate is missing and chain is not regtest", - "BlockchainConnector.getFeePerKilobyte() throws when result is entirely missing (non-regtest)", - "BlockchainConnector.getFeePerKilobyte() uses the relayfee floor on regtest and never consults estimatesmartfee" - ], "fc5c3c204837ea5e": [ "XChainEncoder.createTransaction() - invalid fee throws RangeError for a NaN fee string", "XChainEncoder.createTransaction() - invalid fee throws RangeError for a negative fee" @@ -2266,83 +2686,59 @@ "UtxoTracker.getSyncStatus() throws when result is a non-object (string)", "UtxoTracker.getSyncStatus() throws when result is missing entirely", "UtxoTracker.getSyncStatus() throws when result is null" - ], - "ff07b944e5210e36": [ - "Security: validateAll end-to-end rejects a hostile field outright and never returns attacker strings raw", - "Security: validateAll end-to-end rejects a request object that is not an object", - "Security: validateCombinedDataLength (payload-size DoS guard) accepts a payload at exactly the limit", - "Security: validateCombinedDataLength (payload-size DoS guard) counts multi-byte UTF-8 by bytes, not characters (no smuggling past the cap)", - "Security: validateCombinedDataLength (payload-size DoS guard) includes rawData (Latin-1 bytes) in the combined total", - "Security: validateCombinedDataLength (payload-size DoS guard) rejects a payload one byte over the limit", - "Security: validateCustomOutputs / validateFeeQuote rejects a feeQuote with non-positive or over-max amount (via validateAll, feeQuote is internal-only)", - "Security: validateCustomOutputs / validateFeeQuote rejects an over-limit custom-output set", - "Security: validateCustomOutputs / validateFeeQuote rejects custom output with over-length address or negative value", - "Security: validateEncoding accepts each canonical encoding exactly", - "Security: validateEncoding rejects an unknown/garbage encoding (no injection into the switch)", - "Security: validateFee / validateFeePerKb / validateDust rejects a fully non-numeric fee", - "Security: validateFee / validateFeePerKb / validateDust rejects a numeric-prefixed fee string outright (no truncated passthrough)", - "Security: validateFee / validateFeePerKb / validateDust rejects negative and over-max fee", - "Security: validateFee / validateFeePerKb / validateDust rejects negative dust", - "Security: validateFee / validateFeePerKb / validateDust rejects non-finite and non-positive feePerKb", - "Security: validateFee / validateFeePerKb / validateDust rejects over-max dust (parity with the fee ceiling)", - "Security: validateP2shParams (paired-secret integrity) rejects a non-64-hex p2shHash", - "Security: validateP2shParams (paired-secret integrity) rejects supplying only one of p2shHash / p2shHex", - "Security: validateP2shParams (paired-secret integrity) returns nulls when both omitted", - "Security: validatePubkey / validateChange / validateCompressedPubKey passes null through (optional param) without throwing", - "Security: validatePubkey / validateChange / validateCompressedPubKey rejects a compressedPubKey that is not 02/03 + 64 hex", - "Security: validatePubkey / validateChange / validateCompressedPubKey rejects an over-length / injection-shaped change address", - "Security: validatePubkey / validateChange / validateCompressedPubKey rejects an over-length pubkey (shares the validateAddress 100-char bound)", - "Security: validatePubkey / validateChange / validateCompressedPubKey rejects non-string and empty pubkey", - "Security: validateUtxoArray / validateUtxoEntry accepts exactly MAX_UTXO_COUNT (off-by-one upper edge)", - "Security: validateUtxoArray / validateUtxoEntry rejects a malformed (non-hex / odd-length) scriptPubKey", - "Security: validateUtxoArray / validateUtxoEntry rejects a non-array utxos", - "Security: validateUtxoArray / validateUtxoEntry rejects a numeric-prefixed injection value outright (never coerces trailing garbage)", - "Security: validateUtxoArray / validateUtxoEntry rejects a txid that is not 64 hex chars (injection-shaped)", - "Security: validateUtxoArray / validateUtxoEntry rejects an empty/missing scriptPubKey", - "Security: validateUtxoArray / validateUtxoEntry rejects an over-limit UTXO set (DoS via huge input)", - "Security: validateUtxoArray / validateUtxoEntry rejects array/null entries masquerading as objects (prototype confusion)", - "Security: validateUtxoArray / validateUtxoEntry rejects negative / non-integer vout and value", - "Security: validateUtxoArray / validateUtxoEntry rejects non-exact numeric amounts (scientific notation, decimals, hex)", - "Security: validation integrity (regex anchors + set membership) accepts each canonical encoding by exact name and rejects the empty string", - "Security: validation integrity (regex anchors + set membership) anchors the 64-hex txid regex (rejects 64 hex embedded in a longer string)", - "Security: validation integrity (regex anchors + set membership) anchors the compressedPubKey regex (rejects leading and trailing junk)", - "Security: validation integrity (regex anchors + set membership) anchors the p2shHash regex the same way" ] }, "scripts": { "test": { - "fileCount": 136, - "titleCount": 1031, + "fileCount": 174, + "titleCount": 1316, "files": { + "test/unit/XChainEncoder/build_transaction/first_input_key.test.js": "04aeb439e25ca4ed", + "test/unit/XChainEncoder/caller_pubkey_resolution.test.js": "be87ec7e82b5c79d", + "test/unit/XChainEncoder/fee_policy.test.js": "57394c6893888d79", + "test/unit/XChainEncoder/fee_rate_cap_sign_guard.test.js": "840b30c99abe79b8", "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", + "test/unit/api/broadcast_tx_node_rejected.test.js": "7d9862c885ce6b83", "test/unit/api/cors_preflight.test.js": "78e833e8f04d5380", "test/unit/api/encoder_stress_sweep.test.js": "358184203c4ccfba", "test/unit/api/get_tx_status.test.js": "87462cdada9ac477", "test/unit/api/health_serve_readiness.test.js": "99f46ad865902c51", + "test/unit/api/inherited_method_names.test.js": "f7bb31daba1bd6bb", "test/unit/api/json_rpc_real_app.test.js": "7174ac9d6bdce059", "test/unit/api/jsonrpc_body_guard.test.js": "58706b2a54fa0008", + "test/unit/api/mainnet_prelaunch_refusal.test.js": "714506268452508a", "test/unit/api/middleware_order.test.js": "cc7754caab01c0b9", + "test/unit/api/no_bare_param_errors.test.js": "c902501b3d758dbd", + "test/unit/api/node_tier_gate.test.js": "3edfd81d1bf84723", "test/unit/api/openrpc_coverage.test.js": "995c9801f2581aab", + "test/unit/api/openrpc_key_exemption.test.js": "b7508c7d593792ce", + "test/unit/api/status_node_tier.test.js": "0c349b0447588e62", + "test/unit/bin_tools/pin_identity.test.js": "ed4f134b2377cc02", + "test/unit/bin_tools/suite_title_additive.test.js": "2f28bf0d76b56ce6", "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": "845d953a12be6745", - "test/unit/blockchain_connector.test/04_send_raw_transaction.test.js": "e65ba2021038db9f", + "test/unit/blockchain_connector.test/04_send_raw_transaction.test.js": "b906f7728955c7e9", "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/06_get_fee_per_kilobyte.test.js": "e1b10cc67b1fee13", "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/blockchain_connector.test/08_fee_estimate_rpc_error_detail.test.js": "4e34a41823538af8", "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/blockchain_connector.test/11_rpc_timeout_env.test.js": "03e0966ef3abab8e", + "test/unit/blockchain_connector.test/12_node_tier.test.js": "90700e68ae785acd", + "test/unit/blockchain_connector.test/13_test_chain_implausible_estimate.test.js": "4d2fee8572bf0a2f", "test/unit/build/apply_bufferutils_patch.test.js": "3eb6eaeabe786d34", - "test/unit/build/compression.test.js": "68868c37d76d5eb2", + "test/unit/build/compression.test.js": "41e23c0843778aa7", "test/unit/build/compression.test/01_action_string_helpers.test.js": "f6cecc24770508b5", "test/unit/build/compression.test/02_create_transaction_integration.test.js": "ce55de477de9f588", "test/unit/build/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js": "f5a56bfe7b90389c", "test/unit/build/compression.test/04_validator.test.js": "dfdfc61603d906bd", "test/unit/build/compression.test/05_constants_conformance.test.js": "51af3196bafc7da3", "test/unit/build/crypto_networks.test.js": "3f015c065bb424cc", + "test/unit/build/multi_chunk_single_fund_tx.test.js": "527c9a64750185e0", "test/unit/build/package_fee_sizing.test.js": "0c5deea0cdab64b5", "test/unit/build/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js": "051aa0f149569c03", "test/unit/build/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js": "8b96a6f9f68661f9", @@ -2353,13 +2749,17 @@ "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": "a54b04e2034f9891", + "test/unit/build/utxo_tracker.test/04_tracker_error_text_sanitized.test.js": "79c5479ff8297918", "test/unit/build/utxo_tracker_get_tx_block.test.js": "c67fd5ac2d0b5ff4", + "test/unit/chain_identity_genesis.test.js": "360eb4295df65345", + "test/unit/chain_identity_parity.test.js": "55106b3a8b9dd7c7", "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", - "test/unit/common/validator/action_manifest_conformance.test.js": "babd2db450e2e6a1", + "test/unit/common/validator/action_manifest_conformance.test.js": "128129d7e17e2e21", "test/unit/common/validator/large_satoshi_amounts.test.js": "c512e0cbe2bc163a", - "test/unit/common/validator/validator.test.js": "bae2f2e4bfcbbd48", + "test/unit/common/validator/message_truncation.test.js": "b50f47cceb49ac60", + "test/unit/common/validator/validator.test.js": "b8ca9bc2aab3b6ff", "test/unit/common/validator/validator.test/01_validate_data_param.test.js": "4ccb179cd2d34bce", "test/unit/common/validator/validator.test/02_validate_action_name_encoder_action_name_gate.test.js": "048d6d9734e4b8ea", "test/unit/common/validator/validator.test/03_validate_combined_data_length.test.js": "da6106a9ba02aab9", @@ -2367,17 +2767,19 @@ "test/unit/common/validator/validator.test/05_validate_encoding.test.js": "57b96d6f511dbf43", "test/unit/common/validator/validator.test/06_max_fee_satoshis.test.js": "4e788c4ec94c6c63", "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/08_validate_fee_per_kb.test.js": "8c32d904d7276991", "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": "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", - "test/unit/common/validator/validator.test/14_validate_compressed_pub_key.test.js": "e6a6ea744a94dc4a", + "test/unit/common/validator/validator.test/14_validate_compressed_pub_key.test.js": "2089fa0000e3be71", "test/unit/common/validator/validator.test/15_validate_change.test.js": "a58c0275055cc988", "test/unit/common/validator/validator.test/16_validate_address.test.js": "b7598b5920b35f20", "test/unit/common/validator/validator.test/17_validate_all.test.js": "b4c7bf0a08619cca", - "test/unit/crash_handlers.test.js": "ab75df16ddef33d7", + "test/unit/common/validator/validator.test/18_validate_all_unknown_params.test.js": "b3bb61858e37f56c", + "test/unit/common/validator/value_checks.test.js": "9fbc06b5565c3384", + "test/unit/crash_handlers.test.js": "b0f5daa791aa1a25", "test/unit/observability.test.js": "ca8fa416b5e8a763", "test/unit/protocol/changes.test.js": "f23a5e117558dc06", "test/unit/protocol/changes_parity.test.js": "cbb165a359525e9c", @@ -2391,12 +2793,26 @@ "test/unit/server/single_instance_guard.test/01_acquire_instance_lock.test.js": "807a63a77985ea07", "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/build_correctness.test/01_relay_floor_size_estimate.test.js": "61d96b51460a6591", + "test/unit/xchain_encoder/build_correctness.test/02_fee_floor_selection.test.js": "4498c030666a1756", + "test/unit/xchain_encoder/build_correctness.test/03_deterministic_order.test.js": "09319c2cb6e64dd7", + "test/unit/xchain_encoder/build_correctness.test/04_p2wsh_reveal_pad_pricing.test.js": "1d2df4e0c840c80e", + "test/unit/xchain_encoder/build_correctness.test/05_payment_dust_floor.test.js": "7511bcaabf35f784", + "test/unit/xchain_encoder/build_correctness.test/06_missing_confirmations.test.js": "7e10c19605f33fbd", "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/psbt_conformance/bare_multisig_dust_relay_rate.test.js": "79f70c9b2a22cdc8", + "test/unit/xchain_encoder/psbt_conformance/custom_outputs_not_mutated.test.js": "81ecf756db9b39a4", + "test/unit/xchain_encoder/psbt_conformance/doge_soft_dust_surcharge.test.js": "62430dae2c4e7b28", + "test/unit/xchain_encoder/psbt_conformance/nested_segwit_input.test.js": "6ed81952aa49c09d", + "test/unit/xchain_encoder/psbt_conformance/p2wsh_reveal_headroom.test.js": "73f78b4564a3a7e3", + "test/unit/xchain_encoder/psbt_conformance/reveal_funding_pairing.test.js": "ac90eb5a2fb0989f", + "test/unit/xchain_encoder/psbt_conformance/stripped_size_floor.test.js": "e29fba7018c99cf3", "test/unit/xchain_encoder/suggested_fee_ceiling.test.js": "a76fc6f4a4157148", + "test/unit/xchain_encoder/trackerless_profile.test.js": "dec04c307d2060b7", "test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js": "0748973400fcace3", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js": "23e242d978109d26", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js": "bb903d583344a390", @@ -2419,6 +2835,7 @@ "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/17_payment_only.test.js": "214613c17899118f", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/18_payload_guards.test.js": "1e9678e996275889", "test/unit/xchain_encoder/xchain_encoder_data_to_pubkey.test.js": "ccc87ed9c55081d5", + "test/unit/xchain_encoder/xchain_encoder_dust_cap.test/01_unconfirmed_at_cap.test.js": "7a10c5e46972a773", "test/unit/xchain_encoder/xchain_encoder_exact_inputs.test.js": "0402af4b590d4603", "test/unit/xchain_encoder/xchain_encoder_extra.test.js": "6a7197d4097f3c0c", "test/unit/xchain_encoder/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js": "83ca670b30f24208", @@ -2431,7 +2848,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": "36a860097c5c6a21", + "test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js": "847236b040aee4d9", "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", @@ -2444,11 +2861,11 @@ "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": "c5c3639ee7e9a648", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js": "542e2ed5169dba02", "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/trackerless_profile.test.js": "e3f640b2cf4c0689", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/published_vectors.test.js": "331f46bf5c424942", "test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js": "2a1c91a7aceda8eb" } }, @@ -2499,7 +2916,7 @@ "test/e2e/edge_cases_e2e.test.js": "bb074808e7081443", "test/e2e/encoding_boundaries_e2e.test.js": "efc2d14c81fd5e58", "test/e2e/error_rejection_e2e.test.js": "c91ea7fe4f2edf18", - "test/e2e/multi_chain_e2e.test.js": "a845505c93fa3021", + "test/e2e/multi_chain_e2e.test.js": "6205fd9c842af35b", "test/e2e/obfuscation_integrity_e2e.test.js": "883e1aa41a43b1c9", "test/e2e/p2sh_p2wsh_sequence_e2e.test.js": "0d8834d5fc73c75b", "test/e2e/round_trip_e2e.test.js": "c29f3481850f5d9f", @@ -2547,8 +2964,8 @@ } }, "test:regression": { - "fileCount": 28, - "titleCount": 314, + "fileCount": 29, + "titleCount": 328, "files": { "test/regression/reg_01_encoding_types.test.js": "c438a1275387f62b", "test/regression/reg_02_obfuscation.test.js": "6c8feaefe1116923", @@ -2573,7 +2990,8 @@ "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_32_create_tx_error_code_split.test.js": "d092f82fbad4908f", + "test/regression/reg_32_create_tx_error_code_split.test.js": "99abb4fef6f98fbc", + "test/regression/reg_33_network_invalid_address.test.js": "f557fd05ee550b78", "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", @@ -2585,51 +3003,68 @@ }, "test:security": { "fileCount": 8, - "titleCount": 71, + "titleCount": 73, "files": { "test/security/concurrency_gate.test.js": "5c149d2d20812d9a", "test/security/concurrency_gate.test/01_resolve_limit.test.js": "5140a8874ea32fb0", - "test/security/concurrency_gate.test/02_api_js_wiring.test.js": "9856f6ee77509f9c", + "test/security/concurrency_gate.test/02_api_js_wiring.test.js": "7b0dd058f5e2f3a4", "test/security/concurrency_gate.test/03_probe_variants.test.js": "9a05dc7d05139e84", - "test/security/input_validation.test.js": "ff07b944e5210e36", + "test/security/input_validation.test.js": "a0863ade61747ae3", "test/security/obfuscation_key.test.js": "7fbd6025650718a6", "test/security/payload_size.test.js": "f1872a85a8671d97", "test/security/rate_limit_log.test.js": "aa6df06a6ffcd257" } }, "test:unit": { - "fileCount": 136, - "titleCount": 1031, + "fileCount": 174, + "titleCount": 1316, "files": { + "test/unit/XChainEncoder/build_transaction/first_input_key.test.js": "04aeb439e25ca4ed", + "test/unit/XChainEncoder/caller_pubkey_resolution.test.js": "be87ec7e82b5c79d", + "test/unit/XChainEncoder/fee_policy.test.js": "57394c6893888d79", + "test/unit/XChainEncoder/fee_rate_cap_sign_guard.test.js": "840b30c99abe79b8", "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", + "test/unit/api/broadcast_tx_node_rejected.test.js": "7d9862c885ce6b83", "test/unit/api/cors_preflight.test.js": "78e833e8f04d5380", "test/unit/api/encoder_stress_sweep.test.js": "358184203c4ccfba", "test/unit/api/get_tx_status.test.js": "87462cdada9ac477", "test/unit/api/health_serve_readiness.test.js": "99f46ad865902c51", + "test/unit/api/inherited_method_names.test.js": "f7bb31daba1bd6bb", "test/unit/api/json_rpc_real_app.test.js": "7174ac9d6bdce059", "test/unit/api/jsonrpc_body_guard.test.js": "58706b2a54fa0008", + "test/unit/api/mainnet_prelaunch_refusal.test.js": "714506268452508a", "test/unit/api/middleware_order.test.js": "cc7754caab01c0b9", + "test/unit/api/no_bare_param_errors.test.js": "c902501b3d758dbd", + "test/unit/api/node_tier_gate.test.js": "3edfd81d1bf84723", "test/unit/api/openrpc_coverage.test.js": "995c9801f2581aab", + "test/unit/api/openrpc_key_exemption.test.js": "b7508c7d593792ce", + "test/unit/api/status_node_tier.test.js": "0c349b0447588e62", + "test/unit/bin_tools/pin_identity.test.js": "ed4f134b2377cc02", + "test/unit/bin_tools/suite_title_additive.test.js": "2f28bf0d76b56ce6", "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": "845d953a12be6745", - "test/unit/blockchain_connector.test/04_send_raw_transaction.test.js": "e65ba2021038db9f", + "test/unit/blockchain_connector.test/04_send_raw_transaction.test.js": "b906f7728955c7e9", "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/06_get_fee_per_kilobyte.test.js": "e1b10cc67b1fee13", "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/blockchain_connector.test/08_fee_estimate_rpc_error_detail.test.js": "4e34a41823538af8", "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/blockchain_connector.test/11_rpc_timeout_env.test.js": "03e0966ef3abab8e", + "test/unit/blockchain_connector.test/12_node_tier.test.js": "90700e68ae785acd", + "test/unit/blockchain_connector.test/13_test_chain_implausible_estimate.test.js": "4d2fee8572bf0a2f", "test/unit/build/apply_bufferutils_patch.test.js": "3eb6eaeabe786d34", - "test/unit/build/compression.test.js": "68868c37d76d5eb2", + "test/unit/build/compression.test.js": "41e23c0843778aa7", "test/unit/build/compression.test/01_action_string_helpers.test.js": "f6cecc24770508b5", "test/unit/build/compression.test/02_create_transaction_integration.test.js": "ce55de477de9f588", "test/unit/build/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js": "f5a56bfe7b90389c", "test/unit/build/compression.test/04_validator.test.js": "dfdfc61603d906bd", "test/unit/build/compression.test/05_constants_conformance.test.js": "51af3196bafc7da3", "test/unit/build/crypto_networks.test.js": "3f015c065bb424cc", + "test/unit/build/multi_chunk_single_fund_tx.test.js": "527c9a64750185e0", "test/unit/build/package_fee_sizing.test.js": "0c5deea0cdab64b5", "test/unit/build/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js": "051aa0f149569c03", "test/unit/build/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js": "8b96a6f9f68661f9", @@ -2640,13 +3075,17 @@ "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": "a54b04e2034f9891", + "test/unit/build/utxo_tracker.test/04_tracker_error_text_sanitized.test.js": "79c5479ff8297918", "test/unit/build/utxo_tracker_get_tx_block.test.js": "c67fd5ac2d0b5ff4", + "test/unit/chain_identity_genesis.test.js": "360eb4295df65345", + "test/unit/chain_identity_parity.test.js": "55106b3a8b9dd7c7", "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", - "test/unit/common/validator/action_manifest_conformance.test.js": "babd2db450e2e6a1", + "test/unit/common/validator/action_manifest_conformance.test.js": "128129d7e17e2e21", "test/unit/common/validator/large_satoshi_amounts.test.js": "c512e0cbe2bc163a", - "test/unit/common/validator/validator.test.js": "bae2f2e4bfcbbd48", + "test/unit/common/validator/message_truncation.test.js": "b50f47cceb49ac60", + "test/unit/common/validator/validator.test.js": "b8ca9bc2aab3b6ff", "test/unit/common/validator/validator.test/01_validate_data_param.test.js": "4ccb179cd2d34bce", "test/unit/common/validator/validator.test/02_validate_action_name_encoder_action_name_gate.test.js": "048d6d9734e4b8ea", "test/unit/common/validator/validator.test/03_validate_combined_data_length.test.js": "da6106a9ba02aab9", @@ -2654,17 +3093,19 @@ "test/unit/common/validator/validator.test/05_validate_encoding.test.js": "57b96d6f511dbf43", "test/unit/common/validator/validator.test/06_max_fee_satoshis.test.js": "4e788c4ec94c6c63", "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/08_validate_fee_per_kb.test.js": "8c32d904d7276991", "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": "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", - "test/unit/common/validator/validator.test/14_validate_compressed_pub_key.test.js": "e6a6ea744a94dc4a", + "test/unit/common/validator/validator.test/14_validate_compressed_pub_key.test.js": "2089fa0000e3be71", "test/unit/common/validator/validator.test/15_validate_change.test.js": "a58c0275055cc988", "test/unit/common/validator/validator.test/16_validate_address.test.js": "b7598b5920b35f20", "test/unit/common/validator/validator.test/17_validate_all.test.js": "b4c7bf0a08619cca", - "test/unit/crash_handlers.test.js": "ab75df16ddef33d7", + "test/unit/common/validator/validator.test/18_validate_all_unknown_params.test.js": "b3bb61858e37f56c", + "test/unit/common/validator/value_checks.test.js": "9fbc06b5565c3384", + "test/unit/crash_handlers.test.js": "b0f5daa791aa1a25", "test/unit/observability.test.js": "ca8fa416b5e8a763", "test/unit/protocol/changes.test.js": "f23a5e117558dc06", "test/unit/protocol/changes_parity.test.js": "cbb165a359525e9c", @@ -2678,12 +3119,26 @@ "test/unit/server/single_instance_guard.test/01_acquire_instance_lock.test.js": "807a63a77985ea07", "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/build_correctness.test/01_relay_floor_size_estimate.test.js": "61d96b51460a6591", + "test/unit/xchain_encoder/build_correctness.test/02_fee_floor_selection.test.js": "4498c030666a1756", + "test/unit/xchain_encoder/build_correctness.test/03_deterministic_order.test.js": "09319c2cb6e64dd7", + "test/unit/xchain_encoder/build_correctness.test/04_p2wsh_reveal_pad_pricing.test.js": "1d2df4e0c840c80e", + "test/unit/xchain_encoder/build_correctness.test/05_payment_dust_floor.test.js": "7511bcaabf35f784", + "test/unit/xchain_encoder/build_correctness.test/06_missing_confirmations.test.js": "7e10c19605f33fbd", "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/psbt_conformance/bare_multisig_dust_relay_rate.test.js": "79f70c9b2a22cdc8", + "test/unit/xchain_encoder/psbt_conformance/custom_outputs_not_mutated.test.js": "81ecf756db9b39a4", + "test/unit/xchain_encoder/psbt_conformance/doge_soft_dust_surcharge.test.js": "62430dae2c4e7b28", + "test/unit/xchain_encoder/psbt_conformance/nested_segwit_input.test.js": "6ed81952aa49c09d", + "test/unit/xchain_encoder/psbt_conformance/p2wsh_reveal_headroom.test.js": "73f78b4564a3a7e3", + "test/unit/xchain_encoder/psbt_conformance/reveal_funding_pairing.test.js": "ac90eb5a2fb0989f", + "test/unit/xchain_encoder/psbt_conformance/stripped_size_floor.test.js": "e29fba7018c99cf3", "test/unit/xchain_encoder/suggested_fee_ceiling.test.js": "a76fc6f4a4157148", + "test/unit/xchain_encoder/trackerless_profile.test.js": "dec04c307d2060b7", "test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js": "0748973400fcace3", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js": "23e242d978109d26", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js": "bb903d583344a390", @@ -2706,6 +3161,7 @@ "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/17_payment_only.test.js": "214613c17899118f", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/18_payload_guards.test.js": "1e9678e996275889", "test/unit/xchain_encoder/xchain_encoder_data_to_pubkey.test.js": "ccc87ed9c55081d5", + "test/unit/xchain_encoder/xchain_encoder_dust_cap.test/01_unconfirmed_at_cap.test.js": "7a10c5e46972a773", "test/unit/xchain_encoder/xchain_encoder_exact_inputs.test.js": "0402af4b590d4603", "test/unit/xchain_encoder/xchain_encoder_extra.test.js": "6a7197d4097f3c0c", "test/unit/xchain_encoder/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js": "83ca670b30f24208", @@ -2718,7 +3174,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": "36a860097c5c6a21", + "test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js": "847236b040aee4d9", "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", @@ -2731,11 +3187,11 @@ "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": "c5c3639ee7e9a648", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js": "542e2ed5169dba02", "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/trackerless_profile.test.js": "e3f640b2cf4c0689", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/published_vectors.test.js": "331f46bf5c424942", "test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js": "2a1c91a7aceda8eb" } } diff --git a/src/build/blockchain_connector/fee_estimation.js b/src/build/blockchain_connector/fee_estimation.js index 966b8f2c..f6a4b1e0 100644 --- a/src/build/blockchain_connector/fee_estimation.js +++ b/src/build/blockchain_connector/fee_estimation.js @@ -135,16 +135,16 @@ async function requestSmartFee(connector, blocksNumber) { return response.data; } -// Non-regtest estimates must be positive and below the configured ceiling. -function usableSmartFee(responseData) { +async function usableSmartFee(connector, responseData) { if (!responseData.result || !(Number(responseData.result.feerate) > 0)) return null const feerate = Number(responseData.result.feerate); const ceiling = feeEstimateSanityCeiling(); if (feerate > ceiling) { - logger.error('estimatesmartfee returned ' + feerate + '/kB on a non-regtest ' + - 'chain, above the ' + ceiling + '/kB sanity ceiling; clamping to the ceiling. ' + - 'Set FEE_ESTIMATE_SANITY_CEILING to override.'); - return ceiling; + const mainnet = (await connector.chainName()) === 'main'; + logger.error('estimatesmartfee returned ' + feerate + '/kB, above the ' + ceiling + + '/kB sanity ceiling; ' + (mainnet ? 'clamping to the ceiling' : 'treating it as no estimate') + + '. Set FEE_ESTIMATE_SANITY_CEILING to override.'); + return mainnet ? ceiling : null; } return feerate; } @@ -242,7 +242,7 @@ module.exports = { try { if (await this.isRegtest()) return await regtestFee(this) const responseData = await requestSmartFee(this, blocksNumber) - const feerate = usableSmartFee(responseData) + const feerate = await usableSmartFee(this, responseData) if (feerate !== null) return feerate return await requireNoEstimateFallback(this, responseData) } catch (error) { diff --git a/test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js b/test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js index a545b8ad..c31a516a 100644 --- a/test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js +++ b/test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js @@ -329,16 +329,18 @@ describe('BlockchainConnector.getFeePerKilobyte()', () => { assert.ok(errorSpy.some(m => /sanity ceiling/.test(m)), 'expected a loud diagnostic') }) - it('clamps a spiked testnet estimate using the dogecoin-scale default when NETWORK says dogecoin', async () => { + it('treats a spiked testnet estimate as unavailable and uses the relayfee fallback', async () => { process.env.NETWORK = 'dogecoin-testnet' axios.post = async (url, data) => { if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } if (data.method === 'estimatesmartfee') return { data: { result: { feerate: 5000 } } } // 5000 DOGE/kB: absurd + if (data.method === 'getnetworkinfo') return { data: { result: { relayfee: 0.001 } } } return { data: { result: {} } } } const c = makeConnector() const feerate = await c.getFeePerKilobyte(6) - assert.strictEqual(feerate, 10, 'expected clamp to the dogecoin default ceiling 10 DOGE/kB') + assert.ok(Math.abs(feerate - 0.01) < 1e-12, 'expected 10x the 0.001 relay floor, got ' + feerate) + assert.ok(errorSpy.some(m => /treating it as no estimate/.test(m)), 'expected a loud diagnostic') }) }) }) diff --git a/test/unit/blockchain_connector.test/13_test_chain_implausible_estimate.test.js b/test/unit/blockchain_connector.test/13_test_chain_implausible_estimate.test.js new file mode 100644 index 00000000..e12b4554 --- /dev/null +++ b/test/unit/blockchain_connector.test/13_test_chain_implausible_estimate.test.js @@ -0,0 +1,45 @@ +// 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') + +describe('BlockchainConnector.getFeePerKilobyte() test-chain implausible estimate', () => { + let originalPost + let originalNetwork + + beforeEach(() => { + originalPost = axios.post + originalNetwork = process.env.NETWORK + process.env.NETWORK = 'bitcoin-testnet' + }) + + afterEach(() => { + axios.post = originalPost + if (originalNetwork === undefined) delete process.env.NETWORK + else process.env.NETWORK = originalNetwork + }) + + it('uses the no-estimate fallback instead of clamping an estimate above the sanity ceiling', async () => { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'testnet4' } } } + if (data.method === 'estimatesmartfee') return { data: { result: { feerate: 7.48876052, blocks: 6 } } } + if (data.method === 'getnetworkinfo') return { data: { result: { relayfee: 0.000001 } } } + return { data: { result: {} } } + } + + const connector = new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', 'rpcpass') + const feerate = await connector.getFeePerKilobyte(6) + + assert.ok(Math.abs(feerate - 0.00001) < 1e-12, + 'expected 10x the 0.000001 relay floor, got ' + feerate) + }) +}) From ec41581825fb3d58a309983ac55ff479cdbf7852 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 05:22:08 -0700 Subject: [PATCH 21/22] Narrow fee estimation suite title pin Keep the title map limited to the changed fee-estimation suite and the new focused regression file. --- bin/pins/at1-suite-titles.json | 963 +++++++++------------------------ 1 file changed, 256 insertions(+), 707 deletions(-) diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index 917f7626..18f0652e 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -4,23 +4,6 @@ "XChainEncoder.createTransaction() unconfirmed filtering excludes mempool UTXOs when unconfirmed=false", "XChainEncoder.createTransaction() unconfirmed filtering keeps mempool UTXOs when unconfirmed=true" ], - "03e0966ef3abab8e": [ - "NODE_RPC_TIMEOUT parsing RPC_TIMEOUT defaults to 30000 when NODE_RPC_TIMEOUT is unset", - "NODE_RPC_TIMEOUT parsing RPC_TIMEOUT honours a valid NODE_RPC_TIMEOUT", - "NODE_RPC_TIMEOUT parsing RPC_TIMEOUT is always a positive integer, so axios always installs a timer", - "NODE_RPC_TIMEOUT parsing RPC_TIMEOUT keeps the 30000 default for a unit-suffixed NODE_RPC_TIMEOUT instead of truncating", - "NODE_RPC_TIMEOUT parsing RPC_TIMEOUT keeps the 30000 default for an empty NODE_RPC_TIMEOUT instead of NaN", - "NODE_RPC_TIMEOUT parsing envInt accepts a clean positive integer, trimmed", - "NODE_RPC_TIMEOUT parsing envInt falls back to 30000 with a warning for \" \"", - "NODE_RPC_TIMEOUT parsing envInt falls back to 30000 with a warning for \"\"", - "NODE_RPC_TIMEOUT parsing envInt falls back to 30000 with a warning for \"-5\"", - "NODE_RPC_TIMEOUT parsing envInt falls back to 30000 with a warning for \"0\"", - "NODE_RPC_TIMEOUT parsing envInt falls back to 30000 with a warning for \"1.5\"", - "NODE_RPC_TIMEOUT parsing envInt falls back to 30000 with a warning for \"30s\"", - "NODE_RPC_TIMEOUT parsing envInt falls back to 30000 with a warning for \"NaN\"", - "NODE_RPC_TIMEOUT parsing envInt falls back to 30000 with a warning for \"abc\"", - "NODE_RPC_TIMEOUT parsing envInt returns the fallback without a warning when the value is unset" - ], "0402af4b590d4603": [ "XChainEncoder create_tx options.exactInputs errors rather than silently deduplicating a repeated outpoint", "XChainEncoder create_tx options.exactInputs errors rather than silently dropping a named unconfirmed outpoint", @@ -51,11 +34,6 @@ "Encoder input validator validateActionName (encoder ACTION-name gate) rejects an action newer than the deployed decoder", "Encoder input validator validateActionName (encoder ACTION-name gate) validateAll still accepts a valid canonical action end-to-end" ], - "04aeb439e25ca4ed": [ - "bindObfuscationKey binds the key to the available outpoint moved into the first-input slot", - "bindObfuscationKey exact inputs raises OperationalError when exact mode cannot claim its first input", - "bindObfuscationKey reservation lifetime reserves the first input for the configured TTL" - ], "04c80607df6faac2": [ "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.28: BATCH semicolon-separated actions preserved", "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.29: Large BATCH forces P2SH 5+ actions in batch exceeds OP_RETURN and auto-selects P2SH", @@ -120,16 +98,6 @@ "XChainEncoder.createTransaction() OP_RETURN encoding path OP_RETURN output script starts with OP_RETURN opcode (0x6a)", "XChainEncoder.createTransaction() OP_RETURN encoding path auto-selects OP_RETURN for small data and returns correct encoding" ], - "09319c2cb6e64dd7": [ - "candidate order is deterministic on equal values every permutation of a set with ties builds one transaction", - "candidate order is deterministic on equal values the order is value descending, then txid, then vout", - "candidate order is deterministic on equal values two equal-value UTXOs in either order build the same transaction" - ], - "0c349b0447588e62": [ - "GET /status node-tier readiness returns 200 with a null mismatch when no mismatch has been recorded", - "GET /status node-tier readiness returns 503 and publishes a recorded node-tier mismatch reason", - "assertNodeTier mismatch state records the mismatch reason on the connector before refusing the node" - ], "0c5deea0cdab64b5": [ "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 counts a shared ancestor once when two inputs both descend from it", "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 dedupes repeated input txids before it asks the node", @@ -243,13 +211,6 @@ "CORS_ORIGIN allowlist parsing what a caller actually receives resolves CORS_ORIGIN `*` to the parser sentinel that cors() treats as \"any origin\"", "CORS_ORIGIN allowlist parsing what a caller actually receives sends no ACAO at all when CORS is disabled" ], - "128129d7e17e2e21": [ - "ACTION manifest conformance: encoder validateActionName gate @regression ACTION_ALIASES exactly equals the manifest aliases (and the decoder ACTION_ALIASES)", - "ACTION manifest conformance: encoder validateActionName gate @regression VALID_ACTION_NAMES exactly equals the manifest wireDecoded slice (the decoder arbiter set)", - "ACTION manifest conformance: encoder validateActionName gate @regression byte-identity to canonical manifest vendored test/fixtures/action-manifest.json is byte-identical to canonical", - "ACTION manifest conformance: encoder validateActionName gate @regression keeps settlement anchors outside the encoder ACTION-name gate", - "ACTION manifest conformance: encoder validateActionName gate @regression records BET version 4 as user-encodable" - ], "13527b5e48f4a7cf": [ "Encoder input validator validateFee null/false pass; coerces; rejects NaN, negative, over-max" ], @@ -370,21 +331,6 @@ "Chaos Category B: Input & Data Corruption B-6: Binary/NUL content in ACTION data NUL bytes in data produce valid PSBT", "Chaos Category B: Input & Data Corruption B-6: Binary/NUL content in ACTION data emoji/high-Unicode in data produce valid PSBT" ], - "1d2df4e0c840c80e": [ - "P2WSH reveal funding prices the stripped-size pad output btc: a 30-byte single-chunk reveal at 10 sat/vB pays the rate", - "P2WSH reveal funding prices the stripped-size pad output btc: a 30-byte single-chunk reveal at 100 sat/vB pays the rate", - "P2WSH reveal funding prices the stripped-size pad output btc: a 30-byte single-chunk reveal at 50 sat/vB pays the rate", - "P2WSH reveal funding prices the stripped-size pad output btc: a 470-byte single-chunk reveal at 10 sat/vB pays the rate", - "P2WSH reveal funding prices the stripped-size pad output btc: a 470-byte single-chunk reveal at 100 sat/vB pays the rate", - "P2WSH reveal funding prices the stripped-size pad output btc: a 470-byte single-chunk reveal at 50 sat/vB pays the rate", - "P2WSH reveal funding prices the stripped-size pad output ltc: a 30-byte single-chunk reveal at 10 sat/vB pays the rate", - "P2WSH reveal funding prices the stripped-size pad output ltc: a 30-byte single-chunk reveal at 100 sat/vB pays the rate", - "P2WSH reveal funding prices the stripped-size pad output ltc: a 30-byte single-chunk reveal at 50 sat/vB pays the rate", - "P2WSH reveal funding prices the stripped-size pad output ltc: a 470-byte single-chunk reveal at 10 sat/vB pays the rate", - "P2WSH reveal funding prices the stripped-size pad output ltc: a 470-byte single-chunk reveal at 100 sat/vB pays the rate", - "P2WSH reveal funding prices the stripped-size pad output ltc: a 470-byte single-chunk reveal at 50 sat/vB pays the rate", - "P2WSH reveal funding prices the stripped-size pad output with no change address the pad is priced at the largest standard output" - ], "1e9678e996275889": [ "XChainEncoder.createTransaction() payload guards (library boundary) does not warn on a canonical ACTION name", "XChainEncoder.createTransaction() payload guards (library boundary) does not warn on a known ACTION alias", @@ -401,10 +347,6 @@ "XChainEncoder.createTransaction() payload guards (library boundary) warns a library caller whose data leads with an unknown ACTION name", "XChainEncoder.createTransaction() payload guards (library boundary) warns when an unknown ACTION name arrives as a Buffer" ], - "2089fa0000e3be71": [ - "Encoder input validator validateCompressedPubKey null passes; enforces the 02/03 + 64-hex shape", - "Encoder input validator validateCompressedPubKey refuses a well-shaped key whose x is not on the curve, for both prefixes" - ], "214613c17899118f": [ "XChainEncoder.createTransaction() payment-only emits NO OP_RETURN output when there is no action payload", "XChainEncoder.createTransaction() payment-only still emits a nulldata output for a rawData-only payload", @@ -444,6 +386,11 @@ "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) refuses the p2shHash reveal flow for TAPROOT", "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) returns {commit psbt, revealPsbt, envelope} with the commit output at vout 0" ], + "2842761e884c6f3b": [ + "BlockchainConnector.getFeePerKilobyte() node RPC error detail carries the error body of an HTTP 200 answer into the thrown message", + "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", @@ -518,10 +465,6 @@ "2dd7e128b3fa1284": [ "E2E-5: UTXO, Fee, and Change Integration E2E-5.13: Custom outputs coexist with ACTION custom output value deducted from change" ], - "2f28bf0d76b56ce6": [ - "suite title comparison additive growth prints added files and titles without failing the compare CLI", - "suite title comparison additive growth prints dropped files and titles and fails the compare CLI" - ], "2f6d87eaf711d8a0": [ "broadcast_tx rejection logging logs the locally derived txid without logging raw transaction hex" ], @@ -533,10 +476,6 @@ "325536fb1b16a149": [ "E2E-5: UTXO, Fee, and Change Integration E2E-5.15: Null feePerKb triggers RPC call calls getFeePerKilobyte when feePerKb is null" ], - "331f46bf5c424942": [ - "XChainEncoder published Taproot envelope vectors matches chunk_rebalance through prepareData", - "XChainEncoder published Taproot envelope vectors matches envelope_chunking through prepareData" - ], "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", @@ -560,14 +499,6 @@ "encoder stress-sweep unit @regression MULTISIGN requires compressedPubKey validateAll throws when encoding is MULTISIGN and compressedPubKey is absent", "encoder stress-sweep unit @regression UtxoTracker pagination is bounded aborts on a repeated pagination cursor (stalled/hostile tracker)" ], - "360eb4295df65345": [ - "chain identity genesis helpers accepts an agreeing hash regardless of case", - "chain identity genesis helpers catches a same-tier cross-coin pairing", - "chain identity genesis helpers catches a testnet3 versus testnet4 pairing", - "chain identity genesis helpers fails open when no usable hash is pinned", - "chain identity genesis helpers fails open when the node reports no usable hash", - "chain identity genesis helpers reports whether the genesis pin is absent" - ], "361338400dd1a0a7": [ "REG-04: Validator Functions REG-04.11: validateP2shParams() accepts valid pair", "REG-04: Validator Functions REG-04.11: validateP2shParams() returns nulls when both absent", @@ -593,6 +524,27 @@ "REG-14: P2SH/P2WSH reveal clears the min-relay fee floor P2WSH funds a reveal with no customOutputs", "REG-14: P2SH/P2WSH reveal clears the min-relay fee floor charges the funding tx exactly the fee output byte cost it forgot before" ], + "36a860097c5c6a21": [ + "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 cap disabled: a plausible feePerKb is honored, not clamped", + "XChainEncoder fee-rate cap cap disabled: the burn backstop anchors a feePerKb-only clamp on relayfee with no estimate", + "XChainEncoder fee-rate cap cap disabled: the burn backstop clamps a feePerKb-only drain to 100x the node rate", + "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 envelope cancel: anchors the relative cap on relayfee when the node has no estimate", + "XChainEncoder fee-rate cap envelope cancel: cap disabled and no estimate, the burn backstop anchors on relayfee", + "XChainEncoder fee-rate cap envelope cancel: cap disabled, a plausible feePerKb is honored", + "XChainEncoder fee-rate cap envelope cancel: cap disabled, the burn backstop keeps a drain-shaped feePerKb off the commit", + "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" + ], "393ef05040e4ada2": [ "REG-07: Action Pipeline Regression REG-07.1: SEND baseline round-trip SEND with memo field preserved", "REG-07: Action Pipeline Regression REG-07.1: SEND baseline round-trip SEND|0|JDOG|1| encodes and decodes exactly", @@ -628,14 +580,6 @@ "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" ], - "3edfd81d1bf84723": [ - "JSON-RPC node tier gate broadcast_tx refuses a mainnet node under a testnet encoder as -32010 NODE_CHAIN_MISMATCH", - "JSON-RPC node tier gate create_envelope_cancel_tx refuses a mainnet node under a testnet encoder as -32010 NODE_CHAIN_MISMATCH", - "JSON-RPC node tier gate create_tx refuses a mainnet node under a testnet encoder as -32010 NODE_CHAIN_MISMATCH", - "JSON-RPC node tier gate create_tx with a caller-supplied feePerKb still builds on a matching testnet node", - "JSON-RPC node tier gate estimate_fee refuses a mainnet node under a testnet encoder as -32010 NODE_CHAIN_MISMATCH", - "JSON-RPC node tier gate the mainnet prelaunch refusal fires before the node tier check" - ], "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\"", @@ -664,20 +608,6 @@ "CryptoNetworks .getFirstBlock() returns 67847500 for dogecoin-testnet", "CryptoNetworks .getFirstBlock() returns 950000 for bitcoin-mainnet" ], - "41e23c0843778aa7": [ - "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES a FILE of another version", - "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES a non-FILE action (nowhere to record the marker)", - "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES a payload over the pre-compression input cap", - "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES a token-gated FILE (\u00a75.4: the field means inflate-after-decrypt)", - "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES to re-compress when the caller already declared a codec", - "encoder FILE payload compression (spec Part B) compressPayloadForAction a gated FILE passes its own COMPRESSION field through untouched", - "encoder FILE payload compression (spec Part B) compressPayloadForAction compresses a public FILE payload and appends the COMPRESSION field", - "encoder FILE payload compression (spec Part B) compressPayloadForAction emits RAW when the payload compresses beyond the ratio guard", - "encoder FILE payload compression (spec Part B) compressPayloadForAction keeps already-compressed media raw, leaving the action byte-identical", - "encoder FILE payload compression (spec Part B) compressPayloadForAction passes a payload-free action straight through", - "encoder FILE payload compression (spec Part B) compressPayloadForAction the marker and the bytes always move together", - "encoder FILE payload compression: refusal error class compressPayloadForAction marks every explicit refusal as invalid params" - ], "4405034a199528ec": [ "coverage ratchet floors enforces every declared floor, at the declared value", "coverage ratchet floors fails the job on a shortfall rather than only reporting it", @@ -688,14 +618,6 @@ "XChainEncoder.createTransaction() - feeQuote injection does not add feeQuote when amount is 0", "XChainEncoder.createTransaction() - feeQuote injection does not add feeQuote when feeQuote has no address" ], - "4498c030666a1756": [ - "input selection targets the floored fee an unfundable spend names the fee the build needs and the fee that was requested", - "input selection targets the floored fee btc caller rate 1 sat/vB: a spend the two inputs fund builds with both", - "input selection targets the floored fee btc explicit fee under the dust floor: a spend the two inputs fund builds with both", - "input selection targets the floored fee doge caller rate at the relay floor: a spend the two inputs fund builds with both", - "input selection targets the floored fee ltc caller rate 1 lit/vB: a spend the two inputs fund builds with both", - "input selection targets the floored fee ltc node estimate of 2 lit/vB: a spend the two inputs fund builds with both" - ], "4526c9e9c70c2999": [ "Custom Output Boundaries custom output value exceeding inputs total custom output > UTXO throws INSUFFICIENT_FUNDS", "Custom Output Boundaries custom output with value=0 value=0 output is rejected as a non-positive custom output", @@ -774,14 +696,6 @@ "two-phase reveal package prefund @regression @tier1 lifts the prefund so a commit paying under the target rate still clears it as a package", "two-phase reveal package prefund @regression @tier1 never prefunds more than the commit inputs hold" ], - "4e34a41823538af8": [ - "BlockchainConnector.getFeePerKilobyte() node RPC error detail adds no suffix for a feerate -1 body or result.errors with no usable string", - "BlockchainConnector.getFeePerKilobyte() node RPC error detail carries a modern node result.errors reason into the thrown message", - "BlockchainConnector.getFeePerKilobyte() node RPC error detail carries the error body of an HTTP 200 answer into the thrown message", - "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", - "BlockchainConnector.getFeePerKilobyte() node RPC error detail still takes the testnet relay fallback for a modern no-estimate body" - ], "4e788c4ec94c6c63": [ "Encoder input validator MAX_FEE_SATOSHIS is pinned at 21,000 BTC in satoshis, not 21M BTC" ], @@ -806,49 +720,16 @@ "encoder FILE payload compression (spec Part B) constants conformance the local values are the pinned ones", "encoder FILE payload compression (spec Part B) constants conformance they equal the canonical declaration (skips without the docs sibling)" ], - "527c9a64750185e0": [ - "multi-chunk payload through a single P2SH fund tx @regression @tier1 carries every chunk as its own leg of one funding transaction", - "multi-chunk payload through a single P2SH fund tx @regression @tier1 funds a distinct script per chunk so each leg can be revealed on its own", - "multi-chunk payload through a single P2SH fund tx @regression @tier1 is deterministic for the same inputs", - "multi-chunk payload through a single P2SH fund tx @regression @tier1 refuses a payload over the compiled size ceiling instead of splitting without bound", - "multi-chunk payload through a single P2SH fund tx @regression @tier1 reveals by spending every funded leg in one transaction and keeping a value output" - ], "52961e7b06b0e39f": [ "BlockchainConnector constructor builds the correct URL from host and port", "BlockchainConnector constructor stores rpcUser and rpcPassword" ], - "542e2ed5169dba02": [ - "XChainEncoder TAPROOT envelope caller public keys must be secp256k1 points refuses an off-curve TAPROOT compressedPubKey at the envelope builder", - "XChainEncoder TAPROOT envelope caller public keys must be secp256k1 points refuses an off-curve cancel internalPubkey in both forms before any reservation or fee RPC", - "XChainEncoder TAPROOT envelope key-path cancel fee-rate cap without a node estimate caps a caller feePerKb on the relayfee anchor when the node estimate is unavailable", - "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", - "XChainEncoder TAPROOT envelope key-path cancel outpoint shares the create_tx outpoint rule accepts a decimal-string vout and an uppercase txid, building canonical forms", - "XChainEncoder TAPROOT envelope key-path cancel outpoint shares the create_tx outpoint rule keys a zero-padded vout as its integer, so it collides with create_tx", - "XChainEncoder TAPROOT envelope key-path cancel outpoint shares the create_tx outpoint rule still rejects every non-integer vout spelling as a typed commitVout error" - ], - "55106b3a8b9dd7c7": [ - "chain identity map parity with xchain-decoder matches every decoder chain key and network value" - ], "56b53ed53845ff32": [ "Chaos Category E: Resource Exhaustion E-1: Large P2WSH encoding (memory stress) 8189-byte payload (compiled 8192, at limit) with P2WSH completes in <5s", "Chaos Category E: Resource Exhaustion E-1: Large P2WSH encoding (memory stress) 8193-byte payload exceeds limit \u2192 RangeError", "Chaos Category E: Resource Exhaustion E-2: High UTXO count processing 500 legacy UTXOs: getTransactionHex called for each consumed", "Chaos Category E: Resource Exhaustion E-2: High UTXO count processing 500 segwit UTXOs, all consumed, completes in <10s" ], - "57394c6893888d79": [ - "capCallerFeeRate: operator cap against the burn backstop MAX_FEE_RATE_KB tighter than 100x: that cap binds", - "capCallerFeeRate: operator cap against the burn backstop a rate under every limit is honored unchanged", - "capCallerFeeRate: operator cap against the burn backstop both caps off: the backstop binds and capFeePerBytes stays null", - "capCallerFeeRate: operator cap against the burn backstop default multiplier 100: clamps to the cap and names the cap, as before", - "capCallerFeeRate: operator cap against the burn backstop loose MAX_FEE_RATE_KB with the multiplier off: the backstop binds", - "capCallerFeeRate: operator cap against the burn backstop multiplier 1000: the backstop binds while capFeePerBytes keeps the operator value", - "capCallerFeeRate: operator cap against the burn backstop no anchor and no cap: nothing to clamp against" - ], "57b96d6f511dbf43": [ "Encoder input validator validateEncoding accepts the valid set, null passes, unknown throws" ], @@ -897,42 +778,6 @@ "61c7893f33172ff0": [ "XChainEncoder.createTransaction() UTXO sorting (largest first) uses the largest UTXO first" ], - "61d96b51460a6591": [ - "single-transaction size estimate covers the header and segwit marker a node estimate under the relay floor is lifted to the floor rather than built under it", - "single-transaction size estimate covers the header and segwit marker btc: 10 P2WPKH inputs at 1 sat/vB pay the rate on the real vsize", - "single-transaction size estimate covers the header and segwit marker btc: 20 P2WPKH inputs at 1 sat/vB pay the rate on the real vsize", - "single-transaction size estimate covers the header and segwit marker ltc: 20 P2WPKH inputs at 5 sat/vB pay the rate on the real vsize" - ], - "6205fd9c842af35b": [ - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid bitcoin-mainnet has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid bitcoin-regtest has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid bitcoin-testnet has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid dogecoin-mainnet has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid dogecoin-regtest has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid dogecoin-testnet has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid litecoin-mainnet has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid litecoin-regtest has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid litecoin-testnet has valid config with positive dustThreshold", - "E2E-6: Multi-Chain Validation E2E-6.1: bitcoin-regtest OP_RETURN creates valid OP_RETURN PSBT with correct dust threshold", - "E2E-6: Multi-Chain Validation E2E-6.2: dogecoin-regtest OP_RETURN creates valid OP_RETURN PSBT with correct dust threshold", - "E2E-6: Multi-Chain Validation E2E-6.3: litecoin-regtest OP_RETURN creates valid OP_RETURN PSBT with correct dust threshold", - "E2E-6: Multi-Chain Validation E2E-6.7: Bitcoin P2WSH creates P2WSH witness output on bitcoin-regtest", - "E2E-6: Multi-Chain Validation E2E-6.8: Bitcoin MULTISIGN (dust=546) multisig output at 546 sats", - "E2E-6: Multi-Chain Validation E2E-6.9: Litecoin MULTISIGN (dust=5460) multisig output at 7860 sats", - "E2E-6: Multi-Chain Validation Fee floor uses chain-specific dust Litecoin fee floor is 5460, not 546", - "E2E-6: Multi-Chain Validation bitcoin-regtest P2SH creates P2SH with output value >= chain dust threshold", - "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" - ], - "62430dae2c4e7b28": [ - "XChainEncoder Dogecoin soft-dust relay surcharge adds nothing for a payment at the soft limit", - "XChainEncoder Dogecoin soft-dust relay surcharge adds the surcharge on top of an explicit fee", - "XChainEncoder Dogecoin soft-dust relay surcharge adds the whole soft limit to the fee for a 100000-koinu payment", - "XChainEncoder Dogecoin soft-dust relay surcharge adds the whole soft limit to the fee for a 500000-koinu payment", - "XChainEncoder Dogecoin soft-dust relay surcharge adds the whole soft limit to the fee for a 999999-koinu payment", - "XChainEncoder Dogecoin soft-dust relay surcharge prefunds the surcharge for a sub-limit payment on a P2SH reveal", - "XChainEncoder Dogecoin soft-dust relay surcharge surcharges a hub feeQuote output under the soft limit" - ], "6248ca3f37b35faa": [ "singleInstanceGuard releaseLockOnSignals defaults to the real process when none is injected", "singleInstanceGuard releaseLockOnSignals deregisters itself before re-raising, or the re-raise re-enters the handler", @@ -989,6 +834,19 @@ "XChainEncoder.createTransaction() custom dust parameter does NOT override the fee floor (fee floor uses this.dustAmount)", "XChainEncoder.createTransaction() custom dust parameter overrides finalDust for MULTISIGN output value when it raises the floor" ], + "68868c37d76d5eb2": [ + "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES a FILE of another version", + "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES a non-FILE action (nowhere to record the marker)", + "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES a payload over the pre-compression input cap", + "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES a token-gated FILE (\u00a75.4: the field means inflate-after-decrypt)", + "encoder FILE payload compression (spec Part B) compressPayloadForAction REFUSES to re-compress when the caller already declared a codec", + "encoder FILE payload compression (spec Part B) compressPayloadForAction a gated FILE passes its own COMPRESSION field through untouched", + "encoder FILE payload compression (spec Part B) compressPayloadForAction compresses a public FILE payload and appends the COMPRESSION field", + "encoder FILE payload compression (spec Part B) compressPayloadForAction emits RAW when the payload compresses beyond the ratio guard", + "encoder FILE payload compression (spec Part B) compressPayloadForAction keeps already-compressed media raw, leaving the action byte-identical", + "encoder FILE payload compression (spec Part B) compressPayloadForAction passes a payload-free action straight through", + "encoder FILE payload compression (spec Part B) compressPayloadForAction the marker and the bytes always move together" + ], "6a7197d4097f3c0c": [ "XChainEncoder.createTransaction(): non-segwit UTXO path calls connector.getTransactionHex for a P2PKH UTXO", "XChainEncoder.createTransaction(): non-segwit UTXO path stores the nonWitnessUtxo buffer from the hex for legacy inputs", @@ -1030,13 +888,6 @@ "REG-04: Validator Functions REG-04.13: validateChange() throws TypeError for non-string", "REG-04: Validator Functions REG-04.13: validateChange() throws TypeError for string > 100 chars" ], - "6ed81952aa49c09d": [ - "XChainEncoder P2SH-P2WPKH funding inputs builds a P2SH address given with no public key as a legacy input", - "XChainEncoder P2SH-P2WPKH funding inputs builds each input with witnessUtxo and the key's P2WPKH redeemScript", - "XChainEncoder P2SH-P2WPKH funding inputs leaves a P2SH input the key does not match as a legacy input", - "XChainEncoder P2SH-P2WPKH funding inputs prices the inputs at their signed size, so the fee lands at the requested rate", - "XChainEncoder P2SH-P2WPKH funding inputs takes the key from compressedPubKey when pubkey is the P2SH address" - ], "6f8680c56c04f0ef": [ "P2SH two-phase package prefund @regression @tier1 a confirmed commit asks the reveal for nothing", "P2SH two-phase package prefund @regression @tier1 degrades to the per-transaction fee when the commit lookup throws", @@ -1048,44 +899,6 @@ "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)" ], - "714506268452508a": [ - "mainnet prelaunch operator override ENCODER_MAINNET_PRELAUNCH_OVERRIDE=\"\" leaves the refusal in force", - "mainnet prelaunch operator override ENCODER_MAINNET_PRELAUNCH_OVERRIDE=\"0\" leaves the refusal in force", - "mainnet prelaunch operator override ENCODER_MAINNET_PRELAUNCH_OVERRIDE=\"false\" leaves the refusal in force", - "mainnet prelaunch operator override ENCODER_MAINNET_PRELAUNCH_OVERRIDE=\"maybe\" leaves the refusal in force", - "mainnet prelaunch operator override ENCODER_MAINNET_PRELAUNCH_OVERRIDE=1 lifts the refusal", - "mainnet prelaunch operator override ENCODER_MAINNET_PRELAUNCH_OVERRIDE=YES lifts the refusal", - "mainnet prelaunch operator override ENCODER_MAINNET_PRELAUNCH_OVERRIDE=on lifts the refusal", - "mainnet prelaunch operator override ENCODER_MAINNET_PRELAUNCH_OVERRIDE=true lifts the refusal", - "mainnet prelaunch operator override is read live: unsetting it restores the refusal on the same controller", - "mainnet prelaunch refusal builds a non-validator action at and after launch", - "mainnet prelaunch refusal never refuses a testnet encoder", - "mainnet prelaunch refusal refuses a non-validator action before launch as -32010 MAINNET_PRELAUNCH", - "mainnet prelaunch refusal refuses an alias of a non-validator action (TRANSFER)", - "mainnet prelaunch user actions sharing or missing the validator set builds a user format of a validator name at launch", - "mainnet prelaunch user actions sharing or missing the validator set refuses ANCHOR|4 before launch", - "mainnet prelaunch user actions sharing or missing the validator set refuses ATTEST|0 before launch", - "mainnet prelaunch user actions sharing or missing the validator set refuses ATTEST|2 before launch", - "mainnet prelaunch user actions sharing or missing the validator set refuses DELEGATE|0 before launch", - "mainnet prelaunch user actions sharing or missing the validator set refuses PRICE|1 before launch", - "mainnet prelaunch user actions sharing or missing the validator set refuses SLASH|0 before launch", - "mainnet prelaunch user actions sharing or missing the validator set refuses STAKE|0 before launch", - "mainnet prelaunch user actions sharing or missing the validator set refuses UNSTAKE|0 before launch", - "mainnet prelaunch validator actions lets ANCHOR|0 through before launch", - "mainnet prelaunch validator actions lets ANCHOR|1 through before launch", - "mainnet prelaunch validator actions lets ANCHOR|2 through before launch", - "mainnet prelaunch validator actions lets ANCHOR|3 through before launch", - "mainnet prelaunch validator actions lets ATTEST|1 through before launch", - "mainnet prelaunch validator actions lets ATTEST|3 through before launch", - "mainnet prelaunch validator actions lets ATTEST|4 through before launch", - "mainnet prelaunch validator actions lets ATTEST|5 through before launch", - "mainnet prelaunch validator actions lets ATTEST|6 through before launch", - "mainnet prelaunch validator actions lets NODEPROOF|0 through before launch", - "mainnet prelaunch validator actions lets PRICE|0 through before launch", - "mainnet prelaunch validator actions lets ROLLCALL|0 through before launch", - "mainnet prelaunch validator actions lets ROLLCALL|1 through before launch", - "mainnet prelaunch validator actions reads the name the way consensus does: trimmed and upper-cased, format optional, Buffer decoded" - ], "7174ac9d6bdce059": [ "real JSON-RPC app HTTP dispatch routes each public handler through src/api.js and the production router" ], @@ -1107,34 +920,6 @@ "envelope-cancel outpoint reservation @regression the first build's ticket still releases after a failed retry", "envelope-cancel outpoint reservation @regression three overlapping builds: an out-of-order failure frees nothing" ], - "73f78b4564a3a7e3": [ - "XChainEncoder multi-chunk P2WSH reveal headroom bitcoin-regtest 1500-byte payload: the reveal returns change at or above the output floor", - "XChainEncoder multi-chunk P2WSH reveal headroom bitcoin-regtest 1500-byte payload: the reveal still pays its target rate on its signed size", - "XChainEncoder multi-chunk P2WSH reveal headroom bitcoin-regtest 600-byte payload: the reveal returns change at or above the output floor", - "XChainEncoder multi-chunk P2WSH reveal headroom bitcoin-regtest 600-byte payload: the reveal still pays its target rate on its signed size", - "XChainEncoder multi-chunk P2WSH reveal headroom litecoin-regtest 1500-byte payload: the reveal returns change at or above the output floor", - "XChainEncoder multi-chunk P2WSH reveal headroom litecoin-regtest 1500-byte payload: the reveal still pays its target rate on its signed size", - "XChainEncoder multi-chunk P2WSH reveal headroom litecoin-regtest 600-byte payload: the reveal returns change at or above the output floor", - "XChainEncoder multi-chunk P2WSH reveal headroom litecoin-regtest 600-byte payload: the reveal still pays its target rate on its signed size" - ], - "7511bcaabf35f784": [ - "caller payment outputs respect the coin relay dust threshold a P2WSH funding call refuses a sub-dust payment before the commit is built", - "caller payment outputs respect the coin relay dust threshold btc: exactly the floor builds", - "caller payment outputs respect the coin relay dust threshold btc: one unit under the floor is refused", - "caller payment outputs respect the coin relay dust threshold btc: the encoder exposes 546 as its payment floor", - "caller payment outputs respect the coin relay dust threshold create_tx answers a sub-dust payment with -32602 before any node call", - "caller payment outputs respect the coin relay dust threshold doge: a 0.005 DOGE payment between the hard and soft limits builds", - "caller payment outputs respect the coin relay dust threshold doge: exactly the floor builds", - "caller payment outputs respect the coin relay dust threshold doge: one unit under the floor is refused", - "caller payment outputs respect the coin relay dust threshold doge: the encoder exposes 100000 as its payment floor", - "caller payment outputs respect the coin relay dust threshold ltc: exactly the floor builds", - "caller payment outputs respect the coin relay dust threshold ltc: one unit under the floor is refused", - "caller payment outputs respect the coin relay dust threshold ltc: the encoder exposes 5460 as its payment floor", - "caller payment outputs respect the coin relay dust threshold the protocol-fee output injected from feeQuote keeps its own positive-amount rule", - "caller payment outputs respect the coin relay dust threshold validateAll at the API boundary accepts an output at the floor", - "caller payment outputs respect the coin relay dust threshold validateAll at the API boundary keeps the positive-amount rule when no floor is passed", - "caller payment outputs respect the coin relay dust threshold validateAll at the API boundary refuses an output under the floor the caller passes" - ], "76410d2234a85853": [ "minimal-op canonicalization guards (empty-data shape flag-day scoped) empty-data shapes remain intentionally supported (flag-day scoped) does NOT reject empty data-only (payment-only / no-ACTION tx)", "minimal-op canonicalization guards (empty-data shape flag-day scoped) empty-data shapes remain intentionally supported (flag-day scoped) does NOT reject the deliberately-supported rawData-only shape", @@ -1177,58 +962,15 @@ "78ea596173e06da0": [ "XChainEncoder.createTransaction() P2SH encoding path (tx2: spending) creates tx2 with P2SH input and OP_RETURN marker" ], - "79c5479ff8297918": [ - "UtxoTracker.getUtxosFromAddress() tracker error text caps a long tracker message at 120 characters", - "UtxoTracker.getUtxosFromAddress() tracker error text cleans or drops a hostile string code and keeps the message", - "UtxoTracker.getUtxosFromAddress() tracker error text drops a tracker message that names an internal endpoint", - "UtxoTracker.getUtxosFromAddress() tracker error text drops an oversized tracker message to the generic text", - "UtxoTracker.getUtxosFromAddress() tracker error text keeps a normal tracker code and message unchanged", - "UtxoTracker.getUtxosFromAddress() tracker error text keeps a numeric JSON-RPC code", - "UtxoTracker.getUtxosFromAddress() tracker error text reads a non-string message as unknown error, not [object Object]", - "UtxoTracker.getUtxosFromAddress() tracker error text replaces control characters in the tracker message" - ], "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" ], - "79f70c9b2a22cdc8": [ - "XChainEncoder bare-multisig carrier dust per coin relay rate bitcoin-regtest: every multisig carrier output is 786", - "XChainEncoder bare-multisig carrier dust per coin relay rate dogecoin-regtest: every multisig carrier output is 1000000", - "XChainEncoder bare-multisig carrier dust per coin relay rate litecoin-regtest: every multisig carrier output is 7860" - ], - "7a10c5e46972a773": [ - "tracker-funded selection at the input cap preserves the range error when additional inputs could fund the transaction", - "tracker-funded selection at the input cap returns a retryable operational error for an all-confirmed selection at the cap", - "tracker-funded selection at the input cap returns the same operational error when unconfirmed outputs are excluded", - "tracker-funded selection at the input cap uses a large unconfirmed output before 1,082 confirmed dust outputs when allowed" - ], "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" ], - "7b0dd058f5e2f3a4": [ - "Security: global in-flight concurrency cap api.js wiring exempts the spec from the API key through the shared predicate, not an exact match", - "Security: global in-flight concurrency cap api.js wiring mounts a bounded reserve for the exempt readiness probes", - "Security: global in-flight concurrency cap api.js wiring mounts the gate on the app with an env-overridable cap", - "Security: global in-flight concurrency cap api.js wiring reports the gate stats so a stampede is visible to operators" - ], - "7d9862c885ce6b83": [ - "broadcast_tx node rejection codes answers a mempool duplicate as TX_ALREADY_IN_CHAIN by message", - "broadcast_tx node rejection codes answers a node rejection as -32010 NODE_REJECTED with the node code", - "broadcast_tx node rejection codes answers an already-known tx as TX_ALREADY_IN_CHAIN by node code", - "broadcast_tx node rejection codes keeps a node error without a numeric code at -32603", - "broadcast_tx node rejection codes keeps a transport fault at -32603 with no data", - "broadcast_tx node rejection codes through the real connector keeps a transport fault at -32603", - "broadcast_tx node rejection codes through the real connector propagates the node code from a node error body to -32010", - "broadcast_tx node rejection codes through the real connector propagates the node code from an HTTP 500 body" - ], - "7e10c19605f33fbd": [ - "a UTXO with no confirmations field counts as unconfirmed leaves the caller UTXO objects as they were handed over", - "a UTXO with no confirmations field counts as unconfirmed unconfirmed=false skips it and spends a confirmed output instead", - "a UTXO with no confirmations field counts as unconfirmed unconfirmed=false with only such outputs reports no spendable UTXOs", - "a UTXO with no confirmations field counts as unconfirmed unconfirmed=true still spends it" - ], "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", @@ -1287,9 +1029,6 @@ "Encoder input validator validateRawTxHex rejects hex above MAX_BROADCAST_TX_HEX_LENGTH", "Encoder input validator validateRawTxHex rejects non-strings, empty, odd-length and non-hex input" ], - "81ecf756db9b39a4": [ - "XChainEncoder leaves the caller's customOutputs array alone builds twice from one array with one fee output each time" - ], "82bd252980196b0b": [ "E2E-5: UTXO, Fee, and Change Integration E2E-5.12: RBF sequence flag rbf=false sets sequence 0xffffffff", "E2E-5: UTXO, Fee, and Change Integration E2E-5.12: RBF sequence flag rbf=true sets sequence 0xfffffffd (RBF armed, BIP68 disabled)" @@ -1299,17 +1038,16 @@ "REG-31: a shortfall caused by reserved inputs names them @regression the failed third build released its own dust claim, leaving only the two live holds", "REG-31: a shortfall caused by reserved inputs names them @regression third build after two un-broadcast builds: dust-only selection, reserved candidates named" ], + "83a0c93d5c03a6e1": [ + "Encoder input validator validateFeePerKb null/false pass; rejects non-finite and non-positive", + "Encoder input validator validateFeePerKb rejects hex, scientific, boolean, array, and object forms Number() would coerce" + ], "83ca670b30f24208": [ "XChainEncoder.createTransaction(): P2WSH tx1 (funding) P2WSH output value covers at least the dust floor", "XChainEncoder.createTransaction(): P2WSH tx1 (funding) creates P2WSH output for small data on a segwit-capable network", "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" ], - "840b30c99abe79b8": [ - "XChainEncoder fee-rate cap sign guard keeps the default multiplier for a negative or NaN MAX_FEE_RATE_MULTIPLIER", - "XChainEncoder fee-rate cap sign guard prices a node-rated build the same under a negative cap as under the defaults", - "XChainEncoder fee-rate cap sign guard treats a zero or negative MAX_FEE_RATE_KB as no absolute cap" - ], "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", @@ -1326,32 +1064,6 @@ "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" ], - "847236b040aee4d9": [ - "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 cap disabled: a plausible feePerKb is honored, not clamped", - "XChainEncoder fee-rate cap cap disabled: the burn backstop anchors a feePerKb-only clamp on relayfee with no estimate", - "XChainEncoder fee-rate cap cap disabled: the burn backstop clamps a feePerKb-only drain to 100x the node rate", - "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 envelope cancel: anchors the relative cap on relayfee when the node has no estimate", - "XChainEncoder fee-rate cap envelope cancel: cap disabled and no estimate, the burn backstop anchors on relayfee", - "XChainEncoder fee-rate cap envelope cancel: cap disabled, a plausible feePerKb is honored", - "XChainEncoder fee-rate cap envelope cancel: cap disabled, the burn backstop keeps a drain-shaped feePerKb off the commit", - "XChainEncoder fee-rate cap envelope cancel: loose multiplier, the burn backstop keeps a drain-shaped feePerKb off the commit", - "XChainEncoder fee-rate cap loose MAX_FEE_RATE_KB alone: the burn backstop clamps a 500x feePerKb", - "XChainEncoder fee-rate cap loose multiplier: a plausible feePerKb is honored, not clamped", - "XChainEncoder fee-rate cap loose multiplier: an explicit 500x fee is still refused, matching the feePerKb clamp", - "XChainEncoder fee-rate cap loose multiplier: the burn backstop clamps a 500x feePerKb to 100x the node rate", - "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" - ], "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", @@ -1400,32 +1112,10 @@ "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" ], - "8c32d904d7276991": [ - "Encoder input validator validateFeePerKb null/false pass; rejects non-finite and non-positive", - "Encoder input validator validateFeePerKb rejects a rate above the money-field ceiling on both the number and string paths", - "Encoder input validator validateFeePerKb rejects hex, scientific, boolean, array, and object forms Number() would coerce" - ], "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" ], - "90700e68ae785acd": [ - "BlockchainConnector.assertNodeTier() chain matching passes a mainnet encoder on a node reporting chain \"main\"", - "BlockchainConnector.assertNodeTier() chain matching passes a regtest encoder on a node reporting chain \"regtest\"", - "BlockchainConnector.assertNodeTier() chain matching passes a testnet encoder on a node reporting chain \"test\"", - "BlockchainConnector.assertNodeTier() chain matching passes a testnet encoder on a node reporting chain \"testnet3\"", - "BlockchainConnector.assertNodeTier() chain matching passes a testnet encoder on a node reporting chain \"testnet4\"", - "BlockchainConnector.assertNodeTier() chain matching refuses a mainnet encoder on a node reporting chain \"regtest\" with NODE_CHAIN_MISMATCH", - "BlockchainConnector.assertNodeTier() chain matching refuses a mainnet encoder on a node reporting chain \"signet\" with NODE_CHAIN_MISMATCH", - "BlockchainConnector.assertNodeTier() chain matching refuses a mainnet encoder on a node reporting chain \"test\" with NODE_CHAIN_MISMATCH", - "BlockchainConnector.assertNodeTier() chain matching refuses a regtest encoder on a node reporting chain \"testnet4\" with NODE_CHAIN_MISMATCH", - "BlockchainConnector.assertNodeTier() chain matching refuses a testnet encoder on a node reporting chain \"main\" with NODE_CHAIN_MISMATCH", - "BlockchainConnector.assertNodeTier() pass-through and caching costs no further RPC once a matching node has passed", - "BlockchainConnector.assertNodeTier() pass-through and caching lets a response with no chain field through, warning once across calls", - "BlockchainConnector.assertNodeTier() pass-through and caching lets a transport error through", - "BlockchainConnector.assertNodeTier() pass-through and caching lets an unrecognized chain string through", - "BlockchainConnector.assertNodeTier() pass-through and caching skips the check when no consensus network was given" - ], "920e3050fd4107ed": [ "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.14: AIRDROP list index reference preserved", "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.15: DIVIDEND two-tick relationship preserved", @@ -1468,6 +1158,11 @@ "REG-18: chained sends reserve caller-supplied inputs @regression the measured shape: a second send re-supplying the spent input is refused, not rebuilt", "REG-18: chained sends reserve caller-supplied inputs @regression three chained sends over a fresh view each produce three DISTINCT transactions" ], + "9856f6ee77509f9c": [ + "Security: global in-flight concurrency cap api.js wiring mounts a bounded reserve for the exempt readiness probes", + "Security: global in-flight concurrency cap api.js wiring mounts the gate on the app with an env-overridable cap", + "Security: global in-flight concurrency cap api.js wiring reports the gate stats so a stampede is visible to operators" + ], "990dee7527465c8e": [ "XChainEncoder reveal: p2shHash must be the id of p2shHex accepts an uppercase p2shHash that names the same funding tx", "XChainEncoder reveal: p2shHash must be the id of p2shHex rejects a P2SH reveal whose p2shHash is not the funding tx id", @@ -1494,22 +1189,6 @@ "openrpc.json method coverage extracts a sane validateAll param list", "openrpc.json method coverage spec methods === controller methods" ], - "99abb4fef6f98fbc": [ - "REG-32: an unmarked TypeError or RangeError from the build is an internal error create_envelope_cancel_tx answers -32603, generic, for a native RangeError", - "REG-32: an unmarked TypeError or RangeError from the build is an internal error create_envelope_cancel_tx answers -32603, generic, for a native TypeError", - "REG-32: an unmarked TypeError or RangeError from the build is an internal error create_tx answers -32603, generic, for a native RangeError", - "REG-32: an unmarked TypeError or RangeError from the build is an internal error create_tx answers -32603, generic, for a native TypeError", - "REG-32: an unmarked TypeError or RangeError from the build is an internal error release_inputs answers -32603, generic, for a native RangeError", - "REG-32: an unmarked TypeError or RangeError from the build is an internal error release_inputs answers -32603, generic, for a native TypeError", - "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 caller fault with -32602 keeps the reason for an explicit compress request on a non-FILE action", - "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" - ], "99f2a27c2274c9bc": [ "TxSizeEstimator .estimateInputSize(utxo) Legacy inputs (nonWitnessUtxo) returns 180 for P2PKH nonWitnessUtxo", "TxSizeEstimator .estimateInputSize(utxo) Legacy inputs (nonWitnessUtxo) returns 289 for P2SH nonWitnessUtxo", @@ -1635,61 +1314,6 @@ "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" ], - "9fbc06b5565c3384": [ - "firstNonLatin1 returns minus one when every character fits in Latin-1", - "firstNonLatin1 returns the index of the first character outside Latin-1", - "parseSatoshiAmount rejects negative and non-integer values", - "parseSatoshiAmount rejects values above the unsigned 64-bit maximum", - "parseSatoshiAmount requires allowBig for a bigint above the safe integer limit", - "parseSatoshiAmount returns an exact bigint for a large decimal string with allowBig", - "parseSatoshiAmount returns numbers for safe integer strings and bigints", - "toExactInt accepts exact integer numbers and decimal strings", - "toExactInt rejects fractional and non-numeric values", - "validateOptionalBoolean passes through omitted values and booleans", - "validateOptionalBoolean rejects a string false with a precise coercion warning" - ], - "a0863ade61747ae3": [ - "Security: validateAll end-to-end rejects a hostile field outright and never returns attacker strings raw", - "Security: validateAll end-to-end rejects a request object that is not an object", - "Security: validateCombinedDataLength (payload-size DoS guard) accepts a payload at exactly the limit", - "Security: validateCombinedDataLength (payload-size DoS guard) counts multi-byte UTF-8 by bytes, not characters (no smuggling past the cap)", - "Security: validateCombinedDataLength (payload-size DoS guard) includes rawData (Latin-1 bytes) in the combined total", - "Security: validateCombinedDataLength (payload-size DoS guard) rejects a payload one byte over the limit", - "Security: validateCustomOutputs / validateFeeQuote rejects a feeQuote with non-positive or over-max amount (via validateAll, feeQuote is internal-only)", - "Security: validateCustomOutputs / validateFeeQuote rejects an over-limit custom-output set", - "Security: validateCustomOutputs / validateFeeQuote rejects custom output with over-length address or negative value", - "Security: validateEncoding accepts each canonical encoding exactly", - "Security: validateEncoding rejects an unknown/garbage encoding (no injection into the switch)", - "Security: validateFee / validateFeePerKb / validateDust rejects a fully non-numeric fee", - "Security: validateFee / validateFeePerKb / validateDust rejects a numeric-prefixed fee string outright (no truncated passthrough)", - "Security: validateFee / validateFeePerKb / validateDust rejects an over-max feePerKb (parity with the fee ceiling)", - "Security: validateFee / validateFeePerKb / validateDust rejects negative and over-max fee", - "Security: validateFee / validateFeePerKb / validateDust rejects negative dust", - "Security: validateFee / validateFeePerKb / validateDust rejects non-finite and non-positive feePerKb", - "Security: validateFee / validateFeePerKb / validateDust rejects over-max dust (parity with the fee ceiling)", - "Security: validateP2shParams (paired-secret integrity) rejects a non-64-hex p2shHash", - "Security: validateP2shParams (paired-secret integrity) rejects supplying only one of p2shHash / p2shHex", - "Security: validateP2shParams (paired-secret integrity) returns nulls when both omitted", - "Security: validatePubkey / validateChange / validateCompressedPubKey passes null through (optional param) without throwing", - "Security: validatePubkey / validateChange / validateCompressedPubKey rejects a compressedPubKey that is not 02/03 + 64 hex", - "Security: validatePubkey / validateChange / validateCompressedPubKey rejects an over-length / injection-shaped change address", - "Security: validatePubkey / validateChange / validateCompressedPubKey rejects an over-length pubkey (shares the validateAddress 100-char bound)", - "Security: validatePubkey / validateChange / validateCompressedPubKey rejects non-string and empty pubkey", - "Security: validateUtxoArray / validateUtxoEntry accepts exactly MAX_UTXO_COUNT (off-by-one upper edge)", - "Security: validateUtxoArray / validateUtxoEntry rejects a malformed (non-hex / odd-length) scriptPubKey", - "Security: validateUtxoArray / validateUtxoEntry rejects a non-array utxos", - "Security: validateUtxoArray / validateUtxoEntry rejects a numeric-prefixed injection value outright (never coerces trailing garbage)", - "Security: validateUtxoArray / validateUtxoEntry rejects a txid that is not 64 hex chars (injection-shaped)", - "Security: validateUtxoArray / validateUtxoEntry rejects an empty/missing scriptPubKey", - "Security: validateUtxoArray / validateUtxoEntry rejects an over-limit UTXO set (DoS via huge input)", - "Security: validateUtxoArray / validateUtxoEntry rejects array/null entries masquerading as objects (prototype confusion)", - "Security: validateUtxoArray / validateUtxoEntry rejects negative / non-integer vout and value", - "Security: validateUtxoArray / validateUtxoEntry rejects non-exact numeric amounts (scientific notation, decimals, hex)", - "Security: validation integrity (regex anchors + set membership) accepts each canonical encoding by exact name and rejects the empty string", - "Security: validation integrity (regex anchors + set membership) anchors the 64-hex txid regex (rejects 64 hex embedded in a longer string)", - "Security: validation integrity (regex anchors + set membership) anchors the compressedPubKey regex (rejects leading and trailing junk)", - "Security: validation integrity (regex anchors + set membership) anchors the p2shHash regex the same way" - ], "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", @@ -1773,6 +1397,27 @@ "paginated utxo fetch is one chain-state snapshot @regression refuses when the reorg counter moves at an unchanged height", "paginated utxo fetch is one chain-state snapshot @regression still merges for a tracker that publishes no per-page sync sibling" ], + "a845505c93fa3021": [ + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid bitcoin-mainnet has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid bitcoin-regtest has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid bitcoin-testnet has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid dogecoin-mainnet has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid dogecoin-regtest has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid dogecoin-testnet has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid litecoin-mainnet has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid litecoin-regtest has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.10: All 9 network configs valid litecoin-testnet has valid config with positive dustThreshold", + "E2E-6: Multi-Chain Validation E2E-6.1: bitcoin-regtest OP_RETURN creates valid OP_RETURN PSBT with correct dust threshold", + "E2E-6: Multi-Chain Validation E2E-6.2: dogecoin-regtest OP_RETURN creates valid OP_RETURN PSBT with correct dust threshold", + "E2E-6: Multi-Chain Validation E2E-6.3: litecoin-regtest OP_RETURN creates valid OP_RETURN PSBT with correct dust threshold", + "E2E-6: Multi-Chain Validation E2E-6.7: Bitcoin P2WSH creates P2WSH witness output on bitcoin-regtest", + "E2E-6: Multi-Chain Validation E2E-6.8: Bitcoin MULTISIGN (dust=546) multisig output at 546 sats", + "E2E-6: Multi-Chain Validation E2E-6.9: Litecoin MULTISIGN (dust=5460) multisig output at 7860 sats", + "E2E-6: Multi-Chain Validation Fee floor uses chain-specific dust Litecoin fee floor is 5460, not 546", + "E2E-6: Multi-Chain Validation bitcoin-regtest P2SH creates P2SH with output value >= chain dust threshold", + "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" + ], "aa6df06a6ffcd257": [ "Security: rate-limit refusal counter/logger limitedHandler logs nothing more for refusals inside the same window", "Security: rate-limit refusal counter/logger limitedHandler logs one line at the first refusal of a window, with count 1", @@ -1782,20 +1427,12 @@ "Security: rate-limit refusal counter/logger src/api.js wiring routes the limiter through limitedHandler and carries no bare message: option", "Security: rate-limit refusal counter/logger wired into a real express-rate-limit instance carries the draft-6 RateLimit-Policy/Limit and Retry-After headers on the 429 this handler sends" ], - "ac90eb5a2fb0989f": [ - "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2SH 100-byte payload: a reveal of a different same-length payload is refused", - "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2SH 100-byte payload: a reveal of the funded payload is built", - "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2SH 1500-byte payload: a reveal of a different same-length payload is refused", - "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2SH 1500-byte payload: a reveal of the funded payload is built", - "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2SH 600-byte payload: a reveal of a different same-length payload is refused", - "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2SH 600-byte payload: a reveal of the funded payload is built", - "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2WSH 100-byte payload: a reveal of a different same-length payload is refused", - "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2WSH 100-byte payload: a reveal of the funded payload is built", - "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2WSH 1500-byte payload: a reveal of a different same-length payload is refused", - "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2WSH 1500-byte payload: a reveal of the funded payload is built", - "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2WSH 600-byte payload: a reveal of a different same-length payload is refused", - "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2WSH 600-byte payload: a reveal of the funded payload is built", - "XChainEncoder chunk reveal refuses scripts that do not match the funded outputs P2WSH: a reveal whose payload needs more chunks than were funded is refused" + "ab75df16ddef33d7": [ + "encoder crash handlers a broken logger cannot swallow the exit", + "encoder crash handlers a non-Error rejection reason still yields a readable record", + "encoder crash handlers an uncaught exception emits one CRASH record and exits non-zero", + "encoder crash handlers an unhandled rejection emits CRASH and lets the process continue", + "encoder crash handlers api.js installs them from the entry-point guard, not at module scope" ], "aec434dabffb9884": [ "errorSanitize.safeUpstreamReason caps length and strips control characters", @@ -1826,16 +1463,6 @@ "UTXO Value Boundaries string-typed UTXO values (parseInt coercion) \"1000000\" string value \u2192 treated as 1000000 sats correctly", "UTXO Value Boundaries unconfirmed filtering exhaustion all UTXOs mempool + unconfirmed=false \u2192 falls to UtxoTracker" ], - "b0f5daa791aa1a25": [ - "encoder crash handlers a broken logger cannot swallow the exit", - "encoder crash handlers a consensus-pin mismatch at module load leaves a CRASH record, not a bare stack dump", - "encoder crash handlers a non-Error rejection reason still yields a readable record", - "encoder crash handlers an uncaught exception emits one CRASH record and exits non-zero", - "encoder crash handlers an unhandled rejection emits CRASH and lets the process continue", - "encoder crash handlers an unknown NETWORK at module load leaves a CRASH record, not a bare stack dump", - "encoder crash handlers api.js installs the handlers before it builds the singleton encoder", - "encoder crash handlers api.js installs them from the entry-point guard, not at module scope" - ], "b2c94f7368f553aa": [ "TxSizeEstimator Boundaries conservative fallback in pipeline missing UTXO data uses 350-byte fallback \u2192 fee includes input estimate", "TxSizeEstimator Boundaries estimateInputSize unknown script type returns 350-byte fallback for unrecognized script", @@ -1860,12 +1487,6 @@ "XChainEncoder.isSegwitUTXO() returns true for P2WPKH (version 0, 20-byte hash)", "XChainEncoder.isSegwitUTXO() returns true for P2WSH (version 0, 32-byte hash)" ], - "b3bb61858e37f56c": [ - "Encoder input validator validateAll unknown top-level params keeps the allow-list equal to the published create_tx params", - "Encoder input validator validateAll unknown top-level params refuses each misspelled money-path setting and names the key", - "Encoder input validator validateAll unknown top-level params reports the unknown key ahead of any other field error", - "Encoder input validator validateAll unknown top-level params still accepts every documented param, null values included" - ], "b3f82aa329a90693": [ "XChainEncoder.createTransaction(): P2WSH tx2 (spending) creates P2WSH input with witnessScript and OP_RETURN marker", "XChainEncoder.createTransaction(): P2WSH tx2 (spending) throws RangeError when p2shHex has no output at the expected voutPsbtIndex", @@ -1880,13 +1501,6 @@ "Encoder input validator validateAll runs the combined-length pre-check on rawData-only requests", "Encoder input validator validateAll throws when params is not an object" ], - "b50f47cceb49ac60": [ - "Validator messages truncate caller strings leaves a short value whole and marks a cut one with its length", - "Validator messages truncate caller strings names the type of a non-string encoding instead of [object Object]", - "Validator messages truncate caller strings truncates an oversized all-digit satoshi amount, with and without allowBig", - "Validator messages truncate caller strings truncates an oversized encoding, fee, options key and boolean string", - "Validator messages truncate caller strings truncates an oversized unknown create_tx param name" - ], "b66602bc925af7a5": [ "Change Address Boundaries changeSatoshis = 0 (exact spend) does not throw even without change address when changeSatoshis = 0", "Change Address Boundaries changeSatoshis = 0 (exact spend) no change output added when change is exactly 0", @@ -1908,35 +1522,9 @@ "BlockchainConnector.sendRawTransaction() maxfeerate retry propagates the retry failure if the maxfeerate=0 retry is also rejected", "BlockchainConnector.sendRawTransaction() maxfeerate retry retries once with maxfeerate=0 on the fee-cap error when chain is regtest" ], - "b7508c7d593792ce": [ - "API-key gate exempts every spec request shape @regression serves the spec without a key for GET, HEAD, a trailing slash and any case", - "API-key gate exempts every spec request shape @regression still answers 401 without a key everywhere else, /status included", - "isSpecRequest matches only the spec route accepts GET and HEAD on the spec path in any case, with or without a trailing slash", - "isSpecRequest matches only the spec route leaves the probe predicate covering /status as before", - "isSpecRequest matches only the spec route refuses /status, other methods and lookalike paths" - ], "b7598b5920b35f20": [ "Encoder input validator validateAddress accepts a valid string (incl. exactly 100 chars); rejects empty, non-string, and over-length" ], - "b8ca9bc2aab3b6ff": [ - "Encoder input validator validatePubkey passes null/valid through, throws on bad input", - "Encoder input validator validatePubkey refuses an uncompressed key by its form, not as an oversized address" - ], - "b906f7728955c7e9": [ - "BlockchainConnector.sendRawTransaction() JSON.stringify fallback in HTTP-500 error body when error.message is absent", - "BlockchainConnector.sendRawTransaction() falls back to JSON.stringify when error has no message", - "BlockchainConnector.sendRawTransaction() node code carries nodeCode from an HTTP 500 error body", - "BlockchainConnector.sendRawTransaction() node code carries the node RPC code as nodeCode on a rejection", - "BlockchainConnector.sendRawTransaction() node code leaves nodeCode unset on a transport fault", - "BlockchainConnector.sendRawTransaction() rethrows transport errors that have no response body", - "BlockchainConnector.sendRawTransaction() returns the txid on success", - "BlockchainConnector.sendRawTransaction() sends sendrawtransaction with the tx hex as param", - "BlockchainConnector.sendRawTransaction() surfaces bitcoind HTTP-500 error body when axios throws", - "BlockchainConnector.sendRawTransaction() throws generic error when result is missing and no error", - "BlockchainConnector.sendRawTransaction() throws when JSON-RPC response contains an error field", - "BlockchainConnector.sendRawTransaction() uses error.message from JSON-RPC error when present", - "BlockchainConnector.sendRawTransaction() uses the message from error.response.data.error when code is -26" - ], "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", @@ -1960,6 +1548,14 @@ "Category B: Encoding Type Integration B-7: Forced encoding override uses P2SH when explicitly requested despite data fitting OP_RETURN", "Category B: Encoding Type Integration B-8: Oversized OP_RETURN rejected rejects forced OP_RETURN when data exceeds a single output" ], + "babd2db450e2e6a1": [ + "ACTION manifest conformance: encoder validateActionName gate @regression ACTION_ALIASES exactly equals the manifest aliases (and the decoder ACTION_ALIASES)", + "ACTION manifest conformance: encoder validateActionName gate @regression VALID_ACTION_NAMES exactly equals the manifest wireDecoded slice (the decoder arbiter set)", + "ACTION manifest conformance: encoder validateActionName gate @regression byte-identity to canonical manifest vendored test/fixtures/action-manifest.json is byte-identical to canonical" + ], + "bae2f2e4bfcbbd48": [ + "Encoder input validator validatePubkey passes null/valid through, throws on bad input" + ], "bb074808e7081443": [ "E2E-7: Complex Parameter & Edge Case Handling E2E-7.10: DISPENSER with all fields all 8 dispenser fields including trailing zeros preserved", "E2E-7: Complex Parameter & Edge Case Handling E2E-7.11: ORDER with zero expiration expiration=0 (no expiry) preserved", @@ -1996,18 +1592,6 @@ "singleInstanceGuard assertSingleInstance throws on non-integer values", "singleInstanceGuard assertSingleInstance throws when ENCODER_REPLICAS > 1" ], - "be87ec7e82b5c79d": [ - "caller pubkey: curve check and caller-input refusals a P2SH build with an off-curve pubkey and caller utxos is refused before any output is built", - "caller pubkey: curve check and caller-input refusals resolveCallerAddress refuses an off-curve key and an unresolvable identity", - "caller pubkey: curve check and caller-input refusals resolveCallerAddress still passes an on-network address through, Taproot included", - "caller pubkey: curve check and caller-input refusals resolveCallerHash160 refuses an off-curve key instead of hashing it into the chunk gate", - "caller pubkey: curve check and caller-input refusals resolveCallerHash160 types an unresolvable identity as caller input", - "caller pubkey: curve check and caller-input refusals uses a fixture key that really is off the curve and passes the shape checks", - "caller pubkey: curve check and caller-input refusals validatePubkey refuses an off-curve compressed key and keeps a real one", - "caller pubkey: the tracker lane answers an unresolvable identity with -32602 a hex string that is no pubkey and no address", - "caller pubkey: the tracker lane answers an unresolvable identity with -32602 a mainnet address on regtest", - "caller pubkey: the tracker lane answers an unresolvable identity with -32602 a regtest address and a compressed pubkey hex still reach the tracker and build" - ], "c08315d45c994876": [ "XChainEncoder.createTransaction() no UTXOs available falls back to utxoTracker when utxos param is null", "XChainEncoder.createTransaction() no UTXOs available resolves a raw pubkey (not an address) to P2WPKH before querying the tracker on a segwit network", @@ -2067,6 +1651,18 @@ "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)" ], + "c5c3639ee7e9a648": [ + "XChainEncoder TAPROOT envelope key-path cancel fee-rate cap without a node estimate caps a caller feePerKb on the relayfee anchor when the node estimate is unavailable", + "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", + "XChainEncoder TAPROOT envelope key-path cancel outpoint shares the create_tx outpoint rule accepts a decimal-string vout and an uppercase txid, building canonical forms", + "XChainEncoder TAPROOT envelope key-path cancel outpoint shares the create_tx outpoint rule keys a zero-padded vout as its integer, so it collides with create_tx", + "XChainEncoder TAPROOT envelope key-path cancel outpoint shares the create_tx outpoint rule still rejects every non-integer vout spelling as a typed commitVout error" + ], "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", @@ -2085,10 +1681,6 @@ "Encoding Chunk Boundaries: Full Pipeline P2WSH real-world boundary (bitcoin-regtest only) 474-char data (compiled=477) \u2192 2 P2WSH outputs (crosses chunk boundary)", "Encoding Chunk Boundaries: Full Pipeline P2WSH real-world boundary (bitcoin-regtest only) small P2WSH data (500 chars) works correctly" ], - "c902501b3d758dbd": [ - "caller-input errors carry the -32602 marker flags a class passed by reference, and ignores comments, strings and instanceof", - "caller-input errors carry the -32602 marker src raises no bare TypeError or RangeError, only the Param classes or a plain Error" - ], "c91ea7fe4f2edf18": [ "E2E-8: Error Handling & Negative Tests E2E-8.10: Null data parameter handles null data gracefully", "E2E-8: Error Handling & Negative Tests E2E-8.11: Negative fee value does not produce negative-value outputs", @@ -2269,6 +1861,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", @@ -2367,46 +1968,6 @@ "Chaos Category D: Arithmetic & State Corruption D-4: UTXO array mutation across calls createTransaction mutates the caller's utxos array in-place", "Chaos Category D: Arithmetic & State Corruption D-4: UTXO array mutation across calls second call with same pre-sorted array still succeeds" ], - "dec04c307d2060b7": [ - "UTXO_TRACKER_PROFILE=remote fails closed UtxoTracker.assertTrackerReady types a transport failure in the remote profile", - "UTXO_TRACKER_PROFILE=remote fails closed classifies an absent sync as SYNC_MISSING only in the remote profile", - "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a empty status tracker with UTXO_TRACKER_SYNC_MISSING", - "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a halted tracker with UTXO_TRACKER_HALTED", - "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a not ready tracker with UTXO_TRACKER_NOT_READY", - "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a stale tracker with UTXO_TRACKER_STALE", - "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a status without lag tracker with UTXO_TRACKER_SYNC_MISSING", - "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a unreachable tracker with UTXO_TRACKER_UNREACHABLE", - "UTXO_TRACKER_PROFILE=remote fails closed keeps the default profile failing open on a missing sync field", - "UTXO_TRACKER_PROFILE=remote fails closed passes a healthy remote tracker", - "UTXO_TRACKER_PROFILE=remote fails closed refuses a get_utxos response that carries no sync field", - "UTXO_TRACKER_PROFILE=remote fails closed refuses construction without a tracker url or port", - "UTXO_TRACKER_PROFILE=remote fails closed resolves the profile and rejects unknown values", - "UTXO_TRACKER_PROFILE=remote fails closed sends the registered lag and heights on a remote STALE for an unasserted synced", - "UTXO_TRACKER_PROFILE=remote fails closed wraps a get_utxos transport failure as UNREACHABLE and leaks no host", - "classifyTrackerFreshness(): the halt reason is tracker-authored bounds a long halt reason and keeps an absent one null", - "classifyTrackerFreshness(): the halt reason is tracker-authored collapses a halt reason carrying an endpoint in both the message and the details", - "classifyTrackerFreshness(): the single tracker-freshness verdict does not mutate the sync object it is handed", - "classifyTrackerFreshness(): the single tracker-freshness verdict fails open on every field it is not explicitly told is bad", - "classifyTrackerFreshness(): the single tracker-freshness verdict names the halt, not the staleness, when a halted tracker is ALSO stale", - "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a halted tracker even at lag 0, and carries the halt reason", - "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a lag above the supplied ceiling and names the ceiling it used", - "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a tracker that de-asserts synced, whatever the lag", - "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a tracker whose mempool has not reconverged", - "classifyTrackerFreshness(): the single tracker-freshness verdict refuses an orphaned view (tracker committed above the node) and says so", - "classifyTrackerFreshness(): the single tracker-freshness verdict reports no refusal, and present:false, for a tracker with no freshness surface", - "classifyTrackerFreshness(): the single tracker-freshness verdict separates the tracker's positive synced claim from the refusal verdict", - "classifyTrackerFreshness(): the single tracker-freshness verdict serves at exactly the ceiling (lag == max is not \"above\")", - "classifyTrackerFreshness(): the single tracker-freshness verdict takes the ceiling from its argument, not from a baked-in constant", - "classifyTrackerFreshness(): the single tracker-freshness verdict treats a non-numeric lag as unknown rather than comparing it", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: halted", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: healthy", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: lag at the ceiling", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: lag one over the ceiling", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: mempool not reconverged", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: orphaned view", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: tracker de-asserts synced", - "create_tx and health() reach the same verdict on the same tracker @regression reports the halt and the mempool state alongside the verdict" - ], "df4be611311f4206": [ "REG-04: Validator Functions REG-04.14: validateAll() integration rejects non-boolean rbf and unconfirmed, passes real booleans through", "REG-04: Validator Functions REG-04.14: validateAll() integration returns sanitized object with all fields for valid input", @@ -2432,39 +1993,6 @@ "BlockchainConnector.getNetworkInfo() throws when result is missing from response", "BlockchainConnector.getNetworkInfo() wraps axios transport errors" ], - "e1b10cc67b1fee13": [ - "BlockchainConnector.getFeePerKilobyte() does NOT fall back to the relayfee floor on mainnet", - "BlockchainConnector.getFeePerKilobyte() falls back to 10x the relayfee floor on testnet when estimatesmartfee has no data", - "BlockchainConnector.getFeePerKilobyte() falls back to the regtest default when relayfee is unavailable", - "BlockchainConnector.getFeePerKilobyte() honours FEE_NO_ESTIMATE_RELAY_MULTIPLIER for the no-estimate fallback", - "BlockchainConnector.getFeePerKilobyte() ignores a non-positive or unparseable multiplier and keeps the default", - "BlockchainConnector.getFeePerKilobyte() no-estimate reported as an RPC error rather than feerate:-1 does NOT invent a rate on mainnet when estimatesmartfee throws", - "BlockchainConnector.getFeePerKilobyte() no-estimate reported as an RPC error rather than feerate:-1 falls back to 10x the relayfee floor on testnet when estimatesmartfee throws", - "BlockchainConnector.getFeePerKilobyte() no-estimate reported as an RPC error rather than feerate:-1 keeps a node-down failure a failure", - "BlockchainConnector.getFeePerKilobyte() no-estimate reported as an RPC error rather than feerate:-1 still rejects on testnet when the node reports no relayfee either", - "BlockchainConnector.getFeePerKilobyte() rethrows transport errors", - "BlockchainConnector.getFeePerKilobyte() returns feerate when present (non-regtest)", - "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate clamps a spiked mainnet estimate to the coin-default ceiling and logs loudly", - "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate honours FEE_ESTIMATE_SANITY_CEILING as an override", - "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate ignores a non-positive or unparseable override and keeps the coin default", - "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate passes through an estimate under the ceiling unchanged", - "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate treats a spiked testnet estimate as unavailable and uses the relayfee fallback", - "BlockchainConnector.getFeePerKilobyte() sends estimatesmartfee with the blocksNumber param (non-regtest)", - "BlockchainConnector.getFeePerKilobyte() still prefers the estimate over the relayfee floor on testnet when one exists", - "BlockchainConnector.getFeePerKilobyte() throws on testnet when neither an estimate nor a relayfee is available", - "BlockchainConnector.getFeePerKilobyte() throws when feerate is missing and chain is not regtest", - "BlockchainConnector.getFeePerKilobyte() throws when result is entirely missing (non-regtest)", - "BlockchainConnector.getFeePerKilobyte() uses the relayfee floor on regtest and never consults estimatesmartfee" - ], - "e29fba7018c99cf3": [ - "XChainEncoder stripped-size floor on single-transaction builds bitcoin-regtest: builds a one-input OP_RETURN-only transaction, which clears 65 bytes", - "XChainEncoder stripped-size floor on single-transaction builds leaves a build that returns change untouched by the floor", - "XChainEncoder stripped-size floor on single-transaction builds litecoin-regtest ADDRESS|0|1|0: refuses an OP_RETURN-only build its one input cannot lift over 82 bytes", - "XChainEncoder stripped-size floor on single-transaction builds litecoin-regtest ADDRESS|0|1|0: takes a second input to lift an OP_RETURN-only build over 82 bytes", - "XChainEncoder stripped-size floor on single-transaction builds litecoin-regtest BROADCAST|0|hi: refuses an OP_RETURN-only build its one input cannot lift over 82 bytes", - "XChainEncoder stripped-size floor on single-transaction builds litecoin-regtest BROADCAST|0|hi: takes a second input to lift an OP_RETURN-only build over 82 bytes", - "XChainEncoder stripped-size floor on single-transaction builds litecoin-regtest: builds a full sweep to one p2wpkh payment, 82 bytes at the 82-byte floor" - ], "e349cec537f9beb2": [ "assertEnvelopeRecognized() FAIL-CLOSES when the tip is unknowable, rather than assuming", "assertEnvelopeRecognized() REFUSES below the activation height, naming the shortfall", @@ -2487,6 +2015,45 @@ "XChainEncoder P2SH reveal headroom scales the leg with the fee rate and with the payload size", "XChainEncoder P2SH reveal headroom the DOGE reveal is no longer a full burn: it leaves a >= dust output to the caller" ], + "e3f640b2cf4c0689": [ + "UTXO_TRACKER_PROFILE=remote fails closed UtxoTracker.assertTrackerReady types a transport failure in the remote profile", + "UTXO_TRACKER_PROFILE=remote fails closed classifies an absent sync as SYNC_MISSING only in the remote profile", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a empty status tracker with UTXO_TRACKER_SYNC_MISSING", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a halted tracker with UTXO_TRACKER_HALTED", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a not ready tracker with UTXO_TRACKER_NOT_READY", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a stale tracker with UTXO_TRACKER_STALE", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a status without lag tracker with UTXO_TRACKER_SYNC_MISSING", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a unreachable tracker with UTXO_TRACKER_UNREACHABLE", + "UTXO_TRACKER_PROFILE=remote fails closed keeps the default profile failing open on a missing sync field", + "UTXO_TRACKER_PROFILE=remote fails closed passes a healthy remote tracker", + "UTXO_TRACKER_PROFILE=remote fails closed refuses a get_utxos response that carries no sync field", + "UTXO_TRACKER_PROFILE=remote fails closed refuses construction without a tracker url or port", + "UTXO_TRACKER_PROFILE=remote fails closed resolves the profile and rejects unknown values", + "UTXO_TRACKER_PROFILE=remote fails closed wraps a get_utxos transport failure as UNREACHABLE and leaks no host", + "classifyTrackerFreshness(): the halt reason is tracker-authored bounds a long halt reason and keeps an absent one null", + "classifyTrackerFreshness(): the halt reason is tracker-authored collapses a halt reason carrying an endpoint in both the message and the details", + "classifyTrackerFreshness(): the single tracker-freshness verdict does not mutate the sync object it is handed", + "classifyTrackerFreshness(): the single tracker-freshness verdict fails open on every field it is not explicitly told is bad", + "classifyTrackerFreshness(): the single tracker-freshness verdict names the halt, not the staleness, when a halted tracker is ALSO stale", + "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a halted tracker even at lag 0, and carries the halt reason", + "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a lag above the supplied ceiling and names the ceiling it used", + "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a tracker that de-asserts synced, whatever the lag", + "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a tracker whose mempool has not reconverged", + "classifyTrackerFreshness(): the single tracker-freshness verdict refuses an orphaned view (tracker committed above the node) and says so", + "classifyTrackerFreshness(): the single tracker-freshness verdict reports no refusal, and present:false, for a tracker with no freshness surface", + "classifyTrackerFreshness(): the single tracker-freshness verdict separates the tracker's positive synced claim from the refusal verdict", + "classifyTrackerFreshness(): the single tracker-freshness verdict serves at exactly the ceiling (lag == max is not \"above\")", + "classifyTrackerFreshness(): the single tracker-freshness verdict takes the ceiling from its argument, not from a baked-in constant", + "classifyTrackerFreshness(): the single tracker-freshness verdict treats a non-numeric lag as unknown rather than comparing it", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: halted", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: healthy", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: lag at the ceiling", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: lag one over the ceiling", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: mempool not reconverged", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: orphaned view", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: tracker de-asserts synced", + "create_tx and health() reach the same verdict on the same tracker @regression reports the halt and the mempool state alongside the verdict" + ], "e4c3435f9bc2b0af": [ "Category F: Multi-Chain Network Configs CryptoNetworks completeness bitcoin-mainnet returns valid config with dustThreshold", "Category F: Multi-Chain Network Configs CryptoNetworks completeness bitcoin-regtest returns valid config with dustThreshold", @@ -2511,6 +2078,21 @@ "Category F: Multi-Chain Network Configs litecoin-regtest: basic PSBT construction creates valid P2SH transaction", "Category F: Multi-Chain Network Configs litecoin-regtest: basic PSBT construction uses correct dustThreshold of 5460" ], + "e65ba2021038db9f": [ + "BlockchainConnector.sendRawTransaction() JSON.stringify fallback in HTTP-500 error body when error.message is absent", + "BlockchainConnector.sendRawTransaction() falls back to JSON.stringify when error has no message", + "BlockchainConnector.sendRawTransaction() rethrows transport errors that have no response body", + "BlockchainConnector.sendRawTransaction() returns the txid on success", + "BlockchainConnector.sendRawTransaction() sends sendrawtransaction with the tx hex as param", + "BlockchainConnector.sendRawTransaction() surfaces bitcoind HTTP-500 error body when axios throws", + "BlockchainConnector.sendRawTransaction() throws generic error when result is missing and no error", + "BlockchainConnector.sendRawTransaction() throws when JSON-RPC response contains an error field", + "BlockchainConnector.sendRawTransaction() uses error.message from JSON-RPC error when present", + "BlockchainConnector.sendRawTransaction() uses the message from error.response.data.error when code is -26" + ], + "e6a6ea744a94dc4a": [ + "Encoder input validator validateCompressedPubKey null passes; enforces the 02/03 + 64-hex shape" + ], "e855d976bf5194b0": [ "Data/Payload Boundaries UTF-8 multi-byte at byte boundary 75-byte UTF-8 string (37 two-byte chars + 1 ASCII) \u2192 OP_RETURN", "Data/Payload Boundaries UTF-8 multi-byte at byte boundary 76-byte UTF-8 string (38 two-byte chars) \u2192 P2SH", @@ -2528,13 +2110,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" ], - "ed4f134b2377cc02": [ - "identity pin file lists exports the complete ordered identity file lists", - "identity pin values builds hashes for every tracked identity file", - "identity pin values compares a freshly built pin equal to itself", - "identity pin values normalizes comparable values in deterministic key order", - "identity pin values reports exactly one changed coin digest" - ], "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", @@ -2597,15 +2172,6 @@ "UtxoTracker constructor builds the correct URL from host and port", "UtxoTracker constructor stores the port" ], - "f557fd05ee550b78": [ - "REG-33: create_envelope_cancel_tx refuses a network-invalid destination a mainnet destination on regtest", - "REG-33: create_tx refuses a network-invalid address as a caller fault a P2SH funding call, where the custom output is only priced and never emitted", - "REG-33: create_tx refuses a network-invalid address as a caller fault a change address with a bad checksum", - "REG-33: create_tx refuses a network-invalid address as a caller fault a mainnet customOutputs address on regtest", - "REG-33: create_tx refuses a network-invalid address as a caller fault a mainnet feeQuote address on regtest", - "REG-33: valid addresses still build a regtest Taproot custom output on a default create_tx", - "REG-33: valid addresses still build assertAddressOnNetwork accepts regtest P2PKH and P2TR and labels a refusal" - ], "f5a56bfe7b90389c": [ "encoder FILE payload compression (spec Part B) the compression report carries the bytes that were written a reveal REBUILT FROM THE REPORT reproduces the commit carrier byte for byte", "encoder FILE payload compression (spec Part B) the compression report carries the bytes that were written reports the written action string and the stored payload", @@ -2621,13 +2187,6 @@ "f7313ee5d4a1e4d5": [ "Fuzz: dataToPubkey() holds the pubkey-shape invariant over 2000 random chunks" ], - "f7bb31daba1bd6bb": [ - "JSON-RPC controller holds only its own methods finds no inherited name in the dispatch table", - "JSON-RPC controller holds only its own methods over HTTP answers -32601 for constructor instead of echoing the params", - "JSON-RPC controller holds only its own methods over HTTP answers -32601 for every inherited name in one batch", - "JSON-RPC controller holds only its own methods over HTTP still dispatches a real method", - "JSON-RPC controller holds only its own methods still carries the real methods as own functions" - ], "fa31a66c65833973": [ "REG-04: Validator Functions REG-04.1: validatePubkey() accepts a non-empty string", "REG-04: Validator Functions REG-04.1: validatePubkey() accepts string exactly 100 chars", @@ -2673,6 +2232,30 @@ "REG-04: Validator Functions REG-04.7: validateDust() throws RangeError for negative value", "REG-04: Validator Functions REG-04.7: validateDust() throws TypeError for non-integer string" ], + "e1b10cc67b1fee13": [ + "BlockchainConnector.getFeePerKilobyte() does NOT fall back to the relayfee floor on mainnet", + "BlockchainConnector.getFeePerKilobyte() falls back to 10x the relayfee floor on testnet when estimatesmartfee has no data", + "BlockchainConnector.getFeePerKilobyte() falls back to the regtest default when relayfee is unavailable", + "BlockchainConnector.getFeePerKilobyte() honours FEE_NO_ESTIMATE_RELAY_MULTIPLIER for the no-estimate fallback", + "BlockchainConnector.getFeePerKilobyte() ignores a non-positive or unparseable multiplier and keeps the default", + "BlockchainConnector.getFeePerKilobyte() no-estimate reported as an RPC error rather than feerate:-1 does NOT invent a rate on mainnet when estimatesmartfee throws", + "BlockchainConnector.getFeePerKilobyte() no-estimate reported as an RPC error rather than feerate:-1 falls back to 10x the relayfee floor on testnet when estimatesmartfee throws", + "BlockchainConnector.getFeePerKilobyte() no-estimate reported as an RPC error rather than feerate:-1 keeps a node-down failure a failure", + "BlockchainConnector.getFeePerKilobyte() no-estimate reported as an RPC error rather than feerate:-1 still rejects on testnet when the node reports no relayfee either", + "BlockchainConnector.getFeePerKilobyte() rethrows transport errors", + "BlockchainConnector.getFeePerKilobyte() returns feerate when present (non-regtest)", + "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate clamps a spiked mainnet estimate to the coin-default ceiling and logs loudly", + "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate honours FEE_ESTIMATE_SANITY_CEILING as an override", + "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate ignores a non-positive or unparseable override and keeps the coin default", + "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate passes through an estimate under the ceiling unchanged", + "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate treats a spiked testnet estimate as unavailable and uses the relayfee fallback", + "BlockchainConnector.getFeePerKilobyte() sends estimatesmartfee with the blocksNumber param (non-regtest)", + "BlockchainConnector.getFeePerKilobyte() still prefers the estimate over the relayfee floor on testnet when one exists", + "BlockchainConnector.getFeePerKilobyte() throws on testnet when neither an estimate nor a relayfee is available", + "BlockchainConnector.getFeePerKilobyte() throws when feerate is missing and chain is not regtest", + "BlockchainConnector.getFeePerKilobyte() throws when result is entirely missing (non-regtest)", + "BlockchainConnector.getFeePerKilobyte() uses the relayfee floor on regtest and never consults estimatesmartfee" + ], "fc5c3c204837ea5e": [ "XChainEncoder.createTransaction() - invalid fee throws RangeError for a NaN fee string", "XChainEncoder.createTransaction() - invalid fee throws RangeError for a negative fee" @@ -2686,59 +2269,84 @@ "UtxoTracker.getSyncStatus() throws when result is a non-object (string)", "UtxoTracker.getSyncStatus() throws when result is missing entirely", "UtxoTracker.getSyncStatus() throws when result is null" + ], + "ff07b944e5210e36": [ + "Security: validateAll end-to-end rejects a hostile field outright and never returns attacker strings raw", + "Security: validateAll end-to-end rejects a request object that is not an object", + "Security: validateCombinedDataLength (payload-size DoS guard) accepts a payload at exactly the limit", + "Security: validateCombinedDataLength (payload-size DoS guard) counts multi-byte UTF-8 by bytes, not characters (no smuggling past the cap)", + "Security: validateCombinedDataLength (payload-size DoS guard) includes rawData (Latin-1 bytes) in the combined total", + "Security: validateCombinedDataLength (payload-size DoS guard) rejects a payload one byte over the limit", + "Security: validateCustomOutputs / validateFeeQuote rejects a feeQuote with non-positive or over-max amount (via validateAll, feeQuote is internal-only)", + "Security: validateCustomOutputs / validateFeeQuote rejects an over-limit custom-output set", + "Security: validateCustomOutputs / validateFeeQuote rejects custom output with over-length address or negative value", + "Security: validateEncoding accepts each canonical encoding exactly", + "Security: validateEncoding rejects an unknown/garbage encoding (no injection into the switch)", + "Security: validateFee / validateFeePerKb / validateDust rejects a fully non-numeric fee", + "Security: validateFee / validateFeePerKb / validateDust rejects a numeric-prefixed fee string outright (no truncated passthrough)", + "Security: validateFee / validateFeePerKb / validateDust rejects negative and over-max fee", + "Security: validateFee / validateFeePerKb / validateDust rejects negative dust", + "Security: validateFee / validateFeePerKb / validateDust rejects non-finite and non-positive feePerKb", + "Security: validateFee / validateFeePerKb / validateDust rejects over-max dust (parity with the fee ceiling)", + "Security: validateP2shParams (paired-secret integrity) rejects a non-64-hex p2shHash", + "Security: validateP2shParams (paired-secret integrity) rejects supplying only one of p2shHash / p2shHex", + "Security: validateP2shParams (paired-secret integrity) returns nulls when both omitted", + "Security: validatePubkey / validateChange / validateCompressedPubKey passes null through (optional param) without throwing", + "Security: validatePubkey / validateChange / validateCompressedPubKey rejects a compressedPubKey that is not 02/03 + 64 hex", + "Security: validatePubkey / validateChange / validateCompressedPubKey rejects an over-length / injection-shaped change address", + "Security: validatePubkey / validateChange / validateCompressedPubKey rejects an over-length pubkey (shares the validateAddress 100-char bound)", + "Security: validatePubkey / validateChange / validateCompressedPubKey rejects non-string and empty pubkey", + "Security: validateUtxoArray / validateUtxoEntry accepts exactly MAX_UTXO_COUNT (off-by-one upper edge)", + "Security: validateUtxoArray / validateUtxoEntry rejects a malformed (non-hex / odd-length) scriptPubKey", + "Security: validateUtxoArray / validateUtxoEntry rejects a non-array utxos", + "Security: validateUtxoArray / validateUtxoEntry rejects a numeric-prefixed injection value outright (never coerces trailing garbage)", + "Security: validateUtxoArray / validateUtxoEntry rejects a txid that is not 64 hex chars (injection-shaped)", + "Security: validateUtxoArray / validateUtxoEntry rejects an empty/missing scriptPubKey", + "Security: validateUtxoArray / validateUtxoEntry rejects an over-limit UTXO set (DoS via huge input)", + "Security: validateUtxoArray / validateUtxoEntry rejects array/null entries masquerading as objects (prototype confusion)", + "Security: validateUtxoArray / validateUtxoEntry rejects negative / non-integer vout and value", + "Security: validateUtxoArray / validateUtxoEntry rejects non-exact numeric amounts (scientific notation, decimals, hex)", + "Security: validation integrity (regex anchors + set membership) accepts each canonical encoding by exact name and rejects the empty string", + "Security: validation integrity (regex anchors + set membership) anchors the 64-hex txid regex (rejects 64 hex embedded in a longer string)", + "Security: validation integrity (regex anchors + set membership) anchors the compressedPubKey regex (rejects leading and trailing junk)", + "Security: validation integrity (regex anchors + set membership) anchors the p2shHash regex the same way" ] }, "scripts": { "test": { - "fileCount": 174, - "titleCount": 1316, + "fileCount": 136, + "titleCount": 1031, "files": { - "test/unit/XChainEncoder/build_transaction/first_input_key.test.js": "04aeb439e25ca4ed", - "test/unit/XChainEncoder/caller_pubkey_resolution.test.js": "be87ec7e82b5c79d", - "test/unit/XChainEncoder/fee_policy.test.js": "57394c6893888d79", - "test/unit/XChainEncoder/fee_rate_cap_sign_guard.test.js": "840b30c99abe79b8", "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", - "test/unit/api/broadcast_tx_node_rejected.test.js": "7d9862c885ce6b83", "test/unit/api/cors_preflight.test.js": "78e833e8f04d5380", "test/unit/api/encoder_stress_sweep.test.js": "358184203c4ccfba", "test/unit/api/get_tx_status.test.js": "87462cdada9ac477", "test/unit/api/health_serve_readiness.test.js": "99f46ad865902c51", - "test/unit/api/inherited_method_names.test.js": "f7bb31daba1bd6bb", "test/unit/api/json_rpc_real_app.test.js": "7174ac9d6bdce059", "test/unit/api/jsonrpc_body_guard.test.js": "58706b2a54fa0008", - "test/unit/api/mainnet_prelaunch_refusal.test.js": "714506268452508a", "test/unit/api/middleware_order.test.js": "cc7754caab01c0b9", - "test/unit/api/no_bare_param_errors.test.js": "c902501b3d758dbd", - "test/unit/api/node_tier_gate.test.js": "3edfd81d1bf84723", "test/unit/api/openrpc_coverage.test.js": "995c9801f2581aab", - "test/unit/api/openrpc_key_exemption.test.js": "b7508c7d593792ce", - "test/unit/api/status_node_tier.test.js": "0c349b0447588e62", - "test/unit/bin_tools/pin_identity.test.js": "ed4f134b2377cc02", - "test/unit/bin_tools/suite_title_additive.test.js": "2f28bf0d76b56ce6", "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": "845d953a12be6745", - "test/unit/blockchain_connector.test/04_send_raw_transaction.test.js": "b906f7728955c7e9", + "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": "e1b10cc67b1fee13", "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": "4e34a41823538af8", + "test/unit/blockchain_connector.test/08_fee_estimate_rpc_error_detail.test.js": "2842761e884c6f3b", "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/blockchain_connector.test/11_rpc_timeout_env.test.js": "03e0966ef3abab8e", - "test/unit/blockchain_connector.test/12_node_tier.test.js": "90700e68ae785acd", "test/unit/blockchain_connector.test/13_test_chain_implausible_estimate.test.js": "4d2fee8572bf0a2f", "test/unit/build/apply_bufferutils_patch.test.js": "3eb6eaeabe786d34", - "test/unit/build/compression.test.js": "41e23c0843778aa7", + "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", "test/unit/build/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js": "f5a56bfe7b90389c", "test/unit/build/compression.test/04_validator.test.js": "dfdfc61603d906bd", "test/unit/build/compression.test/05_constants_conformance.test.js": "51af3196bafc7da3", "test/unit/build/crypto_networks.test.js": "3f015c065bb424cc", - "test/unit/build/multi_chunk_single_fund_tx.test.js": "527c9a64750185e0", "test/unit/build/package_fee_sizing.test.js": "0c5deea0cdab64b5", "test/unit/build/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js": "051aa0f149569c03", "test/unit/build/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js": "8b96a6f9f68661f9", @@ -2749,17 +2357,13 @@ "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": "a54b04e2034f9891", - "test/unit/build/utxo_tracker.test/04_tracker_error_text_sanitized.test.js": "79c5479ff8297918", "test/unit/build/utxo_tracker_get_tx_block.test.js": "c67fd5ac2d0b5ff4", - "test/unit/chain_identity_genesis.test.js": "360eb4295df65345", - "test/unit/chain_identity_parity.test.js": "55106b3a8b9dd7c7", "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", - "test/unit/common/validator/action_manifest_conformance.test.js": "128129d7e17e2e21", + "test/unit/common/validator/action_manifest_conformance.test.js": "babd2db450e2e6a1", "test/unit/common/validator/large_satoshi_amounts.test.js": "c512e0cbe2bc163a", - "test/unit/common/validator/message_truncation.test.js": "b50f47cceb49ac60", - "test/unit/common/validator/validator.test.js": "b8ca9bc2aab3b6ff", + "test/unit/common/validator/validator.test.js": "bae2f2e4bfcbbd48", "test/unit/common/validator/validator.test/01_validate_data_param.test.js": "4ccb179cd2d34bce", "test/unit/common/validator/validator.test/02_validate_action_name_encoder_action_name_gate.test.js": "048d6d9734e4b8ea", "test/unit/common/validator/validator.test/03_validate_combined_data_length.test.js": "da6106a9ba02aab9", @@ -2767,19 +2371,17 @@ "test/unit/common/validator/validator.test/05_validate_encoding.test.js": "57b96d6f511dbf43", "test/unit/common/validator/validator.test/06_max_fee_satoshis.test.js": "4e788c4ec94c6c63", "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": "8c32d904d7276991", + "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": "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", - "test/unit/common/validator/validator.test/14_validate_compressed_pub_key.test.js": "2089fa0000e3be71", + "test/unit/common/validator/validator.test/14_validate_compressed_pub_key.test.js": "e6a6ea744a94dc4a", "test/unit/common/validator/validator.test/15_validate_change.test.js": "a58c0275055cc988", "test/unit/common/validator/validator.test/16_validate_address.test.js": "b7598b5920b35f20", "test/unit/common/validator/validator.test/17_validate_all.test.js": "b4c7bf0a08619cca", - "test/unit/common/validator/validator.test/18_validate_all_unknown_params.test.js": "b3bb61858e37f56c", - "test/unit/common/validator/value_checks.test.js": "9fbc06b5565c3384", - "test/unit/crash_handlers.test.js": "b0f5daa791aa1a25", + "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", @@ -2793,26 +2395,12 @@ "test/unit/server/single_instance_guard.test/01_acquire_instance_lock.test.js": "807a63a77985ea07", "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/build_correctness.test/01_relay_floor_size_estimate.test.js": "61d96b51460a6591", - "test/unit/xchain_encoder/build_correctness.test/02_fee_floor_selection.test.js": "4498c030666a1756", - "test/unit/xchain_encoder/build_correctness.test/03_deterministic_order.test.js": "09319c2cb6e64dd7", - "test/unit/xchain_encoder/build_correctness.test/04_p2wsh_reveal_pad_pricing.test.js": "1d2df4e0c840c80e", - "test/unit/xchain_encoder/build_correctness.test/05_payment_dust_floor.test.js": "7511bcaabf35f784", - "test/unit/xchain_encoder/build_correctness.test/06_missing_confirmations.test.js": "7e10c19605f33fbd", "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/psbt_conformance/bare_multisig_dust_relay_rate.test.js": "79f70c9b2a22cdc8", - "test/unit/xchain_encoder/psbt_conformance/custom_outputs_not_mutated.test.js": "81ecf756db9b39a4", - "test/unit/xchain_encoder/psbt_conformance/doge_soft_dust_surcharge.test.js": "62430dae2c4e7b28", - "test/unit/xchain_encoder/psbt_conformance/nested_segwit_input.test.js": "6ed81952aa49c09d", - "test/unit/xchain_encoder/psbt_conformance/p2wsh_reveal_headroom.test.js": "73f78b4564a3a7e3", - "test/unit/xchain_encoder/psbt_conformance/reveal_funding_pairing.test.js": "ac90eb5a2fb0989f", - "test/unit/xchain_encoder/psbt_conformance/stripped_size_floor.test.js": "e29fba7018c99cf3", "test/unit/xchain_encoder/suggested_fee_ceiling.test.js": "a76fc6f4a4157148", - "test/unit/xchain_encoder/trackerless_profile.test.js": "dec04c307d2060b7", "test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js": "0748973400fcace3", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js": "23e242d978109d26", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js": "bb903d583344a390", @@ -2835,7 +2423,6 @@ "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/17_payment_only.test.js": "214613c17899118f", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/18_payload_guards.test.js": "1e9678e996275889", "test/unit/xchain_encoder/xchain_encoder_data_to_pubkey.test.js": "ccc87ed9c55081d5", - "test/unit/xchain_encoder/xchain_encoder_dust_cap.test/01_unconfirmed_at_cap.test.js": "7a10c5e46972a773", "test/unit/xchain_encoder/xchain_encoder_exact_inputs.test.js": "0402af4b590d4603", "test/unit/xchain_encoder/xchain_encoder_extra.test.js": "6a7197d4097f3c0c", "test/unit/xchain_encoder/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js": "83ca670b30f24208", @@ -2848,7 +2435,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": "847236b040aee4d9", + "test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js": "36a860097c5c6a21", "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", @@ -2861,11 +2448,11 @@ "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": "542e2ed5169dba02", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js": "c5c3639ee7e9a648", "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_taproot_envelope.test/published_vectors.test.js": "331f46bf5c424942", + "test/unit/xchain_encoder/trackerless_profile.test.js": "e3f640b2cf4c0689", "test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js": "2a1c91a7aceda8eb" } }, @@ -2916,7 +2503,7 @@ "test/e2e/edge_cases_e2e.test.js": "bb074808e7081443", "test/e2e/encoding_boundaries_e2e.test.js": "efc2d14c81fd5e58", "test/e2e/error_rejection_e2e.test.js": "c91ea7fe4f2edf18", - "test/e2e/multi_chain_e2e.test.js": "6205fd9c842af35b", + "test/e2e/multi_chain_e2e.test.js": "a845505c93fa3021", "test/e2e/obfuscation_integrity_e2e.test.js": "883e1aa41a43b1c9", "test/e2e/p2sh_p2wsh_sequence_e2e.test.js": "0d8834d5fc73c75b", "test/e2e/round_trip_e2e.test.js": "c29f3481850f5d9f", @@ -2964,8 +2551,8 @@ } }, "test:regression": { - "fileCount": 29, - "titleCount": 328, + "fileCount": 28, + "titleCount": 314, "files": { "test/regression/reg_01_encoding_types.test.js": "c438a1275387f62b", "test/regression/reg_02_obfuscation.test.js": "6c8feaefe1116923", @@ -2990,8 +2577,7 @@ "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_32_create_tx_error_code_split.test.js": "99abb4fef6f98fbc", - "test/regression/reg_33_network_invalid_address.test.js": "f557fd05ee550b78", + "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", @@ -3003,68 +2589,52 @@ }, "test:security": { "fileCount": 8, - "titleCount": 73, + "titleCount": 71, "files": { "test/security/concurrency_gate.test.js": "5c149d2d20812d9a", "test/security/concurrency_gate.test/01_resolve_limit.test.js": "5140a8874ea32fb0", - "test/security/concurrency_gate.test/02_api_js_wiring.test.js": "7b0dd058f5e2f3a4", + "test/security/concurrency_gate.test/02_api_js_wiring.test.js": "9856f6ee77509f9c", "test/security/concurrency_gate.test/03_probe_variants.test.js": "9a05dc7d05139e84", - "test/security/input_validation.test.js": "a0863ade61747ae3", + "test/security/input_validation.test.js": "ff07b944e5210e36", "test/security/obfuscation_key.test.js": "7fbd6025650718a6", "test/security/payload_size.test.js": "f1872a85a8671d97", "test/security/rate_limit_log.test.js": "aa6df06a6ffcd257" } }, "test:unit": { - "fileCount": 174, - "titleCount": 1316, + "fileCount": 136, + "titleCount": 1031, "files": { - "test/unit/XChainEncoder/build_transaction/first_input_key.test.js": "04aeb439e25ca4ed", - "test/unit/XChainEncoder/caller_pubkey_resolution.test.js": "be87ec7e82b5c79d", - "test/unit/XChainEncoder/fee_policy.test.js": "57394c6893888d79", - "test/unit/XChainEncoder/fee_rate_cap_sign_guard.test.js": "840b30c99abe79b8", "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", - "test/unit/api/broadcast_tx_node_rejected.test.js": "7d9862c885ce6b83", "test/unit/api/cors_preflight.test.js": "78e833e8f04d5380", "test/unit/api/encoder_stress_sweep.test.js": "358184203c4ccfba", "test/unit/api/get_tx_status.test.js": "87462cdada9ac477", "test/unit/api/health_serve_readiness.test.js": "99f46ad865902c51", - "test/unit/api/inherited_method_names.test.js": "f7bb31daba1bd6bb", "test/unit/api/json_rpc_real_app.test.js": "7174ac9d6bdce059", "test/unit/api/jsonrpc_body_guard.test.js": "58706b2a54fa0008", - "test/unit/api/mainnet_prelaunch_refusal.test.js": "714506268452508a", "test/unit/api/middleware_order.test.js": "cc7754caab01c0b9", - "test/unit/api/no_bare_param_errors.test.js": "c902501b3d758dbd", - "test/unit/api/node_tier_gate.test.js": "3edfd81d1bf84723", "test/unit/api/openrpc_coverage.test.js": "995c9801f2581aab", - "test/unit/api/openrpc_key_exemption.test.js": "b7508c7d593792ce", - "test/unit/api/status_node_tier.test.js": "0c349b0447588e62", - "test/unit/bin_tools/pin_identity.test.js": "ed4f134b2377cc02", - "test/unit/bin_tools/suite_title_additive.test.js": "2f28bf0d76b56ce6", "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": "845d953a12be6745", - "test/unit/blockchain_connector.test/04_send_raw_transaction.test.js": "b906f7728955c7e9", + "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": "e1b10cc67b1fee13", "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": "4e34a41823538af8", + "test/unit/blockchain_connector.test/08_fee_estimate_rpc_error_detail.test.js": "2842761e884c6f3b", "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/blockchain_connector.test/11_rpc_timeout_env.test.js": "03e0966ef3abab8e", - "test/unit/blockchain_connector.test/12_node_tier.test.js": "90700e68ae785acd", "test/unit/blockchain_connector.test/13_test_chain_implausible_estimate.test.js": "4d2fee8572bf0a2f", "test/unit/build/apply_bufferutils_patch.test.js": "3eb6eaeabe786d34", - "test/unit/build/compression.test.js": "41e23c0843778aa7", + "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", "test/unit/build/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js": "f5a56bfe7b90389c", "test/unit/build/compression.test/04_validator.test.js": "dfdfc61603d906bd", "test/unit/build/compression.test/05_constants_conformance.test.js": "51af3196bafc7da3", "test/unit/build/crypto_networks.test.js": "3f015c065bb424cc", - "test/unit/build/multi_chunk_single_fund_tx.test.js": "527c9a64750185e0", "test/unit/build/package_fee_sizing.test.js": "0c5deea0cdab64b5", "test/unit/build/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js": "051aa0f149569c03", "test/unit/build/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js": "8b96a6f9f68661f9", @@ -3075,17 +2645,13 @@ "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": "a54b04e2034f9891", - "test/unit/build/utxo_tracker.test/04_tracker_error_text_sanitized.test.js": "79c5479ff8297918", "test/unit/build/utxo_tracker_get_tx_block.test.js": "c67fd5ac2d0b5ff4", - "test/unit/chain_identity_genesis.test.js": "360eb4295df65345", - "test/unit/chain_identity_parity.test.js": "55106b3a8b9dd7c7", "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", - "test/unit/common/validator/action_manifest_conformance.test.js": "128129d7e17e2e21", + "test/unit/common/validator/action_manifest_conformance.test.js": "babd2db450e2e6a1", "test/unit/common/validator/large_satoshi_amounts.test.js": "c512e0cbe2bc163a", - "test/unit/common/validator/message_truncation.test.js": "b50f47cceb49ac60", - "test/unit/common/validator/validator.test.js": "b8ca9bc2aab3b6ff", + "test/unit/common/validator/validator.test.js": "bae2f2e4bfcbbd48", "test/unit/common/validator/validator.test/01_validate_data_param.test.js": "4ccb179cd2d34bce", "test/unit/common/validator/validator.test/02_validate_action_name_encoder_action_name_gate.test.js": "048d6d9734e4b8ea", "test/unit/common/validator/validator.test/03_validate_combined_data_length.test.js": "da6106a9ba02aab9", @@ -3093,19 +2659,17 @@ "test/unit/common/validator/validator.test/05_validate_encoding.test.js": "57b96d6f511dbf43", "test/unit/common/validator/validator.test/06_max_fee_satoshis.test.js": "4e788c4ec94c6c63", "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": "8c32d904d7276991", + "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": "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", - "test/unit/common/validator/validator.test/14_validate_compressed_pub_key.test.js": "2089fa0000e3be71", + "test/unit/common/validator/validator.test/14_validate_compressed_pub_key.test.js": "e6a6ea744a94dc4a", "test/unit/common/validator/validator.test/15_validate_change.test.js": "a58c0275055cc988", "test/unit/common/validator/validator.test/16_validate_address.test.js": "b7598b5920b35f20", "test/unit/common/validator/validator.test/17_validate_all.test.js": "b4c7bf0a08619cca", - "test/unit/common/validator/validator.test/18_validate_all_unknown_params.test.js": "b3bb61858e37f56c", - "test/unit/common/validator/value_checks.test.js": "9fbc06b5565c3384", - "test/unit/crash_handlers.test.js": "b0f5daa791aa1a25", + "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", @@ -3119,26 +2683,12 @@ "test/unit/server/single_instance_guard.test/01_acquire_instance_lock.test.js": "807a63a77985ea07", "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/build_correctness.test/01_relay_floor_size_estimate.test.js": "61d96b51460a6591", - "test/unit/xchain_encoder/build_correctness.test/02_fee_floor_selection.test.js": "4498c030666a1756", - "test/unit/xchain_encoder/build_correctness.test/03_deterministic_order.test.js": "09319c2cb6e64dd7", - "test/unit/xchain_encoder/build_correctness.test/04_p2wsh_reveal_pad_pricing.test.js": "1d2df4e0c840c80e", - "test/unit/xchain_encoder/build_correctness.test/05_payment_dust_floor.test.js": "7511bcaabf35f784", - "test/unit/xchain_encoder/build_correctness.test/06_missing_confirmations.test.js": "7e10c19605f33fbd", "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/psbt_conformance/bare_multisig_dust_relay_rate.test.js": "79f70c9b2a22cdc8", - "test/unit/xchain_encoder/psbt_conformance/custom_outputs_not_mutated.test.js": "81ecf756db9b39a4", - "test/unit/xchain_encoder/psbt_conformance/doge_soft_dust_surcharge.test.js": "62430dae2c4e7b28", - "test/unit/xchain_encoder/psbt_conformance/nested_segwit_input.test.js": "6ed81952aa49c09d", - "test/unit/xchain_encoder/psbt_conformance/p2wsh_reveal_headroom.test.js": "73f78b4564a3a7e3", - "test/unit/xchain_encoder/psbt_conformance/reveal_funding_pairing.test.js": "ac90eb5a2fb0989f", - "test/unit/xchain_encoder/psbt_conformance/stripped_size_floor.test.js": "e29fba7018c99cf3", "test/unit/xchain_encoder/suggested_fee_ceiling.test.js": "a76fc6f4a4157148", - "test/unit/xchain_encoder/trackerless_profile.test.js": "dec04c307d2060b7", "test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js": "0748973400fcace3", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js": "23e242d978109d26", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js": "bb903d583344a390", @@ -3161,7 +2711,6 @@ "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/17_payment_only.test.js": "214613c17899118f", "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/18_payload_guards.test.js": "1e9678e996275889", "test/unit/xchain_encoder/xchain_encoder_data_to_pubkey.test.js": "ccc87ed9c55081d5", - "test/unit/xchain_encoder/xchain_encoder_dust_cap.test/01_unconfirmed_at_cap.test.js": "7a10c5e46972a773", "test/unit/xchain_encoder/xchain_encoder_exact_inputs.test.js": "0402af4b590d4603", "test/unit/xchain_encoder/xchain_encoder_extra.test.js": "6a7197d4097f3c0c", "test/unit/xchain_encoder/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js": "83ca670b30f24208", @@ -3174,7 +2723,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": "847236b040aee4d9", + "test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js": "36a860097c5c6a21", "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", @@ -3187,11 +2736,11 @@ "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": "542e2ed5169dba02", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js": "c5c3639ee7e9a648", "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_taproot_envelope.test/published_vectors.test.js": "331f46bf5c424942", + "test/unit/xchain_encoder/trackerless_profile.test.js": "e3f640b2cf4c0689", "test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js": "2a1c91a7aceda8eb" } } From 83020a0a0e64d5bd75ebc0fd570b5477ec707efa Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 09:00:30 -0700 Subject: [PATCH 22/22] Release v0.24.0 --- CHANGELOG.md | 17 +++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16dd4b9c..a522ee38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.24.0] - 2026-10-17 + +### Added +- Added a transaction status lookup method and exposed a node tier mismatch in status readiness. + +### Changed +- Priced transactions at their full size, selected inputs against the final fee and refused sub-dust payments. +- Funded multi-chunk P2WSH reveals with first-leg headroom and a surplus sweep. +- Refused non-validator transaction creation on mainnet before launch. + +### Fixed +- Sized bare-multisig dust from each coin's relay rate and refused a reveal that does not spend its funded scripts. +- Kept fee tiers ordered by confirmation speed and used the relay fallback for implausible test-chain fees. + +### Security +- Bumped axios, fast-uri, ip-address, proxy-addr and brace-expansion to resolve security advisories. + ## [0.23.0] - 2026-10-11 ### Added diff --git a/package-lock.json b/package-lock.json index 62232c45..dd3902ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-encoder", - "version": "0.23.0", + "version": "0.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-encoder", - "version": "0.23.0", + "version": "0.24.0", "license": "AGPL-3.0-or-later", "dependencies": { "axios": "^1.20.0", diff --git a/package.json b/package.json index c557ad6e..21eebbdd 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.23.0", + "version": "0.24.0", "license": "AGPL-3.0-or-later", "repository": { "type": "git",